From 2e14256dedbdbef48086d1d8b80a232487f6d110 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Wed, 26 Aug 2026 20:33:22 +0200 Subject: [PATCH] =?UTF-8?q?Neuer=20GastZugang-Service=20f=C3=BCr=20den=20a?= =?UTF-8?q?nonymen=20YouTube-Gast-Zugang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_$(uuidgen | cut -d- -f1) --- lib/services/gast_zugang.dart | 82 +++++++++++++++++++ test/services/gast_zugang_test.dart | 120 ++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 lib/services/gast_zugang.dart create mode 100644 test/services/gast_zugang_test.dart diff --git a/lib/services/gast_zugang.dart b/lib/services/gast_zugang.dart new file mode 100644 index 0000000..4df938a --- /dev/null +++ b/lib/services/gast_zugang.dart @@ -0,0 +1,82 @@ +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 get gastHeader => + {if (hatToken) 'X-Guest-Token': _token!}; + + /// Stellt einen früher geholten Gast-Token wieder her (App-Start). + Future 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 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 daten; + try { + daten = jsonDecode(antwort.body) as Map; + } 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(); + } +} diff --git a/test/services/gast_zugang_test.dart b/test/services/gast_zugang_test.dart new file mode 100644 index 0000000..cb54fa5 --- /dev/null +++ b/test/services/gast_zugang_test.dart @@ -0,0 +1,120 @@ +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 werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future 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); + }); +}