Files
sync_replay_project/packages/server/bin/server.dart
T
foxyzandClaude Opus 5.5 7487b4deb1 Protocol 15: one shared session log on the server, chat never logged, Mark a problem
Every app streams its redacted diagnostic lines (server time, batched
every 10 s, gzip, buffered while offline) to POST /l/; the server merges
them with its own into session-YYYY-MM-DD.log in time order, tagged by
who. Opt-out in Settings > About; Mark a problem (Ctrl+Shift+B, the
player menus, About) writes a marker with an optional note. Copy
diagnostics can include the server's session log. Chat text never
reaches any log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:41:06 +03:00

171 lines
7.2 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:args/args.dart';
import 'package:logging/logging.dart' as pkg_logging;
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
/// Dedicated server: `dart compile exe` of this file is shipped as
/// `watch-together-server`. Runs [serve] until SIGINT or SIGTERM.
Future<void> main(List<String> args) async {
final parser = ArgParser()
..addOption('port', abbr: 'p', defaultsTo: '8080', help: 'TCP port to listen on (all IPv4 interfaces).')
..addOption('secret', defaultsTo: '', help: 'Shared password. Empty means an open server: anyone who can reach it can join.')
..addOption('name', defaultsTo: 'server', help: 'Server name, reported by /health.')
..addOption('media', defaultsTo: '', help: 'Local file to serve as the starting media.')
..addOption('advertise-host', defaultsTo: '', help: 'Host put in file-media URLs instead of the one each client dialled.')
..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('yt-dlp-cookies',
defaultsTo: '',
help: 'cookies.txt or browser-extension JSON export to log that yt-dlp in (sites that refuse guests). '
'An account login: keep it private. Copied into --yt-dlp-dir at start.')
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
..addOption('log-dir',
help: 'Folder for the rotating log files (server-YYYY-MM-DD.log, and session-YYYY-MM-DD.log with '
'every member\'s shared diagnostics merged in; kept 7 days). '
'Default: logs next to --config, else logs next to this binary (systemd: \$STATE_DIRECTORY/logs).')
..addFlag('upgrade',
negatable: false,
help: 'Update this install (the whole folder, app included) to the latest release, then exit. '
'Restart the server afterwards to run it.')
..addFlag('check-update', negatable: false, help: 'Print whether a newer release exists, then exit.')
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
final ArgResults parsed;
try {
parsed = parser.parse(args);
} on FormatException catch (e) {
stderr.writeln('${e.message}\n\n${parser.usage}');
exitCode = 64;
return;
}
if (parsed['help'] as bool) {
// ignore: avoid_print
print('watch-together-server $appVersion (proto ${RelayConstants.protoVersion})\n\n${parser.usage}');
return;
}
if (parsed['upgrade'] as bool || parsed['check-update'] as bool) {
exitCode = await runUpgrade(
program: 'watch-together-server',
checkOnly: !(parsed['upgrade'] as bool),
restartNote: (v) => 'updated to $v — restart the server to run $v.');
return;
}
initLogging(level: pkg_logging.Level.INFO);
final configPath = parsed['config'] as String?;
final logDir = (parsed['log-dir'] as String?) ??
(configPath != null ? '${File(configPath).absolute.parent.path}${Platform.pathSeparator}logs' : serverDataDir('logs'));
final files = startLogFiles(logDir, prefix: 'server');
if (files != null) stdout.writeln('logs: ${files.dir}');
pkg_logging.Logger('server').info('watch-together-server $appVersion (proto ${RelayConstants.protoVersion}) '
'on ${Platform.operatingSystem} ${Platform.operatingSystemVersion}, pid $pid');
AppConfig config;
try {
if (configPath != null) {
config = await AppConfig.loadFile(configPath);
} else {
final port = int.tryParse('${parsed['port']}');
if (port == null || port < 0 || port > 65535) {
throw ConfigException('bad --port: ${parsed['port']}');
}
config = AppConfig(
port: port,
secret: '${parsed['secret']}',
name: '${parsed['name']}',
mediaPath: '${parsed['media']}',
advertiseHost: '${parsed['advertise-host']}',
ytDlp: parsed['yt-dlp'] as bool,
ytDlpDir: '${parsed['yt-dlp-dir']}',
ytDlpCookies: '${parsed['yt-dlp-cookies']}',
);
}
final jellyfinPath = parsed['jellyfin'] as String?;
if (jellyfinPath != null) {
final c = config;
config = AppConfig(
port: c.port,
secret: c.secret,
name: c.name,
mediaPath: c.mediaPath,
logLevel: c.logLevel,
advertiseHost: c.advertiseHost,
hostKey: c.hostKey,
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
ytDlp: c.ytDlp,
ytDlpDir: c.ytDlpDir,
ytDlpCookies: c.ytDlpCookies,
);
}
await _setUpCookies(config);
} on ConfigException catch (e) {
stderr.writeln(e.message);
exitCode = 64;
return;
}
if (config.secret.isEmpty) {
stderr.writeln(
'WARNING: no --secret set: this is an open server, anyone who can reach port ${config.port} can join.');
}
final events = ReceivePort('server events');
// The session log (every member's diagnostics and the server's records
// merged) goes next to the server's own.
final done = serve({...config.toMap(), if (files != null) 'logDir': files.dir}, events: events.sendPort);
final signals = <StreamSubscription<ProcessSignal>>[];
await for (final msg in events) {
if (msg is! Map) continue;
if (msg['t'] == 'error') {
stderr.writeln('server failed to start: ${msg['message']}');
pkg_logging.Logger('server').severe('failed to start: ${msg['message']}');
exitCode = 1;
break;
}
if (msg['t'] == 'ready') {
final stop = msg['stop'] as SendPort;
// ignore: avoid_print
print('READY port=${msg['port']} name=${config.name}');
void onSignal(ProcessSignal _) => stop.send({'t': 'stop'});
signals.add(ProcessSignal.sigint.watch().listen(onSignal));
if (!Platform.isWindows) {
signals.add(ProcessSignal.sigterm.watch().listen(onSignal));
}
break;
}
}
await done;
events.close();
for (final s in signals) {
await s.cancel();
}
}
/// The private cookie copy in the server's yt-dlp folder: converted from
/// [AppConfig.ytDlpCookies], or removed when none is given (so dropping the
/// flag logs yt-dlp out).
Future<void> _setUpCookies(AppConfig config) async {
if (!config.ytDlp) return;
final cookies = YtDlpCookies(serverYtDlpDir(config.ytDlpDir));
try {
if (config.ytDlpCookies.isEmpty) {
await cookies.remove();
return;
}
final kept = await cookies.import(config.ytDlpCookies);
stdout.writeln('yt-dlp cookies: ${kept.count} for ${kept.sites.join(', ')}');
} on FormatException catch (e) {
throw ConfigException('--yt-dlp-cookies ${config.ytDlpCookies}: ${e.message}');
} on FileSystemException catch (e) {
throw ConfigException('--yt-dlp-cookies: ${e.message} (${e.path})');
}
}