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
+1443 -122

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. 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; 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: From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run - 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 - Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real `watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome. 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 / details open on the right with "Put it up · S1E3", mark watched /
unwatched, remove from Continue and the episode list (click an episode 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. 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 - **Link**: paste an http(s) link: a video file, or a web page with a
thumbnail for YouTube, else the file name); in a server, pick how the 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 others get it (everyone fetches it, the server fetches it, or through
you). Your last 12 links are listed under it. you). Your last 12 links are listed under it.
- **Files**: video folders you add on this computer, grouped by series - **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` | | `--name` | `server` | name reported by `/health` |
| `--media` | none | local file to serve as the starting video | | `--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 | | `--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 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. 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 - Through me: your computer downloads it and passes it on, for links only
you can open. 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 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. 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 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -7,6 +8,8 @@ import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'app.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/screens/home/ui.dart';
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/state/settings.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'); debugPrint('load connect form failed: $e\n$st');
} }
MediaKit.ensureInitialized(); 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()); 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 '../sync/user_seek.dart';
import 'frame_watchdog.dart'; import 'frame_watchdog.dart';
import 'media_marks.dart'; import 'media_marks.dart';
import 'yt_dlp_app.dart' show ytdlFormat;
/// Owns one mpv [Player] (and its [VideoController]) per session: created /// Owns one mpv [Player] (and its [VideoController]) per session: created
/// by [init] on join, released by [dispose] on leave, swapped by /// 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. /// Called once when the watchdog sees a frozen picture.
void Function()? onFrozen; 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 /// Fingerprint of the picture on screen, installed by the video view
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle. /// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
Future<int?> Function()? shownFrame; Future<int?> Function()? shownFrame;
@@ -298,6 +319,11 @@ class PlaybackController {
// an error there while shaders are on is a shader that failed to load // an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it). // or compile (mpv then renders without it).
void _onLog(PlayerLog log) { 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.prefix != 'libmpv_render') return;
if (log.text.contains('suspected software renderer')) { if (log.text.contains('suspected software renderer')) {
_setRenderMode(RenderMode.softwareGl); _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:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
@@ -12,6 +13,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart'; import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart'; import '../playback/media_marks.dart';
import '../playback/playback_controller.dart'; import '../playback/playback_controller.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/anilist.dart'; import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
@@ -132,7 +134,10 @@ final class _FileOrigin extends _RelayOrigin {
final class _LinkOrigin extends _RelayOrigin { final class _LinkOrigin extends _RelayOrigin {
final String url; 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 /// 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); /// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host. /// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile; 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(); final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server. /// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync); late final AvatarCache avatars = AvatarCache(sync);
@@ -293,8 +300,9 @@ class SessionCoordinator {
bool _jellyfinListening = false; bool _jellyfinListening = false;
SessionCoordinator( SessionCoordinator(
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs}) {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
: jellyfin = jellyfin ?? JellyfinAccounts.instance, : ytDlp = ytDlp ?? AppYtDlp.instance,
jellyfin = jellyfin ?? JellyfinAccounts.instance,
trackPrefs = trackPrefs ?? TrackPrefs(); trackPrefs = trackPrefs ?? TrackPrefs();
/// Player lifetime = one session: created here on connect, disposed by /// Player lifetime = one session: created here on connect, disposed by
@@ -304,6 +312,7 @@ class SessionCoordinator {
if (_playerReady) return; if (_playerReady) return;
try { try {
playback.onFrozen = () => unawaited(reloadVideo(auto: true)); 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); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
await playback.init(); await playback.init();
_playerReady = true; _playerReady = true;
@@ -456,6 +465,7 @@ class SessionCoordinator {
diff.reset(); diff.reset();
_opening = true; _opening = true;
try { try {
await _prepareYtDlp(url);
await playback.openUrl(url, headers: headers); await playback.openUrl(url, headers: headers);
health.value = health.value =
const HealthStatus(HealthSeverity.ok, 'connected'); 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; 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 /// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has). /// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) { void _relayStatus(StateBroadcast r) {
@@ -494,7 +526,7 @@ class SessionCoordinator {
} }
String _relayMediaUrlFor(String name, int mediaId) => 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. /// Serves [bytes] for media [mediaId] on this member's relay channel.
/// The server only pulls while someone watches through it. /// The server only pulls while someone watches through it.
@@ -617,9 +649,19 @@ class SessionCoordinator {
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true); _startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
} else if (origin is _LinkOrigin) { } else if (origin is _LinkOrigin) {
await _openMedia(origin.url); 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) { } else if (relay != null && !mine) {
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)); await _openMedia(_relayMediaUrlFor(name, r.mediaId));
}
} else { } else {
var url = stateMediaUrl(r); var url = stateMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
@@ -635,6 +677,9 @@ class SessionCoordinator {
} }
_seekGate.reset(); _seekGate.reset();
_clearPendingSeek(); _clearPendingSeek();
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
_awaitLiveFor = -1;
await _openMedia(_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId));
} }
_jellyfinTransport(r); _jellyfinTransport(r);
_relayStatus(r); _relayStatus(r);
@@ -1060,8 +1105,11 @@ class SessionCoordinator {
if (ref != null && client != null) { if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId); bytes = await jellyfinArt(client, ref.itemId);
} else if (ref == null && (media.url ?? '').isNotEmpty) { } else if (ref == null && (media.url ?? '').isNotEmpty) {
// A web link: its name is a URL tail ("1"), not a show; never guess. // A web link is not a show: never guess. A page's thumbnail is
bytes = null; // 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 { } else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name); bytes = await aniListArt(_aniList ??= AniListClient(), name);
@@ -1089,32 +1137,57 @@ class SessionCoordinator {
_swap(media.copyWith(relay: const MediaRelay())); _swap(media.copyWith(relay: const MediaRelay()));
return null; return null;
} }
if (relay == LinkRelay.direct) { LinkProbe? probe;
_swap(media); String? probeError;
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 { try {
size = await probe.size().timeout(const Duration(seconds: 15)); probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
} catch (e) { } catch (e) {
return relay == LinkRelay.me probeError = '$e';
? 'cannot pass this link on: $e' }
: 'cannot check this link: $e'; // A page is named by its title, never its URL tail ("1", "watch").
} finally { final uri = Uri.tryParse(url) ?? Uri();
await probe.close(); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
if (relay == LinkRelay.direct) {
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
return null;
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url); // This computer reads it: a file, or a page's single-file format.
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay())); if (probe == null) return 'cannot pass this link on: $probeError';
} else { final problem = probe.relayProblem;
_swap(HelloMedia( if (problem != null) return problem;
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true))); 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; 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, /// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
/// relayed by this member for viewers without an account. /// relayed by this member for viewers without an account.
+42 -41
View File
@@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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 '../screens/home/media_picker.dart';
import '../state/connect_prefs.dart'; import '../state/connect_prefs.dart';
import 'motion.dart'; import 'motion.dart';
@@ -109,49 +110,49 @@ class LinkPreview {
const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
} }
/// GET [uri] and return the body (oEmbed). /// What a link is (see [AppYtDlp.probe]).
typedef OEmbedGetter = Future<String> Function(Uri uri); typedef LinkProber = Future<LinkProbe> Function(String url);
Future<String> defaultOEmbedGet(Uri uri) async { Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
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');
}
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host; 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, /// "3:07", "1:02:03".
/// channel and thumbnail; anything else (or a failed lookup) shows the String clockText(double seconds) {
/// file name and host. Null for text that is not an http(s) link. final s = seconds.round();
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async { 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()); final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; 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)); final host = _host(uri);
if (!isYouTubeLink(uri)) return plain; // A media file name needs no lookup.
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
final LinkProbe p;
try { try {
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'})); p = await probe(url.trim());
final j = jsonDecode(body); } catch (e) {
if (j is! Map || j['title'] is! String) return plain; return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
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;
} }
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", ... /// "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. /// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp; final Future<String?> Function(LinkMedia media) onPutUp;
final RecentLinks recents; final RecentLinks recents;
final OEmbedGetter oembed; final LinkProber probe;
const LibraryLinkSource({ const LibraryLinkSource({
super.key, super.key,
required this.controller, required this.controller,
required this.onPutUp, required this.onPutUp,
required this.recents, required this.recents,
this.offlineHint, this.offlineHint,
this.oembed = defaultOEmbedGet, this.probe = defaultLinkProber,
}); });
@override @override
@@ -246,7 +247,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (mounted) setState(() {}); if (mounted) setState(() {});
return; return;
} }
final p = await previewLink(url, get: widget.oembed); final p = await previewLink(url, probe: widget.probe);
_previews[url] = p; _previews[url] = p;
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@@ -272,7 +273,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
error = '$e'; error = '$e';
} }
if (error == null) { 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( unawaited(widget.recents.add(RecentLink(
url: url, url: url,
title: p?.title ?? LinkMedia(url).label, 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), Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( 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, textAlign: TextAlign.center,
style: TextStyle(color: scheme.onSurfaceVariant), style: TextStyle(color: scheme.onSurfaceVariant),
), ),
+3 -3
View File
@@ -83,7 +83,7 @@ class LibraryTab extends StatefulWidget {
final VoidCallback? onOpenIntegrations; final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance]. /// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks; final RecentLinks? recentLinks;
final OEmbedGetter oembed; final LinkProber probe;
/// The Files source (tests inject one); defaults to [FilesPage]. /// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool online, required bool relayed, final Widget Function(BuildContext context, {required bool online, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder; required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
@@ -100,7 +100,7 @@ class LibraryTab extends StatefulWidget {
this.jellyfinStart, this.jellyfinStart,
this.onOpenIntegrations, this.onOpenIntegrations,
this.recentLinks, this.recentLinks,
this.oembed = defaultOEmbedGet, this.probe = defaultLinkProber,
this.filesBuilder, this.filesBuilder,
this.now = DateTime.now, this.now = DateTime.now,
}); });
@@ -245,7 +245,7 @@ class _LibraryTabState extends State<LibraryTab> {
controller: _c, controller: _c,
offlineHint: _hint, offlineHint: _hint,
recents: widget.recentLinks ?? RecentLinks.instance, recents: widget.recentLinks ?? RecentLinks.instance,
oembed: widget.oembed, probe: widget.probe,
onPutUp: _put, onPutUp: _put,
), ),
MediaKind.file => _files(context), MediaKind.file => _files(context),
@@ -2,10 +2,12 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show YtDlp;
import '../controls/player_settings.dart'; import '../controls/player_settings.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart'; import '../jellyfin/accounts_panel.dart';
import '../playback/yt_dlp_app.dart';
import '../schedule/animeflow.dart'; import '../schedule/animeflow.dart';
import '../screens/home/about_page.dart'; import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart'; import '../screens/home/settings_page.dart';
@@ -107,6 +109,7 @@ class SettingsTab extends StatelessWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
), ),
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
]; ];
case SettingsSection.integrations: case SettingsSection.integrations:
final accounts = jellyfin ?? JellyfinAccounts.instance; 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 /// AnimeFlow (the Schedule tab's source): connected as whom, with which
/// rights, or why not. /// rights, or why not.
class AnimeFlowCard extends StatefulWidget { class AnimeFlowCard extends StatefulWidget {
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../playback/yt_dlp_app.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../sfx/synth_sfx.dart'; import '../sfx/synth_sfx.dart';
import '../session/health.dart'; import '../session/health.dart';
@@ -165,6 +166,16 @@ class SessionState {
/// Set when the hosted server died mid-session; Home shows it on the /// Set when the hosted server died mid-session; Home shows it on the
/// Host card and clears it. /// Host card and clears it.
final serverCrash = signal<String?>(null); 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; ServerIsolate? _server;
String? _setupCrash; String? _setupCrash;
@@ -258,6 +269,7 @@ class SessionState {
mediaPath: params.mediaPath, mediaPath: params.mediaPath,
hostKey: hostKey, hostKey: hostKey,
jellyfin: relay, jellyfin: relay,
ytDlpDir: await _sharedYtDlpDir(),
), ),
onLog: (line) => debugPrint('[server] $line'), onLog: (line) => debugPrint('[server] $line'),
onCrash: (cause) => _onServerCrash(server, cause), onCrash: (cause) => _onServerCrash(server, cause),
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "watch_together") set(BINARY_NAME "watch_together")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
@@ -54,6 +54,19 @@ static void my_application_activate(GApplication* application) {
gtk_window_set_default_size(window, 1280, 720); 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(); g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments( fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments); project, self->dart_entrypoint_arguments);
+1
View File
@@ -38,3 +38,4 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/shaders/anime4k/ - 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'); 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 { test('preview: a page by yt-dlp, a file by its name, else why not', () async {
Uri? asked; final yt = await previewLink('https://www.youtube.com/watch?v=abc',
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async { probe: (url) async => LinkProbe.page(Uri.parse(url),
asked = uri; info: const YtDlpInfo(
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'}); 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'));
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'));
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1', 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)); 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')); final page = await previewLink('https://example.com/a',
expect((down!.title, down.subtitle), ('abc', 'youtu.be')); 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); expect(await previewLink('not a link'), isNull);
}); });
+3 -4
View File
@@ -108,10 +108,9 @@ Future<Harness> pumpLibrary(
jellyfinStart: start, jellyfinStart: start,
recentLinks: h.recents, recentLinks: h.recents,
now: () => fixtureNow, now: () => fixtureNow,
oembed: (uri) async => jsonEncode({ probe: (url) async => url.endsWith('.mkv')
'title': 'A video', ? LinkProbe.file(Uri.parse(url), 1)
'author_name': 'Chan', : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
}),
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) => filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(online: online, relayed: relayed, onPlay: onPlay), FilesStub(online: online, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async { 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:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.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/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:watch_together/src/session/session_coordinator.dart';
@@ -67,7 +68,10 @@ void main() {
Future<SessionCoordinator> member(String name) async { Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore()); final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false); 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); all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name)); await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0); await c.playback.player.setVolume(0);
@@ -150,7 +154,7 @@ void main() {
await playTogether([alice, bob]); await playTogether([alice, bob]);
expect(hits.length, greaterThan(before)); 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); final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) { page.listen((r) {
r.response.headers.contentType = ContentType.html; r.response.headers.contentType = ContentType.html;
@@ -158,7 +162,7 @@ void main() {
r.response.close(); r.response.close();
}); });
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)), 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); await page.close(force: true);
} finally { } finally {
await origin.close(force: true); 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`. # /etc/systemd/system/, then `systemctl enable --now watch-together`.
# Flags: --port <n> (default 8080), --secret <password> (empty = open # Flags: --port <n> (default 8080), --secret <password> (empty = open
# server, anyone who can reach the port can join), --name, --media <file>, # 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 # Prefer --config with a root-only file over --secret here: unit files and
# process lists are readable by other users. # 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 ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
Restart=on-failure Restart=on-failure
DynamicUser=true DynamicUser=true
StateDirectory=watch-together
NoNewPrivileges=true NoNewPrivileges=true
[Install] [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` - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`, (`--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 SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build. 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` - **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 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 `parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
ServerId is known (in the link, else `/System/Info/Public`), so its ServerId is known (in the link, else `/System/Info/Public`), so its
`api_key` is never shared (`mediaForLink`, host and swap alike). `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 | | 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) | | 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/*` Relayed links are range-read as one file: a media file as is, a web page
response (YouTube and other pages, which only the player resolves) is as yt-dlp's single-file format, resolved where the bytes are fetched (see
refused before the swap. "yt-dlp"); the swap carries only the page URL and the mode.
How bytes move: How bytes move:
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and - The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
answers the server's pulls from the file or the URL (`RelaySource`, 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 `Authorization` header from this machine). Viewers only ever talk HTTP
to the server, so the source never sees mpv's request pattern. to the server, so the source never sees mpv's request pattern.
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in - 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 pipe), one shared window across requests (deadlocked on mpv's unread
requests), Dart `http2` (connection window stuck at 64 KiB). 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 ## Jellyfin in a session
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, 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 watched / unwatched, remove from Continue, overview, the episode list
(click picks the episode, double-click puts it up). A double-click on a (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 card puts it up (a series: `playableFor`, its next episode). Link
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and (`library_link.dart`): paste field, a preview (a page's yt-dlp title,
thumbnail, else the file name), the three relay modes online, and the 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 last 12 links put up (`recentLinks` in the `connect_form` box). Files
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
an isolate, videos grouped by series; series start collapsed, a header 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 Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join servers and links, the profile picture and colour; saved even when a join
fails) and fails) and
`settings`. Link media names are derived from the URL path and never `settings`. Link media names are a web page's title (see "yt-dlp"), else
contain a query string. derived from the URL path, never containing a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one - `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 it carries nothing sensitive. The Join card's recent servers poll it for live
status. 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 ## Updater
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested). `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 ## 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. A pasted Jellyfin link becomes the Jellyfin item below when possible.
- **Jellyfin item**: picked in the app's library browser. Shared as "this - **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. 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 `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 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 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 - `state.media.relay.source`: member id of the source (absent with
`fetch`). `live` (omitted when false): bytes can flow (the source's `fetch`). `live` (omitted when false): bytes can flow (the source's
channel is connected, or the server could read the link). channel is connected, or the server could read the link).
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's - `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: `/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 - `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed `/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 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 2. **Files**: next episode from Files (by parsed episode, with the
countdown). Later: tracking for file series. countdown). Later: tracking for file series.
3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl 3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
hook (bundled on Windows); then relay of single-file formats; then a bundled Windows libmpv has the ytdl hook (Lua); if not, resolve in the
resolver for the Link preview (title, thumbnail, duration). 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" 4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
filter on the Schedule; today's "Following" (AnimeFlow's monitored 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/config.dart';
export 'src/protocol.dart'; export 'src/protocol.dart';
export 'src/relay.dart'; export 'src/relay.dart';
export 'src/link_probe.dart';
export 'src/yt_dlp.dart';
export 'src/media_source.dart'; export 'src/media_source.dart';
export 'src/sync_engine.dart'; export 'src/sync_engine.dart';
export 'src/jellyfin/client.dart'; export 'src/jellyfin/client.dart';
+12
View File
@@ -105,6 +105,12 @@ class AppConfig {
final String hostKey; final String hostKey;
/// Optional login for relaying Jellyfin media to guests; null = no relay. /// Optional login for relaying Jellyfin media to guests; null = no relay.
final JellyfinCredential? jellyfin; 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({ const AppConfig({
required this.port, required this.port,
@@ -115,6 +121,8 @@ class AppConfig {
this.advertiseHost = '', this.advertiseHost = '',
this.hostKey = '', this.hostKey = '',
this.jellyfin, this.jellyfin,
this.ytDlp = true,
this.ytDlpDir = '',
}); });
factory AppConfig.defaults() => factory AppConfig.defaults() =>
@@ -131,6 +139,8 @@ class AppConfig {
advertiseHost: (map['advertiseHost'] as String?) ?? '', advertiseHost: (map['advertiseHost'] as String?) ?? '',
hostKey: (map['hostKey'] as String?) ?? '', hostKey: (map['hostKey'] as String?) ?? '',
jellyfin: JellyfinCredential.fromMap(map['jellyfin']), jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
ytDlp: (map['ytDlp'] as bool?) ?? true,
ytDlpDir: (map['ytDlpDir'] as String?) ?? '',
); );
} on ConfigException { } on ConfigException {
rethrow; rethrow;
@@ -148,6 +158,8 @@ class AppConfig {
'advertiseHost': advertiseHost, 'advertiseHost': advertiseHost,
'hostKey': hostKey, 'hostKey': hostKey,
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(), if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
'ytDlp': ytDlp,
'ytDlpDir': ytDlpDir,
}; };
static Future<AppConfig> loadFile(String path) async => 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'); 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. /// Binary relay frame: 4-byte big-endian pull id, then payload.
Uint8List encodeRelayFrame(int id, List<int> payload) { Uint8List encodeRelayFrame(int id, List<int> payload) {
final out = Uint8List(4 + payload.length); final out = Uint8List(4 + payload.length);
@@ -66,6 +71,12 @@ class FileRelayBytes implements RelayBytes {
Future<void> close() async {} 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 /// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
/// stream; [headers] (auth) never leave this machine. /// stream; [headers] (auth) never leave this machine.
class HttpRelayBytes implements RelayBytes { class HttpRelayBytes implements RelayBytes {
@@ -85,7 +96,7 @@ class HttpRelayBytes implements RelayBytes {
final res = await req.close().timeout(const Duration(seconds: 20)); final res = await req.close().timeout(const Duration(seconds: 20));
if (res.statusCode != 200 && res.statusCode != 206) { if (res.statusCode != 200 && res.statusCode != 206) {
await res.drain<void>().catchError((_) {}); await res.drain<void>().catchError((_) {});
throw HttpException('upstream HTTP ${res.statusCode}', uri: url); throw UpstreamHttpException(res.statusCode, url);
} }
return res; 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', ..addOption('jellyfin',
help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) ' 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.') '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.') ..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.'); ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
final ArgResults parsed; final ArgResults parsed;
@@ -52,6 +58,8 @@ Future<void> main(List<String> args) async {
name: '${parsed['name']}', name: '${parsed['name']}',
mediaPath: '${parsed['media']}', mediaPath: '${parsed['media']}',
advertiseHost: '${parsed['advertise-host']}', advertiseHost: '${parsed['advertise-host']}',
ytDlp: parsed['yt-dlp'] as bool,
ytDlpDir: '${parsed['yt-dlp-dir']}',
); );
} }
final jellyfinPath = parsed['jellyfin'] as String?; final jellyfinPath = parsed['jellyfin'] as String?;
@@ -66,6 +74,8 @@ Future<void> main(List<String> args) async {
advertiseHost: c.advertiseHost, advertiseHost: c.advertiseHost,
hostKey: c.hostKey, hostKey: c.hostKey,
jellyfin: await JellyfinCredential.loadFile(jellyfinPath), jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
ytDlp: c.ytDlp,
ytDlpDir: c.ytDlpDir,
); );
} }
} on ConfigException catch (e) { } on ConfigException catch (e) {
+8 -3
View File
@@ -34,11 +34,15 @@ class ActiveRelay {
{RelayBuffer? buffer, required void Function() onLiveChanged}) => {RelayBuffer? buffer, required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, 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, 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 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); final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
unawaited(() async { unawaited(() async {
try { try {
@@ -48,6 +52,7 @@ class ActiveRelay {
onLiveChanged(); onLiveChanged();
} catch (e) { } catch (e) {
_log.warning('relay fetch: cannot read the link: $e'); _log.warning('relay fetch: cannot read the link: $e');
if (!r._disposed) onFail?.call('$e');
} }
}()); }());
return r; return r;
+6 -2
View File
@@ -20,9 +20,11 @@ class ServerHub {
final Map<String, RelicWebSocket> sockets = {}; final Map<String, RelicWebSocket> sockets = {};
/// The server's Jellyfin login for the `/j/...` relay; null = no relay. /// The server's Jellyfin login for the `/j/...` relay; null = no relay.
JellyfinCredential? jellyfin; JellyfinCredential? jellyfin;
/// Reads web pages for "the server fetches it"; null = files only.
final YtDlp? ytDlp;
ServerHub(this.sessions, this.expectedSecret, ServerHub(this.sessions, this.expectedSecret,
{SyncEngine? engine, this.hostKey = '', this.jellyfin}) {SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
: engine = engine ?? SyncEngine(); : engine = engine ?? SyncEngine();
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to. /// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
@@ -356,7 +358,9 @@ void mountSyncRoute(
late final ActiveRelay active; late final ActiveRelay active;
active = relayReq.fetch active = relayReq.fetch
? ActiveRelay.fetch(hub.engine.mediaId, name, link!, ? 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); : _memberRelay(hub, token, name);
hub.setRelay(active); hub.setRelay(active);
hub.changed(); hub.changed();
+28 -2
View File
@@ -12,6 +12,29 @@ import 'relay_routes.dart';
import 'server_hub.dart'; import 'server_hub.dart';
import 'session.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 { class SyncServer {
final AppConfig config; final AppConfig config;
final pkg_logging.Logger _log = createLogger('sync_server'); final pkg_logging.Logger _log = createLogger('sync_server');
@@ -26,7 +49,10 @@ class SyncServer {
HttpClient? _relayClient; HttpClient? _relayClient;
SyncServer(this.config) { 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; bool get isRunning => _server != null;
@@ -78,7 +104,7 @@ class SyncServer {
}, relayUrlBuilder: (sid, token, itemId, dialledHost) => }, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}', 'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) => 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( _app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'), body: Body.fromString('not found'),
); );