diff --git a/README.md b/README.md index 9077a28..d8cd5ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# watch-together +

+ watch-together icon +

+ +

watch-together

Watch a video with friends, in sync, with the quality of a real mpv player. Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk; @@ -22,7 +26,8 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md). From the Releases page of this repo: - Linux: `watch-together--linux-x64.tar.gz`. Extract, run - `watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`). + `watch_together`. On start it adds itself to your app launcher (and its + icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`). - Windows: `watch-together--windows-x64.zip`. Extract, run `watch_together.exe`. mpv is bundled. New and not yet tried on a real Windows desktop; reports welcome. @@ -103,8 +108,10 @@ folder to Files, or fill Link). details open on the right with "Put it up · S1E3", mark watched / unwatched, remove from Continue and the episode list (click an episode to choose it). Double-click a card or an episode to put it up at once. -- **Link**: paste an http(s) link. The preview shows what it is (title and - thumbnail for YouTube, else the file name); in a server, pick how the +- **Link**: paste an http(s) link: a video file, or a web page with a + video (YouTube, X, and whatever else yt-dlp knows). The preview shows + what it is (a page's title, uploader, length and thumbnail, else the + file name); in a server, pick how the others get it (everyone fetches it, the server fetches it, or through you). Your last 12 links are listed under it. - **Files**: video folders you add on this computer, grouped by series @@ -130,7 +137,9 @@ then everyone (you included) uses Join. | `--name` | `server` | name reported by `/health` | | `--media` | none | local file to serve as the starting video | | `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled | -| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags | +| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link | +| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) | +| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`); replaces the flags | It prints `READY port=... name=...` once listening and stops cleanly on Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone. @@ -291,7 +300,12 @@ the others get it: - Through me: your computer downloads it and passes it on, for links only you can open. -The last two need a link to a video file (not a page like YouTube) and +Web pages work in all three: the app and the dedicated server each +download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked +against the release's checksums, updated once a day; Settings → Player +shows its version and has "Update now"), so there is nothing to install. +The last two need a page with a single-file video (X has one; YouTube only +has separate video and audio streams, so use "Everyone fetches it") and never show the link to anyone. Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is about 11 Mbit/s. diff --git a/apps/desktop/assets/icon/icon.svg b/apps/desktop/assets/icon/icon.svg new file mode 100644 index 0000000..7787acc --- /dev/null +++ b/apps/desktop/assets/icon/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/desktop/assets/icon/icon_128.png b/apps/desktop/assets/icon/icon_128.png new file mode 100644 index 0000000..8e45edb Binary files /dev/null and b/apps/desktop/assets/icon/icon_128.png differ diff --git a/apps/desktop/assets/icon/icon_16.png b/apps/desktop/assets/icon/icon_16.png new file mode 100644 index 0000000..cbb940a Binary files /dev/null and b/apps/desktop/assets/icon/icon_16.png differ diff --git a/apps/desktop/assets/icon/icon_256.png b/apps/desktop/assets/icon/icon_256.png new file mode 100644 index 0000000..2bcd0d3 Binary files /dev/null and b/apps/desktop/assets/icon/icon_256.png differ diff --git a/apps/desktop/assets/icon/icon_32.png b/apps/desktop/assets/icon/icon_32.png new file mode 100644 index 0000000..cfdb966 Binary files /dev/null and b/apps/desktop/assets/icon/icon_32.png differ diff --git a/apps/desktop/assets/icon/icon_48.png b/apps/desktop/assets/icon/icon_48.png new file mode 100644 index 0000000..efff09f Binary files /dev/null and b/apps/desktop/assets/icon/icon_48.png differ diff --git a/apps/desktop/assets/icon/icon_512.png b/apps/desktop/assets/icon/icon_512.png new file mode 100644 index 0000000..2d87f56 Binary files /dev/null and b/apps/desktop/assets/icon/icon_512.png differ diff --git a/apps/desktop/assets/icon/icon_64.png b/apps/desktop/assets/icon/icon_64.png new file mode 100644 index 0000000..0c667e1 Binary files /dev/null and b/apps/desktop/assets/icon/icon_64.png differ diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index e48c3aa..61f28ac 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -7,6 +8,8 @@ import 'package:sync_relay_core/core.dart'; import 'package:window_manager/window_manager.dart'; import 'app.dart'; +import 'src/playback/yt_dlp_app.dart'; +import 'src/platform/desktop_entry.dart'; import 'src/screens/home/ui.dart'; import 'src/state/session_state.dart'; import 'src/state/settings.dart'; @@ -60,6 +63,10 @@ Future main(List args) async { debugPrint('load connect form failed: $e\n$st'); } MediaKit.ensureInitialized(); + // yt-dlp for web page links: downloaded on first run, kept current. + AppYtDlp.instance.warmUp(); + // Linux: the launcher entry and icon Wayland needs (release installs). + unawaited(installDesktopEntry()); runApp(const WatchTogetherApp()); } diff --git a/apps/desktop/lib/src/platform/desktop_entry.dart b/apps/desktop/lib/src/platform/desktop_entry.dart new file mode 100644 index 0000000..b5f6dc9 --- /dev/null +++ b/apps/desktop/lib/src/platform/desktop_entry.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../update/updater.dart' show findInstallRoot; + +/// The app's id: GTK's application id, so the Wayland `app_id` the +/// compositor matches to [desktopFileName] for the window's icon. +const appId = 'moe.senkochan.watch_together'; +const desktopFileName = '$appId.desktop'; + +/// Linux release installs (an unpacked tarball) carry no launcher entry, so +/// Wayland shows a generic icon. On start: write the `.desktop` file into +/// `~/.local/share/applications` (Exec = this binary) and the icon into +/// the hicolor theme, only when something changed (a moved install, a new +/// icon). Never in debug runs; failures only log. +Future installDesktopEntry({String? executable, String? dataHome, String? home}) async { + if (!Platform.isLinux || kDebugMode) return; + try { + final exe = executable ?? Platform.resolvedExecutable; + final root = findInstallRoot(exe, windows: false); + if (root == null) return; + final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ?? + '${home ?? Platform.environment['HOME']}/.local/share'; + final icons = '${root.path}/data/flutter_assets/assets/icon'; + for (final size in const [16, 32, 48, 64, 128, 256, 512]) { + final src = File('$icons/icon_$size.png'); + if (!src.existsSync()) continue; + final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png'); + if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue; + await dst.parent.create(recursive: true); + await src.copy(dst.path); + } + final entry = desktopEntryText(exe); + final file = File('$share/applications/$desktopFileName'); + if (file.existsSync() && file.readAsStringSync() == entry) return; + await file.parent.create(recursive: true); + await file.writeAsString(entry); + } catch (e) { + debugPrint('desktop entry: $e'); + } +} + +@visibleForTesting +String desktopEntryText(String exe) => '''[Desktop Entry] +Type=Application +Name=Watch together +Comment=Watch videos in sync with friends +Exec="$exe" +Icon=$appId +StartupWMClass=$appId +Categories=AudioVideo;Video;Player;Network; +Terminal=false +'''; diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 12db6ec..bb72805 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -16,6 +16,7 @@ import '../sync/state_diff.dart'; import '../sync/user_seek.dart'; import 'frame_watchdog.dart'; import 'media_marks.dart'; +import 'yt_dlp_app.dart' show ytdlFormat; /// Owns one mpv [Player] (and its [VideoController]) per session: created /// by [init] on join, released by [dispose] on leave, swapped by @@ -60,6 +61,26 @@ class PlaybackController { /// Called once when the watchdog sees a frozen picture. void Function()? onFrozen; + /// mpv's yt-dlp hook could not open the current link (its error text). + void Function(String error)? onYtdlError; + + /// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web + /// page links, at most 1080p. Once per player. + Future useYtDlp(String path) async { + final p = _player; + if (p == null || identical(p, _ytdlSetOn)) return; + try { + final dynamic platform = p.platform; + await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path'); + await platform?.setProperty('ytdl-format', ytdlFormat); + _ytdlSetOn = p; + } catch (e) { + debugPrint('yt-dlp path not set: $e'); + } + } + + Player? _ytdlSetOn; + /// Fingerprint of the picture on screen, installed by the video view /// (`ShownFrameProbe.sample`). Without it the watchdog stays idle. Future Function()? shownFrame; @@ -298,6 +319,11 @@ class PlaybackController { // an error there while shaders are on is a shader that failed to load // or compile (mpv then renders without it). void _onLog(PlayerLog log) { + if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) { + final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), ''); + if (text.isNotEmpty) onYtdlError?.call(text); + return; + } if (log.prefix != 'libmpv_render') return; if (log.text.contains('suspected software renderer')) { _setRenderMode(RenderMode.softwareGl); diff --git a/apps/desktop/lib/src/playback/yt_dlp_app.dart b/apps/desktop/lib/src/playback/yt_dlp_app.dart new file mode 100644 index 0000000..7d19144 --- /dev/null +++ b/apps/desktop/lib/src/playback/yt_dlp_app.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:sync_relay_core/core.dart'; + +/// mpv's `ytdl-format` for links every player opens itself. +const ytdlFormat = 'bv*[height<=1080]+ba/b'; + +/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with +/// the hosted server. [probe] remembers what pasted links are for a while, +/// so the Link preview and the swap ask once. +class AppYtDlp { + static final instance = AppYtDlp(); + + /// [ytDlp]: a given one (tests); default in the app-support folder. + AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp); + + Future? _ytDlp; + Future get ytDlp => _ytDlp ??= () async { + final base = await getApplicationSupportDirectory(); + return YtDlp(dir(base.path)); + }(); + + static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp'; + + /// Gets yt-dlp ready in the background (download on first run, a daily + /// update check afterwards). + void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null)); + + final _probes = )>{}; + static const _probeTtl = Duration(minutes: 10); + + Future probe(String url) { + final now = DateTime.now(); + _probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl); + final hit = _probes[url]; + if (hit != null) return hit.$2; + final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y)); + _probes[url] = (now, f); + f.catchError((Object _) { + _probes.remove(url); + return LinkProbe.file(Uri(), -1); + }); + return f; + } +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 44b398d..50f0790 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:signals/signals_flutter.dart'; @@ -12,6 +13,7 @@ import '../jellyfin/session_media.dart'; import '../jellyfin/track_prefs.dart'; import '../playback/media_marks.dart'; import '../playback/playback_controller.dart'; +import '../playback/yt_dlp_app.dart'; import '../schedule/anilist.dart'; import '../screens/home/picked_media.dart'; import '../sfx/sfx.dart'; @@ -132,7 +134,10 @@ final class _FileOrigin extends _RelayOrigin { final class _LinkOrigin extends _RelayOrigin { final String url; - const _LinkOrigin(this.url); + /// What the link is (a page goes through [ytDlp]). + final LinkProbe probe; + final YtDlp ytDlp; + const _LinkOrigin(this.url, this.probe, this.ytDlp); } /// A Jellyfin item: served with this install's account, else with the @@ -159,6 +164,8 @@ class SessionCoordinator { /// Set by the hosting app: makes a local file the hosted one (File swap); /// null when this app is not the host. Future<({String name, int size})?> Function(String path)? hostFile; + /// The app's yt-dlp and link probes (tests pass their own). + final AppYtDlp ytDlp; final SyncClient sync = SyncClient(); /// Member pictures by `avatarHash`, fetched from the server. late final AvatarCache avatars = AvatarCache(sync); @@ -293,8 +300,9 @@ class SessionCoordinator { bool _jellyfinListening = false; SessionCoordinator( - {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs}) - : jellyfin = jellyfin ?? JellyfinAccounts.instance, + {this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp}) + : ytDlp = ytDlp ?? AppYtDlp.instance, + jellyfin = jellyfin ?? JellyfinAccounts.instance, trackPrefs = trackPrefs ?? TrackPrefs(); /// Player lifetime = one session: created here on connect, disposed by @@ -304,6 +312,7 @@ class SessionCoordinator { if (_playerReady) return; try { playback.onFrozen = () => unawaited(reloadVideo(auto: true)); + playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e"); playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6); await playback.init(); _playerReady = true; @@ -456,6 +465,7 @@ class SessionCoordinator { diff.reset(); _opening = true; try { + await _prepareYtDlp(url); await playback.openUrl(url, headers: headers); health.value = const HealthStatus(HealthSeverity.ok, 'connected'); @@ -470,8 +480,30 @@ class SessionCoordinator { } } + /// A web link (not this server's): mpv's yt-dlp hook gets the app's + /// managed yt-dlp, downloaded first if needed; a media file name does + /// not wait for that. + Future _prepareYtDlp(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return; + if (uri.host == _wsHost && uri.port == _wsPort) return; + try { + final y = await ytDlp.ytDlp; + final ready = y.ensure(); + final path = hasMediaExtension(uri) + ? y.path + : await ready.timeout(const Duration(seconds: 90), onTimeout: () => null); + if (path != null) await playback.useYtDlp(path); + } catch (e) { + debugPrint('yt-dlp not ready: $e'); + } + } + bool _relayWaitShown = false; + /// The server-fetched media this viewer opens once it is live. + int _awaitLiveFor = -1; + /// Relayed through a member whose channel is down: say who everyone is /// waiting for (the buffer still plays what it has). void _relayStatus(StateBroadcast r) { @@ -494,7 +526,7 @@ class SessionCoordinator { } String _relayMediaUrlFor(String name, int mediaId) => - 'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}'; + 'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${relayUrlName(name)}'; /// Serves [bytes] for media [mediaId] on this member's relay channel. /// The server only pulls while someone watches through it. @@ -617,9 +649,19 @@ class SessionCoordinator { _startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true); } else if (origin is _LinkOrigin) { await _openMedia(origin.url); - _startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true); + _startRelaySource( + r.mediaId, LinkRelayBytes(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe), + announce: true); } else if (relay != null && !mine) { - await _openMedia(_relayMediaUrlFor(name, r.mediaId)); + if (relay.fetch && !relay.live) { + // The server is still getting it (yt-dlp takes seconds; mpv + // gives up on a silent request after 5): open once it is live. + _openUrl = null; + _awaitLiveFor = r.mediaId; + health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…'); + } else { + await _openMedia(_relayMediaUrlFor(name, r.mediaId)); + } } else { var url = stateMediaUrl(r); if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { @@ -635,6 +677,9 @@ class SessionCoordinator { } _seekGate.reset(); _clearPendingSeek(); + } else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) { + _awaitLiveFor = -1; + await _openMedia(_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId)); } _jellyfinTransport(r); _relayStatus(r); @@ -1060,8 +1105,11 @@ class SessionCoordinator { if (ref != null && client != null) { bytes = await jellyfinArt(client, ref.itemId); } else if (ref == null && (media.url ?? '').isNotEmpty) { - // A web link: its name is a URL tail ("1"), not a show; never guess. - bytes = null; + // A web link is not a show: never guess. A page's thumbnail is + // its banner. + final thumb = (await _probeFor(media.url!))?.info?.thumbnail; + final uri = thumb == null ? null : Uri.tryParse(thumb); + bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null); } else { final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; bytes = await aniListArt(_aniList ??= AniListClient(), name); @@ -1089,33 +1137,58 @@ class SessionCoordinator { _swap(media.copyWith(relay: const MediaRelay())); return null; } + LinkProbe? probe; + String? probeError; + try { + probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45)); + } catch (e) { + probeError = '$e'; + } + // A page is named by its title, never its URL tail ("1", "watch"). + final uri = Uri.tryParse(url) ?? Uri(); + final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); if (relay == LinkRelay.direct) { - _swap(media); + _swap(HelloMedia(name: name, size: 0, duration: 0, url: url)); return null; } - // Relayed: it must be a single file the relay can range-read (not a - // page like YouTube, which only the player can resolve). - final probe = HttpRelayBytes(Uri.parse(url)); - final int size; - try { - size = await probe.size().timeout(const Duration(seconds: 15)); - } catch (e) { - return relay == LinkRelay.me - ? 'cannot pass this link on: $e' - : 'cannot check this link: $e'; - } finally { - await probe.close(); - } if (relay == LinkRelay.me) { - _pendingOrigin = _LinkOrigin(url); - _swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay())); - } else { - _swap(HelloMedia( - name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true))); + // This computer reads it: a file, or a page's single-file format. + if (probe == null) return 'cannot pass this link on: $probeError'; + final problem = probe.relayProblem; + if (problem != null) return problem; + final y = await ytDlp.ytDlp; + final check = LinkRelayBytes(uri, ytDlp: y, probe: probe); + final int size; + try { + size = await check.size().timeout(const Duration(seconds: 15)); + } catch (e) { + return 'cannot pass this link on: $e'; + } finally { + await check.close(); + } + _pendingOrigin = _LinkOrigin(url, probe, y); + _swap(HelloMedia(name: name, size: size, duration: 0, relay: const MediaRelay())); + return null; } + // The server reads it with its own yt-dlp; only a page this app knows + // has no single file is refused here (the server may reach what this + // computer cannot). + if (probe != null && probe.isPage && probe.info != null && probe.info!.single == null) { + return noSingleFileText; + } + _swap(HelloMedia( + name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true))); return null; } + Future _probeFor(String url) async { + try { + return await ytDlp.probe(url).timeout(const Duration(seconds: 45)); + } catch (_) { + return null; + } + } + /// A Jellyfin pick (browser, next / previous episode) goes up as a ref, /// relayed by this member for viewers without an account. void swapJellyfin(JellyfinMedia media) { diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index dca5f0a..001a7d2 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText; +import '../playback/yt_dlp_app.dart'; import '../screens/home/media_picker.dart'; import '../state/connect_prefs.dart'; import 'motion.dart'; @@ -109,49 +110,49 @@ class LinkPreview { const LinkPreview({required this.title, required this.subtitle, this.thumbnail}); } -/// GET [uri] and return the body (oEmbed). -typedef OEmbedGetter = Future Function(Uri uri); +/// What a link is (see [AppYtDlp.probe]). +typedef LinkProber = Future Function(String url); -Future defaultOEmbedGet(Uri uri) async { - final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); - try { - final req = await client.getUrl(uri); - final res = await req.close().timeout(const Duration(seconds: 8)); - if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri); - return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8)); - } finally { - client.close(force: true); - } -} - -bool isYouTubeLink(Uri uri) { - final h = uri.host.toLowerCase(); - return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com'); -} +Future defaultLinkProber(String url) => AppYtDlp.instance.probe(url); String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host; -/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title, -/// channel and thumbnail; anything else (or a failed lookup) shows the -/// file name and host. Null for text that is not an http(s) link. -Future previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async { +/// "3:07", "1:02:03". +String clockText(double seconds) { + final s = seconds.round(); + final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0'); + return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec'; +} + +/// The preview of [url]: a web page shows yt-dlp's title, uploader, +/// duration and thumbnail (or why it is not a video); a media file (by +/// name or by probe) its file name and host. Null for text that is not an http(s) link. +Future previewLink(String url, {LinkProber probe = defaultLinkProber}) async { final uri = Uri.tryParse(url.trim()); if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null; - final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri)); - if (!isYouTubeLink(uri)) return plain; + final host = _host(uri); + // A media file name needs no lookup. + if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); + final LinkProbe p; try { - final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'})); - final j = jsonDecode(body); - if (j is! Map || j['title'] is! String) return plain; - final author = j['author_name']; - return LinkPreview( - title: j['title'] as String, - subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '), - thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null, - ); - } catch (_) { - return plain; + p = await probe(url.trim()); + } catch (e) { + return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e'); } + final info = p.info; + if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host); + if (info == null) { + return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}'); + } + return LinkPreview( + title: p.title ?? host, + subtitle: [ + host, + if (info.uploader.isNotEmpty) info.uploader, + if (info.duration case final d? when d > 0) clockText(d), + ].join(' · '), + thumbnail: info.thumbnail, + ); } /// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ... @@ -195,14 +196,14 @@ class LibraryLinkSource extends StatefulWidget { /// Puts the link up; returns an error text, or null when sent. final Future Function(LinkMedia media) onPutUp; final RecentLinks recents; - final OEmbedGetter oembed; + final LinkProber probe; const LibraryLinkSource({ super.key, required this.controller, required this.onPutUp, required this.recents, this.offlineHint, - this.oembed = defaultOEmbedGet, + this.probe = defaultLinkProber, }); @override @@ -246,7 +247,7 @@ class _LibraryLinkSourceState extends State { if (mounted) setState(() {}); return; } - final p = await previewLink(url, get: widget.oembed); + final p = await previewLink(url, probe: widget.probe); _previews[url] = p; if (mounted) setState(() {}); } @@ -272,7 +273,7 @@ class _LibraryLinkSourceState extends State { error = '$e'; } if (error == null) { - final p = _previews[url] ?? await previewLink(url, get: widget.oembed); + final p = _previews[url] ?? await previewLink(url, probe: widget.probe); unawaited(widget.recents.add(RecentLink( url: url, title: p?.title ?? LinkMedia(url).label, @@ -346,7 +347,7 @@ class _LibraryLinkSourceState extends State { Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall), const SizedBox(height: 6), Text( - 'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.', + 'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.', textAlign: TextAlign.center, style: TextStyle(color: scheme.onSurfaceVariant), ), diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart index 871cabe..ce2f09d 100644 --- a/apps/desktop/lib/src/shell/library_tab.dart +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -83,7 +83,7 @@ class LibraryTab extends StatefulWidget { final VoidCallback? onOpenIntegrations; /// Defaults to [RecentLinks.instance]. final RecentLinks? recentLinks; - final OEmbedGetter oembed; + final LinkProber probe; /// The Files source (tests inject one); defaults to [FilesPage]. final Widget Function(BuildContext context, {required bool online, required bool relayed, required Future Function(String path) onPlay, String? nowPlayingPath})? filesBuilder; @@ -100,7 +100,7 @@ class LibraryTab extends StatefulWidget { this.jellyfinStart, this.onOpenIntegrations, this.recentLinks, - this.oembed = defaultOEmbedGet, + this.probe = defaultLinkProber, this.filesBuilder, this.now = DateTime.now, }); @@ -245,7 +245,7 @@ class _LibraryTabState extends State { controller: _c, offlineHint: _hint, recents: widget.recentLinks ?? RecentLinks.instance, - oembed: widget.oembed, + probe: widget.probe, onPutUp: _put, ), MediaKind.file => _files(context), diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 7bcde37..a4bdac2 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show YtDlp; import '../controls/player_settings.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/accounts_panel.dart'; +import '../playback/yt_dlp_app.dart'; import '../schedule/animeflow.dart'; import '../screens/home/about_page.dart'; import '../screens/home/settings_page.dart'; @@ -107,6 +109,7 @@ class SettingsTab extends StatelessWidget { child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()), ), const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()), + const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()), ]; case SettingsSection.integrations: final accounts = jellyfin ?? JellyfinAccounts.instance; @@ -352,6 +355,67 @@ class RecentServersPanel extends StatelessWidget { } } +/// The app's yt-dlp (web page links): its version, "Update now", and the +/// last error. +class YtDlpSettings extends StatefulWidget { + /// Defaults to [AppYtDlp.instance]'s. + final Future? ytDlp; + const YtDlpSettings({super.key, this.ytDlp}); + + @override + State createState() => _YtDlpSettingsState(); +} + +class _YtDlpSettingsState extends State { + late final Future _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return FutureBuilder( + future: _y, + builder: (context, snap) { + final y = snap.data; + if (y == null) return const SizedBox.shrink(); + return StreamBuilder( + stream: y.changes, + builder: (context, _) { + final err = y.lastError; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.travel_explore), + title: Text(y.busy + ? 'Downloading…' + : y.version == null + ? 'Not downloaded yet' + : 'Version ${y.version}'), + subtitle: err == null + ? null + : Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)), + trailing: TextButton( + onPressed: y.busy + ? null + : () => y.path == null ? y.ensure() : y.update(force: true), + child: const Text('Update now'), + ), + ), + Text( + 'Plays links to web pages (YouTube, X, …) in all three link modes. ' + 'Downloaded and kept up to date by the app; checked once a day.', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), + ), + ], + ); + }, + ); + }, + ); + } +} + /// AnimeFlow (the Schedule tab's source): connected as whom, with which /// rights, or why not. class AnimeFlowCard extends StatefulWidget { diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index a232e10..b3b5fe9 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/session_media.dart'; +import '../playback/yt_dlp_app.dart'; import '../sfx/sfx.dart'; import '../sfx/synth_sfx.dart'; import '../session/health.dart'; @@ -165,6 +166,16 @@ class SessionState { /// Set when the hosted server died mid-session; Home shows it on the /// Host card and clears it. final serverCrash = signal(null); + + /// The app's yt-dlp folder for the hosted server; '' (the server's own + /// default) when the app-support folder is not available. + static Future _sharedYtDlpDir() async { + try { + return (await AppYtDlp.instance.ytDlp).dir; + } catch (_) { + return ''; + } + } ServerIsolate? _server; String? _setupCrash; @@ -258,6 +269,7 @@ class SessionState { mediaPath: params.mediaPath, hostKey: hostKey, jellyfin: relay, + ytDlpDir: await _sharedYtDlpDir(), ), onLog: (line) => debugPrint('[server] $line'), onCrash: (cause) => _onServerCrash(server, cause), diff --git a/apps/desktop/linux/CMakeLists.txt b/apps/desktop/linux/CMakeLists.txt index 09ac0f9..54f226b 100644 --- a/apps/desktop/linux/CMakeLists.txt +++ b/apps/desktop/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "watch_together") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.watch_together") +set(APPLICATION_ID "moe.senkochan.watch_together") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/apps/desktop/linux/runner/my_application.cc b/apps/desktop/linux/runner/my_application.cc index 1642d3e..cd74d43 100644 --- a/apps/desktop/linux/runner/my_application.cc +++ b/apps/desktop/linux/runner/my_application.cc @@ -54,6 +54,19 @@ static void my_application_activate(GApplication* application) { gtk_window_set_default_size(window, 1280, 720); + // The window icon (X11; Wayland takes it from the .desktop file the app + // installs for its application id). + { + g_autofree gchar* exe = g_file_read_link("/proc/self/exe", nullptr); + if (exe != nullptr) { + g_autofree gchar* dir = g_path_get_dirname(exe); + g_autofree gchar* icon = g_build_filename( + dir, "data", "flutter_assets", "assets", "icon", "icon_256.png", + nullptr); + gtk_window_set_icon_from_file(window, icon, nullptr); + } + } + g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( project, self->dart_entrypoint_arguments); diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index 7b58c80..da49832 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -38,3 +38,4 @@ flutter: uses-material-design: true assets: - assets/shaders/anime4k/ + - assets/icon/ diff --git a/apps/desktop/test/library_rows_test.dart b/apps/desktop/test/library_rows_test.dart index a97f85d..3b03cc4 100644 --- a/apps/desktop/test/library_rows_test.dart +++ b/apps/desktop/test/library_rows_test.dart @@ -189,23 +189,21 @@ void main() { expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv'); }); - test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async { - Uri? asked; - final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async { - asked = uri; - return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'}); - }); - expect(asked!.host, 'www.youtube.com'); - expect(asked!.path, '/oembed'); - expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'}); - expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg')); + test('preview: a page by yt-dlp, a file by its name, else why not', () async { + final yt = await previewLink('https://www.youtube.com/watch?v=abc', + probe: (url) async => LinkProbe.page(Uri.parse(url), + info: const YtDlpInfo( + title: 'A video', uploader: 'Chan', duration: 187, thumbnail: 'https://i.ytimg/x.jpg'))); + expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan · 3:07', 'https://i.ytimg/x.jpg')); final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1', - get: (_) => throw StateError('no lookup for plain links')); + probe: (url) async => LinkProbe.file(Uri.parse(url), 10)); expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null)); - final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline')); - expect((down!.title, down.subtitle), ('abc', 'youtu.be')); + final page = await previewLink('https://example.com/a', + probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL')); + expect((page!.title, page.subtitle), + ('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)")); expect(await previewLink('not a link'), isNull); }); diff --git a/apps/desktop/test/library_tab_test.dart b/apps/desktop/test/library_tab_test.dart index f03d74a..471de2b 100644 --- a/apps/desktop/test/library_tab_test.dart +++ b/apps/desktop/test/library_tab_test.dart @@ -108,10 +108,9 @@ Future pumpLibrary( jellyfinStart: start, recentLinks: h.recents, now: () => fixtureNow, - oembed: (uri) async => jsonEncode({ - 'title': 'A video', - 'author_name': 'Chan', - }), + probe: (url) async => url.endsWith('.mkv') + ? LinkProbe.file(Uri.parse(url), 1) + : LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')), filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) => FilesStub(online: online, relayed: relayed, onPlay: onPlay), onPutUp: (m) async { diff --git a/apps/desktop/test/relay_e2e_test.dart b/apps/desktop/test/relay_e2e_test.dart index e0096ef..4fe8859 100644 --- a/apps/desktop/test/relay_e2e_test.dart +++ b/apps/desktop/test/relay_e2e_test.dart @@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_server/server.dart'; import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; +import 'package:watch_together/src/playback/yt_dlp_app.dart'; import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:watch_together/src/session/session_coordinator.dart'; @@ -67,7 +68,10 @@ void main() { Future member(String name) async { final acc = JellyfinAccounts(store: MemoryJellyfinStore()); await acc.ensureLoaded(validate: false); - final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore())); + final c = SessionCoordinator( + jellyfin: acc, + trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), + ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false))); all.add(c); await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name)); await c.playback.player.setVolume(0); @@ -150,7 +154,7 @@ void main() { await playTogether([alice, bob]); expect(hits.length, greaterThan(before)); - // A page instead of a file is refused up front. + // A page yt-dlp cannot read is refused up front. final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); page.listen((r) { r.response.headers.contentType = ContentType.html; @@ -158,7 +162,7 @@ void main() { r.response.close(); }); expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)), - contains('cannot pass this link on')); + contains('Not a video')); await page.close(force: true); } finally { await origin.close(force: true); diff --git a/apps/desktop/windows/runner/resources/app_icon.ico b/apps/desktop/windows/runner/resources/app_icon.ico index c04e20c..3990c9a 100644 Binary files a/apps/desktop/windows/runner/resources/app_icon.ico and b/apps/desktop/windows/runner/resources/app_icon.ico differ diff --git a/deploy/watch-together.service b/deploy/watch-together.service index 00be23a..8d0d7f6 100644 --- a/deploy/watch-together.service +++ b/deploy/watch-together.service @@ -4,7 +4,9 @@ # /etc/systemd/system/, then `systemctl enable --now watch-together`. # Flags: --port (default 8080), --secret (empty = open # server, anyone who can reach the port can join), --name, --media , -# --advertise-host , --config . See README. +# --advertise-host , --no-yt-dlp, --yt-dlp-dir , --config +# . See README. The server's yt-dlp (for web page links it +# fetches) is downloaded into the state directory below. # Prefer --config with a root-only file over --secret here: unit files and # process lists are readable by other users. @@ -17,6 +19,7 @@ Wants=network-online.target ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me Restart=on-failure DynamicUser=true +StateDirectory=watch-together NoNewPrivileges=true [Install] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 961389c..10083cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,7 +43,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` and shipped next to the app as `watch-together-server`. It parses flags (`--port`, `--secret`, `--name`, `--media`, `--advertise-host`, - `--jellyfin `, `--config`; default port 8080) into the same map and calls `serve`; + `--jellyfin `, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--config`; default port 8080) into the same map and calls `serve`; SIGINT and SIGTERM stop it. A Flutter binary cannot run without a display, which is why the server is its own build. @@ -160,7 +160,8 @@ Three sources, same sync. Any of them can also go through the relay - **Link**: the media field holds an http(s) URL. It travels as `media.url` in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it - directly; the server is only the clock. A pasted link that + directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the + server is only the clock. A pasted link that `parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the ServerId is known (in the link, else `/System/Info/Public`), so its `api_key` is never shared (`mediaForLink`, host and swap alike). @@ -394,14 +395,14 @@ What gets relayed (`SessionCoordinator.swapPicked`): | Link, "Through me" | relayed from that member's computer; for links only they can open | | Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) | -Relayed links must be one file the relay can range-read: a `text/*` -response (YouTube and other pages, which only the player resolves) is -refused before the swap. +Relayed links are range-read as one file: a media file as is, a web page +as yt-dlp's single-file format, resolved where the bytes are fetched (see +"yt-dlp"); the swap carries only the page URL and the mode. How bytes move: - The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and answers the server's pulls from the file or the URL (`RelaySource`, - `FileRelayBytes`, `HttpRelayBytes`; a Jellyfin source sends its own + `FileRelayBytes`, `HttpRelayBytes`, `LinkRelayBytes`; a Jellyfin source sends its own `Authorization` header from this machine). Viewers only ever talk HTTP to the server, so the source never sees mpv's request pattern. - The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in @@ -456,6 +457,62 @@ so for quick seeks. Rejected along the way: a fixed per-request window pipe), one shared window across requests (deadlocked on mpv's unread requests), Dart `http2` (connection window stuck at 64 KiB). +## yt-dlp + +Web pages with a video (YouTube, Twitter/X, ...) play through yt-dlp in +all three link modes. Nothing is bundled or installed by hand. + +- **Manager** (`YtDlp`, `packages/core/lib/src/yt_dlp.dart`, used by the + app and the server): the managed copy in its folder (`yt-dlp` / + `yt-dlp.exe`) first; if missing it downloads the official standalone + build for the platform (`ytDlpAssetName`: `yt-dlp_linux`, + `yt-dlp_linux_aarch64`, musl variants, `yt-dlp.exe`, ...) from the tag + `releases/latest` redirects to, checks it against that release's + `SHA2-256SUMS`, makes it executable (not on Windows) and renames it into + place. If the download fails, a `yt-dlp` on PATH is the fallback. After + that, at most once a day (`last-check` stamp in the folder) and in the + background, a newer tag is downloaded the same way; nothing waits for + it. `version`, `lastError`, `busy` and `changes` feed Settings → Player + → yt-dlp ("Update now" forces the check). Every failure is non-fatal. + Folders: the app's is `/yt-dlp` (`AppYtDlp`, warmed up at + start), shared with its hosted server; the dedicated server's is + `--yt-dlp-dir`, else `$STATE_DIRECTORY/yt-dlp` (systemd), else + `yt-dlp` next to the binary if writable, else + `~/.cache/watch-together/yt-dlp`. +- **Resolve** (`YtDlp.resolve`): `yt-dlp -J --no-playlist --no-warnings + --ignore-config`, 25 s timeout: title, uploader, duration, thumbnail, + and the best single-file format (`pickSingleFile`: video and audio not + `none`, plain http(s), a video container, tallest up to 1080p) with its + URL and `http_headers`; none when the page has only split or HLS + streams (YouTube today). +- **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a + video/audio type, or a generic one on a media file name, is a file + (`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp + cannot read it the page's `og:title` / `` is kept with yt-dlp's + reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app + probes on paste (`AppYtDlp.probe`, remembered 10 minutes) for the Link + preview and the swap: a page is named by its title, never its URL tail, + and its thumbnail goes up as the banner (no poster). +- **Everyone fetches it**: the swap sends the page URL. Before opening an + external link every app points mpv's hook at its managed copy + (`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`, + once per player), waiting for the download unless the URL names a media + file. A hook error shows as "can't open this link: ...". +- **Through me**: the putter's probe must give a single-file format, else + the swap is refused with a hint to use "Everyone fetches it". Its relay + source reads that format with yt-dlp's headers (`PageRelayBytes`); on + 403/410 (expired format URL) it resolves the page again once and goes + on from where it stopped. +- **Server fetches it**: the server probes the link itself + (`LinkRelayBytes`) with its own yt-dlp (off with `--no-yt-dlp` / + `ytDlp: false`) and relays the single-file format the same way. The + putter only refuses a page its own yt-dlp says has no single file (the + server may reach what the putter cannot); when the server cannot read + it, the putter gets an `err` with the reason. Viewers open a fetched + link's `/r/` URL only once `relay.live` is set ("the server is getting + the video…" until then): resolving takes seconds and media_kit's mpv + gives up on a silent request after 5 s (`network-timeout`). + ## Jellyfin in a session Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, @@ -581,8 +638,8 @@ always scrolls the page). Space never presses a focused button watched / unwatched, remove from Continue, overview, the episode list (click picks the episode, double-click puts it up). A double-click on a card puts it up (a series: `playableFor`, its next episode). Link - (`library_link.dart`): paste field, a preview (YouTube oEmbed title and - thumbnail, else the file name), the three relay modes online, and the + (`library_link.dart`): paste field, a preview (a page's yt-dlp title, + uploader, length and thumbnail, else the file name; see "yt-dlp"), the three relay modes online, and the last 12 links put up (`recentLinks` in the `connect_form` box). Files is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in an isolate, videos grouped by series; series start collapsed, a header @@ -695,8 +752,8 @@ always scrolls the page). Space never presses a focused button Settings persist in two Hive boxes: `connect_form` (last form, recent servers and links, the profile picture and colour; saved even when a join fails) and -`settings`. Link media names are derived from the URL path and never -contain a query string. +`settings`. Link media names are a web page's title (see "yt-dlp"), else +derived from the URL path, never containing a query string. Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): - `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one @@ -811,6 +868,18 @@ displayable state. The server's `GET /health` returns JSON (`ok`, `name`, it carries nothing sensitive. The Join card's recent servers poll it for live status. +## App icon and launcher entry + +`assets/icon/icon.svg` is the source; `icon_<size>.png` and the Windows +`app_icon.ico` are rendered from it (rsvg-convert, magick). The GTK +application id is `moe.senkochan.watch_together`, which Wayland uses as the +window's `app_id`; compositors take the icon from the `.desktop` file of +that id. A tarball install has none, so on start a Linux release build +(`platform/desktop_entry.dart`, skipped in debug) writes +`~/.local/share/applications/moe.senkochan.watch_together.desktop` (Exec = +the running binary) and the icons into the hicolor theme, only when they +changed. The runner also sets the window icon from the bundle (X11). + ## Updater `apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested). diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 175e89e..6e8be51 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -79,7 +79,9 @@ anyone except the host. ## Where video comes from -- **Link**: you paste an http(s) URL. Every app plays it directly with mpv. +- **Link**: you paste an http(s) URL. Every app plays it directly with mpv; + a web page (YouTube, X, ...) goes through yt-dlp, which the app and the + server download and update by themselves. A pasted Jellyfin link becomes the Jellyfin item below when possible. - **Jellyfin item**: picked in the app's library browser. Shared as "this item on this Jellyfin server", never as a link with someone's sign-in. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 622ceb1..105eb61 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -228,13 +228,16 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true} `url` (else `relay fetch needs an http(s) url`), which the server keeps to itself, and a server with a password (else `server fetch needs a server password`: an open server would fetch any address it can reach - for anyone). Any later swap ends the relay. + for anyone). A web page `url` is read through the server's yt-dlp; when + the server cannot read the link, the swapper later gets `The server + can't fetch this link: <reason>`. Any later swap ends the relay. - `state.media.relay.source`: member id of the source (absent with `fetch`). `live` (omitted when false): bytes can flow (the source's channel is connected, or the server could read the link). - `welcome.mediaUrl` for relayed media without `jellyfin`: this member's `/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one: - `http://<host>/r/default/<token>/<mediaId>/<name>` (name URL-encoded). + `http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and + `\` made `_`, then URL-encoded; the server ignores it). - `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for `/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed media (a player still on an older `mediaId` gets 404, never the new diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8d8eb57..28fa368 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,9 +14,11 @@ off by deleting it in the same commit as the code. Wire changes also edit 2. **Files**: next episode from Files (by parsed episode, with the countdown). Later: tracking for file series. -3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl - hook (bundled on Windows); then relay of single-file formats; then a - resolver for the Link preview (title, thumbnail, duration). +3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the + bundled Windows libmpv has the ytdl hook (Lua); if not, resolve in the + app and open the single-file format. YouTube: yt-dlp wants a JS runtime + (deno) for full extraction, and it only has split streams, so relaying + it would need muxing. Formats that need yt-dlp's cookies. 4. **AniList account**: sign in (OAuth via a localhost callback), "My list" filter on the Schedule; today's "Following" (AnimeFlow's monitored diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 658950b..21fa406 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -5,6 +5,8 @@ export 'src/clock_sync.dart'; export 'src/config.dart'; export 'src/protocol.dart'; export 'src/relay.dart'; +export 'src/link_probe.dart'; +export 'src/yt_dlp.dart'; export 'src/media_source.dart'; export 'src/sync_engine.dart'; export 'src/jellyfin/client.dart'; diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart index b4f0dd9..4e6d12a 100644 --- a/packages/core/lib/src/config.dart +++ b/packages/core/lib/src/config.dart @@ -105,6 +105,12 @@ class AppConfig { final String hostKey; /// Optional login for relaying Jellyfin media to guests; null = no relay. final JellyfinCredential? jellyfin; + /// "The server fetches it" reads web pages (YouTube, Twitter/X, ...) + /// through its own yt-dlp, downloaded on first use. + final bool ytDlp; + /// Where that yt-dlp lives (writable); empty: a `yt-dlp` folder next to + /// the server binary, else `~/.cache/watch-together/yt-dlp`. + final String ytDlpDir; const AppConfig({ required this.port, @@ -115,6 +121,8 @@ class AppConfig { this.advertiseHost = '', this.hostKey = '', this.jellyfin, + this.ytDlp = true, + this.ytDlpDir = '', }); factory AppConfig.defaults() => @@ -131,6 +139,8 @@ class AppConfig { advertiseHost: (map['advertiseHost'] as String?) ?? '', hostKey: (map['hostKey'] as String?) ?? '', jellyfin: JellyfinCredential.fromMap(map['jellyfin']), + ytDlp: (map['ytDlp'] as bool?) ?? true, + ytDlpDir: (map['ytDlpDir'] as String?) ?? '', ); } on ConfigException { rethrow; @@ -148,6 +158,8 @@ class AppConfig { 'advertiseHost': advertiseHost, 'hostKey': hostKey, if (jellyfin != null) 'jellyfin': jellyfin!.toMap(), + 'ytDlp': ytDlp, + 'ytDlpDir': ytDlpDir, }; static Future<AppConfig> loadFile(String path) async => diff --git a/packages/core/lib/src/link_probe.dart b/packages/core/lib/src/link_probe.dart new file mode 100644 index 0000000..eff44ea --- /dev/null +++ b/packages/core/lib/src/link_probe.dart @@ -0,0 +1,304 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'relay.dart'; +import 'yt_dlp.dart'; + +// What a pasted link is: a media file (range-readable as is) or a web page +// that yt-dlp turns into one. Used where the bytes are fetched: the +// putter's app (preview, "I pass it on") and the server ("the server +// fetches it"). + +const _mediaExtensions = { + 'mkv', 'mp4', 'm4v', 'webm', 'mov', 'avi', 'wmv', 'flv', 'ts', 'm2ts', 'mpg', 'mpeg', 'ogv', // + 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'wav', +}; + +const _fileTypes = {'application/mp4', 'application/ogg', 'application/x-matroska'}; +const _genericTypes = {'', 'application/octet-stream', 'binary/octet-stream', 'application/binary'}; + +/// True when a `Range: bytes=0-0` answer ([status], [contentType]'s mime +/// type) means [url] is a media file: a video/audio type, or a generic one +/// on a media file name. Pages, HLS playlists and errors are not. +bool looksLikeMediaFile({required int status, required String contentType, required Uri url}) { + if (status != 200 && status != 206) return false; + final type = contentType.toLowerCase(); + if (type.contains('mpegurl')) return false; + if (type.startsWith('video/') || type.startsWith('audio/') || _fileTypes.contains(type)) return true; + return _genericTypes.contains(type) && hasMediaExtension(url); +} + +/// [url]'s file name ends in a media extension (`.mkv`, `.mp4`, ...). +bool hasMediaExtension(Uri url) { + final last = url.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? ''; + final dot = last.lastIndexOf('.'); + return dot > 0 && _mediaExtensions.contains(last.substring(dot + 1).toLowerCase()); +} + +/// A page's `og:title`, else its `<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]*?)', caseSensitive: false).firstMatch(html); + final v = _unescape(m?.group(1) ?? ''); + return v.isEmpty ? null : v; +} + +String _unescape(String s) => s + .replaceAllMapped(RegExp(r'&#(x?)([0-9a-fA-F]+);'), (m) { + final n = int.tryParse(m.group(2)!, radix: m.group(1)!.isEmpty ? 10 : 16); + return n == null ? m.group(0)! : String.fromCharCode(n); + }) + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + +/// The text for a page yt-dlp cannot read. +String notAVideoText(String? ytDlpError) => + ytDlpError == null ? 'Not a video' : "Not a video (and yt-dlp couldn't read it: $ytDlpError)"; + +/// The text for a page with only split or HLS streams. +const noSingleFileText = 'This page has no single-file video to pass on (only separate video and audio ' + "streams). Choose 'Everyone fetches it' instead."; + +class LinkProbeException implements Exception { + final String message; + const LinkProbeException(this.message); + @override + String toString() => message; +} + +/// What [probeLink] found. +class LinkProbe { + final Uri url; + final bool isPage; + /// A file's byte size; -1 when unknown or a page. + final int size; + /// yt-dlp's answer for a page it could read. + final YtDlpInfo? info; + /// A page's title from its HTML when yt-dlp could not read it. + final String? pageTitle; + /// Why yt-dlp could not read the page. + final String? ytDlpError; + const LinkProbe.file(this.url, this.size) + : isPage = false, + info = null, + pageTitle = null, + ytDlpError = null; + const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError}) + : isPage = true, + size = -1; + + /// The name to show: yt-dlp's title, the page's, else the host; null + /// for a file (named by its file name). + String? get title { + if (!isPage) return null; + final t = info?.title ?? pageTitle ?? ''; + return t.isNotEmpty ? t : url.host; + } + + /// Why a relay cannot read this link, or null when it can. + String? get relayProblem => !isPage + ? null + : info == null + ? notAVideoText(ytDlpError) + : info!.single == null + ? noSingleFileText + : null; +} + +const _probeLimit = 512 * 1024; + +/// Finds out what [url] is: a `Range: bytes=0-0` GET; a media file is +/// done, anything else goes to yt-dlp ([ytDlp] null: no yt-dlp), and when +/// yt-dlp cannot read it, the page's own title. Throws +/// [LinkProbeException] when the link cannot be reached at all. +Future probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async { + final c = client ?? (HttpClient()..connectionTimeout = const Duration(seconds: 10)); + try { + String? body; + Object? getError; + try { + final req = await c.getUrl(url).timeout(const Duration(seconds: 15)); + req.headers + ..set(HttpHeaders.rangeHeader, 'bytes=0-0') + ..set(HttpHeaders.acceptEncodingHeader, 'identity'); + final res = await req.close().timeout(const Duration(seconds: 15)); + final type = res.headers.contentType?.mimeType ?? ''; + if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) { + await res.drain().catchError((_) {}); + final total = res.headers.value(HttpHeaders.contentRangeHeader)?.split('/').last; + final n = int.tryParse(total ?? '') ?? (res.statusCode == 200 ? res.contentLength : -1); + return LinkProbe.file(url, n > 0 ? n : -1); + } + body = type.contains('html') ? await _readText(res) : null; + if (body == null) await res.drain().catchError((_) {}); + } catch (e) { + getError = e; + } + String? ytErr; + if (ytDlp != null) { + try { + return LinkProbe.page(url, info: await ytDlp.resolve(url.toString())); + } catch (e) { + ytErr = '$e'; + } + } + if (getError != null) { + throw LinkProbeException('cannot open the link: $getError${ytErr == null ? '' : ' (yt-dlp: $ytErr)'}'); + } + // A server honouring the Range sent one byte of the page: get it all. + if (body != null && body.length < 2) { + try { + final req = await c.getUrl(url).timeout(const Duration(seconds: 15)); + body = await _readText(await req.close().timeout(const Duration(seconds: 15))); + } catch (_) {} + } + return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr); + } finally { + if (client == null) c.close(force: true); + } +} + +Future _readText(HttpClientResponse res) async { + final out = BytesBuilder(copy: false); + try { + await for (final chunk in res.timeout(const Duration(seconds: 15))) { + out.add(chunk); + if (out.length >= _probeLimit) break; + } + } catch (_) {} + return utf8.decode(out.takeBytes(), allowMalformed: true); +} + +/// A page's single-file format (see [YtDlpInfo.single]), range-read with +/// yt-dlp's headers. Format URLs expire: on 403/410 the page is resolved +/// again once and the read goes on from where it stopped. +class PageRelayBytes implements RelayBytes { + final Uri page; + final YtDlp ytDlp; + YtDlpFormat? _format; + HttpRelayBytes? _inner; + int? _size; + var _gen = 0; + Future? _refreshing; + PageRelayBytes(this.page, this.ytDlp, {YtDlpFormat? format}) : _format = format; + + Future _resolve() async { + final f = (await ytDlp.resolve(page.toString())).single; + if (f == null) throw const LinkProbeException(noSingleFileText); + return f; + } + + Future _open() async => + _inner ??= () { + final f = _format!; + return HttpRelayBytes(f.url, headers: f.headers); + }(); + + static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410); + + Future _refresh(int gen) { + if (gen != _gen) return Future.value(); + return _refreshing ??= () async { + try { + final f = await _resolve(); + final old = _inner; + _format = f; + _inner = null; + _gen++; + await old?.close(); + } finally { + _refreshing = null; + } + }(); + } + + @override + Future size() async { + if (_size != null) return _size!; + _format ??= await _resolve(); + final gen = _gen; + try { + return _size = await (await _open()).size(); + } catch (e) { + if (!_expired(e)) rethrow; + await _refresh(gen); + return _size = await (await _open()).size(); + } + } + + @override + Stream> read(int start, int end) async* { + var pos = start; + var retried = false; + while (true) { + final gen = _gen; + final inner = await _open(); + try { + await for (final chunk in inner.read(pos, end)) { + pos += chunk.length; + yield chunk; + } + return; + } catch (e) { + if (retried || !_expired(e)) rethrow; + retried = true; + await _refresh(gen); + final n = await (await _open()).size(); + if (_size != null && n != _size) throw const LinkProbeException('the video changed on the page'); + } + } + } + + @override + Future close() async => _inner?.close(); +} + +/// A link of either kind: probed on first [size] (or given [probe]), then +/// read as a file ([HttpRelayBytes]) or a page ([PageRelayBytes]). Throws +/// [LinkProbeException] with the reason when it cannot be relayed. +class LinkRelayBytes implements RelayBytes { + final Uri url; + final YtDlp? ytDlp; + LinkProbe? _probe; + RelayBytes? _inner; + LinkRelayBytes(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe; + + /// The probe, after [size]. + LinkProbe? get probe => _probe; + + @override + Future size() async { + if (_inner == null) { + final probe = _probe ??= await probeLink(url, ytDlp: ytDlp); + final problem = probe.relayProblem; + if (problem != null) throw LinkProbeException(problem); + _inner = probe.isPage ? PageRelayBytes(url, ytDlp!, format: probe.info!.single) : HttpRelayBytes(url); + } + return _inner!.size(); + } + + @override + Stream> read(int start, int end) { + final inner = _inner; + if (inner == null) return Stream.error(StateError('size() first')); + return inner.read(start, end); + } + + @override + Future close() async => _inner?.close(); +} diff --git a/packages/core/lib/src/relay.dart b/packages/core/lib/src/relay.dart index c383181..698bcd0 100644 --- a/packages/core/lib/src/relay.dart +++ b/packages/core/lib/src/relay.dart @@ -27,6 +27,11 @@ import 'logging.dart'; final _log = createLogger('relay'); +/// The `` segment of a `/r/` URL: the media name URL-encoded, its +/// slashes made `_` first (a page title can hold a URL, and an encoded +/// slash still splits the path). The server ignores the segment. +String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_')); + /// Binary relay frame: 4-byte big-endian pull id, then payload. Uint8List encodeRelayFrame(int id, List payload) { final out = Uint8List(4 + payload.length); @@ -66,6 +71,12 @@ class FileRelayBytes implements RelayBytes { Future close() async {} } +/// A non-2xx answer from an [HttpRelayBytes] upstream. +class UpstreamHttpException extends HttpException { + final int status; + UpstreamHttpException(this.status, Uri url) : super('upstream HTTP $status', uri: url); +} + /// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin /// stream; [headers] (auth) never leave this machine. class HttpRelayBytes implements RelayBytes { @@ -85,7 +96,7 @@ class HttpRelayBytes implements RelayBytes { final res = await req.close().timeout(const Duration(seconds: 20)); if (res.statusCode != 200 && res.statusCode != 206) { await res.drain().catchError((_) {}); - throw HttpException('upstream HTTP ${res.statusCode}', uri: url); + throw UpstreamHttpException(res.statusCode, url); } return res; } diff --git a/packages/core/lib/src/yt_dlp.dart b/packages/core/lib/src/yt_dlp.dart new file mode 100644 index 0000000..a622622 --- /dev/null +++ b/packages/core/lib/src/yt_dlp.dart @@ -0,0 +1,414 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' show Abi; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import 'logging.dart'; + +// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The +// app and the server each keep their own copy, the official standalone +// build downloaded from GitHub and checked against the release's +// SHA2-256SUMS, refreshed at most once a day. Nothing is installed by hand. + +final _log = createLogger('yt-dlp'); + +class YtDlpException implements Exception { + final String message; + const YtDlpException(this.message); + @override + String toString() => message; +} + +/// One playable format of a resolved page: a URL and the headers yt-dlp +/// says to send with it. +class YtDlpFormat { + final String id; + final Uri url; + final Map headers; + final int? height; + final String ext; + const YtDlpFormat({required this.id, required this.url, this.headers = const {}, this.height, this.ext = ''}); +} + +/// What `yt-dlp -J` says about a page. +class YtDlpInfo { + final String title; + final String uploader; + final String extractor; + final String webpageUrl; + /// Seconds; null when unknown (a live stream). + final double? duration; + final String? thumbnail; + /// The best format holding video and audio in one http(s) file (what a + /// relay can range-read); null when the page only has split or HLS + /// streams. + final YtDlpFormat? single; + const YtDlpInfo({ + required this.title, + this.uploader = '', + this.extractor = '', + this.webpageUrl = '', + this.duration, + this.thumbnail, + this.single, + }); + + /// Parses `yt-dlp -J` output; a playlist answer uses its first entry. + static YtDlpInfo parse(Map j) { + final entries = j['entries']; + if (j['_type'] == 'playlist' && entries is List) { + final first = entries.whereType().firstOrNull; + if (first == null) throw const YtDlpException('the page has no videos'); + return parse(Map.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 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 (` `) as name -> lowercase hex. +Map 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.broadcast(); + /// Fires when [version], [lastError] or [busy] change. + Stream get changes => _changes.stream; + Future? _ensuring; + Future? _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 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 _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 _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 update({bool force = false}) => _updating ??= _update(force).whenComplete(() => _updating = null); + + Future _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 _stamp() async { + try { + await File(_stampPath).writeAsString(DateTime.now().toIso8601String()); + } catch (_) {} + } + + /// The tag `releases/latest` redirects to. + Future _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().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 _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().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> _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().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 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.from(j)); + } on FormatException catch (e) { + throw YtDlpException('unreadable yt-dlp output: ${e.message}'); + } + } + + Future close() => _changes.close(); +} + +class _DigestSink implements Sink { + late Digest value; + @override + void add(Digest data) => value = data; + @override + void close() {} +} diff --git a/packages/core/test/yt_dlp_test.dart b/packages/core/test/yt_dlp_test.dart new file mode 100644 index 0000000..9d8d3f6 --- /dev/null +++ b/packages/core/test/yt_dlp_test.dart @@ -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 _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 , entities decoded', () { + expect(htmlTitle('<title>Plain'), + "Tom & Jerry '24"); + expect(htmlTitle("\n A "page"\n"), 'A "page"'); + expect(htmlTitle('

nothing

'), 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'); +} diff --git a/packages/server/bin/server.dart b/packages/server/bin/server.dart index ef19c7f..e1915b4 100644 --- a/packages/server/bin/server.dart +++ b/packages/server/bin/server.dart @@ -19,6 +19,12 @@ Future main(List args) async { ..addOption('jellyfin', help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) ' 'to relay Jellyfin media to viewers without an account. The device fields must be the ones the token was issued with.') + ..addFlag('yt-dlp', + defaultsTo: true, + help: 'Read web pages (YouTube, Twitter/X, ...) for "the server fetches it" with yt-dlp, ' + 'downloaded on first use.') + ..addOption('yt-dlp-dir', + defaultsTo: '', help: 'Writable folder for that yt-dlp (default: yt-dlp next to this binary).') ..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.') ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.'); final ArgResults parsed; @@ -52,6 +58,8 @@ Future main(List args) async { name: '${parsed['name']}', mediaPath: '${parsed['media']}', advertiseHost: '${parsed['advertise-host']}', + ytDlp: parsed['yt-dlp'] as bool, + ytDlpDir: '${parsed['yt-dlp-dir']}', ); } final jellyfinPath = parsed['jellyfin'] as String?; @@ -66,6 +74,8 @@ Future main(List args) async { advertiseHost: c.advertiseHost, hostKey: c.hostKey, jellyfin: await JellyfinCredential.loadFile(jellyfinPath), + ytDlp: c.ytDlp, + ytDlpDir: c.ytDlpDir, ); } } on ConfigException catch (e) { diff --git a/packages/server/lib/src/relay_routes.dart b/packages/server/lib/src/relay_routes.dart index e9679b0..81feaf0 100644 --- a/packages/server/lib/src/relay_routes.dart +++ b/packages/server/lib/src/relay_routes.dart @@ -34,11 +34,15 @@ class ActiveRelay { {RelayBuffer? buffer, required void Function() onLiveChanged}) => ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged); - /// The server reads [url] itself. + /// The server reads [url] itself: a media file as is, a web page + /// through [ytDlp]. [onFail] gets the reason when it cannot. static ActiveRelay fetch(int mediaId, String name, Uri url, - {RelayBuffer? buffer, required void Function() onLiveChanged}) { + {RelayBuffer? buffer, + YtDlp? ytDlp, + required void Function() onLiveChanged, + void Function(String reason)? onFail}) { final b = buffer ?? RelayBuffer(); - final local = LocalRelayFeed(HttpRelayBytes(url), b); + final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b); final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged); unawaited(() async { try { @@ -48,6 +52,7 @@ class ActiveRelay { onLiveChanged(); } catch (e) { _log.warning('relay fetch: cannot read the link: $e'); + if (!r._disposed) onFail?.call('$e'); } }()); return r; diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 38c7e6e..13dc9da 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -20,9 +20,11 @@ class ServerHub { final Map sockets = {}; /// The server's Jellyfin login for the `/j/...` relay; null = no relay. JellyfinCredential? jellyfin; + /// Reads web pages for "the server fetches it"; null = files only. + final YtDlp? ytDlp; ServerHub(this.sessions, this.expectedSecret, - {SyncEngine? engine, this.hostKey = '', this.jellyfin}) + {SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp}) : engine = engine ?? SyncEngine(); /// The current media is a Jellyfin ref on the server [jellyfin] logs in to. @@ -356,7 +358,9 @@ void mountSyncRoute( late final ActiveRelay active; active = relayReq.fetch ? ActiveRelay.fetch(hub.engine.mediaId, name, link!, - onLiveChanged: () => hub.relayLiveChanged(active)) + ytDlp: hub.ytDlp, + onLiveChanged: () => hub.relayLiveChanged(active), + onFail: (why) => wsErr(ws, "The server can't fetch this link: $why")) : _memberRelay(hub, token, name); hub.setRelay(active); hub.changed(); diff --git a/packages/server/lib/src/sync_server.dart b/packages/server/lib/src/sync_server.dart index cbd56f0..5f99bd8 100644 --- a/packages/server/lib/src/sync_server.dart +++ b/packages/server/lib/src/sync_server.dart @@ -12,6 +12,29 @@ import 'relay_routes.dart'; import 'server_hub.dart'; import 'session.dart'; +/// [configured], else systemd's `$STATE_DIRECTORY/yt-dlp`, else `yt-dlp` +/// next to the server binary when that is writable (not under `dart run`), +/// else `~/.cache/watch-together/yt-dlp`. +String serverYtDlpDir(String configured) { + if (configured.isNotEmpty) return configured; + final sep = Platform.pathSeparator; + final state = (Platform.environment['STATE_DIRECTORY'] ?? '').split(':').first; + if (state.isNotEmpty) return '$state${sep}yt-dlp'; + final exe = File(Platform.resolvedExecutable); + final name = exe.uri.pathSegments.last.toLowerCase(); + if (name != 'dart' && name != 'dart.exe' && name != 'dartaotruntime') { + final dir = Directory('${exe.parent.path}${sep}yt-dlp'); + try { + dir.createSync(recursive: true); + final probe = File('${dir.path}$sep.writable')..writeAsStringSync(''); + probe.deleteSync(); + return dir.path; + } catch (_) {} + } + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; + return '$home$sep.cache${sep}watch-together${sep}yt-dlp'; +} + class SyncServer { final AppConfig config; final pkg_logging.Logger _log = createLogger('sync_server'); @@ -26,7 +49,10 @@ class SyncServer { HttpClient? _relayClient; SyncServer(this.config) { - hub = ServerHub(sessions, config.secret, hostKey: config.hostKey, jellyfin: config.jellyfin); + hub = ServerHub(sessions, config.secret, + hostKey: config.hostKey, + jellyfin: config.jellyfin, + ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp)); } bool get isRunning => _server != null; @@ -78,7 +104,7 @@ class SyncServer { }, relayUrlBuilder: (sid, token, itemId, dialledHost) => 'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}', relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) => - 'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${Uri.encodeComponent(name)}'); + 'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}'); _app.fallback = (final req) => Response.notFound( body: Body.fromString('not found'), );