Restructure step 5: one media pipeline on a player host

- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy,
  render probe, log routing, disposal order; the only media_kit user.
- MediaPipeline: plan (one pure switch over state, origin, caps,
  presence) -> resolve -> open (prepared player promoted, else fresh)
  -> ready; commands queued during a switch apply once (P15); viewers
  play the buffer and wait for a dropped source without seek loops (D6).
- Every web page resolved by our yt-dlp into an EDL with headers and
  cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no).
- D11: sign-in errors say which cookies were used and whether they hold
  any for the site.
- Relay: 256 KiB slices read as they fill, superseded pulls released
  (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only
  when someone needs it (P28).
- STARSYNC_SMOKE run and a Windows CI smoke step after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-16 01:08:54 +03:00
1 parent 8970f2694b
commit 25b19f2d6a
47 files changed
+4083 -1728

No files matched your search

+73 -6
View File
@@ -30,26 +30,93 @@ void main() {
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 {
test('kept sites become a private file yt-dlp is 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']);
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--', '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');
expect(y.resolveArgs('U'), ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '-f', playFormat, '--write-subs', '--sub-langs', playSubLangs, '--cookies', path, '--', 'U']);
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(y.resolveArgs('U').sublist(9, 11), ['--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);
});
// D11: a sign-in error says which cookies were used and what they hold
// for the site.
group('cookie notes', () {
test('the texts', () {
expect(cookieNoteText(null, 'bilibili.com', -1), 'no cookies were used');
const file = CookieFile('/x', 3, ['youtube.com']);
expect(cookieNoteText(file, 'bilibili.com', 0), 'used the cookies file, which has none for bilibili.com');
expect(cookieNoteText(file, 'bilibili.com', 4),
'used the cookies file (4 for bilibili.com: signed out or expired?)');
const ff = CookieBrowser('firefox', 'work');
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 0, error: null)),
"used Firefox (work)'s cookies, which have none for bilibili.com: sign in to bilibili.com there");
expect(cookieNoteText(ff, 'bilibili.com', -1, read: (count: 12, error: null)),
"used Firefox (work)'s cookies (12 for bilibili.com: that account may not see it)");
expect(cookieNoteText(const CookieBrowser('chrome'), 'x.com', -1, read: (count: -1, error: 'locked')),
"used Chrome's cookies, but yt-dlp couldn't read them: locked");
expect(withCookieNote(noFormatsText, 'no cookies were used'),
'No video found: it may need signing in (no cookies were used)');
expect(withCookieNote(needsCookiesText, 'x'), 'This site needs cookies (x) — Settings → Player → yt-dlp → Cookies');
expect(withCookieNote(notFoundText, 'x'), notFoundText);
expect(mayNeedSignIn('No video formats found!'), isTrue);
expect(mayNeedSignIn('HTTP Error 404'), isFalse);
});
test("a failed resolve names the file's cookies for the site; a browser's are counted by yt-dlp",
() async {
final dir = await Directory.systemTemp.createTemp('cookie_note');
addTearDown(() => dir.delete(recursive: true));
// A stand-in yt-dlp: a version, a resolve that finds no formats, and
// the browser read (no URL) that writes the jar it is given.
final fake = File('${dir.path}/yt-dlp');
await fake.writeAsString(r'''#!/bin/sh
case "$1" in --version) echo 2099.01.01; exit 0;; esac
jar=""; prev=""
for a in "$@"; do [ "$prev" = "--cookies" ] && jar="$a"; prev="$a"; done
case "$*" in *-J*) echo "ERROR: [BiliBili] BV1: No video formats found!" >&2; exit 1;; esac
printf '# Netscape HTTP Cookie File\n.bilibili.com\tTRUE\t/\tTRUE\t2000000000\tSESSDATA\tx\n.bilibili.com\tTRUE\t/\tFALSE\t2000000000\tbuvid3\ty\n.other.org\tTRUE\t/\tFALSE\t0\ta\tb\n' > "$jar"
echo "Extracted 3 cookies from firefox" >&2
echo "yt-dlp: error: You must provide at least one URL." >&2; exit 2
''');
await Process.run('chmod', ['+x', fake.path]);
final y = YtDlp(dir.path, updateEvery: const Duration(days: 9999));
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
const page = 'https://www.bilibili.com/video/BV1';
Future<YtDlpException> fail() async {
try {
await y.resolve(page);
} on YtDlpException catch (e) {
return e;
}
throw StateError('resolved');
}
expect((await fail()).cookies, 'no cookies were used');
await y.cookies.saveFile(parseCookies('.youtube.com\tTRUE\t/\tFALSE\t0\tSID\tv\n'));
expect((await fail()).cookies, 'used the cookies file, which has none for bilibili.com');
await y.cookies.saveBrowser('firefox');
final e = await fail();
expect(e.message, contains('No video formats found'));
expect(e.cookies, "used Firefox's cookies (2 for bilibili.com: that account may not see it)");
expect(dir.listSync().map((f) => f.path).where((p) => p.contains('cookie-check')), isEmpty,
reason: 'the browser jar is deleted at once');
// The probe carries it into the plain words.
expect(LinkProbe.page(Uri.parse(page), ytDlpError: e.message, cookies: e.cookies).problem,
"No video found: it may need signing in (used Firefox's cookies (2 for bilibili.com: that account may not see it))");
}, skip: Platform.isWindows ? 'the stand-in yt-dlp is a shell script' : false);
});
}
+42
View File
@@ -261,4 +261,46 @@ void main() {
expect(pick(['avc1.640033', 'vp09.00.40.08', 'av01.0.08M.08']), 'v2');
expect(pick(['avc1.640033', 'vp09.00.40.08']), 'v1');
});
test('what a player opens: yt-dlp\'s pick, an EDL for video + audio, headers with the cookies, subtitles but danmaku', () {
final split = YtDlpInfo.parse({
'title': 'Page',
'formats': <Object>[],
'requested_formats': [
{
'format_id': 'v', 'url': 'https://cdn/v.m4s', 'protocol': 'https',
'http_headers': {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA'},
'cookies': 'SESSDATA=abc; Domain=.bilibili.com; Path=/; Secure; Expires=1999999999; buvid3=xyz; Domain=.bilibili.com',
},
{'format_id': 'a', 'url': 'https://cdn/a.m4s', 'protocol': 'https', 'http_headers': {'Referer': 'other'}},
],
'requested_subtitles': {
'zh-CN': {'url': 'https://subs/zh.vtt', 'ext': 'vtt', 'name': 'Chinese'},
'danmaku': {'url': 'https://comment/1.xml', 'ext': 'xml'},
},
}).play!;
expect(split.urls, ['https://cdn/v.m4s', 'https://cdn/a.m4s']);
expect(split.mpvUrl, mpvEdl(['https://cdn/v.m4s', 'https://cdn/a.m4s']));
expect(split.headers, {'Referer': 'https://www.bilibili.com/', 'User-Agent': 'UA', 'Cookie': 'SESSDATA=abc; buvid3=xyz'});
expect([for (final s in split.subtitles) '${s.lang} ${s.name} ${s.url}'], ['zh-CN Chinese https://subs/zh.vtt']);
final single = YtDlpInfo.parse({
'title': 'Tweet', 'url': 'https://video.twimg.com/v.mp4', 'protocol': 'https', 'ext': 'mp4',
'http_headers': {'User-Agent': 'UA'},
}).play!;
expect(single.mpvUrl, 'https://video.twimg.com/v.mp4');
expect(single.headers, {'User-Agent': 'UA'});
final hls = YtDlpInfo.parse({
'title': 'Live', 'is_live': true,
'requested_formats': [{'url': 'https://x/live.m3u8', 'protocol': 'm3u8_native'}],
}).play!;
expect(hls.mpvUrl, 'https://x/live.m3u8', reason: 'a live stream plays, it is only not relayed');
expect(YtDlpInfo.parse({'title': 'DASH', 'requested_formats': [{'url': 'https://x/f', 'protocol': 'http_dash_segments', 'manifest_url': 'https://x/m.mpd'}]}).play?.mpvUrl,
'https://x/m.mpd');
expect(YtDlpInfo.parse({'title': 'none', 'formats': <Object>[]}).play, isNull);
expect(YtDlpInfo.parse({'title': 'rtmp', 'url': 'rtmp://x/y', 'protocol': 'rtmp'}).play, isNull);
expect(cookieHeaderOf(null), '');
expect(cookieHeaderOf('a=1; Path=/; HttpOnly; b=2=3'), 'a=1; b=2=3');
});
}