add watchparty sync service

This commit is contained in:
Pas 2025-05-17 19:08:46 -06:00
parent ec8a973c2e
commit e37eeae3fe
5 changed files with 500 additions and 0 deletions

View file

@ -0,0 +1,196 @@
// Example frontend implementation for the player status API
/**
* Function to send player status to the backend
*/
export async function sendPlayerStatus({
userId,
roomCode,
isHost,
content,
player
}: {
userId: string;
roomCode: string;
isHost: boolean;
content: {
title: string;
type: string;
};
player: {
isPlaying: boolean;
isPaused: boolean;
isLoading: boolean;
hasPlayedOnce: boolean;
time: number;
duration: number;
volume: number;
playbackRate: number;
buffered: number;
};
}) {
try {
const response = await fetch('/api/player/status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
roomCode,
isHost,
content,
player,
}),
});
if (!response.ok) {
throw new Error(`Failed to send player status: ${response.statusText}`);
}
const data = await response.json();
console.log('Successfully sent player status update', data);
return data;
} catch (error) {
console.error('Error sending player status:', error);
throw error;
}
}
/**
* Function to get player status for a specific user in a room
*/
export async function getPlayerStatus(userId: string, roomCode: string) {
try {
const response = await fetch(`/api/player/status?userId=${userId}&roomCode=${roomCode}`);
if (!response.ok) {
throw new Error(`Failed to get player status: ${response.statusText}`);
}
const data = await response.json();
console.log('Retrieved player status data:', data);
return data;
} catch (error) {
console.error('Error getting player status:', error);
throw error;
}
}
/**
* Function to get status for all users in a room
*/
export async function getRoomStatuses(roomCode: string) {
try {
const response = await fetch(`/api/player/status?roomCode=${roomCode}`);
if (!response.ok) {
throw new Error(`Failed to get room statuses: ${response.statusText}`);
}
const data = await response.json();
console.log('Retrieved room statuses data:', data);
return data;
} catch (error) {
console.error('Error getting room statuses:', error);
throw error;
}
}
/**
* Example implementation for updating WebhookReporter to use the API
*/
export function ModifiedWebhookReporter() {
// Example replacing the Discord webhook code
/*
useEffect(() => {
// Skip if watch party is not enabled or no status
if (!watchPartyEnabled || !latestStatus || !latestStatus.hasPlayedOnce) return;
const now = Date.now();
// Create a state fingerprint to detect meaningful changes
const stateFingerprint = JSON.stringify({
isPlaying: latestStatus.isPlaying,
isPaused: latestStatus.isPaused,
isLoading: latestStatus.isLoading,
time: Math.floor(latestStatus.time / 5) * 5, // Round to nearest 5 seconds
volume: Math.round(latestStatus.volume * 100),
playbackRate: latestStatus.playbackRate,
});
// Check if state has changed meaningfully AND
// it's been at least 5 seconds since last report
const hasStateChanged = stateFingerprint !== lastReportedStateRef.current;
const timeThresholdMet = now - lastReportTime.current >= 5000;
if (!hasStateChanged && !timeThresholdMet) return;
let contentTitle = "Unknown content";
let contentType = "";
if (meta) {
if (meta.type === "movie") {
contentTitle = meta.title;
contentType = "Movie";
} else if (meta.type === "show" && meta.episode) {
contentTitle = `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`;
contentType = "TV Show";
}
}
// Send to our backend instead of Discord
const sendToBackend = async () => {
try {
await sendPlayerStatus({
userId,
roomCode: roomCode || 'none',
isHost: isHost || false,
content: {
title: contentTitle,
type: contentType || 'Unknown',
},
player: {
isPlaying: latestStatus.isPlaying,
isPaused: latestStatus.isPaused,
isLoading: latestStatus.isLoading,
hasPlayedOnce: latestStatus.hasPlayedOnce,
time: latestStatus.time,
duration: latestStatus.duration,
volume: latestStatus.volume,
playbackRate: latestStatus.playbackRate,
buffered: latestStatus.buffered,
},
});
// Update last report time and fingerprint
lastReportTime.current = now;
lastReportedStateRef.current = stateFingerprint;
console.log("Sent player status update to backend", {
time: new Date().toISOString(),
isPlaying: latestStatus.isPlaying,
currentTime: Math.floor(latestStatus.time),
userId,
content: contentTitle,
roomCode,
});
} catch (error) {
console.error("Failed to send player status to backend", error);
}
};
sendToBackend();
}, [
latestStatus,
statusHistory.length,
userId,
account,
meta,
watchPartyEnabled,
roomCode,
isHost,
]);
*/
}

143
server/api/player/README.md Normal file
View file

@ -0,0 +1,143 @@
# Player Status API
This API allows for tracking and retrieving player status data for users in watch party rooms. Status data is automatically cleaned up if it's older than 1 minute.
## Endpoints
### POST `/api/player/status`
Send a player status update.
**Request Body:**
```json
{
"userId": "user123", // Required: User identifier
"roomCode": "room456", // Required: Room code
"isHost": true, // Optional: Whether the user is the host
"content": { // Optional: Content information
"title": "Movie Title",
"type": "Movie", // "Movie", "TV Show", etc.
"tmdbId": 12345, // Optional: TMDB ID for the content
"seasonNumber": 1, // Optional: Season number (for TV shows)
"episodeNumber": 3 // Optional: Episode number (for TV shows)
},
"player": { // Optional: Player state
"isPlaying": true,
"isPaused": false,
"isLoading": false,
"hasPlayedOnce": true,
"time": 120.5, // Current playback position in seconds
"duration": 3600, // Total content duration in seconds
"volume": 0.8, // Volume level (0-1)
"playbackRate": 1, // Playback speed
"buffered": 180 // Buffered seconds
}
}
```
**Response:**
```json
{
"success": true,
"timestamp": 1625097600000 // The timestamp assigned to this status update
}
```
### GET `/api/player/status?userId=user123&roomCode=room456`
Get status updates for a specific user in a specific room.
**Query Parameters:**
- `userId`: User identifier
- `roomCode`: Room code
**Response:**
```json
{
"userId": "user123",
"roomCode": "room456",
"statuses": [
{
"userId": "user123",
"roomCode": "room456",
"isHost": true,
"content": {
"title": "Movie Title",
"type": "Movie",
"tmdbId": 12345,
"seasonNumber": null,
"episodeNumber": null
},
"player": {
"isPlaying": true,
"isPaused": false,
"isLoading": false,
"hasPlayedOnce": true,
"time": 120.5,
"duration": 3600,
"volume": 0.8,
"playbackRate": 1,
"buffered": 180
},
"timestamp": 1625097600000
}
// More status updates if available
]
}
```
### GET `/api/player/status?roomCode=room456`
Get status updates for all users in a specific room.
**Query Parameters:**
- `roomCode`: Room code
**Response:**
```json
{
"roomCode": "room456",
"users": {
"user123": [
{
"userId": "user123",
"roomCode": "room456",
"isHost": true,
"content": {
"title": "Show Title",
"type": "TV Show",
"tmdbId": 67890,
"seasonNumber": 2,
"episodeNumber": 5
},
"player": {
"isPlaying": true,
"isPaused": false,
"isLoading": false,
"hasPlayedOnce": true,
"time": 120.5,
"duration": 3600,
"volume": 0.8,
"playbackRate": 1,
"buffered": 180
},
"timestamp": 1625097600000
}
// More status updates for this user if available
],
"user456": [
// Status updates for another user
]
}
}
```
## Notes
- Status data is automatically cleaned up if it's older than 1 minute
- The system keeps a maximum of 5 status updates per user per room
- Timestamps are in milliseconds since epoch (Unix timestamp)

View file

@ -0,0 +1,56 @@
import { defineEventHandler, getQuery, createError } from 'h3';
import { playerStatusStore, CLEANUP_INTERVAL } from '~/utils/playerStatus';
export default defineEventHandler((event) => {
const query = getQuery(event);
const userId = query.userId as string;
const roomCode = query.roomCode as string;
// If roomCode is provided but no userId, return all statuses for that room
if (roomCode && !userId) {
const cutoffTime = Date.now() - CLEANUP_INTERVAL;
const roomStatuses: Record<string, any[]> = {};
for (const [key, statuses] of playerStatusStore.entries()) {
if (key.includes(`:${roomCode}`)) {
const userId = key.split(':')[0];
const recentStatuses = statuses.filter(status => status.timestamp >= cutoffTime);
if (recentStatuses.length > 0) {
roomStatuses[userId] = recentStatuses;
}
}
}
return {
roomCode,
users: roomStatuses
};
}
// If both userId and roomCode are provided, return status for that user in that room
if (userId && roomCode) {
const key = `${userId}:${roomCode}`;
const statuses = playerStatusStore.get(key) || [];
// Remove statuses older than 15 minutes
const cutoffTime = Date.now() - CLEANUP_INTERVAL;
const recentStatuses = statuses.filter(status => status.timestamp >= cutoffTime);
if (recentStatuses.length !== statuses.length) {
playerStatusStore.set(key, recentStatuses);
}
return {
userId,
roomCode,
statuses: recentStatuses
};
}
// If neither is provided, return error
throw createError({
statusCode: 400,
statusMessage: 'Missing required query parameters: roomCode and/or userId'
});
});

View file

@ -0,0 +1,53 @@
import { defineEventHandler, readBody, createError } from 'h3';
import { playerStatusStore, PlayerStatus } from '~/utils/playerStatus';
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!body || !body.userId || !body.roomCode) {
throw createError({
statusCode: 400,
statusMessage: 'Missing required fields: userId, roomCode'
});
}
const status: PlayerStatus = {
userId: body.userId,
roomCode: body.roomCode,
isHost: body.isHost || false,
content: {
title: body.content?.title || 'Unknown',
type: body.content?.type || 'Unknown',
tmdbId: body.content?.tmdbId,
seasonId: body.content?.seasonId,
episodeId: body.content?.episodeId,
seasonNumber: body.content?.seasonNumber,
episodeNumber: body.content?.episodeNumber
},
player: {
isPlaying: body.player?.isPlaying || false,
isPaused: body.player?.isPaused || false,
isLoading: body.player?.isLoading || false,
hasPlayedOnce: body.player?.hasPlayedOnce || false,
time: body.player?.time || 0,
duration: body.player?.duration || 0,
volume: body.player?.volume || 0,
playbackRate: body.player?.playbackRate || 1,
buffered: body.player?.buffered || 0,
},
timestamp: Date.now()
};
const key = `${status.userId}:${status.roomCode}`;
const existingStatuses = playerStatusStore.get(key) || [];
// Add new status and keep only the last 5 statuses
existingStatuses.push(status);
if (existingStatuses.length > 5) {
existingStatuses.shift();
}
playerStatusStore.set(key, existingStatuses);
return { success: true, timestamp: status.timestamp };
});

View file

@ -0,0 +1,52 @@
// Interface for player status
export interface PlayerStatus {
userId: string;
roomCode: string;
isHost: boolean;
content: {
title: string;
type: string;
tmdbId?: number | string;
seasonId?: number;
episodeId?: number;
seasonNumber?: number;
episodeNumber?: number;
};
player: {
isPlaying: boolean;
isPaused: boolean;
isLoading: boolean;
hasPlayedOnce: boolean;
time: number;
duration: number;
volume: number;
playbackRate: number;
buffered: number;
};
timestamp: number;
}
// In-memory store for player status data
// Key: userId+roomCode, Value: Status data array
export const playerStatusStore = new Map<string, Array<PlayerStatus>>();
// Cleanup interval (1 minute in milliseconds)
export const CLEANUP_INTERVAL = 1 * 60 * 1000;
// Clean up old status entries
function cleanupOldStatuses() {
const cutoffTime = Date.now() - CLEANUP_INTERVAL;
for (const [key, statuses] of playerStatusStore.entries()) {
const filteredStatuses = statuses.filter(status => status.timestamp >= cutoffTime);
if (filteredStatuses.length === 0) {
playerStatusStore.delete(key);
} else {
playerStatusStore.set(key, filteredStatuses);
}
}
}
// Schedule cleanup every 1 minute
setInterval(cleanupOldStatuses, 1 * 60 * 1000);