From 5d0bdd95685153dd54e456c45f682dfc3b0e011c Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:45:02 -0500 Subject: [PATCH] feat(settings): in-app YouTube cookie management for age-restricted videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only Settings panel (iOS + web) to paste a cookies.txt so age-restricted / members-only videos play — no SSH or server filesystem. Shows status (Not connected / Connected · updated / Cookies expired ⚠), a paste box + Save, and Remove. Hits the new /api/settings/youtube-cookies endpoints; set once, applies to every profile. Also fixes a stray hardcoded "Version 1.0.0" in About -> 0.1.0 (pre-release). analyze clean, 156 tests pass, flutter build web succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/config/constants.dart | 2 + lib/screens/settings_screen.dart | 225 ++++++++++++++++++++++++++++++- lib/services/api_service.dart | 29 ++++ 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/lib/config/constants.dart b/lib/config/constants.dart index 4238e5f..d8699e9 100644 --- a/lib/config/constants.dart +++ b/lib/config/constants.dart @@ -40,6 +40,8 @@ class AppConstants { static const String wsTicket = '$apiBase/auth/ws-ticket'; static const String youtubeResolve = '$apiBase/youtube/resolve'; static const String youtubeSuggestions = '$apiBase/youtube/suggestions'; + static const String settingsYoutubeCookies = + '$apiBase/settings/youtube-cookies'; static const String channels = '$apiBase/channels'; static const String channelSubscribe = '$apiBase/channels/subscribe'; static const String channelSubscribeBulk = '$apiBase/channels/subscribe-bulk'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 78e25ca..fd37b66 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -276,6 +276,14 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: 24), + // YouTube account (admin only) — enables age-restricted videos + // for everyone on this server. + if (authState.currentUser?.isAdmin == true) ...[ + const _SectionHeader(title: 'YouTube Account'), + const _YoutubeCookiesSection(), + const SizedBox(height: 24), + ], + // About section const _SectionHeader(title: 'About'), Card( @@ -284,7 +292,7 @@ class _SettingsScreenState extends ConsumerState { const ListTile( leading: Icon(Icons.info_outline), title: Text('NullFeed'), - subtitle: Text('Version 1.0.0'), + subtitle: Text('Version 0.1.0'), ), ListTile( leading: const Icon(Icons.code), @@ -319,3 +327,218 @@ class _SectionHeader extends StatelessWidget { ); } } + +/// Admin panel to paste/refresh a YouTube cookies.txt so age-restricted / +/// members-only videos play. Set once; applies to every profile on the server. +class _YoutubeCookiesSection extends ConsumerStatefulWidget { + const _YoutubeCookiesSection(); + + @override + ConsumerState<_YoutubeCookiesSection> createState() => + _YoutubeCookiesSectionState(); +} + +class _YoutubeCookiesSectionState + extends ConsumerState<_YoutubeCookiesSection> { + final _controller = TextEditingController(); + ({bool configured, bool stale, String? updatedAt})? _status; + bool _loading = true; + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final s = await ref.read(apiServiceProvider).getYoutubeCookiesStatus(); + if (mounted) { + setState(() { + _status = s; + _loading = false; + }); + } + } catch (_) { + if (mounted) setState(() => _loading = false); + } + } + + Future _save() async { + final text = _controller.text.trim(); + if (text.isEmpty) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final s = await ref.read(apiServiceProvider).saveYoutubeCookies(text); + if (!mounted) return; + _controller.clear(); + setState(() { + _status = s; + _busy = false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('YouTube cookies saved'))); + } on ApiException catch (e) { + if (mounted) { + setState(() { + _error = e.message; + _busy = false; + }); + } + } + } + + Future _remove() async { + setState(() => _busy = true); + try { + final api = ref.read(apiServiceProvider); + await api.clearYoutubeCookies(); + final s = await api.getYoutubeCookiesStatus(); + if (mounted) { + setState(() { + _status = s; + _busy = false; + }); + } + } catch (_) { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final status = _status; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + _statusRow(status), + const SizedBox(height: 12), + Text( + 'Paste a cookies.txt from a browser signed in to YouTube (e.g. the ' + '"Get cookies.txt LOCALLY" extension) to play age-restricted ' + 'videos. Set once — it applies to every profile on this server.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: NullFeedTheme.textMuted), + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + maxLines: 4, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + decoration: const InputDecoration( + hintText: '# Netscape HTTP Cookie File …', + border: OutlineInputBorder(), + isDense: true, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle( + color: NullFeedTheme.errorColor, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + FilledButton( + onPressed: _busy ? null : _save, + child: _busy + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + if (status?.configured == true) ...[ + const SizedBox(width: 12), + TextButton( + onPressed: _busy ? null : _remove, + child: const Text( + 'Remove', + style: TextStyle(color: NullFeedTheme.errorColor), + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _statusRow(({bool configured, bool stale, String? updatedAt})? s) { + if (s == null || !s.configured) { + return const Row( + children: [ + Icon(Icons.cancel_outlined, color: NullFeedTheme.textMuted, size: 18), + SizedBox(width: 8), + Text('Not connected'), + ], + ); + } + if (s.stale) { + return const Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: NullFeedTheme.errorColor, + size: 18, + ), + SizedBox(width: 8), + Expanded( + child: Text('Cookies expired — paste fresh ones to keep playing'), + ), + ], + ); + } + return Row( + children: [ + const Icon( + Icons.check_circle, + color: NullFeedTheme.successColor, + size: 18, + ), + const SizedBox(width: 8), + Expanded(child: Text('Connected${_formatUpdated(s.updatedAt)}')), + ], + ); + } + + String _formatUpdated(String? iso) { + final dt = iso == null ? null : DateTime.tryParse(iso); + if (dt == null) return ''; + return ' · updated ${dt.toLocal().toString().split('.').first}'; + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 87c1644..1e5709b 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -452,6 +452,35 @@ class ApiService { await _dio.delete('$_baseUrl${AppConstants.videoDetail(videoId)}'); }); + // YouTube cookies (admin only) — enables age-restricted / members-only videos. + ({bool configured, bool stale, String? updatedAt}) _parseCookieStatus( + Map data, + ) => ( + configured: data['configured'] == true, + stale: data['stale'] == true, + updatedAt: data['updated_at'] as String?, + ); + + Future<({bool configured, bool stale, String? updatedAt})> + getYoutubeCookiesStatus() => _guard(() async { + final r = await _dio.get('$_baseUrl${AppConstants.settingsYoutubeCookies}'); + return _parseCookieStatus(r.data as Map); + }); + + Future<({bool configured, bool stale, String? updatedAt})> saveYoutubeCookies( + String cookies, + ) => _guard(() async { + final r = await _dio.put( + '$_baseUrl${AppConstants.settingsYoutubeCookies}', + data: {'cookies': cookies}, + ); + return _parseCookieStatus(r.data as Map); + }); + + Future clearYoutubeCookies() => _guard(() async { + await _dio.delete('$_baseUrl${AppConstants.settingsYoutubeCookies}'); + }); + /// Records an evictable cache claim on [videoId] and (server-side) kicks off /// its HQ download, without it showing in the Downloads tab. Called when the /// user starts instant playback of a not-yet-downloaded video so the player