diff --git a/src/app.ts b/src/app.ts index 267e44d..c922cb4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -48,6 +48,8 @@ import { getRating } from "./routes/ratings/getRating"; import { postClearCache as ratingPostClearCache } from "./routes/ratings/postClearCache"; import { getTopCategoryUsers } from "./routes/getTopCategoryUsers"; import { addUserAsTempVIP } from "./routes/addUserAsTempVIP"; +import { endpoint as getVideoLabels } from "./routes/getVideoLabel"; +import { getVideoLabelsByHash } from "./routes/getVideoLabelByHash"; export function createServer(callback: () => void): Server { // Create a service (the app object is just a callback). @@ -202,6 +204,10 @@ function setupRoutes(router: Router) { router.post("/api/ratings/rate", postRateEndpoints); router.post("/api/ratings/clearCache", ratingPostClearCache); + // labels + router.get("/api/videoLabels", getVideoLabels); + router.get("/api/videoLabels/:prefix", getVideoLabelsByHash); + if (config.postgres) { router.get("/database", (req, res) => dumpDatabase(req, res, true)); router.get("/database.json", (req, res) => dumpDatabase(req, res, false)); diff --git a/src/routes/getVideoLabel.ts b/src/routes/getVideoLabel.ts new file mode 100644 index 0000000..fc98c5b --- /dev/null +++ b/src/routes/getVideoLabel.ts @@ -0,0 +1,176 @@ +import { Request, Response } from "express"; +import { db } from "../databases/databases"; +import { videoLabelsHashKey, videoLabelsKey } from "../utils/redisKeys"; +import { SBRecord } from "../types/lib.model"; +import { DBSegment, Segment, Service, VideoData, VideoID, VideoIDHash } from "../types/segments.model"; +import { Logger } from "../utils/logger"; +import { QueryCacher } from "../utils/queryCacher"; +import { getService } from "../utils/getService"; + +function transformDBSegments(segments: DBSegment[]): Segment[] { + return segments.map((chosenSegment) => ({ + category: chosenSegment.category, + actionType: chosenSegment.actionType, + segment: [chosenSegment.startTime, chosenSegment.endTime], + UUID: chosenSegment.UUID, + locked: chosenSegment.locked, + votes: chosenSegment.votes, + videoDuration: chosenSegment.videoDuration, + userID: chosenSegment.userID, + description: chosenSegment.description + })); +} + +async function getLabelsByVideoID(videoID: VideoID, service: Service): Promise { + try { + const segments: DBSegment[] = await getSegmentsFromDBByVideoID(videoID, service); + console.log("dbSegments", segments); + return chooseSegment(segments); + } catch (err) { + if (err) { + Logger.error(err as string); + return null; + } + } +} + +async function getLabelsbyHash(hashedVideoIDPrefix: VideoIDHash, service: Service): Promise> { + const segments: SBRecord = {}; + + try { + type SegmentWithHashPerVideoID = SBRecord; + + const segmentPerVideoID: SegmentWithHashPerVideoID = (await getSegmentsFromDBByHash(hashedVideoIDPrefix, service)) + .reduce((acc: SegmentWithHashPerVideoID, segment: DBSegment) => { + acc[segment.videoID] = acc[segment.videoID] || { + hash: segment.hashedVideoID, + segments: [] + }; + + acc[segment.videoID].segments ??= []; + acc[segment.videoID].segments.push(segment); + + return acc; + }, {}); + + for (const [videoID, videoData] of Object.entries(segmentPerVideoID)) { + const data: VideoData = { + hash: videoData.hash, + segments: chooseSegment(videoData.segments), + }; + + if (data.segments.length > 0) { + segments[videoID] = data; + } + } + + return segments; + } catch (err) { + Logger.error(err as string); + return null; + } +} + +async function getSegmentsFromDBByHash(hashedVideoIDPrefix: VideoIDHash, service: Service): Promise { + const fetchFromDB = () => db + .prepare( + "all", + `SELECT "startTime", "endTime", "videoID", "votes", "locked", "UUID", "userID", "category", "actionType", "hashedVideoID", "description" FROM "sponsorTimes" + WHERE "hashedVideoID" LIKE ? AND "service" = ? AND actionType = 'full' AND "hidden" = 0 AND "shadowHidden" = 0`, + [`${hashedVideoIDPrefix}%`, service] + ) as Promise; + + if (hashedVideoIDPrefix.length === 4) { + return await QueryCacher.get(fetchFromDB, videoLabelsHashKey(hashedVideoIDPrefix, service)); + } + + return await fetchFromDB(); +} + +async function getSegmentsFromDBByVideoID(videoID: VideoID, service: Service): Promise { + const fetchFromDB = () => db + .prepare( + "all", + `SELECT "startTime", "endTime", "votes", "locked", "UUID", "userID", "category", "actionType", "description" FROM "sponsorTimes" + WHERE "videoID" = ? AND "service" = ? AND "hidden" = 0 AND "shadowHidden" = 0`, + [videoID, service] + ) as Promise; + + return await QueryCacher.get(fetchFromDB, videoLabelsKey(videoID, service)); +} + +function chooseSegment(choices: T[]): Segment[] { + // filter out -2 segments + choices = choices.filter((segment) => segment.votes > -2); + const results = []; + // trivial decisions + if (choices.length === 0) { + return []; + } else if (choices.length === 1) { + return transformDBSegments(choices); + } + // if locked, only choose from locked + const locked = choices.filter((segment) => segment.locked); + if (locked.length > 0) { + choices = locked; + } + //no need to filter, just one label + if (choices.length === 1) { + return transformDBSegments(choices); + } + // sponsor > exclusive > selfpromo + const sponsorResult = choices.find((segment) => segment.category === "sponsor"); + const eaResult = choices.find((segment) => segment.category === "exclusive_access"); + const selfpromoResult = choices.find((segment) => segment.category === "selfpromo"); + if (sponsorResult) { + results.push(sponsorResult); + } else if (eaResult) { + results.push(eaResult); + } else if (selfpromoResult) { + results.push(selfpromoResult); + } + console.log("chosenresults", results); + return transformDBSegments(results); +} + +async function handleGetLabel(req: Request, res: Response): Promise { + const videoID = req.query.videoID as VideoID; + if (!videoID) { + res.status(400).send("videoID not specified"); + return false; + } + + const service = getService(req.query.service, req.body.service); + const segments = await getLabelsByVideoID(videoID, service); + + console.log("segments", segments); + + if (!segments || segments.length === 0) { + res.sendStatus(404); + return false; + } + + return segments; +} + +async function endpoint(req: Request, res: Response): Promise { + try { + const segments = await handleGetLabel(req, res); + + // If false, res.send has already been called + if (segments) { + //send result + return res.send(segments); + } + } catch (err) { + if (err instanceof SyntaxError) { + return res.status(400).send("Categories parameter does not match format requirements."); + } else return res.sendStatus(500); + } +} + +export { + getLabelsByVideoID, + getLabelsbyHash, + endpoint +}; diff --git a/src/routes/getVideoLabelByHash.ts b/src/routes/getVideoLabelByHash.ts new file mode 100644 index 0000000..7ffc0e3 --- /dev/null +++ b/src/routes/getVideoLabelByHash.ts @@ -0,0 +1,27 @@ +import { hashPrefixTester } from "../utils/hashPrefixTester"; +import { getLabelsbyHash } from "./getVideoLabel"; +import { Request, Response } from "express"; +import { VideoIDHash, Service } from "../types/segments.model"; +import { getService } from "../utils/getService"; + +export async function getVideoLabelsByHash(req: Request, res: Response): Promise { + let hashPrefix = req.params.prefix as VideoIDHash; + if (!req.params.prefix || !hashPrefixTester(req.params.prefix)) { + return res.status(400).send("Hash prefix does not match format requirements."); // Exit early on faulty prefix + } + hashPrefix = hashPrefix.toLowerCase() as VideoIDHash; + + const service: Service = getService(req.query.service, req.body.service); + + // Get all video id's that match hash prefix + const segments = await getLabelsbyHash(hashPrefix, service); + + if (!segments) return res.status(404).json([]); + + const output = Object.entries(segments).map(([videoID, data]) => ({ + videoID, + hash: data.hash, + segments: data.segments, + })); + return res.status(output.length === 0 ? 404 : 200).json(output); +} diff --git a/src/utils/redisKeys.ts b/src/utils/redisKeys.ts index 2da45a8..90f7343 100644 --- a/src/utils/redisKeys.ts +++ b/src/utils/redisKeys.ts @@ -36,4 +36,14 @@ export function shaHashKey(singleIter: HashedValue): string { } export const tempVIPKey = (userID: HashedUserID): string => - `vip.temp.${userID}`; \ No newline at end of file + `vip.temp.${userID}`; + +export const videoLabelsKey = (videoID: VideoID, service: Service): string => + `labels.v1.${service}.videoID.${videoID}`; + +export function videoLabelsHashKey(hashedVideoIDPrefix: VideoIDHash, service: Service): string { + hashedVideoIDPrefix = hashedVideoIDPrefix.substring(0, 4) as VideoIDHash; + if (hashedVideoIDPrefix.length !== 4) Logger.warn(`Redis skip segment hash-prefix key is not length 4! ${hashedVideoIDPrefix}`); + + return `labels.v1.${service}.${hashedVideoIDPrefix}`; +} \ No newline at end of file diff --git a/test/cases/getVideoLabels.ts b/test/cases/getVideoLabels.ts new file mode 100644 index 0000000..7202d2c --- /dev/null +++ b/test/cases/getVideoLabels.ts @@ -0,0 +1,170 @@ +import { db } from "../../src/databases/databases"; +import assert from "assert"; +import { client } from "../utils/httpClient"; + +describe("getVideoLabels", () => { + const endpoint = "/api/videoLabels"; + before(async () => { + const query = 'INSERT INTO "sponsorTimes" ("videoID", "votes", "locked", "UUID", "userID", "timeSubmitted", "category", "actionType", "hidden", "shadowHidden", "startTime", "endTime", "views") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0)'; + await db.prepare("run", query, ["getLabelSponsor" , 2, 0, "label01", "labeluser", 0, "sponsor", "full", 0, 0]); + await db.prepare("run", query, ["getLabelEA" , 2, 0, "label02", "labeluser", 0, "exclusive_access", "full", 0, 0]); + await db.prepare("run", query, ["getLabelSelfpromo" , 2, 0, "label03", "labeluser", 0, "selfpromo", "full", 0, 0]); + // priority override + await db.prepare("run", query, ["getLabelPriority" , 2, 0, "label04", "labeluser", 0, "sponsor", "full", 0, 0]); + await db.prepare("run", query, ["getLabelPriority" , 2, 0, "label05", "labeluser", 0, "exclusive_access", "full", 0, 0]); + await db.prepare("run", query, ["getLabelPriority" , 2, 0, "label06", "labeluser", 0, "selfpromo", "full", 0, 0]); + // locked only + await db.prepare("run", query, ["getLabelLocked" , 2, 0, "label07", "labeluser", 0, "sponsor", "full", 0, 0]); + await db.prepare("run", query, ["getLabelLocked" , 2, 0, "label08", "labeluser", 0, "exclusive_access", "full", 0, 0]); + await db.prepare("run", query, ["getLabelLocked" , 2, 1, "label09", "labeluser", 0, "selfpromo", "full", 0, 0]); + // hidden segments + await db.prepare("run", query, ["getLabelDownvote" ,-2, 0, "label10", "labeluser", 0, "selfpromo", "full", 0, 0]); + await db.prepare("run", query, ["getLabelHidden" ,2, 0, "label11", "labeluser", 0, "selfpromo", "full", 1, 0]); + await db.prepare("run", query, ["getLabelShadowHidden",2, 0, "label12", "labeluser", 0, "selfpromo", "full", 0, 1]); + // priority override2 + await db.prepare("run", query, ["getLabelPriority2" , -2, 0, "label13", "labeluser", 0, "sponsor", "full", 0, 0]); + await db.prepare("run", query, ["getLabelPriority2" , 2, 0, "label14", "labeluser", 0, "exclusive_access", "full", 0, 0]); + await db.prepare("run", query, ["getLabelPriority2" , 2, 0, "label15", "labeluser", 0, "selfpromo", "full", 0, 0]); + + return; + }); + + function validateLabel(result: any) { + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].segment[0], 0); + assert.strictEqual(result[0].segment[1], 0); + assert.strictEqual(result[0].actionType, "full"); + assert.strictEqual(result[0].userID, "labeluser"); + } + + const get = (videoID: string) => client.get(endpoint, { params: { videoID } }); + + it("Should be able to get sponsor only label", (done) => { + get("getLabelSponsor") + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + validateLabel(data); + assert.strictEqual(data[0].category, "sponsor"); + assert.strictEqual(data[0].UUID, "label01"); + assert.strictEqual(data[0].locked, 0); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get exclusive access only label", (done) => { + get("getLabelEA") + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + validateLabel(data); + assert.strictEqual(data[0].category, "exclusive_access"); + assert.strictEqual(data[0].UUID, "label02"); + assert.strictEqual(data[0].locked, 0); + done(); + }) + .catch(err => done(err)); + }); + + it("Should be able to get selfpromo only label", (done) => { + get("getLabelSelfpromo") + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + validateLabel(data); + assert.strictEqual(data[0].category, "selfpromo"); + assert.strictEqual(data[0].UUID, "label03"); + assert.strictEqual(data[0].locked, 0); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get only sponsor if multiple segments exist", (done) => { + get("getLabelPriority") + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + validateLabel(data); + assert.strictEqual(data[0].category, "sponsor"); + assert.strictEqual(data[0].UUID, "label04"); + assert.strictEqual(data[0].locked, 0); + done(); + }) + .catch(err => done(err)); + }); + + it("Should override priority if locked", (done) => { + get("getLabelLocked") + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + validateLabel(data); + assert.strictEqual(data[0].category, "selfpromo"); + assert.strictEqual(data[0].UUID, "label09"); + assert.strictEqual(data[0].locked, 1); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get highest priority category", (done) => { + get("getLabelPriority2") + .then(res => { + assert.strictEqual(res.status, 200); + const data = res.data; + validateLabel(data); + assert.strictEqual(data[0].category, "exclusive_access"); + assert.strictEqual(data[0].UUID, "label14"); + assert.strictEqual(data[0].locked, 0); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 if all submissions are downvoted", (done) => { + get("getLabelDownvote") + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 if all submissions are hidden", (done) => { + get("getLabelHidden") + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 if all submissions are shadowhidden", (done) => { + get("getLabelShadowHidden") + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 if no segment found", (done) => { + get("notarealvideo") + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get 400 if no videoID passed in", (done) => { + client.get(endpoint) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); +});