Sessions leave redacted logs; viewers wait for a member relay's source before opening it
Logs: one redaction pass (query strings to names, member tokens,
passwords, cookies, auth headers) for every line; daily files with size
and age caps in the app (hosted server included) and the dedicated
server (--log-dir); swaps, relay channels, per-member /r /m /j outcomes,
server fetches, mpv load errors, yt-dlp runs. Settings > About: Copy
diagnostics, Open logs folder.
Bilibili through someone else: the viewer opened /r/ before the source
dialled in; the server holds that request and mpv gives up after 5 s
("could not be opened"). Viewers now open a member relay once it is
live, the putter starts its source before its own player, and track
sizes are read in parallel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
572104f49e
commit
4ab37c55cf
25 files changed
+1124
-108
No files matched your search
@@ -30,6 +30,9 @@ Future<void> main(List<String> args) async {
|
||||
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, 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. '
|
||||
@@ -58,8 +61,14 @@ Future<void> main(List<String> args) async {
|
||||
}
|
||||
|
||||
initLogging(level: pkg_logging.Level.INFO);
|
||||
AppConfig config;
|
||||
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);
|
||||
@@ -114,6 +123,7 @@ Future<void> main(List<String> args) async {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -80,15 +80,16 @@ void mountJellyfinRelayRoute(
|
||||
upRes = await upReq.close().timeout(timeout);
|
||||
} catch (e) {
|
||||
_relayLog.warning('upstream ${ref.itemId}: $e');
|
||||
onLog?.call(token, MediaLog(method: req.method.name, path: '/j/$sid/$itemId', rangeHeader: range,
|
||||
status: 502, bytesWritten: 0, durationMs: stopwatch.elapsedMilliseconds));
|
||||
return _plain(502);
|
||||
}
|
||||
|
||||
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||
void log(int status, int bytes) => onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/j/$sid/$shortToken/$itemId',
|
||||
path: '/j/$sid/$itemId',
|
||||
rangeHeader: range,
|
||||
status: status,
|
||||
bytesWritten: bytes,
|
||||
|
||||
@@ -27,6 +27,24 @@ class MediaLog {
|
||||
'$method $path range=${rangeHeader ?? '-'} -> $status bytes=$bytesWritten ${durationMs}ms';
|
||||
}
|
||||
|
||||
final _requestLog = createLogger('media');
|
||||
|
||||
/// Each member's media requests (`/m`, `/r`, `/j`) for the log: a few per
|
||||
/// member, route and outcome every 30 s (mpv reads in many ranges), every
|
||||
/// failure kind at least once per window.
|
||||
class MediaRequestLog {
|
||||
final _throttle = LogThrottle(burst: 4, window: const Duration(seconds: 30));
|
||||
|
||||
void call(String who, MediaLog log) {
|
||||
final bad = log.status >= 400;
|
||||
final route = log.path.split('/').where((s) => s.isNotEmpty).firstOrNull ?? '';
|
||||
final pass = _throttle.allow('$who $route ${bad ? log.status : 'ok'}');
|
||||
if (pass == null) return;
|
||||
final line = '$pass$who: $log';
|
||||
bad ? _requestLog.warning(line) : _requestLog.info(line);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaResolution {
|
||||
final int status;
|
||||
final Map<String, String> headers;
|
||||
@@ -187,14 +205,20 @@ void mountSessionMediaRoute(
|
||||
}
|
||||
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: media.name);
|
||||
if (res.status != 200 && res.status != 206) {
|
||||
onLog?.call(token, MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/m/$sid/${media.name}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: res.status,
|
||||
bytesWritten: 0,
|
||||
durationMs: stopwatch.elapsedMilliseconds));
|
||||
return _gatedResponse(res);
|
||||
}
|
||||
final end = res.end ?? size - 1;
|
||||
final byteCount = end - res.start + 1;
|
||||
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||
onLog?.call(token, MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/m/$sid/$shortToken/${media.name}',
|
||||
path: '/m/$sid/${media.name}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: res.status,
|
||||
bytesWritten: byteCount,
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'server_hub.dart';
|
||||
// `queue` messages (Up next, see PROTOCOL.md "Queue"): what an added item
|
||||
// may carry, and the ops applied to the engine's queue.
|
||||
|
||||
final _log = createLogger('queue');
|
||||
|
||||
final _key = RegExp(r'^[A-Za-z0-9_-]{8,64}$');
|
||||
|
||||
bool _httpUrl(String? url) {
|
||||
@@ -76,9 +78,11 @@ void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<Strin
|
||||
final error = engine.applyQueueAdd(token, cleaned.item,
|
||||
next: intent.next, privateUrl: cleaned.privateUrl, thumb: thumb);
|
||||
if (error != null) {
|
||||
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}) refused: $error');
|
||||
wsErr(ws, error);
|
||||
return;
|
||||
}
|
||||
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}${intent.next ? ', next' : ''})');
|
||||
changed = true;
|
||||
case 'move':
|
||||
changed = engine.applyQueueMove(token, intent.id, intent.to);
|
||||
@@ -89,5 +93,9 @@ void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<Strin
|
||||
case 'skip':
|
||||
changed = engine.applyQueuePlay(token, '');
|
||||
}
|
||||
if (intent.op != 'add') {
|
||||
_log.info('${hub.who(token)}: ${intent.op}${intent.id.isEmpty ? '' : ' ${intent.id}'}'
|
||||
'${intent.op == 'move' ? ' to ${intent.to}' : ''}${changed ? '' : ' (no change)'}');
|
||||
}
|
||||
if (changed) hub.changed();
|
||||
}
|
||||
@@ -203,7 +203,9 @@ class ActiveRelay {
|
||||
final r = ActiveRelay._(mediaId, name, null, onLiveChanged, fetch: true, queueId: queueId, warm: queueId != null)
|
||||
..onFail = onFail;
|
||||
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
|
||||
_log.info('relay fetch of "$name"${queueId == null ? '' : ' (warm, Up next $queueId)'}: ${logUrl(url)}');
|
||||
unawaited(() async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final tracks = await media.open();
|
||||
final infos = <_TrackInfo>[
|
||||
@@ -213,12 +215,14 @@ class ActiveRelay {
|
||||
: (kind: t.kind, size: await t.bytes!.size(), pieces: null),
|
||||
];
|
||||
if (r._disposed) return;
|
||||
_log.info('relay fetch of "$name" ready in ${sw.elapsedMilliseconds} ms: '
|
||||
'${infos.map((t) => t.size != null ? '${t.kind.isEmpty ? 'file' : t.kind} ${t.size} bytes' : '${t.kind} ${t.pieces!.length} pieces').join(' + ')}');
|
||||
r._setTracks(relayTrackSpecs(tracks));
|
||||
r._local = [for (var i = 0; i < tracks.length; i++) LocalRelayFeed(tracks[i], r.buffers[i])];
|
||||
r._attachAll(r._local, infos);
|
||||
onLiveChanged();
|
||||
} catch (e) {
|
||||
_log.warning('relay fetch: cannot read the link: $e');
|
||||
_log.warning('relay fetch of "$name": cannot read the link after ${sw.elapsedMilliseconds} ms: $e');
|
||||
if (!r._disposed) {
|
||||
r.failed = true;
|
||||
r.onFail?.call('$e');
|
||||
@@ -370,9 +374,12 @@ void mountRelayRoutes(
|
||||
return WebSocketUpgrade((final ws) async {
|
||||
final r = found;
|
||||
if (r == null) {
|
||||
_log.warning('relay channel refused: not the source of ${queueId == null ? 'the current media' : 'Up next item $queueId'}');
|
||||
await _closeNotSource(ws);
|
||||
return;
|
||||
}
|
||||
final opened = DateTime.now();
|
||||
String? closed;
|
||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||
var attached = false;
|
||||
try {
|
||||
@@ -397,7 +404,7 @@ void mountRelayRoutes(
|
||||
return;
|
||||
}
|
||||
attached = true;
|
||||
_log.info('relay channel up (${r.name}, '
|
||||
_log.info('relay channel up (${r.name}${r.warm ? ', warm' : ''}, from ${r.sourceId}, '
|
||||
'${infos.map((t) => t.size != null ? '${t.size} bytes' : '${t.pieces!.length} pieces').join(' + ')})');
|
||||
r._attachChannel(ws, infos);
|
||||
case 'end':
|
||||
@@ -413,6 +420,8 @@ void mountRelayRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e is CloseReceived) {
|
||||
closed = '${e.code ?? '-'} ${e.reason}'.trim();
|
||||
} else if (e is BinaryDataReceived && attached) {
|
||||
try {
|
||||
// Pull ids are unique across tracks: only the owner takes it.
|
||||
@@ -423,11 +432,13 @@ void mountRelayRoutes(
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// channel broke
|
||||
} catch (e) {
|
||||
_log.warning('relay channel broke (${r.name}): $e');
|
||||
} finally {
|
||||
r._detachChannel(ws);
|
||||
_log.info('relay channel down (${r.name})');
|
||||
_log.info('relay channel down (${r.name}, from ${r.sourceId}) after '
|
||||
'${DateTime.now().difference(opened).inSeconds} s, ${closed == null ? 'no close frame' : 'closed $closed'}'
|
||||
'${r._disposed ? ', relay dropped' : ''}');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -438,83 +449,97 @@ void mountRelayRoutes(
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final key = req.pathParameters.raw[#mediaId] ?? '';
|
||||
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
|
||||
final session = sessions.get(sid);
|
||||
if (session == null) return _plain(410);
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
// A player still on an older media must not get the new one's bytes.
|
||||
final r = relayAt(key);
|
||||
if (r == null) return _plain(404);
|
||||
final notConnected = Response(503,
|
||||
body: Body.fromString('relay source not connected'),
|
||||
headers: Headers.build((h) => h['retry-after'] = ['2']));
|
||||
if (!await r.whenKnown()) return notConnected;
|
||||
final buffer = r.bufferAt(track);
|
||||
if (buffer == null || (piece && buffer is! PieceBuffer)) return _plain(404);
|
||||
if (!await buffer.ready()) return notConnected;
|
||||
final pieceIndex = piece ? req.pathParameters.raw[#piece] ?? '' : null;
|
||||
final typed = req.headers.range;
|
||||
String? rangeHeader;
|
||||
if (typed != null && typed.ranges.isNotEmpty) {
|
||||
final x = typed.ranges.first;
|
||||
rangeHeader = '${typed.unit}=${x.start?.toString() ?? ''}-${x.end?.toString() ?? ''}';
|
||||
}
|
||||
// Every outcome goes to the log (throttled per member there); a 503
|
||||
// after a long wait is a source that never came up.
|
||||
Response logged(Response res, {int bytes = 0}) {
|
||||
onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/r/$sid/$key/${track == null ? '' : '$track/'}${pieceIndex == null ? '' : 'p/$pieceIndex'}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: res.statusCode,
|
||||
bytesWritten: bytes,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
));
|
||||
return res;
|
||||
}
|
||||
|
||||
final session = sessions.get(sid);
|
||||
if (session == null) return logged(_plain(410));
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
default:
|
||||
return logged(_plain(401));
|
||||
}
|
||||
// A player still on an older media must not get the new one's bytes.
|
||||
final r = relayAt(key);
|
||||
if (r == null) return logged(_plain(404));
|
||||
Response notConnected() => logged(Response(503,
|
||||
body: Body.fromString('relay source not connected'),
|
||||
headers: Headers.build((h) => h['retry-after'] = ['2'])));
|
||||
if (!await r.whenKnown()) return notConnected();
|
||||
final buffer = r.bufferAt(track);
|
||||
if (buffer == null || (piece && buffer is! PieceBuffer)) return logged(_plain(404));
|
||||
if (!await buffer.ready()) return notConnected();
|
||||
if (buffer is PieceBuffer) {
|
||||
if (!piece) {
|
||||
buffer.opened(token);
|
||||
return Response(200,
|
||||
return logged(Response(200,
|
||||
body: Body.fromString(buffer.playlist ?? '', mimeType: MimeType('application', 'vnd.apple.mpegurl')),
|
||||
headers: Headers.build((h) => h['cache-control'] = ['no-store']));
|
||||
headers: Headers.build((h) => h['cache-control'] = ['no-store'])));
|
||||
}
|
||||
final index = int.tryParse(req.pathParameters.raw[#piece] ?? '');
|
||||
if (index == null || index < 0 || index >= buffer.list!.length) return _plain(404);
|
||||
final index = int.tryParse(pieceIndex ?? '');
|
||||
if (index == null || index < 0 || index >= buffer.list!.length) return logged(_plain(404));
|
||||
final data = await buffer.piece(token, index);
|
||||
if (data == null) return notConnected;
|
||||
if (data == null) return notConnected();
|
||||
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: data.length, filename: 'piece');
|
||||
final headers = Headers.build((h) => res.headers.forEach((k, v) => h[k] = [v]));
|
||||
if (res.status != 200 && res.status != 206) {
|
||||
return Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers);
|
||||
return logged(Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers));
|
||||
}
|
||||
final end = res.end ?? data.length - 1;
|
||||
final n = end - res.start + 1;
|
||||
buffer.bytesToViewers += n;
|
||||
session.addBytes(token, n);
|
||||
return Response(res.status,
|
||||
body: Body.fromData(Uint8List.sublistView(data, res.start, end + 1), mimeType: MimeType.octetStream),
|
||||
headers: headers);
|
||||
return logged(
|
||||
Response(res.status,
|
||||
body: Body.fromData(Uint8List.sublistView(data, res.start, end + 1), mimeType: MimeType.octetStream),
|
||||
headers: headers),
|
||||
bytes: n);
|
||||
}
|
||||
final size = (buffer as RelayBuffer).size;
|
||||
final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: r.name);
|
||||
final headers = Headers.build((h) => res.headers.forEach((k, v) => h[k] = [v]));
|
||||
if (res.status != 200 && res.status != 206) {
|
||||
return Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers);
|
||||
return logged(Response(res.status, body: Body.fromString('status ${res.status}'), headers: headers));
|
||||
}
|
||||
final end = res.end ?? size - 1;
|
||||
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||
onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/r/$sid/$shortToken/$key/${track == null ? '' : '$track/'}${r.name}',
|
||||
rangeHeader: rangeHeader,
|
||||
status: res.status,
|
||||
bytesWritten: end - res.start + 1,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
));
|
||||
final Stream<Uint8List> body =
|
||||
buffer.read(token, res.start, end, onSent: (n) => session.addBytes(token, n));
|
||||
final Stream<Uint8List> body = buffer
|
||||
.read(token, res.start, end, onSent: (n) => session.addBytes(token, n))
|
||||
.transform(StreamTransformer.fromHandlers(handleError: (e, st, sink) {
|
||||
_readFails(token, '${r.name} track ${track ?? 0} from ${res.start}: $e');
|
||||
sink.addError(e, st);
|
||||
}));
|
||||
MimeType mime;
|
||||
try {
|
||||
mime = MimeType.parse(res.headers['content-type'] ?? '');
|
||||
} catch (_) {
|
||||
mime = MimeType.octetStream;
|
||||
}
|
||||
return Response(res.status,
|
||||
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
|
||||
headers: headers);
|
||||
return logged(
|
||||
Response(res.status,
|
||||
body: Body.fromDataStream(body, mimeType: mime, contentLength: end - res.start + 1),
|
||||
headers: headers),
|
||||
bytes: end - res.start + 1);
|
||||
}
|
||||
|
||||
// `:seg` is the name of a single file, the track of several (the router
|
||||
@@ -525,3 +550,11 @@ void mountRelayRoutes(
|
||||
}
|
||||
|
||||
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||
|
||||
final _readFailThrottle = LogThrottle(burst: 3);
|
||||
|
||||
/// A viewer's read of a relay broke (the source never sent the bytes).
|
||||
void _readFails(String token, String what) {
|
||||
final pass = _readFailThrottle.allow(token);
|
||||
if (pass != null) _log.warning('${pass}relay read for a viewer broke: $what');
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import 'sync_server.dart';
|
||||
///
|
||||
/// On [events] it reports `{'t':'ready','port':p,'stop':SendPort}` or
|
||||
/// `{'t':'error','message':m}`, and forwards log records as
|
||||
/// `{'t':'log','level','logger','message'[,'error']}`. Sending
|
||||
/// `{'t':'log','level','logger','message'[,'error'][,'stack']}`. Sending
|
||||
/// `{'t':'stop'}` to the `stop` port stops the server; the returned future
|
||||
/// completes once it is down. `{'t':'file','path':p,'reply':SendPort}` makes
|
||||
/// a file on this machine the hosted file (the hosting app's in-session file
|
||||
@@ -29,6 +29,7 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
|
||||
'logger': r.loggerName,
|
||||
'message': r.message,
|
||||
if (r.error != null) 'error': '${r.error}',
|
||||
if (r.stackTrace != null) 'stack': '${r.stackTrace}',
|
||||
}));
|
||||
}
|
||||
final SyncServer server;
|
||||
|
||||
@@ -70,17 +70,32 @@ class ServerHub {
|
||||
/// Replaces the relay (null: the new media is not relayed). The old
|
||||
/// buffer is dropped with it.
|
||||
void setRelay(ActiveRelay? next) {
|
||||
if (!identical(relay, next)) relay?.dispose();
|
||||
if (!identical(relay, next)) {
|
||||
final old = relay;
|
||||
if (old != null) _hubLog.info('relay of "${old.name}" dropped (sent ${_sent(old)})');
|
||||
old?.dispose();
|
||||
}
|
||||
relay = next;
|
||||
}
|
||||
|
||||
static String _sent(ActiveRelay r) =>
|
||||
'${(r.buffers.fold<int>(0, (a, b) => a + b.bytesFromFeed) / 1048576).toStringAsFixed(1)} MiB in';
|
||||
|
||||
/// The warm relay: Up next's first item relayed before it goes up, so
|
||||
/// every viewer preloads it (see ARCHITECTURE "Getting ready"); null
|
||||
/// when that item is not relayed (or nobody would read it).
|
||||
ActiveRelay? warm;
|
||||
|
||||
void _setWarm(ActiveRelay? next) {
|
||||
if (!identical(warm, next)) warm?.dispose();
|
||||
if (!identical(warm, next)) {
|
||||
final old = warm;
|
||||
if (old != null) _hubLog.info('warm relay of Up next ${old.queueId} ("${old.name}") dropped (${_sent(old)})');
|
||||
old?.dispose();
|
||||
if (next != null) {
|
||||
_hubLog.info('warm relay of Up next ${next.queueId} ("${next.name}"): '
|
||||
'${next.fetch ? 'the server fetches it' : next.sourceId.isEmpty ? 'from the first member with an account' : 'from ${next.sourceId}'}');
|
||||
}
|
||||
}
|
||||
warm = next;
|
||||
}
|
||||
|
||||
@@ -214,9 +229,11 @@ class ServerHub {
|
||||
? w.fetch && !w.failed
|
||||
: !w.fetch && w.sourceToken == token && (!w.known || _sameTracks(w.tracks, tracks));
|
||||
if (ok) {
|
||||
_hubLog.info('warm relay of Up next $queueId promoted (${_sent(w)}, live ${w.live})');
|
||||
w.ensureTracks(tracks);
|
||||
return w;
|
||||
}
|
||||
_hubLog.info('warm relay of Up next $queueId not promoted (${w.failed ? 'its fetch failed' : 'another kind or source'})');
|
||||
w.dispose();
|
||||
return null;
|
||||
}
|
||||
@@ -246,11 +263,15 @@ class ServerHub {
|
||||
if (!identical(relay, r) || m?.relay == null) return;
|
||||
final was = m!.relay!;
|
||||
if (was.live == r.live && was.tracks.join(',') == r.tracks.join(',')) return;
|
||||
if (was.live != r.live) _hubLog.info('relay of "${r.name}" ${r.live ? 'live' : 'not live (source away)'}');
|
||||
engine.media = m.copyWith(relay: was.copyWith(live: r.live, tracks: r.tracks));
|
||||
changed();
|
||||
}
|
||||
|
||||
final _readRates = ReadRates();
|
||||
var _statTicks = 0;
|
||||
|
||||
static String _kbs(int bytesPerSec) => '${(bytesPerSec / 1024).round()} KiB/s';
|
||||
|
||||
/// Once a second: each member's read rate (`members[].rate`) and the
|
||||
/// relay's source rate and bitrate (`media.relay.rate` / `need`). Goes
|
||||
@@ -268,6 +289,12 @@ class ServerHub {
|
||||
if (r == null || m == null || was == null || r.mediaId != engine.mediaId) return;
|
||||
r.sampleRate();
|
||||
final rate = r.rate, need = r.need(m.duration);
|
||||
if (++_statTicks % 30 == 0 && r.live) {
|
||||
final readers = engine.members.values.where((x) => x.rate > 0).map((x) => '${x.name} ${_kbs(x.rate)}');
|
||||
_hubLog.info('relay stats "${r.name}": source ${_kbs(rate)}, needs ${_kbs(need)}, '
|
||||
'buffered ${(r.cachedBytes / 1048576).toStringAsFixed(1)} MiB, ${_sent(r)}'
|
||||
'${readers.isEmpty ? '' : '; reading: ${readers.join(', ')}'}');
|
||||
}
|
||||
if (rate != was.rate || need != was.need) {
|
||||
engine.media = m.copyWith(relay: was.copyWith(rate: rate, need: need));
|
||||
}
|
||||
@@ -275,6 +302,14 @@ class ServerHub {
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// A member for the log: `name (id)`, never its token.
|
||||
String who(String token) {
|
||||
final m = engine.members[token];
|
||||
if (m != null) return '${m.name} (${m.id})';
|
||||
final v = session.tokens[token];
|
||||
return v != null ? '${v.name} (no member)' : 'unknown member';
|
||||
}
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
/// still known.
|
||||
EngineMember? resumable(String token) {
|
||||
@@ -365,6 +400,7 @@ class ServerHub {
|
||||
}
|
||||
|
||||
void forgetSocket(String token) {
|
||||
if (engine.members.containsKey(token)) _hubLog.info('leave ${who(token)}');
|
||||
engine.applyLeave(token);
|
||||
session.tokens.remove(token);
|
||||
sockets.remove(token);
|
||||
@@ -383,6 +419,17 @@ class ServerHub {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a swap's media plays, for the log (a link's query cut to names).
|
||||
String _swapKind(HelloMedia m) {
|
||||
final relay = m.relay;
|
||||
final url = (m.url ?? '').isEmpty ? '' : ' ${logUrl(m.url)}';
|
||||
if (m.jellyfin != null) return 'Jellyfin item ${m.jellyfin!.itemId}${relay == null ? '' : ', relayed from the swapper'}';
|
||||
if (relay == null) return url.isEmpty ? 'hosted file' : 'link, everyone fetches it:$url';
|
||||
if (relay.fetch) return 'link, the server fetches it:$url';
|
||||
final tracks = relay.tracks.isEmpty ? 'one file' : relay.tracks.map((t) => t.toJson().values.join('/')).join(' + ');
|
||||
return 'through the swapper ($tracks, ${m.size} bytes)';
|
||||
}
|
||||
|
||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||
/// content hash, for members (valid token) only. The bytes for a
|
||||
/// hash never change, so clients may cache them for good.
|
||||
@@ -519,6 +566,8 @@ void mountSyncRoute(
|
||||
final swap = SwapIntent.fromJson(decoded);
|
||||
var parsed = swap.media;
|
||||
final queueId = swap.queueId;
|
||||
_hubLog.info('swap by ${hub.who(token)}: "${parsed.name}" ${_swapKind(parsed)}'
|
||||
'${queueId == null ? '' : ' (Up next $queueId)'}');
|
||||
if (queueId != null) {
|
||||
// Already put up by someone else: nothing to do.
|
||||
if (!hub.engine.queue.has(queueId)) return;
|
||||
@@ -682,6 +731,12 @@ void mountSyncRoute(
|
||||
case 'report':
|
||||
if (token == null) return;
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
final before = hub.engine.members[token]?.error ?? '';
|
||||
if (report.error != before && report.mediaId == hub.engine.mediaId) {
|
||||
_hubLog.warning(report.error.isEmpty
|
||||
? '${hub.who(token)}: player error cleared'
|
||||
: '${hub.who(token)}: cannot play media ${report.mediaId}: ${report.error}');
|
||||
}
|
||||
hub.engine.applyReport(
|
||||
token, report.position, report.buffered, report.eventId,
|
||||
duration: report.duration, mediaId: report.mediaId, jf: report.jf, error: report.error);
|
||||
@@ -708,8 +763,10 @@ void mountSyncRoute(
|
||||
handleQueueIntent(hub, token, ws, decoded);
|
||||
}
|
||||
} on ProtocolException catch (e) {
|
||||
_hubLog.info('${token == null ? 'a socket' : hub.who(token)}: bad $type message: ${e.message}');
|
||||
wsErr(ws, 'bad message: ${e.message}');
|
||||
} catch (_) {
|
||||
} catch (e, st) {
|
||||
_hubLog.warning('${token == null ? 'a socket' : hub.who(token)}: $type message failed: $e', e, st);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,19 @@ 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;
|
||||
String serverYtDlpDir(String configured) => configured.isNotEmpty ? configured : serverDataDir('yt-dlp');
|
||||
|
||||
/// A writable folder [name] for the dedicated server's own files:
|
||||
/// `$STATE_DIRECTORY/<name>` (systemd), else next to the binary if
|
||||
/// writable, else `~/.cache/watch-together/<name>`.
|
||||
String serverDataDir(String name) {
|
||||
final sep = Platform.pathSeparator;
|
||||
final state = (Platform.environment['STATE_DIRECTORY'] ?? '').split(':').first;
|
||||
if (state.isNotEmpty) return '$state${sep}yt-dlp';
|
||||
if (state.isNotEmpty) return '$state$sep$name';
|
||||
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');
|
||||
final exeName = exe.uri.pathSegments.last.toLowerCase();
|
||||
if (exeName != 'dart' && exeName != 'dart.exe' && exeName != 'dartaotruntime') {
|
||||
final dir = Directory('${exe.parent.path}$sep$name');
|
||||
try {
|
||||
dir.createSync(recursive: true);
|
||||
final probe = File('${dir.path}$sep.writable')..writeAsStringSync('');
|
||||
@@ -32,7 +36,7 @@ String serverYtDlpDir(String configured) {
|
||||
} catch (_) {}
|
||||
}
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.';
|
||||
return '$home$sep.cache${sep}watch-together${sep}yt-dlp';
|
||||
return '$home$sep.cache${sep}watch-together$sep$name';
|
||||
}
|
||||
|
||||
class SyncServer {
|
||||
@@ -93,12 +97,15 @@ class SyncServer {
|
||||
})),
|
||||
);
|
||||
});
|
||||
mountSessionMediaRoute(_app, sessions);
|
||||
mountRelayRoutes(_app, sessions, relayAt: hub.relayAt, sourceOf: hub.sourceOf);
|
||||
final requests = MediaRequestLog();
|
||||
void logRequest(String token, MediaLog log) => requests(hub.who(token), log);
|
||||
mountSessionMediaRoute(_app, sessions, onLog: logRequest);
|
||||
mountRelayRoutes(_app, sessions, relayAt: hub.relayAt, sourceOf: hub.sourceOf, onLog: logRequest);
|
||||
mountAvatarRoute(_app, hub);
|
||||
mountArtRoute(_app, hub);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(), next: () => hub.nextJellyfinRef);
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(),
|
||||
next: () => hub.nextJellyfinRef, onLog: logRequest);
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
String authority(String dialledHost) => config.advertiseHost.isNotEmpty
|
||||
? '${config.advertiseHost}:$boundPort'
|
||||
|
||||
Reference in new issue
Block a user