mirror of
https://github.com/p-stream/backend.git
synced 2026-01-11 20:10:33 +00:00
add watchparty sync service
This commit is contained in:
parent
ec8a973c2e
commit
e37eeae3fe
5 changed files with 500 additions and 0 deletions
196
examples/player-status-integration.ts
Normal file
196
examples/player-status-integration.ts
Normal 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
143
server/api/player/README.md
Normal 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)
|
||||
56
server/api/player/status.get.ts
Normal file
56
server/api/player/status.get.ts
Normal 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'
|
||||
});
|
||||
});
|
||||
53
server/api/player/status.post.ts
Normal file
53
server/api/player/status.post.ts
Normal 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 };
|
||||
});
|
||||
52
server/utils/playerStatus.ts
Normal file
52
server/utils/playerStatus.ts
Normal 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);
|
||||
Loading…
Reference in a new issue