From 31e678fdc24b646303620f21c856f598db165158 Mon Sep 17 00:00:00 2001 From: Ajay Date: Mon, 17 Feb 2025 03:16:57 -0500 Subject: [PATCH] Store titles for casual vote submissions When an uploader changes the title, it will reset the casual votes --- databases/_sponsorTimes.db.sql | 9 +++++ databases/_upgrade_private_13.sql | 7 ++++ databases/_upgrade_sponsorTimes_43.sql | 7 ++++ src/config.ts | 3 ++ src/routes/getBranding.ts | 17 +++++---- src/routes/postCasual.ts | 51 +++++++++++++++++++------- src/types/branding.model.ts | 5 ++- test/cases/getBranding.ts | 26 ++++++++++++- test/cases/postCasual.ts | 40 +++++++++++++++++++- test/utils/partialDeepEquals.ts | 2 +- 10 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 databases/_upgrade_private_13.sql create mode 100644 databases/_upgrade_sponsorTimes_43.sql diff --git a/databases/_sponsorTimes.db.sql b/databases/_sponsorTimes.db.sql index a7f3919..480575a 100644 --- a/databases/_sponsorTimes.db.sql +++ b/databases/_sponsorTimes.db.sql @@ -95,6 +95,15 @@ CREATE TABLE IF NOT EXISTS "casualVotes" ( "timeSubmitted" INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS "casualVoteTitles" ( + "videoID" TEXT NOT NULL, + "service" TEXT NOT NULL, + "id" INTEGER NOT NULL, + "hashedVideoID" TEXT NOT NULL, + "title" TEXT NOT NULL, + PRIMARY KEY("videoID", "service", "id") +); + CREATE EXTENSION IF NOT EXISTS pgcrypto; --!sqlite-ignore CREATE EXTENSION IF NOT EXISTS pg_trgm; --!sqlite-ignore diff --git a/databases/_upgrade_private_13.sql b/databases/_upgrade_private_13.sql new file mode 100644 index 0000000..771e94b --- /dev/null +++ b/databases/_upgrade_private_13.sql @@ -0,0 +1,7 @@ +BEGIN TRANSACTION; + +ALTER TABLE "casualVotes" ADD "titleID" INTEGER default 0; + +UPDATE "config" SET value = 13 WHERE key = 'version'; + +COMMIT; \ No newline at end of file diff --git a/databases/_upgrade_sponsorTimes_43.sql b/databases/_upgrade_sponsorTimes_43.sql new file mode 100644 index 0000000..b7bc074 --- /dev/null +++ b/databases/_upgrade_sponsorTimes_43.sql @@ -0,0 +1,7 @@ +BEGIN TRANSACTION; + +ALTER TABLE "casualVotes" ADD "titleID" INTEGER default 0; + +UPDATE "config" SET value = 43 WHERE key = 'version'; + +COMMIT; diff --git a/src/config.ts b/src/config.ts index 61589b4..75b4590 100644 --- a/src/config.ts +++ b/src/config.ts @@ -153,6 +153,9 @@ addDefaults(config, { { name: "casualVotes", order: "timeSubmitted" + }, + { + name: "casualVoteTitles" }] }, diskCacheURL: null, diff --git a/src/routes/getBranding.ts b/src/routes/getBranding.ts index 345d2cc..f518f8a 100644 --- a/src/routes/getBranding.ts +++ b/src/routes/getBranding.ts @@ -53,9 +53,10 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service: const getCasualVotes = () => db.prepare( "all", - `SELECT "category", "upvotes" FROM "casualVotes" - WHERE "videoID" = ? AND "service" = ? - ORDER BY "timeSubmitted" ASC`, + `SELECT "casualVotes"."category", "casualVotes"."upvotes", "casualVoteTitles"."title" + FROM "casualVotes" LEFT JOIN "casualVoteTitles" ON "casualVotes"."videoID" = "casualVoteTitles"."videoID" AND "casualVotes"."service" = "casualVoteTitles"."service" AND "casualVotes"."titleID" = "casualVoteTitles"."id" + WHERE "casualVotes"."videoID" = ? AND "casualVotes"."service" = ? + ORDER BY "casualVotes"."timeSubmitted" ASC`, [videoID, service], { useReplica: true } ) as Promise; @@ -131,9 +132,10 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi const getCasualVotes = () => db.prepare( "all", - `SELECT "videoID", "category", "upvotes" FROM "casualVotes" - WHERE "hashedVideoID" LIKE ? AND "service" = ? - ORDER BY "timeSubmitted" ASC`, + `SELECT "casualVotes"."videoID", "casualVotes"."category", "casualVotes"."upvotes", "casualVoteTitles"."title" + FROM "casualVotes" LEFT JOIN "casualVoteTitles" ON "casualVotes"."videoID" = "casualVoteTitles"."videoID" AND "casualVotes"."service" = "casualVoteTitles"."service" AND "casualVotes"."titleID" = "casualVoteTitles"."id" + WHERE "casualVotes"."hashedVideoID" LIKE ? AND "casualVotes"."service" = ? + ORDER BY "casualVotes"."timeSubmitted" ASC`, [`${videoHashPrefix}%`, service], { useReplica: true } ) as Promise; @@ -233,7 +235,8 @@ async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fe const casualDownvotes = dbCasualVotes.filter((r) => r.category === "downvote")[0]; const casualVotes = dbCasualVotes.filter((r) => r.category !== "downvote").map((r) => ({ id: r.category, - count: r.upvotes - (casualDownvotes?.upvotes ?? 0) + count: r.upvotes - (casualDownvotes?.upvotes ?? 0), + title: r.title })).filter((a) => a.count > 0); const videoDuration = dbSegments.filter(s => s.videoDuration !== 0)[0]?.videoDuration ?? null; diff --git a/src/routes/postCasual.ts b/src/routes/postCasual.ts index 8a026d3..4e83d27 100644 --- a/src/routes/postCasual.ts +++ b/src/routes/postCasual.ts @@ -22,6 +22,7 @@ interface ExistingVote { export async function postCasual(req: Request, res: Response) { const { videoID, userID, downvote } = req.body as CasualVoteSubmission; let categories = req.body.categories as CasualCategory[]; + const title = (req.body.title as string)?.toLowerCase(); const service = getService(req.body.service); if (downvote) { @@ -50,19 +51,41 @@ export async function postCasual(req: Request, res: Response) { return res.status(200).send("OK"); } + let titleID = 0; + if (title) { + // See if title needs to be added + const titles = await db.prepare("all", `SELECT "title", "id" from "casualVoteTitles" WHERE "videoID" = ? AND "service" = ? ORDER BY "id"`, [videoID, service]) as { title: string, id: number }[]; + if (titles.length > 0) { + const existingTitle = titles.find((t) => t.title === title); + if (existingTitle) { + titleID = existingTitle.id; + } else { + titleID = titles[titles.length - 1].id + 1; + await db.prepare("run", `INSERT INTO "casualVoteTitles" ("videoID", "service", "hashedVideoID", "id", "title") VALUES (?, ?, ?, ?, ?)`, [videoID, service, hashedVideoID, titleID, title]); + } + } else { + await db.prepare("run", `INSERT INTO "casualVoteTitles" ("videoID", "service", "hashedVideoID", "id", "title") VALUES (?, ?, ?, ?, ?)`, [videoID, service, hashedVideoID, titleID, title]); + } + } else { + const titles = await db.prepare("all", `SELECT "title", "id" from "casualVoteTitles" WHERE "videoID" = ? AND "service" = ? ORDER BY "id"`, [videoID, service]) as { title: string, id: number }[]; + if (titles.length > 0) { + titleID = titles[titles.length - 1].id; + } + } + const now = Date.now(); for (const category of categories) { - const existingUUID = (await db.prepare("get", `SELECT "UUID" from "casualVotes" where "videoID" = ? AND "category" = ?`, [videoID, category]))?.UUID; + const existingUUID = (await db.prepare("get", `SELECT "UUID" from "casualVotes" where "videoID" = ? AND "service" = ? AND "titleID" = ? AND "category" = ?`, [videoID, service, titleID, category]))?.UUID; const UUID = existingUUID || crypto.randomUUID(); - const alreadyVotedTheSame = await handleExistingVotes(videoID, service, UUID, hashedUserID, hashedIP, category, downvote, now); + const alreadyVotedTheSame = await handleExistingVotes(videoID, service, titleID, hashedUserID, hashedIP, category, downvote, now); if (existingUUID) { if (!alreadyVotedTheSame) { await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" + 1 WHERE "UUID" = ?`, [UUID]); } } else { - await db.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "hashedVideoID", "timeSubmitted", "UUID", "category", "upvotes") VALUES (?, ?, ?, ?, ?, ?, ?)`, - [videoID, service, hashedVideoID, now, UUID, category, 1]); + await db.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "titleID", "hashedVideoID", "timeSubmitted", "UUID", "category", "upvotes") VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [videoID, service, titleID, hashedVideoID, now, UUID, category, 1]); } } @@ -77,31 +100,31 @@ export async function postCasual(req: Request, res: Response) { } } -async function handleExistingVotes(videoID: VideoID, service: Service, UUID: string, +async function handleExistingVotes(videoID: VideoID, service: Service, titleID: number, hashedUserID: HashedUserID, hashedIP: HashedIP, category: CasualCategory, downvote: boolean, now: number): Promise { - const existingVote = await privateDB.prepare("get", `SELECT "UUID" from "casualVotes" WHERE "videoID" = ? AND "service" = ? AND "userID" = ? AND "category" = ?`, [videoID, service, hashedUserID, category]) as ExistingVote; + const existingVote = await privateDB.prepare("get", `SELECT "UUID" from "casualVotes" WHERE "videoID" = ? AND "service" = ? AND "titleID" = ? AND "userID" = ? AND "category" = ?`, [videoID, service, titleID, hashedUserID, category]) as ExistingVote; if (existingVote) { return true; } else { if (downvote) { // Remove upvotes for all categories on this video - const existingUpvotes = await privateDB.prepare("all", `SELECT "category" from "casualVotes" WHERE "category" != 'downvote' AND "videoID" = ? AND "service" = ? AND "userID" = ?`, [videoID, service, hashedUserID]); + const existingUpvotes = await privateDB.prepare("all", `SELECT "category" from "casualVotes" WHERE "category" != 'downvote' AND "videoID" = ? AND "service" = ? AND "titleID" = ? AND "userID" = ?`, [videoID, service, titleID, hashedUserID]); for (const existingUpvote of existingUpvotes) { - await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "videoID" = ? AND "category" = ?`, [videoID, existingUpvote.category]); - await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "videoID" = ? AND "userID" = ? AND "category" = ?`, [videoID, hashedUserID, existingUpvote.category]); + await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "videoID" = ? AND "service" = ? AND "titleID" = ? AND "category" = ?`, [videoID, service, titleID, existingUpvote.category]); + await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "videoID" = ? AND "service" = ? AND "titleID" = ? AND "userID" = ? AND "category" = ?`, [videoID, service, titleID, hashedUserID, existingUpvote.category]); } } else { // Undo a downvote if it exists - const existingDownvote = await privateDB.prepare("get", `SELECT "UUID" from "casualVotes" WHERE "category" = 'downvote' AND "videoID" = ? AND "service" = ? AND "userID" = ?`, [videoID, service, hashedUserID]) as ExistingVote; + const existingDownvote = await privateDB.prepare("get", `SELECT "UUID" from "casualVotes" WHERE "category" = 'downvote' AND "videoID" = ? AND "service" = ? AND "titleID" = ? AND "userID" = ?`, [videoID, service, titleID, hashedUserID]) as ExistingVote; if (existingDownvote) { - await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "category" = 'downvote' AND "videoID" = ?`, [videoID]); - await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "category" = 'downvote' AND "videoID" = ? AND "userID" = ?`, [videoID, hashedUserID]); + await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "category" = 'downvote' AND "videoID" = ? AND "service" = ? AND "titleID" = ?`, [videoID, service, titleID]); + await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "category" = 'downvote' AND "videoID" = ? AND "service" = ? AND "titleID" = ? AND "userID" = ?`, [videoID, service, titleID, hashedUserID]); } } } - await privateDB.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "userID", "hashedIP", "category", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?)`, - [videoID, service, hashedUserID, hashedIP, category, now]); + await privateDB.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "titleID", "userID", "hashedIP", "category", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?, ?)`, + [videoID, service, titleID, hashedUserID, hashedIP, category, now]); return false; } \ No newline at end of file diff --git a/src/types/branding.model.ts b/src/types/branding.model.ts index f4eae0e..bd6e64b 100644 --- a/src/types/branding.model.ts +++ b/src/types/branding.model.ts @@ -54,7 +54,8 @@ export interface ThumbnailResult { export interface CasualVote { id: string, - count: number + count: number, + title: string | null } export interface BrandingResult { @@ -107,6 +108,7 @@ export interface CasualVoteSubmission { service: Service; downvote: boolean | undefined; categories: CasualCategory[]; + title?: string; } export interface BrandingSegmentDBResult { @@ -120,6 +122,7 @@ export interface CasualVoteDBResult { category: CasualCategory; upvotes: number; downvotes: number; + title?: string; } export interface BrandingSegmentHashDBResult extends BrandingDBSubmissionData { diff --git a/test/cases/getBranding.ts b/test/cases/getBranding.ts index bfd47d0..3aa88b3 100644 --- a/test/cases/getBranding.ts +++ b/test/cases/getBranding.ts @@ -16,6 +16,7 @@ describe("getBranding", () => { const videoIDvidDuration = "videoID7"; const videoIDCasual = "videoIDCasual"; const videoIDCasualDownvoted = "videoIDCasualDownvoted"; + const videoIDCasualTitle = "videoIDCasualTitle"; const videoID1Hash = getHash(videoID1, 1).slice(0, 4); const videoID2LockedHash = getHash(videoID2Locked, 1).slice(0, 4); @@ -26,6 +27,7 @@ describe("getBranding", () => { const videoIDvidDurationHash = getHash(videoIDUnverified, 1).slice(0, 4); const videoIDCasualHash = getHash(videoIDCasual, 1).slice(0, 4); const videoIDCasualDownvotedHash = getHash(videoIDCasualDownvoted, 1).slice(0, 4); + const videoIDCasualTitleHash = getHash(videoIDCasualTitle, 1).slice(0, 4); const endpoint = "/api/branding"; const getBranding = (params: Record) => client({ @@ -48,6 +50,7 @@ describe("getBranding", () => { const thumbnailVotesQuery = `INSERT INTO "thumbnailVotes" ("UUID", "votes", "locked", "shadowHidden", "downvotes", "removed") VALUES (?, ?, ?, ?, ?, ?)`; const segmentQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", "views", "category", "actionType", "service", "videoDuration", "hidden", "shadowHidden", "description", "hashedVideoID") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; const insertCasualVotesQuery = `INSERT INTO "casualVotes" ("UUID", "videoID", "service", "hashedVideoID", "category", "upvotes", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?, ?)`; + const insertCasualVotesTitleQuery = `INSERT INTO "casualVoteTitles" ("videoID", "service", "hashedVideoID", "id", "title") VALUES (?, ?, ?, ?, ?)`; await Promise.all([ db.prepare("run", titleQuery, [videoID1, "title1", 0, "userID1", Service.YouTube, videoID1Hash, 1, "UUID1"]), @@ -154,6 +157,13 @@ describe("getBranding", () => { db.prepare("run", insertCasualVotesQuery, ["postBrandCasual2", videoIDCasualDownvoted, Service.YouTube, videoIDCasualDownvotedHash, "clever", 1, Date.now()]), db.prepare("run", insertCasualVotesQuery, ["postBrandCasual2d", videoIDCasualDownvoted, Service.YouTube, videoIDCasualDownvotedHash, "downvote", 1, Date.now()]), db.prepare("run", insertCasualVotesQuery, ["postBrandCasual3", videoIDCasualDownvoted, Service.YouTube, videoIDCasualDownvotedHash, "other", 4, Date.now()]), + db.prepare("run", insertCasualVotesQuery, ["postBrandCasual4", videoIDCasualTitle, Service.YouTube, videoIDCasualTitleHash, "clever", 8, Date.now()]), + db.prepare("run", insertCasualVotesQuery, ["postBrandCasual4d", videoIDCasualTitle, Service.YouTube, videoIDCasualTitleHash, "downvote", 4, Date.now()]), + db.prepare("run", insertCasualVotesQuery, ["postBrandCasual4o", videoIDCasualTitle, Service.YouTube, videoIDCasualTitleHash, "other", 3, Date.now()]), + ]); + + await Promise.all([ + db.prepare("run", insertCasualVotesTitleQuery, [videoIDCasualTitle, Service.YouTube, videoIDCasualTitleHash, 0, "a cool title"]), ]); }); @@ -351,7 +361,8 @@ describe("getBranding", () => { await checkVideo(videoIDCasual, videoIDCasualHash, true, { casualVotes: [{ id: "clever", - count: 1 + count: 1, + title: null }] }); }); @@ -360,7 +371,18 @@ describe("getBranding", () => { await checkVideo(videoIDCasualDownvoted, videoIDCasualDownvotedHash, true, { casualVotes: [{ id: "other", - count: 3 + count: 3, + title: null + }] + }); + }); + + it("should get casual votes with title", async () => { + await checkVideo(videoIDCasualTitle, videoIDCasualTitleHash, true, { + casualVotes: [{ + id: "clever", + count: 4, + title: "a cool title" }] }); }); diff --git a/test/cases/postCasual.ts b/test/cases/postCasual.ts index 588e136..5c4cc92 100644 --- a/test/cases/postCasual.ts +++ b/test/cases/postCasual.ts @@ -16,7 +16,7 @@ describe("postCasual", () => { data }); - const queryCasualVotesByVideo = (videoID: string, all = false) => db.prepare(all ? "all" : "get", `SELECT * FROM "casualVotes" WHERE "videoID" = ? ORDER BY "timeSubmitted" ASC`, [videoID]); + const queryCasualVotesByVideo = (videoID: string, all = false, titleID = 0) => db.prepare(all ? "all" : "get", `SELECT * FROM "casualVotes" WHERE "videoID" = ? AND "titleID" = ? ORDER BY "timeSubmitted" ASC`, [videoID, titleID]); it("submit casual vote", async () => { const videoID = "postCasual1"; @@ -25,6 +25,7 @@ describe("postCasual", () => { categories: ["clever"], userID: userID1, service: Service.YouTube, + title: "title", videoID }); @@ -42,6 +43,7 @@ describe("postCasual", () => { categories: ["clever"], userID: userID1, service: Service.YouTube, + title: "title", videoID }); @@ -59,6 +61,7 @@ describe("postCasual", () => { categories: ["clever"], userID: userID2, service: Service.YouTube, + title: "title", videoID }); @@ -96,6 +99,7 @@ describe("postCasual", () => { downvote: true, userID: userID3, service: Service.YouTube, + title: "title", videoID }); @@ -117,6 +121,7 @@ describe("postCasual", () => { downvote: false, userID: userID3, service: Service.YouTube, + title: "title", videoID }); @@ -137,6 +142,7 @@ describe("postCasual", () => { categories: ["clever", "other"], userID: userID1, service: Service.YouTube, + title: "title", videoID }); @@ -157,6 +163,7 @@ describe("postCasual", () => { downvote: true, userID: userID1, service: Service.YouTube, + title: "title", videoID }); @@ -180,6 +187,7 @@ describe("postCasual", () => { categories: ["clever", "other"], userID: userID1, service: Service.YouTube, + title: "title", videoID }); @@ -199,6 +207,7 @@ describe("postCasual", () => { const res = await postCasual({ userID: userID1, service: Service.YouTube, + title: "title", videoID, downvote: true }); @@ -210,4 +219,33 @@ describe("postCasual", () => { assert.strictEqual(dbVotes.upvotes, 1); }); + it("submit multiple casual votes for different title", async () => { + const videoID = "postCasual2"; + + const res = await postCasual({ + categories: ["clever", "funny"], + userID: userID2, + service: Service.YouTube, + title: "title 2", + videoID + }); + + assert.strictEqual(res.status, 200); + const dbVotes = await queryCasualVotesByVideo(videoID, true, 1); + + assert.strictEqual(dbVotes[0].category, "clever"); + assert.strictEqual(dbVotes[0].upvotes, 1); + + assert.strictEqual(dbVotes[1].category, "funny"); + assert.strictEqual(dbVotes[1].upvotes, 1); + + const dbVotesOriginal = await queryCasualVotesByVideo(videoID, true, 0); + + assert.strictEqual(dbVotesOriginal[0].category, "clever"); + assert.strictEqual(dbVotesOriginal[0].upvotes, 1); + + assert.strictEqual(dbVotesOriginal[1].category, "other"); + assert.strictEqual(dbVotesOriginal[1].upvotes, 1); + }); + }); diff --git a/test/utils/partialDeepEquals.ts b/test/utils/partialDeepEquals.ts index 1077da5..741e2c6 100644 --- a/test/utils/partialDeepEquals.ts +++ b/test/utils/partialDeepEquals.ts @@ -10,7 +10,7 @@ export const partialDeepEquals = (actual: Record, expected: Record< // loop over key, value of expected for (const [key, value] of Object.entries(expected)) { // if value is object or array, recurse - if (Array.isArray(value) || typeof value === "object") { + if (Array.isArray(value) || (typeof value === "object" && value !== null)) { if (!partialDeepEquals(actual?.[key], value, false)) { if (print) printActualExpected(actual, expected, key); return false;