Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -75,6 +75,10 @@ class ActiveRelay {
|
||||
RelicWebSocket? _channel;
|
||||
var _channelFeeds = const <ChannelRelayFeed>[];
|
||||
List<_TrackInfo>? _infos;
|
||||
/// The source's first `info` on this relay: a later channel (the source
|
||||
/// back within its grace) must send the same one, else the relay ends
|
||||
/// (4002).
|
||||
_SourceInfo? _firstInfo;
|
||||
/// Shared by the tracks: time any of them waits on the source.
|
||||
final clock = FeedClock();
|
||||
final _rate = SourceRate();
|
||||
@@ -295,7 +299,9 @@ class ActiveRelay {
|
||||
|
||||
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
|
||||
final old = _channel;
|
||||
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
|
||||
// A second channel for the same relay replaces the first (the
|
||||
// source's app reconnected before the old link died).
|
||||
if (old != null && !identical(old, ws)) unawaited(_closeWith(old, CloseCodes.relayReplaced, 'replaced'));
|
||||
_channel = ws;
|
||||
final named = tracks.isNotEmpty;
|
||||
final feeds = _channelFeeds = [
|
||||
@@ -317,6 +323,33 @@ class ActiveRelay {
|
||||
}
|
||||
}
|
||||
|
||||
/// A source's `info` as a whole: its tracks, and the optional `name` and
|
||||
/// `duration` it gives. Two are the same media when the tracks agree
|
||||
/// (kinds, byte sizes, piece shapes) and the name and duration agree
|
||||
/// where both give one.
|
||||
typedef _SourceInfo = ({List<_TrackInfo> tracks, String name, double duration});
|
||||
|
||||
bool _sameInfo(_SourceInfo a, _SourceInfo b) {
|
||||
if (a.tracks.length != b.tracks.length) return false;
|
||||
for (var i = 0; i < a.tracks.length; i++) {
|
||||
final x = a.tracks[i], y = b.tracks[i];
|
||||
if (x.kind != y.kind || x.size != y.size || (x.pieces == null) != (y.pieces == null)) return false;
|
||||
if (x.pieces != null && !x.pieces!.sameShape(y.pieces!)) return false;
|
||||
}
|
||||
if (a.name.isNotEmpty && b.name.isNotEmpty && a.name != b.name) return false;
|
||||
if (a.duration > 0 && b.duration > 0 && (a.duration - b.duration).abs() > 0.5) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
_SourceInfo _sourceInfo(Map j, List<_TrackInfo> tracks) {
|
||||
final d = j['duration'];
|
||||
return (
|
||||
tracks: tracks,
|
||||
name: j['name'] is String ? j['name'] as String : '',
|
||||
duration: d is num && d.isFinite && d > 0 ? d.toDouble() : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// The tracks of a channel's `info` frame (`size`, or `tracks[]` each
|
||||
/// with a `size` or, `"as":"pieces"`, a piece list), or null when
|
||||
/// malformed.
|
||||
@@ -342,9 +375,11 @@ List<_TrackInfo>? _infoTracks(Map j) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
Future<void> _closeNotSource(RelicWebSocket ws) => _closeWith(ws, CloseCodes.relayNotSource, 'not the source');
|
||||
|
||||
Future<void> _closeWith(RelicWebSocket ws, int code, String reason) async {
|
||||
try {
|
||||
await ws.close(relayCloseNotSource, 'not the source');
|
||||
await ws.close(code, reason);
|
||||
} catch (_) {
|
||||
await ws.tryClose();
|
||||
}
|
||||
@@ -357,12 +392,15 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
|
||||
/// with tracks (a pieces track's media playlist), and
|
||||
/// `.../<key>/<track>/p/<i>` piece `i` of a pieces track. `<key>` is the
|
||||
/// media id, or the queue id of a warm (or promoted) relay. [relayAt]
|
||||
/// finds the relay of a key, [sourceOf] the relay a token may feed.
|
||||
/// finds the relay of a key, [sourceOf] the relay a token may feed;
|
||||
/// [onOtherMedia] hears of a source back with another media than its
|
||||
/// first `info` (the channel is closed with 4002; the relay should end).
|
||||
void mountRelayRoutes(
|
||||
RelicApp app,
|
||||
SessionMap sessions, {
|
||||
required ActiveRelay? Function(String key) relayAt,
|
||||
required ActiveRelay? Function(String token, String? queueId) sourceOf,
|
||||
void Function(ActiveRelay relay)? onOtherMedia,
|
||||
void Function(String token, MediaLog log)? onLog,
|
||||
}) {
|
||||
app.get('/r/:sid/:token/source', (final req) {
|
||||
@@ -397,12 +435,22 @@ void mountRelayRoutes(
|
||||
case 'info':
|
||||
if (attached) continue;
|
||||
final infos = _infoTracks(j);
|
||||
final first = r._firstInfo;
|
||||
if (infos != null && first != null && !_sameInfo(first, _sourceInfo(j, infos))) {
|
||||
// The source came back with another media: the relay
|
||||
// ends (its buffer is of the first one).
|
||||
_log.warning('relay channel: the source is back with another media (${r.name}); the relay ends');
|
||||
await _closeWith(ws, CloseCodes.relayOtherMedia, 'other media');
|
||||
onOtherMedia?.call(r);
|
||||
return;
|
||||
}
|
||||
if (infos == null || !r._channelInfo(infos)) {
|
||||
// Not the media this swap named: the source stops.
|
||||
_log.warning('relay channel: info does not match the media (${r.name})');
|
||||
await _closeNotSource(ws);
|
||||
return;
|
||||
}
|
||||
r._firstInfo ??= _sourceInfo(j, infos);
|
||||
attached = true;
|
||||
_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(' + ')})');
|
||||
|
||||
Reference in new issue
Block a user