Skip to content

Commit 07435b9

Browse files
committed
Add casual mode endpoint
1 parent ab9cab8 commit 07435b9

File tree

13 files changed

+409
-16
lines changed

13 files changed

+409
-16
lines changed

databases/_private.db.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,15 @@ CREATE TABLE IF NOT EXISTS "thumbnailVotes" (
4444
"type" INTEGER NOT NULL
4545
);
4646

47+
CREATE TABLE IF NOT EXISTS "casualVotes" (
48+
"UUID" SERIAL PRIMARY KEY,
49+
"videoID" TEXT NOT NULL,
50+
"service" TEXT NOT NULL,
51+
"userID" TEXT NOT NULL,
52+
"hashedIP" TEXT NOT NULL,
53+
"category" TEXT NOT NULL,
54+
"type" INTEGER NOT NULL,
55+
"timeSubmitted" INTEGER NOT NULL
56+
);
57+
4758
COMMIT;

databases/_private_indexes.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,11 @@ CREATE INDEX IF NOT EXISTS "categoryVotes_UUID"
2323
CREATE INDEX IF NOT EXISTS "ratings_videoID"
2424
ON public."ratings" USING btree
2525
("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, service COLLATE pg_catalog."default" ASC NULLS LAST, "userID" COLLATE pg_catalog."default" ASC NULLS LAST, "timeSubmitted" ASC NULLS LAST)
26+
TABLESPACE pg_default;
27+
28+
-- casualVotes
29+
30+
CREATE INDEX IF NOT EXISTS "casualVotes_videoID"
31+
ON public."casualVotes" USING btree
32+
("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, "service" COLLATE pg_catalog."default" ASC NULLS LAST, "userID" COLLATE pg_catalog."default" ASC NULLS LAST)
2633
TABLESPACE pg_default;

databases/_sponsorTimes.db.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ CREATE TABLE IF NOT EXISTS "thumbnailVotes" (
8484
FOREIGN KEY("UUID") REFERENCES "thumbnails"("UUID")
8585
);
8686

87+
CREATE TABLE IF NOT EXISTS "casualVotes" (
88+
"UUID" TEXT PRIMARY KEY,
89+
"videoID" TEXT NOT NULL,
90+
"service" TEXT NOT NULL,
91+
"hashedVideoID" TEXT NOT NULL,
92+
"category" TEXT NOT NULL,
93+
"upvotes" INTEGER NOT NULL default 0,
94+
"downvotes" INTEGER NOT NULL default 0,
95+
"timeSubmitted" INTEGER NOT NULL
96+
);
97+
8798
CREATE EXTENSION IF NOT EXISTS pgcrypto; --!sqlite-ignore
8899
CREATE EXTENSION IF NOT EXISTS pg_trgm; --!sqlite-ignore
89100

databases/_sponsorTimes_indexes.sql

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,26 @@ CREATE INDEX IF NOT EXISTS "thumbnails_hashedVideoID_2"
173173
CREATE INDEX IF NOT EXISTS "thumbnailVotes_votes"
174174
ON public."thumbnailVotes" USING btree
175175
("UUID" COLLATE pg_catalog."default" ASC NULLS LAST, "votes" DESC NULLS LAST)
176+
TABLESPACE pg_default;
177+
178+
-- casualVotes
179+
180+
CREATE INDEX IF NOT EXISTS "casualVotes_timeSubmitted"
181+
ON public."casualVotes" USING btree
182+
("timeSubmitted" ASC NULLS LAST)
183+
TABLESPACE pg_default;
184+
185+
CREATE INDEX IF NOT EXISTS "casualVotes_userID_timeSubmitted"
186+
ON public."casualVotes" USING btree
187+
("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, "service" COLLATE pg_catalog."default" ASC NULLS LAST, "userID" COLLATE pg_catalog."default" DESC NULLS LAST, "timeSubmitted" DESC NULLS LAST)
188+
TABLESPACE pg_default;
189+
190+
CREATE INDEX IF NOT EXISTS "casualVotes_videoID"
191+
ON public."casualVotes" USING btree
192+
("videoID" COLLATE pg_catalog."default" ASC NULLS LAST, "service" COLLATE pg_catalog."default" ASC NULLS LAST)
193+
TABLESPACE pg_default;
194+
195+
CREATE INDEX IF NOT EXISTS "casualVotes_hashedVideoID_2"
196+
ON public."casualVotes" USING btree
197+
(service COLLATE pg_catalog."default" ASC NULLS LAST, "hashedVideoID" text_pattern_ops ASC NULLS LAST, "timeSubmitted" ASC NULLS LAST)
176198
TABLESPACE pg_default;

src/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { getFeatureFlag } from "./routes/getFeatureFlag";
5959
import { getReady } from "./routes/getReady";
6060
import { getMetrics } from "./routes/getMetrics";
6161
import { getSegmentID } from "./routes/getSegmentID";
62+
import { postCasual } from "./routes/postCasual";
6263

6364
export function createServer(callback: () => void): Server {
6465
// Create a service (the app object is just a callback).
@@ -234,6 +235,8 @@ function setupRoutes(router: Router, server: Server) {
234235
router.get("/api/branding/:prefix", getBrandingByHashEndpoint);
235236
router.post("/api/branding", postBranding);
236237

238+
router.post("/api/casual", postCasual);
239+
237240
/* istanbul ignore next */
238241
if (config.postgres?.enabled) {
239242
router.get("/database", (req, res) => dumpDatabase(req, res, true));

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ addDefaults(config, {
2020
readOnly: false,
2121
webhooks: [],
2222
categoryList: ["sponsor", "selfpromo", "exclusive_access", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight", "chapter"],
23+
casualCategoryList: ["funny", "creative", "clever", "descriptive", "other"],
2324
categorySupport: {
2425
sponsor: ["skip", "mute", "full"],
2526
selfpromo: ["skip", "mute", "full"],

src/routes/getBranding.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isEmpty } from "lodash";
33
import { config } from "../config";
44
import { db, privateDB } from "../databases/databases";
55
import { Postgres } from "../databases/Postgres";
6-
import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model";
6+
import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, CasualVoteDBResult, CasualVoteHashDBResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model";
77
import { HashedIP, IPAddress, Service, VideoID, VideoIDHash, Visibility } from "../types/segments.model";
88
import { shuffleArray } from "../utils/array";
99
import { getHashCache } from "../utils/getHashCache";
@@ -51,10 +51,20 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
5151
{ useReplica: true }
5252
) as Promise<BrandingSegmentDBResult[]>;
5353

54+
const getCasualVotes = () => db.prepare(
55+
"all",
56+
`SELECT "category", "upvotes", "downvotes" FROM "casualVotes"
57+
WHERE "videoID" = ? AND "service" = ?
58+
ORDER BY "timeSubmitted" ASC`,
59+
[videoID, service],
60+
{ useReplica: true }
61+
) as Promise<CasualVoteDBResult[]>;
62+
5463
const getBranding = async () => {
5564
const titles = getTitles();
5665
const thumbnails = getThumbnails();
5766
const segments = getSegments();
67+
const casualVotes = getCasualVotes();
5868

5969
for (const title of await titles) {
6070
title.title = title.title.replace("<", "‹");
@@ -63,7 +73,8 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
6373
return {
6474
titles: await titles,
6575
thumbnails: await thumbnails,
66-
segments: await segments
76+
segments: await segments,
77+
casualVotes: await casualVotes
6778
};
6879
};
6980

@@ -85,7 +96,8 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
8596
currentIP: null as Promise<HashedIP> | null
8697
};
8798

88-
return filterAndSortBranding(videoID, returnUserID, fetchAll, branding.titles, branding.thumbnails, branding.segments, ip, cache);
99+
return filterAndSortBranding(videoID, returnUserID, fetchAll, branding.titles,
100+
branding.thumbnails, branding.segments, branding.casualVotes, ip, cache);
89101
}
90102

91103
export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, service: Service, ip: IPAddress, returnUserID: boolean, fetchAll: boolean): Promise<Record<VideoID, BrandingResult>> {
@@ -117,20 +129,31 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
117129
{ useReplica: true }
118130
) as Promise<BrandingSegmentHashDBResult[]>;
119131

132+
const getCasualVotes = () => db.prepare(
133+
"all",
134+
`SELECT "videoID", "category", "upvotes", "downvotes" FROM "casualVotes"
135+
WHERE "hashedVideoID" LIKE ? AND "service" = ?
136+
ORDER BY "timeSubmitted" ASC`,
137+
[`${videoHashPrefix}%`, service],
138+
{ useReplica: true }
139+
) as Promise<CasualVoteHashDBResult[]>;
140+
120141
const branding = await QueryCacher.get(async () => {
121142
// Make sure they are both called in parallel
122143
const branding = {
123144
titles: getTitles(),
124145
thumbnails: getThumbnails(),
125-
segments: getSegments()
146+
segments: getSegments(),
147+
casualVotes: getCasualVotes()
126148
};
127149

128150
const dbResult: Record<VideoID, BrandingHashDBResult> = {};
129151
const initResult = (submission: BrandingDBSubmissionData) => {
130152
dbResult[submission.videoID] = dbResult[submission.videoID] || {
131153
titles: [],
132154
thumbnails: [],
133-
segments: []
155+
segments: [],
156+
casualVotes: []
134157
};
135158
};
136159

@@ -150,6 +173,11 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
150173
dbResult[segment.videoID].segments.push(segment);
151174
});
152175

176+
(await branding.casualVotes).forEach((casualVote) => {
177+
initResult(casualVote);
178+
dbResult[casualVote.videoID].casualVotes.push(casualVote);
179+
});
180+
153181
return dbResult;
154182
}, brandingHashKey(videoHashPrefix, service));
155183

@@ -162,14 +190,14 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
162190
await Promise.all(Object.keys(branding).map(async (key) => {
163191
const castedKey = key as VideoID;
164192
processedResult[castedKey] = await filterAndSortBranding(castedKey, returnUserID, fetchAll, branding[castedKey].titles,
165-
branding[castedKey].thumbnails, branding[castedKey].segments, ip, cache);
193+
branding[castedKey].thumbnails, branding[castedKey].segments, branding[castedKey].casualVotes, ip, cache);
166194
}));
167195

168196
return processedResult;
169197
}
170198

171199
async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fetchAll: boolean, dbTitles: TitleDBResult[],
172-
dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[],
200+
dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[], dbCasualVotes: CasualVoteDBResult[],
173201
ip: IPAddress, cache: { currentIP: Promise<HashedIP> | null }): Promise<BrandingResult> {
174202

175203
const shouldKeepTitles = shouldKeepSubmission(dbTitles, BrandingSubmissionType.Title, ip, cache);
@@ -202,11 +230,17 @@ async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fe
202230
}))
203231
.filter((a) => (fetchAll && !a.original) || a.votes >= 1 || (a.votes >= 0 && !a.original) || a.locked) as ThumbnailResult[];
204232

233+
const casualVotes = dbCasualVotes.map((r) => ({
234+
id: r.category,
235+
count: r.upvotes - r.downvotes
236+
})).filter((a) => a.count > 0);
237+
205238
const videoDuration = dbSegments.filter(s => s.videoDuration !== 0)[0]?.videoDuration ?? null;
206239

207240
return {
208241
titles,
209242
thumbnails,
243+
casualVotes,
210244
randomTime: findRandomTime(videoID, dbSegments, videoDuration),
211245
videoDuration: videoDuration,
212246
};
@@ -303,7 +337,7 @@ export async function getBranding(req: Request, res: Response) {
303337
.then(etag => res.set("ETag", etag))
304338
.catch(() => null);
305339

306-
const status = result.titles.length > 0 || result.thumbnails.length > 0 ? 200 : 404;
340+
const status = result.titles.length > 0 || result.thumbnails.length > 0 || result.casualVotes.length > 0 ? 200 : 404;
307341
return res.status(status).json(result);
308342
} catch (e) {
309343
Logger.error(e as string);

src/routes/postCasual.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Request, Response } from "express";
2+
import { config } from "../config";
3+
import { db, privateDB } from "../databases/databases";
4+
5+
import { BrandingUUID, CasualCategory, CasualVoteSubmission } from "../types/branding.model";
6+
import { HashedIP, IPAddress, Service, VideoID } from "../types/segments.model";
7+
import { HashedUserID } from "../types/user.model";
8+
import { getHashCache } from "../utils/getHashCache";
9+
import { getIP } from "../utils/getIP";
10+
import { getService } from "../utils/getService";
11+
import { Logger } from "../utils/logger";
12+
import crypto from "crypto";
13+
import { QueryCacher } from "../utils/queryCacher";
14+
import { acquireLock } from "../utils/redisLock";
15+
import { checkBanStatus } from "../utils/checkBan";
16+
17+
enum CasualVoteType {
18+
Upvote = 1,
19+
Downvote = 2
20+
}
21+
22+
interface ExistingVote {
23+
UUID: BrandingUUID;
24+
type: number;
25+
}
26+
27+
export async function postCasual(req: Request, res: Response) {
28+
const { videoID, userID, downvote, category } = req.body as CasualVoteSubmission;
29+
const service = getService(req.body.service);
30+
31+
if (!videoID || !userID || userID.length < 30 || !service || !category) {
32+
return res.status(400).send("Bad Request");
33+
}
34+
if (!config.casualCategoryList.includes(category)) {
35+
return res.status(400).send("Invalid category");
36+
}
37+
38+
try {
39+
const hashedUserID = await getHashCache(userID);
40+
const hashedVideoID = await getHashCache(videoID, 1);
41+
const hashedIP = await getHashCache(getIP(req) + config.globalSalt as IPAddress);
42+
const isBanned = await checkBanStatus(hashedUserID, hashedIP);
43+
44+
const lock = await acquireLock(`postCasual:${videoID}.${hashedUserID}`);
45+
if (!lock.status) {
46+
res.status(429).send("Vote already in progress");
47+
return;
48+
}
49+
50+
if (isBanned) {
51+
return res.status(200).send("OK");
52+
}
53+
54+
const now = Date.now();
55+
const voteType: CasualVoteType = downvote ? CasualVoteType.Downvote : CasualVoteType.Upvote;
56+
57+
const existingUUID = (await db.prepare("get", `SELECT "UUID" from "casualVotes" where "videoID" = ? AND "category" = ?`, [videoID, category]))?.UUID;
58+
const UUID = existingUUID || crypto.randomUUID();
59+
60+
const alreadyVotedTheSame = await handleExistingVotes(videoID, service, UUID, hashedUserID, hashedIP, category, voteType, now);
61+
if (existingUUID) {
62+
if (!alreadyVotedTheSame) {
63+
if (downvote) {
64+
await db.prepare("run", `UPDATE "casualVotes" SET "downvotes" = "downvotes" + 1 WHERE "UUID" = ?`, [UUID]);
65+
} else {
66+
await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" + 1 WHERE "UUID" = ?`, [UUID]);
67+
}
68+
}
69+
} else {
70+
if (downvote) {
71+
throw new Error("Title submission doesn't exist");
72+
}
73+
74+
await db.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "hashedVideoID", "timeSubmitted", "UUID", "category", "upvotes", "downvotes") VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
75+
[videoID, service, hashedVideoID, now, UUID, category, downvote ? 0 : 1, downvote ? 1 : 0]);
76+
}
77+
78+
//todo: cache clearing
79+
QueryCacher.clearBrandingCache({ videoID, hashedVideoID, service });
80+
81+
res.status(200).send("OK");
82+
83+
lock.unlock();
84+
} catch (e) {
85+
Logger.error(e as string);
86+
res.status(500).send("Internal Server Error");
87+
}
88+
}
89+
90+
async function handleExistingVotes(videoID: VideoID, service: Service, UUID: string,
91+
hashedUserID: HashedUserID, hashedIP: HashedIP, category: CasualCategory, voteType: CasualVoteType, now: number): Promise<boolean> {
92+
const existingVote = await privateDB.prepare("get", `SELECT "UUID", "type" from "casualVotes" WHERE "videoID" = ? AND "service" = ? AND "userID" = ? AND category = ?`, [videoID, service, hashedUserID, category]) as ExistingVote;
93+
if (existingVote) {
94+
if (existingVote.type === voteType) {
95+
return true;
96+
}
97+
98+
if (existingVote.type === CasualVoteType.Upvote) {
99+
await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "UUID" = ?`, [UUID]);
100+
} else {
101+
await db.prepare("run", `UPDATE "casualVotes" SET "downvotes" = "downvotes" - 1 WHERE "UUID" = ?`, [UUID]);
102+
}
103+
104+
await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "UUID" = ?`, [existingVote.UUID]);
105+
}
106+
107+
await privateDB.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "userID", "hashedIP", "category", "type", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?, ?)`,
108+
[videoID, service, hashedUserID, hashedIP, category, voteType, now]);
109+
110+
return false;
111+
}

0 commit comments

Comments
 (0)