From 745717a4a05982a52b489db68e8e0d3b03192e62 Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 18:21:22 +0300 Subject: [PATCH] yt-dlp for page links in every Link mode; app icon and Linux launcher entry yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are probed: direct files stay links, pages are resolved for title, uploader, duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the managed copy. I pass it on / the server fetches it: the best single-file format, re-resolved once when its URL expires. Relay names never split the /r/ path; viewers of server-fetched pages wait for the relay to go live. No wire change. Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the Windows .ico). GTK app id moe.senkochan.watch_together; release builds install their .desktop file and icons into ~/.local/share so Wayland shows the icon; the window icon is set for X11. README shows the icon. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 +- apps/desktop/assets/icon/icon.svg | 9 + apps/desktop/assets/icon/icon_128.png | Bin 0 -> 7487 bytes apps/desktop/assets/icon/icon_16.png | Bin 0 -> 732 bytes apps/desktop/assets/icon/icon_256.png | Bin 0 -> 17789 bytes apps/desktop/assets/icon/icon_32.png | Bin 0 -> 1462 bytes apps/desktop/assets/icon/icon_48.png | Bin 0 -> 2378 bytes apps/desktop/assets/icon/icon_512.png | Bin 0 -> 46089 bytes apps/desktop/assets/icon/icon_64.png | Bin 0 -> 3233 bytes apps/desktop/lib/main.dart | 7 + .../lib/src/platform/desktop_entry.dart | 54 +++ .../lib/src/playback/playback_controller.dart | 26 ++ apps/desktop/lib/src/playback/yt_dlp_app.dart | 47 ++ .../lib/src/session/session_coordinator.dart | 127 ++++-- apps/desktop/lib/src/shell/library_link.dart | 83 ++-- apps/desktop/lib/src/shell/library_tab.dart | 6 +- apps/desktop/lib/src/shell/settings_tab.dart | 64 +++ apps/desktop/lib/src/state/session_state.dart | 12 + apps/desktop/linux/CMakeLists.txt | 2 +- apps/desktop/linux/runner/my_application.cc | 13 + apps/desktop/pubspec.yaml | 1 + apps/desktop/test/library_rows_test.dart | 24 +- apps/desktop/test/library_tab_test.dart | 7 +- apps/desktop/test/relay_e2e_test.dart | 10 +- .../windows/runner/resources/app_icon.ico | Bin 33772 -> 370070 bytes deploy/watch-together.service | 5 +- docs/ARCHITECTURE.md | 89 +++- docs/HOW-IT-WORKS.md | 4 +- docs/PROTOCOL.md | 7 +- docs/ROADMAP.md | 8 +- packages/core/lib/core.dart | 2 + packages/core/lib/src/config.dart | 12 + packages/core/lib/src/link_probe.dart | 304 +++++++++++++ packages/core/lib/src/relay.dart | 13 +- packages/core/lib/src/yt_dlp.dart | 414 ++++++++++++++++++ packages/core/test/yt_dlp_test.dart | 132 ++++++ packages/server/bin/server.dart | 10 + packages/server/lib/src/relay_routes.dart | 11 +- packages/server/lib/src/server_hub.dart | 8 +- packages/server/lib/src/sync_server.dart | 30 +- 40 files changed, 1444 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/assets/icon/icon.svg create mode 100644 apps/desktop/assets/icon/icon_128.png create mode 100644 apps/desktop/assets/icon/icon_16.png create mode 100644 apps/desktop/assets/icon/icon_256.png create mode 100644 apps/desktop/assets/icon/icon_32.png create mode 100644 apps/desktop/assets/icon/icon_48.png create mode 100644 apps/desktop/assets/icon/icon_512.png create mode 100644 apps/desktop/assets/icon/icon_64.png create mode 100644 apps/desktop/lib/src/platform/desktop_entry.dart create mode 100644 apps/desktop/lib/src/playback/yt_dlp_app.dart create mode 100644 packages/core/lib/src/link_probe.dart create mode 100644 packages/core/lib/src/yt_dlp.dart create mode 100644 packages/core/test/yt_dlp_test.dart 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 0000000000000000000000000000000000000000..8e45edbf24fde37f28361642aa4dbe65c555b2a8 GIT binary patch literal 7487 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 JPDHLkV1h%Zu@?XU literal 0 HcmV?d00001 diff --git a/apps/desktop/assets/icon/icon_16.png b/apps/desktop/assets/icon/icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..cbb940af0fafa46a3c6b1eb941ef22aad4e9da03 GIT binary patch literal 732 zcmV<20wev2P)X;6pGGvK{l2f_}TNoND6O<)w+eC;P zqc*MDwQA9(pG`%JpcWAXL6j>kw5XthDGCFLu^DruKb@I$p0D@by(i9nKL5_+-Jf&M zx%YhEIY(es+FO&&N+JpJVu}??fj<_h{25RnP~HBA0UU3OArvO$mK+FEpeIpd?2p25pU%m`E+ab|goUGBh!(VehFc$5^_EzqW_Y&K9)q zZPaHa+r`M0X^g)4gY!3LP*`RdKE(zP0b!OaVL4K-Po3Fx8EMN$+4gow7!Sg)0nhTw0-$4-`KYfjZ{pT?GGNpC3)n#xTXAOd>{evO_ z(9=+X7vGm~@BSEW-+6+MpVDiXDCSqj3pNZBXsD0C6NHBovv6F_f*%ExniH+N4m7(; zO%YMVuX+D=0m9B$nPwxmSc0oI_5zo(vf-&jXrVZtuc?aXKy-wl%%G^)v0%9+ zTpOGfXyjKuJE^;orLn%FiECSt+U@6Uy`4G5ek-$HriDl2KMI))Z~p^KSmk@;c=a^^ O0000P^G| literal 0 HcmV?d00001 diff --git a/apps/desktop/assets/icon/icon_256.png b/apps/desktop/assets/icon/icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..2bcd0d398e3168d107e2a6bfbd12b8fffc180fa5 GIT binary patch literal 17789 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 AbpQYW literal 0 HcmV?d00001 diff --git a/apps/desktop/assets/icon/icon_32.png b/apps/desktop/assets/icon/icon_32.png new file mode 100644 index 0000000000000000000000000000000000000000..cfdb966e15f50cb5f57a893b725c57b7ad829079 GIT binary patch literal 1462 zcmV;n1xfmeP)!Rn#F!W%;Xfadm&Ood{6kDg%tDNb_~0SIArXukVwi~3jPYJOCR=Gs+p(@|?|N_V zG53~^-rL&^LXh*v&FSyl`}>^V@0|1dodWk+n0}zO@iBiM@&*vCBG62L9Hd$ZL zClH|Os;25UHT4|=4Kuw5Z6 z?JaX)P67TcK!V5M+ikH#U9SL0Ll^4~R-vQk3 zGGq2+D|{X^7zZSVC7kceAe<0J;S-%E)K^>36-w*jitZLUdJDk&ssmuOS_O;&H!+6g z%`QAO#|_R*xRex;6!L%xXs)$m{iA+Z>JC z9UyX6f;W5Zcy+EDyG|!?A)XyG=A4NT9TZUKuY%pmsbhOKKY-osVI2G;j;_#9>HIGB zn*z!h?suB8s>z8@PF^1uuIRC34n2`9k|}YtwA0xv|^%@$Mf5RICD8>2)`QXVkSlLd8TgB8>d&-kVB$?WxMl~SIbIn9hDW3d31{eoYFN2qF^(Pj82)OHHlaZ< z^PCo58UoUt?V>D08YA0pPj2l3Z(`s%rh$`Y2S|Idh>{Fhxw8!66&zp;?MFJWY}F=2 zV@XX|ofAyRQ@#wYI#&{aQKKYDAkJ0>FP55Kf}wuAv!x9kC(jvXd-WtMf_(!9Ifa4) z`chLeS5{)jakuJMbD0!G5OUbL>i~AO9fBxIhH#g|0xQoT=JJL)#TP@MH)gW}Y<4Sr zTn^EUgsfP2r>FNC5{VQ7otLrgqp#5WZ=@{drP;n@o)4kE;*D6IM|?>DG)6%ffyd** z+U6>RZV349;(%(4MhEclBX5k`tSG-4>m6wD+wgNoU)lVc5EvOfRt_&rw_r{4M2LA8 zI7T?wJ&25$zb$yJ*M?VCHelzzV4*p!P)(c>T+t^xdDJ_2a15~W9Ol!Xl6&%55FRIx^EQ QGXMYp07*qoM6N<$g4GbOvH$=8 literal 0 HcmV?d00001 diff --git a/apps/desktop/assets/icon/icon_48.png b/apps/desktop/assets/icon/icon_48.png new file mode 100644 index 0000000000000000000000000000000000000000..efff09f4930eb7f2c31c6698f85d9322bbdfa272 GIT binary patch literal 2378 zcmV-Q3AOf#P)(e$K~!jg)tY&56W1Nbzq?vVmSiCx82La3VpA~pWzJ?wXt>g8nKoRR z2^rd9O4?zXLSf2G$@Cijpg=nzf#xE>v;>k2$+QJrk_H!O62KfuxB?j8#^+eEgV?44c6U z4}j7@bfmbr*qBrSX~KS$!^UjT^#>n){>Y%$x*>X1=>3|W{a@jA9ASKc31jn3$jvZd z=nw&Zk-;YtoSi{@UgN>d+AajdiiIs1c;WZAO-cE{#GSmz;LOBKE-+lUpD zGBI<65jKl}wgAIJuLzf4gwe>uV&XBI5FS+ zR3Ro5rQ?rB>Tstau~Fi9c-|_ddMd!6x#gFQv0+wT8unai$Cak80jmwqaadSt!=}{^ z6c?BvXG>;gp>&KDFCMGMTc@4y`Y4HqW2~&Lah$5o(zshmFC3a3ZJxjMHZe9llCe)P;DOqw`K zvxlv9`tbbz>hO23{i6|QKkb0olGZN|lW}B5DQc8eaTt6UI?DYpFX9Y4mLnI#}1 zymRaacMP&j}|vj>F33-@=C{cVg|Dh2VK!)x$C(?0B;l5849=1w-)r{YW#JRKk^B z>bP)PzAy(uhQ)xYcCRXY-;vp488!=^-}*ETAJ~e~MFpxJ_FtD;V|l3pMuVUNFEfed z3ll6-kj3$6?C@%uuyQtY##o$qcRRLhS_M;@K~~q9*ejL6U;u(%h&eYU_`W@$5LFrt zdbk5Y_+qLsF!-nmf(}1f_g$PW+l8eIXDWJFfDjTHbRg*UdRh4Re(b40Y!W1g#5x@h zw=bkkDm7THJq!C@cn+`Z*@&DRo3h$a2nIc`4A1(R1JQ!g1oMSRqt0u$#S2UE!XGv$ zs-GYLf?g*NPrWiKI|ur7Cj){^yOT=IC}|GrbQ}au9$sEX6;@IKVqy2M4PJXCPJK{; zpKWf!VGRbv|^yI64x+kW>3Zr`m>fTbjO=~rQ1dRT=#UTI|~FCbA5 z(Knn{kjk)UG#cRb`4r*h@Le4q?AiA&4*um7L?&v2m+X;z3tHNJprl1se4dkwM&%3p zyguj+dI&nHzJw7&^s?}E4G$v!N;vvXIp#0kh&K+E4G4Zjz8S;qW>htFs7iNqds$&c z*0*p-6yfdmAwAuM?#>W4PRqe_<&6m~Px$$km+-ys%tOWHFYwv5N@XXd0MGHjW7Bid z(A*Ci)vI|C`h&z&?B>z5Vck-e7jWx zyv!t4b5a+bU0DBqJ&sp(^r?MCix->E)Ta#3>wxvkM&Va$icx;04M#ty(*z%##<2=}Aw+VpVwM0`})iNoa$xZU8< zs5Z$x%7cw-YOTE$+thnxcxU z=*EqDH$*WKz9Thw0xDZtt4H?@<~Y2{5q72uFQq~uhHFh8Tx)X6s*M~h1`ZF7XkWBM zuL|ibFG;6?slxXsmH#hza8zw=6Ot<+ox}1XJqeI^(87a!9O0=-RQhF)S?@JnxeU-V zJ*R<#4<1BMH@DVED1CvLT~wfbTrt8_G&WG;78!%%zr z%PgdChxD2OA?quGzsnJAAJQtUt@Bnz+;j>rdyL4NPy&vd$_REM*ppu5Ah}mO5?}V{ w-3pQWp2C;HaeXs*H1i~0m2_SU-`Cpy3rYA=ymq{P#sB~S07*qoM6N<$g6O|t<8 literal 0 HcmV?d00001 diff --git a/apps/desktop/assets/icon/icon_512.png b/apps/desktop/assets/icon/icon_512.png new file mode 100644 index 0000000000000000000000000000000000000000..2d87f567bd5457ca6979758fa7338018970cb7d2 GIT binary patch literal 46089 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 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 c04e20caf6370ebb9253ad831cc31de4a9c965f6..3990c9a5e725aa6a941a97f1b8bf21a4c6a0a25f 100644 GIT binary patch literal 370070 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�wXQ1j+ntn$Fj$NnMUegyXyoU|u@O=SENM)DtR9iY9a1xac& z^c_e0lDE;ZjNKXi4mws+S@ka3SGz?Qsz=MJn=p9$cbIX{ z&zN!FFPQNF$)2!3i?H8w)r(MOmI!%;85Ha8zv*ulb)aHkjgV(NC)6;I>7dmmgWzlW$t6>Sn;kP`;Jxbp<~Va=v+tpaW{N`)@9G2re!t!hNwoIU72{2m7H{5z)K^%LEKy}MzlxU`+fX<-G35zq?cWSyF(YyOI^j`J}x-NMKots}m$J)oxw)!D-Yac8o~1h)W|Q2b?dFsgmb4k< z&*u0-PBGhSLiuCj;z=GzoD?j#C;x(if`23Vf9DfApg*UwAX$y7`VDAZ@|Ym|?u$tF z+ez*_sO;3qzUQ*f(Yxmh^zHo;efy{!IE}u8U!nid84Mggi~b{LF>vkIn0g(_pUM74 zlKU|#w~*`!`_uK+b$-O;!mO1h)dseD43*AE;&CFnrq;0BNHGct-3>(&dtY4Y>? zojv)pzmx25)5+flctEcMJT_dATmWswLNrXjhWvt;NdAQT-6Z?Vzd-L5I@$L#*jJA;oItIh`!p)&qo(U()K5Q% z=7q=5y7FGMt+^lVYac+{x(Cp*{5CYsJ%rl+i=k~;2z72P?fp(RoLlRTT(ZKVCR)pR z(`3KV$ml?FU~sjg@^4B=_}?V|Gh;;um>!7d0^-M>j1QRR6=%^LreVcl6Zv~?zai%W zvISoykW3wwKYJhfew&Tu@7p}!EjpmgP+O3{XLTSYFl@auvKQnZ8#}HjGT{eF2h8V) z?yv<5S+i}_#tcqDBB z&mSN8}9Wdtrw)fBnc;Lh2fPnuTuKd~OiX#*2nEWLj z@I4(!4-BtzMfUu&MN<+ENIGCy{@%(1bk5KoE4mGzrTRR1jNNWHefYA@KRcE zaG<;8Z0q}&Pgooozgyyg(d7Zk7r2(q-xM`i2G`^uxzCJ+y7A{~&{jwwDY zKA+BY9tb~^F*$srEHHXlYdOxy(be~|`~fw^jY$t!9?|zYPLS4&hM@!Bkw18x^2})wtnd1 zy5TRBOZIhUaKuTH^W&Kzkyla<*&;>Il&k{3f`4#!8-Byl6^QKeopq}l=wN> z8zY4zdt}sLvR6h8BztDm0D3BJfM5?rKOopk(HDlY_lv$jc;? zKPl{>8wcrsX#}JZkVZfn0cixJ5s*ee8Ubkpq!ExtKpFvQ1f&s=MnDA{^od@h^PtrGM;KXC6fMby#&zTj7tPPHG|{F=lgGum@q+` zFk!;~^+qh5D{V@d7?MEeUvEgB82U8X^K^G6M}-yPn{GpNVtE}a1V-bS2nvhJwzy$ux{-&i321K z5X}S3d;s=0V;_J$H{jpsal|N2GC8!6@O~GSUk$itcOUU0 zae&SEfc01aixWs3;4Ti(bHlHp<0jk{I(}l|Sj6H;_9-PWBrh#6^kH`2jREV{9!ne` zae(_`f@Z#eqkaJQ4`^-xHa`d-AHdG)XFKtve-4g7%B0YG!u(78o-1BU9ALU`;P{#V zTR)Ke0OK_QwnkuB6JU>r)&wj*9wrAc9(a*Tt%K@+&b}=*C}>J*V95O(@0Mzb10)U@ z!L>m%9&mvJhz9~rP^>WFoCUa*jt=1pIdI6&e6-{k=Re^6e*ZfX5c zM+T`uq1v>-un(z#@UJhSRNF2R2S^;?!yJIH^Zie;+#)L`am>HW&OlBln@_m^7sEa; z5(kL#2A#?gWPBjS157!=dX6B=9~`xK0E-DQ4)~4v0y00p|25_TFX|w>$1ZJh_>*aq zhj7pF&uWPSBo45hH#};&!fuWS2pm8>FyT3UjY;xE>hJ9Wv+JDXo z5(n6x4@h%?b73w(F@b=?1wMXYKf^v@{usx-xRyA;{ColF`~cZ>;=F+UezalxC& z5BQz0`U0}R@XP2PdxPB<?{}&a# z-8Zd=;(+4$fHW77<^s}OKzFu?fP3BkVV)<*e1Ly?dwu}R>m%HsVc6$I@&lwZMfsUw z)@tc2G4nHpEOS7>*Wv#?DbySN03n}$a@bQG_u^XO0Eq*vVV}P)ae$*7;Qy>Q?iV1u zUn0i5>0^llBo5$Mw^mCW;2;NhS$>dgazuqJDEv1n2EX4F|LnQM0TKsT!#;mq;sA#@ zfc6b~$KGLfb_Zem0|EOC{}Ko2?-Sv73W#ex{@Fbv6yulf7_ofjxWoYtalnTG0RjIx z+OYt6Q2510*q1m!xQ9SGTUj0+n z0ckEE%>}s6Z>^U60K0y`FQMZm#f=gNoF@;8c!UZV{v{5ueE*Q-2S|Q^XZ?VH6NLW} zKVO{ed6N$N4F3`bSi570y)JQp!~ve=fDu1i{5*MZ#EZuGmpDM;09$tqNF3lX4mjWc z<&pFOGUyC1bL>lgfGzw>et>k>m~hvKhwd1m+~D$&iUSb#pR$gB$q%r_1JYbT@&i2N z2Rt(p_|KRep|Op9$q%rFf5{J!`~Z*n0plkYc$xz;gCg$E5aN9t=eBBTUBDU_NPd9i z2e{u4m~f{j@t+eA=}*}ImEm9F0R5fB(!Imdy~Eyf@9?kT{{8`;;(*NHh=m;cyq4wy z!~lOyR~A?dC$(|d(XpL@$aLt*14%<~lf3G**=+>2|814fYp0x9=0PK7*WAByX@ zpnCc-G%tS!Z5#iF?(JWq`_j|8vhyo+Q|Z~w%4zgmrZ1OMefgK@*`qI4P<_Q0=-I0; z`@TTeuJ_Qi_EA*NIfA05waC--LY`8H#Gpu0^llMur1q(V56s&qhV>5i~7+3SC=1NB6~N2zpf3lhKKQw=9QX{aTb_n?`W47489-``%z$@}dvPssz{qew z|>sfSNbQawg5xyDj4cMp0 z4EqB9>9K%+!o3mx3Ex(1Kf`|S6|6Xj{VP95-<4mW@6czua@A+(rP99hWmL_*3aZLk zNRCQ2;{%BUMt}px`)_bJ{%P+2Y(}sy?q`a7@$<370kZI9i}#Rw`tEVrlq_hptI@dN zX>@HSylGV68|sEdNQp@^aDl`D9_E1Y{!h9S|GDF1{wpIm@>c=>=MV=JYA&>hf4avI z?&g5>$W*9HmZD+KBk0WJ;96&sho7Ilm!9Sy8{a1v0!aBn}D}vqEW8Vn>dY|8beb;Qi zP3&8m_ZzUUxBcAiA0{r~wqJyOhJT@4OZBy%p#SJ6#0MXuX6aF6=2Y9@0VzMgmHYsA z<^z-Oza!Itf5ro2p96B!o6x@eEM#FxZs&m1;20DZEk@JASJ1KHYjm>0u)onB_ASl( zO>I9tH^lr;qhs6qXj%OiG%UIg)ibY0Mc)A``=RaIhw|RbQQCG9N;@t_S@)%=oPG#3 z7u<}-75AfM@LQ-9eKzs$j2|G6%0y}1 zPPDD~knH`}ma%Wm_KWBICbqw0(@8Wed=T31{U|J32t`H>(x=FfL~Db#?j3RHjuG-v z(qprbo!f-M+9l9V-GllSccNqGU(w5Ae-!iUyV{2B7qHJbfZ2YIeU5*6?Rs75KKvHS z2Dc+QGTDR&Bn}uJ2L!y}4*at_JPG$dbNrtx98f>_jDY|0#v^v|Z;u1gBT`Y`xEJlK z$sVu!Mu&Y<{M+^W$v2>wKP%14pF?@)URv|-L3*^D&KDS+^90jk?yC{clOD42uKOLgP?exs~kwr|4KqxEHXm$G;`NpK$<- z`3Z49dd%YeH1}^^^A;+)4nkEhi1es5lk-IM=ZK9G2Z(b6lfx5`tL{MMoc-w9`zFov z3F8Le-`w`=uuu3W+#AYG^!TQa(0%nADC$}zniohM;4BA({rAK$SABrY(1;3-dDD8V zazL?mn*sm2IY2+*Kg?kcNTr4)&IS!yWN7MXwjPPIE_kbS%_BkLYy$((DD6Y5WTPkGh8TL8; z*W0%J9UDGFW%o76RkR{`a)f|+)BS_KxNk6l&OpneSbz1x!{|M7Qke6b*?z(9>ukRc z`}Fu2m79qRZvGIhJ0FHzu8}xkd;lEffUxre=DTYDGlC)yi1BXe@mS)30@WNN>@)k% zN_JAYO%6y8i9uQIPPDJ0xZY~QxheL!?I*uqi1~|bf9uM3P*SrQ>6C9|ig!!TeUSs= z^?L!5Ba@(RTaJ#)pQkxLVVm23z29$+eTIK3w|s>Dn@^(Rg55|E)(VV$0c&RnTYCLm zIafGr+{C@E;-Bz-pC!zjzBU#(AU!AwO><8p!wxfB$+vr$D zxMxLd`x)*z_C+zj4#I!K+{d6&4I-8HQ>T!Pw1jnQulX_uuss3sEcc7 zp8GlnXo^=`#6I@{^5m^X9KiB}D_gHe`%1z%D-8P%*#5d%4F{1f)49@mwD;~$`U z*#RVkOfljCYk0Tx`k3c{@c}O90V{$dU$cgHORxJX2V}-%qjmA;Hu2vy=XJ0U%-ML#n!S?I1FL1yIn0o6;^j`luRJBtj4lw0_@c}1Y#(!|sS%&|y%mHOJdmO?( z=K!HzL2*H`-_Pg$BHQ1-<}^xcwjnK8mp@U^!2=5a1 z|3*CV7W&8!Xx@G&QsXlu4$%1mKf3|{g6$uR98e_0`^bmaukkro)8~i-Ok;lStG+-{ z*=q9jBh0YR@`UZ>3Jd%o`2hw$K(|&90(JXh^xp8+5dM#!BHvbLfcXIx?bjfEaui|TJU>8; zd+}pwE?~+7_BkMaau~{IUqrb;H0M7?e!q$B@4fbA$}xO_&LVpbo%^3eUU}C!%>nMg zzwdK^oc8b(6|b_p#%I8^lXHUR3*Ln)XOPwg!zB)2c>%n*AwR&>4`9!8idtxo5akfw z_$K8MzJ^|A`>$cxe-S;0pGVJCe?iZcbbi{t$5Gfc`<&$f_u=0+Ie_85P&t?K`d)Xh z&9}l2?98CNjCSGx&I7tRzu4}ZK9>9d=luZ20cnX@LcFi<=wE4n5%c?BM$gqR5dNP- z&!K0~P5AFV@C5BYdK8-Wh36y(xQzc0hI?_*tp)Nq0I#k2{gz%IiMfCxHV-A`Tg~?J z4A;Ip`?-dhFH)Ysa>ydmqAfE;w^)%_iZsdr zGPVEwx#R~7&ku;Fvk%HLs)D*hD= z`@DFeACMWDhT@`?g!yODw)|`F!Mz3kX#F3AoZd-sZ z=JOw-dH(^j{WSOQBHzDr55xb1=-hoDS}*xCvWuF}84hp<{=J<8_?-h;#07=9gQ#de zN^!nVyn+kZSpsD>bXFI|1k%Yz;kMpZEpfmwIe=mU%E~@;5bnFl@9!kr-^pzMo`=wR z*#qbx{CDiU8!Z?A37L5n=L8413;({t0h|jmXg@yVg4%&cMxF=g+~BH?+mI(~p*6t> zVZV^Iy~EacK;nSmb3jfJo!v|}pV|JdJr4`Ezhn3P=-71++AsYxoojq2nl~SVEKND) zIl!Ix_cacX2Ze)`+|(-6_TJ}l9$@p2%9iUX2S`ct{4fLV8TNUR@&i1-HYltQ7Pp|C zY`($v@46Qqm)?!`9e1H^`yFVz=ufCyc^FBN31gZA+>L)<;n9VPty8_Z^9m;@tj(uKB959p*#BndK zJ(MHFIG~_@Ci(ni_XXR3r_T0Yd>h)f-Aees87-S`gt}$E!3SV_2Q6XU+H2m<0iMFY zFLQvL?k}pU-avkUYwHE$W0C_riGN?^fc&^*l%{8)itfs3Uqp)2o4%23|DVY3C)>Z3Z2y*HXx(%Z;s1IxuRlt68XZI`xuj!>14aV>zQO^Sp;J(jnknF4%mFp^7dh+$u=&19@cF|D z|E%bIf74o;3z&1kX!`*vv8kwEeFQCJ``Nso+y3TtN6@_HFdA1Lf~vB|$OrIsKVYQr z?+YBDNlGW|EA%Bx#jl5nj>ghbrqUcUWxjpS0Ft(d(3daNaEj{IUsLJf(ZM@98i=ZLnhs;*Sz4QO%AA^ z@f_$rL<{(rIKUL=ww{kR2dJw1ggHO+`>*wKK)r_6uZE`*nUl$Gy1Df+B1RSbjjEa<+B+Q%+wF-2o&cpPv<-?{8X5 za{*hp7yoTEIY8JiL>O;g%WVJEXj*+pu>B1Cjm!6v53q;!33Z#{-xucs?!dpVbAUP_ z&4TUMaeyi&QOFTwd4e)JJC@)3Z5sctY`)HbeTf4?ba#%kJBPjC-eK__#Jb1!&k;(E zO+(%C{e*vR`wx)q--m`}SD<0(<*1pzgU%6^`#cA@j{hlO`1e%~$cbLhix8?{Yv%iX7!u@UIAq7U7@q0ONoNG|qa( zhy$uSj$6dOx{qMfK;umg4Tx1+RYnSOtOZ*qWp@$Wkvz+#Z4wARnD&ucapC`ru#JA-V< z_vhHx)mc#)bidbGfdkmNo^pCGiw_D#AO9O^4yZ0D#m>Fg;Gvh^#VcR_T`27F&b>## z{wCf>EbSe3ZtrkzNt%O{qfNybtXYlXc z9FP@C_j{yeS;T)49vCnI<%{O{XclOrOxxKpAbZdi#7=V}uJj4Ojg{3xqelgxH zJ%0V{uh_ox02ER2k{@7pzJTTYfb^tnRMUK)+5XzOg#X!_2>%;UGh-d9r>#b2ROWvJ|3ipuUq$jLWd3*^}6 zwfAs9Kh!H9Pi?~lyd#p9i$_1=cv*8fQ#z_?!>yS?|S~@Z?Sy&dZf{sh8Yi> z!yF)&7ou{2VSlCI_g4}2t9llrs(T@d8)nd2V5kirfboELaDXT9?`<5w;(lzMUyOVH zn5_r!JBWRW18iZ|6My^jQ?H_~q)OrdIy0P|AfJ9ecjiK4U*H_%0CxUIS?5BL?Y{t(g#C)PIVh^1iUjgI`FZ2s z$^o9n|7ddnJC{sLzJG;i-H&qt^9wlUO>1xG08irE(eF0P892u{Ac5k5nubAC_ADa5 ze<3P5nC+j7iq=^uZ=L~JvciCWZ}kI40{^4Q0ZPI+!#*z@`z#*7;sU0)7eDuA4saBs zo_?QM&cHdw0a=Pt0sEDmI@_-$+plez1#R$iWTdMs;@?|1 zz#aJaRt_kLNixEJr920$l+d}pS(Nj`iY?5W{>_^>V7S;E$mM;qhkp_caa} z4Gg>EH-(%5^2NrQA0VfBKEuAYVX9#J%WHa2R@H^l%67<-vTWLa&I6;t0iM9WH*i2c z?e%B)XY+lA|B6h)KH2^|=PhR7~gmww{k>Jir}T9IfvVat1cjIRkVK>sW~i zrqG_>lG=X4K9yRs{ng!s|4x*V{m;o#JA{A610&4=p2GiVbAUQ7MTdXFeihC6D+&LV zS%oM{%AvbTqY3*_7V+;Ze!ys9*PXxn@T>2mqrQDia6lrRfv3@S5cX-_uebeWWcy3C zZBXS`IE??1<^WIPe>6FO#rTDIe+K#e4EyBwS11Z8_D6Yt0`?jHeU}5=iNn$SE}k=x zmzd@-4_ICo9R0h-@{5}V+h1PYWw8CF+7=Y5Ymi85q1?}Rq#h|hz|;62Ee=nda_7dvO5xY8#%K$e^XX8TL4It0JJw7dl+rA;U)sYhybD&e2q zHDG*}m_07=v>z~1_#a6Q$fG;GE9DgP7u$Y?2IZL=WQQlv{GYJTi;vC)yaB78Y6H(1 z$cl=y%LCrz2c*)va*4K`Vt#Di&ul-#ej|#D>mX0e(Bt2`IKWl>=aH>t*mksxM6Pgt zbQ0NrI-`;1{Z-8NQ~Z_L{!)1Xvcuv8{QEiwcnW*o^t~)+V9rcBPt#7`u(xmk^YJyM zEqdGEY_R<$MYT|*=7{j`JsjW;{CgV*6vk8RUx@kT3o-vHvi()r#VAS3haxP-fPY`+ z0B^#nr`vMf?GHgiu^d}G;0+v*6cmXKW4^z>i|XL}UCuVD zEU|>}uMZ8-ihn(6fD_|;CEw3#evL^G$i`^#eniX)5} zNVNv!&6rIVjQ6i^?56s956}Buj&^d{-Bg46la&86?v<9>dVrSv>q7(PlB)on`ZY7MBVZS>{+@cmBsen%_0Y;H1FI>>~1 zP{HiWI*>XI(3=00YXE8y4T~%N{eA9A#(zDzs~X5+stn7&(Q<)g@utVmVPgjN-Eaqm ziv?1t0oCYZ=Dyz-_cPz`v^JB4@joLo4F9Ri1rou3Dm9=gV?OrvM4z9pzt3G!$JuvR zHIZdOc|`t=qyc*5GTGnh-bbI;od%R+|G)bBPM-JIao^tp{u{|!<)l39Ev4@NQx^v$ zivN^p0LJz=V{e8)+|PV}Bei0UhS~*XQTaED1|*v`y?)*v?1LPxE|`iqpag4v*41?& z_HUy)hnrmR{Z30G**O2%QTR`O91uVM^NFz@&SLEs)NFhJ>iaFFtmX&5?~D7H?{`O#B-`1N6#cvcK2cpMOm));is2KnZHk^)A%>oM7MH0{$DxSz8bO9q|9L@Sj`_ z(1w5gXh0SE_ub_z=V$ykfcqx+{$`k)s^=ER%)j0>AlaPh`SW&P8$JeLu|YE9fa2UD z_~$2yQ^BD0d8{F zv{B78`PZ`s=$Xf4f45_={xYopmve(;X~1I419q{v-_|Voesewecao#ZPHcQY=>Za#cJtNAq{?{6Td*+q^T#=nj7vA?`2sP8|NceTff?Hkmd|I}(g6~_1TxS!Yj zT6o;w3g7Rp=|ucr!ud}J4bU@>$^LF{eDb%L_)mrgEXv8B>iethe;y{so#JZ}-N`MFT@m*RfO_uI;Gkti|DW4hubZ{Si!smNWk%kQn!mG}<^5K&SD49) zb?8i3+l0sejD6WsTo2Hae?4db_y3j6)W+g|bGy&?W1n{LZ!wue_&2Tw=#|H0f3Lj< zZUg%nK1==n0>ul9i1~hJ4eEZV`Ae~XEm^P!t!YL^fP0z$6vqKt^Ph4JK>e?&665`9 zeR02~3+#h`3)W%F!~B1q|1<8DmV?L9`vF4x2~ycFkZjKM_<8ppd!9<>%%w%(N-r8v zg8qM3t&3bN??=twUSUD*UrXjiWt4}p2!6k>leszp-+GWU;2V&k5RR zT;^VROmQwykzYnmjP-S*=IxG=2a;S5NF@KM(E!ZV8BPQ?Ap_dB@nuO>V8p{ZPu5B{0|j}ikUISxoP|EbUbSCNay{q6Am?VSBS@IOdR z)!lsk&&d26Ljw}co1X4}`;o`Uh5C6RIG2sb`=Q(`AE!bCP~Wd9s`mMQv)}jIiz}#f zZZ75d`PYvIB!mBCYk*^sO^W-iJ(BOY4^X>(kXp?BRFl1kDlM0PhCZfJ8B;hx@Yizjt4AGgZQ8hB6=eSWg;IfVJdpEZ@giKSvpK z0CB$!dr;d7OQ~>Hrk{U3X+XUEn=$s6u^p==W2~@wK?P^O&C23_=KF_W!_@9v!p8tG z{*A5ydf+q>-{I);QWnX@*7Y$eh4!G}!2H3IxgdKhU_QhpnFD&76 zkQw(%(?dNVZvL^)w%#doI};pe_IU(STa`c+NlAZ*wk1?7s~De>rRgax`yP{*9smsbDvr&t!f1=d4`I zxo7Ol=8yXgt^p-ECO*zr%KO27VKL|5n!h+^_GLXtrUq!wzdkgeCaVDBeFmumaeoJ6 z-^JLUg#TYjP1bQ@^MvHwU+t*(dO$oZCiA^-fBF^e*?%)t&CByMFS9Qn(?J8OOog0( z2WtL~#U&E|(17asd6Xa0?6k1HNDTk_(13;tFK534aX(|fy>2D7H>@JJa|QMS zsfx(I-s6B|GJLY1b@chS$Wzw}?lTn@@clCX>}Yfis0IIa)cd%h=Fj~<_%F+z&-vG@ z1|*LE)N6oaQJus;^ZSVVJL*@#&Z3USvr(UIjmp2CHQ;2KOvd|IU;cMDZKu*%*$VE{ zR|6J7zt|X`saDL#{pDlcFXLa28jx82Q>y{I2H;o*_9v+maersSYU*e>o7x-KP<`z< z_@8H34M+yZ)BOzAmwyBL@@?n`D~8XPE!h9dI*>kbKq>k$rm|l`7Uce%ea62|H6UL8 zEtqd_j%ukHA5eq6fSN5!!2e3g_k;b8#&f8%@my+goz3?HQOo_+p3_@BV7hE3`t?ts zFMrrG=x1JLUp}U@2Gn4Dj~f4#^G&|JhkX3&Py@8!U(Xs)SMHH~KjQvQ@Xy%qY+4H) zSWni%mYDp%tOt71fJE~g>-||@{v|v2P({{!jNJ|Re3^UYF&#C4%{^fKd}{o&^@#%e z4(U?^wBlc{8eq+{Q9H}~x$j>C?iv5nvYCx=fCh~Q6pfA4x<6SxT zjD6V@vA>dkcCI%KSd1J{b^cI{>j#93{dvDXv?0Jh+lNqp8lWZrdeneg%=dTOk^l3! ze~rX`(|YP`*+^~m>#!zxbr~!4AeenPA{fPS|-+vzb|3>O`e-~r6dPuXq$Ai`s zPV72D(HKp{b*V4^7ObI=5AL&kW^4@@xb7J$gl?(E08uocFl^nwgs)Sk&cD9mfJE@G zHx00wEJ1NU^ZlJIn>qh2b?d1nGtY1ukO;mb-Sg-RZ;`jYO~Jj)KRX&!1E67(kN=e_ zN^IfzkDvh+nAfA0_uC4J`27;S*O(S#gc8NSo;08aWBpnjtKj<)_cz1$w`}6{I3Ni|s?91oI(|}rY8?AbdsDALg$oz-V088Ft zRsJjIT$k^JjP16*b32SCk_$Nk**pHE$_7r?eqqx~$thbiOVC>o$8<7)Sk z`||CJN~zQz>&vW%I~q#^+^etP{P$dbgs(FeHV0t*hiE`)Mvf}`EFLJDofX8tp5uT- z^RE{Tuwd`MHuU)^eSc@`Mbz20mD*Y^;&TFJ{*9mkYRqc&9P7&;^$v0F8T+zD_W!ab z7)JvJ4?fTNUv~8O#BzDIo>;6JV9s4c4wW%~W{d+??lr7D*JB)z4E}YY0a%};$-3MZ z_cPyrA@}`VZQH1;{bKY5uJ-2uvJS*LJ}?#i0<7O%E8gY%uzOy*b1zk8E$}lhvo9ah zMFUFa7SM{P|H1iZIRMKAVqH(Lcy>0}FXjA)YCsvrK`5CIJ=dECB#VDNXn?K2NnI`9 zrB1Lf`TlM2|J$jv;}XOG+tC+TYZwi9?6vna<^PT&k5ePotu37u@b@zFp~rO4fcoK0 ze)bvv9h(mN`HvO{us%PD|6mPJ*8M`cS3cI02E@<*LSk&kYAGEnoVk!%oaacsA926z z`@7mNrLNAMRBv8Bg@3)~0#_e6tO@_mzWZC69G&EIb=Z6z&i^T(0fPsg4dQ?41HYxk z=*N&_fN&a6X+UjuKD9P%Le4Mc{Sx~- z!Tz`G(MdYp%cjr1(Gb^sChAX!3I}^M9&nK_7U^Pjbl82?7n02kK(>T2Iko{me&)5(1Q<`T)Sb2XViL@$b2k zJf5pyyQ!sV8^#2h45I=258e?m7hrk9ft&6j2j=C6vL5bnx@th{xqDUFXa1j=JQhG% z2Vm!Htzvcdv7a2q`-F}UV6i|XzaM%nRdIl3{2NRIoTXh7|6P}pr|SxEe&Qub^7al0pHK&{<6BEf80O}bB03MmmjA>12pB|KpIe+ zg)zYmo54Q#=e~azdAz&H)4dONHMO;0Mdo>>hSC701LfF*GGG$(GW+sTjrDNPrIQAD zwtqh&`*I90aQ$=s@j%S?SHr(E_GMGX0j%clXI~yaWFQUDoPPspfUU3*^?&g1MeGmu zz23d#?YWw|diKMvrAF80pmD+SxFF?G&trrn9S|D8{I|vFrB%;<6BGYT2kQFQ@fd*B z{ABKxN2UR6ZSN`U8%zTd!oOiO0Am83>)`+4`@4OwA! zDzX>&**Cfd#LK^p@g0lle2kDa&rV(4Eca(|zvTP912>U(;1==@-b$Ulw_q>FMxg<| zK199u3s~#=XysF1#LYgB15h{YTE3NQ0ILVc%!eM07zb#?Z04``v?wse9-SYV+PmX6zdlTK`jfEb0MMV}WGF0gl$CwBpIXYr{VLzpMe?wY!4i z{!sqeW20$+R{W=f2GnNdQ;YjDp7(o^_j?B=-|ro|9sJ)(-6MBVYx_Q8dyA?uANrio zfaGgHA5elK95;hMoQXHT) z|LLFs&Wb_Q{22eYf&bgdJA5Y${Db}Okspv3R&O~wrWkF;0d+6AANC{a9(#a1OOBAUdZ~y56!VIb(JxSzV(6a`_&wnm4*29^R`vxuZ%c#BkpcMCy-phSIOJGa}fD|i0|(n ze~`MzAENH1kC12VDA`NALIV<@0TuHXQ{&J^8pJ+bYHM~Slke$#*6=N_QPadWK8HZ= zAJC%)Xvcs0YJj7}gFFEKpT+)TMAo zg0;9?SAP%8KO3}0_jLHv%WIitF)e6ayAN{^duTECh>WNMNuDpDE&u7O0WQS&9@PC< z-tYJQ%NYO1V2@GH@+ZhU@dPzEHVO@xrUsOu@2? z!`MYsj6DccwIGQapgsTTs{wVj6XadO`1kq#rIPRO0rx%2pQN72r*V9mnwxgSG+#*c z3xxCwz&D!9>!@kT`P6&mQQhYG)76E^$N$3D7HwL73DuZeu|HUp7$AvrhvVhnj=9w~ zRZGvBV1sQ9VtwTOA-%^+3Ih5z>PHQ%eIDP@{7b>VC5Cf86i;dncczUa;T0 z@&(w>sJ;Jg)jfrj?91mu1Hx+n8!N=>0*fiTxO8fhx`To(vZ&Aa@W-3BXBiH>R z9aZChrpE$oqqQKVyH@%i#Z2>O1Ro>RJ5;HM_6GT7tPzbwFr9Y#P9{fb|m$-}G`y^MWOJ zze~2pK|l90|LkZ44bX)DQ&U!RWB%GP7O5EXJJ|Z7+ykBMBKlXzUsivwiz`e{rI~q&_G~oY)ov|<5DVhsdOjhpS{l5P!+4sN2eLv&B z5B&F@`|s2T>l{5owt{-00sdG3yr0Cr!!>1fG<^NbwBoTZY2_2JXJTLDTgh~+=Y6{$ zq0+f|>g>xpU>ptjB5wY3azFL6e~N2>8T*R4JN6;|N9@mh|Jgr>NxolZzjy6_P@m6w z*Stl|EjzHDNPbuy5OIKd98iY%sdeR5wEW?}(u!kWa{gC5@fEFn5;4FtUne9N@N79u z#n7lCaL&vh_p6;7Km+o=GyC^(^Pio2dN$bXfPe;+VSl3r?;0Ar^Fx|^^dDd!2H(HpapwQQ zzh48ijRV@(U*qRq=06QI-~$c#e@m7BQ(Obsydlr{F`oAa`F_T|kNtkI-@gHN-n-Oy z-Y>{Ac?@d`uyux~t}P@qAdBp-0UAE|22DP4f+m0R56u4!urK?5=Kohd1$+8y%oox| z1G=`}4)!xu`8Tu%oRRVKc=>l^=e`h@|5IE8%yWyW)pHPaKh*wM-Y@%p=Jz-Jhr~Yk z?*sq+8)2J%Nqw8%rLJWUVE<9il$_vH)c~uxlLq$wlqMhk!pA=N5Ayv!zc2g#r@%gP zfZofnP7BzLHMduL9n1HNQKOLfPdg2GRs;SYiOByct^q6`VC#u>V{CBWT4(@|`N6)z z_si_}Z~i6qZ+?#kzWW~a!@8ErV_b6zntp(@6tnJ){Bu*CPf9)c?bs zpQ8N&jDKcqkC7JaG19yConZFi`v*3Hd-#5t{eknL0T;k7{1xmy>RI;^HMj4;SmH_^ z6Qr{SRDieU;Vm?F>-)6)!M}rj7-N5m@AuXG9uMUFEbi~waX+y&1LU!S>{yvsK@$)C z=d?6{?HypQYNBG!yUe}vDAfR!wE)BA0y8qMjhp`*(^|&A&;T&bI9Hg=zVc`_pu)u_pAB- z@w?t9o2xH~d!+`{cb`2i{#(zwlC#hFPh$-@Ctm)YSqqzG{)Gkv^Ut(E$-aDEi@70< zuJzQt<|X9)k$k_zzQq3rG`JPE?L*iH)Vt{wa`)~fdxeLpG8e?81A5Yca%hpw?4gcx z57F4+k7?pZf1`;9!2JV%2mdey{{i2Rnjg>mrFuWB`At0V2{kX-LS<;{XlsfN?0YOG z4H&!YT`Gkp$?VI=Kn);W;((bA@$zrYHsvV!7a9igyXzU4>%NF8VIq4K%;oO|US14_TIJg+IS#k3!w zyMGumt0X4Z5$Y|KOvf0Za!hsPDCUcS8r>LavW|pXdDn_9fpB-ogG5{QeNjvt(^a=R)k7(&#f25^%|B-XQ?A|}oGR{8u_pu-3`-9k*YJN)JKXK$! z>Rf-2Y6@$mal$7tR+!fU3o5au;EAvrV0C&V{?lIrUeSVmb`N`&X_v&m&;X^+S33`8 zU#<@XG=Rqgh~XO>)}cP|G;)6Ue_!4o$@fd_OZ<<3|KTfO;D6+*U(?7g*zVuZ$e!Oy zwim~Ju-(ULaOa!UxAl3{6MjOTHTP2IibK>kx|>=@c5!PPy^1=P?Wc~FH^L5)=iED~ z_o7E>;EGph^x6+-{MOHC{I<_&>ES=n(%WHof_G-$2k&>m?uNndFS{4qD}4WQRo{;~ zKdbqp-p|-){e5GH-=mh%EmVOxzsx^ZfXyE~x%mQ219~tnK&`Kzt=++WKVx4uU;QsM z24Ig39S6*qb%kd9=a}39{)Gmpaj$$X77dVc1B@N0?4geFJD>sY(ICtFneUh5{vA_& zKVx6^{lh`*|At1vJ+qO$|CdJh!LI%tjb8IRTC)EWT5>Ip*Wq{ocK!dOB?o^`OK$i* zjot8{GCXzUgoIrm`yFxbBxb_ZkM$N%@iKiCiW{y+PCe<1FU~b{>N_qRKfq}KK{Wy=Re^4S?nLx_p^GxT=T_1maAzcPq- zCHwMuyc#fi`#V7zu>8?K@IHhRCHKnn^oj$%EdSS>f7XhBb{|KM>1V|K&AyvEgQ*u&O$7N{^Pio2=~VuO281#n`uJouAfN@+)Y!CvIwua(z_y=Dc|Y^}VSK+b?vKg$ zAA;{!#QkH6xPM$3_p_LPO56_(P}KZbf8W?GA5i!CcTt1qT&l^hQXMB03;XhGPF4e| z3u}Vb4|KGQD#i|_xdzP0*seAIb=eE^)cF@05XyY$<9Ibd)&h347FejEVKsFuJxKi* zy-35mp#^&o3n1rLjr*^aeE-;i$i6=~?hnoT1988y=C|~&&uQeE*TMU})YP+)YKv^h z=i?YB7*Yp_R|7^5zsX~O$;bZ8$NMP93a}U;UE_fKnKSdX=AXv^Ii@#+2FxdOCQLC7 zIFxs_$69KDQV(h{#>iIMLUnbM)Y`w3Jge`dzKfor;XTJ`bU*5U;D40W{22cS<-9+7 z&5!$jR_{j)u;d2RB+p1`YSW`Th z{Vd<(^wR*WMXp)DU#Nzl?|)r*{zV+1#=Y{nL}>ui1GPCrOdHJkwPY`EBB!~R>Km3* zQ^y8s>Dxxl+_vL-bvC?g;#+l?Hs7_ivfsmU&IY(V3lhG&27p4p91hwew`h0jIJCuo?hc zOHe)jm-B$M(SRQ(f_ddVF(1(BLVn^}bk+vGxnJ zEZY`!-e5o*44w;|nPF41ny7PEw&^*c0W1fQO`7)lbsKo+_SW|TN(?B$-zF(FETTZSC{QpB}fXuz}C^SH5K*BZPMA3IL7Ro%P;^@f9 zyHd$NJCEEiD8>hExuCuY=KV9|^@J5#~D@-y8tivPPa$8=n1fYSFX z&qW*{;(&z20Uw$Ex9NW3TMtq*t#Cmf;+|+J92U_ z6&euAeCT7L0YU?!Yrx`}bGF5Zot|EHI`}^*G$54u(8odpga$;^fa~=H^>n@q+dI^m zlXs8MfKcW`9}5i-8lb8HcWbqW=XAK$^0iC@T-kXCga(8%ANp8mfY1P?24vlAJ`gQy>#|g`nyAl)2CRYxh`vGpx?!RGv&TXMPGJrB zrufXPwTa?S5BK$r5BQwN0=_lG#e=_vKF*mOTb%BrN96&ybTtMUkGUt(w2G)R&kn1-mgE75-hO~wVVu5Va z37LO(6dE8jAQJoXHG^rum!qsRqh-qnsq z&;YDA_DspF?7U=iWw6gX-M6;5*k52ueNbqC8vF8ddewm9nOScXpP7lFe5qNd*I@k1 zU-xvru2^>73nCW~ae&Ntw4=T>AoDfBy^Qn8AEiA<`M$$q?{OXzh&UjU4y0HEzAT%W zb+q)%EXT?J;>A1FTA+TW>0c2GOxUwc&xWrJihYNbYl(-Ri?ziQwzl{?;C^H2oH=Kx z{dUE3+O~irGq1prmA3<9iC%;jd=;z*LIXm%S3VXRpos>2SvD){MeJ36Df0V#ZGYY3 zZqsRj#+;mgwqp%)dzNVzbl^4E2}icj044LG=RyOftpO)WXJx+%=C3N7oz+r~-`sS5 zqvEy6YXLUr*qUw1v1Ki6!dhf&p#=wEkHB6)?(n8PJNJFqXRs46@>>?peP+XPsx8EG z>So3BQ7sd%fmygMz%e#6Pw59x)&tqOs0YMR4>(aiEBiCp`>;3R&tHH&0y|JPD`#!_ z%&ey3SvlE8pNF43`ot%R77#5UT0pdbXaUgzq6I_?h!zknAX-4QfM@~H0-^;(3y2mF zEg)Jzw18*<(E_3cL<@)(5G^2DK(v5p0nq};{dC3kuclr; z?KDf^1;^E{|F^*Pt!mc;AMlOZWyJ@me0%ne4EY0&tH0m>1-7bR_kRKP%cq^@yT9sR z&)#E^UJ&N%`M=;F!rlL0r1u{Wcir~~JQeQxx1{%nyX^Y|!n7`X*A{kzXxGF3`Mw4S z`{(-_Anc!i+G)H2j)(vLyaApHf1Ni#wCh{Lzn}jC!e5peFv|4^&Bq%!Li6zkjCMUj z^YR9c_j-i?9{wK`?fUUZ?Sucoc&|Sd>Hhcu#Cv^fr2FFslIZJ^KA!ym#q~%nAlfHV z>xkcv_eJD#N2`xVEO4^^ ziAe3wu5S(h0$$&LD*W}B`VWqWe?RY^i0~i8`!^yqa0dJS5gO1E<@y$OJwgNX>k%4| zy*)w&oi|T}3O@hyiNZF3uYW6S17z@z58nV5>3Y~dz;`*^FM$8=v)6?AAM}0wuz!H> p>xcaxvdEvW{ssIWALa}AKR(O{1a7bT8&}*udjGEfo1Stf{~sw70m%RW literal 33772 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 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'), );