yt-dlp for page links in every Link mode; app icon and Linux launcher entry

yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and
the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are
probed: direct files stay links, pages are resolved for title, uploader,
duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the
managed copy. I pass it on / the server fetches it: the best single-file
format, re-resolved once when its URL expires. Relay names never split
the /r/ path; viewers of server-fetched pages wait for the relay to go
live. No wire change.

Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the
Windows .ico). GTK app id moe.senkochan.watch_together; release builds
install their .desktop file and icons into ~/.local/share so Wayland
shows the icon; the window icon is set for X11. README shows the icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 18:21:22 +03:00
1 parent 9ddd85de90
commit 745717a4a0
40 files changed
+1444 -123

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
// yt-dlp output parsing, the single-file pick and the link classifier.
// The live test (real download from GitHub, a real tweet) runs only with
// YTDLP_LIVE=1.
library;
import 'dart:ffi' show Abi;
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
Map<String, Object?> _f(String id, String protocol, Object? vcodec, Object? acodec, int? height,
{String ext = 'mp4', num tbr = 0}) =>
{
'format_id': id, 'protocol': protocol, 'vcodec': vcodec, 'acodec': acodec, 'height': height, //
'ext': ext, 'tbr': tbr, 'url': 'https://cdn.example/$id', 'http_headers': {'User-Agent': 'UA'},
};
void main() {
group('single-file pick', () {
test('a tweet: the tallest progressive mp4 (codecs unknown), not the split HLS', () {
final f = pickSingleFile([
_f('hls-audio', 'm3u8_native', 'none', null, null),
_f('http-256', 'https', null, null, 270, tbr: 256),
_f('http-2176', 'https', null, null, 598, tbr: 2176),
_f('http-832', 'https', null, null, 360, tbr: 832),
_f('hls-430', 'm3u8_native', 'avc1', 'none', 598),
])!;
expect((f.id, f.height), ('http-2176', 598));
expect(f.headers, {'User-Agent': 'UA'});
});
test('split or HLS only: none', () {
expect(
pickSingleFile([
_f('140', 'https', 'none', 'mp4a', null, ext: 'm4a'),
_f('137', 'https', 'avc1', 'none', 1080),
_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080),
]),
isNull);
});
test('up to 1080p, else the shortest above; mp4/webm win ties', () {
expect(
pickSingleFile([
_f('4k', 'https', 'vp9', 'opus', 2160, ext: 'webm'),
_f('720', 'https', 'avc1', 'mp4a', 720),
_f('1080mkv', 'https', 'avc1', 'mp4a', 1080, ext: 'mkv'),
_f('1080', 'https', 'avc1', 'mp4a', 1080),
])!.id,
'1080');
expect(
pickSingleFile([
_f('4k', 'https', 'vp9', 'opus', 2160, ext: 'webm'),
_f('1440', 'https', 'vp9', 'opus', 1440, ext: 'webm'),
])!.id,
'1440');
});
test('info: metadata, a playlist answer uses its first entry', () {
final info = YtDlpInfo.parse({
'_type': 'playlist',
'entries': [
{
'title': 'a girl. - https://t.co/x', 'uploader': 'a girl.', 'duration': 6.3, //
'thumbnail': 'https://pbs/x.jpg', 'extractor_key': 'Twitter',
'formats': [_f('http-2176', 'https', null, null, 598)],
},
],
});
expect((info.title, info.uploader, info.duration, info.thumbnail, info.extractor, info.single?.id),
('a girl. - https://t.co/x', 'a girl.', 6.3, 'https://pbs/x.jpg', 'Twitter', 'http-2176'));
});
});
test('link classifier: media types and media file names are files; pages, HLS, errors are not', () {
bool file(int status, String type, String url) =>
looksLikeMediaFile(status: status, contentType: type, url: Uri.parse(url));
expect(file(206, 'video/mp4', 'https://a/watch'), isTrue);
expect(file(200, 'audio/mpeg', 'https://a/x'), isTrue);
expect(file(206, 'application/octet-stream', 'https://a/dir/ep%201.MKV?sig=1'), isTrue);
expect(file(206, 'application/octet-stream', 'https://a/download'), isFalse);
expect(file(200, 'text/html', 'https://x.com/u/status/1/video/1'), isFalse);
expect(file(200, 'text/html', 'https://a/fake.mp4'), isFalse);
expect(file(200, 'application/vnd.apple.mpegurl', 'https://a/index.m3u8'), isFalse);
expect(file(403, 'video/mp4', 'https://a/x.mp4'), isFalse);
});
test('a page title in a /r/ URL stays one path segment', () {
expect(relayUrlName('a girl. - https://t.co/x'), 'a%20girl.%20-%20https%3A__t.co_x');
});
test('page title: og:title first (any attribute order), else <title>, entities decoded', () {
expect(htmlTitle('<title>Plain</title><meta content="Tom &amp; Jerry &#39;24" property="og:title">'),
"Tom & Jerry '24");
expect(htmlTitle("<head><TITLE>\n A &quot;page&quot;\n</TITLE></head>"), 'A "page"');
expect(htmlTitle('<p>nothing</p>'), isNull);
});
test('release files: checksums, asset per platform, error text', () {
final sums = parseSha256Sums('${'a' * 64} yt-dlp\n${'B' * 64} yt-dlp.exe\ngarbage\n');
expect(sums, {'yt-dlp': 'a' * 64, 'yt-dlp.exe': 'b' * 64});
expect(ytDlpAssetName(Abi.linuxX64), 'yt-dlp_linux');
expect(ytDlpAssetName(Abi.linuxArm64, musl: true), 'yt-dlp_musllinux_aarch64');
expect(ytDlpAssetName(Abi.windowsX64), 'yt-dlp.exe');
expect(ytDlpAssetName(Abi.androidArm), isNull);
expect(ytDlpErrorText('WARNING: x\nERROR: [twitter] 123: No video could be found in this tweet\n'),
'No video could be found in this tweet');
expect(ytDlpErrorText('ERROR: Unsupported URL: https://example.com/'), 'Unsupported URL: https://example.com/');
});
test('live: download yt-dlp, resolve a tweet, relay its single file', () async {
final dir = await Directory.systemTemp.createTemp('ytdlp_live');
addTearDown(() => dir.delete(recursive: true));
final y = YtDlp(dir.path);
expect(await y.ensure(), y.managedPath, reason: '${y.lastError}');
expect(y.version, isNotNull);
final url = Uri.parse('https://x.com/velcrotea/status/2108421180701188522/video/1');
final probe = await probeLink(url, ytDlp: y);
expect(probe.isPage, isTrue);
expect(probe.title, startsWith('a girl.'));
expect(probe.relayProblem, isNull);
final bytes = LinkRelayBytes(url, ytDlp: y, probe: probe);
final size = await bytes.size();
var n = 0;
await for (final c in bytes.read(0, size - 1)) {
n += c.length;
}
await bytes.close();
expect(n, size);
}, timeout: const Timeout(Duration(minutes: 3)), skip: Platform.environment['YTDLP_LIVE'] == '1' ? false : 'YTDLP_LIVE not set');
}