Cloud-Account-Kontingent: App liest cloud_remaining, sobald der Server es liefert
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8611738124
commit
d03a4e93a0
@@ -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"
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
|
||||
@@ -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<String, String> 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<void> abmelden() async {
|
||||
_token = null;
|
||||
_benutzer = '';
|
||||
|
||||
@@ -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“ …');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user