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--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--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.
diff --git a/apps/desktop/assets/icon/icon.svg b/apps/desktop/assets/icon/icon.svg
new file mode 100644
index 0000000..7787acc
--- /dev/null
+++ b/apps/desktop/assets/icon/icon.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/apps/desktop/assets/icon/icon_128.png b/apps/desktop/assets/icon/icon_128.png
new file mode 100644
index 0000000..8e45edb
Binary files /dev/null and b/apps/desktop/assets/icon/icon_128.png differ
diff --git a/apps/desktop/assets/icon/icon_16.png b/apps/desktop/assets/icon/icon_16.png
new file mode 100644
index 0000000..cbb940a
Binary files /dev/null and b/apps/desktop/assets/icon/icon_16.png differ
diff --git a/apps/desktop/assets/icon/icon_256.png b/apps/desktop/assets/icon/icon_256.png
new file mode 100644
index 0000000..2bcd0d3
Binary files /dev/null and b/apps/desktop/assets/icon/icon_256.png differ
diff --git a/apps/desktop/assets/icon/icon_32.png b/apps/desktop/assets/icon/icon_32.png
new file mode 100644
index 0000000..cfdb966
Binary files /dev/null and b/apps/desktop/assets/icon/icon_32.png differ
diff --git a/apps/desktop/assets/icon/icon_48.png b/apps/desktop/assets/icon/icon_48.png
new file mode 100644
index 0000000..efff09f
Binary files /dev/null and b/apps/desktop/assets/icon/icon_48.png differ
diff --git a/apps/desktop/assets/icon/icon_512.png b/apps/desktop/assets/icon/icon_512.png
new file mode 100644
index 0000000..2d87f56
Binary files /dev/null and b/apps/desktop/assets/icon/icon_512.png differ
diff --git a/apps/desktop/assets/icon/icon_64.png b/apps/desktop/assets/icon/icon_64.png
new file mode 100644
index 0000000..0c667e1
Binary files /dev/null and b/apps/desktop/assets/icon/icon_64.png differ
diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart
index e48c3aa..61f28ac 100644
--- a/apps/desktop/lib/main.dart
+++ b/apps/desktop/lib/main.dart
@@ -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 main(List 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());
}
diff --git a/apps/desktop/lib/src/platform/desktop_entry.dart b/apps/desktop/lib/src/platform/desktop_entry.dart
new file mode 100644
index 0000000..b5f6dc9
--- /dev/null
+++ b/apps/desktop/lib/src/platform/desktop_entry.dart
@@ -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 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
+''';
diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart
index 12db6ec..bb72805 100644
--- a/apps/desktop/lib/src/playback/playback_controller.dart
+++ b/apps/desktop/lib/src/playback/playback_controller.dart
@@ -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 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 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);
diff --git a/apps/desktop/lib/src/playback/yt_dlp_app.dart b/apps/desktop/lib/src/playback/yt_dlp_app.dart
new file mode 100644
index 0000000..7d19144
--- /dev/null
+++ b/apps/desktop/lib/src/playback/yt_dlp_app.dart
@@ -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;
+ Future 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 = )>{};
+ static const _probeTtl = Duration(minutes: 10);
+
+ Future 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;
+ }
+}
diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart
index 44b398d..50f0790 100644
--- a/apps/desktop/lib/src/session/session_coordinator.dart
+++ b/apps/desktop/lib/src/session/session_coordinator.dart
@@ -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 _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 _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) {
diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart
index dca5f0a..001a7d2 100644
--- a/apps/desktop/lib/src/shell/library_link.dart
+++ b/apps/desktop/lib/src/shell/library_link.dart
@@ -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 Function(Uri uri);
+/// What a link is (see [AppYtDlp.probe]).
+typedef LinkProber = Future Function(String url);
-Future 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 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 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 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 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 {
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 {
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 {
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),
),
diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart
index 871cabe..ce2f09d 100644
--- a/apps/desktop/lib/src/shell/library_tab.dart
+++ b/apps/desktop/lib/src/shell/library_tab.dart
@@ -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 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 {
controller: _c,
offlineHint: _hint,
recents: widget.recentLinks ?? RecentLinks.instance,
- oembed: widget.oembed,
+ probe: widget.probe,
onPutUp: _put,
),
MediaKind.file => _files(context),
diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart
index 7bcde37..a4bdac2 100644
--- a/apps/desktop/lib/src/shell/settings_tab.dart
+++ b/apps/desktop/lib/src/shell/settings_tab.dart
@@ -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;
+ const YtDlpSettings({super.key, this.ytDlp});
+
+ @override
+ State createState() => _YtDlpSettingsState();
+}
+
+class _YtDlpSettingsState extends State {
+ late final Future _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return FutureBuilder(
+ future: _y,
+ builder: (context, snap) {
+ final y = snap.data;
+ if (y == null) return const SizedBox.shrink();
+ return StreamBuilder(
+ 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 {
diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart
index a232e10..b3b5fe9 100644
--- a/apps/desktop/lib/src/state/session_state.dart
+++ b/apps/desktop/lib/src/state/session_state.dart
@@ -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(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 _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),
diff --git a/apps/desktop/linux/CMakeLists.txt b/apps/desktop/linux/CMakeLists.txt
index 09ac0f9..54f226b 100644
--- a/apps/desktop/linux/CMakeLists.txt
+++ b/apps/desktop/linux/CMakeLists.txt
@@ -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.
diff --git a/apps/desktop/linux/runner/my_application.cc b/apps/desktop/linux/runner/my_application.cc
index 1642d3e..cd74d43 100644
--- a/apps/desktop/linux/runner/my_application.cc
+++ b/apps/desktop/linux/runner/my_application.cc
@@ -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);
diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml
index 7b58c80..da49832 100644
--- a/apps/desktop/pubspec.yaml
+++ b/apps/desktop/pubspec.yaml
@@ -38,3 +38,4 @@ flutter:
uses-material-design: true
assets:
- assets/shaders/anime4k/
+ - assets/icon/
diff --git a/apps/desktop/test/library_rows_test.dart b/apps/desktop/test/library_rows_test.dart
index a97f85d..3b03cc4 100644
--- a/apps/desktop/test/library_rows_test.dart
+++ b/apps/desktop/test/library_rows_test.dart
@@ -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);
});
diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart
index f03d74a..471de2b 100644
--- a/apps/desktop/test/library_tab_test.dart
+++ b/apps/desktop/test/library_tab_test.dart
@@ -108,10 +108,9 @@ Future 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 {
diff --git a/apps/desktop/test/relay_e2e_test.dart b/apps/desktop/test/relay_e2e_test.dart
index e0096ef..4fe8859 100644
--- a/apps/desktop/test/relay_e2e_test.dart
+++ b/apps/desktop/test/relay_e2e_test.dart
@@ -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 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);
diff --git a/apps/desktop/windows/runner/resources/app_icon.ico b/apps/desktop/windows/runner/resources/app_icon.ico
index c04e20c..3990c9a 100644
Binary files a/apps/desktop/windows/runner/resources/app_icon.ico and b/apps/desktop/windows/runner/resources/app_icon.ico differ
diff --git a/deploy/watch-together.service b/deploy/watch-together.service
index 00be23a..8d0d7f6 100644
--- a/deploy/watch-together.service
+++ b/deploy/watch-together.service
@@ -4,7 +4,9 @@
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
# Flags: --port (default 8080), --secret (empty = open
# server, anyone who can reach the port can join), --name, --media ,
-# --advertise-host , --config . See README.
+# --advertise-host , --no-yt-dlp, --yt-dlp-dir , --config
+# . 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]
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 961389c..10083cb 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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 `, `--config`; default port 8080) into the same map and calls `serve`;
+ `--jellyfin `, `--[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 `/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` / `` 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_.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).
diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md
index 175e89e..6e8be51 100644
--- a/docs/HOW-IT-WORKS.md
+++ b/docs/HOW-IT-WORKS.md
@@ -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.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 622ceb1..105eb61 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -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: `. 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:///r/default///` (name URL-encoded).
+ `http:///r/default///` (name with `/` and
+ `\` made `_`, then URL-encoded; the server ignores it).
- `GET /r////`: 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
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 8d8eb57..28fa368 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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
diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart
index 658950b..21fa406 100644
--- a/packages/core/lib/core.dart
+++ b/packages/core/lib/core.dart
@@ -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';
diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart
index b4f0dd9..4e6d12a 100644
--- a/packages/core/lib/src/config.dart
+++ b/packages/core/lib/src/config.dart
@@ -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 loadFile(String path) async =>
diff --git a/packages/core/lib/src/link_probe.dart b/packages/core/lib/src/link_probe.dart
new file mode 100644
index 0000000..eff44ea
--- /dev/null
+++ b/packages/core/lib/src/link_probe.dart
@@ -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 ``, entities decoded; null if
+/// neither.
+String? htmlTitle(String html) {
+ for (final tag in RegExp(r']*>', 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']*>([\s\S]*?)', 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('"', '"')
+ .replaceAll(''', "'")
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll(' ', ' ')
+ .replaceAll('&', '&')
+ .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 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().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().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 _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? _refreshing;
+ PageRelayBytes(this.page, this.ytDlp, {YtDlpFormat? format}) : _format = format;
+
+ Future _resolve() async {
+ final f = (await ytDlp.resolve(page.toString())).single;
+ if (f == null) throw const LinkProbeException(noSingleFileText);
+ return f;
+ }
+
+ Future _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 _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 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> 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 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 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> read(int start, int end) {
+ final inner = _inner;
+ if (inner == null) return Stream.error(StateError('size() first'));
+ return inner.read(start, end);
+ }
+
+ @override
+ Future close() async => _inner?.close();
+}
diff --git a/packages/core/lib/src/relay.dart b/packages/core/lib/src/relay.dart
index c383181..698bcd0 100644
--- a/packages/core/lib/src/relay.dart
+++ b/packages/core/lib/src/relay.dart
@@ -27,6 +27,11 @@ import 'logging.dart';
final _log = createLogger('relay');
+/// The `` 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 payload) {
final out = Uint8List(4 + payload.length);
@@ -66,6 +71,12 @@ class FileRelayBytes implements RelayBytes {
Future 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().catchError((_) {});
- throw HttpException('upstream HTTP ${res.statusCode}', uri: url);
+ throw UpstreamHttpException(res.statusCode, url);
}
return res;
}
diff --git a/packages/core/lib/src/yt_dlp.dart b/packages/core/lib/src/yt_dlp.dart
new file mode 100644
index 0000000..a622622
--- /dev/null
+++ b/packages/core/lib/src/yt_dlp.dart
@@ -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 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 j) {
+ final entries = j['entries'];
+ if (j['_type'] == 'playlist' && entries is List) {
+ final first = entries.whereType