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
This commit is contained in:
2026-05-01 11:25:07 +02:00
parent f2e5ca1927
commit e2d638b3f4
3 changed files with 62 additions and 34 deletions
@@ -232,19 +232,4 @@ public class PlaylistApiController {
playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId"));
return ResponseEntity.ok().build();
}
@PostMapping("/{playlistId}/position")
public ResponseEntity<Void> reportPosition(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("playlistId") String playlistId,
@RequestBody java.util.Map<String, Object> 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();
}
}
@@ -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<String, Object> 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();
}
}
@@ -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);
});
}