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:
foxyzandClaude Opus 5.5 committed 2026-10-12 19:05:32 +03:00
1 parent 572104f49e
commit 4ab37c55cf
25 files changed
+1124 -108

No files matched your search

+60 -3
View File
@@ -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;
}
}