SyncPlay is 21 of 22 routes unwritten, and the one that answers is a stub #198
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
21 of SyncPlay's 22 documented operations are unwritten. The one answered is
the listing, stubbed so the client's group picker renders empty rather than
throwing:
Everything else —
New,Join,Leave,Play/Pause/Unpause,Seek,Buffering,Ready,Ping,SetNewQueue,SetPlaylistItem,MovePlaylistItem,RemoveFromPlaylist,Queue,NextItem,PreviousItem,SetRepeatMode,SetShuffleMode,SetIgnoreWait, andGET /SyncPlay/{id}—is a
404.MILESTONES.mdlists it under explicitly out of scope with thecount as the reason.
What it actually costs
~7,000 lines upstream across
SyncPlaypaths, and the controller is the smallpart of it:
Emby.Server.Implementations/SyncPlay/Group.csMediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.csMediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.csJellyfin.Api/Controllers/SyncPlayController.csEmby.Server.Implementations/SyncPlay/SyncPlayManager.csThe shape is a state machine per group —
GroupStates/{Idle,Paused,Playing,Waiting}GroupState.csoverAbstractGroupState.cs— with a play queue beside it, and it drives clientsover the websocket rather than over the response:
SyncPlayCommandMessageinMediaBrowser.Controller/Net/WebSocketMessages/Outbound/. Nothing incrates/ephyra-http/src/routes/session/socket.rssends one today.Why it is not just another controller
Every other piece of state this server holds is either a database row or the
session registry. A SyncPlay group is a third: long-lived, mutable, shared
between accounts, and correct only if every member's clock is reconciled — the
Waitingstate is 681 lines because of exactly that.ARCHITECTURE.md's ruleabout use cases holding state rather than ports is where it would land, beside
the session registry (
MILESTONES.mdM7 records that argument).It is also the one feature on this list that cannot be diffed the usual way:
the harness replays one client against two servers, and a group needs at least
two clients whose timing is the thing under test.
MILESTONES.md's risk tablealready names a second client has never been done.
If it is taken on
Suggested order, smallest observable thing first:
New,Join,Leave,List,GET /{id}—with no playback state at all. That alone makes the picker real and is
testable against upstream with one client.
PlayQueueManageras a pure type, which is a queue with repeat and shuffleand no timing in it.
client becomes unavoidable.
Deleting the
/SyncPlay/Liststub fromabsent.rsis part of step 1.Steps 1 and 2 are done in
d5019e7c. Step 3 is not, so this stays open.What answers now
New,Join,Leave,ListandGET /SyncPlay/{id}.SyncPlayis a usecase holding state beside the session registry — the third kind of state here
after the database and the sessions — and it composes no messages: it answers
Told { sessions, message }and the adapter turns each into aSyncPlayGroupUpdateor aSyncPlayCommand.GroupJoined,UserJoined,GroupLeft,UserLeft,NotInGroupandGroupDoesNotExistgo out over thesocket in wire shapes captured from a live upstream socket. Upstream's three
access policies are types beside the others in
auth.rs, and a session whoselast socket closes leaves its group — all five
sessions_endedcall sites gothrough one function now, so a route cannot file the audit line and forget the
group.
PlayQueueManageris ported whole with 33 unit tests and wired to nothing,which is where step 3 picks it up.
Measured
Two live servers side by side — upstream on 8140 over a
cp -asnapshot copy,ours on 8141 over a copy of the same state — 44 requests across three accounts
holding the three values of
SyncPlayAccess. Statuses, bodies, participants,access refusals, the
400for a caller naming no account, the404for anon-guid segment and the case-insensitive spellings all agree.
Nine mutations were applied and caught. A tenth was not: dropping the
api_key || !accountguard inauth.rschanged nothing, because the rowlookup below it already covered every case. The guard was dead and went rather
than earning a test for a branch that cannot differ.
What a user gets, stated plainly
A group can be entered and playback in it does not sync. The seventeen
playback routes are unwritten, so
Ping,Ready,Buffering,SetNewQueue,Pause,Unpause,SeekandStopall answer405— the web client callsevery one of them once it is in a group. That is a knowing trade: the picker
is real and diffed, and the timing half is the part that needs a second client
to test honestly.
Divergences, each with its reason
Idle. No playback state exists, so it never leavesthe state a new one starts in; upstream moves a group opened by a session
that is already playing to
Waiting.{"GroupName":null}and{"GroupId":"zz"}answer400as plain text whereupstream writes a validation
ProblemDetails— the body classjson.rsalready records as accepted.
GETonNew/Join/Leaveanswers405withAllow: GET, POSTagainstupstream's
Allow: POST: upstream constrains/SyncPlay/{id}to a guid andthe route table cannot, so the id route's
GETjoins the union.IdleGroupState.SessionJoinedinfull, one command to the joining session against the group's own last
activity, with no timing reconciliation in it. Without it a client that joins
while playing something keeps playing.
Not done
No click-through: the two-server differential was judged the stronger evidence.
The claim that the picker is real rests on the routes and socket messages
agreeing with upstream, not on having watched it in a browser.
Step 3 is done in
d689b605, so all 22 operations answer.crates/ephyra-app/src/session/sync_play/group.rsis the state machine:Idle/Waiting/Paused/Playingas one enum carrying the fields upstreamputs on its state objects, with
prevthreaded through exactly as upstream'sprevStateis.PlayQueueManager— ported in step 2 and wired to nothing — iswired now.
The group adopts what the session opening it is playing, which replaces the
stopgap landed earlier: the queue, the item, its runtime and the position come
over and the group enters
Waitingwithresume_playingset from whether thatsession was paused. No stop command is sent. The
playing: boolfield and thecomment about leaving a playing session alone are gone.
The lock still holds nothing across an
.awaitand the registry still composesno messages: the two library facts the machine needs — item runtimes, and
whether every member may see a queue — are resolved by the adapter and handed
in.
ARCHITECTURE.mdrecords that edge.Measured
Pinned upstream and ours side by side, two clients with two websockets each,
53 requests reaching all four states plus a separate adoption scenario. Dates
compared with a tolerance, and a command scheduled against an earlier group
activity compared by which step it points at rather than by its absolute value.
52 of 53 agree. The one that does not is
SetShuffleMode: Shuffle: bothput the playing entry at index 0 and report the same multiset, the same mode
and the same position, and the rest of the order differs — which is what a
shuffle is. Adoption was diffed separately, playing and paused, and is
identical in both.
Fourteen mutations were applied and all fourteen caught. The thirteenth
exposed a gap in where a test lived rather than in the code: the access test
sat in
ephyra-app, where the permission is a parameter, so breaking theadapter's wiring changed nothing. It moved to the HTTP layer with a joiner the
parental rating blocks, and both adapter mutations are caught now.
What was not tested, stated rather than implied
Three or more clients, and genuinely divergent real clocks — both clients ran
on one host, so the clock offset was exercised by lying about
Whenin arequest body rather than by two machines disagreeing. The arithmetic and the
branch selection are verified; that real drifting players converge is not.
Nothing played media either: every
ReadyandBufferingreport wassynthetic, so whether the web client is happy with these commands is untested.
Divergences, each with its reason
UtcNowseveraltimes while handling one. Measured effect: upstream reported a start
position of 13 ticks — 1.3 µs between two reads — where ours reports 0. It
is what makes the machine testable without a fake clock inside it.
!IsPlaying → AllGroupfilter in the recovery branch is nearlydead: it needs a delay beyond twice the highest ping and within 500 ms at
once, which happens only when a client reports a ping of zero. Reproduced
faithfully, and a test reaches it.
/SyncPlay/{id}'sAllowheader stays wider than upstream's, which is step1's difference and already recorded there.
One thing this found and did not fix
A playing session's position stands still here between reports where upstream's
advances on a timer, which is what made the first adoption diff disagree.
That belongs to the session registry rather than to SyncPlay and is filed as
#201.