Every app gets the first Up next item ready itself (own Jellyfin plan, direct links, guests via /j when state.relayNext says the server's login can pass it on) and opens it in a hidden, muted, paused second player; the swap promotes that player instead of opening cold, so its buffer counts at once. Reports go out on open and when the buffer reaches the gate's need. SwitchTrace times each phase. Measured in the e2e harness: 1.5-3 s before, 35-103 ms with a preload. Relayed items still open cold for everyone but their source (ROADMAP). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.1 KiB
Dart
191 lines
7.1 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:logging/logging.dart' as pkg_logging;
|
|
import 'package:relic/relic.dart';
|
|
import 'package:sync_relay_core/core.dart';
|
|
|
|
import 'jellyfin_relay.dart';
|
|
import 'media_endpoint.dart';
|
|
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');
|
|
final RelicApp _app = RelicApp();
|
|
final SessionMap sessions = SessionMap();
|
|
late final ServerHub hub;
|
|
RelicServer? _server;
|
|
DateTime? _startedAt;
|
|
Timer? _heartbeat;
|
|
bool _routesMounted = false;
|
|
/// Upstream connections of the Jellyfin relay; created on first use.
|
|
HttpClient? _relayClient;
|
|
|
|
/// Listen on 127.0.0.1 only (the app's solo session), not on every
|
|
/// IPv4 interface.
|
|
final bool loopbackOnly;
|
|
|
|
/// [ytDlp] replaces the server's own (tests).
|
|
SyncServer(this.config, {YtDlp? ytDlp, this.loopbackOnly = false}) {
|
|
hub = ServerHub(sessions, config.secret,
|
|
hostKey: config.hostKey,
|
|
jellyfin: config.jellyfin,
|
|
ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
|
}
|
|
|
|
bool get isRunning => _server != null;
|
|
int get boundPort => _server?.port ?? config.port;
|
|
Session get session => sessions.getOrCreate('default', config.secret);
|
|
|
|
Future<bool> setLocalFile(String path) async {
|
|
final file = File(path);
|
|
if (!await file.exists()) return false;
|
|
final name = file.path.split(Platform.pathSeparator).last;
|
|
final size = await file.length();
|
|
session.media = SessionMedia(name: name, size: size, duration: 0, file: file);
|
|
hub.engine.media = HelloMedia(name: name, size: size, duration: 0);
|
|
return true;
|
|
}
|
|
|
|
void _mountRoutes() {
|
|
if (_routesMounted) return;
|
|
_routesMounted = true;
|
|
_app.get('/health', (final req) {
|
|
final s = sessions.get('default');
|
|
final viewers = s?.tokens.length ?? 0;
|
|
final bytes = s?.totalBytesServed ?? 0;
|
|
final uptime = _startedAt == null ? 0 : DateTime.now().difference(_startedAt!).inSeconds;
|
|
return Response.ok(
|
|
body: Body.fromString(jsonEncode({
|
|
'ok': true,
|
|
'name': config.name,
|
|
'proto': RelayConstants.protoVersion,
|
|
'viewers': viewers,
|
|
'bytesServed': bytes,
|
|
'uptimeSecs': uptime,
|
|
})),
|
|
);
|
|
});
|
|
mountSessionMediaRoute(_app, sessions);
|
|
mountRelayRoutes(_app, sessions, () => hub.relay);
|
|
mountAvatarRoute(_app, hub);
|
|
mountArtRoute(_app, hub);
|
|
mountJellyfinRelayRoute(
|
|
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(), next: () => hub.nextJellyfinRef);
|
|
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
|
String authority(String dialledHost) => config.advertiseHost.isNotEmpty
|
|
? '${config.advertiseHost}:$boundPort'
|
|
: dialledHost;
|
|
mountSyncRoute(_app, hub, (sid, token, media, dialledHost) {
|
|
final name = Uri.encodeComponent(media?.name ?? 'file');
|
|
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
|
|
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
|
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
|
|
relayMediaUrlBuilder: (sid, token, mediaId, name, tracks, dialledHost) =>
|
|
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
|
|
_app.fallback = (final req) => Response.notFound(
|
|
body: Body.fromString('not found'),
|
|
);
|
|
}
|
|
|
|
Future<void> start() async {
|
|
if (_server != null) return;
|
|
final s = session;
|
|
if (config.mediaPath.isNotEmpty) {
|
|
final file = File(config.mediaPath);
|
|
if (await file.exists()) {
|
|
final name = file.path.split(Platform.pathSeparator).last;
|
|
final size = await file.length();
|
|
s.media = SessionMedia(name: name, size: size, duration: 0, file: file);
|
|
hub.engine.media = HelloMedia(name: name, size: size, duration: 0);
|
|
} else {
|
|
_log.warning('media file not found: ${config.mediaPath}');
|
|
}
|
|
}
|
|
if (config.secret.isEmpty) {
|
|
_log.warning('no password set: open server, anyone who can reach it can join');
|
|
}
|
|
await _resolveJellyfinServerId();
|
|
_mountRoutes();
|
|
_server = await _app.serve(
|
|
address: loopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4,
|
|
port: config.port,
|
|
// A killed isolate never closes its listening socket, so the port stays
|
|
// bound in this process. Shared lets the next server in the same
|
|
// process bind it again (it then gets all connections); another
|
|
// process is still refused.
|
|
shared: true,
|
|
);
|
|
_startedAt = DateTime.now();
|
|
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
hub.engine.tick();
|
|
hub.sweepClosedSockets();
|
|
hub.dropSilentSockets();
|
|
hub.sampleRates();
|
|
hub.broadcastState();
|
|
hub.broadcastFeed();
|
|
});
|
|
_log.info('server "${config.name}" listening on $boundPort');
|
|
}
|
|
|
|
/// A configured credential without a ServerId gets it from
|
|
/// `/System/Info/Public` (unauthenticated). On failure the relay stays off.
|
|
Future<void> _resolveJellyfinServerId() async {
|
|
final cred = hub.jellyfin;
|
|
if (cred == null) return;
|
|
if (cred.serverId.isEmpty) {
|
|
final client = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo);
|
|
try {
|
|
final info = await client.publicInfo().timeout(const Duration(seconds: 10));
|
|
hub.jellyfin = cred.withServerId(info.id);
|
|
} catch (e) {
|
|
_log.warning('jellyfin relay off: server id lookup failed: $e');
|
|
return;
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
_log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}');
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
if (_server == null) return;
|
|
_heartbeat?.cancel();
|
|
_heartbeat = null;
|
|
_relayClient?.close(force: true);
|
|
_relayClient = null;
|
|
hub.setRelay(null);
|
|
await hub.closeAllSockets();
|
|
await _app.close();
|
|
_server = null;
|
|
_log.info('server "${config.name}" stopped');
|
|
}
|
|
}
|