From e2d638b3f450863c7ff1e574e0b02475c7309eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Fri, 1 May 2026 11:25:07 +0200 Subject: [PATCH] refactor: send playlist position heartbeat over STOMP instead of HTTP POST - Add PlaylistWsController with @MessageMapping for position reports; authentication via Principal, same authorizationService guard - renderer.js: store stompClient on this so the interval can reach it; replace fetch() position POST with stompClient.send() to /app/channel/... - Remove POST /{playlistId}/position REST endpoint from PlaylistApiController --- .../controller/PlaylistApiController.java | 15 ------ .../controller/PlaylistWsController.java | 50 +++++++++++++++++++ .../resources/static/js/broadcast/renderer.js | 31 +++++------- 3 files changed, 62 insertions(+), 34 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistWsController.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java index 31a1172..1157213 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java @@ -232,19 +232,4 @@ public class PlaylistApiController { playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId")); return ResponseEntity.ok().build(); } - - @PostMapping("/{playlistId}/position") - public ResponseEntity reportPosition( - @PathVariable("broadcaster") String broadcaster, - @PathVariable("playlistId") String playlistId, - @RequestBody java.util.Map body, - OAuth2AuthenticationToken oauthToken - ) { - String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); - String trackId = (String) body.get("trackId"); - double position = body.get("position") instanceof Number n ? n.doubleValue() : 0.0; - playlistService.reportPosition(broadcaster, playlistId, trackId, position); - return ResponseEntity.ok().build(); - } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistWsController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistWsController.java new file mode 100644 index 0000000..ca45ed3 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistWsController.java @@ -0,0 +1,50 @@ +package dev.kruhlmann.imgfloat.controller; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.PlaylistService; +import java.security.Principal; +import java.util.Map; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Controller; + +@Controller +public class PlaylistWsController { + + private final PlaylistService playlistService; + private final AuthorizationService authorizationService; + + public PlaylistWsController(PlaylistService playlistService, AuthorizationService authorizationService) { + this.playlistService = playlistService; + this.authorizationService = authorizationService; + } + + /** + * Periodic position heartbeat sent by the broadcast renderer over STOMP. + * Payload: { "trackId": "...", "position": 42.5 } + */ + @MessageMapping("/channel/{broadcaster}/playlists/{playlistId}/position") + public void reportPosition( + @DestinationVariable String broadcaster, + @DestinationVariable String playlistId, + @Payload Map payload, + Principal principal + ) { + String sessionUsername = sessionUsername(principal); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + String trackId = (String) payload.get("trackId"); + double position = payload.get("position") instanceof Number n ? n.doubleValue() : 0.0; + playlistService.reportPosition(broadcaster, playlistId, trackId, position); + } + + private String sessionUsername(Principal principal) { + if (principal instanceof OAuth2AuthenticationToken token) { + OauthSessionUser user = OauthSessionUser.from(token); + return user == null ? null : user.login(); + } + return principal == null ? null : principal.getName(); + } +} diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index 9d43570..46ca0d3 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -87,9 +87,9 @@ export class BroadcastRenderer { connect() { const socket = new SockJS("/ws"); - const stompClient = Stomp.over(socket); - stompClient.connect({}, () => { - stompClient.subscribe(`/topic/channel/${this.broadcaster}`, (payload) => { + this.stompClient = Stomp.over(socket); + this.stompClient.connect({}, () => { + this.stompClient.subscribe(`/topic/channel/${this.broadcaster}`, (payload) => { const body = JSON.parse(payload.body); this.handleEvent(body); }); @@ -119,25 +119,18 @@ export class BroadcastRenderer { }) .catch(() => {}); - // Periodically persist playback position so reconnects can resume accurately + // Periodically persist playback position over STOMP so reconnects can resume accurately this._positionReporterInterval = setInterval(() => { if (!this.playlistCurrentElement || this.playlistCurrentElement.paused) return; if (!this.playlistState.playlistId || !this.playlistState.trackId) return; - const xsrf = this._xsrfToken(); - fetch( - `/api/channels/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/position`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(xsrf ? { "X-XSRF-TOKEN": xsrf } : {}), - }, - body: JSON.stringify({ - trackId: this.playlistState.trackId, - position: this.playlistCurrentElement.currentTime, - }), - } - ).catch(() => {}); + this.stompClient.send( + `/app/channel/${encodeURIComponent(this.broadcaster)}/playlists/${encodeURIComponent(this.playlistState.playlistId)}/position`, + {}, + JSON.stringify({ + trackId: this.playlistState.trackId, + position: this.playlistCurrentElement.currentTime, + }) + ); }, 5000); }); }