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]
|
## [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"
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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“ …');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user