Continue watching en live status
Twee onafhankelijke mechanismen met één ding gemeen: geen van beide wordt bij het lezen afgeleid. De continue-watching-lijst is een voorberekende tabel; live status is een fanout van in-memory registries.
Continue watching (recentlyWatched)
Zie het continue-watching-flow-diagram. De
continue_watching-tabel (migratie V20) bevat één rij per user per container — show / film / boek
/ comicserie / podcastaflevering (group_id) — die wijst naar het item om mee te hervatten. Bij
comics is de container de serie en de target het volgende volume
(ContinueWatchingService.upsertComicVolume; recomputeForComicSeries laat de serie terugkeren
wanneer de scanner een volume toevoegt).
ContinueWatchingService (database-module) is de eigenaar; de GraphQL-query recentlyWatched is
één geïndexeerde read.
- Incrementeel, zelfde transactie.
onWatchStatusChanged(watchStatus)wordt aangeroepen binnen de transactie van elke watch-status-write (PlayQueueService.updateWatchStatus,BookController.updateReadingProgress,ReadingProgressController,PlaybackHistoryService.markPlayed), zodat cache en waarheid samen committen — geen event ertussen. Elk nieuw codepad dat eenWatchStatusEntityschrijft moet dit aanroepen, anders loopt de lijst achter tot de nachtelijke rebuild. - Overdracht bij uitkijken. Een onafgemaakt item hervat zichzelf; een uitgekeken item draagt
over aan de volgende ongekeken episode/chapter, gevonden met één geïndexeerde query
(
EpisodeRepository.findNextUnwatchedEpisodeId,ChapterRepository.findNextUnfinishedChapterId) — nooit door een hele show te laden. - Watched is grens-bewust. Progress is altijd absoluut binnen het mediabestand. Een item telt
als gekeken zodra de heartbeat binnen een minuut van — of voorbij — de eindpositie van het item
komt: normaal het einde van het bestand, voor een aflevering in een multi-episode-bestand
(
s04e06-e07.mkv, zie hoofdstuk 2) de eigen slicegrens van die aflevering. Zonder dat onderscheid zou alleen de laatste aflevering van zo'n bestand ooit kunnen uitspelen. - Een boek is als geheel voltooid. De enkele
BOOK-rij van een boek heeft twee onafhankelijke slots — audio (chapter_entity_id) en epub (book_entity_id) — maar het laatste hoofdstuk uitluisteren maakt beide leeg: het einde van het audioboek bereiken betekent dat het boek af is, en een oudere achtergebleven epub-leespositie mag het niet in de lijst houden. De rebuild past dezelfde regel toe op tijdstempel: een leespositie ouder dan het moment waarop het laatste hoofdstuk voltooid werd, geldt als verouderd. Daarna een eerder hoofdstuk starten (de heartbeat zet zijnwatchedterug op false) of nieuwe leesvoortgang syncen is een nieuwe start en zet het boek terug in de lijst. - Rijen met alleen NULL-targets blijven staan. Als er niets meer te vervolgen valt, gaan alle
target-kolommen op NULL, maar de rij blijft bewust bestaan. Voegt de scanner later een episode
toe, dan maakt
recomputeForShow(aangeroepen vanuitScannerHelperService.getOrCreateEpisode;recomputeForBookvoor chapters) die nieuwe episode de target en verschijnt de show weer in de lijst. De rij verwijderen zou die terugkeer onmogelijk maken. - Self-healing.
ContinueWatchingRebuildScheduler(worker) queuet nachtelijk (03:30) per user eenCONTINUE_WATCHING_REBUILD_REQUESTED, en eenmalig bij startup zolang de tabel leeg is (de backfill na V20).rebuildForUsergooit de rijen van de user weg en herberekent ze uitwatch_status_entity, wat ook entries opruimt waarvan de media verdwenen is. - Race-veilige upsert. Writes lopen via een native
INSERT … ON CONFLICT DO UPDATE(ContinueWatchingRepository.upsert), zodat twee gelijktijdige heartbeats van één user niet kunnen falen op een unique-constraint-race;last_watchedbeweegt viaGREATESTalleen vooruit. PreTranscodeServiceleest dezelfde tabel — de entries zijn de "wat gaan ze hierna spelen"-set (hoofdstuk 4) — in plaats van zelf de kijkgeschiedenis af te lopen.
Track-plays
Muziektracks hergebruiken de watch-status-machinerie als afspeelhistorie in plaats van
hervat-positie (migratie V29 voegt track_entity_id toe aan watch_status_entity). De
playback-heartbeat (PlayQueueService) schrijft één rij per afgespeeld play-queue-item zodra 30
seconden — of de helft van een track korter dan een minuut — is beluisterd; herhaalde heartbeats
van hetzelfde item komen via WatchStatusService.getOrCreateForTrack op dezelfde rij uit, dus een
play telt nooit dubbel, terwijl de track opnieuw afspelen (een nieuw queue-item) een nieuwe rij
oplevert. De play count van een gebruiker voor een track is simpelweg het aantal rijen
(WatchStatusRepository.findTrackPlayStats), en date_updated van de rij is meteen "laatst
afgespeeld". Track-rijen bereiken continue watching nooit: de type-dispatch van
onWatchStatusChanged en de rebuild-queries matchen alleen de andere mediatypes.
Live status (core/.../status/)
Los van de werkqueues publiceert elke node zijn toestand naar een fanout-exchange
(StatusExchangeConfig) die elke node op zijn eigen anonieme queue consumeert
(StatusEventListener), zodat de clusterstatus overal convergeert en elke node een subscription kan
beantwoorden.
| Producer | Publiceert |
|---|---|
NodeActivityPublisher | node-heartbeat |
QueueDepthPoller | RabbitMQ-queuedieptes |
ProcessingActivityAdvice | AOP-advice dat meldt welke handler op dat moment bezig is |
RecentFailuresBuffer | recente handler-mislukkingen (gevoed vanuit het dead-letter-pad) |
PlaybackStatusService | playback-heartbeats van clients → PlaybackSessionRegistry, verlopen via PlaybackSessionSweeper |
ServerStatusBroadcaster verbindt de registries met de GraphQL-websocket-subscriptions:
serverActivity en nowPlaying (ServerStatusController) en playbackCommands(playQueueId)
(PlaybackCommandController — party-mode-afstandsbediening: PLAY / PAUSE / NEXT / PREVIOUS / SEEK /
SKIP_TO_ITEM / QUEUE_CHANGED / STOP, plus STOP_FOLLOW en SET_REPEAT — zie PlaybackCommandType in
schema.graphqls).
Het status/-pakket is inmiddels breder dan deze registries: DevicePresenceRegistry +
DeviceCommandService houden geregistreerde apparaten bij en routeren deviceCommands ernaartoe;
FollowerRegistry + FollowerStatusService houden bij wie met een sessie meeluistert; en
TranscodeActivityRegistry voedt live transcode-voortgang in serverActivity. De mechaniek van
apparaten en meeluisteren staat in hoofdstuk 9.
Twee invarianten voordat je aan deze code komt:
- De activity- en now-playing-sinks zijn replay-latest: een nieuwe subscriber moet meteen de huidige toestand krijgen, en een emit vanaf een RabbitMQ-listener-thread mag nooit blokkeren.
- De command-sink is bewust best-effort en non-replaying: een re-subscriber die het laatste commando opnieuw afgespeeld krijgt, zou het opnieuw uitvoeren (bijvoorbeeld nogmaals seeken).
Handlers hier doen geen database-toegang — RabbitMQ-listener-threads hebben geen Hibernate-sessie (hoofdstuk 1); alles wat ze aanraken is in-memory registry-state.
Sessies delen & privacy
Zichtbaarheid van now-playing en afstandsbediening staan onder controle van de eigenaar
(PlaybackSharingService, gemodelleerd op LibraryAccessService: een per-eigenaar-config die ~15s
gecachet wordt en door de sharing-mutaties wordt geïnvalideerd). Twee scopes, opgeslagen in
user_sharing_settings met per-capability-allowlists in user_sharing_grant (VIEW / CONTROL):
- Now-playing staat standaard op
EVERYONE(behoudt het oorspronkelijke gedrag waarbij alle sessies zichtbaar waren); instelbaar opPRIVATEof eenALLOWLISTvan gebruikers. - Afstandsbediening staat standaard op
PRIVATE(alleen de eigenaar) — een bewuste aanscherping van de oude party-modus waarin elke gebruiker elke sessie kon bedienen. KanEVERYONE, eenALLOWLISTofSAME_AS_NOW_PLAYINGzijn. Ook per sessie te overrulen (setSessionSharingschrijftplay_queue_entity.control_scope_overrideplus de eigenplay_queue_control_grant-lijst van die sessie).
Handhavingspunten, allemaal deny-as-not-found (nooit een 403):
ServerStatusController.nowPlaying/serverActivitySnapshotfilteren de sessielijst per kijker metcanViewen stempelen elke overgebleven sessie met een per-kijker-controllable-vlag (canControl). De now-playing-sink emit nog steeds op de RabbitMQ-listener-thread, dus de subscription-resolver stapt over naarSchedulers.boundedElastic()vóór de (gecachete) sharing-lookups — de listener-thread blijft DB-vrij. De per-sessie-override + allowlist reizen mee inPlaybackStatusData, ingebed op de heartbeat-request-thread (die wél een Hibernate-sessie heeft), zodat de resolver de queue nooit opnieuw hoeft te lezen.PlaybackCommandController.sendPlaybackCommandenPlayQueueService.getPlayQueue/getEditableQueuetoetsen opcanControl; een geweigerde aanroeper krijgt een genegeerd commando / lege Optional. De eigenaar passeert altijd beide controles.
shareableUsers levert een niet-admin, alleen-naam-gebruikerslijst zodat een gewone gebruiker een
allowlist kan vullen (de users-query blijft admin-only).