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

+20 -6
View File
@@ -1,4 +1,8 @@
# watch-together
<p align="center">
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="watch-together icon">
</p>
<h1 align="center">watch-together</h1>
Watch a video with friends, in sync, with the quality of a real mpv player.
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
@@ -22,7 +26,8 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
`watch_together`. On start it adds itself to your app launcher (and its
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome.
@@ -103,8 +108,10 @@ folder to Files, or fill Link).
details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode
to choose it). Double-click a card or an episode to put it up at once.
- **Link**: paste an http(s) link. The preview shows what it is (title and
thumbnail for YouTube, else the file name); in a server, pick how the
- **Link**: paste an http(s) link: a video file, or a web page with a
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
what it is (a page's title, uploader, length and thumbnail, else the
file name); in a server, pick how the
others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it.
- **Files**: video folders you add on this computer, grouped by series
@@ -130,7 +137,9 @@ then everyone (you included) uses Join.
| `--name` | `server` | name reported by `/health` |
| `--media` | none | local file to serve as the starting video |
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
| `--[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 |
It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
@@ -291,7 +300,12 @@ the others get it:
- Through me: your computer downloads it and passes it on, for links only
you can open.
The last two need a link to a video file (not a page like YouTube) and
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.
The last two need a page with a single-file video (X has one; YouTube only
has separate video and audio streams, so use "Everyone fetches it") and
never show the link to anyone. Your upload has to beat the video's
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+7
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -7,6 +8,8 @@ import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'src/playback/yt_dlp_app.dart';
import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart';
import 'src/state/session_state.dart';
import 'src/state/settings.dart';
@@ -60,6 +63,10 @@ Future<void> main(List<String> args) async {
debugPrint('load connect form failed: $e\n$st');
}
MediaKit.ensureInitialized();
// yt-dlp for web page links: downloaded on first run, kept current.
AppYtDlp.instance.warmUp();
// Linux: the launcher entry and icon Wayland needs (release installs).
unawaited(installDesktopEntry());
runApp(const WatchTogetherApp());
}
@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../update/updater.dart' show findInstallRoot;
/// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon.
const appId = 'moe.senkochan.watch_together';
const desktopFileName = '$appId.desktop';
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
/// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new
/// icon). Never in debug runs; failures only log.
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
if (!Platform.isLinux || kDebugMode) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false);
if (root == null) return;
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon';
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue;
await dst.parent.create(recursive: true);
await src.copy(dst.path);
}
final entry = desktopEntryText(exe);
final file = File('$share/applications/$desktopFileName');
if (file.existsSync() && file.readAsStringSync() == entry) return;
await file.parent.create(recursive: true);
await file.writeAsString(entry);
} catch (e) {
debugPrint('desktop entry: $e');
}
}
@visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
Name=Watch together
Comment=Watch videos in sync with friends
Exec="$exe"
Icon=$appId
StartupWMClass=$appId
Categories=AudioVideo;Video;Player;Network;
Terminal=false
''';
@@ -16,6 +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;
/// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by
@@ -60,6 +61,26 @@ class PlaybackController {
/// Called once when the watchdog sees a frozen picture.
void Function()? onFrozen;
/// mpv's yt-dlp hook could not open the current link (its error text).
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.
Future<void> useYtDlp(String path) async {
final p = _player;
if (p == null || identical(p, _ytdlSetOn)) 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;
} catch (e) {
debugPrint('yt-dlp path not set: $e');
}
}
Player? _ytdlSetOn;
/// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame;
@@ -298,6 +319,11 @@ class PlaybackController {
// an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it).
void _onLog(PlayerLog log) {
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) onYtdlError?.call(text);
return;
}
if (log.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl);
@@ -0,0 +1,47 @@
import 'dart:async';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
/// mpv's `ytdl-format` for links every player opens itself.
const ytdlFormat = 'bv*[height<=1080]+ba/b';
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
/// the hosted server. [probe] remembers what pasted links are for a while,
/// so the Link preview and the swap ask once.
class AppYtDlp {
static final instance = AppYtDlp();
/// [ytDlp]: a given one (tests); default in the app-support folder.
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
Future<YtDlp>? _ytDlp;
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
final base = await getApplicationSupportDirectory();
return YtDlp(dir(base.path));
}();
static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp';
/// Gets yt-dlp ready in the background (download on first run, a daily
/// update check afterwards).
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
final _probes = <String, (DateTime, Future<LinkProbe>)>{};
static const _probeTtl = Duration(minutes: 10);
Future<LinkProbe> probe(String url) {
final now = DateTime.now();
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
final hit = _probes[url];
if (hit != null) return hit.$2;
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
_probes[url] = (now, f);
f.catchError((Object _) {
_probes.remove(url);
return LinkProbe.file(Uri(), -1);
});
return f;
}
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
@@ -12,6 +13,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
@@ -132,7 +134,10 @@ final class _FileOrigin extends _RelayOrigin {
final class _LinkOrigin extends _RelayOrigin {
final String url;
const _LinkOrigin(this.url);
/// What the link is (a page goes through [ytDlp]).
final LinkProbe probe;
final YtDlp ytDlp;
const _LinkOrigin(this.url, this.probe, this.ytDlp);
}
/// A Jellyfin item: served with this install's account, else with the
@@ -159,6 +164,8 @@ class SessionCoordinator {
/// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile;
/// The app's yt-dlp and link probes (tests pass their own).
final AppYtDlp ytDlp;
final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
@@ -293,8 +300,9 @@ class SessionCoordinator {
bool _jellyfinListening = false;
SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
: ytDlp = ytDlp ?? AppYtDlp.instance,
jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by
@@ -304,6 +312,7 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init();
_playerReady = true;
@@ -456,6 +465,7 @@ class SessionCoordinator {
diff.reset();
_opening = true;
try {
await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
@@ -470,8 +480,30 @@ class SessionCoordinator {
}
}
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
Future<void> _prepareYtDlp(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
if (uri.host == _wsHost && uri.port == _wsPort) return;
try {
final y = await ytDlp.ytDlp;
final ready = y.ensure();
final path = hasMediaExtension(uri)
? y.path
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
if (path != null) await playback.useYtDlp(path);
} catch (e) {
debugPrint('yt-dlp not ready: $e');
}
}
bool _relayWaitShown = false;
/// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1;
/// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) {
@@ -494,7 +526,7 @@ class SessionCoordinator {
}
String _relayMediaUrlFor(String name, int mediaId) =>
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${relayUrlName(name)}';
/// Serves [bytes] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it.
@@ -617,9 +649,19 @@ class SessionCoordinator {
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
} else if (origin is _LinkOrigin) {
await _openMedia(origin.url);
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
_startRelaySource(
r.mediaId, LinkRelayBytes(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true);
} else if (relay != null && !mine) {
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
if (relay.fetch && !relay.live) {
// The server is still getting it (yt-dlp takes seconds; mpv
// gives up on a silent request after 5): open once it is live.
_openUrl = null;
_awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
}
} else {
var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
@@ -635,6 +677,9 @@ class SessionCoordinator {
}
_seekGate.reset();
_clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_awaitLiveFor = -1;
await _openMedia(_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId));
}
_jellyfinTransport(r);
_relayStatus(r);
@@ -1060,8 +1105,11 @@ class SessionCoordinator {
if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId);
} else if (ref == null && (media.url ?? '').isNotEmpty) {
// A web link: its name is a URL tail ("1"), not a show; never guess.
bytes = null;
// A web link is not a show: never guess. A page's thumbnail is
// its banner.
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null);
} else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -1089,33 +1137,58 @@ class SessionCoordinator {
_swap(media.copyWith(relay: const MediaRelay()));
return null;
}
LinkProbe? probe;
String? probeError;
try {
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) {
probeError = '$e';
}
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
if (relay == LinkRelay.direct) {
_swap(media);
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
return null;
}
// Relayed: it must be a single file the relay can range-read (not a
// page like YouTube, which only the player can resolve).
final probe = HttpRelayBytes(Uri.parse(url));
final int size;
try {
size = await probe.size().timeout(const Duration(seconds: 15));
} catch (e) {
return relay == LinkRelay.me
? 'cannot pass this link on: $e'
: 'cannot check this link: $e';
} finally {
await probe.close();
}
if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url);
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
} else {
_swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
// This computer reads it: a file, or a page's single-file format.
if (probe == null) return 'cannot pass this link on: $probeError';
final problem = probe.relayProblem;
if (problem != null) return problem;
final y = await ytDlp.ytDlp;
final check = LinkRelayBytes(uri, ytDlp: y, probe: probe);
final int size;
try {
size = await check.size().timeout(const Duration(seconds: 15));
} catch (e) {
return 'cannot pass this link on: $e';
} finally {
await check.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia(name: name, size: size, duration: 0, relay: const MediaRelay()));
return null;
}
// The server reads it with its own yt-dlp; only a page this app knows
// has no single file is refused here (the server may reach what this
// computer cannot).
if (probe != null && probe.isPage && probe.info != null && probe.info!.single == null) {
return noSingleFileText;
}
_swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
return null;
}
Future<LinkProbe?> _probeFor(String url) async {
try {
return await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (_) {
return null;
}
}
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media) {
+42 -41
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
import '../playback/yt_dlp_app.dart';
import '../screens/home/media_picker.dart';
import '../state/connect_prefs.dart';
import 'motion.dart';
@@ -109,49 +110,49 @@ class LinkPreview {
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
}
/// GET [uri] and return the body (oEmbed).
typedef OEmbedGetter = Future<String> Function(Uri uri);
/// What a link is (see [AppYtDlp.probe]).
typedef LinkProber = Future<LinkProbe> Function(String url);
Future<String> defaultOEmbedGet(Uri uri) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
try {
final req = await client.getUrl(uri);
final res = await req.close().timeout(const Duration(seconds: 8));
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
} finally {
client.close(force: true);
}
}
bool isYouTubeLink(Uri uri) {
final h = uri.host.toLowerCase();
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
}
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
/// channel and thumbnail; anything else (or a failed lookup) shows the
/// file name and host. Null for text that is not an http(s) link.
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
/// "3:07", "1:02:03".
String clockText(double seconds) {
final s = seconds.round();
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
}
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
/// duration and thumbnail (or why it is not a video); a media file (by
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
if (!isYouTubeLink(uri)) return plain;
final host = _host(uri);
// A media file name needs no lookup.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
final LinkProbe p;
try {
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
final j = jsonDecode(body);
if (j is! Map || j['title'] is! String) return plain;
final author = j['author_name'];
return LinkPreview(
title: j['title'] as String,
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
);
} catch (_) {
return plain;
p = await probe(url.trim());
} catch (e) {
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
}
final info = p.info;
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
if (info == null) {
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
}
return LinkPreview(
title: p.title ?? host,
subtitle: [
host,
if (info.uploader.isNotEmpty) info.uploader,
if (info.duration case final d? when d > 0) clockText(d),
].join(' · '),
thumbnail: info.thumbnail,
);
}
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
@@ -195,14 +196,14 @@ class LibraryLinkSource extends StatefulWidget {
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents;
final OEmbedGetter oembed;
final LinkProber probe;
const LibraryLinkSource({
super.key,
required this.controller,
required this.onPutUp,
required this.recents,
this.offlineHint,
this.oembed = defaultOEmbedGet,
this.probe = defaultLinkProber,
});
@override
@@ -246,7 +247,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (mounted) setState(() {});
return;
}
final p = await previewLink(url, get: widget.oembed);
final p = await previewLink(url, probe: widget.probe);
_previews[url] = p;
if (mounted) setState(() {});
}
@@ -272,7 +273,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
error = '$e';
}
if (error == null) {
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
unawaited(widget.recents.add(RecentLink(
url: url,
title: p?.title ?? LinkMedia(url).label,
@@ -346,7 +347,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6),
Text(
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant),
),
+3 -3
View File
@@ -83,7 +83,7 @@ class LibraryTab extends StatefulWidget {
final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks;
final OEmbedGetter oembed;
final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool online, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
@@ -100,7 +100,7 @@ class LibraryTab extends StatefulWidget {
this.jellyfinStart,
this.onOpenIntegrations,
this.recentLinks,
this.oembed = defaultOEmbedGet,
this.probe = defaultLinkProber,
this.filesBuilder,
this.now = DateTime.now,
});
@@ -245,7 +245,7 @@ class _LibraryTabState extends State<LibraryTab> {
controller: _c,
offlineHint: _hint,
recents: widget.recentLinks ?? RecentLinks.instance,
oembed: widget.oembed,
probe: widget.probe,
onPutUp: _put,
),
MediaKind.file => _files(context),
@@ -2,10 +2,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show YtDlp;
import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
@@ -107,6 +109,7 @@ class SettingsTab extends StatelessWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
];
case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance;
@@ -352,6 +355,67 @@ class RecentServersPanel extends StatelessWidget {
}
}
/// The app's yt-dlp (web page links): its version, "Update now", and the
/// last error.
class YtDlpSettings extends StatefulWidget {
/// Defaults to [AppYtDlp.instance]'s.
final Future<YtDlp>? ytDlp;
const YtDlpSettings({super.key, this.ytDlp});
@override
State<YtDlpSettings> createState() => _YtDlpSettingsState();
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return FutureBuilder<YtDlp>(
future: _y,
builder: (context, snap) {
final y = snap.data;
if (y == null) return const SizedBox.shrink();
return StreamBuilder<void>(
stream: y.changes,
builder: (context, _) {
final err = y.lastError;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.travel_explore),
title: Text(y.busy
? 'Downloading…'
: y.version == null
? 'Not downloaded yet'
: 'Version ${y.version}'),
subtitle: err == null
? null
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
trailing: TextButton(
onPressed: y.busy
? null
: () => y.path == null ? y.ensure() : y.update(force: true),
child: const Text('Update now'),
),
),
Text(
'Plays links to web pages (YouTube, X, …) in all three link modes. '
'Downloaded and kept up to date by the app; checked once a day.',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
],
);
},
);
},
);
}
}
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not.
class AnimeFlowCard extends StatefulWidget {
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart';
import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart';
import '../sfx/synth_sfx.dart';
import '../session/health.dart';
@@ -165,6 +166,16 @@ class SessionState {
/// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it.
final serverCrash = signal<String?>(null);
/// The app's yt-dlp folder for the hosted server; '' (the server's own
/// default) when the app-support folder is not available.
static Future<String> _sharedYtDlpDir() async {
try {
return (await AppYtDlp.instance.ytDlp).dir;
} catch (_) {
return '';
}
}
ServerIsolate? _server;
String? _setupCrash;
@@ -258,6 +269,7 @@ class SessionState {
mediaPath: params.mediaPath,
hostKey: hostKey,
jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
),
onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause),
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "watch_together")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.watch_together")
set(APPLICATION_ID "moe.senkochan.watch_together")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
@@ -54,6 +54,19 @@ static void my_application_activate(GApplication* application) {
gtk_window_set_default_size(window, 1280, 720);
// The window icon (X11; Wayland takes it from the .desktop file the app
// installs for its application id).
{
g_autofree gchar* exe = g_file_read_link("/proc/self/exe", nullptr);
if (exe != nullptr) {
g_autofree gchar* dir = g_path_get_dirname(exe);
g_autofree gchar* icon = g_build_filename(
dir, "data", "flutter_assets", "assets", "icon", "icon_256.png",
nullptr);
gtk_window_set_icon_from_file(window, icon, nullptr);
}
}
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
+1
View File
@@ -38,3 +38,4 @@ flutter:
uses-material-design: true
assets:
- assets/shaders/anime4k/
- assets/icon/
+11 -13
View File
@@ -189,23 +189,21 @@ void main() {
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
});
test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async {
Uri? asked;
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async {
asked = uri;
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'});
});
expect(asked!.host, 'www.youtube.com');
expect(asked!.path, '/oembed');
expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'});
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg'));
test('preview: a page by yt-dlp, a file by its name, else why not', () async {
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
probe: (url) async => LinkProbe.page(Uri.parse(url),
info: const YtDlpInfo(
title: 'A video', uploader: 'Chan', duration: 187, thumbnail: 'https://i.ytimg/x.jpg')));
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan · 3:07', 'https://i.ytimg/x.jpg'));
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1',
get: (_) => throw StateError('no lookup for plain links'));
probe: (url) async => LinkProbe.file(Uri.parse(url), 10));
expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null));
final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline'));
expect((down!.title, down.subtitle), ('abc', 'youtu.be'));
final page = await previewLink('https://example.com/a',
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
expect((page!.title, page.subtitle),
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
expect(await previewLink('not a link'), isNull);
});
+3 -4
View File
@@ -108,10 +108,9 @@ Future<Harness> pumpLibrary(
jellyfinStart: start,
recentLinks: h.recents,
now: () => fixtureNow,
oembed: (uri) async => jsonEncode({
'title': 'A video',
'author_name': 'Chan',
}),
probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1)
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async {
+7 -3
View File
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
@@ -67,7 +68,10 @@ void main() {
Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false)));
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0);
@@ -150,7 +154,7 @@ void main() {
await playTogether([alice, bob]);
expect(hits.length, greaterThan(before));
// A page instead of a file is refused up front.
// A page yt-dlp cannot read is refused up front.
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) {
r.response.headers.contentType = ContentType.html;
@@ -158,7 +162,7 @@ void main() {
r.response.close();
});
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
contains('cannot pass this link on'));
contains('Not a video'));
await page.close(force: true);
} finally {
await origin.close(force: true);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+4 -1
View File
@@ -4,7 +4,9 @@
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
# Flags: --port <n> (default 8080), --secret <password> (empty = open
# server, anyone who can reach the port can join), --name, --media <file>,
# --advertise-host <host>, --config <file.yaml|json>. See README.
# --advertise-host <host>, --no-yt-dlp, --yt-dlp-dir <dir>, --config
# <file.yaml|json>. See README. The server's yt-dlp (for web page links it
# fetches) is downloaded into the state directory below.
# Prefer --config with a root-only file over --secret here: unit files and
# process lists are readable by other users.
@@ -17,6 +19,7 @@ Wants=network-online.target
ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
Restart=on-failure
DynamicUser=true
StateDirectory=watch-together
NoNewPrivileges=true
[Install]
+79 -10
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>`, `--config`; default port 8080) into the same map and calls `serve`;
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--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.
@@ -160,7 +160,8 @@ Three sources, same sync. Any of them can also go through the relay
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
directly; the server is only the clock. A pasted link that
directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the
server is only the clock. A pasted link that
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
ServerId is known (in the link, else `/System/Info/Public`), so its
`api_key` is never shared (`mediaForLink`, host and swap alike).
@@ -394,14 +395,14 @@ What gets relayed (`SessionCoordinator.swapPicked`):
| Link, "Through me" | relayed from that member's computer; for links only they can open |
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
Relayed links must be one file the relay can range-read: a `text/*`
response (YouTube and other pages, which only the player resolves) is
refused before the swap.
Relayed links are range-read as one file: a media file as is, a web page
as yt-dlp's single-file format, resolved where the bytes are fetched (see
"yt-dlp"); the swap carries only the page URL and the mode.
How bytes move:
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
answers the server's pulls from the file or the URL (`RelaySource`,
`FileRelayBytes`, `HttpRelayBytes`; a Jellyfin source sends its own
`FileRelayBytes`, `HttpRelayBytes`, `LinkRelayBytes`; a Jellyfin source sends its own
`Authorization` header from this machine). Viewers only ever talk HTTP
to the server, so the source never sees mpv's request pattern.
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in
@@ -456,6 +457,62 @@ so for quick seeks. Rejected along the way: a fixed per-request window
pipe), one shared window across requests (deadlocked on mpv's unread
requests), Dart `http2` (connection window stuck at 64 KiB).
## yt-dlp
Web pages with a video (YouTube, Twitter/X, ...) play through yt-dlp in
all three link modes. Nothing is bundled or installed by hand.
- **Manager** (`YtDlp`, `packages/core/lib/src/yt_dlp.dart`, used by the
app and the server): the managed copy in its folder (`yt-dlp` /
`yt-dlp.exe`) first; if missing it downloads the official standalone
build for the platform (`ytDlpAssetName`: `yt-dlp_linux`,
`yt-dlp_linux_aarch64`, musl variants, `yt-dlp.exe`, ...) from the tag
`releases/latest` redirects to, checks it against that release's
`SHA2-256SUMS`, makes it executable (not on Windows) and renames it into
place. If the download fails, a `yt-dlp` on PATH is the fallback. After
that, at most once a day (`last-check` stamp in the folder) and in the
background, a newer tag is downloaded the same way; nothing waits for
it. `version`, `lastError`, `busy` and `changes` feed Settings → Player
→ yt-dlp ("Update now" forces the check). Every failure is non-fatal.
Folders: the app's is `<app support>/yt-dlp` (`AppYtDlp`, warmed up at
start), shared with its hosted server; the dedicated server's is
`--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,
and the best single-file format (`pickSingleFile`: video and audio not
`none`, plain http(s), a video container, tallest up to 1080p) with its
URL and `http_headers`; none when the page has only split or HLS
streams (YouTube today).
- **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a
video/audio type, or a generic one on a media file name, is a file
(`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's
reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app
probes on paste (`AppYtDlp.probe`, remembered 10 minutes) for the Link
preview and the swap: a page is named by its title, never its URL tail,
and its thumbnail goes up as the banner (no poster).
- **Everyone fetches it**: the swap sends the page URL. Before opening an
external link every app points mpv's hook at its managed copy
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
once per player), waiting for the download unless the URL names a media
file. A hook error shows as "can't open this link: ...".
- **Through me**: the putter's probe must give a single-file format, else
the swap is refused with a hint to use "Everyone fetches it". Its relay
source reads that format with yt-dlp's headers (`PageRelayBytes`); on
403/410 (expired format URL) it resolves the page again once and goes
on from where it stopped.
- **Server fetches it**: the server probes the link itself
(`LinkRelayBytes`) with its own yt-dlp (off with `--no-yt-dlp` /
`ytDlp: false`) and relays the single-file format the same way. The
putter only refuses a page its own yt-dlp says has no single file (the
server may reach what the putter cannot); when the server cannot read
it, the putter gets an `err` with the reason. Viewers open a fetched
link's `/r/` URL only once `relay.live` is set ("the server is getting
the video…" until then): resolving takes seconds and media_kit's mpv
gives up on a silent request after 5 s (`network-timeout`).
## Jellyfin in a session
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
@@ -581,8 +638,8 @@ always scrolls the page). Space never presses a focused button
watched / unwatched, remove from Continue, overview, the episode list
(click picks the episode, double-click puts it up). A double-click on a
card puts it up (a series: `playableFor`, its next episode). Link
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and
thumbnail, else the file name), the three relay modes online, and the
(`library_link.dart`): paste field, a preview (a page's yt-dlp title,
uploader, length and thumbnail, else the file name; see "yt-dlp"), the three relay modes online, and the
last 12 links put up (`recentLinks` in the `connect_form` box). Files
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
an isolate, videos grouped by series; series start collapsed, a header
@@ -695,8 +752,8 @@ always scrolls the page). Space never presses a focused button
Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join
fails) and
`settings`. Link media names are derived from the URL path and never
contain a query string.
`settings`. Link media names are a web page's title (see "yt-dlp"), else
derived from the URL path, never containing a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
@@ -811,6 +868,18 @@ displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
it carries nothing sensitive. The Join card's recent servers poll it for live
status.
## App icon and launcher entry
`assets/icon/icon.svg` is the source; `icon_<size>.png` and the Windows
`app_icon.ico` are rendered from it (rsvg-convert, magick). The GTK
application id is `moe.senkochan.watch_together`, which Wayland uses as the
window's `app_id`; compositors take the icon from the `.desktop` file of
that id. A tarball install has none, so on start a Linux release build
(`platform/desktop_entry.dart`, skipped in debug) writes
`~/.local/share/applications/moe.senkochan.watch_together.desktop` (Exec =
the running binary) and the icons into the hicolor theme, only when they
changed. The runner also sets the window icon from the bundle (X11).
## Updater
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested).
+3 -1
View File
@@ -79,7 +79,9 @@ anyone except the host.
## Where video comes from
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv.
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv;
a web page (YouTube, X, ...) goes through yt-dlp, which the app and the
server download and update by themselves.
A pasted Jellyfin link becomes the Jellyfin item below when possible.
- **Jellyfin item**: picked in the app's library browser. Shared as "this
item on this Jellyfin server", never as a link with someone's sign-in.
+5 -2
View File
@@ -228,13 +228,16 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
`url` (else `relay fetch needs an http(s) url`), which the server keeps
to itself, and a server with a password (else `server fetch needs a
server password`: an open server would fetch any address it can reach
for anyone). Any later swap ends the relay.
for anyone). A web page `url` is read through the server's yt-dlp; when
the server cannot read the link, the swapper later gets `The server
can't fetch this link: <reason>`. Any later swap ends the relay.
- `state.media.relay.source`: member id of the source (absent with
`fetch`). `live` (omitted when false): bytes can flow (the source's
channel is connected, or the server could read the link).
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
`/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one:
`http://<host>/r/default/<token>/<mediaId>/<name>` (name URL-encoded).
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
`\` made `_`, then URL-encoded; the server ignores it).
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed
media (a player still on an older `mediaId` gets 404, never the new
+5 -3
View File
@@ -14,9 +14,11 @@ off by deleting it in the same commit as the code. Wire changes also edit
2. **Files**: next episode from Files (by parsed episode, with the
countdown). Later: tracking for file series.
3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl
hook (bundled on Windows); then relay of single-file formats; then a
resolver for the Link preview (title, thumbnail, duration).
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
bundled Windows libmpv has the ytdl hook (Lua); if not, resolve in the
app and open the single-file format. YouTube: yt-dlp wants a JS runtime
(deno) for full extraction, and it only has split streams, so relaying
it would need muxing. Formats that need yt-dlp's cookies.
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored
+2
View File
@@ -5,6 +5,8 @@ export 'src/clock_sync.dart';
export 'src/config.dart';
export 'src/protocol.dart';
export 'src/relay.dart';
export 'src/link_probe.dart';
export 'src/yt_dlp.dart';
export 'src/media_source.dart';
export 'src/sync_engine.dart';
export 'src/jellyfin/client.dart';
+12
View File
@@ -105,6 +105,12 @@ class AppConfig {
final String hostKey;
/// Optional login for relaying Jellyfin media to guests; null = no relay.
final JellyfinCredential? jellyfin;
/// "The server fetches it" reads web pages (YouTube, Twitter/X, ...)
/// through its own yt-dlp, downloaded on first use.
final bool ytDlp;
/// 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;
const AppConfig({
required this.port,
@@ -115,6 +121,8 @@ class AppConfig {
this.advertiseHost = '',
this.hostKey = '',
this.jellyfin,
this.ytDlp = true,
this.ytDlpDir = '',
});
factory AppConfig.defaults() =>
@@ -131,6 +139,8 @@ class AppConfig {
advertiseHost: (map['advertiseHost'] as String?) ?? '',
hostKey: (map['hostKey'] as String?) ?? '',
jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
ytDlp: (map['ytDlp'] as bool?) ?? true,
ytDlpDir: (map['ytDlpDir'] as String?) ?? '',
);
} on ConfigException {
rethrow;
@@ -148,6 +158,8 @@ class AppConfig {
'advertiseHost': advertiseHost,
'hostKey': hostKey,
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
'ytDlp': ytDlp,
'ytDlpDir': ytDlpDir,
};
static Future<AppConfig> loadFile(String path) async =>
+304
View File
@@ -0,0 +1,304 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'relay.dart';
import 'yt_dlp.dart';
// What a pasted link is: a media file (range-readable as is) or a web page
// that yt-dlp turns into one. Used where the bytes are fetched: the
// putter's app (preview, "I pass it on") and the server ("the server
// fetches it").
const _mediaExtensions = {
'mkv', 'mp4', 'm4v', 'webm', 'mov', 'avi', 'wmv', 'flv', 'ts', 'm2ts', 'mpg', 'mpeg', 'ogv', //
'mp3', 'm4a', 'flac', 'opus', 'ogg', 'wav',
};
const _fileTypes = {'application/mp4', 'application/ogg', 'application/x-matroska'};
const _genericTypes = {'', 'application/octet-stream', 'binary/octet-stream', 'application/binary'};
/// True when a `Range: bytes=0-0` answer ([status], [contentType]'s mime
/// type) means [url] is a media file: a video/audio type, or a generic one
/// on a media file name. Pages, HLS playlists and errors are not.
bool looksLikeMediaFile({required int status, required String contentType, required Uri url}) {
if (status != 200 && status != 206) return false;
final type = contentType.toLowerCase();
if (type.contains('mpegurl')) return false;
if (type.startsWith('video/') || type.startsWith('audio/') || _fileTypes.contains(type)) return true;
return _genericTypes.contains(type) && hasMediaExtension(url);
}
/// [url]'s file name ends in a media extension (`.mkv`, `.mp4`, ...).
bool hasMediaExtension(Uri url) {
final last = url.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? '';
final dot = last.lastIndexOf('.');
return dot > 0 && _mediaExtensions.contains(last.substring(dot + 1).toLowerCase());
}
/// A page's `og:title`, else its `<title>`, entities decoded; null if
/// neither.
String? htmlTitle(String html) {
for (final tag in RegExp(r'<meta\b[^>]*>', caseSensitive: false).allMatches(html)) {
final t = tag.group(0)!;
if (!RegExp(r'''(?:property|name)\s*=\s*["']og:title["']''', caseSensitive: false).hasMatch(t)) continue;
final c = RegExp('content\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')', caseSensitive: false).firstMatch(t);
final v = _unescape(c?.group(1) ?? c?.group(2) ?? '');
if (v.isNotEmpty) return v;
}
final m = RegExp(r'<title[^>]*>([\s\S]*?)</title>', caseSensitive: false).firstMatch(html);
final v = _unescape(m?.group(1) ?? '');
return v.isEmpty ? null : v;
}
String _unescape(String s) => s
.replaceAllMapped(RegExp(r'&#(x?)([0-9a-fA-F]+);'), (m) {
final n = int.tryParse(m.group(2)!, radix: m.group(1)!.isEmpty ? 10 : 16);
return n == null ? m.group(0)! : String.fromCharCode(n);
})
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
/// The text for a page yt-dlp cannot read.
String notAVideoText(String? ytDlpError) =>
ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)";
/// The text for a page with only split or HLS streams.
const noSingleFileText = 'This page has no single-file video to pass on (only separate video and audio '
"streams). Choose 'Everyone fetches it' instead.";
class LinkProbeException implements Exception {
final String message;
const LinkProbeException(this.message);
@override
String toString() => message;
}
/// What [probeLink] found.
class LinkProbe {
final Uri url;
final bool isPage;
/// A file's byte size; -1 when unknown or a page.
final int size;
/// yt-dlp's answer for a page it could read.
final YtDlpInfo? info;
/// A page's title from its HTML when yt-dlp could not read it.
final String? pageTitle;
/// Why yt-dlp could not read the page.
final String? ytDlpError;
const LinkProbe.file(this.url, this.size)
: isPage = false,
info = null,
pageTitle = null,
ytDlpError = null;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError})
: isPage = true,
size = -1;
/// The name to show: yt-dlp's title, the page's, else the host; null
/// for a file (named by its file name).
String? get title {
if (!isPage) return null;
final t = info?.title ?? pageTitle ?? '';
return t.isNotEmpty ? t : url.host;
}
/// Why a relay cannot read this link, or null when it can.
String? get relayProblem => !isPage
? null
: info == null
? notAVideoText(ytDlpError)
: info!.single == null
? noSingleFileText
: null;
}
const _probeLimit = 512 * 1024;
/// Finds out what [url] is: a `Range: bytes=0-0` GET; a media file is
/// done, anything else goes to yt-dlp ([ytDlp] null: no yt-dlp), and when
/// yt-dlp cannot read it, the page's own title. Throws
/// [LinkProbeException] when the link cannot be reached at all.
Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
final c = client ?? (HttpClient()..connectionTimeout = const Duration(seconds: 10));
try {
String? body;
Object? getError;
try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15));
final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {});
final total = res.headers.value(HttpHeaders.contentRangeHeader)?.split('/').last;
final n = int.tryParse(total ?? '') ?? (res.statusCode == 200 ? res.contentLength : -1);
return LinkProbe.file(url, n > 0 ? n : -1);
}
body = type.contains('html') ? await _readText(res) : null;
if (body == null) await res.drain<void>().catchError((_) {});
} catch (e) {
getError = e;
}
String? ytErr;
if (ytDlp != null) {
try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()));
} catch (e) {
ytErr = '$e';
}
}
if (getError != null) {
throw LinkProbeException('cannot open the link: $getError${ytErr == null ? '' : ' (yt-dlp: $ytErr)'}');
}
// A server honouring the Range sent one byte of the page: get it all.
if (body != null && body.length < 2) {
try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {}
}
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr);
} finally {
if (client == null) c.close(force: true);
}
}
Future<String> _readText(HttpClientResponse res) async {
final out = BytesBuilder(copy: false);
try {
await for (final chunk in res.timeout(const Duration(seconds: 15))) {
out.add(chunk);
if (out.length >= _probeLimit) break;
}
} catch (_) {}
return utf8.decode(out.takeBytes(), allowMalformed: true);
}
/// A page's single-file format (see [YtDlpInfo.single]), range-read with
/// yt-dlp's headers. Format URLs expire: on 403/410 the page is resolved
/// again once and the read goes on from where it stopped.
class PageRelayBytes implements RelayBytes {
final Uri page;
final YtDlp ytDlp;
YtDlpFormat? _format;
HttpRelayBytes? _inner;
int? _size;
var _gen = 0;
Future<void>? _refreshing;
PageRelayBytes(this.page, this.ytDlp, {YtDlpFormat? format}) : _format = format;
Future<YtDlpFormat> _resolve() async {
final f = (await ytDlp.resolve(page.toString())).single;
if (f == null) throw const LinkProbeException(noSingleFileText);
return f;
}
Future<HttpRelayBytes> _open() async =>
_inner ??= () {
final f = _format!;
return HttpRelayBytes(f.url, headers: f.headers);
}();
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
Future<void> _refresh(int gen) {
if (gen != _gen) return Future.value();
return _refreshing ??= () async {
try {
final f = await _resolve();
final old = _inner;
_format = f;
_inner = null;
_gen++;
await old?.close();
} finally {
_refreshing = null;
}
}();
}
@override
Future<int> size() async {
if (_size != null) return _size!;
_format ??= await _resolve();
final gen = _gen;
try {
return _size = await (await _open()).size();
} catch (e) {
if (!_expired(e)) rethrow;
await _refresh(gen);
return _size = await (await _open()).size();
}
}
@override
Stream<List<int>> read(int start, int end) async* {
var pos = start;
var retried = false;
while (true) {
final gen = _gen;
final inner = await _open();
try {
await for (final chunk in inner.read(pos, end)) {
pos += chunk.length;
yield chunk;
}
return;
} catch (e) {
if (retried || !_expired(e)) rethrow;
retried = true;
await _refresh(gen);
final n = await (await _open()).size();
if (_size != null && n != _size) throw const LinkProbeException('the video changed on the page');
}
}
}
@override
Future<void> close() async => _inner?.close();
}
/// A link of either kind: probed on first [size] (or given [probe]), then
/// read as a file ([HttpRelayBytes]) or a page ([PageRelayBytes]). Throws
/// [LinkProbeException] with the reason when it cannot be relayed.
class LinkRelayBytes implements RelayBytes {
final Uri url;
final YtDlp? ytDlp;
LinkProbe? _probe;
RelayBytes? _inner;
LinkRelayBytes(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
/// The probe, after [size].
LinkProbe? get probe => _probe;
@override
Future<int> size() async {
if (_inner == null) {
final probe = _probe ??= await probeLink(url, ytDlp: ytDlp);
final problem = probe.relayProblem;
if (problem != null) throw LinkProbeException(problem);
_inner = probe.isPage ? PageRelayBytes(url, ytDlp!, format: probe.info!.single) : HttpRelayBytes(url);
}
return _inner!.size();
}
@override
Stream<List<int>> read(int start, int end) {
final inner = _inner;
if (inner == null) return Stream.error(StateError('size() first'));
return inner.read(start, end);
}
@override
Future<void> close() async => _inner?.close();
}
+12 -1
View File
@@ -27,6 +27,11 @@ import 'logging.dart';
final _log = createLogger('relay');
/// The `<name>` segment of a `/r/` URL: the media name URL-encoded, its
/// slashes made `_` first (a page title can hold a URL, and an encoded
/// slash still splits the path). The server ignores the segment.
String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_'));
/// Binary relay frame: 4-byte big-endian pull id, then payload.
Uint8List encodeRelayFrame(int id, List<int> payload) {
final out = Uint8List(4 + payload.length);
@@ -66,6 +71,12 @@ class FileRelayBytes implements RelayBytes {
Future<void> close() async {}
}
/// A non-2xx answer from an [HttpRelayBytes] upstream.
class UpstreamHttpException extends HttpException {
final int status;
UpstreamHttpException(this.status, Uri url) : super('upstream HTTP $status', uri: url);
}
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
/// stream; [headers] (auth) never leave this machine.
class HttpRelayBytes implements RelayBytes {
@@ -85,7 +96,7 @@ class HttpRelayBytes implements RelayBytes {
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200 && res.statusCode != 206) {
await res.drain<void>().catchError((_) {});
throw HttpException('upstream HTTP ${res.statusCode}', uri: url);
throw UpstreamHttpException(res.statusCode, url);
}
return res;
}
+414
View File
@@ -0,0 +1,414 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' show Abi;
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'logging.dart';
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
// app and the server each keep their own copy, the official standalone
// build downloaded from GitHub and checked against the release's
// SHA2-256SUMS, refreshed at most once a day. Nothing is installed by hand.
final _log = createLogger('yt-dlp');
class YtDlpException implements Exception {
final String message;
const YtDlpException(this.message);
@override
String toString() => message;
}
/// One playable format of a resolved page: a URL and the headers yt-dlp
/// says to send with it.
class YtDlpFormat {
final String id;
final Uri url;
final Map<String, String> headers;
final int? height;
final String ext;
const YtDlpFormat({required this.id, required this.url, this.headers = const {}, this.height, this.ext = ''});
}
/// What `yt-dlp -J` says about a page.
class YtDlpInfo {
final String title;
final String uploader;
final String extractor;
final String webpageUrl;
/// Seconds; null when unknown (a live stream).
final double? duration;
final String? thumbnail;
/// The best format holding video and audio in one http(s) file (what a
/// relay can range-read); null when the page only has split or HLS
/// streams.
final YtDlpFormat? single;
const YtDlpInfo({
required this.title,
this.uploader = '',
this.extractor = '',
this.webpageUrl = '',
this.duration,
this.thumbnail,
this.single,
});
/// Parses `yt-dlp -J` output; a playlist answer uses its first entry.
static YtDlpInfo parse(Map<String, Object?> j) {
final entries = j['entries'];
if (j['_type'] == 'playlist' && entries is List) {
final first = entries.whereType<Map>().firstOrNull;
if (first == null) throw const YtDlpException('the page has no videos');
return parse(Map<String, Object?>.from(first));
}
String str(String k) => j[k] is String ? (j[k] as String).trim() : '';
final formats = j['formats'] is List ? (j['formats'] as List) : [if (j['url'] is String) j];
return YtDlpInfo(
title: str('title').isNotEmpty ? str('title') : str('fulltitle'),
uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'),
extractor: str('extractor_key').isNotEmpty ? str('extractor_key') : str('extractor'),
webpageUrl: str('webpage_url'),
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
single: pickSingleFile(formats),
);
}
}
const _singleFileExts = {'mp4', 'webm', 'mov', 'm4v', 'mkv'};
/// The best single-file format of [formats] (yt-dlp format maps): video
/// and audio both present (or not known to be absent), plain http(s), a
/// video container; the tallest up to [maxHeight], else the shortest above
/// it; mp4/webm and higher bitrate break ties.
YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) {
final ok = <(Map, int)>[];
for (final f in formats) {
if (f is! Map) continue;
final url = f['url'];
final protocol = f['protocol'];
if (url is! String || (protocol != 'http' && protocol != 'https')) continue;
if (f['vcodec'] == 'none' || f['acodec'] == 'none') continue;
if (!_singleFileExts.contains(f['ext'])) continue;
final uri = Uri.tryParse(url);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) continue;
ok.add((f, f['height'] is num ? (f['height'] as num).toInt() : 0));
}
if (ok.isEmpty) return null;
double tbr(Map f) => f['tbr'] is num ? (f['tbr'] as num).toDouble() : 0;
int rank((Map, int) a, (Map, int) b) {
final (fa, ha) = a;
final (fb, hb) = b;
final ina = ha <= maxHeight, inb = hb <= maxHeight;
if (ina != inb) return ina ? -1 : 1;
if (ha != hb) return ina ? hb.compareTo(ha) : ha.compareTo(hb);
final ma = fa['ext'] == 'mp4' || fa['ext'] == 'webm', mb = fb['ext'] == 'mp4' || fb['ext'] == 'webm';
if (ma != mb) return ma ? -1 : 1;
return tbr(fb).compareTo(tbr(fa));
}
ok.sort(rank);
final (f, h) = ok.first;
final headers = f['http_headers'];
return YtDlpFormat(
id: '${f['format_id'] ?? ''}',
url: Uri.parse(f['url'] as String),
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
height: h == 0 ? null : h,
ext: '${f['ext']}',
);
}
/// The official standalone build for this platform, or null if there is
/// none.
String? ytDlpAssetName(Abi abi, {bool musl = false}) => switch (abi) {
Abi.linuxX64 => musl ? 'yt-dlp_musllinux' : 'yt-dlp_linux',
Abi.linuxArm64 => musl ? 'yt-dlp_musllinux_aarch64' : 'yt-dlp_linux_aarch64',
Abi.windowsX64 => 'yt-dlp.exe',
Abi.windowsArm64 => 'yt-dlp_arm64.exe',
Abi.windowsIA32 => 'yt-dlp_x86.exe',
Abi.macosX64 || Abi.macosArm64 => 'yt-dlp_macos',
_ => null,
};
/// `SHA2-256SUMS` lines (`<hex> <name>`) as name -> lowercase hex.
Map<String, String> parseSha256Sums(String text) => {
for (final line in const LineSplitter().convert(text))
if (RegExp(r'^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$').firstMatch(line) case final m?)
m.group(2)!: m.group(1)!.toLowerCase(),
};
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
String ytDlpErrorText(String stderr) {
final lines = const LineSplitter().convert(stderr).map((l) => l.trim()).where((l) => l.isNotEmpty).toList();
if (lines.isEmpty) return 'yt-dlp failed';
final err = lines.lastWhere((l) => l.startsWith('ERROR:'), orElse: () => lines.last);
return err.replaceFirst(RegExp(r'^ERROR:\s*'), '').replaceFirst(RegExp(r'^\[[^\]]+\]\s*[^:\s]*:\s*'), '');
}
/// Finds, downloads, updates and runs yt-dlp. The managed copy lives in
/// [dir] (writable); without it a `yt-dlp` on PATH is the fallback when
/// the download fails. Every failure is non-fatal: [lastError] says why.
class YtDlp {
final String dir;
final bool enabled;
/// How often [ensure] looks for a newer release in the background.
final Duration updateEvery;
final HttpClient Function() _http;
static const releases = 'https://github.com/yt-dlp/yt-dlp/releases';
YtDlp(this.dir, {this.enabled = true, this.updateEvery = const Duration(days: 1), HttpClient Function()? http})
: _http = http ?? HttpClient.new;
String? _path;
/// The binary in use, once found.
String? get path => _path;
/// `yt-dlp --version` of [path].
String? version;
String? lastError;
/// A download or update is running.
bool busy = false;
final _changes = StreamController<void>.broadcast();
/// Fires when [version], [lastError] or [busy] change.
Stream<void> get changes => _changes.stream;
Future<String?>? _ensuring;
Future<void>? _updating;
String get managedPath => p.join(dir, Platform.isWindows ? 'yt-dlp.exe' : 'yt-dlp');
String get _stampPath => p.join(dir, 'last-check');
void _changed() {
if (!_changes.isClosed) _changes.add(null);
}
void _fail(String message) {
lastError = message;
_log.warning(message);
_changed();
}
/// The binary to run, downloading it first if needed; null (see
/// [lastError]) when there is none. A newer release is looked for in the
/// background at most every [updateEvery].
Future<String?> ensure() {
final known = _path;
if (known != null && (known == 'yt-dlp' || File(known).existsSync())) {
unawaited(update());
return Future.value(known);
}
return _ensuring ??= _ensure().whenComplete(() => _ensuring = null);
}
Future<String?> _ensure() async {
if (!enabled) {
lastError = 'yt-dlp is turned off';
return null;
}
if (await File(managedPath).exists()) {
final v = await _versionOf(managedPath);
if (v != null) {
_use(managedPath, v);
unawaited(update());
return managedPath;
}
}
try {
await _download(await _latestTag());
return managedPath;
} catch (e) {
final onPath = await _versionOf('yt-dlp');
if (onPath != null) {
_use('yt-dlp', onPath);
_fail('could not download yt-dlp ($e); using the one on PATH');
return 'yt-dlp';
}
_fail('could not download yt-dlp: $e');
return null;
}
}
void _use(String path, String v) {
_path = path;
version = v;
_changed();
}
Future<String?> _versionOf(String exe) async {
try {
final r = await Process.run(exe, ['--version']).timeout(const Duration(seconds: 20));
final v = '${r.stdout}'.trim();
return r.exitCode == 0 && v.isNotEmpty ? v : null;
} catch (_) {
return null;
}
}
/// Replaces the managed copy when a newer release is out. Without
/// [force], at most every [updateEvery] (remembered across restarts).
Future<void> update({bool force = false}) => _updating ??= _update(force).whenComplete(() => _updating = null);
Future<void> _update(bool force) async {
if (!enabled) return;
try {
if (!force) {
final stamp = File(_stampPath);
if (await stamp.exists()) {
final at = DateTime.tryParse((await stamp.readAsString()).trim());
if (at != null && DateTime.now().difference(at) < updateEvery) return;
}
}
final tag = await _latestTag();
final managed = await File(managedPath).exists() ? await _versionOf(managedPath) : null;
if (managed != tag) {
await _download(tag);
} else {
_use(managedPath, managed!);
await _stamp();
}
if (lastError != null) {
lastError = null;
_changed();
}
} catch (e) {
_fail('yt-dlp update failed: $e');
}
}
Future<void> _stamp() async {
try {
await File(_stampPath).writeAsString(DateTime.now().toIso8601String());
} catch (_) {}
}
/// The tag `releases/latest` redirects to.
Future<String> _latestTag() async {
final client = _http();
try {
final req = await client.getUrl(Uri.parse('$releases/latest')).timeout(const Duration(seconds: 20));
req.followRedirects = false;
final res = await req.close().timeout(const Duration(seconds: 20));
await res.drain<void>().catchError((_) {});
final loc = res.headers.value(HttpHeaders.locationHeader) ?? '';
final tag = Uri.tryParse(loc)?.pathSegments.lastOrNull ?? '';
if (!res.isRedirect || tag.isEmpty || tag == 'latest') {
throw YtDlpException('no latest release (HTTP ${res.statusCode})');
}
return tag;
} finally {
client.close(force: true);
}
}
Future<void> _download(String tag) async {
final asset = ytDlpAssetName(Abi.current(), musl: _isMusl());
if (asset == null) throw YtDlpException('no yt-dlp build for ${Abi.current()}');
busy = true;
_changed();
final client = _http();
final part = File('$managedPath.part$pid');
try {
await Directory(dir).create(recursive: true);
final sums = parseSha256Sums(utf8.decode(await _get(client, '$releases/download/$tag/SHA2-256SUMS')));
final want = sums[asset];
if (want == null) throw YtDlpException('$asset is not in the release checksums');
_log.info('downloading yt-dlp $tag ($asset)');
final req = await client.getUrl(Uri.parse('$releases/download/$tag/$asset')).timeout(const Duration(seconds: 30));
final res = await req.close().timeout(const Duration(seconds: 30));
if (res.statusCode != 200) {
await res.drain<void>().catchError((_) {});
throw YtDlpException('download HTTP ${res.statusCode}');
}
final sink = part.openWrite();
final hashOut = _DigestSink();
final hasher = sha256.startChunkedConversion(hashOut);
await for (final chunk in res.timeout(const Duration(seconds: 60))) {
hasher.add(chunk);
sink.add(chunk);
}
hasher.close();
await sink.close();
if (hashOut.value.toString() != want) throw const YtDlpException('checksum mismatch');
if (!Platform.isWindows) {
final r = await Process.run('chmod', ['755', part.path]);
if (r.exitCode != 0) throw YtDlpException('chmod failed: ${r.stderr}');
}
final v = await _versionOf(part.path);
if (v == null) throw const YtDlpException('the downloaded yt-dlp does not run');
final target = File(managedPath);
// Windows cannot rename onto an existing file.
if (Platform.isWindows && await target.exists()) await target.delete();
await part.rename(managedPath);
_use(managedPath, v);
await _stamp();
_log.info('yt-dlp $v ready at $managedPath');
} finally {
client.close(force: true);
if (await part.exists()) await part.delete().catchError((_) => part);
busy = false;
_changed();
}
}
Future<List<int>> _get(HttpClient client, String url) async {
final req = await client.getUrl(Uri.parse(url)).timeout(const Duration(seconds: 20));
final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200) {
await res.drain<void>().catchError((_) {});
throw YtDlpException('$url: HTTP ${res.statusCode}');
}
final out = BytesBuilder(copy: false);
await for (final c in res.timeout(const Duration(seconds: 20))) {
out.add(c);
}
return out.takeBytes();
}
static bool _isMusl() {
if (!Platform.isLinux) return false;
try {
return Directory('/lib').listSync().any((e) => p.basename(e.path).startsWith('ld-musl-'));
} catch (_) {
return false;
}
}
/// Asks yt-dlp what [url] is. 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 out = proc.stdout.transform(utf8.decoder).join();
final err = proc.stderr.transform(utf8.decoder).join();
final int code;
try {
code = await proc.exitCode.timeout(timeout);
} on TimeoutException {
proc.kill();
throw YtDlpException('yt-dlp took longer than ${timeout.inSeconds} s');
}
if (code != 0) throw YtDlpException(ytDlpErrorText(await err));
try {
final j = jsonDecode(await out);
if (j is! Map) throw const FormatException('not an object');
return YtDlpInfo.parse(Map<String, Object?>.from(j));
} on FormatException catch (e) {
throw YtDlpException('unreadable yt-dlp output: ${e.message}');
}
}
Future<void> close() => _changes.close();
}
class _DigestSink implements Sink<Digest> {
late Digest value;
@override
void add(Digest data) => value = data;
@override
void close() {}
}
+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');
}
+10
View File
@@ -19,6 +19,12 @@ Future<void> main(List<String> args) async {
..addOption('jellyfin',
help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) '
'to relay Jellyfin media to viewers without an account. The device fields must be the ones the token was issued with.')
..addFlag('yt-dlp',
defaultsTo: true,
help: 'Read web pages (YouTube, Twitter/X, ...) for "the server fetches it" with yt-dlp, '
'downloaded on first use.')
..addOption('yt-dlp-dir',
defaultsTo: '', help: 'Writable folder for that yt-dlp (default: yt-dlp next to this binary).')
..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;
@@ -52,6 +58,8 @@ Future<void> main(List<String> args) async {
name: '${parsed['name']}',
mediaPath: '${parsed['media']}',
advertiseHost: '${parsed['advertise-host']}',
ytDlp: parsed['yt-dlp'] as bool,
ytDlpDir: '${parsed['yt-dlp-dir']}',
);
}
final jellyfinPath = parsed['jellyfin'] as String?;
@@ -66,6 +74,8 @@ Future<void> main(List<String> args) async {
advertiseHost: c.advertiseHost,
hostKey: c.hostKey,
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
ytDlp: c.ytDlp,
ytDlpDir: c.ytDlpDir,
);
}
} on ConfigException catch (e) {
+8 -3
View File
@@ -34,11 +34,15 @@ class ActiveRelay {
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
/// The server reads [url] itself.
/// The server reads [url] itself: a media file as is, a web page
/// through [ytDlp]. [onFail] gets the reason when it cannot.
static ActiveRelay fetch(int mediaId, String name, Uri url,
{RelayBuffer? buffer, required void Function() onLiveChanged}) {
{RelayBuffer? buffer,
YtDlp? ytDlp,
required void Function() onLiveChanged,
void Function(String reason)? onFail}) {
final b = buffer ?? RelayBuffer();
final local = LocalRelayFeed(HttpRelayBytes(url), b);
final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b);
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
unawaited(() async {
try {
@@ -48,6 +52,7 @@ class ActiveRelay {
onLiveChanged();
} catch (e) {
_log.warning('relay fetch: cannot read the link: $e');
if (!r._disposed) onFail?.call('$e');
}
}());
return r;
+6 -2
View File
@@ -20,9 +20,11 @@ class ServerHub {
final Map<String, RelicWebSocket> sockets = {};
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
JellyfinCredential? jellyfin;
/// Reads web pages for "the server fetches it"; null = files only.
final YtDlp? ytDlp;
ServerHub(this.sessions, this.expectedSecret,
{SyncEngine? engine, this.hostKey = '', this.jellyfin})
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
: engine = engine ?? SyncEngine();
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
@@ -356,7 +358,9 @@ void mountSyncRoute(
late final ActiveRelay active;
active = relayReq.fetch
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
onLiveChanged: () => hub.relayLiveChanged(active))
ytDlp: hub.ytDlp,
onLiveChanged: () => hub.relayLiveChanged(active),
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
: _memberRelay(hub, token, name);
hub.setRelay(active);
hub.changed();
+28 -2
View File
@@ -12,6 +12,29 @@ import 'relay_routes.dart';
import 'server_hub.dart';
import 'session.dart';
/// [configured], else systemd's `$STATE_DIRECTORY/yt-dlp`, else `yt-dlp`
/// next to the server binary when that is writable (not under `dart run`),
/// else `~/.cache/watch-together/yt-dlp`.
String serverYtDlpDir(String configured) {
if (configured.isNotEmpty) return configured;
final sep = Platform.pathSeparator;
final state = (Platform.environment['STATE_DIRECTORY'] ?? '').split(':').first;
if (state.isNotEmpty) return '$state${sep}yt-dlp';
final exe = File(Platform.resolvedExecutable);
final name = exe.uri.pathSegments.last.toLowerCase();
if (name != 'dart' && name != 'dart.exe' && name != 'dartaotruntime') {
final dir = Directory('${exe.parent.path}${sep}yt-dlp');
try {
dir.createSync(recursive: true);
final probe = File('${dir.path}$sep.writable')..writeAsStringSync('');
probe.deleteSync();
return dir.path;
} catch (_) {}
}
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.';
return '$home$sep.cache${sep}watch-together${sep}yt-dlp';
}
class SyncServer {
final AppConfig config;
final pkg_logging.Logger _log = createLogger('sync_server');
@@ -26,7 +49,10 @@ class SyncServer {
HttpClient? _relayClient;
SyncServer(this.config) {
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey, jellyfin: config.jellyfin);
hub = ServerHub(sessions, config.secret,
hostKey: config.hostKey,
jellyfin: config.jellyfin,
ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
}
bool get isRunning => _server != null;
@@ -78,7 +104,7 @@ class SyncServer {
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${Uri.encodeComponent(name)}');
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}');
_app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'),
);