yt-dlp for page links in every Link mode; app icon and Linux launcher entry
yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are probed: direct files stay links, pages are resolved for title, uploader, duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the managed copy. I pass it on / the server fetches it: the best single-file format, re-resolved once when its URL expires. Relay names never split the /r/ path; viewers of server-fetched pages wait for the relay to go live. No wire change. Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the Windows .ico). GTK app id moe.senkochan.watch_together; release builds install their .desktop file and icons into ~/.local/share so Wayland shows the icon; the window icon is set for X11. README shows the icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -1,4 +1,8 @@
|
|||||||
# watch-together
|
<p align="center">
|
||||||
|
<img src="apps/desktop/assets/icon/icon_256.png" width="128" height="128" alt="watch-together icon">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">watch-together</h1>
|
||||||
|
|
||||||
Watch a video with friends, in sync, with the quality of a real mpv player.
|
Watch a video with friends, in sync, with the quality of a real mpv player.
|
||||||
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
|
Pick from your Jellyfin library, paste any direct video link, or pick a file on your own disk;
|
||||||
@@ -22,7 +26,8 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
|
|||||||
From the Releases page of this repo:
|
From the Releases page of this repo:
|
||||||
|
|
||||||
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
|
||||||
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
|
`watch_together`. On start it adds itself to your app launcher (and its
|
||||||
|
icon), so the window shows its own icon on Wayland. Needs mpv's library installed (`mpv` or `libmpv2`).
|
||||||
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
|
||||||
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
|
||||||
Windows desktop; reports welcome.
|
Windows desktop; reports welcome.
|
||||||
@@ -103,8 +108,10 @@ folder to Files, or fill Link).
|
|||||||
details open on the right with "Put it up · S1E3", mark watched /
|
details open on the right with "Put it up · S1E3", mark watched /
|
||||||
unwatched, remove from Continue and the episode list (click an episode
|
unwatched, remove from Continue and the episode list (click an episode
|
||||||
to choose it). Double-click a card or an episode to put it up at once.
|
to choose it). Double-click a card or an episode to put it up at once.
|
||||||
- **Link**: paste an http(s) link. The preview shows what it is (title and
|
- **Link**: paste an http(s) link: a video file, or a web page with a
|
||||||
thumbnail for YouTube, else the file name); in a server, pick how the
|
video (YouTube, X, and whatever else yt-dlp knows). The preview shows
|
||||||
|
what it is (a page's title, uploader, length and thumbnail, else the
|
||||||
|
file name); in a server, pick how the
|
||||||
others get it (everyone fetches it, the server fetches it, or through
|
others get it (everyone fetches it, the server fetches it, or through
|
||||||
you). Your last 12 links are listed under it.
|
you). Your last 12 links are listed under it.
|
||||||
- **Files**: video folders you add on this computer, grouped by series
|
- **Files**: video folders you add on this computer, grouped by series
|
||||||
@@ -130,7 +137,9 @@ then everyone (you included) uses Join.
|
|||||||
| `--name` | `server` | name reported by `/health` |
|
| `--name` | `server` | name reported by `/health` |
|
||||||
| `--media` | none | local file to serve as the starting video |
|
| `--media` | none | local file to serve as the starting video |
|
||||||
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
||||||
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
|
| `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link |
|
||||||
|
| `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) |
|
||||||
|
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`); replaces the flags |
|
||||||
|
|
||||||
It prints `READY port=... name=...` once listening and stops cleanly on
|
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||||
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
||||||
@@ -291,7 +300,12 @@ the others get it:
|
|||||||
- Through me: your computer downloads it and passes it on, for links only
|
- Through me: your computer downloads it and passes it on, for links only
|
||||||
you can open.
|
you can open.
|
||||||
|
|
||||||
The last two need a link to a video file (not a page like YouTube) and
|
Web pages work in all three: the app and the dedicated server each
|
||||||
|
download [yt-dlp](https://github.com/yt-dlp/yt-dlp) by themselves (checked
|
||||||
|
against the release's checksums, updated once a day; Settings → Player
|
||||||
|
shows its version and has "Update now"), so there is nothing to install.
|
||||||
|
The last two need a page with a single-file video (X has one; YouTube only
|
||||||
|
has separate video and audio streams, so use "Everyone fetches it") and
|
||||||
never show the link to anyone. Your upload has to beat the video's
|
never show the link to anyone. Your upload has to beat the video's
|
||||||
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
|
bitrate: a 25-minute 2 GB episode is about 11 Mbit/s.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2d2547"/><stop offset="1" stop-color="#14111d"/></linearGradient><linearGradient id="t1" gradientUnits="userSpaceOnUse" x1="50.7" y1="148.7" x2="184.6" y2="71.4"><stop offset="0" stop-color="#f0795a" stop-opacity="0"/><stop offset="0.45" stop-color="#f0795a" stop-opacity="0.85"/><stop offset="1" stop-color="#ffc56b"/></linearGradient><linearGradient id="t2" gradientUnits="userSpaceOnUse" x1="205.3" y1="107.3" x2="71.4" y2="184.6"><stop offset="0" stop-color="#ffc56b" stop-opacity="0"/><stop offset="0.45" stop-color="#ffc56b" stop-opacity="0.85"/><stop offset="1" stop-color="#f0795a"/></linearGradient></defs>
|
||||||
|
<rect x="8" y="8" width="240" height="240" rx="56" fill="url(#bg)"/>
|
||||||
|
<circle cx="128" cy="128" r="80" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="6"/>
|
||||||
|
<path d="M205.27 107.29 A80 80 0 0 1 71.43 184.57" fill="none" stroke="url(#t2)" stroke-width="7" stroke-linecap="butt"/><path d="M50.73 148.71 A80 80 0 0 1 184.57 71.43" fill="none" stroke="url(#t1)" stroke-width="7" stroke-linecap="butt"/>
|
||||||
|
<path d="M110 96 L110 160 L162 128 Z" fill="#fff1d6" stroke="#fff1d6" stroke-width="16" stroke-linejoin="round"/>
|
||||||
|
<path d="M71.4 161.6 Q74.7 181.3 94.4 184.6 Q74.7 187.8 71.4 207.6 Q68.2 187.8 48.4 184.6 Q68.2 181.3 71.4 161.6 Z" fill="#f0795a"/>
|
||||||
|
<path d="M184.6 40.4 Q188.9 67.1 215.6 71.4 Q188.9 75.8 184.6 102.4 Q180.2 75.8 153.6 71.4 Q180.2 67.1 184.6 40.4 Z" fill="#ffc56b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 732 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -7,6 +8,8 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'src/playback/yt_dlp_app.dart';
|
||||||
|
import 'src/platform/desktop_entry.dart';
|
||||||
import 'src/screens/home/ui.dart';
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/state/session_state.dart';
|
import 'src/state/session_state.dart';
|
||||||
import 'src/state/settings.dart';
|
import 'src/state/settings.dart';
|
||||||
@@ -60,6 +63,10 @@ Future<void> main(List<String> args) async {
|
|||||||
debugPrint('load connect form failed: $e\n$st');
|
debugPrint('load connect form failed: $e\n$st');
|
||||||
}
|
}
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
|
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||||
|
AppYtDlp.instance.warmUp();
|
||||||
|
// Linux: the launcher entry and icon Wayland needs (release installs).
|
||||||
|
unawaited(installDesktopEntry());
|
||||||
runApp(const WatchTogetherApp());
|
runApp(const WatchTogetherApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../update/updater.dart' show findInstallRoot;
|
||||||
|
|
||||||
|
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||||
|
/// compositor matches to [desktopFileName] for the window's icon.
|
||||||
|
const appId = 'moe.senkochan.watch_together';
|
||||||
|
const desktopFileName = '$appId.desktop';
|
||||||
|
|
||||||
|
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
|
||||||
|
/// Wayland shows a generic icon. On start: write the `.desktop` file into
|
||||||
|
/// `~/.local/share/applications` (Exec = this binary) and the icon into
|
||||||
|
/// the hicolor theme, only when something changed (a moved install, a new
|
||||||
|
/// icon). Never in debug runs; failures only log.
|
||||||
|
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
|
||||||
|
if (!Platform.isLinux || kDebugMode) return;
|
||||||
|
try {
|
||||||
|
final exe = executable ?? Platform.resolvedExecutable;
|
||||||
|
final root = findInstallRoot(exe, windows: false);
|
||||||
|
if (root == null) return;
|
||||||
|
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
|
||||||
|
'${home ?? Platform.environment['HOME']}/.local/share';
|
||||||
|
final icons = '${root.path}/data/flutter_assets/assets/icon';
|
||||||
|
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
|
||||||
|
final src = File('$icons/icon_$size.png');
|
||||||
|
if (!src.existsSync()) continue;
|
||||||
|
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
|
||||||
|
if (dst.existsSync() && dst.lengthSync() == src.lengthSync()) continue;
|
||||||
|
await dst.parent.create(recursive: true);
|
||||||
|
await src.copy(dst.path);
|
||||||
|
}
|
||||||
|
final entry = desktopEntryText(exe);
|
||||||
|
final file = File('$share/applications/$desktopFileName');
|
||||||
|
if (file.existsSync() && file.readAsStringSync() == entry) return;
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
await file.writeAsString(entry);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('desktop entry: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
String desktopEntryText(String exe) => '''[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Watch together
|
||||||
|
Comment=Watch videos in sync with friends
|
||||||
|
Exec="$exe"
|
||||||
|
Icon=$appId
|
||||||
|
StartupWMClass=$appId
|
||||||
|
Categories=AudioVideo;Video;Player;Network;
|
||||||
|
Terminal=false
|
||||||
|
''';
|
||||||
@@ -16,6 +16,7 @@ import '../sync/state_diff.dart';
|
|||||||
import '../sync/user_seek.dart';
|
import '../sync/user_seek.dart';
|
||||||
import 'frame_watchdog.dart';
|
import 'frame_watchdog.dart';
|
||||||
import 'media_marks.dart';
|
import 'media_marks.dart';
|
||||||
|
import 'yt_dlp_app.dart' show ytdlFormat;
|
||||||
|
|
||||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||||
/// by [init] on join, released by [dispose] on leave, swapped by
|
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||||
@@ -60,6 +61,26 @@ class PlaybackController {
|
|||||||
/// Called once when the watchdog sees a frozen picture.
|
/// Called once when the watchdog sees a frozen picture.
|
||||||
void Function()? onFrozen;
|
void Function()? onFrozen;
|
||||||
|
|
||||||
|
/// mpv's yt-dlp hook could not open the current link (its error text).
|
||||||
|
void Function(String error)? onYtdlError;
|
||||||
|
|
||||||
|
/// Points mpv's yt-dlp hook at [path] (the app's managed copy) for web
|
||||||
|
/// page links, at most 1080p. Once per player.
|
||||||
|
Future<void> useYtDlp(String path) async {
|
||||||
|
final p = _player;
|
||||||
|
if (p == null || identical(p, _ytdlSetOn)) return;
|
||||||
|
try {
|
||||||
|
final dynamic platform = p.platform;
|
||||||
|
await platform?.setProperty('script-opts', 'ytdl_hook-ytdl_path=$path');
|
||||||
|
await platform?.setProperty('ytdl-format', ytdlFormat);
|
||||||
|
_ytdlSetOn = p;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('yt-dlp path not set: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Player? _ytdlSetOn;
|
||||||
|
|
||||||
/// Fingerprint of the picture on screen, installed by the video view
|
/// Fingerprint of the picture on screen, installed by the video view
|
||||||
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
||||||
Future<int?> Function()? shownFrame;
|
Future<int?> Function()? shownFrame;
|
||||||
@@ -298,6 +319,11 @@ class PlaybackController {
|
|||||||
// an error there while shaders are on is a shader that failed to load
|
// an error there while shaders are on is a shader that failed to load
|
||||||
// or compile (mpv then renders without it).
|
// or compile (mpv then renders without it).
|
||||||
void _onLog(PlayerLog log) {
|
void _onLog(PlayerLog log) {
|
||||||
|
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
|
||||||
|
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
|
||||||
|
if (text.isNotEmpty) onYtdlError?.call(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (log.prefix != 'libmpv_render') return;
|
if (log.prefix != 'libmpv_render') return;
|
||||||
if (log.text.contains('suspected software renderer')) {
|
if (log.text.contains('suspected software renderer')) {
|
||||||
_setRenderMode(RenderMode.softwareGl);
|
_setRenderMode(RenderMode.softwareGl);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
/// mpv's `ytdl-format` for links every player opens itself.
|
||||||
|
const ytdlFormat = 'bv*[height<=1080]+ba/b';
|
||||||
|
|
||||||
|
/// The app's yt-dlp (see [YtDlp]): in the app-support folder, shared with
|
||||||
|
/// the hosted server. [probe] remembers what pasted links are for a while,
|
||||||
|
/// so the Link preview and the swap ask once.
|
||||||
|
class AppYtDlp {
|
||||||
|
static final instance = AppYtDlp();
|
||||||
|
|
||||||
|
/// [ytDlp]: a given one (tests); default in the app-support folder.
|
||||||
|
AppYtDlp([YtDlp? ytDlp]) : _ytDlp = ytDlp == null ? null : Future.value(ytDlp);
|
||||||
|
|
||||||
|
Future<YtDlp>? _ytDlp;
|
||||||
|
Future<YtDlp> get ytDlp => _ytDlp ??= () async {
|
||||||
|
final base = await getApplicationSupportDirectory();
|
||||||
|
return YtDlp(dir(base.path));
|
||||||
|
}();
|
||||||
|
|
||||||
|
static String dir(String appSupport) => '$appSupport${Platform.pathSeparator}yt-dlp';
|
||||||
|
|
||||||
|
/// Gets yt-dlp ready in the background (download on first run, a daily
|
||||||
|
/// update check afterwards).
|
||||||
|
void warmUp() => unawaited(ytDlp.then((y) => y.ensure()).catchError((_) => null));
|
||||||
|
|
||||||
|
final _probes = <String, (DateTime, Future<LinkProbe>)>{};
|
||||||
|
static const _probeTtl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
Future<LinkProbe> probe(String url) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
_probes.removeWhere((_, v) => now.difference(v.$1) > _probeTtl);
|
||||||
|
final hit = _probes[url];
|
||||||
|
if (hit != null) return hit.$2;
|
||||||
|
final f = ytDlp.then((y) => probeLink(Uri.parse(url), ytDlp: y));
|
||||||
|
_probes[url] = (now, f);
|
||||||
|
f.catchError((Object _) {
|
||||||
|
_probes.remove(url);
|
||||||
|
return LinkProbe.file(Uri(), -1);
|
||||||
|
});
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
@@ -12,6 +13,7 @@ import '../jellyfin/session_media.dart';
|
|||||||
import '../jellyfin/track_prefs.dart';
|
import '../jellyfin/track_prefs.dart';
|
||||||
import '../playback/media_marks.dart';
|
import '../playback/media_marks.dart';
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../schedule/anilist.dart';
|
import '../schedule/anilist.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
@@ -132,7 +134,10 @@ final class _FileOrigin extends _RelayOrigin {
|
|||||||
|
|
||||||
final class _LinkOrigin extends _RelayOrigin {
|
final class _LinkOrigin extends _RelayOrigin {
|
||||||
final String url;
|
final String url;
|
||||||
const _LinkOrigin(this.url);
|
/// What the link is (a page goes through [ytDlp]).
|
||||||
|
final LinkProbe probe;
|
||||||
|
final YtDlp ytDlp;
|
||||||
|
const _LinkOrigin(this.url, this.probe, this.ytDlp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Jellyfin item: served with this install's account, else with the
|
/// A Jellyfin item: served with this install's account, else with the
|
||||||
@@ -159,6 +164,8 @@ class SessionCoordinator {
|
|||||||
/// Set by the hosting app: makes a local file the hosted one (File swap);
|
/// Set by the hosting app: makes a local file the hosted one (File swap);
|
||||||
/// null when this app is not the host.
|
/// null when this app is not the host.
|
||||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||||
|
/// The app's yt-dlp and link probes (tests pass their own).
|
||||||
|
final AppYtDlp ytDlp;
|
||||||
final SyncClient sync = SyncClient();
|
final SyncClient sync = SyncClient();
|
||||||
/// Member pictures by `avatarHash`, fetched from the server.
|
/// Member pictures by `avatarHash`, fetched from the server.
|
||||||
late final AvatarCache avatars = AvatarCache(sync);
|
late final AvatarCache avatars = AvatarCache(sync);
|
||||||
@@ -293,8 +300,9 @@ class SessionCoordinator {
|
|||||||
bool _jellyfinListening = false;
|
bool _jellyfinListening = false;
|
||||||
|
|
||||||
SessionCoordinator(
|
SessionCoordinator(
|
||||||
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs})
|
{this.sfx = const NoopSfxService(), JellyfinAccounts? jellyfin, TrackPrefs? trackPrefs, AppYtDlp? ytDlp})
|
||||||
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
: ytDlp = ytDlp ?? AppYtDlp.instance,
|
||||||
|
jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||||
trackPrefs = trackPrefs ?? TrackPrefs();
|
trackPrefs = trackPrefs ?? TrackPrefs();
|
||||||
|
|
||||||
/// Player lifetime = one session: created here on connect, disposed by
|
/// Player lifetime = one session: created here on connect, disposed by
|
||||||
@@ -304,6 +312,7 @@ class SessionCoordinator {
|
|||||||
if (_playerReady) return;
|
if (_playerReady) return;
|
||||||
try {
|
try {
|
||||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||||
|
playback.onYtdlError = (e) => health.value = HealthStatus(HealthSeverity.error, "can't open this link: $e");
|
||||||
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
|
||||||
await playback.init();
|
await playback.init();
|
||||||
_playerReady = true;
|
_playerReady = true;
|
||||||
@@ -456,6 +465,7 @@ class SessionCoordinator {
|
|||||||
diff.reset();
|
diff.reset();
|
||||||
_opening = true;
|
_opening = true;
|
||||||
try {
|
try {
|
||||||
|
await _prepareYtDlp(url);
|
||||||
await playback.openUrl(url, headers: headers);
|
await playback.openUrl(url, headers: headers);
|
||||||
health.value =
|
health.value =
|
||||||
const HealthStatus(HealthSeverity.ok, 'connected');
|
const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
@@ -470,8 +480,30 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
|
||||||
|
/// managed yt-dlp, downloaded first if needed; a media file name does
|
||||||
|
/// not wait for that.
|
||||||
|
Future<void> _prepareYtDlp(String url) async {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) return;
|
||||||
|
if (uri.host == _wsHost && uri.port == _wsPort) return;
|
||||||
|
try {
|
||||||
|
final y = await ytDlp.ytDlp;
|
||||||
|
final ready = y.ensure();
|
||||||
|
final path = hasMediaExtension(uri)
|
||||||
|
? y.path
|
||||||
|
: await ready.timeout(const Duration(seconds: 90), onTimeout: () => null);
|
||||||
|
if (path != null) await playback.useYtDlp(path);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('yt-dlp not ready: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _relayWaitShown = false;
|
bool _relayWaitShown = false;
|
||||||
|
|
||||||
|
/// The server-fetched media this viewer opens once it is live.
|
||||||
|
int _awaitLiveFor = -1;
|
||||||
|
|
||||||
/// Relayed through a member whose channel is down: say who everyone is
|
/// Relayed through a member whose channel is down: say who everyone is
|
||||||
/// waiting for (the buffer still plays what it has).
|
/// waiting for (the buffer still plays what it has).
|
||||||
void _relayStatus(StateBroadcast r) {
|
void _relayStatus(StateBroadcast r) {
|
||||||
@@ -494,7 +526,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _relayMediaUrlFor(String name, int mediaId) =>
|
String _relayMediaUrlFor(String name, int mediaId) =>
|
||||||
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${Uri.encodeComponent(name)}';
|
'http://$_wsHost:$_wsPort/r/default/${sync.token}/$mediaId/${relayUrlName(name)}';
|
||||||
|
|
||||||
/// Serves [bytes] for media [mediaId] on this member's relay channel.
|
/// Serves [bytes] for media [mediaId] on this member's relay channel.
|
||||||
/// The server only pulls while someone watches through it.
|
/// The server only pulls while someone watches through it.
|
||||||
@@ -617,9 +649,19 @@ class SessionCoordinator {
|
|||||||
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
|
_startRelaySource(r.mediaId, FileRelayBytes(File(origin.path)), announce: true);
|
||||||
} else if (origin is _LinkOrigin) {
|
} else if (origin is _LinkOrigin) {
|
||||||
await _openMedia(origin.url);
|
await _openMedia(origin.url);
|
||||||
_startRelaySource(r.mediaId, HttpRelayBytes(Uri.parse(origin.url)), announce: true);
|
_startRelaySource(
|
||||||
|
r.mediaId, LinkRelayBytes(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
|
||||||
|
announce: true);
|
||||||
} else if (relay != null && !mine) {
|
} else if (relay != null && !mine) {
|
||||||
|
if (relay.fetch && !relay.live) {
|
||||||
|
// The server is still getting it (yt-dlp takes seconds; mpv
|
||||||
|
// gives up on a silent request after 5): open once it is live.
|
||||||
|
_openUrl = null;
|
||||||
|
_awaitLiveFor = r.mediaId;
|
||||||
|
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
|
||||||
|
} else {
|
||||||
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
|
await _openMedia(_relayMediaUrlFor(name, r.mediaId));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
var url = stateMediaUrl(r);
|
var url = stateMediaUrl(r);
|
||||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||||
@@ -635,6 +677,9 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
_seekGate.reset();
|
_seekGate.reset();
|
||||||
_clearPendingSeek();
|
_clearPendingSeek();
|
||||||
|
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
|
||||||
|
_awaitLiveFor = -1;
|
||||||
|
await _openMedia(_relayMediaUrlFor((r.media?['name'] as String?) ?? '', r.mediaId));
|
||||||
}
|
}
|
||||||
_jellyfinTransport(r);
|
_jellyfinTransport(r);
|
||||||
_relayStatus(r);
|
_relayStatus(r);
|
||||||
@@ -1060,8 +1105,11 @@ class SessionCoordinator {
|
|||||||
if (ref != null && client != null) {
|
if (ref != null && client != null) {
|
||||||
bytes = await jellyfinArt(client, ref.itemId);
|
bytes = await jellyfinArt(client, ref.itemId);
|
||||||
} else if (ref == null && (media.url ?? '').isNotEmpty) {
|
} else if (ref == null && (media.url ?? '').isNotEmpty) {
|
||||||
// A web link: its name is a URL tail ("1"), not a show; never guess.
|
// A web link is not a show: never guess. A page's thumbnail is
|
||||||
bytes = null;
|
// its banner.
|
||||||
|
final thumb = (await _probeFor(media.url!))?.info?.thumbnail;
|
||||||
|
final uri = thumb == null ? null : Uri.tryParse(thumb);
|
||||||
|
bytes = uri == null ? null : (banner: await fetchImage(uri, const {}, RelayConstants.maxBannerBytes), poster: null);
|
||||||
} else {
|
} else {
|
||||||
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
||||||
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
||||||
@@ -1089,32 +1137,57 @@ class SessionCoordinator {
|
|||||||
_swap(media.copyWith(relay: const MediaRelay()));
|
_swap(media.copyWith(relay: const MediaRelay()));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (relay == LinkRelay.direct) {
|
LinkProbe? probe;
|
||||||
_swap(media);
|
String? probeError;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Relayed: it must be a single file the relay can range-read (not a
|
|
||||||
// page like YouTube, which only the player can resolve).
|
|
||||||
final probe = HttpRelayBytes(Uri.parse(url));
|
|
||||||
final int size;
|
|
||||||
try {
|
try {
|
||||||
size = await probe.size().timeout(const Duration(seconds: 15));
|
probe = await ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return relay == LinkRelay.me
|
probeError = '$e';
|
||||||
? 'cannot pass this link on: $e'
|
}
|
||||||
: 'cannot check this link: $e';
|
// A page is named by its title, never its URL tail ("1", "watch").
|
||||||
} finally {
|
final uri = Uri.tryParse(url) ?? Uri();
|
||||||
await probe.close();
|
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
|
||||||
|
if (relay == LinkRelay.direct) {
|
||||||
|
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (relay == LinkRelay.me) {
|
if (relay == LinkRelay.me) {
|
||||||
_pendingOrigin = _LinkOrigin(url);
|
// This computer reads it: a file, or a page's single-file format.
|
||||||
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
if (probe == null) return 'cannot pass this link on: $probeError';
|
||||||
} else {
|
final problem = probe.relayProblem;
|
||||||
_swap(HelloMedia(
|
if (problem != null) return problem;
|
||||||
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
final y = await ytDlp.ytDlp;
|
||||||
|
final check = LinkRelayBytes(uri, ytDlp: y, probe: probe);
|
||||||
|
final int size;
|
||||||
|
try {
|
||||||
|
size = await check.size().timeout(const Duration(seconds: 15));
|
||||||
|
} catch (e) {
|
||||||
|
return 'cannot pass this link on: $e';
|
||||||
|
} finally {
|
||||||
|
await check.close();
|
||||||
}
|
}
|
||||||
|
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||||
|
_swap(HelloMedia(name: name, size: size, duration: 0, relay: const MediaRelay()));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// The server reads it with its own yt-dlp; only a page this app knows
|
||||||
|
// has no single file is refused here (the server may reach what this
|
||||||
|
// computer cannot).
|
||||||
|
if (probe != null && probe.isPage && probe.info != null && probe.info!.single == null) {
|
||||||
|
return noSingleFileText;
|
||||||
|
}
|
||||||
|
_swap(HelloMedia(
|
||||||
|
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LinkProbe?> _probeFor(String url) async {
|
||||||
|
try {
|
||||||
|
return await ytDlp.probe(url).timeout(const Duration(seconds: 45));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
/// A Jellyfin pick (browser, next / previous episode) goes up as a ref,
|
||||||
/// relayed by this member for viewers without an account.
|
/// relayed by this member for viewers without an account.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, notAVideoText;
|
||||||
|
|
||||||
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../screens/home/media_picker.dart';
|
import '../screens/home/media_picker.dart';
|
||||||
import '../state/connect_prefs.dart';
|
import '../state/connect_prefs.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
@@ -109,49 +110,49 @@ class LinkPreview {
|
|||||||
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
const LinkPreview({required this.title, required this.subtitle, this.thumbnail});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET [uri] and return the body (oEmbed).
|
/// What a link is (see [AppYtDlp.probe]).
|
||||||
typedef OEmbedGetter = Future<String> Function(Uri uri);
|
typedef LinkProber = Future<LinkProbe> Function(String url);
|
||||||
|
|
||||||
Future<String> defaultOEmbedGet(Uri uri) async {
|
Future<LinkProbe> defaultLinkProber(String url) => AppYtDlp.instance.probe(url);
|
||||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 6);
|
|
||||||
try {
|
|
||||||
final req = await client.getUrl(uri);
|
|
||||||
final res = await req.close().timeout(const Duration(seconds: 8));
|
|
||||||
if (res.statusCode != 200) throw HttpException('status ${res.statusCode}', uri: uri);
|
|
||||||
return await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 8));
|
|
||||||
} finally {
|
|
||||||
client.close(force: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isYouTubeLink(Uri uri) {
|
|
||||||
final h = uri.host.toLowerCase();
|
|
||||||
return h == 'youtu.be' || h == 'youtube.com' || h.endsWith('.youtube.com');
|
|
||||||
}
|
|
||||||
|
|
||||||
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
|
||||||
|
|
||||||
/// The preview of [url]: YouTube links ask YouTube's oEmbed for the title,
|
/// "3:07", "1:02:03".
|
||||||
/// channel and thumbnail; anything else (or a failed lookup) shows the
|
String clockText(double seconds) {
|
||||||
/// file name and host. Null for text that is not an http(s) link.
|
final s = seconds.round();
|
||||||
Future<LinkPreview?> previewLink(String url, {OEmbedGetter get = defaultOEmbedGet}) async {
|
final h = s ~/ 3600, m = s % 3600 ~/ 60, sec = (s % 60).toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$sec' : '$m:$sec';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview of [url]: a web page shows yt-dlp's title, uploader,
|
||||||
|
/// duration and thumbnail (or why it is not a video); a media file (by
|
||||||
|
/// name or by probe) its file name and host. Null for text that is not an http(s) link.
|
||||||
|
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
|
||||||
final uri = Uri.tryParse(url.trim());
|
final uri = Uri.tryParse(url.trim());
|
||||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
|
||||||
final plain = LinkPreview(title: LinkMedia(url.trim()).label, subtitle: _host(uri));
|
final host = _host(uri);
|
||||||
if (!isYouTubeLink(uri)) return plain;
|
// A media file name needs no lookup.
|
||||||
|
if (hasMediaExtension(uri)) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||||
|
final LinkProbe p;
|
||||||
try {
|
try {
|
||||||
final body = await get(Uri.https('www.youtube.com', '/oembed', {'url': url.trim(), 'format': 'json'}));
|
p = await probe(url.trim());
|
||||||
final j = jsonDecode(body);
|
} catch (e) {
|
||||||
if (j is! Map || j['title'] is! String) return plain;
|
return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: '$host · $e');
|
||||||
final author = j['author_name'];
|
|
||||||
return LinkPreview(
|
|
||||||
title: j['title'] as String,
|
|
||||||
subtitle: [_host(uri), if (author is String && author.isNotEmpty) author].join(' · '),
|
|
||||||
thumbnail: j['thumbnail_url'] is String ? j['thumbnail_url'] as String : null,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return plain;
|
|
||||||
}
|
}
|
||||||
|
final info = p.info;
|
||||||
|
if (!p.isPage) return LinkPreview(title: LinkMedia(url.trim()).label, subtitle: host);
|
||||||
|
if (info == null) {
|
||||||
|
return LinkPreview(title: p.title ?? host, subtitle: '$host · ${notAVideoText(p.ytDlpError)}');
|
||||||
|
}
|
||||||
|
return LinkPreview(
|
||||||
|
title: p.title ?? host,
|
||||||
|
subtitle: [
|
||||||
|
host,
|
||||||
|
if (info.uploader.isNotEmpty) info.uploader,
|
||||||
|
if (info.duration case final d? when d > 0) clockText(d),
|
||||||
|
].join(' · '),
|
||||||
|
thumbnail: info.thumbnail,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
/// "today", "yesterday", "3 days ago", "last week", "5 weeks ago", ...
|
||||||
@@ -195,14 +196,14 @@ class LibraryLinkSource extends StatefulWidget {
|
|||||||
/// Puts the link up; returns an error text, or null when sent.
|
/// Puts the link up; returns an error text, or null when sent.
|
||||||
final Future<String?> Function(LinkMedia media) onPutUp;
|
final Future<String?> Function(LinkMedia media) onPutUp;
|
||||||
final RecentLinks recents;
|
final RecentLinks recents;
|
||||||
final OEmbedGetter oembed;
|
final LinkProber probe;
|
||||||
const LibraryLinkSource({
|
const LibraryLinkSource({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.onPutUp,
|
required this.onPutUp,
|
||||||
required this.recents,
|
required this.recents,
|
||||||
this.offlineHint,
|
this.offlineHint,
|
||||||
this.oembed = defaultOEmbedGet,
|
this.probe = defaultLinkProber,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -246,7 +247,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final p = await previewLink(url, get: widget.oembed);
|
final p = await previewLink(url, probe: widget.probe);
|
||||||
_previews[url] = p;
|
_previews[url] = p;
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
@@ -272,7 +273,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
error = '$e';
|
error = '$e';
|
||||||
}
|
}
|
||||||
if (error == null) {
|
if (error == null) {
|
||||||
final p = _previews[url] ?? await previewLink(url, get: widget.oembed);
|
final p = _previews[url] ?? await previewLink(url, probe: widget.probe);
|
||||||
unawaited(widget.recents.add(RecentLink(
|
unawaited(widget.recents.add(RecentLink(
|
||||||
url: url,
|
url: url,
|
||||||
title: p?.title ?? LinkMedia(url).label,
|
title: p?.title ?? LinkMedia(url).label,
|
||||||
@@ -346,7 +347,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
|
|||||||
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
Text('Watch from a link', textAlign: TextAlign.center, style: t.headlineSmall),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'A video file, YouTube, a Jellyfin link… Ctrl+V or drop it anywhere to bring it here.',
|
'A video file, a page (YouTube, X, …) or a Jellyfin link. Ctrl+V or drop it anywhere to bring it here.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class LibraryTab extends StatefulWidget {
|
|||||||
final VoidCallback? onOpenIntegrations;
|
final VoidCallback? onOpenIntegrations;
|
||||||
/// Defaults to [RecentLinks.instance].
|
/// Defaults to [RecentLinks.instance].
|
||||||
final RecentLinks? recentLinks;
|
final RecentLinks? recentLinks;
|
||||||
final OEmbedGetter oembed;
|
final LinkProber probe;
|
||||||
/// The Files source (tests inject one); defaults to [FilesPage].
|
/// The Files source (tests inject one); defaults to [FilesPage].
|
||||||
final Widget Function(BuildContext context, {required bool online, required bool relayed,
|
final Widget Function(BuildContext context, {required bool online, required bool relayed,
|
||||||
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
||||||
@@ -100,7 +100,7 @@ class LibraryTab extends StatefulWidget {
|
|||||||
this.jellyfinStart,
|
this.jellyfinStart,
|
||||||
this.onOpenIntegrations,
|
this.onOpenIntegrations,
|
||||||
this.recentLinks,
|
this.recentLinks,
|
||||||
this.oembed = defaultOEmbedGet,
|
this.probe = defaultLinkProber,
|
||||||
this.filesBuilder,
|
this.filesBuilder,
|
||||||
this.now = DateTime.now,
|
this.now = DateTime.now,
|
||||||
});
|
});
|
||||||
@@ -245,7 +245,7 @@ class _LibraryTabState extends State<LibraryTab> {
|
|||||||
controller: _c,
|
controller: _c,
|
||||||
offlineHint: _hint,
|
offlineHint: _hint,
|
||||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||||
oembed: widget.oembed,
|
probe: widget.probe,
|
||||||
onPutUp: _put,
|
onPutUp: _put,
|
||||||
),
|
),
|
||||||
MediaKind.file => _files(context),
|
MediaKind.file => _files(context),
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show YtDlp;
|
||||||
|
|
||||||
import '../controls/player_settings.dart';
|
import '../controls/player_settings.dart';
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/accounts_panel.dart';
|
import '../jellyfin/accounts_panel.dart';
|
||||||
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../schedule/animeflow.dart';
|
import '../schedule/animeflow.dart';
|
||||||
import '../screens/home/about_page.dart';
|
import '../screens/home/about_page.dart';
|
||||||
import '../screens/home/settings_page.dart';
|
import '../screens/home/settings_page.dart';
|
||||||
@@ -107,6 +109,7 @@ class SettingsTab extends StatelessWidget {
|
|||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||||
),
|
),
|
||||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||||
|
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||||
];
|
];
|
||||||
case SettingsSection.integrations:
|
case SettingsSection.integrations:
|
||||||
final accounts = jellyfin ?? JellyfinAccounts.instance;
|
final accounts = jellyfin ?? JellyfinAccounts.instance;
|
||||||
@@ -352,6 +355,67 @@ class RecentServersPanel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The app's yt-dlp (web page links): its version, "Update now", and the
|
||||||
|
/// last error.
|
||||||
|
class YtDlpSettings extends StatefulWidget {
|
||||||
|
/// Defaults to [AppYtDlp.instance]'s.
|
||||||
|
final Future<YtDlp>? ytDlp;
|
||||||
|
const YtDlpSettings({super.key, this.ytDlp});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<YtDlpSettings> createState() => _YtDlpSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _YtDlpSettingsState extends State<YtDlpSettings> {
|
||||||
|
late final Future<YtDlp> _y = widget.ytDlp ?? AppYtDlp.instance.ytDlp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return FutureBuilder<YtDlp>(
|
||||||
|
future: _y,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final y = snap.data;
|
||||||
|
if (y == null) return const SizedBox.shrink();
|
||||||
|
return StreamBuilder<void>(
|
||||||
|
stream: y.changes,
|
||||||
|
builder: (context, _) {
|
||||||
|
final err = y.lastError;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.travel_explore),
|
||||||
|
title: Text(y.busy
|
||||||
|
? 'Downloading…'
|
||||||
|
: y.version == null
|
||||||
|
? 'Not downloaded yet'
|
||||||
|
: 'Version ${y.version}'),
|
||||||
|
subtitle: err == null
|
||||||
|
? null
|
||||||
|
: Text(err, key: const ValueKey('ytdlp-error'), style: TextStyle(color: scheme.error)),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: y.busy
|
||||||
|
? null
|
||||||
|
: () => y.path == null ? y.ensure() : y.update(force: true),
|
||||||
|
child: const Text('Update now'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Plays links to web pages (YouTube, X, …) in all three link modes. '
|
||||||
|
'Downloaded and kept up to date by the app; checked once a day.',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
|
/// AnimeFlow (the Schedule tab's source): connected as whom, with which
|
||||||
/// rights, or why not.
|
/// rights, or why not.
|
||||||
class AnimeFlowCard extends StatefulWidget {
|
class AnimeFlowCard extends StatefulWidget {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../jellyfin/accounts.dart';
|
import '../jellyfin/accounts.dart';
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
|
import '../playback/yt_dlp_app.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../sfx/synth_sfx.dart';
|
import '../sfx/synth_sfx.dart';
|
||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
@@ -165,6 +166,16 @@ class SessionState {
|
|||||||
/// Set when the hosted server died mid-session; Home shows it on the
|
/// Set when the hosted server died mid-session; Home shows it on the
|
||||||
/// Host card and clears it.
|
/// Host card and clears it.
|
||||||
final serverCrash = signal<String?>(null);
|
final serverCrash = signal<String?>(null);
|
||||||
|
|
||||||
|
/// The app's yt-dlp folder for the hosted server; '' (the server's own
|
||||||
|
/// default) when the app-support folder is not available.
|
||||||
|
static Future<String> _sharedYtDlpDir() async {
|
||||||
|
try {
|
||||||
|
return (await AppYtDlp.instance.ytDlp).dir;
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
ServerIsolate? _server;
|
ServerIsolate? _server;
|
||||||
String? _setupCrash;
|
String? _setupCrash;
|
||||||
|
|
||||||
@@ -258,6 +269,7 @@ class SessionState {
|
|||||||
mediaPath: params.mediaPath,
|
mediaPath: params.mediaPath,
|
||||||
hostKey: hostKey,
|
hostKey: hostKey,
|
||||||
jellyfin: relay,
|
jellyfin: relay,
|
||||||
|
ytDlpDir: await _sharedYtDlpDir(),
|
||||||
),
|
),
|
||||||
onLog: (line) => debugPrint('[server] $line'),
|
onLog: (line) => debugPrint('[server] $line'),
|
||||||
onCrash: (cause) => _onServerCrash(server, cause),
|
onCrash: (cause) => _onServerCrash(server, cause),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
|
|||||||
set(BINARY_NAME "watch_together")
|
set(BINARY_NAME "watch_together")
|
||||||
# The unique GTK application identifier for this application. See:
|
# The unique GTK application identifier for this application. See:
|
||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||||
set(APPLICATION_ID "com.example.watch_together")
|
set(APPLICATION_ID "moe.senkochan.watch_together")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ static void my_application_activate(GApplication* application) {
|
|||||||
|
|
||||||
gtk_window_set_default_size(window, 1280, 720);
|
gtk_window_set_default_size(window, 1280, 720);
|
||||||
|
|
||||||
|
// The window icon (X11; Wayland takes it from the .desktop file the app
|
||||||
|
// installs for its application id).
|
||||||
|
{
|
||||||
|
g_autofree gchar* exe = g_file_read_link("/proc/self/exe", nullptr);
|
||||||
|
if (exe != nullptr) {
|
||||||
|
g_autofree gchar* dir = g_path_get_dirname(exe);
|
||||||
|
g_autofree gchar* icon = g_build_filename(
|
||||||
|
dir, "data", "flutter_assets", "assets", "icon", "icon_256.png",
|
||||||
|
nullptr);
|
||||||
|
gtk_window_set_icon_from_file(window, icon, nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||||
fl_dart_project_set_dart_entrypoint_arguments(
|
fl_dart_project_set_dart_entrypoint_arguments(
|
||||||
project, self->dart_entrypoint_arguments);
|
project, self->dart_entrypoint_arguments);
|
||||||
|
|||||||
@@ -38,3 +38,4 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/shaders/anime4k/
|
- assets/shaders/anime4k/
|
||||||
|
- assets/icon/
|
||||||
@@ -189,23 +189,21 @@ void main() {
|
|||||||
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
|
expect(decodeRecentLinks(jsonEncode([{'url': ''}, {'url': 'https://a/b.mkv'}])).single.title, 'b.mkv');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preview: YouTube via oEmbed, other links by file name, failures fall back', () async {
|
test('preview: a page by yt-dlp, a file by its name, else why not', () async {
|
||||||
Uri? asked;
|
final yt = await previewLink('https://www.youtube.com/watch?v=abc',
|
||||||
final yt = await previewLink('https://www.youtube.com/watch?v=abc', get: (uri) async {
|
probe: (url) async => LinkProbe.page(Uri.parse(url),
|
||||||
asked = uri;
|
info: const YtDlpInfo(
|
||||||
return jsonEncode({'title': 'A video', 'author_name': 'Chan', 'thumbnail_url': 'https://i.ytimg/x.jpg'});
|
title: 'A video', uploader: 'Chan', duration: 187, thumbnail: 'https://i.ytimg/x.jpg')));
|
||||||
});
|
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan · 3:07', 'https://i.ytimg/x.jpg'));
|
||||||
expect(asked!.host, 'www.youtube.com');
|
|
||||||
expect(asked!.path, '/oembed');
|
|
||||||
expect(asked!.queryParameters, {'url': 'https://www.youtube.com/watch?v=abc', 'format': 'json'});
|
|
||||||
expect((yt!.title, yt.subtitle, yt.thumbnail), ('A video', 'youtube.com · Chan', 'https://i.ytimg/x.jpg'));
|
|
||||||
|
|
||||||
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1',
|
final file = await previewLink('https://cdn.example/dir/My%20Movie.mkv?sig=1',
|
||||||
get: (_) => throw StateError('no lookup for plain links'));
|
probe: (url) async => LinkProbe.file(Uri.parse(url), 10));
|
||||||
expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null));
|
expect((file!.title, file.subtitle, file.thumbnail), ('My Movie.mkv', 'cdn.example', null));
|
||||||
|
|
||||||
final down = await previewLink('https://youtu.be/abc', get: (_) async => throw Exception('offline'));
|
final page = await previewLink('https://example.com/a',
|
||||||
expect((down!.title, down.subtitle), ('abc', 'youtu.be'));
|
probe: (url) async => LinkProbe.page(Uri.parse(url), pageTitle: 'Example', ytDlpError: 'Unsupported URL'));
|
||||||
|
expect((page!.title, page.subtitle),
|
||||||
|
('Example', "example.com · Not a video (and yt-dlp couldn't read it: Unsupported URL)"));
|
||||||
expect(await previewLink('not a link'), isNull);
|
expect(await previewLink('not a link'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -108,10 +108,9 @@ Future<Harness> pumpLibrary(
|
|||||||
jellyfinStart: start,
|
jellyfinStart: start,
|
||||||
recentLinks: h.recents,
|
recentLinks: h.recents,
|
||||||
now: () => fixtureNow,
|
now: () => fixtureNow,
|
||||||
oembed: (uri) async => jsonEncode({
|
probe: (url) async => url.endsWith('.mkv')
|
||||||
'title': 'A video',
|
? LinkProbe.file(Uri.parse(url), 1)
|
||||||
'author_name': 'Chan',
|
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
|
||||||
}),
|
|
||||||
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
|
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
|
||||||
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
|
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
|
||||||
onPutUp: (m) async {
|
onPutUp: (m) async {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||||
|
import 'package:watch_together/src/playback/yt_dlp_app.dart';
|
||||||
import 'package:watch_together/src/screens/home/picked_media.dart';
|
import 'package:watch_together/src/screens/home/picked_media.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
|
||||||
@@ -67,7 +68,10 @@ void main() {
|
|||||||
Future<SessionCoordinator> member(String name) async {
|
Future<SessionCoordinator> member(String name) async {
|
||||||
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||||
await acc.ensureLoaded(validate: false);
|
await acc.ensureLoaded(validate: false);
|
||||||
final c = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
final c = SessionCoordinator(
|
||||||
|
jellyfin: acc,
|
||||||
|
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
|
||||||
|
ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false)));
|
||||||
all.add(c);
|
all.add(c);
|
||||||
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
|
||||||
await c.playback.player.setVolume(0);
|
await c.playback.player.setVolume(0);
|
||||||
@@ -150,7 +154,7 @@ void main() {
|
|||||||
await playTogether([alice, bob]);
|
await playTogether([alice, bob]);
|
||||||
expect(hits.length, greaterThan(before));
|
expect(hits.length, greaterThan(before));
|
||||||
|
|
||||||
// A page instead of a file is refused up front.
|
// A page yt-dlp cannot read is refused up front.
|
||||||
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
page.listen((r) {
|
page.listen((r) {
|
||||||
r.response.headers.contentType = ContentType.html;
|
r.response.headers.contentType = ContentType.html;
|
||||||
@@ -158,7 +162,7 @@ void main() {
|
|||||||
r.response.close();
|
r.response.close();
|
||||||
});
|
});
|
||||||
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
|
||||||
contains('cannot pass this link on'));
|
contains('Not a video'));
|
||||||
await page.close(force: true);
|
await page.close(force: true);
|
||||||
} finally {
|
} finally {
|
||||||
await origin.close(force: true);
|
await origin.close(force: true);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 361 KiB |
@@ -4,7 +4,9 @@
|
|||||||
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
|
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
|
||||||
# Flags: --port <n> (default 8080), --secret <password> (empty = open
|
# Flags: --port <n> (default 8080), --secret <password> (empty = open
|
||||||
# server, anyone who can reach the port can join), --name, --media <file>,
|
# server, anyone who can reach the port can join), --name, --media <file>,
|
||||||
# --advertise-host <host>, --config <file.yaml|json>. See README.
|
# --advertise-host <host>, --no-yt-dlp, --yt-dlp-dir <dir>, --config
|
||||||
|
# <file.yaml|json>. See README. The server's yt-dlp (for web page links it
|
||||||
|
# fetches) is downloaded into the state directory below.
|
||||||
# Prefer --config with a root-only file over --secret here: unit files and
|
# Prefer --config with a root-only file over --secret here: unit files and
|
||||||
# process lists are readable by other users.
|
# process lists are readable by other users.
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ Wants=network-online.target
|
|||||||
ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
|
ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
DynamicUser=true
|
DynamicUser=true
|
||||||
|
StateDirectory=watch-together
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
|
|||||||
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||||
and shipped next to the app as `watch-together-server`. It parses flags
|
and shipped next to the app as `watch-together-server`. It parses flags
|
||||||
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
|
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
|
||||||
`--jellyfin <file>`, `--config`; default port 8080) into the same map and calls `serve`;
|
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--config`; default port 8080) into the same map and calls `serve`;
|
||||||
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
|
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
|
||||||
display, which is why the server is its own build.
|
display, which is why the server is its own build.
|
||||||
|
|
||||||
@@ -160,7 +160,8 @@ Three sources, same sync. Any of them can also go through the relay
|
|||||||
|
|
||||||
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
||||||
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
||||||
directly; the server is only the clock. A pasted link that
|
directly (a web page through mpv's yt-dlp hook, see "yt-dlp"); the
|
||||||
|
server is only the clock. A pasted link that
|
||||||
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
|
`parseJellyfinLink` recognises goes up as a Jellyfin ref instead when the
|
||||||
ServerId is known (in the link, else `/System/Info/Public`), so its
|
ServerId is known (in the link, else `/System/Info/Public`), so its
|
||||||
`api_key` is never shared (`mediaForLink`, host and swap alike).
|
`api_key` is never shared (`mediaForLink`, host and swap alike).
|
||||||
@@ -394,14 +395,14 @@ What gets relayed (`SessionCoordinator.swapPicked`):
|
|||||||
| Link, "Through me" | relayed from that member's computer; for links only they can open |
|
| Link, "Through me" | relayed from that member's computer; for links only they can open |
|
||||||
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
|
| Jellyfin item (browser pick or pasted link) | own account first, then the server's login (`/j`), then relayed from the member who put it up (with their sign-in, or the pasted link's key, which stays on their machine) |
|
||||||
|
|
||||||
Relayed links must be one file the relay can range-read: a `text/*`
|
Relayed links are range-read as one file: a media file as is, a web page
|
||||||
response (YouTube and other pages, which only the player resolves) is
|
as yt-dlp's single-file format, resolved where the bytes are fetched (see
|
||||||
refused before the swap.
|
"yt-dlp"); the swap carries only the page URL and the mode.
|
||||||
|
|
||||||
How bytes move:
|
How bytes move:
|
||||||
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
|
- The source dials `/r/.../source` (outbound, so NAT/CGNAT are fine) and
|
||||||
answers the server's pulls from the file or the URL (`RelaySource`,
|
answers the server's pulls from the file or the URL (`RelaySource`,
|
||||||
`FileRelayBytes`, `HttpRelayBytes`; a Jellyfin source sends its own
|
`FileRelayBytes`, `HttpRelayBytes`, `LinkRelayBytes`; a Jellyfin source sends its own
|
||||||
`Authorization` header from this machine). Viewers only ever talk HTTP
|
`Authorization` header from this machine). Viewers only ever talk HTTP
|
||||||
to the server, so the source never sees mpv's request pattern.
|
to the server, so the source never sees mpv's request pattern.
|
||||||
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in
|
- The server keeps one `RelayBuffer` per relayed media: 1 MiB slices in
|
||||||
@@ -456,6 +457,62 @@ so for quick seeks. Rejected along the way: a fixed per-request window
|
|||||||
pipe), one shared window across requests (deadlocked on mpv's unread
|
pipe), one shared window across requests (deadlocked on mpv's unread
|
||||||
requests), Dart `http2` (connection window stuck at 64 KiB).
|
requests), Dart `http2` (connection window stuck at 64 KiB).
|
||||||
|
|
||||||
|
## yt-dlp
|
||||||
|
|
||||||
|
Web pages with a video (YouTube, Twitter/X, ...) play through yt-dlp in
|
||||||
|
all three link modes. Nothing is bundled or installed by hand.
|
||||||
|
|
||||||
|
- **Manager** (`YtDlp`, `packages/core/lib/src/yt_dlp.dart`, used by the
|
||||||
|
app and the server): the managed copy in its folder (`yt-dlp` /
|
||||||
|
`yt-dlp.exe`) first; if missing it downloads the official standalone
|
||||||
|
build for the platform (`ytDlpAssetName`: `yt-dlp_linux`,
|
||||||
|
`yt-dlp_linux_aarch64`, musl variants, `yt-dlp.exe`, ...) from the tag
|
||||||
|
`releases/latest` redirects to, checks it against that release's
|
||||||
|
`SHA2-256SUMS`, makes it executable (not on Windows) and renames it into
|
||||||
|
place. If the download fails, a `yt-dlp` on PATH is the fallback. After
|
||||||
|
that, at most once a day (`last-check` stamp in the folder) and in the
|
||||||
|
background, a newer tag is downloaded the same way; nothing waits for
|
||||||
|
it. `version`, `lastError`, `busy` and `changes` feed Settings → Player
|
||||||
|
→ yt-dlp ("Update now" forces the check). Every failure is non-fatal.
|
||||||
|
Folders: the app's is `<app support>/yt-dlp` (`AppYtDlp`, warmed up at
|
||||||
|
start), shared with its hosted server; the dedicated server's is
|
||||||
|
`--yt-dlp-dir`, else `$STATE_DIRECTORY/yt-dlp` (systemd), else
|
||||||
|
`yt-dlp` next to the binary if writable, else
|
||||||
|
`~/.cache/watch-together/yt-dlp`.
|
||||||
|
- **Resolve** (`YtDlp.resolve`): `yt-dlp -J --no-playlist --no-warnings
|
||||||
|
--ignore-config`, 25 s timeout: title, uploader, duration, thumbnail,
|
||||||
|
and the best single-file format (`pickSingleFile`: video and audio not
|
||||||
|
`none`, plain http(s), a video container, tallest up to 1080p) with its
|
||||||
|
URL and `http_headers`; none when the page has only split or HLS
|
||||||
|
streams (YouTube today).
|
||||||
|
- **Probe** (`probeLink`, `link_probe.dart`): a `Range: bytes=0-0` GET; a
|
||||||
|
video/audio type, or a generic one on a media file name, is a file
|
||||||
|
(`looksLikeMediaFile`); anything else goes to yt-dlp, and when yt-dlp
|
||||||
|
cannot read it the page's `og:title` / `<title>` is kept with yt-dlp's
|
||||||
|
reason ("Not a video (and yt-dlp couldn't read it: ...)"). The app
|
||||||
|
probes on paste (`AppYtDlp.probe`, remembered 10 minutes) for the Link
|
||||||
|
preview and the swap: a page is named by its title, never its URL tail,
|
||||||
|
and its thumbnail goes up as the banner (no poster).
|
||||||
|
- **Everyone fetches it**: the swap sends the page URL. Before opening an
|
||||||
|
external link every app points mpv's hook at its managed copy
|
||||||
|
(`script-opts=ytdl_hook-ytdl_path=...`, `ytdl-format=bv*[height<=1080]+ba/b`,
|
||||||
|
once per player), waiting for the download unless the URL names a media
|
||||||
|
file. A hook error shows as "can't open this link: ...".
|
||||||
|
- **Through me**: the putter's probe must give a single-file format, else
|
||||||
|
the swap is refused with a hint to use "Everyone fetches it". Its relay
|
||||||
|
source reads that format with yt-dlp's headers (`PageRelayBytes`); on
|
||||||
|
403/410 (expired format URL) it resolves the page again once and goes
|
||||||
|
on from where it stopped.
|
||||||
|
- **Server fetches it**: the server probes the link itself
|
||||||
|
(`LinkRelayBytes`) with its own yt-dlp (off with `--no-yt-dlp` /
|
||||||
|
`ytDlp: false`) and relays the single-file format the same way. The
|
||||||
|
putter only refuses a page its own yt-dlp says has no single file (the
|
||||||
|
server may reach what the putter cannot); when the server cannot read
|
||||||
|
it, the putter gets an `err` with the reason. Viewers open a fetched
|
||||||
|
link's `/r/` URL only once `relay.live` is set ("the server is getting
|
||||||
|
the video…" until then): resolving takes seconds and media_kit's mpv
|
||||||
|
gives up on a silent request after 5 s (`network-timeout`).
|
||||||
|
|
||||||
## Jellyfin in a session
|
## Jellyfin in a session
|
||||||
|
|
||||||
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
||||||
@@ -581,8 +638,8 @@ always scrolls the page). Space never presses a focused button
|
|||||||
watched / unwatched, remove from Continue, overview, the episode list
|
watched / unwatched, remove from Continue, overview, the episode list
|
||||||
(click picks the episode, double-click puts it up). A double-click on a
|
(click picks the episode, double-click puts it up). A double-click on a
|
||||||
card puts it up (a series: `playableFor`, its next episode). Link
|
card puts it up (a series: `playableFor`, its next episode). Link
|
||||||
(`library_link.dart`): paste field, a preview (YouTube oEmbed title and
|
(`library_link.dart`): paste field, a preview (a page's yt-dlp title,
|
||||||
thumbnail, else the file name), the three relay modes online, and the
|
uploader, length and thumbnail, else the file name; see "yt-dlp"), the three relay modes online, and the
|
||||||
last 12 links put up (`recentLinks` in the `connect_form` box). Files
|
last 12 links put up (`recentLinks` in the `connect_form` box). Files
|
||||||
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||||
an isolate, videos grouped by series; series start collapsed, a header
|
an isolate, videos grouped by series; series start collapsed, a header
|
||||||
@@ -695,8 +752,8 @@ always scrolls the page). Space never presses a focused button
|
|||||||
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
||||||
servers and links, the profile picture and colour; saved even when a join
|
servers and links, the profile picture and colour; saved even when a join
|
||||||
fails) and
|
fails) and
|
||||||
`settings`. Link media names are derived from the URL path and never
|
`settings`. Link media names are a web page's title (see "yt-dlp"), else
|
||||||
contain a query string.
|
derived from the URL path, never containing a query string.
|
||||||
|
|
||||||
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||||
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
|
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
|
||||||
@@ -811,6 +868,18 @@ displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
|||||||
it carries nothing sensitive. The Join card's recent servers poll it for live
|
it carries nothing sensitive. The Join card's recent servers poll it for live
|
||||||
status.
|
status.
|
||||||
|
|
||||||
|
## App icon and launcher entry
|
||||||
|
|
||||||
|
`assets/icon/icon.svg` is the source; `icon_<size>.png` and the Windows
|
||||||
|
`app_icon.ico` are rendered from it (rsvg-convert, magick). The GTK
|
||||||
|
application id is `moe.senkochan.watch_together`, which Wayland uses as the
|
||||||
|
window's `app_id`; compositors take the icon from the `.desktop` file of
|
||||||
|
that id. A tarball install has none, so on start a Linux release build
|
||||||
|
(`platform/desktop_entry.dart`, skipped in debug) writes
|
||||||
|
`~/.local/share/applications/moe.senkochan.watch_together.desktop` (Exec =
|
||||||
|
the running binary) and the icons into the hicolor theme, only when they
|
||||||
|
changed. The runner also sets the window icon from the bundle (X11).
|
||||||
|
|
||||||
## Updater
|
## Updater
|
||||||
|
|
||||||
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested).
|
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested).
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ anyone except the host.
|
|||||||
|
|
||||||
## Where video comes from
|
## Where video comes from
|
||||||
|
|
||||||
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv.
|
- **Link**: you paste an http(s) URL. Every app plays it directly with mpv;
|
||||||
|
a web page (YouTube, X, ...) goes through yt-dlp, which the app and the
|
||||||
|
server download and update by themselves.
|
||||||
A pasted Jellyfin link becomes the Jellyfin item below when possible.
|
A pasted Jellyfin link becomes the Jellyfin item below when possible.
|
||||||
- **Jellyfin item**: picked in the app's library browser. Shared as "this
|
- **Jellyfin item**: picked in the app's library browser. Shared as "this
|
||||||
item on this Jellyfin server", never as a link with someone's sign-in.
|
item on this Jellyfin server", never as a link with someone's sign-in.
|
||||||
|
|||||||
@@ -228,13 +228,16 @@ state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
|
|||||||
`url` (else `relay fetch needs an http(s) url`), which the server keeps
|
`url` (else `relay fetch needs an http(s) url`), which the server keeps
|
||||||
to itself, and a server with a password (else `server fetch needs a
|
to itself, and a server with a password (else `server fetch needs a
|
||||||
server password`: an open server would fetch any address it can reach
|
server password`: an open server would fetch any address it can reach
|
||||||
for anyone). Any later swap ends the relay.
|
for anyone). A web page `url` is read through the server's yt-dlp; when
|
||||||
|
the server cannot read the link, the swapper later gets `The server
|
||||||
|
can't fetch this link: <reason>`. Any later swap ends the relay.
|
||||||
- `state.media.relay.source`: member id of the source (absent with
|
- `state.media.relay.source`: member id of the source (absent with
|
||||||
`fetch`). `live` (omitted when false): bytes can flow (the source's
|
`fetch`). `live` (omitted when false): bytes can flow (the source's
|
||||||
channel is connected, or the server could read the link).
|
channel is connected, or the server could read the link).
|
||||||
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
|
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
|
||||||
`/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one:
|
`/r/` URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the `/m` one:
|
||||||
`http://<host>/r/default/<token>/<mediaId>/<name>` (name URL-encoded).
|
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
|
||||||
|
`\` made `_`, then URL-encoded; the server ignores it).
|
||||||
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
|
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media, Range table as for
|
||||||
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed
|
`/m`. Errors: 401 bad token, 403 kicked, 404 not the current relayed
|
||||||
media (a player still on an older `mediaId` gets 404, never the new
|
media (a player still on an older `mediaId` gets 404, never the new
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
2. **Files**: next episode from Files (by parsed episode, with the
|
2. **Files**: next episode from Files (by parsed episode, with the
|
||||||
countdown). Later: tracking for file series.
|
countdown). Later: tracking for file series.
|
||||||
|
|
||||||
3. **yt-dlp** (epic): players that open links themselves use mpv's ytdl
|
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||||
hook (bundled on Windows); then relay of single-file formats; then a
|
bundled Windows libmpv has the ytdl hook (Lua); if not, resolve in the
|
||||||
resolver for the Link preview (title, thumbnail, duration).
|
app and open the single-file format. YouTube: yt-dlp wants a JS runtime
|
||||||
|
(deno) for full extraction, and it only has split streams, so relaying
|
||||||
|
it would need muxing. Formats that need yt-dlp's cookies.
|
||||||
|
|
||||||
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
4. **AniList account**: sign in (OAuth via a localhost callback), "My list"
|
||||||
filter on the Schedule; today's "Following" (AnimeFlow's monitored
|
filter on the Schedule; today's "Following" (AnimeFlow's monitored
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export 'src/clock_sync.dart';
|
|||||||
export 'src/config.dart';
|
export 'src/config.dart';
|
||||||
export 'src/protocol.dart';
|
export 'src/protocol.dart';
|
||||||
export 'src/relay.dart';
|
export 'src/relay.dart';
|
||||||
|
export 'src/link_probe.dart';
|
||||||
|
export 'src/yt_dlp.dart';
|
||||||
export 'src/media_source.dart';
|
export 'src/media_source.dart';
|
||||||
export 'src/sync_engine.dart';
|
export 'src/sync_engine.dart';
|
||||||
export 'src/jellyfin/client.dart';
|
export 'src/jellyfin/client.dart';
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ class AppConfig {
|
|||||||
final String hostKey;
|
final String hostKey;
|
||||||
/// Optional login for relaying Jellyfin media to guests; null = no relay.
|
/// Optional login for relaying Jellyfin media to guests; null = no relay.
|
||||||
final JellyfinCredential? jellyfin;
|
final JellyfinCredential? jellyfin;
|
||||||
|
/// "The server fetches it" reads web pages (YouTube, Twitter/X, ...)
|
||||||
|
/// through its own yt-dlp, downloaded on first use.
|
||||||
|
final bool ytDlp;
|
||||||
|
/// Where that yt-dlp lives (writable); empty: a `yt-dlp` folder next to
|
||||||
|
/// the server binary, else `~/.cache/watch-together/yt-dlp`.
|
||||||
|
final String ytDlpDir;
|
||||||
|
|
||||||
const AppConfig({
|
const AppConfig({
|
||||||
required this.port,
|
required this.port,
|
||||||
@@ -115,6 +121,8 @@ class AppConfig {
|
|||||||
this.advertiseHost = '',
|
this.advertiseHost = '',
|
||||||
this.hostKey = '',
|
this.hostKey = '',
|
||||||
this.jellyfin,
|
this.jellyfin,
|
||||||
|
this.ytDlp = true,
|
||||||
|
this.ytDlpDir = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AppConfig.defaults() =>
|
factory AppConfig.defaults() =>
|
||||||
@@ -131,6 +139,8 @@ class AppConfig {
|
|||||||
advertiseHost: (map['advertiseHost'] as String?) ?? '',
|
advertiseHost: (map['advertiseHost'] as String?) ?? '',
|
||||||
hostKey: (map['hostKey'] as String?) ?? '',
|
hostKey: (map['hostKey'] as String?) ?? '',
|
||||||
jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
|
jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
|
||||||
|
ytDlp: (map['ytDlp'] as bool?) ?? true,
|
||||||
|
ytDlpDir: (map['ytDlpDir'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
} on ConfigException {
|
} on ConfigException {
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -148,6 +158,8 @@ class AppConfig {
|
|||||||
'advertiseHost': advertiseHost,
|
'advertiseHost': advertiseHost,
|
||||||
'hostKey': hostKey,
|
'hostKey': hostKey,
|
||||||
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
|
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
|
||||||
|
'ytDlp': ytDlp,
|
||||||
|
'ytDlpDir': ytDlpDir,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Future<AppConfig> loadFile(String path) async =>
|
static Future<AppConfig> loadFile(String path) async =>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'relay.dart';
|
||||||
|
import 'yt_dlp.dart';
|
||||||
|
|
||||||
|
// What a pasted link is: a media file (range-readable as is) or a web page
|
||||||
|
// that yt-dlp turns into one. Used where the bytes are fetched: the
|
||||||
|
// putter's app (preview, "I pass it on") and the server ("the server
|
||||||
|
// fetches it").
|
||||||
|
|
||||||
|
const _mediaExtensions = {
|
||||||
|
'mkv', 'mp4', 'm4v', 'webm', 'mov', 'avi', 'wmv', 'flv', 'ts', 'm2ts', 'mpg', 'mpeg', 'ogv', //
|
||||||
|
'mp3', 'm4a', 'flac', 'opus', 'ogg', 'wav',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _fileTypes = {'application/mp4', 'application/ogg', 'application/x-matroska'};
|
||||||
|
const _genericTypes = {'', 'application/octet-stream', 'binary/octet-stream', 'application/binary'};
|
||||||
|
|
||||||
|
/// True when a `Range: bytes=0-0` answer ([status], [contentType]'s mime
|
||||||
|
/// type) means [url] is a media file: a video/audio type, or a generic one
|
||||||
|
/// on a media file name. Pages, HLS playlists and errors are not.
|
||||||
|
bool looksLikeMediaFile({required int status, required String contentType, required Uri url}) {
|
||||||
|
if (status != 200 && status != 206) return false;
|
||||||
|
final type = contentType.toLowerCase();
|
||||||
|
if (type.contains('mpegurl')) return false;
|
||||||
|
if (type.startsWith('video/') || type.startsWith('audio/') || _fileTypes.contains(type)) return true;
|
||||||
|
return _genericTypes.contains(type) && hasMediaExtension(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [url]'s file name ends in a media extension (`.mkv`, `.mp4`, ...).
|
||||||
|
bool hasMediaExtension(Uri url) {
|
||||||
|
final last = url.pathSegments.where((s) => s.isNotEmpty).lastOrNull ?? '';
|
||||||
|
final dot = last.lastIndexOf('.');
|
||||||
|
return dot > 0 && _mediaExtensions.contains(last.substring(dot + 1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page's `og:title`, else its `<title>`, entities decoded; null if
|
||||||
|
/// neither.
|
||||||
|
String? htmlTitle(String html) {
|
||||||
|
for (final tag in RegExp(r'<meta\b[^>]*>', caseSensitive: false).allMatches(html)) {
|
||||||
|
final t = tag.group(0)!;
|
||||||
|
if (!RegExp(r'''(?:property|name)\s*=\s*["']og:title["']''', caseSensitive: false).hasMatch(t)) continue;
|
||||||
|
final c = RegExp('content\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')', caseSensitive: false).firstMatch(t);
|
||||||
|
final v = _unescape(c?.group(1) ?? c?.group(2) ?? '');
|
||||||
|
if (v.isNotEmpty) return v;
|
||||||
|
}
|
||||||
|
final m = RegExp(r'<title[^>]*>([\s\S]*?)</title>', caseSensitive: false).firstMatch(html);
|
||||||
|
final v = _unescape(m?.group(1) ?? '');
|
||||||
|
return v.isEmpty ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _unescape(String s) => s
|
||||||
|
.replaceAllMapped(RegExp(r'&#(x?)([0-9a-fA-F]+);'), (m) {
|
||||||
|
final n = int.tryParse(m.group(2)!, radix: m.group(1)!.isEmpty ? 10 : 16);
|
||||||
|
return n == null ? m.group(0)! : String.fromCharCode(n);
|
||||||
|
})
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.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<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
|
||||||
|
final c = client ?? (HttpClient()..connectionTimeout = const Duration(seconds: 10));
|
||||||
|
try {
|
||||||
|
String? body;
|
||||||
|
Object? getError;
|
||||||
|
try {
|
||||||
|
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
|
||||||
|
req.headers
|
||||||
|
..set(HttpHeaders.rangeHeader, 'bytes=0-0')
|
||||||
|
..set(HttpHeaders.acceptEncodingHeader, 'identity');
|
||||||
|
final res = await req.close().timeout(const Duration(seconds: 15));
|
||||||
|
final type = res.headers.contentType?.mimeType ?? '';
|
||||||
|
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
|
||||||
|
await res.drain<void>().catchError((_) {});
|
||||||
|
final total = res.headers.value(HttpHeaders.contentRangeHeader)?.split('/').last;
|
||||||
|
final n = int.tryParse(total ?? '') ?? (res.statusCode == 200 ? res.contentLength : -1);
|
||||||
|
return LinkProbe.file(url, n > 0 ? n : -1);
|
||||||
|
}
|
||||||
|
body = type.contains('html') ? await _readText(res) : null;
|
||||||
|
if (body == null) await res.drain<void>().catchError((_) {});
|
||||||
|
} catch (e) {
|
||||||
|
getError = e;
|
||||||
|
}
|
||||||
|
String? ytErr;
|
||||||
|
if (ytDlp != null) {
|
||||||
|
try {
|
||||||
|
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()));
|
||||||
|
} catch (e) {
|
||||||
|
ytErr = '$e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (getError != null) {
|
||||||
|
throw LinkProbeException('cannot open the link: $getError${ytErr == null ? '' : ' (yt-dlp: $ytErr)'}');
|
||||||
|
}
|
||||||
|
// A server honouring the Range sent one byte of the page: get it all.
|
||||||
|
if (body != null && body.length < 2) {
|
||||||
|
try {
|
||||||
|
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
|
||||||
|
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr);
|
||||||
|
} finally {
|
||||||
|
if (client == null) c.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _readText(HttpClientResponse res) async {
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
try {
|
||||||
|
await for (final chunk in res.timeout(const Duration(seconds: 15))) {
|
||||||
|
out.add(chunk);
|
||||||
|
if (out.length >= _probeLimit) break;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return utf8.decode(out.takeBytes(), allowMalformed: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page's single-file format (see [YtDlpInfo.single]), range-read with
|
||||||
|
/// yt-dlp's headers. Format URLs expire: on 403/410 the page is resolved
|
||||||
|
/// again once and the read goes on from where it stopped.
|
||||||
|
class PageRelayBytes implements RelayBytes {
|
||||||
|
final Uri page;
|
||||||
|
final YtDlp ytDlp;
|
||||||
|
YtDlpFormat? _format;
|
||||||
|
HttpRelayBytes? _inner;
|
||||||
|
int? _size;
|
||||||
|
var _gen = 0;
|
||||||
|
Future<void>? _refreshing;
|
||||||
|
PageRelayBytes(this.page, this.ytDlp, {YtDlpFormat? format}) : _format = format;
|
||||||
|
|
||||||
|
Future<YtDlpFormat> _resolve() async {
|
||||||
|
final f = (await ytDlp.resolve(page.toString())).single;
|
||||||
|
if (f == null) throw const LinkProbeException(noSingleFileText);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<HttpRelayBytes> _open() async =>
|
||||||
|
_inner ??= () {
|
||||||
|
final f = _format!;
|
||||||
|
return HttpRelayBytes(f.url, headers: f.headers);
|
||||||
|
}();
|
||||||
|
|
||||||
|
static bool _expired(Object e) => e is UpstreamHttpException && (e.status == 403 || e.status == 410);
|
||||||
|
|
||||||
|
Future<void> _refresh(int gen) {
|
||||||
|
if (gen != _gen) return Future.value();
|
||||||
|
return _refreshing ??= () async {
|
||||||
|
try {
|
||||||
|
final f = await _resolve();
|
||||||
|
final old = _inner;
|
||||||
|
_format = f;
|
||||||
|
_inner = null;
|
||||||
|
_gen++;
|
||||||
|
await old?.close();
|
||||||
|
} finally {
|
||||||
|
_refreshing = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() async {
|
||||||
|
if (_size != null) return _size!;
|
||||||
|
_format ??= await _resolve();
|
||||||
|
final gen = _gen;
|
||||||
|
try {
|
||||||
|
return _size = await (await _open()).size();
|
||||||
|
} catch (e) {
|
||||||
|
if (!_expired(e)) rethrow;
|
||||||
|
await _refresh(gen);
|
||||||
|
return _size = await (await _open()).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) async* {
|
||||||
|
var pos = start;
|
||||||
|
var retried = false;
|
||||||
|
while (true) {
|
||||||
|
final gen = _gen;
|
||||||
|
final inner = await _open();
|
||||||
|
try {
|
||||||
|
await for (final chunk in inner.read(pos, end)) {
|
||||||
|
pos += chunk.length;
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (retried || !_expired(e)) rethrow;
|
||||||
|
retried = true;
|
||||||
|
await _refresh(gen);
|
||||||
|
final n = await (await _open()).size();
|
||||||
|
if (_size != null && n != _size) throw const LinkProbeException('the video changed on the page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async => _inner?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link of either kind: probed on first [size] (or given [probe]), then
|
||||||
|
/// read as a file ([HttpRelayBytes]) or a page ([PageRelayBytes]). Throws
|
||||||
|
/// [LinkProbeException] with the reason when it cannot be relayed.
|
||||||
|
class LinkRelayBytes implements RelayBytes {
|
||||||
|
final Uri url;
|
||||||
|
final YtDlp? ytDlp;
|
||||||
|
LinkProbe? _probe;
|
||||||
|
RelayBytes? _inner;
|
||||||
|
LinkRelayBytes(this.url, {this.ytDlp, LinkProbe? probe}) : _probe = probe;
|
||||||
|
|
||||||
|
/// The probe, after [size].
|
||||||
|
LinkProbe? get probe => _probe;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> size() async {
|
||||||
|
if (_inner == null) {
|
||||||
|
final probe = _probe ??= await probeLink(url, ytDlp: ytDlp);
|
||||||
|
final problem = probe.relayProblem;
|
||||||
|
if (problem != null) throw LinkProbeException(problem);
|
||||||
|
_inner = probe.isPage ? PageRelayBytes(url, ytDlp!, format: probe.info!.single) : HttpRelayBytes(url);
|
||||||
|
}
|
||||||
|
return _inner!.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> read(int start, int end) {
|
||||||
|
final inner = _inner;
|
||||||
|
if (inner == null) return Stream.error(StateError('size() first'));
|
||||||
|
return inner.read(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async => _inner?.close();
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ import 'logging.dart';
|
|||||||
|
|
||||||
final _log = createLogger('relay');
|
final _log = createLogger('relay');
|
||||||
|
|
||||||
|
/// The `<name>` segment of a `/r/` URL: the media name URL-encoded, its
|
||||||
|
/// slashes made `_` first (a page title can hold a URL, and an encoded
|
||||||
|
/// slash still splits the path). The server ignores the segment.
|
||||||
|
String relayUrlName(String name) => Uri.encodeComponent(name.replaceAll(RegExp(r'[/\\]'), '_'));
|
||||||
|
|
||||||
/// Binary relay frame: 4-byte big-endian pull id, then payload.
|
/// Binary relay frame: 4-byte big-endian pull id, then payload.
|
||||||
Uint8List encodeRelayFrame(int id, List<int> payload) {
|
Uint8List encodeRelayFrame(int id, List<int> payload) {
|
||||||
final out = Uint8List(4 + payload.length);
|
final out = Uint8List(4 + payload.length);
|
||||||
@@ -66,6 +71,12 @@ class FileRelayBytes implements RelayBytes {
|
|||||||
Future<void> close() async {}
|
Future<void> close() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A non-2xx answer from an [HttpRelayBytes] upstream.
|
||||||
|
class UpstreamHttpException extends HttpException {
|
||||||
|
final int status;
|
||||||
|
UpstreamHttpException(this.status, Uri url) : super('upstream HTTP $status', uri: url);
|
||||||
|
}
|
||||||
|
|
||||||
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
|
/// An HTTP(S) resource with Range support, e.g. a link or a Jellyfin
|
||||||
/// stream; [headers] (auth) never leave this machine.
|
/// stream; [headers] (auth) never leave this machine.
|
||||||
class HttpRelayBytes implements RelayBytes {
|
class HttpRelayBytes implements RelayBytes {
|
||||||
@@ -85,7 +96,7 @@ class HttpRelayBytes implements RelayBytes {
|
|||||||
final res = await req.close().timeout(const Duration(seconds: 20));
|
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||||
if (res.statusCode != 200 && res.statusCode != 206) {
|
if (res.statusCode != 200 && res.statusCode != 206) {
|
||||||
await res.drain<void>().catchError((_) {});
|
await res.drain<void>().catchError((_) {});
|
||||||
throw HttpException('upstream HTTP ${res.statusCode}', uri: url);
|
throw UpstreamHttpException(res.statusCode, url);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ffi' show Abi;
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'logging.dart';
|
||||||
|
|
||||||
|
// yt-dlp: turns a web page (YouTube, Twitter/X, ...) into media URLs. The
|
||||||
|
// app and the server each keep their own copy, the official standalone
|
||||||
|
// build downloaded from GitHub and checked against the release's
|
||||||
|
// SHA2-256SUMS, refreshed at most once a day. Nothing is installed by hand.
|
||||||
|
|
||||||
|
final _log = createLogger('yt-dlp');
|
||||||
|
|
||||||
|
class YtDlpException implements Exception {
|
||||||
|
final String message;
|
||||||
|
const YtDlpException(this.message);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One playable format of a resolved page: a URL and the headers yt-dlp
|
||||||
|
/// says to send with it.
|
||||||
|
class YtDlpFormat {
|
||||||
|
final String id;
|
||||||
|
final Uri url;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final int? height;
|
||||||
|
final String ext;
|
||||||
|
const YtDlpFormat({required this.id, required this.url, this.headers = const {}, this.height, this.ext = ''});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `yt-dlp -J` says about a page.
|
||||||
|
class YtDlpInfo {
|
||||||
|
final String title;
|
||||||
|
final String uploader;
|
||||||
|
final String extractor;
|
||||||
|
final String webpageUrl;
|
||||||
|
/// Seconds; null when unknown (a live stream).
|
||||||
|
final double? duration;
|
||||||
|
final String? thumbnail;
|
||||||
|
/// The best format holding video and audio in one http(s) file (what a
|
||||||
|
/// relay can range-read); null when the page only has split or HLS
|
||||||
|
/// streams.
|
||||||
|
final YtDlpFormat? single;
|
||||||
|
const YtDlpInfo({
|
||||||
|
required this.title,
|
||||||
|
this.uploader = '',
|
||||||
|
this.extractor = '',
|
||||||
|
this.webpageUrl = '',
|
||||||
|
this.duration,
|
||||||
|
this.thumbnail,
|
||||||
|
this.single,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Parses `yt-dlp -J` output; a playlist answer uses its first entry.
|
||||||
|
static YtDlpInfo parse(Map<String, Object?> j) {
|
||||||
|
final entries = j['entries'];
|
||||||
|
if (j['_type'] == 'playlist' && entries is List) {
|
||||||
|
final first = entries.whereType<Map>().firstOrNull;
|
||||||
|
if (first == null) throw const YtDlpException('the page has no videos');
|
||||||
|
return parse(Map<String, Object?>.from(first));
|
||||||
|
}
|
||||||
|
String str(String k) => j[k] is String ? (j[k] as String).trim() : '';
|
||||||
|
final formats = j['formats'] is List ? (j['formats'] as List) : [if (j['url'] is String) j];
|
||||||
|
return YtDlpInfo(
|
||||||
|
title: str('title').isNotEmpty ? str('title') : str('fulltitle'),
|
||||||
|
uploader: str('uploader').isNotEmpty ? str('uploader') : str('channel'),
|
||||||
|
extractor: str('extractor_key').isNotEmpty ? str('extractor_key') : str('extractor'),
|
||||||
|
webpageUrl: str('webpage_url'),
|
||||||
|
duration: j['duration'] is num ? (j['duration'] as num).toDouble() : null,
|
||||||
|
thumbnail: str('thumbnail').isEmpty ? null : str('thumbnail'),
|
||||||
|
single: pickSingleFile(formats),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _singleFileExts = {'mp4', 'webm', 'mov', 'm4v', 'mkv'};
|
||||||
|
|
||||||
|
/// The best single-file format of [formats] (yt-dlp format maps): video
|
||||||
|
/// and audio both present (or not known to be absent), plain http(s), a
|
||||||
|
/// video container; the tallest up to [maxHeight], else the shortest above
|
||||||
|
/// it; mp4/webm and higher bitrate break ties.
|
||||||
|
YtDlpFormat? pickSingleFile(List<Object?> formats, {int maxHeight = 1080}) {
|
||||||
|
final ok = <(Map, int)>[];
|
||||||
|
for (final f in formats) {
|
||||||
|
if (f is! Map) continue;
|
||||||
|
final url = f['url'];
|
||||||
|
final protocol = f['protocol'];
|
||||||
|
if (url is! String || (protocol != 'http' && protocol != 'https')) continue;
|
||||||
|
if (f['vcodec'] == 'none' || f['acodec'] == 'none') continue;
|
||||||
|
if (!_singleFileExts.contains(f['ext'])) continue;
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) continue;
|
||||||
|
ok.add((f, f['height'] is num ? (f['height'] as num).toInt() : 0));
|
||||||
|
}
|
||||||
|
if (ok.isEmpty) return null;
|
||||||
|
double tbr(Map f) => f['tbr'] is num ? (f['tbr'] as num).toDouble() : 0;
|
||||||
|
int rank((Map, int) a, (Map, int) b) {
|
||||||
|
final (fa, ha) = a;
|
||||||
|
final (fb, hb) = b;
|
||||||
|
final ina = ha <= maxHeight, inb = hb <= maxHeight;
|
||||||
|
if (ina != inb) return ina ? -1 : 1;
|
||||||
|
if (ha != hb) return ina ? hb.compareTo(ha) : ha.compareTo(hb);
|
||||||
|
final ma = fa['ext'] == 'mp4' || fa['ext'] == 'webm', mb = fb['ext'] == 'mp4' || fb['ext'] == 'webm';
|
||||||
|
if (ma != mb) return ma ? -1 : 1;
|
||||||
|
return tbr(fb).compareTo(tbr(fa));
|
||||||
|
}
|
||||||
|
|
||||||
|
ok.sort(rank);
|
||||||
|
final (f, h) = ok.first;
|
||||||
|
final headers = f['http_headers'];
|
||||||
|
return YtDlpFormat(
|
||||||
|
id: '${f['format_id'] ?? ''}',
|
||||||
|
url: Uri.parse(f['url'] as String),
|
||||||
|
headers: headers is Map ? {for (final e in headers.entries) '${e.key}': '${e.value}'} : const {},
|
||||||
|
height: h == 0 ? null : h,
|
||||||
|
ext: '${f['ext']}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The official standalone build for this platform, or null if there is
|
||||||
|
/// none.
|
||||||
|
String? ytDlpAssetName(Abi abi, {bool musl = false}) => switch (abi) {
|
||||||
|
Abi.linuxX64 => musl ? 'yt-dlp_musllinux' : 'yt-dlp_linux',
|
||||||
|
Abi.linuxArm64 => musl ? 'yt-dlp_musllinux_aarch64' : 'yt-dlp_linux_aarch64',
|
||||||
|
Abi.windowsX64 => 'yt-dlp.exe',
|
||||||
|
Abi.windowsArm64 => 'yt-dlp_arm64.exe',
|
||||||
|
Abi.windowsIA32 => 'yt-dlp_x86.exe',
|
||||||
|
Abi.macosX64 || Abi.macosArm64 => 'yt-dlp_macos',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `SHA2-256SUMS` lines (`<hex> <name>`) as name -> lowercase hex.
|
||||||
|
Map<String, String> parseSha256Sums(String text) => {
|
||||||
|
for (final line in const LineSplitter().convert(text))
|
||||||
|
if (RegExp(r'^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$').firstMatch(line) case final m?)
|
||||||
|
m.group(2)!: m.group(1)!.toLowerCase(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The last `ERROR:` line of yt-dlp's stderr without its prefixes
|
||||||
|
/// (`ERROR: [twitter] 123: No video` -> `No video`), else the last line.
|
||||||
|
String ytDlpErrorText(String stderr) {
|
||||||
|
final lines = const LineSplitter().convert(stderr).map((l) => l.trim()).where((l) => l.isNotEmpty).toList();
|
||||||
|
if (lines.isEmpty) return 'yt-dlp failed';
|
||||||
|
final err = lines.lastWhere((l) => l.startsWith('ERROR:'), orElse: () => lines.last);
|
||||||
|
return err.replaceFirst(RegExp(r'^ERROR:\s*'), '').replaceFirst(RegExp(r'^\[[^\]]+\]\s*[^:\s]*:\s*'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds, downloads, updates and runs yt-dlp. The managed copy lives in
|
||||||
|
/// [dir] (writable); without it a `yt-dlp` on PATH is the fallback when
|
||||||
|
/// the download fails. Every failure is non-fatal: [lastError] says why.
|
||||||
|
class YtDlp {
|
||||||
|
final String dir;
|
||||||
|
final bool enabled;
|
||||||
|
/// How often [ensure] looks for a newer release in the background.
|
||||||
|
final Duration updateEvery;
|
||||||
|
final HttpClient Function() _http;
|
||||||
|
static const releases = 'https://github.com/yt-dlp/yt-dlp/releases';
|
||||||
|
|
||||||
|
YtDlp(this.dir, {this.enabled = true, this.updateEvery = const Duration(days: 1), HttpClient Function()? http})
|
||||||
|
: _http = http ?? HttpClient.new;
|
||||||
|
|
||||||
|
String? _path;
|
||||||
|
/// The binary in use, once found.
|
||||||
|
String? get path => _path;
|
||||||
|
/// `yt-dlp --version` of [path].
|
||||||
|
String? version;
|
||||||
|
String? lastError;
|
||||||
|
/// A download or update is running.
|
||||||
|
bool busy = false;
|
||||||
|
final _changes = StreamController<void>.broadcast();
|
||||||
|
/// Fires when [version], [lastError] or [busy] change.
|
||||||
|
Stream<void> get changes => _changes.stream;
|
||||||
|
Future<String?>? _ensuring;
|
||||||
|
Future<void>? _updating;
|
||||||
|
|
||||||
|
String get managedPath => p.join(dir, Platform.isWindows ? 'yt-dlp.exe' : 'yt-dlp');
|
||||||
|
String get _stampPath => p.join(dir, 'last-check');
|
||||||
|
|
||||||
|
void _changed() {
|
||||||
|
if (!_changes.isClosed) _changes.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fail(String message) {
|
||||||
|
lastError = message;
|
||||||
|
_log.warning(message);
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The binary to run, downloading it first if needed; null (see
|
||||||
|
/// [lastError]) when there is none. A newer release is looked for in the
|
||||||
|
/// background at most every [updateEvery].
|
||||||
|
Future<String?> ensure() {
|
||||||
|
final known = _path;
|
||||||
|
if (known != null && (known == 'yt-dlp' || File(known).existsSync())) {
|
||||||
|
unawaited(update());
|
||||||
|
return Future.value(known);
|
||||||
|
}
|
||||||
|
return _ensuring ??= _ensure().whenComplete(() => _ensuring = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _ensure() async {
|
||||||
|
if (!enabled) {
|
||||||
|
lastError = 'yt-dlp is turned off';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (await File(managedPath).exists()) {
|
||||||
|
final v = await _versionOf(managedPath);
|
||||||
|
if (v != null) {
|
||||||
|
_use(managedPath, v);
|
||||||
|
unawaited(update());
|
||||||
|
return managedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _download(await _latestTag());
|
||||||
|
return managedPath;
|
||||||
|
} catch (e) {
|
||||||
|
final onPath = await _versionOf('yt-dlp');
|
||||||
|
if (onPath != null) {
|
||||||
|
_use('yt-dlp', onPath);
|
||||||
|
_fail('could not download yt-dlp ($e); using the one on PATH');
|
||||||
|
return 'yt-dlp';
|
||||||
|
}
|
||||||
|
_fail('could not download yt-dlp: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _use(String path, String v) {
|
||||||
|
_path = path;
|
||||||
|
version = v;
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _versionOf(String exe) async {
|
||||||
|
try {
|
||||||
|
final r = await Process.run(exe, ['--version']).timeout(const Duration(seconds: 20));
|
||||||
|
final v = '${r.stdout}'.trim();
|
||||||
|
return r.exitCode == 0 && v.isNotEmpty ? v : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the managed copy when a newer release is out. Without
|
||||||
|
/// [force], at most every [updateEvery] (remembered across restarts).
|
||||||
|
Future<void> update({bool force = false}) => _updating ??= _update(force).whenComplete(() => _updating = null);
|
||||||
|
|
||||||
|
Future<void> _update(bool force) async {
|
||||||
|
if (!enabled) return;
|
||||||
|
try {
|
||||||
|
if (!force) {
|
||||||
|
final stamp = File(_stampPath);
|
||||||
|
if (await stamp.exists()) {
|
||||||
|
final at = DateTime.tryParse((await stamp.readAsString()).trim());
|
||||||
|
if (at != null && DateTime.now().difference(at) < updateEvery) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final tag = await _latestTag();
|
||||||
|
final managed = await File(managedPath).exists() ? await _versionOf(managedPath) : null;
|
||||||
|
if (managed != tag) {
|
||||||
|
await _download(tag);
|
||||||
|
} else {
|
||||||
|
_use(managedPath, managed!);
|
||||||
|
await _stamp();
|
||||||
|
}
|
||||||
|
if (lastError != null) {
|
||||||
|
lastError = null;
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_fail('yt-dlp update failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stamp() async {
|
||||||
|
try {
|
||||||
|
await File(_stampPath).writeAsString(DateTime.now().toIso8601String());
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tag `releases/latest` redirects to.
|
||||||
|
Future<String> _latestTag() async {
|
||||||
|
final client = _http();
|
||||||
|
try {
|
||||||
|
final req = await client.getUrl(Uri.parse('$releases/latest')).timeout(const Duration(seconds: 20));
|
||||||
|
req.followRedirects = false;
|
||||||
|
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||||
|
await res.drain<void>().catchError((_) {});
|
||||||
|
final loc = res.headers.value(HttpHeaders.locationHeader) ?? '';
|
||||||
|
final tag = Uri.tryParse(loc)?.pathSegments.lastOrNull ?? '';
|
||||||
|
if (!res.isRedirect || tag.isEmpty || tag == 'latest') {
|
||||||
|
throw YtDlpException('no latest release (HTTP ${res.statusCode})');
|
||||||
|
}
|
||||||
|
return tag;
|
||||||
|
} finally {
|
||||||
|
client.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _download(String tag) async {
|
||||||
|
final asset = ytDlpAssetName(Abi.current(), musl: _isMusl());
|
||||||
|
if (asset == null) throw YtDlpException('no yt-dlp build for ${Abi.current()}');
|
||||||
|
busy = true;
|
||||||
|
_changed();
|
||||||
|
final client = _http();
|
||||||
|
final part = File('$managedPath.part$pid');
|
||||||
|
try {
|
||||||
|
await Directory(dir).create(recursive: true);
|
||||||
|
final sums = parseSha256Sums(utf8.decode(await _get(client, '$releases/download/$tag/SHA2-256SUMS')));
|
||||||
|
final want = sums[asset];
|
||||||
|
if (want == null) throw YtDlpException('$asset is not in the release checksums');
|
||||||
|
_log.info('downloading yt-dlp $tag ($asset)');
|
||||||
|
final req = await client.getUrl(Uri.parse('$releases/download/$tag/$asset')).timeout(const Duration(seconds: 30));
|
||||||
|
final res = await req.close().timeout(const Duration(seconds: 30));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
await res.drain<void>().catchError((_) {});
|
||||||
|
throw YtDlpException('download HTTP ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final sink = part.openWrite();
|
||||||
|
final hashOut = _DigestSink();
|
||||||
|
final hasher = sha256.startChunkedConversion(hashOut);
|
||||||
|
await for (final chunk in res.timeout(const Duration(seconds: 60))) {
|
||||||
|
hasher.add(chunk);
|
||||||
|
sink.add(chunk);
|
||||||
|
}
|
||||||
|
hasher.close();
|
||||||
|
await sink.close();
|
||||||
|
if (hashOut.value.toString() != want) throw const YtDlpException('checksum mismatch');
|
||||||
|
if (!Platform.isWindows) {
|
||||||
|
final r = await Process.run('chmod', ['755', part.path]);
|
||||||
|
if (r.exitCode != 0) throw YtDlpException('chmod failed: ${r.stderr}');
|
||||||
|
}
|
||||||
|
final v = await _versionOf(part.path);
|
||||||
|
if (v == null) throw const YtDlpException('the downloaded yt-dlp does not run');
|
||||||
|
final target = File(managedPath);
|
||||||
|
// Windows cannot rename onto an existing file.
|
||||||
|
if (Platform.isWindows && await target.exists()) await target.delete();
|
||||||
|
await part.rename(managedPath);
|
||||||
|
_use(managedPath, v);
|
||||||
|
await _stamp();
|
||||||
|
_log.info('yt-dlp $v ready at $managedPath');
|
||||||
|
} finally {
|
||||||
|
client.close(force: true);
|
||||||
|
if (await part.exists()) await part.delete().catchError((_) => part);
|
||||||
|
busy = false;
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> _get(HttpClient client, String url) async {
|
||||||
|
final req = await client.getUrl(Uri.parse(url)).timeout(const Duration(seconds: 20));
|
||||||
|
final res = await req.close().timeout(const Duration(seconds: 20));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
await res.drain<void>().catchError((_) {});
|
||||||
|
throw YtDlpException('$url: HTTP ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final out = BytesBuilder(copy: false);
|
||||||
|
await for (final c in res.timeout(const Duration(seconds: 20))) {
|
||||||
|
out.add(c);
|
||||||
|
}
|
||||||
|
return out.takeBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isMusl() {
|
||||||
|
if (!Platform.isLinux) return false;
|
||||||
|
try {
|
||||||
|
return Directory('/lib').listSync().any((e) => p.basename(e.path).startsWith('ld-musl-'));
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks yt-dlp what [url] is. Throws [YtDlpException] with yt-dlp's own
|
||||||
|
/// reason on failure.
|
||||||
|
Future<YtDlpInfo> resolve(String url, {Duration timeout = const Duration(seconds: 25)}) async {
|
||||||
|
final exe = await ensure();
|
||||||
|
if (exe == null) throw YtDlpException(lastError ?? 'yt-dlp is not available');
|
||||||
|
final proc = await Process.start(exe, ['-J', '--no-playlist', '--no-warnings', '--ignore-config', '--', url]);
|
||||||
|
final out = proc.stdout.transform(utf8.decoder).join();
|
||||||
|
final err = proc.stderr.transform(utf8.decoder).join();
|
||||||
|
final int code;
|
||||||
|
try {
|
||||||
|
code = await proc.exitCode.timeout(timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
proc.kill();
|
||||||
|
throw YtDlpException('yt-dlp took longer than ${timeout.inSeconds} s');
|
||||||
|
}
|
||||||
|
if (code != 0) throw YtDlpException(ytDlpErrorText(await err));
|
||||||
|
try {
|
||||||
|
final j = jsonDecode(await out);
|
||||||
|
if (j is! Map) throw const FormatException('not an object');
|
||||||
|
return YtDlpInfo.parse(Map<String, Object?>.from(j));
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
throw YtDlpException('unreadable yt-dlp output: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() => _changes.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DigestSink implements Sink<Digest> {
|
||||||
|
late Digest value;
|
||||||
|
@override
|
||||||
|
void add(Digest data) => value = data;
|
||||||
|
@override
|
||||||
|
void close() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// yt-dlp output parsing, the single-file pick and the link classifier.
|
||||||
|
// The live test (real download from GitHub, a real tweet) runs only with
|
||||||
|
// YTDLP_LIVE=1.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ffi' show Abi;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Map<String, Object?> _f(String id, String protocol, Object? vcodec, Object? acodec, int? height,
|
||||||
|
{String ext = 'mp4', num tbr = 0}) =>
|
||||||
|
{
|
||||||
|
'format_id': id, 'protocol': protocol, 'vcodec': vcodec, 'acodec': acodec, 'height': height, //
|
||||||
|
'ext': ext, 'tbr': tbr, 'url': 'https://cdn.example/$id', 'http_headers': {'User-Agent': 'UA'},
|
||||||
|
};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('single-file pick', () {
|
||||||
|
test('a tweet: the tallest progressive mp4 (codecs unknown), not the split HLS', () {
|
||||||
|
final f = pickSingleFile([
|
||||||
|
_f('hls-audio', 'm3u8_native', 'none', null, null),
|
||||||
|
_f('http-256', 'https', null, null, 270, tbr: 256),
|
||||||
|
_f('http-2176', 'https', null, null, 598, tbr: 2176),
|
||||||
|
_f('http-832', 'https', null, null, 360, tbr: 832),
|
||||||
|
_f('hls-430', 'm3u8_native', 'avc1', 'none', 598),
|
||||||
|
])!;
|
||||||
|
expect((f.id, f.height), ('http-2176', 598));
|
||||||
|
expect(f.headers, {'User-Agent': 'UA'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('split or HLS only: none', () {
|
||||||
|
expect(
|
||||||
|
pickSingleFile([
|
||||||
|
_f('140', 'https', 'none', 'mp4a', null, ext: 'm4a'),
|
||||||
|
_f('137', 'https', 'avc1', 'none', 1080),
|
||||||
|
_f('96', 'm3u8_native', 'avc1', 'mp4a', 1080),
|
||||||
|
]),
|
||||||
|
isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('up to 1080p, else the shortest above; mp4/webm win ties', () {
|
||||||
|
expect(
|
||||||
|
pickSingleFile([
|
||||||
|
_f('4k', 'https', 'vp9', 'opus', 2160, ext: 'webm'),
|
||||||
|
_f('720', 'https', 'avc1', 'mp4a', 720),
|
||||||
|
_f('1080mkv', 'https', 'avc1', 'mp4a', 1080, ext: 'mkv'),
|
||||||
|
_f('1080', 'https', 'avc1', 'mp4a', 1080),
|
||||||
|
])!.id,
|
||||||
|
'1080');
|
||||||
|
expect(
|
||||||
|
pickSingleFile([
|
||||||
|
_f('4k', 'https', 'vp9', 'opus', 2160, ext: 'webm'),
|
||||||
|
_f('1440', 'https', 'vp9', 'opus', 1440, ext: 'webm'),
|
||||||
|
])!.id,
|
||||||
|
'1440');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('info: metadata, a playlist answer uses its first entry', () {
|
||||||
|
final info = YtDlpInfo.parse({
|
||||||
|
'_type': 'playlist',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'title': 'a girl. - https://t.co/x', 'uploader': 'a girl.', 'duration': 6.3, //
|
||||||
|
'thumbnail': 'https://pbs/x.jpg', 'extractor_key': 'Twitter',
|
||||||
|
'formats': [_f('http-2176', 'https', null, null, 598)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect((info.title, info.uploader, info.duration, info.thumbnail, info.extractor, info.single?.id),
|
||||||
|
('a girl. - https://t.co/x', 'a girl.', 6.3, 'https://pbs/x.jpg', 'Twitter', 'http-2176'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('link classifier: media types and media file names are files; pages, HLS, errors are not', () {
|
||||||
|
bool file(int status, String type, String url) =>
|
||||||
|
looksLikeMediaFile(status: status, contentType: type, url: Uri.parse(url));
|
||||||
|
expect(file(206, 'video/mp4', 'https://a/watch'), isTrue);
|
||||||
|
expect(file(200, 'audio/mpeg', 'https://a/x'), isTrue);
|
||||||
|
expect(file(206, 'application/octet-stream', 'https://a/dir/ep%201.MKV?sig=1'), isTrue);
|
||||||
|
expect(file(206, 'application/octet-stream', 'https://a/download'), isFalse);
|
||||||
|
expect(file(200, 'text/html', 'https://x.com/u/status/1/video/1'), isFalse);
|
||||||
|
expect(file(200, 'text/html', 'https://a/fake.mp4'), isFalse);
|
||||||
|
expect(file(200, 'application/vnd.apple.mpegurl', 'https://a/index.m3u8'), isFalse);
|
||||||
|
expect(file(403, 'video/mp4', 'https://a/x.mp4'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page title in a /r/ URL stays one path segment', () {
|
||||||
|
expect(relayUrlName('a girl. - https://t.co/x'), 'a%20girl.%20-%20https%3A__t.co_x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page title: og:title first (any attribute order), else <title>, entities decoded', () {
|
||||||
|
expect(htmlTitle('<title>Plain</title><meta content="Tom & Jerry '24" property="og:title">'),
|
||||||
|
"Tom & Jerry '24");
|
||||||
|
expect(htmlTitle("<head><TITLE>\n A "page"\n</TITLE></head>"), 'A "page"');
|
||||||
|
expect(htmlTitle('<p>nothing</p>'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release files: checksums, asset per platform, error text', () {
|
||||||
|
final sums = parseSha256Sums('${'a' * 64} yt-dlp\n${'B' * 64} yt-dlp.exe\ngarbage\n');
|
||||||
|
expect(sums, {'yt-dlp': 'a' * 64, 'yt-dlp.exe': 'b' * 64});
|
||||||
|
expect(ytDlpAssetName(Abi.linuxX64), 'yt-dlp_linux');
|
||||||
|
expect(ytDlpAssetName(Abi.linuxArm64, musl: true), 'yt-dlp_musllinux_aarch64');
|
||||||
|
expect(ytDlpAssetName(Abi.windowsX64), 'yt-dlp.exe');
|
||||||
|
expect(ytDlpAssetName(Abi.androidArm), isNull);
|
||||||
|
expect(ytDlpErrorText('WARNING: x\nERROR: [twitter] 123: No video could be found in this tweet\n'),
|
||||||
|
'No video could be found in this tweet');
|
||||||
|
expect(ytDlpErrorText('ERROR: Unsupported URL: https://example.com/'), 'Unsupported URL: https://example.com/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live: download yt-dlp, resolve a tweet, relay its single file', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('ytdlp_live');
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
final y = YtDlp(dir.path);
|
||||||
|
expect(await y.ensure(), y.managedPath, reason: '${y.lastError}');
|
||||||
|
expect(y.version, isNotNull);
|
||||||
|
final url = Uri.parse('https://x.com/velcrotea/status/2108421180701188522/video/1');
|
||||||
|
final probe = await probeLink(url, ytDlp: y);
|
||||||
|
expect(probe.isPage, isTrue);
|
||||||
|
expect(probe.title, startsWith('a girl.'));
|
||||||
|
expect(probe.relayProblem, isNull);
|
||||||
|
final bytes = LinkRelayBytes(url, ytDlp: y, probe: probe);
|
||||||
|
final size = await bytes.size();
|
||||||
|
var n = 0;
|
||||||
|
await for (final c in bytes.read(0, size - 1)) {
|
||||||
|
n += c.length;
|
||||||
|
}
|
||||||
|
await bytes.close();
|
||||||
|
expect(n, size);
|
||||||
|
}, timeout: const Timeout(Duration(minutes: 3)), skip: Platform.environment['YTDLP_LIVE'] == '1' ? false : 'YTDLP_LIVE not set');
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ Future<void> main(List<String> args) async {
|
|||||||
..addOption('jellyfin',
|
..addOption('jellyfin',
|
||||||
help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) '
|
help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) '
|
||||||
'to relay Jellyfin media to viewers without an account. The device fields must be the ones the token was issued with.')
|
'to relay Jellyfin media to viewers without an account. The device fields must be the ones the token was issued with.')
|
||||||
|
..addFlag('yt-dlp',
|
||||||
|
defaultsTo: true,
|
||||||
|
help: 'Read web pages (YouTube, Twitter/X, ...) for "the server fetches it" with yt-dlp, '
|
||||||
|
'downloaded on first use.')
|
||||||
|
..addOption('yt-dlp-dir',
|
||||||
|
defaultsTo: '', help: 'Writable folder for that yt-dlp (default: yt-dlp next to this binary).')
|
||||||
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
|
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
|
||||||
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
|
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
|
||||||
final ArgResults parsed;
|
final ArgResults parsed;
|
||||||
@@ -52,6 +58,8 @@ Future<void> main(List<String> args) async {
|
|||||||
name: '${parsed['name']}',
|
name: '${parsed['name']}',
|
||||||
mediaPath: '${parsed['media']}',
|
mediaPath: '${parsed['media']}',
|
||||||
advertiseHost: '${parsed['advertise-host']}',
|
advertiseHost: '${parsed['advertise-host']}',
|
||||||
|
ytDlp: parsed['yt-dlp'] as bool,
|
||||||
|
ytDlpDir: '${parsed['yt-dlp-dir']}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final jellyfinPath = parsed['jellyfin'] as String?;
|
final jellyfinPath = parsed['jellyfin'] as String?;
|
||||||
@@ -66,6 +74,8 @@ Future<void> main(List<String> args) async {
|
|||||||
advertiseHost: c.advertiseHost,
|
advertiseHost: c.advertiseHost,
|
||||||
hostKey: c.hostKey,
|
hostKey: c.hostKey,
|
||||||
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
|
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
|
||||||
|
ytDlp: c.ytDlp,
|
||||||
|
ytDlpDir: c.ytDlpDir,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} on ConfigException catch (e) {
|
} on ConfigException catch (e) {
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ class ActiveRelay {
|
|||||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
|
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
|
||||||
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
|
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
|
||||||
|
|
||||||
/// The server reads [url] itself.
|
/// The server reads [url] itself: a media file as is, a web page
|
||||||
|
/// through [ytDlp]. [onFail] gets the reason when it cannot.
|
||||||
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
||||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) {
|
{RelayBuffer? buffer,
|
||||||
|
YtDlp? ytDlp,
|
||||||
|
required void Function() onLiveChanged,
|
||||||
|
void Function(String reason)? onFail}) {
|
||||||
final b = buffer ?? RelayBuffer();
|
final b = buffer ?? RelayBuffer();
|
||||||
final local = LocalRelayFeed(HttpRelayBytes(url), b);
|
final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b);
|
||||||
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
|
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +52,7 @@ class ActiveRelay {
|
|||||||
onLiveChanged();
|
onLiveChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.warning('relay fetch: cannot read the link: $e');
|
_log.warning('relay fetch: cannot read the link: $e');
|
||||||
|
if (!r._disposed) onFail?.call('$e');
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
return r;
|
return r;
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ class ServerHub {
|
|||||||
final Map<String, RelicWebSocket> sockets = {};
|
final Map<String, RelicWebSocket> sockets = {};
|
||||||
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
|
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
|
||||||
JellyfinCredential? jellyfin;
|
JellyfinCredential? jellyfin;
|
||||||
|
/// Reads web pages for "the server fetches it"; null = files only.
|
||||||
|
final YtDlp? ytDlp;
|
||||||
|
|
||||||
ServerHub(this.sessions, this.expectedSecret,
|
ServerHub(this.sessions, this.expectedSecret,
|
||||||
{SyncEngine? engine, this.hostKey = '', this.jellyfin})
|
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
|
||||||
: engine = engine ?? SyncEngine();
|
: engine = engine ?? SyncEngine();
|
||||||
|
|
||||||
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
|
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
|
||||||
@@ -356,7 +358,9 @@ void mountSyncRoute(
|
|||||||
late final ActiveRelay active;
|
late final ActiveRelay active;
|
||||||
active = relayReq.fetch
|
active = relayReq.fetch
|
||||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||||
onLiveChanged: () => hub.relayLiveChanged(active))
|
ytDlp: hub.ytDlp,
|
||||||
|
onLiveChanged: () => hub.relayLiveChanged(active),
|
||||||
|
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
|
||||||
: _memberRelay(hub, token, name);
|
: _memberRelay(hub, token, name);
|
||||||
hub.setRelay(active);
|
hub.setRelay(active);
|
||||||
hub.changed();
|
hub.changed();
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ import 'relay_routes.dart';
|
|||||||
import 'server_hub.dart';
|
import 'server_hub.dart';
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
|
|
||||||
|
/// [configured], else systemd's `$STATE_DIRECTORY/yt-dlp`, else `yt-dlp`
|
||||||
|
/// next to the server binary when that is writable (not under `dart run`),
|
||||||
|
/// else `~/.cache/watch-together/yt-dlp`.
|
||||||
|
String serverYtDlpDir(String configured) {
|
||||||
|
if (configured.isNotEmpty) return configured;
|
||||||
|
final sep = Platform.pathSeparator;
|
||||||
|
final state = (Platform.environment['STATE_DIRECTORY'] ?? '').split(':').first;
|
||||||
|
if (state.isNotEmpty) return '$state${sep}yt-dlp';
|
||||||
|
final exe = File(Platform.resolvedExecutable);
|
||||||
|
final name = exe.uri.pathSegments.last.toLowerCase();
|
||||||
|
if (name != 'dart' && name != 'dart.exe' && name != 'dartaotruntime') {
|
||||||
|
final dir = Directory('${exe.parent.path}${sep}yt-dlp');
|
||||||
|
try {
|
||||||
|
dir.createSync(recursive: true);
|
||||||
|
final probe = File('${dir.path}$sep.writable')..writeAsStringSync('');
|
||||||
|
probe.deleteSync();
|
||||||
|
return dir.path;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.';
|
||||||
|
return '$home$sep.cache${sep}watch-together${sep}yt-dlp';
|
||||||
|
}
|
||||||
|
|
||||||
class SyncServer {
|
class SyncServer {
|
||||||
final AppConfig config;
|
final AppConfig config;
|
||||||
final pkg_logging.Logger _log = createLogger('sync_server');
|
final pkg_logging.Logger _log = createLogger('sync_server');
|
||||||
@@ -26,7 +49,10 @@ class SyncServer {
|
|||||||
HttpClient? _relayClient;
|
HttpClient? _relayClient;
|
||||||
|
|
||||||
SyncServer(this.config) {
|
SyncServer(this.config) {
|
||||||
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey, jellyfin: config.jellyfin);
|
hub = ServerHub(sessions, config.secret,
|
||||||
|
hostKey: config.hostKey,
|
||||||
|
jellyfin: config.jellyfin,
|
||||||
|
ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isRunning => _server != null;
|
bool get isRunning => _server != null;
|
||||||
@@ -78,7 +104,7 @@ class SyncServer {
|
|||||||
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
||||||
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
|
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
|
||||||
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
|
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
|
||||||
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${Uri.encodeComponent(name)}');
|
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}');
|
||||||
_app.fallback = (final req) => Response.notFound(
|
_app.fallback = (final req) => Response.notFound(
|
||||||
body: Body.fromString('not found'),
|
body: Body.fromString('not found'),
|
||||||
);
|
);
|
||||||
|
|||||||