Files
Melo/docs/superpowers/plans/2026-08-26-youtube-gast-zugang.md
Hermes (Server)andClaude Sonnet 5 9f08286e50 Implementierungsplan: YouTube-Gast-Zugang (App-Seite)
7 Tasks nach TDD: GastZugang-Service, Gast-Unterstützung in
YtDownloadService/YtSearchService, Verdrahtung in main.dart, UI in
Download-Tab und YT-Suche-Tab (bewusste Wahl Anmelden/Als-Gast-
fortfahren + Tages-Zähler), Abschluss-Verifikation. Umsetzung auf
neuem Branch feature/youtube-gast-zugang (Task 1, Step 0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 20:28:41 +02:00

58 KiB

YouTube-Gast-Zugang (App-Seite) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Gäste ohne Cloud-Account können den YouTube-Downloader/die YT-Suche über einen anonymen, geräte-gebundenen Gast-Token nutzen — mit einem serverseitig durchgesetzten Tages-Limit (5 Downloads), ohne Cookies, nach bewusster Wahl in der UI.

Architecture: Ein neuer ChangeNotifier-Service GastZugang (analog zu BakaAuth, aber ohne Passwort) hält einen optionalen Gast-Token. Die bestehenden YtDownloadService/YtSearchService bekommen einen optionalen gast-Parameter und wählen pro Anfrage zwischen Baka-Auth-Bearer-Header und X-Guest-Token-Header. Die YouTube-Bildschirme zeigen echten Gästen zwei Buttons ("Anmelden" / "Als Gast fortfahren") statt nur einem.

Tech Stack: Flutter/Dart, provider (State), http + http/testing (Netzwerk/Tests), flutter_secure_storage (Token-Ablage, über die bestehende TokenSpeicher-Abstraktion aus baka_auth.dart), flutter_test (Widget-/Unit-Tests).

Spec: docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md — beschreibt auch den Server-Vertrag (POST /api/guest-token, X-Guest-Token-Header, guest_remaining-Feld, Cookie-Ausschluss für Gäste). Der Server-Teil (yt_proxy.py, claude-server-Worktree) ist NICHT Teil dieses Plans — dieser Plan deckt ausschließlich die App-Seite ab und entwickelt/testet gegen einen gemockten Proxy (wie im gesamten services/-Verzeichnis schon üblich).

Global Constraints

  • Build ohne --dart-define, keine Secrets im Code (bestehende Projektregel).
  • Kein Merge-Marker-Code, flutter analyze muss nach jedem Task sauber sein.
  • flutter test --no-pub muss nach jedem Task grün sein — bei 550+ Tests im Hintergrund laufen lassen (run_in_background), nicht mit kurzem Foreground-Timeout killen.
  • Server-Vertrag (aus der Spec, exakt so von der App erwartet): POST /api/guest-token200 {"guest_token": "<token>"}, kein Auth nötig. Anfragen mit Gast-Zugang senden X-Guest-Token: <token> statt Authorization: Bearer …. Erfolgsantwort von POST /api/yt-dl trägt bei Gast-Zugriff zusätzlich "guest_remaining": <int>.
  • Neuer Branch für dieses Feature: feature/youtube-gast-zugang (Projektregel: fix/xxx oder feature/xxx) — vor Task 1 von fix/p0-vollwertigkeit abzweigen.
  • Vor dem Abschluss-Commit (Task 7): /code-review auf den vollen Diff, gefundene echte Probleme fixen (siehe Vorgehen in diesem Repo bisher).
  • CHANGELOG.md nach Abschluss aktualisieren (Pflicht laut CLAUDE.md).

Task 1: GastZugang-Service

Files:

  • Create: lib/services/gast_zugang.dart
  • Test: test/services/gast_zugang_test.dart

Interfaces:

  • Consumes: TokenSpeicher, SicherenSpeicher aus lib/services/baka_auth.dart (bereits vorhanden, keine Änderung nötig).

  • Produces (für Task 2, 3, 5, 6):

    • class GastZugang extends ChangeNotifier
    • GastZugang({http.Client? client, TokenSpeicher? speicher})
    • bool get hatToken
    • int? get verbleibend
    • Map<String, String> get gastHeader
    • Future<void> laden()
    • Future<String?> holeToken()null bei Erfolg, sonst Fehlertext
    • void merkeVerbleibend(int n)
  • Step 0: Feature-Branch anlegen

git checkout -b feature/youtube-gast-zugang

Ab hier alle Commits dieses Plans auf diesem Branch, nicht auf fix/p0-vollwertigkeit.

  • Step 1: Write the failing test

Create test/services/gast_zugang_test.dart:

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<String, String> werte = {};
  @override
  Future<String?> lesen(String key) async => werte[key];
  @override
  Future<void> schreiben(String key, String wert) async => werte[key] = wert;
  @override
  Future<void> 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);
  });
}
  • Step 2: Run test to verify it fails

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/gast_zugang_test.dart Expected: FAIL — Target of URI doesn't exist: 'package:melo/services/gast_zugang.dart' (die Datei existiert noch nicht).

  • Step 3: Write minimal implementation

Create lib/services/gast_zugang.dart:

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<String, String> get gastHeader =>
      {if (hatToken) 'X-Guest-Token': _token!};

  /// Stellt einen früher geholten Gast-Token wieder her (App-Start).
  Future<void> 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<String?> 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<String, dynamic> daten;
    try {
      daten = jsonDecode(antwort.body) as Map<String, dynamic>;
    } 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();
  }
}
  • Step 4: Run test to verify it passes

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/gast_zugang_test.dart Expected: 00:0X +8: All tests passed!

  • Step 5: Run analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 6: Commit
git add lib/services/gast_zugang.dart test/services/gast_zugang_test.dart
git commit -m "$(cat <<'EOF'
Neuer GastZugang-Service für den anonymen YouTube-Gast-Zugang

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 <noreply@anthropic.com>
EOF
)"

Task 2: YtDownloadService um Gast-Zugang erweitern

Files:

  • Modify: lib/services/yt_download_service.dart
  • Test: test/services/yt_download_test.dart

Interfaces:

  • Consumes: GastZugang aus Task 1 (hatToken, gastHeader, holeToken(), merkeVerbleibend(int)).

  • Produces (für Task 4, 5, 6): YtDownloadService({required BakaAuth auth, GastZugang? gast, http.Client? client})gast ist optional und rückwärtskompatibel (Default null, bestehendes Verhalten unverändert, wenn nicht gesetzt).

  • Step 1: Write the failing tests

In test/services/yt_download_test.dart, Import ergänzen (nach der bestehenden baka_auth.dart-Zeile):

import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/yt_download_service.dart';

Am Ende der Datei, vor der letzten schließenden } von main(), folgende neue group einfügen:

  group('Gast-Zugang', () {
    test('Mit Gast-Token statt Anmeldung wird der Proxy trotzdem gefragt',
        () 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 dienst = YtDownloadService(
        auth: BakaAuth(
            client: MockClient((_) async => http.Response('', 500)),
            speicher: _MemorySpeicher()),
        gast: gast,
        client: MockClient((req) async {
          if (req.url.path == '/api/yt-dl') {
            expect(req.headers['X-Guest-Token'], 'gast-xyz');
            expect(req.headers.containsKey('Authorization'), isFalse);
            return http.Response(
                jsonEncode({
                  'titel': 'Gast-Lied',
                  'dauer': 30,
                  'mp3_url': '/api/dl/g.mp3',
                  'guest_remaining': 4,
                }),
                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);
    });

    test('Ohne Anmeldung UND ohne Gast-Token wird gar nicht erst gefragt',
        () async {
      var aufrufe = 0;
      final dienst = YtDownloadService(
        auth: BakaAuth(
            client: MockClient((_) async => http.Response('', 500)),
            speicher: _MemorySpeicher()),
        client: MockClient((_) async {
          aufrufe++;
          return http.Response('', 200);
        }),
      );

      final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
          zielOrdner: ziel.path);

      expect(ergebnis, isNull);
      expect(dienst.fehler, 'Bitte zuerst beim Baka-Konto anmelden');
      expect(aufrufe, 0);
    });

    test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token',
        () async {
      var tokenAufrufe = 0;
      final speicher = _MemorySpeicher();
      speicher.werte['guest_token'] = 'alter-token';
      final gast = GastZugang(
        client: MockClient((_) async {
          tokenAufrufe++;
          return http.Response(
              jsonEncode({'guest_token': 'neuer-token'}), 200);
        }),
        speicher: speicher,
      );
      await gast.laden();

      final dienst = YtDownloadService(
        auth: BakaAuth(
            client: MockClient((_) async => http.Response('', 500)),
            speicher: _MemorySpeicher()),
        gast: gast,
        client: MockClient((_) async => http.Response('Unauthorized', 401)),
      );

      final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
          zielOrdner: ziel.path);
      // Das Token-Nachholen läuft unawaited im Hintergrund.
      await Future<void>.delayed(Duration.zero);

      expect(ergebnis, isNull);
      expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
      expect(tokenAufrufe, 1);
    });
  });
  • Step 2: Run tests to verify they fail

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_download_test.dart Expected: FAIL — The named parameter 'gast' isn't defined (Compile-Fehler, YtDownloadService kennt gast noch nicht).

  • Step 3: Write minimal implementation

In lib/services/yt_download_service.dart:

Import ergänzen (nach import 'baka_auth.dart';):

import 'baka_auth.dart';
import 'gast_zugang.dart';

Konstruktor und Feld ändern (bisher YtDownloadService({required this.auth, http.Client? client}) mit Feld final BakaAuth auth;):

class YtDownloadService extends ChangeNotifier {
  YtDownloadService({required this.auth, this.gast, http.Client? client})
      : _client = client ?? http.Client();

  static const proxyUrl = 'https://yt.baka-net.de';

  final BakaAuth auth;
  final GastZugang? gast;
  final http.Client _client;

Den Anfang von herunterladen() ersetzen — bisher:

    if (!auth.istAngemeldet) {
      _scheitere('Bitte zuerst beim Baka-Konto anmelden');
      return null;
    }
    if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
      _scheitere('Das ist keine YouTube-Adresse');
      return null;
    }

    // ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ───
    _melde('Proxy wird gefragt …');
    final http.Response antwort;
    try {
      antwort = await _abbrechbar(
        _client
            .post(
              Uri.parse('$proxyUrl/api/yt-dl'),
              headers: {
                ...auth.authHeader,
                'Content-Type': 'application/json'
              },
              body: jsonEncode({'url': url, 'cookies': cookies}),
            )
            .timeout(const Duration(seconds: 180)),
        abbruch,
      );

neu:

    final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
    if (!auth.istAngemeldet && !gastAktiv) {
      _scheitere('Bitte zuerst beim Baka-Konto anmelden');
      return null;
    }
    if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
      _scheitere('Das ist keine YouTube-Adresse');
      return null;
    }
    final zugangsHeader = gastAktiv ? gast!.gastHeader : auth.authHeader;

    // ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ───
    _melde('Proxy wird gefragt …');
    final http.Response antwort;
    try {
      antwort = await _abbrechbar(
        _client
            .post(
              Uri.parse('$proxyUrl/api/yt-dl'),
              headers: {
                ...zugangsHeader,
                'Content-Type': 'application/json'
              },
              body: jsonEncode({'url': url, 'cookies': cookies}),
            )
            .timeout(const Duration(seconds: 180)),
        abbruch,
      );

Die 401-Behandlung ersetzen — bisher:

    if (antwort.statusCode == 401) {
      _scheitere('Anmeldung abgelaufen — bitte neu anmelden');
      return null;
    }

neu:

    if (antwort.statusCode == 401) {
      if (gastAktiv) unawaited(gast!.holeToken());
      _scheitere(gastAktiv
          ? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
          : 'Anmeldung abgelaufen — bitte neu anmelden');
      return null;
    }

Nach dem Auslesen von mp3Url (bisher endet der Block mit final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3'; gefolgt von der Leerzeile vor // ─── 2/2: ...), zwei Zeilen einfügen:

    final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3';
    final gastRest = daten['guest_remaining'] as int?;
    if (gastAktiv && gastRest != null) {
      gast!.merkeVerbleibend(gastRest);
    }

    // ─── 2/2: fertige MP3 abholen und speichern ───

Den MP3-Abruf-Header ersetzen — bisher:

      final mp3 = await _abbrechbar(
        _client
            .get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
            .timeout(const Duration(seconds: 180)),
        abbruch,
      );

neu:

      final mp3 = await _abbrechbar(
        _client
            .get(Uri.parse('$proxyUrl$mp3Url'), headers: zugangsHeader)
            .timeout(const Duration(seconds: 180)),
        abbruch,
      );
  • Step 4: Run tests to verify they pass

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_download_test.dart Expected: alle Tests grün (bestehende 15 + 3 neue = 18).

  • Step 5: Run analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 6: Commit
git add lib/services/yt_download_service.dart test/services/yt_download_test.dart
git commit -m "$(cat <<'EOF'
YtDownloadService: Gast-Zugang neben Baka-Auth unterstützen

Optionaler gast-Parameter (GastZugang) — sendet bei fehlender
Baka-Anmeldung X-Guest-Token statt Authorization, übernimmt
guest_remaining aus der Erfolgsantwort, holt bei 401 automatisch
einen neuen Gast-Token nach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 3: YtSearchService um Gast-Zugang erweitern

Files:

  • Modify: lib/services/yt_search_service.dart
  • Test: test/services/yt_search_service_test.dart

Interfaces:

  • Consumes: GastZugang aus Task 1 (gleiche Methoden wie Task 2).

  • Produces (für Task 4, 6): YtSearchService({required BakaAuth auth, GastZugang? gast, http.Client? client}).

  • Step 1: Write the failing tests

In test/services/yt_search_service_test.dart, Import ergänzen:

import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/yt_search_service.dart';

Innerhalb von group('YtSearchService.suchen', () { ... }), vor dem schließenden }); der Gruppe, zwei neue Tests einfügen:

    test('Mit Gast-Token statt Anmeldung wird trotzdem gesucht', () 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 dienst = YtSearchService(
        auth: BakaAuth(
            client: MockClient((_) async => http.Response('', 500)),
            speicher: _MemorySpeicher()),
        gast: gast,
        client: MockClient((req) async {
          expect(req.headers['X-Guest-Token'], 'gast-xyz');
          expect(req.headers.containsKey('Authorization'), isFalse);
          return http.Response('[]', 200);
        }),
      );

      await dienst.suchen('roses');

      expect(dienst.fehler, isNull);
    });

    test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token',
        () async {
      var tokenAufrufe = 0;
      final speicher = _MemorySpeicher();
      speicher.werte['guest_token'] = 'alter-token';
      final gast = GastZugang(
        client: MockClient((_) async {
          tokenAufrufe++;
          return http.Response(
              jsonEncode({'guest_token': 'neuer-token'}), 200);
        }),
        speicher: speicher,
      );
      await gast.laden();

      final dienst = YtSearchService(
        auth: BakaAuth(
            client: MockClient((_) async => http.Response('', 500)),
            speicher: _MemorySpeicher()),
        gast: gast,
        client: MockClient((_) async => http.Response('Unauthorized', 401)),
      );

      await dienst.suchen('roses');
      await Future<void>.delayed(Duration.zero);

      expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
      expect(tokenAufrufe, 1);
    });
  • Step 2: Run tests to verify they fail

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_search_service_test.dart Expected: FAIL — The named parameter 'gast' isn't defined.

  • Step 3: Write minimal implementation

In lib/services/yt_search_service.dart:

Import ergänzen (nach import 'baka_auth.dart';):

import 'baka_auth.dart';
import 'gast_zugang.dart';
import 'yt_download_service.dart';

Konstruktor und Feld ändern — bisher YtSearchService({required this.auth, http.Client? client}) mit final BakaAuth auth;:

class YtSearchService extends ChangeNotifier {
  YtSearchService({required this.auth, this.gast, http.Client? client})
      : _client = client ?? http.Client();

  static const proxyUrl = YtDownloadService.proxyUrl;

  final BakaAuth auth;
  final GastZugang? gast;
  final http.Client _client;

suchen() anpassen — bisher:

    if (!auth.istAngemeldet) {
      _treffer = const [];
      _fehler = 'Bitte zuerst beim Baka-Konto anmelden';
      notifyListeners();
      return;
    }

    _laeuft = true;
    _fehler = null;
    notifyListeners();

    final uri = Uri.parse('$proxyUrl/api/search')
        .replace(queryParameters: {'q': q});
    final http.Response antwort;
    try {
      antwort = await _client
          .get(uri, headers: auth.authHeader)
          .timeout(const Duration(seconds: 20));
    } on TimeoutException {
      _scheitere('Die Suche braucht zu lange — bitte später erneut versuchen');
      return;
    } catch (e) {
      debugPrint('YT-Suche nicht erreichbar: $e');
      _scheitere('Proxy nicht erreichbar');
      return;
    }

    if (antwort.statusCode == 401) {
      _scheitere('Anmeldung abgelaufen — bitte neu anmelden');
      return;
    }

neu:

    final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
    if (!auth.istAngemeldet && !gastAktiv) {
      _treffer = const [];
      _fehler = 'Bitte zuerst beim Baka-Konto anmelden';
      notifyListeners();
      return;
    }

    _laeuft = true;
    _fehler = null;
    notifyListeners();

    final uri = Uri.parse('$proxyUrl/api/search')
        .replace(queryParameters: {'q': q});
    final http.Response antwort;
    try {
      antwort = await _client
          .get(uri, headers: gastAktiv ? gast!.gastHeader : auth.authHeader)
          .timeout(const Duration(seconds: 20));
    } on TimeoutException {
      _scheitere('Die Suche braucht zu lange — bitte später erneut versuchen');
      return;
    } catch (e) {
      debugPrint('YT-Suche nicht erreichbar: $e');
      _scheitere('Proxy nicht erreichbar');
      return;
    }

    if (antwort.statusCode == 401) {
      if (gastAktiv) unawaited(gast!.holeToken());
      _scheitere(gastAktiv
          ? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
          : 'Anmeldung abgelaufen — bitte neu anmelden');
      return;
    }
  • Step 4: Run tests to verify they pass

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_search_service_test.dart Expected: alle Tests grün (bestehende 11 + 2 neue = 13).

  • Step 5: Run analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 6: Commit
git add lib/services/yt_search_service.dart test/services/yt_search_service_test.dart
git commit -m "$(cat <<'EOF'
YtSearchService: Gast-Zugang neben Baka-Auth unterstützen

Analog zu YtDownloadService: optionaler gast-Parameter, X-Guest-
Token statt Authorization, automatisches Token-Nachholen bei 401.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 4: GastZugang in main.dart verdrahten

Files:

  • Modify: lib/main.dart

Interfaces:

  • Consumes: GastZugang (Task 1), YtDownloadService/YtSearchService mit gast-Parameter (Task 2, 3).
  • Produces (für Task 5, 6): App-weiter GastZugang-Provider, erreichbar über context.watch<GastZugang>()/context.read<GastZugang>().

Kein eigener Test — reine Verdrahtung, wird durch die Widget-Tests in Task 5/6 (die GastZugang aus dem Provider-Baum lesen) sowie den bestehenden home_shell_test.dart/App-Start-Test indirekt abgedeckt.

  • Step 1: Import ergänzen

In lib/main.dart, nach import 'services/geteilte_adresse.dart';:

import 'services/geteilte_adresse.dart';
import 'services/gast_zugang.dart';
  • Step 2: Late-Field ergänzen

Nach late final BakaAuth _bakaAuth;:

late final BakaAuth _bakaAuth;
late final GastZugang _gastZugang;
  • Step 3: Instanziierung + Verdrahtung in main()

Bisher:

  _bakaAuth = BakaAuth();
  await _bakaAuth.laden();
  _ytDownload = YtDownloadService(auth: _bakaAuth);
  _ytSearch = YtSearchService(auth: _bakaAuth);

neu:

  _bakaAuth = BakaAuth();
  await _bakaAuth.laden();
  _gastZugang = GastZugang();
  await _gastZugang.laden();
  _ytDownload = YtDownloadService(auth: _bakaAuth, gast: _gastZugang);
  _ytSearch = YtSearchService(auth: _bakaAuth, gast: _gastZugang);
  • Step 4: Provider ergänzen

In MeloApp.build(), nach ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),:

        ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
        ChangeNotifierProvider<GastZugang>.value(value: _gastZugang),
  • Step 5: Run analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 6: Vollen Testlauf im Hintergrund starten

Run (im Hintergrund, siehe Global Constraints): /home/dustin/development/flutter/bin/flutter test --no-pub Expected: alle bisherigen Tests weiterhin grün — main.dart wird nicht direkt getestet, aber ein Compile-Fehler dort lässt JEDEN Widget-Test fehlschlagen, das ist der eigentliche Schutz hier.

  • Step 7: Commit
git add lib/main.dart
git commit -m "$(cat <<'EOF'
main.dart: GastZugang app-weit verdrahten

Neue Instanz wird beim Start geladen (gespeicherten Gast-Token
wiederherstellen), an YtDownloadService/YtSearchService gereicht
und als Provider bereitgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 5: UI im Download-Tab (downloads_screen.dart)

Files:

  • Modify: lib/downloads/downloads_screen.dart
  • Test: test/downloads/online_screen_test.dart

Interfaces:

  • Consumes: GastZugang aus dem Provider-Baum (Task 4), pruefeServerUser (bereits vorhanden aus baka_auth.dart).

  • Produces: nichts, das andere Tasks konsumieren — UI-Endpunkt.

  • Step 1: Write the failing tests

In test/downloads/online_screen_test.dart, Import ergänzen (nach import 'package:melo/services/download_service.dart';):

import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';

_wrap ersetzen — bisher:

Widget _wrap(BakaAuth auth,
    {required MeloDb db,
    required CategoryService categories,
    NavidromeService? navidrome}) {
  return MultiProvider(
    providers: [
      Provider<MeloDb>.value(value: db),
      // Bewusst .value: der Test besitzt den Dienst und schließt ihn im
      // tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein
      // Aufräum-Timer von drift im Test-Rahmen hängen.
      ChangeNotifierProvider<CategoryService>.value(value: categories),
      ChangeNotifierProvider<BakaAuth>.value(value: auth),
      ChangeNotifierProvider<YtDownloadService>(
        create: (_) => YtDownloadService(
          auth: auth,
          client: MockClient((_) async => http.Response('', 500)),
        ),
      ),
      // Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der
      // Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter.
      ChangeNotifierProvider<DownloadService>(
        create: (_) => DownloadService(
            db: db, navidrome: navidrome ?? NavidromeService()),
      ),
    ],
    child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
  );
}

neu:

Widget _wrap(BakaAuth auth,
    {required MeloDb db,
    required CategoryService categories,
    NavidromeService? navidrome,
    GastZugang? gast}) {
  final gastZugang = gast ?? GastZugang();
  return MultiProvider(
    providers: [
      Provider<MeloDb>.value(value: db),
      // Bewusst .value: der Test besitzt den Dienst und schließt ihn im
      // tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein
      // Aufräum-Timer von drift im Test-Rahmen hängen.
      ChangeNotifierProvider<CategoryService>.value(value: categories),
      ChangeNotifierProvider<BakaAuth>.value(value: auth),
      ChangeNotifierProvider<GastZugang>.value(value: gastZugang),
      ChangeNotifierProvider<YtDownloadService>(
        create: (_) => YtDownloadService(
          auth: auth,
          gast: gastZugang,
          client: MockClient((_) async => http.Response('', 500)),
        ),
      ),
      // Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der
      // Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter.
      ChangeNotifierProvider<DownloadService>(
        create: (_) => DownloadService(
            db: db, navidrome: navidrome ?? NavidromeService()),
      ),
    ],
    child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
  );
}

Am Ende der Datei, vor dem letzten schließenden } von main(), drei neue Tests einfügen:

  testWidgets('Gast sieht zwei Buttons: Anmelden und Als Gast fortfahren',
      (tester) async {
    final auth = BakaAuth(
        client: MockClient((_) async => http.Response('', 500)),
        speicher: _MemorySpeicher());

    await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
    await tester.pumpAndSettle();

    expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
    expect(
        find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
    expect(find.text('YouTube-Adresse'), findsNothing);
  });

  testWidgets(
      'Als Gast fortfahren holt einen Token und schaltet das Adressfeld frei',
      (tester) async {
    final auth = BakaAuth(
        client: MockClient((_) async => http.Response('', 500)),
        speicher: _MemorySpeicher());
    final gast = GastZugang(
      client: MockClient((_) async =>
          http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
      speicher: _MemorySpeicher(),
    );

    await tester.pumpWidget(
        _wrap(auth, db: db, categories: categories, gast: gast));
    await tester.pumpAndSettle();

    await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
    await tester.pumpAndSettle();

    expect(find.text('YouTube-Adresse'), findsOneWidget);
    expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
    expect(find.text('YouTube-Cookies des Servers verwenden'), findsNothing);
  });

  testWidgets('Fehler beim Gast-Token wird angezeigt', (tester) async {
    final auth = BakaAuth(
        client: MockClient((_) async => http.Response('', 500)),
        speicher: _MemorySpeicher());
    final gast = GastZugang(
      client: MockClient((_) async => http.Response('', 500)),
      speicher: _MemorySpeicher(),
    );

    await tester.pumpWidget(
        _wrap(auth, db: db, categories: categories, gast: gast));
    await tester.pumpAndSettle();

    await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
    await tester.pumpAndSettle();

    expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget);
    expect(find.text('YouTube-Adresse'), findsNothing);
  });
  • Step 2: Run tests to verify they fail

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/online_screen_test.dart Expected: FAIL — entweder Compile-Fehler (gast unbekannter Parameter, falls Step 3 hier vor der UI-Änderung läuft) oder findsOneWidget schlägt fehl, weil der neue Button/Zähler-Text noch nicht existiert.

  • Step 3: Write minimal implementation

In lib/downloads/downloads_screen.dart, Import ergänzen (nach import '../services/download_service.dart';):

import '../services/download_service.dart';
import '../services/gast_zugang.dart';

In _YouTubeBereichState, neues Feld nach bool _autoLoginFehlgeschlagen = false;:

  /// Die automatische Anmeldung ist fehlgeschlagen (z. B. abweichendes
  /// Baka-Passwort) — dann bleibt der manuelle Anmelde-Weg als Rückfalloption
  /// sichtbar, sonst käme so ein User nie mehr an den Dialog heran.
  bool _autoLoginFehlgeschlagen = false;

  /// Fehlertext, falls das Holen des Gast-Tokens scheitert.
  String? _gastFehler;

Neue Methode nach _anmelden():

  Future<void> _anmelden() async {
    await showDialog(
      context: context,
      builder: (_) => const _AnmeldeDialog(),
    );
  }

  Future<void> _alsGastFortfahren() async {
    final gast = context.read<GastZugang>();
    setState(() => _gastFehler = null);
    final fehler = await gast.holeToken();
    if (!mounted) return;
    if (fehler != null) setState(() => _gastFehler = fehler);
  }

build() komplett ersetzen — bisher:

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<BakaAuth>();
    final dienst = context.watch<YtDownloadService>();
    // Server-User gelten sofort als berechtigt — die Anmeldung passiert für
    // sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische
    // Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
    // als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück.
    final zugriffOk =
        auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);

    return ListView(
      padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
      children: [
        if (!zugriffOk) ...[
          Text(
            _autoLoginFehlgeschlagen
                ? 'Die automatische Anmeldung hat nicht geklappt — vermutlich '
                    'weicht dein Server-Passwort vom Baka-Konto ab. Einmal '
                    'manuell mit dem aktuellen Baka-Passwort anmelden, dann '
                    'gleicht sich das Passwort wieder ab.'
                : 'Der Downloader läuft über den Baka-Server. Dafür brauchst '
                    'du deine Baka-Anmeldung.',
            style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
          ),
          const SizedBox(height: 12),
          SizedBox(
            width: double.infinity,
            height: MeloTheme.minTouchTarget + 4,
            child: FilledButton.icon(
              icon: const Icon(Icons.login),
              label: const Text('Beim Baka-Konto anmelden',
                  style: TextStyle(fontSize: 16)),
              onPressed: _anmelden,
            ),
          ),
        ] else ...[
          if (!_serverUser) ...[
            Row(
              children: [
                const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
                const SizedBox(width: 8),
                Expanded(
                  child: Text('Angemeldet als ${auth.benutzer}',
                      style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
                ),
                TextButton(
                  onPressed: auth.abmelden,
                  child: const Text('Abmelden'),
                ),
              ],
            ),
            const SizedBox(height: 8),
          ],
          TextField(
            controller: _url,
            enabled: !dienst.laeuft,
            decoration: const InputDecoration(
              labelText: 'YouTube-Adresse',
              hintText: 'https://youtu.be/…',
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 12),
          _KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft),
          SwitchListTile(
            dense: true,
            contentPadding: EdgeInsets.zero,
            title: const Text('YouTube-Cookies des Servers verwenden'),
            subtitle: const Text('Nötig für altersbeschränkte Videos'),
            value: _cookies,
            onChanged: dienst.laeuft ? null : _setzeCookies,
          ),
          if (_orte.length > 1) ...[

neu (der restliche Teil ab if (_orte.length > 1) ...[ bis zum Ende der Methode bleibt unverändert stehen):

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<BakaAuth>();
    final gast = context.watch<GastZugang>();
    final dienst = context.watch<YtDownloadService>();
    // Server-User gelten sofort als berechtigt — die Anmeldung passiert für
    // sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische
    // Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
    // als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück.
    // Ein Gast-Token zählt ebenfalls als Zugang, nur mit Tages-Limit.
    final zugriffOk = auth.istAngemeldet ||
        (_serverUser && !_autoLoginFehlgeschlagen) ||
        gast.hatToken;
    final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;

    return ListView(
      padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
      children: [
        if (!zugriffOk) ...[
          Text(
            _autoLoginFehlgeschlagen
                ? 'Die automatische Anmeldung hat nicht geklappt — vermutlich '
                    'weicht dein Server-Passwort vom Baka-Konto ab. Einmal '
                    'manuell mit dem aktuellen Baka-Passwort anmelden, dann '
                    'gleicht sich das Passwort wieder ab.'
                : 'Der Downloader läuft über den Baka-Server. Dafür brauchst '
                    'du deine Baka-Anmeldung.',
            style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
          ),
          const SizedBox(height: 12),
          SizedBox(
            width: double.infinity,
            height: MeloTheme.minTouchTarget + 4,
            child: FilledButton.icon(
              icon: const Icon(Icons.login),
              label: const Text('Beim Baka-Konto anmelden',
                  style: TextStyle(fontSize: 16)),
              onPressed: _anmelden,
            ),
          ),
          if (!_serverUser) ...[
            const SizedBox(height: 8),
            SizedBox(
              width: double.infinity,
              height: MeloTheme.minTouchTarget + 4,
              child: OutlinedButton.icon(
                icon: const Icon(Icons.person_outline),
                label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
                    style: TextStyle(fontSize: 16)),
                onPressed: _alsGastFortfahren,
              ),
            ),
            if (_gastFehler != null) ...[
              const SizedBox(height: 8),
              Text(_gastFehler!,
                  style: const TextStyle(color: MeloTheme.red, fontSize: 13)),
            ],
          ],
        ] else ...[
          if (!_serverUser && auth.istAngemeldet) ...[
            Row(
              children: [
                const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
                const SizedBox(width: 8),
                Expanded(
                  child: Text('Angemeldet als ${auth.benutzer}',
                      style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
                ),
                TextButton(
                  onPressed: auth.abmelden,
                  child: const Text('Abmelden'),
                ),
              ],
            ),
            const SizedBox(height: 8),
          ],
          if (istGastModus) ...[
            Row(
              children: [
                const Icon(Icons.person_outline, size: 18, color: MeloTheme.text3),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    gast.verbleibend == null
                        ? 'Als Gast unterwegs (5 Downloads/Tag)'
                        : 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
                    style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
          ],
          TextField(
            controller: _url,
            enabled: !dienst.laeuft,
            decoration: const InputDecoration(
              labelText: 'YouTube-Adresse',
              hintText: 'https://youtu.be/…',
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 12),
          _KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft),
          if (!istGastModus)
            SwitchListTile(
              dense: true,
              contentPadding: EdgeInsets.zero,
              title: const Text('YouTube-Cookies des Servers verwenden'),
              subtitle: const Text('Nötig für altersbeschränkte Videos'),
              value: _cookies,
              onChanged: dienst.laeuft ? null : _setzeCookies,
            ),
          if (_orte.length > 1) ...[

Wichtig: Nur der gezeigte Anfang von build() ändert sich (bis einschließlich des SwitchListTile/if (!istGastModus)-Blocks). Der Rest der Methode (if (_orte.length > 1) ...[ bis zum schließenden } von build(), inklusive FilledButton.icon „Herunterladen“ und dem dienst.laeuft-Fortschrittsblock) bleibt exakt wie vorher stehen — insbesondere endet der else-Zweig weiterhin mit demselben schließenden ], wie zuvor.

  • Step 4: Run tests to verify they pass

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/online_screen_test.dart Expected: alle Tests grün (bestehende 12 + 3 neue = 15).

  • Step 5: Run analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 6: Commit
git add lib/downloads/downloads_screen.dart test/downloads/online_screen_test.dart
git commit -m "$(cat <<'EOF'
Download-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler

Echte Gäste sehen jetzt zwei Buttons: "Beim Baka-Konto anmelden"
und "Als Gast fortfahren (5 Downloads/Tag)". Im Gast-Modus wird
der Cookie-Schalter ausgeblendet (Server erzwingt cookies=false
für Gast-Zugriffe ohnehin) und ein Zähler "noch N von 5 heute"
gezeigt, sobald der Server ihn meldet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 6: UI im YT-Suche-Tab (youtube_search_screen.dart)

Files:

  • Modify: lib/downloads/youtube_search_screen.dart
  • Test: test/downloads/youtube_search_screen_test.dart

Interfaces:

  • Consumes: GastZugang aus dem Provider-Baum (Task 4).

  • Produces: nichts, das andere Tasks konsumieren — UI-Endpunkt.

  • Step 1: Write the failing tests

In test/downloads/youtube_search_screen_test.dart, Import ergänzen:

import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/navidrome_service.dart';

_wrap ersetzen — bisher:

Widget _wrap({
  required MeloDb db,
  required BakaAuth auth,
  required YtSearchService search,
  required YtDownloadService download,
  NavidromeService? navidrome,
}) {
  return MultiProvider(
    providers: [
      Provider<MeloDb>.value(value: db),
      ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
      ChangeNotifierProvider<BakaAuth>.value(value: auth),
      ChangeNotifierProvider<YtSearchService>.value(value: search),
      ChangeNotifierProvider<YtDownloadService>.value(value: download),
      // Nur für die Server-User-Prüfung gebraucht (siehe
      // _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein
      // Gast.
      ChangeNotifierProvider<DownloadService>(
        create: (_) => DownloadService(
            db: db, navidrome: navidrome ?? NavidromeService()),
      ),
    ],
    child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())),
  );
}

neu:

Widget _wrap({
  required MeloDb db,
  required BakaAuth auth,
  required YtSearchService search,
  required YtDownloadService download,
  NavidromeService? navidrome,
  GastZugang? gast,
}) {
  return MultiProvider(
    providers: [
      Provider<MeloDb>.value(value: db),
      ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
      ChangeNotifierProvider<BakaAuth>.value(value: auth),
      ChangeNotifierProvider<GastZugang>.value(value: gast ?? GastZugang()),
      ChangeNotifierProvider<YtSearchService>.value(value: search),
      ChangeNotifierProvider<YtDownloadService>.value(value: download),
      // Nur für die Server-User-Prüfung gebraucht (siehe
      // _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein
      // Gast.
      ChangeNotifierProvider<DownloadService>(
        create: (_) => DownloadService(
            db: db, navidrome: navidrome ?? NavidromeService()),
      ),
    ],
    child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())),
  );
}

Am Ende der Datei, vor dem letzten schließenden } von main(), zwei neue Tests einfügen:

  testWidgets('Gast sieht den Button "Als Gast fortfahren"', (tester) async {
    final db = MeloDb(NativeDatabase.memory());
    final auth = BakaAuth(
        client: MockClient((_) async => http.Response('', 500)),
        speicher: _MemorySpeicher());
    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.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);

    await db.close();
  });

  testWidgets(
      'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler',
      (tester) async {
    final db = MeloDb(NativeDatabase.memory());
    final auth = BakaAuth(
        client: MockClient((_) async => http.Response('', 500)),
        speicher: _MemorySpeicher());
    final search = YtSearchService(auth: auth);
    final download = YtDownloadService(auth: auth);
    final gast = GastZugang(
      client: MockClient((_) async =>
          http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
      speicher: _MemorySpeicher(),
    );

    await tester.pumpWidget(_wrap(
        db: db, auth: auth, search: search, download: download, gast: gast));
    await tester.pumpAndSettle();

    await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
    await tester.pumpAndSettle();

    expect(find.textContaining('Baka-Anmeldung'), findsNothing);
    expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);

    await db.close();
  });
  • Step 2: Run tests to verify they fail

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/youtube_search_screen_test.dart Expected: FAIL — findsOneWidget schlägt fehl, der neue Button existiert noch nicht.

  • Step 3: Write minimal implementation

In lib/downloads/youtube_search_screen.dart, Import ergänzen (nach import '../services/download_service.dart';):

import '../services/download_service.dart';
import '../services/gast_zugang.dart';

Neues Feld + neue Methode in _YoutubeSearchScreenState, nach bool _autoLoginFehlgeschlagen = false;:

  /// Die automatische Anmeldung ist fehlgeschlagen — dann bleibt der
  /// Hinweis auf die manuelle Anmeldung (siehe Download-Tab) sichtbar.
  bool _autoLoginFehlgeschlagen = false;

  /// Fehlertext, falls das Holen des Gast-Tokens scheitert.
  String? _gastFehler;

Nach der _pruefeServerUser()-Methode, vor dispose():

  Future<void> _alsGastFortfahren() async {
    final gast = context.read<GastZugang>();
    setState(() => _gastFehler = null);
    final fehler = await gast.holeToken();
    if (!mounted) return;
    if (fehler != null) setState(() => _gastFehler = fehler);
  }

build() ersetzen — bisher:

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<BakaAuth>();
    final suche = context.watch<YtSearchService>();
    final zugriffOk =
        auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);

    return SafeArea(
      child: Column(
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _query,
                    textInputAction: TextInputAction.search,
                    onSubmitted: (_) => _suchen(),
                    decoration: const InputDecoration(
                      hintText: 'YouTube durchsuchen …',
                      prefixIcon: Icon(Icons.search),
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton(
                  onPressed: _suchen,
                  child: const Text('Suchen'),
                ),
              ],
            ),
          ),
          if (!zugriffOk)
            const Padding(
              padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              child: Text(
                'Die Suche läuft über den Baka-Server. Dafür brauchst du '
                'deine Baka-Anmeldung (siehe Download-Tab).',
                style: TextStyle(color: MeloTheme.text2, fontSize: 13),
              ),
            ),
          if (suche.laeuft) const LinearProgressIndicator(),

neu (der Rest der Methode ab if (suche.laeuft) const LinearProgressIndicator(), bleibt unverändert):

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<BakaAuth>();
    final gast = context.watch<GastZugang>();
    final suche = context.watch<YtSearchService>();
    final zugriffOk = auth.istAngemeldet ||
        (_serverUser && !_autoLoginFehlgeschlagen) ||
        gast.hatToken;
    final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;

    return SafeArea(
      child: Column(
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _query,
                    textInputAction: TextInputAction.search,
                    onSubmitted: (_) => _suchen(),
                    decoration: const InputDecoration(
                      hintText: 'YouTube durchsuchen …',
                      prefixIcon: Icon(Icons.search),
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                FilledButton(
                  onPressed: _suchen,
                  child: const Text('Suchen'),
                ),
              ],
            ),
          ),
          if (!zugriffOk) ...[
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              child: Text(
                'Die Suche läuft über den Baka-Server. Dafür brauchst du '
                'deine Baka-Anmeldung (siehe Download-Tab).',
                style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
              ),
            ),
            if (!_serverUser) ...[
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: SizedBox(
                  width: double.infinity,
                  height: MeloTheme.minTouchTarget + 4,
                  child: OutlinedButton.icon(
                    icon: const Icon(Icons.person_outline),
                    label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
                        style: TextStyle(fontSize: 16)),
                    onPressed: _alsGastFortfahren,
                  ),
                ),
              ),
              if (_gastFehler != null)
                Padding(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
                  child: Text(_gastFehler!,
                      style:
                          const TextStyle(color: MeloTheme.red, fontSize: 13)),
                ),
            ],
          ],
          if (istGastModus)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              child: Text(
                gast.verbleibend == null
                    ? 'Als Gast unterwegs (5 Downloads/Tag)'
                    : 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
                style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
              ),
            ),
          if (suche.laeuft) const LinearProgressIndicator(),
  • Step 4: Run tests to verify they pass

Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/youtube_search_screen_test.dart Expected: alle Tests grün (bestehende 4 + 2 neue = 6).

  • Step 5: Run analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 6: Commit
git add lib/downloads/youtube_search_screen.dart test/downloads/youtube_search_screen_test.dart
git commit -m "$(cat <<'EOF'
YT-Suche-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler

Analog zum Download-Tab: echte Gäste sehen zusätzlich zum
bestehenden Hinweis den Button "Als Gast fortfahren (5
Downloads/Tag)"; im Gast-Modus ersetzt ein Zähler den Hinweis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 7: Gesamt-Verifikation, Code-Review, CHANGELOG

Files:

  • Modify: CHANGELOG.md

  • Step 1: Vollen Testlauf im Hintergrund

Run: /home/dustin/development/flutter/bin/flutter test --no-pub (im Hintergrund, siehe Global Constraints) Expected: alle Tests grün, Gesamtzahl = vorherige Gesamtzahl + 8 (Task 1)

  • 3 (Task 2) + 2 (Task 3) + 3 (Task 5) + 2 (Task 6) = +18 gegenüber dem Stand vor diesem Plan.
  • Step 2: Analyze

Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub Expected: No issues found!

  • Step 3: Code-Review

/code-review auf den vollen Diff seit dem Abzweigen von fix/p0-vollwertigkeit laufen lassen. Echte, bestätigte Funde beheben (mit eigenem kleinen Commit je Fund, wie in diesem Repo üblich), triviale/ falsch-positive Funde begründet verwerfen.

  • Step 4: CHANGELOG.md aktualisieren

Neuen Abschnitt ganz oben unter ## [Unreleased] einfügen (Format wie die bestehenden Einträge: ### <Emoji> <Titel> (<Datum>), Stichpunkte mit /🔧/🐛), der beschreibt:

  • Gast-Zugang mit bewusster Wahl in Download-Tab und YT-Suche-Tab

  • 5 Downloads/Tag-Limit (serverseitig durchgesetzt, App zeigt nur den vom Server gemeldeten Stand an)

  • Cookie-Schalter für Gäste ausgeblendet

  • Verweis auf die Spec (docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md) und den Hinweis, dass der Server-Teil (yt_proxy.py) separat läuft und vor dem echten Test auf dem Gerät fertig sein muss

  • Test-Gesamtzahl (aus Step 1)

  • Step 5: Commit

git add CHANGELOG.md
git commit -m "$(cat <<'EOF'
CHANGELOG: YouTube-Gast-Zugang (App-Seite)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
  • Step 6: Push
git push origin feature/youtube-gast-zugang

Hinweis für den Ausführenden: Die App-Seite ist damit fertig und getestet, funktioniert aber gegen den echten Proxy erst, sobald POST /api/guest-token und die erweiterte _checke_auth()-Logik in yt_proxy.py existieren (siehe Spec, Abschnitt „Server-seitige Komponenten“) — das macht die claude-server-Session/Hermes. Vor einem echten APK-Test auf dem Gerät das mit Dustin abstimmen.