- Linux app id dev.foxyz.starsync, binary starsync; Windows starsync.exe, company foxyz; server starsync-server; Dart package starsync; archives starsync-<ver>-*. - 1.2.x compatibility: watch_together and watch-together-server launchers (Linux) / copies (Windows) in the archive; the updater accepts both names and relaunches the new binary; update.sh/.bat use whichever server exists. - First start copies (never moves) the old support folder and this app's Hive boxes out of ~/Documents into <support>/hive; a marker makes it run once; AppDirs is a port; settings schema version with ordered migrations. - Launcher entry dev.foxyz.starsync.desktop; the old one removed. - Repository name and URLs unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
7.2 KiB
Dart
172 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
|
|
/// `starsync-server` (also started by the `watch-together-server`
|
|
/// launcher). 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('starsync-server $appVersion (proto ${RelayConstants.protoVersion})\n\n${parser.usage}');
|
|
return;
|
|
}
|
|
if (parsed['upgrade'] as bool || parsed['check-update'] as bool) {
|
|
exitCode = await runUpgrade(
|
|
program: 'starsync-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('starsync-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})');
|
|
}
|
|
}
|