yt-dlp cookies: from a browser or a file (cookies.txt or extension JSON), per site, private; server --yt-dlp-cookies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 22:59:10 +03:00
1 parent 8b81da4069
commit 8186fbb5d3
11 files changed
+732 -14

No files matched your search

+10 -1
View File
@@ -151,7 +151,8 @@ then everyone (you included) uses Join.
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`); replaces the flags |
| `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags |
It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host.
@@ -316,6 +317,14 @@ Web pages work in all three: the app and the dedicated server each
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
against the release's checksums, updated once a day; Settings → Player
shows its version and has "Update now"), so there is nothing to install.
Sites that refuse guests (Bilibili answers "HTTP Error 412", YouTube may
ask to sign in) need cookies: Settings → Player → yt-dlp → Cookies reads
them from a browser, or from a cookies.txt or a cookie extension's JSON
export (picked or dropped; tick the sites to keep). They stay on this
computer and are used by its yt-dlp only (your own playback, "through
me", and a server you host). Cookies log yt-dlp in as you on those
sites: keep the file private, and for YouTube consider a throwaway
account (Google may flag accounts used with yt-dlp).
YouTube works in all three too. The last two never show the link to
anyone; they can't pass on live streams or pages that only offer streaming
playlists (use "Everyone fetches it" for those). Your upload has to beat the video's
@@ -16,7 +16,7 @@ import '../sync/state_diff.dart';
import '../sync/user_seek.dart';
import 'frame_watchdog.dart';
import 'media_marks.dart';
import 'yt_dlp_app.dart' show ytdlFormat;
import 'yt_dlp_app.dart' show AppYtDlp, ytdlFormat;
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
@@ -65,21 +65,31 @@ class PlaybackController {
void Function(String error)? onYtdlError;
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
/// page links, at most 1080p. Once per player.
/// page links, at most 1080p, once per player; its cookies
/// (`ytdl-raw-options`) whenever they changed.
Future<void> useYtDlp(String path) async {
final p = _player;
if (p == null || identical(p, _ytdlSetOn)) return;
if (p == null) return;
try {
final dynamic platform = p.platform;
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
_ytdlSetOn = p;
if (!identical(p, _ytdlSetOn)) {
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
await platform?.setProperty('ytdl-format', ytdlFormat);
_ytdlSetOn = p;
_ytdlRaw = '';
}
final raw = await AppYtDlp.instance.mpvRawOptions();
if (raw != _ytdlRaw && identical(p, _player)) {
await platform?.setProperty('ytdl-raw-options', raw);
_ytdlRaw = raw;
}
} catch (e) {
debugPrint('yt-dlp path not set: $e');
}
}
Player? _ytdlSetOn;
String _ytdlRaw = '';
/// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
@@ -28,6 +28,16 @@ class AppYtDlp {
/// update check afterwards).
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
/// mpv's `ytdl-raw-options` for the cookies in the yt-dlp folder ('' for
/// none; see [YtDlpCookies]).
Future<String> mpvRawOptions() async {
try {
return (await ytDlp).cookies.mpvRawOptions();
} catch (_) {
return '';
}
}
final _probes = <String, (DateTime, Future<LinkProbe>)>{};
static const _probeTtl = Duration(minutes: 10);
+273 -1
View File
@@ -1,8 +1,13 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget;
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show YtDlp;
import 'package:sync_relay_core/core.dart'
show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, cookiesBySite, parseCookies, suggestedCookieSites;
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
@@ -420,6 +425,8 @@ class _YtDlpSettingsState extends State<YtDlpSettings> {
'Downloaded and kept up to date by the app; checked once a day.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
YtDlpCookieSettings(cookies: y.cookies),
],
);
},
@@ -429,6 +436,271 @@ class _YtDlpSettingsState extends State<YtDlpSettings> {
}
}
/// yt-dlp's cookies (sites that refuse guests): from a browser, or a
/// cookies.txt / JSON export picked or dropped, filtered by site into the
/// private copy in the yt-dlp folder (see [YtDlpCookies]).
class YtDlpCookieSettings extends StatefulWidget {
final YtDlpCookies cookies;
const YtDlpCookieSettings({super.key, required this.cookies});
@override
State<YtDlpCookieSettings> createState() => _YtDlpCookieSettingsState();
}
class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
late var _source = widget.cookies.read();
String? _error;
bool _dragging = false;
void _reload([String? error]) {
if (!mounted) return;
setState(() {
_source = widget.cookies.read();
_error = error;
});
}
String get _summary => switch (_source) {
CookieFile f => '${f.count} cookie${f.count == 1 ? '' : 's'} for ${f.sites.join(', ')}',
CookieBrowser b => 'From ${b.browser}${b.profile.isEmpty ? '' : ' (profile ${b.profile})'}',
_ => 'No cookies: yt-dlp visits sites as a guest',
};
Future<void> _pickFile() async {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
type: FileType.custom,
allowedExtensions: const ['txt', 'json'],
);
} catch (_) {
_reload('file dialog unavailable — try again');
return;
}
final path = files.firstOrNull?.path;
if (path != null) await _importFile(path);
}
Future<void> _importFile(String path) async {
final List<NetscapeCookie> all;
try {
all = parseCookies(await File(path).readAsString());
} on FormatException catch (e) {
_reload("Can't read that file: ${e.message}");
return;
} on FileSystemException catch (e) {
_reload("Can't read that file: ${e.message}");
return;
}
if (!mounted) return;
final bySite = cookiesBySite(all);
final sites = await showDialog<Set<String>>(
context: context,
builder: (_) => _CookieSitesDialog(bySite: {for (final e in bySite.entries) e.key: e.value.length}),
);
if (sites == null || sites.isEmpty) return;
try {
await widget.cookies.saveFile([for (final s in sites) ...?bySite[s]]);
_reload();
} catch (e) {
_reload('Could not keep the cookies: $e');
}
}
Future<void> _pickBrowser() async {
final current = _source;
final picked = await showDialog<CookieBrowser>(
context: context,
builder: (_) => _CookieBrowserDialog(initial: current is CookieBrowser ? current : null),
);
if (picked == null) return;
try {
await widget.cookies.saveBrowser(picked.browser, picked.profile);
_reload();
} catch (e) {
_reload('Could not save: $e');
}
}
Future<void> _remove() async {
try {
await widget.cookies.remove();
_reload();
} catch (e) {
_reload('Could not remove the cookies: $e');
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DropTarget(
onDragEntered: (_) => setState(() => _dragging = true),
onDragExited: (_) => setState(() => _dragging = false),
onDragDone: (d) {
setState(() => _dragging = false);
final path = DesktopDropX.pathsOf(d.files).firstOrNull;
if (path != null) _importFile(path);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _dragging ? scheme.primary : scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.cookie_outlined),
title: const Text('Cookies'),
subtitle: Text(_summary, key: const ValueKey('ytdlp-cookies-summary')),
),
if (_error != null)
Text(_error!, key: const ValueKey('ytdlp-cookies-error'), style: TextStyle(color: scheme.error)),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _pickBrowser,
icon: const Icon(Icons.public),
label: const Text('From a browser…'),
),
OutlinedButton.icon(
onPressed: _pickFile,
icon: const Icon(Icons.file_open_outlined),
label: const Text('From a file…'),
),
if (_source != null)
TextButton(
key: const ValueKey('ytdlp-cookies-remove'),
onPressed: _remove,
child: const Text('Remove cookies'),
),
],
),
const SizedBox(height: 8),
Text(
'For sites that refuse guests (Bilibili, YouTube asking to sign in). Drop a cookies.txt or a '
'cookie-extension JSON export here, or pick one; only the sites you tick are kept, on this computer only. '
'Cookies log yt-dlp in as you on those sites. Keep the file private; for YouTube consider a '
'throwaway account (Google may flag accounts used with yt-dlp).',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
);
}
}
/// Which sites of an imported cookie file to keep; pops the ticked ones.
class _CookieSitesDialog extends StatefulWidget {
/// Site -> number of cookies.
final Map<String, int> bySite;
const _CookieSitesDialog({required this.bySite});
@override
State<_CookieSitesDialog> createState() => _CookieSitesDialogState();
}
class _CookieSitesDialogState extends State<_CookieSitesDialog> {
late final _on = {for (final s in widget.bySite.keys) if (suggestedCookieSites.contains(s)) s};
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Keep cookies for'),
content: SizedBox(
width: 380,
child: ListView(
shrinkWrap: true,
children: [
for (final e in widget.bySite.entries)
CheckboxListTile(
dense: true,
value: _on.contains(e.key),
title: Text(e.key),
subtitle: Text('${e.value} cookie${e.value == 1 ? '' : 's'}'),
onChanged: (v) => setState(() => v == true ? _on.add(e.key) : _on.remove(e.key)),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: _on.isEmpty ? null : () => Navigator.pop(context, {..._on}),
child: const Text('Keep'),
),
],
);
}
}
/// Which browser (and profile) yt-dlp reads cookies from.
class _CookieBrowserDialog extends StatefulWidget {
final CookieBrowser? initial;
const _CookieBrowserDialog({this.initial});
@override
State<_CookieBrowserDialog> createState() => _CookieBrowserDialogState();
}
class _CookieBrowserDialogState extends State<_CookieBrowserDialog> {
late var _browser = widget.initial?.browser ?? cookieBrowsers.first;
late final _profile = TextEditingController(text: widget.initial?.profile ?? '');
@override
void dispose() {
_profile.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Cookies from a browser'),
content: SizedBox(
width: 380,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
initialValue: _browser,
decoration: const InputDecoration(labelText: 'Browser'),
items: [for (final b in cookieBrowsers) DropdownMenuItem(value: b, child: Text(b))],
onChanged: (v) => setState(() => _browser = v ?? _browser),
),
const SizedBox(height: 12),
TextField(
controller: _profile,
decoration: const InputDecoration(labelText: 'Profile (optional)', hintText: 'the default profile'),
),
const SizedBox(height: 12),
Text(
'yt-dlp reads them each time it opens a link (the browser may need to be closed on Windows).',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, CookieBrowser(_browser, _profile.text.trim())),
child: const Text('Use'),
),
],
);
}
}
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not.
class AnimeFlowCard extends StatefulWidget {
+25 -3
View File
@@ -43,7 +43,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--config`; default port 8080) into the same map and calls `serve`;
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--yt-dlp-cookies`, `--config`; default port 8080) into the same map and calls `serve`;
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
@@ -529,8 +529,30 @@ all three link modes. Nothing is bundled or installed by hand.
`--yt-dlp-dir`, else `$STATE_DIRECTORY/yt-dlp` (systemd), else
`yt-dlp` next to the binary if writable, else
`~/.cache/watch-together/yt-dlp`.
- **Resolve** (`YtDlp.resolve`): `yt-dlp -J --no-playlist --no-warnings
--ignore-config`, 25 s timeout: title, uploader, duration, thumbnail,
- **Cookies** (`YtDlpCookies`, `cookies.dart`): a yt-dlp folder holds at
most one source, `cookies.txt` (a private Netscape copy, mode 0600,
written to a `.part` that is made private before anything goes into
it) or `cookies-browser` (`BROWSER[:PROFILE]`). Every resolve adds
`--cookies <copy>` or `--cookies-from-browser <spec>` (the update and
`--version` runs do not); yt-dlp may write the copy back. Imports
(`parseCookies`) take a Netscape cookies.txt or a cookie extension's
JSON array (`name`, `value`, `domain`, `path`, `expirationDate` /
`expires`, `hostOnly`, `secure`, `httpOnly`, `session`; host-only
cookies keep their bare host with `FALSE`, the rest get a leading '.';
session cookies expire `0`), grouped by site (`cookieSite`: the
registrable domain by a short rule, with google.com filed under
youtube.com and bilibili's CDNs under bilibili.com). The app: Settings
→ Player → yt-dlp → Cookies (a browser and profile, or a file picked or
dropped, then the sites to keep, bilibili.com and youtube.com ticked
when present; "Remove cookies"); its hosted server shares the folder,
so it uses them too. mpv's hook gets the same as `ytdl-raw-options`
(`cookies=%N%<path>` / `cookies-from-browser=%N%<spec>`, length-quoted),
re-set before each external link when it changed. The dedicated
server: `--yt-dlp-cookies <file>` / `ytDlpCookies` converted into its
folder at start; without it the copy and browser setting are removed.
Values are never logged and never leave the machine.
- **Resolve** (`YtDlp.resolve`, `resolveArgs`): `yt-dlp -J --no-playlist --no-warnings
--ignore-config` plus the cookies, 25 s timeout: title, uploader, duration, thumbnail,
live or not, and what a relay reads (`pickTracks`), each format with
its URL, `http_headers` and `http_chunk_size`. Only plain http(s)
formats without DRM count:
+1
View File
@@ -7,6 +7,7 @@ export 'src/protocol.dart';
export 'src/relay.dart';
export 'src/link_probe.dart';
export 'src/yt_dlp.dart';
export 'src/cookies.dart';
export 'src/media_source.dart';
export 'src/sync_engine.dart';
export 'src/jellyfin/client.dart';
+7
View File
@@ -111,6 +111,10 @@ class AppConfig {
/// Where that yt-dlp lives (writable); empty: a `yt-dlp` folder next to
/// the server binary, else `~/.cache/watch-together/yt-dlp`.
final String ytDlpDir;
/// Dedicated server: a cookies.txt or browser-extension JSON export
/// copied (converted) into [ytDlpDir] at start, so yt-dlp is logged in
/// (see `YtDlpCookies`); empty: no cookies.
final String ytDlpCookies;
const AppConfig({
required this.port,
@@ -123,6 +127,7 @@ class AppConfig {
this.jellyfin,
this.ytDlp = true,
this.ytDlpDir = '',
this.ytDlpCookies = '',
});
factory AppConfig.defaults() =>
@@ -141,6 +146,7 @@ class AppConfig {
jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
ytDlp: (map['ytDlp'] as bool?) ?? true,
ytDlpDir: (map['ytDlpDir'] as String?) ?? '',
ytDlpCookies: (map['ytDlpCookies'] as String?) ?? '',
);
} on ConfigException {
rethrow;
@@ -160,6 +166,7 @@ class AppConfig {
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
'ytDlp': ytDlp,
'ytDlpDir': ytDlpDir,
'ytDlpCookies': ytDlpCookies,
};
static Future<AppConfig> loadFile(String path) async =>
+297
View File
@@ -0,0 +1,297 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
// Cookies for yt-dlp: some sites refuse guests (Bilibili answers HTTP 412,
// YouTube may ask to sign in). A machine's yt-dlp folder holds at most one
// source: a private Netscape cookies.txt (mode 0600) or the name of a
// browser to read them from. They only ever go to this machine's yt-dlp;
// values are never logged or sent anywhere.
/// One cookie, as a line of a Netscape cookies.txt.
class NetscapeCookie {
/// With a leading '.' when [includeSubdomains].
final String domain;
final bool includeSubdomains;
final String path;
final bool secure;
/// Unix seconds; 0 for a session cookie.
final int expires;
final String name;
final String value;
final bool httpOnly;
const NetscapeCookie({
required this.domain,
this.includeSubdomains = true,
this.path = '/',
this.secure = false,
this.expires = 0,
required this.name,
required this.value,
this.httpOnly = false,
});
/// The host without a leading '.', lowercase.
String get host => (domain.startsWith('.') ? domain.substring(1) : domain).toLowerCase();
String toLine() => [
'${httpOnly ? '#HttpOnly_' : ''}$domain',
includeSubdomains ? 'TRUE' : 'FALSE',
path,
secure ? 'TRUE' : 'FALSE',
'$expires',
name,
value,
].join('\t');
}
/// Reads a Netscape cookies.txt or the JSON array browser extensions
/// export (Cookie-Editor, "Get cookies.txt" style: objects with name,
/// value, domain, path, expirationDate or expires, httpOnly, secure,
/// hostOnly, session; also `{"cookies": [...]}`). Throws [FormatException]
/// without quoting the input (it holds logins).
List<NetscapeCookie> parseCookies(String text) {
final t = text.trimLeft();
if (t.startsWith('[') || t.startsWith('{')) return _fromJson(t);
final out = <NetscapeCookie>[];
for (var line in const LineSplitter().convert(text)) {
var httpOnly = false;
if (line.startsWith('#HttpOnly_')) {
httpOnly = true;
line = line.substring('#HttpOnly_'.length);
} else if (line.startsWith('#') || line.trim().isEmpty) {
continue;
}
final f = line.split('\t');
if (f.length < 7) continue;
out.add(NetscapeCookie(
domain: f[0],
includeSubdomains: f[1].toUpperCase() == 'TRUE',
path: f[2],
secure: f[3].toUpperCase() == 'TRUE',
expires: int.tryParse(f[4]) ?? double.tryParse(f[4])?.toInt() ?? 0,
name: f[5],
value: f.sublist(6).join('\t'),
httpOnly: httpOnly,
));
}
if (out.isEmpty) throw const FormatException('no cookies found (expected a cookies.txt or a JSON export)');
return out;
}
List<NetscapeCookie> _fromJson(String text) {
Object? root;
try {
root = jsonDecode(text);
} on FormatException {
throw const FormatException('not valid JSON');
}
if (root is Map && root['cookies'] is List) root = root['cookies'];
if (root is! List) throw const FormatException('expected a JSON array of cookies');
final out = <NetscapeCookie>[];
for (final c in root) {
if (c is! Map) continue;
final name = c['name'], value = c['value'], domain = c['domain'];
if (name is! String || value is! String || domain is! String || domain.isEmpty) continue;
final hostOnly = c['hostOnly'] == true;
final exp = c['expirationDate'] ?? c['expires'] ?? c['expiry'];
final session = c['session'] == true || exp is! num || exp <= 0;
final path = c['path'];
out.add(NetscapeCookie(
domain: hostOnly ? (domain.startsWith('.') ? domain.substring(1) : domain) : (domain.startsWith('.') ? domain : '.$domain'),
includeSubdomains: !hostOnly,
path: path is String && path.isNotEmpty ? path : '/',
secure: c['secure'] == true,
expires: session ? 0 : exp.toInt(),
name: name,
value: value,
httpOnly: c['httpOnly'] == true,
));
}
if (out.isEmpty) throw const FormatException('no cookies found in the JSON');
return out;
}
/// [cookies] as a Netscape cookies.txt (what yt-dlp's `--cookies` reads).
String netscapeCookiesText(Iterable<NetscapeCookie> cookies) => [
'# Netscape HTTP Cookie File',
'# Written by Starsync for yt-dlp. Holds logins: keep it private.',
'',
for (final c in cookies) c.toLine(),
'',
].join('\n');
// Second-level labels under which a country code needs a third label
// (bbc.co.uk, example.com.cn).
const _secondLevel = {'co', 'com', 'net', 'org', 'ac', 'gov', 'edu', 'ne', 'or', 'go'};
// Domains one login spans, filed under one site.
const _siteAlias = {
'google.com': 'youtube.com',
'youtu.be': 'youtube.com',
'hdslb.com': 'bilibili.com',
'bilivideo.com': 'bilibili.com',
'biliapi.net': 'bilibili.com',
'b23.tv': 'bilibili.com',
'x.com': 'twitter.com',
};
/// The registrable domain of [host] (`www.bilibili.com` -> `bilibili.com`,
/// `a.bbc.co.uk` -> `bbc.co.uk`), by a short rule rather than the full
/// public suffix list.
String registrableDomain(String host) {
final labels = host.toLowerCase().replaceFirst(RegExp(r'^\.+'), '').split('.').where((l) => l.isNotEmpty).toList();
if (labels.length <= 2) return labels.join('.');
final n = labels.length;
final keep = labels[n - 1].length == 2 && _secondLevel.contains(labels[n - 2]) ? 3 : 2;
return labels.sublist(n - keep).join('.');
}
/// The site a cookie of [host] belongs to: its registrable domain, with the
/// domains one login spans folded together (google.com under youtube.com).
String cookieSite(String host) {
final d = registrableDomain(host);
return _siteAlias[d] ?? d;
}
/// [cookies] grouped by [cookieSite], sites sorted by name.
Map<String, List<NetscapeCookie>> cookiesBySite(Iterable<NetscapeCookie> cookies) {
final m = <String, List<NetscapeCookie>>{};
for (final c in cookies) {
m.putIfAbsent(cookieSite(c.host), () => []).add(c);
}
return {for (final k in m.keys.toList()..sort()) k: m[k]!};
}
/// Sites ticked by default after an import: those yt-dlp is usually given
/// cookies for.
const suggestedCookieSites = {'bilibili.com', 'youtube.com'};
/// Browsers `--cookies-from-browser` reads.
const cookieBrowsers = ['firefox', 'chrome', 'chromium', 'brave', 'edge', 'opera', 'vivaldi'];
/// Where a machine's yt-dlp gets cookies: a private file or a browser.
sealed class CookieSource {
const CookieSource();
}
/// [path] is the private copy in the yt-dlp folder.
class CookieFile extends CookieSource {
final String path;
final int count;
/// [cookieSite]s in it, sorted.
final List<String> sites;
const CookieFile(this.path, this.count, this.sites);
}
class CookieBrowser extends CookieSource {
final String browser;
/// Empty: the browser's default profile.
final String profile;
const CookieBrowser(this.browser, [this.profile = '']);
/// `BROWSER[:PROFILE]`, as `--cookies-from-browser` takes it.
String get spec => profile.isEmpty ? browser : '$browser:$profile';
}
/// The cookie setting kept in a yt-dlp folder ([dir]): `cookies.txt`
/// (mode 0600) or `cookies-browser` (the `BROWSER[:PROFILE]` spec), never
/// both.
class YtDlpCookies {
final String dir;
const YtDlpCookies(this.dir);
String get filePath => p.join(dir, 'cookies.txt');
String get _browserPath => p.join(dir, 'cookies-browser');
/// The current source, or null for none. Without [count] a file is not
/// parsed ([CookieFile.count] -1, no sites).
CookieSource? read({bool count = true}) {
try {
final b = File(_browserPath);
if (b.existsSync()) {
final spec = b.readAsStringSync().trim();
if (spec.isNotEmpty) {
final i = spec.indexOf(':');
return i < 0 ? CookieBrowser(spec) : CookieBrowser(spec.substring(0, i), spec.substring(i + 1));
}
}
final f = File(filePath);
if (f.existsSync()) {
if (!count) return CookieFile(filePath, -1, const []);
final cookies = parseCookies(f.readAsStringSync());
return CookieFile(filePath, cookies.length, cookiesBySite(cookies).keys.toList());
}
} catch (_) {}
return null;
}
/// yt-dlp arguments for [source] (default: [read]).
List<String> args([CookieSource? source]) => switch (source ?? read(count: false)) {
CookieFile(:final path) => ['--cookies', path],
CookieBrowser b => ['--cookies-from-browser', b.spec],
null => const [],
};
/// The same as mpv's `ytdl-raw-options` value ('' for none); each value
/// length-quoted (`%N%`) so commas and '=' in paths survive.
String mpvRawOptions([CookieSource? source]) {
String q(String v) => '%${utf8.encode(v).length}%$v';
return switch (source ?? read(count: false)) {
CookieFile(:final path) => 'cookies=${q(path)}',
CookieBrowser b => 'cookies-from-browser=${q(b.spec)}',
null => '',
};
}
/// Keeps [cookies] as the private copy, replacing any browser setting.
Future<CookieFile> saveFile(List<NetscapeCookie> cookies) async {
await Directory(dir).create(recursive: true);
final part = File('$filePath.part$pid');
try {
// Private before anything is written into it.
await part.writeAsString('');
if (!Platform.isWindows) {
final r = await Process.run('chmod', ['600', part.path]);
if (r.exitCode != 0) throw FileSystemException('chmod failed', part.path);
}
await part.writeAsString(netscapeCookiesText(cookies), flush: true);
final target = File(filePath);
if (Platform.isWindows && await target.exists()) await target.delete();
await part.rename(filePath);
} finally {
if (await part.exists()) await part.delete().catchError((_) => part);
}
await _delete(_browserPath);
return CookieFile(filePath, cookies.length, cookiesBySite(cookies).keys.toList());
}
/// Converts the cookies.txt or JSON export at [source] into the private
/// copy, keeping only [sites] ([cookieSite]s; null: all).
Future<CookieFile> import(String source, {Set<String>? sites}) async {
final all = parseCookies(await File(source).readAsString());
final kept = sites == null ? all : [for (final c in all) if (sites.contains(cookieSite(c.host))) c];
if (kept.isEmpty) throw const FormatException('no cookies for the chosen sites');
return saveFile(kept);
}
/// Reads cookies from [browser] (one of [cookieBrowsers]) from now on,
/// replacing any file.
Future<void> saveBrowser(String browser, [String profile = '']) async {
if (!cookieBrowsers.contains(browser)) throw ArgumentError.value(browser, 'browser');
await Directory(dir).create(recursive: true);
await File(_browserPath).writeAsString(CookieBrowser(browser, profile.trim()).spec);
await _delete(filePath);
}
/// No cookies any more.
Future<void> remove() async {
await _delete(filePath);
await _delete(_browserPath);
}
static Future<void> _delete(String path) async {
final f = File(path);
if (await f.exists()) await f.delete();
}
}
+11 -3
View File
@@ -7,6 +7,7 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'cookies.dart';
import 'logging.dart';
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
@@ -284,6 +285,9 @@ class YtDlp {
Future<String?>? _ensuring;
Future<void>? _updating;
/// Cookies every resolve sends (see [YtDlpCookies]), kept in [dir].
late final cookies = YtDlpCookies(dir);
String get managedPath => p.join(dir, Platform.isWindows ? 'yt-dlp.exe' : 'yt-dlp');
String get _stampPath => p.join(dir, 'last-check');
@@ -482,12 +486,12 @@ class YtDlp {
}
}
/// Asks yt-dlp what [url] is. Throws [YtDlpException] with yt-dlp's own
/// reason on failure.
/// Asks yt-dlp what [url] is, with [cookies] if any. Throws
/// [YtDlpException] with yt-dlp's own reason on failure.
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async {
final exe = await ensure();
if (exe == null) throw YtDlpException(lastError ?? 'yt-dlp is not available');
final proc = await Process.start(exe, ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', url]);
final proc = await Process.start(exe, resolveArgs(url));
final out = proc.stdout.transform(utf8.decoder).join();
final err = proc.stderr.transform(utf8.decoder).join();
final int code;
@@ -507,6 +511,10 @@ class YtDlp {
}
}
/// The command line [resolve] runs.
List<String> resolveArgs(String url) =>
['-J', '--no-playlist', '--no-warnings', '--ignore-config', ...cookies.args(), '--', url];
Future<void> close() => _changes.close();
}
+55
View File
@@ -0,0 +1,55 @@
// yt-dlp cookies: the JSON export -> Netscape conversion, the per-site
// filter, and what yt-dlp and mpv are given. Synthetic cookies only.
library;
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
const _json = '''[
{"domain": ".bilibili.com", "name": "SESSDATA", "value": "s1", "path": "/", "expirationDate": 1893456000.5,
"hostOnly": false, "httpOnly": true, "secure": true, "session": false},
{"domain": "www.youtube.com", "name": "PREF", "value": "a=b", "hostOnly": true, "session": true},
{"domain": "accounts.google.com", "name": "SID", "value": "g", "expires": 1893456000},
{"domain": ".example.co.uk", "name": "x", "value": "y", "path": "/p"}
]''';
void main() {
test('JSON export converts to Netscape lines, grouped by site', () {
final cookies = parseCookies(_json);
expect(cookies.map((c) => c.toLine()), [
'#HttpOnly_.bilibili.com\tTRUE\t/\tTRUE\t1893456000\tSESSDATA\ts1',
'www.youtube.com\tFALSE\t/\tFALSE\t0\tPREF\ta=b',
'.accounts.google.com\tTRUE\t/\tFALSE\t1893456000\tSID\tg',
'.example.co.uk\tTRUE\t/p\tFALSE\t0\tx\ty',
]);
expect(cookiesBySite(cookies).keys, ['bilibili.com', 'example.co.uk', 'youtube.com']);
// Netscape text reads back the same.
final again = parseCookies(netscapeCookiesText(cookies));
expect(again.map((c) => c.toLine()), cookies.map((c) => c.toLine()));
});
test('kept sites become a private file yt-dlp and mpv are pointed at', () async {
final dir = await Directory.systemTemp.createTemp('cookies_test');
addTearDown(() => dir.delete(recursive: true));
final src = File('${dir.path}/export.json')..writeAsStringSync(_json);
final y = YtDlp('${dir.path}/yt-dlp, a=b');
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', 'U']);
final kept = await y.cookies.import(src.path, sites: {'bilibili.com'});
expect([kept.count, ...kept.sites], [1, 'bilibili.com']);
final path = y.cookies.filePath;
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--cookies', path, '--', 'U']);
expect(y.cookies.mpvRawOptions(), 'cookies=%${path.length}%$path');
if (!Platform.isWindows) expect(File(path).statSync().modeString(), 'rw-------');
await y.cookies.saveBrowser('firefox', 'work');
expect(y.resolveArgs('U').sublist(4, 6), ['--cookies-from-browser', 'firefox:work']);
expect(File(path).existsSync(), isFalse);
expect(y.cookies.mpvRawOptions(), 'cookies-from-browser=%12%firefox:work');
await y.cookies.remove();
expect(y.cookies.read(), isNull);
});
}
+27
View File
@@ -25,6 +25,10 @@ Future<void> main(List<String> args) async {
'downloaded on first use.')
..addOption('yt-dlp-dir',
defaultsTo: '', help: 'Writable folder for that yt-dlp (default: yt-dlp next to this binary).')
..addOption('yt-dlp-cookies',
defaultsTo: '',
help: 'cookies.txt or browser-extension JSON export to log that yt-dlp in (sites that refuse guests). '
'An account login: keep it private. Copied into --yt-dlp-dir at start.')
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
final ArgResults parsed;
@@ -60,6 +64,7 @@ Future<void> main(List<String> args) async {
advertiseHost: '${parsed['advertise-host']}',
ytDlp: parsed['yt-dlp'] as bool,
ytDlpDir: '${parsed['yt-dlp-dir']}',
ytDlpCookies: '${parsed['yt-dlp-cookies']}',
);
}
final jellyfinPath = parsed['jellyfin'] as String?;
@@ -76,8 +81,10 @@ Future<void> main(List<String> args) async {
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
ytDlp: c.ytDlp,
ytDlpDir: c.ytDlpDir,
ytDlpCookies: c.ytDlpCookies,
);
}
await _setUpCookies(config);
} on ConfigException catch (e) {
stderr.writeln(e.message);
exitCode = 64;
@@ -116,3 +123,23 @@ Future<void> main(List<String> args) async {
await s.cancel();
}
}
/// The private cookie copy in the server's yt-dlp folder: converted from
/// [AppConfig.ytDlpCookies], or removed when none is given (so dropping the
/// flag logs yt-dlp out).
Future<void> _setUpCookies(AppConfig config) async {
if (!config.ytDlp) return;
final cookies = YtDlpCookies(serverYtDlpDir(config.ytDlpDir));
try {
if (config.ytDlpCookies.isEmpty) {
await cookies.remove();
return;
}
final kept = await cookies.import(config.ytDlpCookies);
stdout.writeln('yt-dlp cookies: ${kept.count} for ${kept.sites.join(', ')}');
} on FormatException catch (e) {
throw ConfigException('--yt-dlp-cookies ${config.ytDlpCookies}: ${e.message}');
} on FileSystemException catch (e) {
throw ConfigException('--yt-dlp-cookies: ${e.message} (${e.path})');
}
}