yt-dlp for page links in every Link mode; app icon and Linux launcher entry
yt-dlp: a managed, checksum-verified, daily-updated yt-dlp in the app and the dedicated server (--[no-]yt-dlp, --yt-dlp-dir). Pasted links are probed: direct files stay links, pages are resolved for title, uploader, duration and thumbnail. Everyone fetches it: mpv's ytdl hook on the managed copy. I pass it on / the server fetches it: the best single-file format, re-resolved once when its URL expires. Relay names never split the /r/ path; viewers of server-fetched pages wait for the relay to go live. No wire change. Icon: two stars orbiting a play button (assets/icon/icon.svg, PNGs, the Windows .ico). GTK app id moe.senkochan.watch_together; release builds install their .desktop file and icons into ~/.local/share so Wayland shows the icon; the window icon is set for X11. README shows the icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ddd85de90
commit
745717a4a0
40 files changed
+1444
-123
No files matched your search
@@ -19,6 +19,12 @@ Future<void> main(List<String> args) async {
|
||||
..addOption('jellyfin',
|
||||
help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) '
|
||||
'to relay Jellyfin media to viewers without an account. The device fields must be the ones the token was issued with.')
|
||||
..addFlag('yt-dlp',
|
||||
defaultsTo: true,
|
||||
help: 'Read web pages (YouTube, Twitter/X, ...) for "the server fetches it" with yt-dlp, '
|
||||
'downloaded on first use.')
|
||||
..addOption('yt-dlp-dir',
|
||||
defaultsTo: '', help: 'Writable folder for that yt-dlp (default: yt-dlp next to this binary).')
|
||||
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
|
||||
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
|
||||
final ArgResults parsed;
|
||||
@@ -52,6 +58,8 @@ Future<void> main(List<String> args) async {
|
||||
name: '${parsed['name']}',
|
||||
mediaPath: '${parsed['media']}',
|
||||
advertiseHost: '${parsed['advertise-host']}',
|
||||
ytDlp: parsed['yt-dlp'] as bool,
|
||||
ytDlpDir: '${parsed['yt-dlp-dir']}',
|
||||
);
|
||||
}
|
||||
final jellyfinPath = parsed['jellyfin'] as String?;
|
||||
@@ -66,6 +74,8 @@ Future<void> main(List<String> args) async {
|
||||
advertiseHost: c.advertiseHost,
|
||||
hostKey: c.hostKey,
|
||||
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
|
||||
ytDlp: c.ytDlp,
|
||||
ytDlpDir: c.ytDlpDir,
|
||||
);
|
||||
}
|
||||
} on ConfigException catch (e) {
|
||||
|
||||
@@ -34,11 +34,15 @@ class ActiveRelay {
|
||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) =>
|
||||
ActiveRelay._(mediaId, name, sourceToken, buffer ?? RelayBuffer(), null, onLiveChanged);
|
||||
|
||||
/// The server reads [url] itself.
|
||||
/// The server reads [url] itself: a media file as is, a web page
|
||||
/// through [ytDlp]. [onFail] gets the reason when it cannot.
|
||||
static ActiveRelay fetch(int mediaId, String name, Uri url,
|
||||
{RelayBuffer? buffer, required void Function() onLiveChanged}) {
|
||||
{RelayBuffer? buffer,
|
||||
YtDlp? ytDlp,
|
||||
required void Function() onLiveChanged,
|
||||
void Function(String reason)? onFail}) {
|
||||
final b = buffer ?? RelayBuffer();
|
||||
final local = LocalRelayFeed(HttpRelayBytes(url), b);
|
||||
final local = LocalRelayFeed(LinkRelayBytes(url, ytDlp: ytDlp), b);
|
||||
final r = ActiveRelay._(mediaId, name, null, b, local, onLiveChanged);
|
||||
unawaited(() async {
|
||||
try {
|
||||
@@ -48,6 +52,7 @@ class ActiveRelay {
|
||||
onLiveChanged();
|
||||
} catch (e) {
|
||||
_log.warning('relay fetch: cannot read the link: $e');
|
||||
if (!r._disposed) onFail?.call('$e');
|
||||
}
|
||||
}());
|
||||
return r;
|
||||
|
||||
@@ -20,9 +20,11 @@ class ServerHub {
|
||||
final Map<String, RelicWebSocket> sockets = {};
|
||||
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
|
||||
JellyfinCredential? jellyfin;
|
||||
/// Reads web pages for "the server fetches it"; null = files only.
|
||||
final YtDlp? ytDlp;
|
||||
|
||||
ServerHub(this.sessions, this.expectedSecret,
|
||||
{SyncEngine? engine, this.hostKey = '', this.jellyfin})
|
||||
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
|
||||
: engine = engine ?? SyncEngine();
|
||||
|
||||
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
|
||||
@@ -356,7 +358,9 @@ void mountSyncRoute(
|
||||
late final ActiveRelay active;
|
||||
active = relayReq.fetch
|
||||
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
|
||||
onLiveChanged: () => hub.relayLiveChanged(active))
|
||||
ytDlp: hub.ytDlp,
|
||||
onLiveChanged: () => hub.relayLiveChanged(active),
|
||||
onFail: (why) => wsErr(ws, "The server can't fetch this link: $why"))
|
||||
: _memberRelay(hub, token, name);
|
||||
hub.setRelay(active);
|
||||
hub.changed();
|
||||
|
||||
@@ -12,6 +12,29 @@ import 'relay_routes.dart';
|
||||
import 'server_hub.dart';
|
||||
import 'session.dart';
|
||||
|
||||
/// [configured], else systemd's `$STATE_DIRECTORY/yt-dlp`, else `yt-dlp`
|
||||
/// next to the server binary when that is writable (not under `dart run`),
|
||||
/// else `~/.cache/watch-together/yt-dlp`.
|
||||
String serverYtDlpDir(String configured) {
|
||||
if (configured.isNotEmpty) return configured;
|
||||
final sep = Platform.pathSeparator;
|
||||
final state = (Platform.environment['STATE_DIRECTORY'] ?? '').split(':').first;
|
||||
if (state.isNotEmpty) return '$state${sep}yt-dlp';
|
||||
final exe = File(Platform.resolvedExecutable);
|
||||
final name = exe.uri.pathSegments.last.toLowerCase();
|
||||
if (name != 'dart' && name != 'dart.exe' && name != 'dartaotruntime') {
|
||||
final dir = Directory('${exe.parent.path}${sep}yt-dlp');
|
||||
try {
|
||||
dir.createSync(recursive: true);
|
||||
final probe = File('${dir.path}$sep.writable')..writeAsStringSync('');
|
||||
probe.deleteSync();
|
||||
return dir.path;
|
||||
} catch (_) {}
|
||||
}
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.';
|
||||
return '$home$sep.cache${sep}watch-together${sep}yt-dlp';
|
||||
}
|
||||
|
||||
class SyncServer {
|
||||
final AppConfig config;
|
||||
final pkg_logging.Logger _log = createLogger('sync_server');
|
||||
@@ -26,7 +49,10 @@ class SyncServer {
|
||||
HttpClient? _relayClient;
|
||||
|
||||
SyncServer(this.config) {
|
||||
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey, jellyfin: config.jellyfin);
|
||||
hub = ServerHub(sessions, config.secret,
|
||||
hostKey: config.hostKey,
|
||||
jellyfin: config.jellyfin,
|
||||
ytDlp: YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
||||
}
|
||||
|
||||
bool get isRunning => _server != null;
|
||||
@@ -78,7 +104,7 @@ class SyncServer {
|
||||
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
|
||||
relayMediaUrlBuilder: (sid, token, mediaId, name, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${Uri.encodeComponent(name)}');
|
||||
'http://${authority(dialledHost)}/r/$sid/$token/$mediaId/${relayUrlName(name)}');
|
||||
_app.fallback = (final req) => Response.notFound(
|
||||
body: Body.fromString('not found'),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user