forked from Foxyz/sync_replay_project
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:
1 parent
8970f2694b
commit
25b19f2d6a
47 files changed
+4083
-1728
No files matched your search
@@ -2,8 +2,10 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:starsync/src/playback/playback_controller.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:starsync/src/platform/desktop/media_kit_host.dart';
|
||||
import 'package:starsync/src/playback/yt_dlp_app.dart';
|
||||
import 'package:starsync/src/session/media_pipeline.dart';
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
const _referer = 'https://www.bilibili.com/';
|
||||
@@ -22,7 +24,8 @@ Future<(String, String)> _media() async {
|
||||
return (v, a);
|
||||
}
|
||||
|
||||
/// A CDN like Bilibili's: 403 without the page's Referer.
|
||||
/// A CDN like Bilibili's: 403 without the page's Referer. `/video/...` is
|
||||
/// the page itself (HTML: the app's probe sends it to yt-dlp).
|
||||
class _Cdn {
|
||||
_Cdn(this._server, this._files) {
|
||||
_server.listen(_serve);
|
||||
@@ -30,11 +33,27 @@ class _Cdn {
|
||||
final HttpServer _server;
|
||||
final Map<String, String> _files;
|
||||
int ok = 0, forbidden = 0;
|
||||
final cookies = <String?>[];
|
||||
|
||||
String url(String name) => 'http://127.0.0.1:${_server.port}/$name';
|
||||
|
||||
String page(String id) => 'http://127.0.0.1:${_server.port}/video/$id';
|
||||
int subtitles = 0;
|
||||
|
||||
Future<void> _serve(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
if (req.uri.pathSegments.firstOrNull == 'video') {
|
||||
res.headers.contentType = ContentType.html;
|
||||
res.write('<html><title>Fake page</title></html>');
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
if (req.uri.pathSegments.lastOrNull == 'en.vtt') {
|
||||
subtitles++;
|
||||
res.write('WEBVTT\n\n00:00.000 --> 00:05.000\nHello\n');
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
final file = _files[req.uri.pathSegments.lastOrNull];
|
||||
if (file == null || req.headers.value('referer') != _referer) {
|
||||
forbidden++;
|
||||
@@ -43,6 +62,7 @@ class _Cdn {
|
||||
return;
|
||||
}
|
||||
ok++;
|
||||
cookies.add(req.headers.value('cookie'));
|
||||
final bytes = await File(file).readAsBytes();
|
||||
var start = 0, end = bytes.length - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
@@ -59,25 +79,37 @@ class _Cdn {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in yt-dlp printing what yt-dlp gives for a split-track page:
|
||||
/// `requested_formats` with per-format `http_headers`, none at the top.
|
||||
Future<String> _fakeYtDlp(_Cdn cdn, {required bool headers}) async {
|
||||
final dir = await Directory('$_tmp/ytdl_headers_fake').create(recursive: true);
|
||||
/// A stand-in yt-dlp (the managed copy in its own folder) printing what
|
||||
/// yt-dlp gives for a split-track page: `requested_formats` with
|
||||
/// per-format `http_headers` and `cookies`, subtitles with Bilibili's
|
||||
/// danmaku among them. [fail]: it finds no formats instead.
|
||||
Future<AppYtDlp> _fakeYtDlp(_Cdn cdn, {required bool headers, bool fail = false}) async {
|
||||
final dir = Directory('$_tmp/ytdl_direct_${headers ? 'h' : 'n'}${fail ? 'f' : ''}');
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
dir.createSync(recursive: true);
|
||||
final h = headers ? {'Referer': _referer, 'User-Agent': 'fake-agent'} : <String, String>{};
|
||||
final json = jsonEncode({
|
||||
'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake',
|
||||
'format_id': 'v+a', 'ext': 'mp4',
|
||||
'requested_formats': [
|
||||
{'format_id': 'v', 'url': cdn.url('v.m4s'), 'protocol': 'https', 'ext': 'mp4',
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h},
|
||||
'vcodec': 'avc1.64001F', 'acodec': 'none', 'width': 320, 'height': 180, 'http_headers': h,
|
||||
'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'},
|
||||
{'format_id': 'a', 'url': cdn.url('a.m4s'), 'protocol': 'https', 'ext': 'm4a',
|
||||
'vcodec': 'none', 'acodec': 'mp4a.40.2', 'http_headers': h},
|
||||
],
|
||||
'requested_subtitles': {
|
||||
'en': {'url': cdn.url('en.vtt'), 'ext': 'vtt', 'name': 'English'},
|
||||
'danmaku': {'url': cdn.url('danmaku.xml'), 'ext': 'xml'},
|
||||
},
|
||||
});
|
||||
final f = File('${dir.path}/yt-dlp-${headers ? 'h' : 'n'}');
|
||||
await f.writeAsString("#!/bin/sh\ncat <<'EOF'\n$json\nEOF\n");
|
||||
final f = File('${dir.path}/yt-dlp');
|
||||
await f.writeAsString('#!/bin/sh\n'
|
||||
'case "\$1" in --version) echo 2099.01.01; exit 0;; esac\n'
|
||||
'${fail ? 'echo "ERROR: [fake] BV1: No video formats found!" >&2; exit 1\n' : "cat <<'EOF'\n$json\nEOF\n"}');
|
||||
await Process.run('chmod', ['+x', f.path]);
|
||||
return f.path;
|
||||
await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String());
|
||||
return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -85,32 +117,39 @@ void main() {
|
||||
late HttpServer server;
|
||||
|
||||
setUpAll(() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final (v, a) = await _media();
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
cdn = _Cdn(server, {'v.m4s': v, 'a.m4s': a});
|
||||
});
|
||||
tearDownAll(() => server.close(force: true));
|
||||
|
||||
// A page "everyone fetches" opens through the app's own resolve: its
|
||||
// stream URLs (an EDL of video + audio) with the CDN's headers; mpv's
|
||||
// yt-dlp hook is off.
|
||||
test(
|
||||
"mpv's yt-dlp hook sends each format's Referer to the CDN",
|
||||
"a page plays from yt-dlp's stream URLs with each CDN's Referer and the cookies; subtitles but danmaku",
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
final host = MediaKitHost()..init();
|
||||
final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true));
|
||||
final p = m.playback;
|
||||
try {
|
||||
await p.init();
|
||||
expect(p.pinMismatches, isEmpty, reason: 'stream-lavf-o and the rest took effect');
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: true));
|
||||
await p.openUrl('https://www.bilibili.com/video/BV1fake');
|
||||
await m.start();
|
||||
expect(host.pinMismatches, isEmpty, reason: 'stream-lavf-o, ytdl=no and the rest took effect');
|
||||
expect(await p.player.getProperty('ytdl'), 'no');
|
||||
expect(await m.open(cdn.page('BV1fake')), isTrue);
|
||||
final end = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (DateTime.now().isBefore(end) && p.duration.peek() == Duration.zero) {
|
||||
while (DateTime.now().isBefore(end) && (p.duration.peek() == Duration.zero || cdn.subtitles == 0)) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(p.duration.peek(), greaterThan(Duration.zero), reason: 'both tracks opened');
|
||||
expect(cdn.forbidden, 0, reason: 'every request had the Referer');
|
||||
expect(cdn.ok, greaterThanOrEqualTo(2));
|
||||
expect(cdn.cookies, everyElement('SESSDATA=abc'));
|
||||
expect(cdn.subtitles, 1, reason: 'the page\'s subtitles are added, danmaku is not');
|
||||
await _until(() => p.tracks.peek().any((t) => t.isExternal), 'the subtitle track listed');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await m.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
@@ -120,29 +159,54 @@ void main() {
|
||||
'a 403 is a load error, not a reconnect loop',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
final p = PlaybackController();
|
||||
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false));
|
||||
final p = m.playback;
|
||||
final errors = <String>[];
|
||||
p.onLoadError = errors.add;
|
||||
try {
|
||||
await p.init();
|
||||
await m.start();
|
||||
var logs = 0;
|
||||
final sub = p.player.stream.log.listen((_) => logs++);
|
||||
await p.useYtDlp(await _fakeYtDlp(cdn, headers: false));
|
||||
final sub = p.player.logs.listen((_) => logs++);
|
||||
final before = cdn.forbidden;
|
||||
await p.openUrl('https://www.bilibili.com/video/BV2fake');
|
||||
await m.open(cdn.page('BV2fake'));
|
||||
await Future.delayed(const Duration(seconds: 8));
|
||||
await sub.cancel();
|
||||
// ignore: avoid_print
|
||||
print('403s: ${cdn.forbidden - before}, log lines: $logs, errors: $errors');
|
||||
expect(cdn.forbidden - before, lessThan(10), reason: 'no retry loop on 403');
|
||||
expect(errors, hasLength(1));
|
||||
// The status only when ffmpeg logs through this mpv (see _onLog).
|
||||
// The status only when ffmpeg logs through this mpv (see the host).
|
||||
expect(errors.single, anyOf(contains('403'), contains('could not be opened')));
|
||||
expect(logs, lessThan(30), reason: 'warnings and errors only');
|
||||
} finally {
|
||||
await p.dispose();
|
||||
await m.dispose();
|
||||
}
|
||||
},
|
||||
skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script',
|
||||
);
|
||||
|
||||
// D11: yt-dlp finds no video; the error names the cookies it used.
|
||||
test('a page yt-dlp finds no video on: the error says which cookies were used', () async {
|
||||
final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true, fail: true));
|
||||
final got = <(String, String?)>[];
|
||||
m.onError = (raw, {cookies}) => got.add((raw, cookies));
|
||||
try {
|
||||
await m.start();
|
||||
expect(await m.open(cdn.page('BV3fake')), isFalse);
|
||||
expect(got.single.$1, contains('No video formats found'));
|
||||
expect(got.single.$2, 'no cookies were used');
|
||||
expect(withCookieNote(playErrorText(got.single.$1), got.single.$2),
|
||||
'No video found: it may need signing in (no cookies were used)');
|
||||
} finally {
|
||||
await m.dispose();
|
||||
}
|
||||
}, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script');
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() cond, String what) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 10));
|
||||
while (!cond()) {
|
||||
if (DateTime.now().isAfter(end)) fail('timed out waiting for $what');
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user