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 main(List 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.') ..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); AppConfig config; final configPath = parsed['config'] as String?; 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'); final done = serve(config.toMap(), events: events.sendPort); final signals = >[]; await for (final msg in events) { if (msg is! Map) continue; if (msg['t'] == 'error') { stderr.writeln('server 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 _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})'); } }