watchparty/server/src/partyManager.js
Nicolas 07613c1cec
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Redesign home page: library tabs, poster tiles, ongoing parties list
- New backend endpoints: GET /api/catalog/libraries (Emby's
  /Users/{id}/Views), GET /api/catalog/libraries/:id/items (top-level,
  Recursive=false - movies as movies, TV as Series tiles), GET
  /api/parties (list every active party server-wide, for the home
  page's "watch parties en cours" section)
- Item DTO gained `type` and `canWatchParty` (only Movie/Episode/Video
  are directly streamable - a Series tile only gets "Voir sur Emby")
  and `embyUrl`, a deep link into Emby's own web client built from
  EMBY_SERVER_URL + serverId (now captured at login)
- Party creation now fetches and stores the item's name/poster so the
  home page's party list doesn't need a separate lookup per party
- client/js/views/browse.js rewritten: ongoing-parties section, a
  join-by-code fallback, one tab per library, and a tile grid with a
  hover-revealed two-button overlay (start party / view on Emby) - the
  first only shown for playable types
- Removed itemDetail.js/the /item/:id route (tiles act directly now,
  no intermediate detail page); player.js simplified to use
  party.itemName instead of a redundant item lookup
- app.css: full-width home layout (no max-width cap, as asked), CSS
  grid handling all the responsive reflow, :hover/:focus-within reveal
  for the tile overlay with an always-visible (hover: none) fallback
  for touch devices

Verified by actually driving a browser (mocked fetch standing in for
/api/parties and /api/catalog/* - real login still needs real
credentials): tab switching swaps content correctly, the overlay
reveals per-tile only, a Series tile correctly omits the watch-party
button, and the touch fallback shows both buttons inline without
hover. See docs/architecture.md for exactly what that does and doesn't
confirm (real Emby /Views and item-list response shapes are still
unverified).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:54:14 +02:00

139 lines
4.1 KiB
JavaScript

'use strict';
const crypto = require('crypto');
// No 0/O/1/I - easy to read aloud / type on a phone.
const JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function generateJoinCode() {
const bytes = crypto.randomBytes(6);
let code = '';
for (let i = 0; i < 6; i++) {
code += JOIN_CODE_ALPHABET[bytes[i] % JOIN_CODE_ALPHABET.length];
}
return code;
}
/**
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
* from the earlier Emby-plugin attempt (see git history / docs/architecture.md),
* adapted for a model where we own the player: one host drives playback,
* everyone else's client just applies whatever the host reports. Nothing is
* persisted to disk; a server restart clears every party, which is fine - a
* party is meant to live for one watch session, not persist for weeks.
*/
class PartyManager {
constructor() {
this.partiesById = new Map();
this.partyIdByJoinCode = new Map();
}
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) {
const party = {
id: crypto.randomUUID(),
joinCode: generateJoinCode(),
itemId,
itemName,
itemImageUrl,
hostUserId,
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
// userId -> { userId, userName, connection, isPaused, positionTicks, lastUpdatedUtc }
members: new Map()
};
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
this.partiesById.set(party.id, party);
this.partyIdByJoinCode.set(party.joinCode, party.id);
return party;
}
getById(partyId) {
return this.partiesById.get(partyId) || null;
}
getByJoinCode(joinCode) {
const id = this.partyIdByJoinCode.get((joinCode || '').toUpperCase());
return id ? this.getById(id) : null;
}
join(party, userId, userName) {
const member = this._newMember(userId, userName);
party.members.set(userId, member);
party.lastActivityUtc = Date.now();
return member;
}
leave(party, userId) {
party.members.delete(userId);
party.lastActivityUtc = Date.now();
if (party.hostUserId === userId) {
// Host left: promote whoever's left (arbitrary but deterministic-ish;
// good enough for v1 - matches the old plugin's reference-fallback logic).
const next = party.members.keys().next();
party.hostUserId = next.done ? null : next.value;
}
if (party.members.size === 0) {
this.partiesById.delete(party.id);
this.partyIdByJoinCode.delete(party.joinCode);
}
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive() {
return Array.from(this.partiesById.values());
}
removeIdleParties(idleTimeoutMs) {
const cutoff = Date.now() - idleTimeoutMs;
for (const party of this.partiesById.values()) {
if (party.lastActivityUtc < cutoff) {
this.partiesById.delete(party.id);
this.partyIdByJoinCode.delete(party.joinCode);
}
}
}
/**
* `viewerUserId` lets the response say "youAreHost" instead of the client
* having to know its own Emby user id to figure that out itself - now that
* auth is a server-side session (see embyAuth.js), the client doesn't hold
* an id to compare with at all.
*/
toPublicState(party, viewerUserId) {
const host = party.members.get(party.hostUserId);
return {
partyId: party.id,
joinCode: party.joinCode,
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
isPaused: m.isPaused,
positionTicks: m.positionTicks,
connected: !!m.connection
}))
};
}
_newMember(userId, userName) {
return {
userId,
userName,
connection: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()
};
}
}
module.exports = { PartyManager: new PartyManager() };