From d03a4e93a05bc6fd7f2deac61be29d12005e2e05 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 13:22:04 +0200 Subject: [PATCH] Cloud-Account-Kontingent: App liest cloud_remaining, sobald der Server es liefert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BakaAuth bekommt verbleibend/merkeVerbleibend analog zu GastZugang. YtDownloadService liest cloud_remaining bei Cloud-Zugriff (nicht bei Gast-Zugriff — Felder bleiben getrennt). UI zeigt "Noch N von 100 heute" für angemeldete Cloud-Accounts. Reine App-Seite, wirkungslos bis der Server (separates Vorhaben) cloud_remaining liefert. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP --- CHANGELOG.md | 36 ++++++++++ lib/downloads/downloads_screen.dart | 15 ++++ lib/downloads/youtube_search_screen.dart | 16 +++++ lib/services/baka_auth.dart | 12 ++++ lib/services/yt_download_service.dart | 4 ++ test/downloads/online_screen_test.dart | 15 ++++ .../downloads/youtube_search_screen_test.dart | 20 ++++++ test/services/baka_auth_test.dart | 14 ++++ test/services/yt_download_test.dart | 72 +++++++++++++++++++ 9 files changed, 204 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce34284..234f36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [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-Modus war eine Einbahnstraße.** Einmal "Als Gast fortfahren" diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index bc7063e..68cfbf9 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -831,6 +831,21 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { ), 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) ...[ Row( children: [ diff --git a/lib/downloads/youtube_search_screen.dart b/lib/downloads/youtube_search_screen.dart index f3191c5..f8638a5 100644 --- a/lib/downloads/youtube_search_screen.dart +++ b/lib/downloads/youtube_search_screen.dart @@ -159,6 +159,22 @@ class _YoutubeSearchScreenState extends State { ), ], ], + 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) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), diff --git a/lib/services/baka_auth.dart b/lib/services/baka_auth.dart index ecfa178..1d47cdf 100644 --- a/lib/services/baka_auth.dart +++ b/lib/services/baka_auth.dart @@ -47,10 +47,16 @@ class BakaAuth extends ChangeNotifier { String? _token; String _benutzer = ''; + int? _verbleibend; bool get istAngemeldet => _token != null && _token!.isNotEmpty; 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`. Map get authHeader => { if (istAngemeldet) 'Authorization': 'Bearer $_token', @@ -111,6 +117,12 @@ class BakaAuth extends ChangeNotifier { await anmelden(benutzer, passwort); } + /// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand. + void merkeVerbleibend(int n) { + _verbleibend = n; + notifyListeners(); + } + Future abmelden() async { _token = null; _benutzer = ''; diff --git a/lib/services/yt_download_service.dart b/lib/services/yt_download_service.dart index ce33304..cf2e2cb 100644 --- a/lib/services/yt_download_service.dart +++ b/lib/services/yt_download_service.dart @@ -160,6 +160,10 @@ class YtDownloadService extends ChangeNotifier { if (gastAktiv && gastRest != null) { gast!.merkeVerbleibend(gastRest); } + final cloudRest = daten['cloud_remaining'] as int?; + if (!gastAktiv && cloudRest != null) { + auth.merkeVerbleibend(cloudRest); + } // ─── 2/2: fertige MP3 abholen und speichern ─── _melde('Lade „$titel“ …'); diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart index 8f68dd1..3f6b988 100644 --- a/test/downloads/online_screen_test.dart +++ b/test/downloads/online_screen_test.dart @@ -253,6 +253,21 @@ void main() { 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( 'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten ' 'beim Baka-Konto an', (tester) async { diff --git a/test/downloads/youtube_search_screen_test.dart b/test/downloads/youtube_search_screen_test.dart index 12a1ece..3b93f26 100644 --- a/test/downloads/youtube_search_screen_test.dart +++ b/test/downloads/youtube_search_screen_test.dart @@ -176,6 +176,26 @@ void main() { 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. // herunterladen() kombiniert Future.any mit .timeout() — dessen Timer // bleibt unter Flutters fake_async-Testbindung immer als "pending" stehen diff --git a/test/services/baka_auth_test.dart b/test/services/baka_auth_test.dart index 6be2650..b992265 100644 --- a/test/services/baka_auth_test.dart +++ b/test/services/baka_auth_test.dart @@ -129,6 +129,20 @@ void main() { 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 { final speicher = _MemorySpeicher(); final auth = BakaAuth( diff --git a/test/services/yt_download_test.dart b/test/services/yt_download_test.dart index 002894e..97c4798 100644 --- a/test/services/yt_download_test.dart +++ b/test/services/yt_download_test.dart @@ -380,4 +380,76 @@ void main() { 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); + }); + }); }