feat: add video likes and plays tracking system

Backend (Database & API):
- Add likes_count, plays_count, views_count to sexy_videos table
- Create sexy_video_likes junction table for user-video likes
- Create sexy_video_plays table for analytics tracking
- Add POST /sexy/videos/:id/like endpoint
- Add DELETE /sexy/videos/:id/like endpoint
- Add GET /sexy/videos/:id/like-status endpoint
- Add POST /sexy/videos/:id/play endpoint
- Add PATCH /sexy/videos/:id/play/:playId endpoint

Frontend (Types & Services):
- Update Video interface with counter fields
- Add VideoLikeStatus, VideoLikeResponse, VideoPlayResponse types
- Add likeVideo() service function
- Add unlikeVideo() service function
- Add getVideoLikeStatus() service function
- Add recordVideoPlay() service function
- Add updateVideoPlay() service function

Next: Implement UI components for like button and play count display
This commit is contained in:
Valknar XXX
2025-10-28 10:29:02 +01:00
parent e891e0de0a
commit da267eb66d
3 changed files with 290 additions and 1 deletions

View File

@@ -249,5 +249,177 @@ export default {
res.status(500).json({ error: error.message || "Failed to delete recording" });
}
});
// POST /sexy/videos/:id/like - Like a video
router.post("/videos/:id/like", async (req, res) => {
const accountability = req.accountability;
if (!accountability?.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const videoId = req.params.id;
const userId = accountability.user;
try {
const likesService = new ItemsService("sexy_video_likes", {
schema: await getSchema(),
accountability,
});
// Check if already liked
const existing = await likesService.readByQuery({
filter: { video_id: videoId, user_id: userId },
limit: 1,
});
if (existing.length > 0) {
return res.status(400).json({ error: "Already liked" });
}
// Create like
await likesService.createOne({
video_id: videoId,
user_id: userId,
});
// Increment likes_count
const videosService = new ItemsService("sexy_videos", {
schema: await getSchema(),
});
const video = await videosService.readOne(videoId);
await videosService.updateOne(videoId, {
likes_count: (video.likes_count || 0) + 1,
});
res.json({ liked: true, likes_count: (video.likes_count || 0) + 1 });
} catch (error: any) {
res.status(500).json({ error: error.message || "Failed to like video" });
}
});
// DELETE /sexy/videos/:id/like - Unlike a video
router.delete("/videos/:id/like", async (req, res) => {
const accountability = req.accountability;
if (!accountability?.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const videoId = req.params.id;
const userId = accountability.user;
try {
const likesService = new ItemsService("sexy_video_likes", {
schema: await getSchema(),
accountability,
});
// Find and delete like
const existing = await likesService.readByQuery({
filter: { video_id: videoId, user_id: userId },
limit: 1,
});
if (existing.length === 0) {
return res.status(400).json({ error: "Not liked" });
}
await likesService.deleteOne(existing[0].id);
// Decrement likes_count
const videosService = new ItemsService("sexy_videos", {
schema: await getSchema(),
});
const video = await videosService.readOne(videoId);
await videosService.updateOne(videoId, {
likes_count: Math.max((video.likes_count || 0) - 1, 0),
});
res.json({ liked: false, likes_count: Math.max((video.likes_count || 0) - 1, 0) });
} catch (error: any) {
res.status(500).json({ error: error.message || "Failed to unlike video" });
}
});
// GET /sexy/videos/:id/like-status - Get like status for a video
router.get("/videos/:id/like-status", async (req, res) => {
const accountability = req.accountability;
if (!accountability?.user) {
return res.json({ liked: false });
}
const videoId = req.params.id;
const userId = accountability.user;
try {
const likesService = new ItemsService("sexy_video_likes", {
schema: await getSchema(),
accountability,
});
const existing = await likesService.readByQuery({
filter: { video_id: videoId, user_id: userId },
limit: 1,
});
res.json({ liked: existing.length > 0 });
} catch (error: any) {
res.status(500).json({ error: error.message || "Failed to get like status" });
}
});
// POST /sexy/videos/:id/play - Record a video play
router.post("/videos/:id/play", async (req, res) => {
const accountability = req.accountability;
const videoId = req.params.id;
const { session_id } = req.body;
try {
const playsService = new ItemsService("sexy_video_plays", {
schema: await getSchema(),
});
const videosService = new ItemsService("sexy_videos", {
schema: await getSchema(),
});
// Record play
const play = await playsService.createOne({
video_id: videoId,
user_id: accountability?.user || null,
session_id: session_id || null,
});
// Increment plays_count
const video = await videosService.readOne(videoId);
await videosService.updateOne(videoId, {
plays_count: (video.plays_count || 0) + 1,
});
res.json({ success: true, play_id: play, plays_count: (video.plays_count || 0) + 1 });
} catch (error: any) {
res.status(500).json({ error: error.message || "Failed to record play" });
}
});
// PATCH /sexy/videos/:id/play/:playId - Update play progress
router.patch("/videos/:id/play/:playId", async (req, res) => {
const { playId } = req.params;
const { duration_watched, completed } = req.body;
try {
const playsService = new ItemsService("sexy_video_plays", {
schema: await getSchema(),
});
await playsService.updateOne(playId, {
duration_watched,
completed,
});
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message || "Failed to update play" });
}
});
},
};

View File

@@ -16,7 +16,7 @@ import {
readComments,
aggregate,
} from "@directus/sdk";
import type { Article, Model, Recording, Stats, User, Video } from "$lib/types";
import type { Article, Model, Recording, Stats, User, Video, VideoLikeStatus, VideoLikeResponse, VideoPlayResponse } from "$lib/types";
import { PUBLIC_URL } from "$env/static/public";
import { logger } from "$lib/logger";
@@ -630,3 +630,95 @@ export async function getRecording(id: string, fetch?: typeof globalThis.fetch)
{ id },
);
}
export async function likeVideo(videoId: string) {
return loggedApiCall(
"likeVideo",
async () => {
const directus = getDirectusInstance(fetch);
return directus.request<VideoLikeResponse>(
customEndpoint({
method: "POST",
path: `/sexy/videos/${videoId}/like`,
})
);
},
{ videoId }
);
}
export async function unlikeVideo(videoId: string) {
return loggedApiCall(
"unlikeVideo",
async () => {
const directus = getDirectusInstance(fetch);
return directus.request<VideoLikeResponse>(
customEndpoint({
method: "DELETE",
path: `/sexy/videos/${videoId}/like`,
})
);
},
{ videoId }
);
}
export async function getVideoLikeStatus(videoId: string, fetch?: typeof globalThis.fetch) {
return loggedApiCall(
"getVideoLikeStatus",
async () => {
const directus = getDirectusInstance(fetch);
return directus.request<VideoLikeStatus>(
customEndpoint({
method: "GET",
path: `/sexy/videos/${videoId}/like-status`,
})
);
},
{ videoId }
);
}
export async function recordVideoPlay(videoId: string, sessionId?: string) {
return loggedApiCall(
"recordVideoPlay",
async () => {
const directus = getDirectusInstance(fetch);
return directus.request<VideoPlayResponse>(
customEndpoint({
method: "POST",
path: `/sexy/videos/${videoId}/play`,
body: JSON.stringify({ session_id: sessionId }),
headers: { "Content-Type": "application/json" },
})
);
},
{ videoId }
);
}
export async function updateVideoPlay(
videoId: string,
playId: string,
durationWatched: number,
completed: boolean
) {
return loggedApiCall(
"updateVideoPlay",
async () => {
const directus = getDirectusInstance(fetch);
return directus.request(
customEndpoint({
method: "PATCH",
path: `/sexy/videos/${videoId}/play/${playId}`,
body: JSON.stringify({
duration_watched: durationWatched,
completed,
}),
headers: { "Content-Type": "application/json" },
})
);
},
{ videoId, playId, durationWatched, completed }
);
}

View File

@@ -89,6 +89,9 @@ export interface Video {
upload_date: Date;
premium?: boolean;
featured?: boolean;
likes_count?: number;
plays_count?: number;
views_count?: number;
}
export interface Comment {
@@ -155,3 +158,25 @@ export interface Recording {
featured?: boolean;
public?: boolean;
}
export interface VideoLikeStatus {
liked: boolean;
}
export interface VideoPlayRecord {
id: string;
video_id: string;
duration_watched?: number;
completed: boolean;
}
export interface VideoLikeResponse {
liked: boolean;
likes_count: number;
}
export interface VideoPlayResponse {
success: boolean;
play_id: string;
plays_count: number;
}