import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.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/'; /// Separate video-only and audio-only files, like Bilibili's DASH .m4s. Future<(String, String)> _media() async { final v = '$_tmp/ytdl_headers_v.mp4', a = '$_tmp/ytdl_headers_a.m4a'; for (final (path, args) in [ (v, ['-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24', '-c:v', 'libx264', '-preset', 'ultrafast']), (a, ['-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', '-c:a', 'aac']), ]) { if (await File(path).exists()) continue; final r = await Process.run('ffmpeg', ['-y', '-v', 'error', ...args, '-t', '30', path]); if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}'); } return (v, a); } /// 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); } final HttpServer _server; final Map _files; int ok = 0, forbidden = 0; final cookies = []; final agents = []; /// Like Bilibili cutting a long read: a response stops after this many /// bytes (the connection dropped), and the next request after a cut is /// refused with 412 (where ffmpeg's reconnect gives up). Null: whole. int? cutAfter; bool _cut = false; int cuts = 0, refused = 0; 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 _serve(HttpRequest req) async { final res = req.response; if (req.uri.pathSegments.firstOrNull == 'video') { res.headers.contentType = ContentType.html; res.write('Fake page'); 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++; res.statusCode = HttpStatus.forbidden; await res.close(); return; } if (_cut) { _cut = false; refused++; res.statusCode = HttpStatus.preconditionFailed; await res.close(); return; } ok++; cookies.add(req.headers.value('cookie')); agents.add(req.headers.value('user-agent')); 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') ?? ''); res.headers.set('accept-ranges', 'bytes'); res.headers.contentType = ContentType('video', 'mp4'); if (range != null) { start = int.tryParse(range[1]!) ?? 0; end = int.tryParse(range[2]!) ?? end; res.statusCode = HttpStatus.partialContent; res.headers.set('content-range', 'bytes $start-$end/${bytes.length}'); } res.contentLength = end - start + 1; final cut = cutAfter; if (cut != null && end - start + 1 > cut) { // Headers by hand on the raw socket, part of the body, then dropped. final socket = await res.detachSocket(writeHeaders: false); final status = range == null ? '200 OK' : '206 Partial Content'; socket.add(utf8.encode('HTTP/1.1 $status\r\naccept-ranges: bytes\r\ncontent-type: video/mp4\r\n' '${range == null ? '' : 'content-range: bytes $start-$end/${bytes.length}\r\n'}' 'content-length: ${end - start + 1}\r\nconnection: close\r\n\r\n')); socket.add(bytes.sublist(start, start + cut)); _cut = true; cuts++; try { await socket.flush(); } catch (_) {} socket.destroy(); return; } res.add(bytes.sublist(start, end + 1)); await res.close(); } } /// 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 _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'} : {}; final 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, '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, 'cookies': 'SESSDATA=abc; Domain=.127.0.0.1; Path=/'}, ]; final json = jsonEncode({ 'id': 'BV1', 'title': 'Fake page', 'duration': 30, 'extractor': 'fake', 'format_id': 'v+a', 'ext': 'mp4', 'formats': formats, 'requested_formats': formats, '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'); 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]); await File('${dir.path}/last-check').writeAsString(DateTime.now().toIso8601String()); return AppYtDlp(YtDlp(dir.path, updateEvery: const Duration(days: 9999))); } void main() { late _Cdn cdn; late HttpServer server; setUpAll(() async { 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 (video + audio) read by the app's own reader // (SharedLinks.local) with the CDN's headers and cookies, mpv on its // loopback EDL; mpv's yt-dlp hook is off. test( "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 host = MediaKitHost()..init(); final m = MediaPipeline(host: host, links: await _fakeYtDlp(cdn, headers: true)); final p = m.playback; try { 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 || 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(m.shared.hasLocal(cdn.page('BV1fake')), isTrue); expect(cdn.agents, everyElement(startsWith('fake-agent')), reason: 'the reader sends the format\'s headers'); 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 m.dispose(); } }, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script', ); test( 'a 403 is a load error, not a reconnect loop', timeout: const Timeout(Duration(minutes: 1)), () async { final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: false)); final p = m.playback; final errors = []; p.onLoadError = errors.add; try { await m.start(); var logs = 0; final sub = p.player.logs.listen((_) => logs++); final before = cdn.forbidden; 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 the host). expect(errors.single, anyOf(contains('403'), contains('could not be opened'))); expect(logs, lessThan(30), reason: 'warnings and errors only'); } finally { await m.dispose(); } }, skip: Platform.isLinux ? false : 'fake yt-dlp is a shell script', ); // Bilibili cuts a long read and refuses the reconnect right after it // (412): ffmpeg's own reconnect ends playback there ("Stream ends // prematurely", "Operation not permitted"); the reader resumes after a // back-off and the player never sees the cut. test( 'a CDN that cuts reads and refuses the reconnect (412): played to the end without an error', timeout: const Timeout(Duration(minutes: 1)), () async { final m = MediaPipeline(host: MediaKitHost()..init(), links: await _fakeYtDlp(cdn, headers: true)); final p = m.playback; final errors = []; p.onLoadError = errors.add; m.onError = (raw, {cookies}) => errors.add(raw); cdn.cutAfter = 48 * 1024; final cutsBefore = cdn.cuts; try { await m.start(); final page = cdn.page('BV4fake'); expect(await m.open(page), isTrue); await _until(() => p.duration.peek() > Duration.zero, 'opened', secs: 20); await p.player.setProperty('speed', '8'); await p.player.play(); await _until(() => p.positionSecs > 25 || p.player.state.completed, 'played to the end', secs: 30); // ignore: avoid_print print('cuts: ${cdn.cuts - cutsBefore}, refused: ${cdn.refused}, errors: $errors'); expect(cdn.cuts - cutsBefore, greaterThan(2), reason: 'the CDN did cut'); expect(cdn.refused, greaterThan(0)); expect(errors, isEmpty); expect(m.shared.hasLocal(page), isTrue); } finally { cdn.cutAfter = null; 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 _until(bool Function() cond, String what, {int secs = 10}) async { final end = DateTime.now().add(Duration(seconds: secs)); while (!cond()) { if (DateTime.now().isAfter(end)) fail('timed out waiting for $what'); await Future.delayed(const Duration(milliseconds: 100)); } }