Store titles for casual vote submissions

When an uploader changes the title, it will reset the casual votes
This commit is contained in:
Ajay
2025-02-17 03:16:57 -05:00
parent d44ce3c2dc
commit 31e678fdc2
10 changed files with 141 additions and 26 deletions

View File

@@ -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

View File

@@ -0,0 +1,7 @@
BEGIN TRANSACTION;
ALTER TABLE "casualVotes" ADD "titleID" INTEGER default 0;
UPDATE "config" SET value = 13 WHERE key = 'version';
COMMIT;

View File

@@ -0,0 +1,7 @@
BEGIN TRANSACTION;
ALTER TABLE "casualVotes" ADD "titleID" INTEGER default 0;
UPDATE "config" SET value = 43 WHERE key = 'version';
COMMIT;

View File

@@ -153,6 +153,9 @@ addDefaults(config, {
{
name: "casualVotes",
order: "timeSubmitted"
},
{
name: "casualVoteTitles"
}]
},
diskCacheURL: null,

View File

@@ -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<CasualVoteDBResult[]>;
@@ -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<CasualVoteHashDBResult[]>;
@@ -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;

View File

@@ -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<boolean> {
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;
}

View File

@@ -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 {

View File

@@ -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<string, any>) => 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"
}]
});
});

View File

@@ -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);
});
});

View File

@@ -10,7 +10,7 @@ export const partialDeepEquals = (actual: Record<string, any>, 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;