Merge remote-tracking branch 'origin/master' into fix/track-image-albumid-fallback
# Conflicts: # README.md # docs/architecture.md
This commit is contained in:
commit
6bec09ae20
8 changed files with 187 additions and 6 deletions
|
|
@ -395,6 +395,10 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
- [x] Linked whatever's playing (video or music) to the OS's own media
|
- [x] Linked whatever's playing (video or music) to the OS's own media
|
||||||
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
|
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
|
||||||
play/pause/seek/next — see docs/architecture.md
|
play/pause/seek/next — see docs/architecture.md
|
||||||
|
- [x] Fixed switching a party between devices leaving both "on the
|
||||||
|
session" with no working controls — the old device never reacted
|
||||||
|
to being kicked, and the leave route had no way to tell a stale
|
||||||
|
device apart from the current one — see docs/architecture.md
|
||||||
- [x] Fixed a track started directly (not via its album/artist) never
|
- [x] Fixed a track started directly (not via its album/artist) never
|
||||||
falling back to its album's artwork — see docs/architecture.md
|
falling back to its album's artwork — see docs/architecture.md
|
||||||
(probably not the whole "images are hard to find" story — flagged
|
(probably not the whole "images are hard to find" story — flagged
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,14 @@ export function joinParty(partyId: string): Promise<PartyPublicState> {
|
||||||
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends this device's own deviceId so the server can tell a stale
|
||||||
|
* device (already replaced by a takeover on another one - see
|
||||||
|
* getDeviceId's own comment) apart from the currently-active one, the
|
||||||
|
* same way the socket "join"/"leave"/"disconnect" handlers already do
|
||||||
|
* via the live connection itself - a REST call has no connection to
|
||||||
|
* compare, so this is what lets the server apply that same guard here. */
|
||||||
export function leaveParty(partyId: string): Promise<{ ok: true }> {
|
export function leaveParty(partyId: string): Promise<{ ok: true }> {
|
||||||
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST', body: JSON.stringify({ deviceId: getDeviceId() }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParty(partyId: string): Promise<PartyPublicState> {
|
export function getParty(partyId: string): Promise<PartyPublicState> {
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,24 @@ export function joinMusicParty(partyId: string, party: PartyPublicState): void {
|
||||||
else leaveMusicParty();
|
else leaveMusicParty();
|
||||||
},
|
},
|
||||||
onError: (message) => showToast(message, { variant: 'error' }),
|
onError: (message) => showToast(message, { variant: 'error' }),
|
||||||
onClose: () => {}
|
// Fires for every disconnect, intentional or not - leaveMusicParty
|
||||||
|
// itself already clears activePartyId synchronously, well before its
|
||||||
|
// own conn.leave() actually closes the socket, so seeing it still set
|
||||||
|
// here means this device's connection died some other way (most
|
||||||
|
// often: replaced by a takeover on another device - see
|
||||||
|
// SocketGateway's "join" handler). Without this, the bar just sat
|
||||||
|
// there looking/sounding fully live forever on the old device, on a
|
||||||
|
// conn that was actually already dead underneath it - reported
|
||||||
|
// directly as "both devices end up on the session, controls never
|
||||||
|
// transfer." leaveMusicParty() is safe to call here even though the
|
||||||
|
// party already moved on: leaveParty's own deviceId check (see
|
||||||
|
// api/party.ts) makes its REST call a no-op for a stale device
|
||||||
|
// instead of ending the CURRENT device's membership by mistake.
|
||||||
|
onClose: () => {
|
||||||
|
if (!activePartyId) return;
|
||||||
|
showToast('Connexion à la party interrompue.', { variant: 'error' });
|
||||||
|
leaveMusicParty();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,14 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
|
|
||||||
let isHost = false;
|
let isHost = false;
|
||||||
let hasNextQueued = false;
|
let hasNextQueued = false;
|
||||||
|
// Set at the top of cleanup() below, which every intentional way of
|
||||||
|
// leaving this route (the "Quitter" button, a sidebar link, browser
|
||||||
|
// back, ...) already runs synchronously before the socket actually
|
||||||
|
// closes - so by the time the async "disconnect" this connection's
|
||||||
|
// own onClose reacts to actually arrives, this is already true for
|
||||||
|
// every one of those. Still false there only means an unprompted
|
||||||
|
// close (most often: a device takeover elsewhere - see onClose below).
|
||||||
|
let leftIntentionally = false;
|
||||||
let conn: PartyConnection | null = null;
|
let conn: PartyConnection | null = null;
|
||||||
let hls: HlsInstance | null = null;
|
let hls: HlsInstance | null = null;
|
||||||
let currentItemId: string | null = null;
|
let currentItemId: string | null = null;
|
||||||
|
|
@ -1277,7 +1285,23 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
else navigate('/home');
|
else navigate('/home');
|
||||||
},
|
},
|
||||||
onError: (message) => showToast(message, { variant: 'error' }),
|
onError: (message) => showToast(message, { variant: 'error' }),
|
||||||
onClose: () => {}
|
// Fires for every disconnect, intentional or not - leftIntentionally
|
||||||
|
// is already true by the time this runs for any of those (see its
|
||||||
|
// own comment), so still false here means an unprompted close, most
|
||||||
|
// often a device takeover elsewhere (SocketGateway's "join" handler)
|
||||||
|
// - without this, the video/hls.js instance just kept running
|
||||||
|
// forever on the old device, on a conn that was actually already
|
||||||
|
// dead underneath it, reported directly as "both devices end up on
|
||||||
|
// the session, controls never transfer." navigate() runs this same
|
||||||
|
// route's cleanup() below, which is now safe to do even though the
|
||||||
|
// party already moved on - leaveParty's own deviceId check (see
|
||||||
|
// api/party.ts) makes its REST call a no-op for a stale device
|
||||||
|
// instead of ending the CURRENT device's membership by mistake.
|
||||||
|
onClose: () => {
|
||||||
|
if (leftIntentionally) return;
|
||||||
|
showToast('Connexion à la party interrompue.', { variant: 'error' });
|
||||||
|
navigate('/home');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1291,6 +1315,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
// other way (sidebar link, browser back, ...). This is the fix for the
|
// other way (sidebar link, browser back, ...). This is the fix for the
|
||||||
// actual leak: previously, only the "Quitter" button ran any of this.
|
// actual leak: previously, only the "Quitter" button ran any of this.
|
||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
|
leftIntentionally = true;
|
||||||
if (heartbeat) clearInterval(heartbeat);
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
if (progressTicker) clearInterval(progressTicker);
|
if (progressTicker) clearInterval(progressTicker);
|
||||||
if (requestBannerTimer) clearTimeout(requestBannerTimer);
|
if (requestBannerTimer) clearTimeout(requestBannerTimer);
|
||||||
|
|
|
||||||
|
|
@ -4415,6 +4415,69 @@ call sites already gives), and the actual OS-level popup itself - the
|
||||||
whole point - isn't something this sandbox can display or click
|
whole point - isn't something this sandbox can display or click
|
||||||
regardless. Worth a real Windows/Chrome check once deployed.
|
regardless. Worth a real Windows/Chrome check once deployed.
|
||||||
|
|
||||||
|
## Device takeover: both devices "on the session", neither one's controls working
|
||||||
|
|
||||||
|
Reported directly: switching a watch/listen party from one device to
|
||||||
|
another left both devices looking active, with no controls actually
|
||||||
|
transferring. The device-takeover mechanism itself - `SocketGateway`'s
|
||||||
|
"join" handler force-disconnecting the old device's socket once the new
|
||||||
|
one confirms - was already correct and already guarded against the
|
||||||
|
obvious failure mode (its own comment on the "leave"/"disconnect"
|
||||||
|
handlers: a stale connection's own eventual disconnect must not be
|
||||||
|
mistaken for a real leave). The actual bug was two gaps *around* that,
|
||||||
|
neither in the takeover mechanism itself.
|
||||||
|
|
||||||
|
**Gap 1 - the old device never reacted to being kicked.** Both
|
||||||
|
`Player.ts` and `MusicBar.ts` wired `onClose: () => {}` - a complete
|
||||||
|
no-op - to the connection's "disconnect" event. So the moment a takeover
|
||||||
|
force-disconnected the old device's socket, that device's own video/
|
||||||
|
hls.js instance (or the persistent music bar) just kept running exactly
|
||||||
|
as before: the socket was dead, but nothing told the local `<video>`/
|
||||||
|
`<audio>` to stop, and nothing navigated away or reset `isHost`. From
|
||||||
|
the old device's own screen, everything still looked (and for local-only
|
||||||
|
playback, sounded) fully live - exactly "both devices on the session."
|
||||||
|
Fixed: both now react to an *unprompted* close (one that didn't already
|
||||||
|
run this route/module's own explicit teardown - `leftIntentionally` in
|
||||||
|
Player.ts, `activePartyId` already being null in MusicBar.ts, both set
|
||||||
|
synchronously by that explicit teardown well before the async
|
||||||
|
"disconnect" this checks against ever arrives) by tearing down local
|
||||||
|
playback and leaving, the same as if the user had clicked "Quitter"
|
||||||
|
there themselves.
|
||||||
|
|
||||||
|
**Gap 2 - reacting to the kick had no safe way to do that.** The obvious
|
||||||
|
fix for gap 1 - call the same leave path "Quitter" already uses - ran
|
||||||
|
straight into a second, worse bug: `POST /api/parties/:partyId/leave`
|
||||||
|
removes a member by `userId` alone, with no equivalent of the socket
|
||||||
|
handlers' own staleness guard (there's no live connection for a REST
|
||||||
|
call to compare against). Calling it from the *old*, superseded device
|
||||||
|
would have ended the *new* device's membership too - silently undoing
|
||||||
|
the takeover it was supposed to be cleaning up after. Fixed by giving
|
||||||
|
this route the same guard via `deviceId` instead of a socket reference:
|
||||||
|
new `PartyService.isStaleDevice(member, deviceId)`, and `leaveParty()`
|
||||||
|
(client) now sends its own `getDeviceId()` for the server to compare.
|
||||||
|
Only ever blocks on positive evidence of a mismatch - a member with no
|
||||||
|
recorded deviceId yet, or a caller that sent none, still leaves exactly
|
||||||
|
as before this existed.
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` on both projects (153/153
|
||||||
|
server tests - new coverage for `isStaleDevice` covering a matching
|
||||||
|
deviceId, a mismatched one, an unset member deviceId, an unset caller
|
||||||
|
deviceId, and an unknown member, each checked against the *reason* it
|
||||||
|
should or shouldn't block, not just the outcome). The client-side
|
||||||
|
onClose fix was checked against the exact reported scenario, not just
|
||||||
|
reasoned about: a fake `io()` standing in for Socket.IO (real `.on`/
|
||||||
|
`.emit`/a scriptable `.disconnect()` that fires real listeners) let
|
||||||
|
`joinMusicParty`/`leaveMusicParty` run for real. Firing `disconnect`
|
||||||
|
right after a real `leaveMusicParty()` call produced no toast and left
|
||||||
|
the bar hidden (no spurious "connection lost" message on an intentional
|
||||||
|
leave); firing it with no prior leave - the takeover-kick shape -
|
||||||
|
produced the "Connexion à la party interrompue" toast and correctly
|
||||||
|
tore the bar down. Player.ts's own `leftIntentionally` fix mirrors this
|
||||||
|
exact shape (confirmed by reading, not re-verified with a second live
|
||||||
|
harness - hls.js/a full player DOM would need mocking for no more
|
||||||
|
signal than the MusicBar case already gives, given both share the same
|
||||||
|
logic shape end to end).
|
||||||
|
|
||||||
## A track started directly never fell back to its album's artwork
|
## A track started directly never fell back to its album's artwork
|
||||||
|
|
||||||
Reported directly: the app struggles to find artist/album/track images.
|
Reported directly: the app struggles to find artist/album/track images.
|
||||||
|
|
|
||||||
|
|
@ -615,10 +615,17 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
if (party) {
|
if (party) {
|
||||||
|
const member = party.members.get(req.embySession!.userId);
|
||||||
|
const deviceId = typeof req.body?.deviceId === 'string' ? req.body.deviceId : null;
|
||||||
|
// See PartyService.isStaleDevice's own comment for why this check
|
||||||
|
// exists at all - a REST call has no live connection to guard this
|
||||||
|
// with, unlike the socket "leave"/"disconnect" handlers.
|
||||||
|
if (!PartyService.isStaleDevice(member, deviceId)) {
|
||||||
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
||||||
// handoff notification and the lobby refresh only live in one place.
|
// handoff notification and the lobby refresh only live in one place.
|
||||||
SocketGateway.applyLeave(io, party, req.embySession!.userId);
|
SocketGateway.applyLeave(io, party, req.embySession!.userId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,30 @@ class PartyServiceClass {
|
||||||
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused };
|
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request claiming to be `deviceId` is stale for `member` -
|
||||||
|
* already replaced by a takeover on a *different* device (see
|
||||||
|
* SocketGateway's "join" handler). Used by the REST /leave route
|
||||||
|
* (index.ts), which - unlike the socket "leave"/"disconnect" handlers,
|
||||||
|
* guarded by comparing `member.connection` against the live socket
|
||||||
|
* itself - has no connection to compare against instead. Without this,
|
||||||
|
* a device already superseded by a takeover elsewhere could still call
|
||||||
|
* /leave and end the CURRENT device's membership by mistake - confirmed
|
||||||
|
* as the real cause of a "both devices end up on the session, controls
|
||||||
|
* never transfer" report: the stale device's own client didn't react to
|
||||||
|
* being disconnected at all (see Player.ts/MusicBar.ts's onClose), so
|
||||||
|
* its still-rendered "Quitter" button stayed clickable and reached here.
|
||||||
|
*
|
||||||
|
* Only ever returns true on positive evidence of a mismatch (both sides
|
||||||
|
* known and different) - a member with no recorded deviceId yet, or a
|
||||||
|
* caller that didn't send one, is never treated as stale, mirroring the
|
||||||
|
* socket handlers' own "nothing to conflict with yet" reasoning for a
|
||||||
|
* null deviceId.
|
||||||
|
*/
|
||||||
|
isStaleDevice(member: PartyMember | undefined, deviceId: string | null): boolean {
|
||||||
|
return !!(member?.deviceId && deviceId && member.deviceId !== deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a member. If they were the host, promotes whoever's left
|
* Removes a member. If they were the host, promotes whoever's left
|
||||||
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,41 @@ describe('PartyService', () => {
|
||||||
assert.equal(called, false);
|
assert.equal(called, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false when the deviceId matches the member\'s own', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!;
|
||||||
|
alice.deviceId = 'device-a';
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): true when the deviceId differs from the member\'s own - the actual takeover case', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!;
|
||||||
|
alice.deviceId = 'device-a'; // device B's takeover already moved this forward
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, 'device-b'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false when the member has no recorded deviceId yet - nothing to conflict with, same reasoning as the socket "join" handler', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!; // deviceId still null - no socket "join" has succeeded yet
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false when the caller sent no deviceId at all - only ever blocks on positive evidence of a mismatch', () => {
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const alice = party.members.get('u-alice')!;
|
||||||
|
alice.deviceId = 'device-a';
|
||||||
|
|
||||||
|
assert.equal(PartyService.isStaleDevice(alice, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStaleDevice(): false for an unknown member (already left some other way)', () => {
|
||||||
|
assert.equal(PartyService.isStaleDevice(undefined, 'device-a'), false);
|
||||||
|
});
|
||||||
|
|
||||||
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
||||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue