Merge pull request #440 from ajayyy/full-video-labels

Full video labeling
This commit is contained in:
Ajay Ramachandran
2022-01-06 15:55:11 -05:00
committed by GitHub
14 changed files with 438 additions and 125 deletions

View File

@@ -94,6 +94,7 @@
| -- | :--: | -- | | -- | :--: | -- |
| videoID | TEXT | not null | | videoID | TEXT | not null |
| userID | TEXT | not null | | userID | TEXT | not null |
| actionType | TEXT | not null, default 'skip' |
| category | TEXT | not null | | category | TEXT | not null |
| hashedVideoID | TEXT | not null, default '' | | hashedVideoID | TEXT | not null, default '' |
| reason | TEXT | not null, default '' | | reason | TEXT | not null, default '' |

View File

@@ -0,0 +1,21 @@
BEGIN TRANSACTION;
CREATE TABLE "sqlb_temp_table_29" (
"videoID" TEXT NOT NULL,
"userID" TEXT NOT NULL,
"actionType" TEXT NOT NULL DEFAULT 'skip',
"category" TEXT NOT NULL,
"hashedVideoID" TEXT NOT NULL default '',
"reason" TEXT NOT NULL default '',
"service" TEXT NOT NULL default 'YouTube'
);
INSERT INTO sqlb_temp_table_29 SELECT "videoID","userID",'skip',"category","hashedVideoID","reason","service" FROM "lockCategories";
INSERT INTO sqlb_temp_table_29 SELECT "videoID","userID",'mute',"category","hashedVideoID","reason","service" FROM "lockCategories";
DROP TABLE "lockCategories";
ALTER TABLE sqlb_temp_table_29 RENAME TO "lockCategories";
UPDATE "config" SET value = 29 WHERE key = 'version';
COMMIT;

View File

@@ -21,8 +21,8 @@ addDefaults(config, {
webhooks: [], webhooks: [],
categoryList: ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight", "chapter"], categoryList: ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight", "chapter"],
categorySupport: { categorySupport: {
sponsor: ["skip", "mute"], sponsor: ["skip", "mute", "full"],
selfpromo: ["skip", "mute"], selfpromo: ["skip", "mute", "full"],
interaction: ["skip", "mute"], interaction: ["skip", "mute"],
intro: ["skip", "mute"], intro: ["skip", "mute"],
outro: ["skip", "mute"], outro: ["skip", "mute"],

View File

@@ -167,7 +167,7 @@ async function getSegmentsFromDBByVideoID(videoID: VideoID, service: Service): P
// amountOfChoices specifies the maximum amount of choices to return, 1 or more. // amountOfChoices specifies the maximum amount of choices to return, 1 or more.
// Choices are unique // Choices are unique
// If a predicate is given, it will only filter choices following it, and will leave the rest in the list // If a predicate is given, it will only filter choices following it, and will leave the rest in the list
function getWeightedRandomChoice<T extends VotableObject>(choices: T[], amountOfChoices: number, predicate?: (choice: T) => void): T[] { function getWeightedRandomChoice<T extends VotableObject>(choices: T[], amountOfChoices: number, filterLocked = false, predicate?: (choice: T) => void): T[] {
//trivial case: no need to go through the whole process //trivial case: no need to go through the whole process
if (amountOfChoices >= choices.length) { if (amountOfChoices >= choices.length) {
return choices; return choices;
@@ -183,6 +183,10 @@ function getWeightedRandomChoice<T extends VotableObject>(choices: T[], amountOf
const splitArray = partition(choices, predicate); const splitArray = partition(choices, predicate);
filteredChoices = splitArray[0]; filteredChoices = splitArray[0];
forceIncludedChoices = splitArray[1]; forceIncludedChoices = splitArray[1];
if (filterLocked && filteredChoices.some((value) => value.locked)) {
filteredChoices = filteredChoices.filter((value) => value.locked);
}
} }
//assign a weight to each choice //assign a weight to each choice
@@ -200,7 +204,7 @@ function getWeightedRandomChoice<T extends VotableObject>(choices: T[], amountOf
// Nothing to filter for // Nothing to filter for
if (amountOfChoices >= choicesWithWeights.length) { if (amountOfChoices >= choicesWithWeights.length) {
return choices; return [...forceIncludedChoices, ...filteredChoices];
} }
//iterate and find amountOfChoices choices //iterate and find amountOfChoices choices
@@ -230,9 +234,10 @@ async function chooseSegments(videoID: VideoID, service: Service, segments: DBSe
? await QueryCacher.get(fetchData, skipSegmentGroupsKey(videoID, service)) ? await QueryCacher.get(fetchData, skipSegmentGroupsKey(videoID, service))
: await fetchData(); : await fetchData();
// Filter for only 1 item for POI categories // Filter for only 1 item for POI categories and Full video
return getWeightedRandomChoice(groups, 1, (choice) => getCategoryActionType(choice.segments[0].category) === CategoryActionType.POI) let chosenGroups = getWeightedRandomChoice(groups, 1, true, (choice) => choice.segments[0].actionType === ActionType.Full);
.map(//randomly choose 1 good segment per group and return them chosenGroups = getWeightedRandomChoice(chosenGroups, 1, true, (choice) => getCategoryActionType(choice.segments[0].category) === CategoryActionType.POI);
return chosenGroups.map(//randomly choose 1 good segment per group and return them
group => getWeightedRandomChoice(group.segments, 1)[0] group => getWeightedRandomChoice(group.segments, 1)[0]
); );
} }
@@ -300,7 +305,7 @@ function splitPercentOverlap(groups: OverlappingSegmentGroup[]): OverlappingSegm
group.segments.forEach((segment) => { group.segments.forEach((segment) => {
const bestGroup = result.find((group) => { const bestGroup = result.find((group) => {
// At least one segment in the group must have high % overlap or the same action type // At least one segment in the group must have high % overlap or the same action type
// Since POI segments will always have 0 overlap, they will always be in their own groups // Since POI and Full video segments will always have <= 0 overlap, they will always be in their own groups
return group.segments.some((compareSegment) => { return group.segments.some((compareSegment) => {
const overlap = Math.min(segment.endTime, compareSegment.endTime) - Math.max(segment.startTime, compareSegment.startTime); const overlap = Math.min(segment.endTime, compareSegment.endTime) - Math.max(segment.startTime, compareSegment.startTime);
const overallDuration = Math.max(segment.endTime, compareSegment.endTime) - Math.min(segment.startTime, compareSegment.startTime); const overallDuration = Math.max(segment.endTime, compareSegment.endTime) - Math.min(segment.startTime, compareSegment.startTime);

View File

@@ -3,14 +3,15 @@ import { getHashCache } from "../utils/getHashCache";
import { isUserVIP } from "../utils/isUserVIP"; import { isUserVIP } from "../utils/isUserVIP";
import { db } from "../databases/databases"; import { db } from "../databases/databases";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { VideoIDHash } from "../types/segments.model"; import { ActionType, Category, VideoIDHash } from "../types/segments.model";
import { getService } from "../utils/getService"; import { getService } from "../utils/getService";
export async function postLockCategories(req: Request, res: Response): Promise<string[]> { export async function postLockCategories(req: Request, res: Response): Promise<string[]> {
// Collect user input data // Collect user input data
const videoID = req.body.videoID; const videoID = req.body.videoID;
let userID = req.body.userID; let userID = req.body.userID;
const categories = req.body.categories; const categories = req.body.categories as Category[];
const actionTypes = req.body.actionTypes as ActionType[] || [ActionType.Skip, ActionType.Mute];
const reason: string = req.body.reason ?? ""; const reason: string = req.body.reason ?? "";
const service = getService(req.body.service); const service = getService(req.body.service);
@@ -20,6 +21,8 @@ export async function postLockCategories(req: Request, res: Response): Promise<s
|| !categories || !categories
|| !Array.isArray(categories) || !Array.isArray(categories)
|| categories.length === 0 || categories.length === 0
|| !Array.isArray(actionTypes)
|| actionTypes.length === 0
) { ) {
res.status(400).json({ res.status(400).json({
message: "Bad Format", message: "Bad Format",
@@ -38,38 +41,39 @@ export async function postLockCategories(req: Request, res: Response): Promise<s
return; return;
} }
// Get existing lock categories markers const existingLocks = (await db.prepare("all", 'SELECT "category", "actionType" from "lockCategories" where "videoID" = ? AND "service" = ?', [videoID, service])) as
let noCategoryList = await db.prepare("all", 'SELECT "category" from "lockCategories" where "videoID" = ? AND "service" = ?', [videoID, service]); { category: Category, actionType: ActionType }[];
if (!noCategoryList || noCategoryList.length === 0) {
noCategoryList = []; const filteredCategories = filterData(categories);
const filteredActionTypes = filterData(actionTypes);
const locksToApply: { category: Category, actionType: ActionType }[] = [];
const overwrittenLocks: { category: Category, actionType: ActionType }[] = [];
for (const category of filteredCategories) {
for (const actionType of filteredActionTypes) {
if (!existingLocks.some((lock) => lock.category === category && lock.actionType === actionType)) {
locksToApply.push({
category,
actionType
});
} else { } else {
noCategoryList = noCategoryList.map((obj: any) => { overwrittenLocks.push({
return obj.category; category,
actionType
}); });
} }
}
// get user categories not already submitted that match accepted format }
let filteredCategories = categories.filter((category) => {
return !!category.match(/^[_a-zA-Z]+$/);
});
// remove any duplicates
filteredCategories = filteredCategories.filter((category, index) => {
return filteredCategories.indexOf(category) === index;
});
const categoriesToMark = filteredCategories.filter((category) => {
return noCategoryList.indexOf(category) === -1;
});
// calculate hash of videoID // calculate hash of videoID
const hashedVideoID: VideoIDHash = await getHashCache(videoID, 1); const hashedVideoID: VideoIDHash = await getHashCache(videoID, 1);
// create database entry // create database entry
for (const category of categoriesToMark) { for (const lock of locksToApply) {
try { try {
await db.prepare("run", `INSERT INTO "lockCategories" ("videoID", "userID", "category", "hashedVideoID", "reason", "service") VALUES(?, ?, ?, ?, ?, ?)`, [videoID, userID, category, hashedVideoID, reason, service]); await db.prepare("run", `INSERT INTO "lockCategories" ("videoID", "userID", "actionType", "category", "hashedVideoID", "reason", "service") VALUES(?, ?, ?, ?, ?, ?, ?)`, [videoID, userID, lock.actionType, lock.category, hashedVideoID, reason, service]);
} catch (err) { } catch (err) {
Logger.error(`Error submitting 'lockCategories' marker for category '${category}' for video '${videoID}' (${service})`); Logger.error(`Error submitting 'lockCategories' marker for category '${lock.category}' and actionType '${lock.actionType}' for video '${videoID}' (${service})`);
Logger.error(err as string); Logger.error(err as string);
res.status(500).json({ res.status(500).json({
message: "Internal Server Error: Could not write marker to the database.", message: "Internal Server Error: Could not write marker to the database.",
@@ -78,19 +82,14 @@ export async function postLockCategories(req: Request, res: Response): Promise<s
} }
// update reason for existed categories // update reason for existed categories
let overlapCategories = [];
if (reason.length !== 0) { if (reason.length !== 0) {
overlapCategories = filteredCategories.filter((category) => { for (const lock of overwrittenLocks) {
return noCategoryList.indexOf(category) !== -1;
});
for (const category of overlapCategories) {
try { try {
await db.prepare("run", await db.prepare("run",
'UPDATE "lockCategories" SET "reason" = ?, "userID" = ? WHERE "videoID" = ? AND "category" = ? AND "service" = ?', 'UPDATE "lockCategories" SET "reason" = ?, "userID" = ? WHERE "videoID" = ? AND "actionType" = ? AND "category" = ? AND "service" = ?',
[reason, userID, videoID, category, service]); [reason, userID, videoID, lock.actionType, lock.category, service]);
} catch (err) { } catch (err) {
Logger.error(`Error submitting 'lockCategories' marker for category '${category}' for video '${videoID} (${service})'`); Logger.error(`Error submitting 'lockCategories' marker for category '${lock.category}' and actionType '${lock.actionType}' for video '${videoID}' (${service})`);
Logger.error(err as string); Logger.error(err as string);
res.status(500).json({ res.status(500).json({
message: "Internal Server Error: Could not write marker to the database.", message: "Internal Server Error: Could not write marker to the database.",
@@ -100,6 +99,20 @@ export async function postLockCategories(req: Request, res: Response): Promise<s
} }
res.status(200).json({ res.status(200).json({
submitted: [...categoriesToMark, ...overlapCategories], submitted: reason.length === 0
? [...filteredCategories.filter(((category) => locksToApply.some((lock) => category === lock.category)))]
: [...filteredCategories], // Legacy
submittedValues: [...locksToApply, ...overwrittenLocks],
});
}
function filterData<T extends string>(data: T[]): T[] {
// get user categories not already submitted that match accepted format
const filtered = data.filter((elem) => {
return !!elem.match(/^[_a-zA-Z]+$/);
});
// remove any duplicates
return filtered.filter((elem, index) => {
return filtered.indexOf(elem) === index;
}); });
} }

View File

@@ -9,17 +9,18 @@ import { getIP } from "../utils/getIP";
import { getFormattedTime } from "../utils/getFormattedTime"; import { getFormattedTime } from "../utils/getFormattedTime";
import { dispatchEvent } from "../utils/webhookUtils"; import { dispatchEvent } from "../utils/webhookUtils";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { ActionType, Category, CategoryActionType, IncomingSegment, SegmentUUID, Service, VideoDuration, VideoID } from "../types/segments.model"; import { ActionType, Category, CategoryActionType, IncomingSegment, IPAddress, SegmentUUID, Service, VideoDuration, VideoID } from "../types/segments.model";
import { deleteLockCategories } from "./deleteLockCategories"; import { deleteLockCategories } from "./deleteLockCategories";
import { getCategoryActionType } from "../utils/categoryInfo"; import { getCategoryActionType } from "../utils/categoryInfo";
import { QueryCacher } from "../utils/queryCacher"; import { QueryCacher } from "../utils/queryCacher";
import { getReputation } from "../utils/reputation"; import { getReputation } from "../utils/reputation";
import { APIVideoData, APIVideoInfo } from "../types/youtubeApi.model"; import { APIVideoData, APIVideoInfo } from "../types/youtubeApi.model";
import { UserID } from "../types/user.model"; import { HashedUserID, UserID } from "../types/user.model";
import { isUserVIP } from "../utils/isUserVIP"; import { isUserVIP } from "../utils/isUserVIP";
import { parseUserAgent } from "../utils/userAgent"; import { parseUserAgent } from "../utils/userAgent";
import { getService } from "../utils/getService"; import { getService } from "../utils/getService";
import axios from "axios"; import axios from "axios";
import { vote } from "./voteOnSponsorTime";
type CheckResult = { type CheckResult = {
pass: boolean, pass: boolean,
@@ -238,7 +239,7 @@ async function autoModerateSubmission(apiVideoInfo: APIVideoInfo,
const startTime = parseFloat(segments[i].segment[0]); const startTime = parseFloat(segments[i].segment[0]);
const endTime = parseFloat(segments[i].segment[1]); const endTime = parseFloat(segments[i].segment[1]);
const UUID = getSubmissionUUID(submission.videoID, segments[i].actionType, submission.userID, startTime, endTime, submission.service); const UUID = getSubmissionUUID(submission.videoID, segments[i].category, segments[i].actionType, submission.userID, startTime, endTime, submission.service);
// Send to Discord // Send to Discord
// Note, if this is too spammy. Consider sending all the segments as one Webhook // Note, if this is too spammy. Consider sending all the segments as one Webhook
sendWebhooksNB(submission.userID, submission.videoID, UUID, startTime, endTime, segments[i].category, nbPredictions.probabilities[predictionIdx], data); sendWebhooksNB(submission.userID, submission.videoID, UUID, startTime, endTime, segments[i].category, nbPredictions.probabilities[predictionIdx], data);
@@ -343,7 +344,7 @@ function checkInvalidFields(videoID: VideoID, userID: UserID, segments: Incoming
return CHECK_PASS; return CHECK_PASS;
} }
async function checkEachSegmentValid(userID: string, videoID: VideoID, async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, userID: HashedUserID, videoID: VideoID,
segments: IncomingSegment[], service: string, isVIP: boolean, lockedCategoryList: Array<any>): Promise<CheckResult> { segments: IncomingSegment[], service: string, isVIP: boolean, lockedCategoryList: Array<any>): Promise<CheckResult> {
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
@@ -357,7 +358,7 @@ async function checkEachSegmentValid(userID: string, videoID: VideoID,
} }
// Reject segment if it's in the locked categories list // Reject segment if it's in the locked categories list
const lockIndex = lockedCategoryList.findIndex(c => segments[i].category === c.category); const lockIndex = lockedCategoryList.findIndex(c => segments[i].category === c.category && segments[i].actionType === c.actionType);
if (!isVIP && lockIndex !== -1) { if (!isVIP && lockIndex !== -1) {
// TODO: Do something about the fradulent submission // TODO: Do something about the fradulent submission
Logger.warn(`Caught a submission for a locked category. userID: '${userID}', videoID: '${videoID}', category: '${segments[i].category}', times: ${segments[i].segment}`); Logger.warn(`Caught a submission for a locked category. userID: '${userID}', videoID: '${videoID}', category: '${segments[i].category}', times: ${segments[i].segment}`);
@@ -383,8 +384,10 @@ async function checkEachSegmentValid(userID: string, videoID: VideoID,
if (isNaN(startTime) || isNaN(endTime) if (isNaN(startTime) || isNaN(endTime)
|| startTime === Infinity || endTime === Infinity || startTime < 0 || startTime > endTime || startTime === Infinity || endTime === Infinity || startTime < 0 || startTime > endTime
|| (getCategoryActionType(segments[i].category) === CategoryActionType.Skippable && startTime === endTime) || (getCategoryActionType(segments[i].category) === CategoryActionType.Skippable
|| (getCategoryActionType(segments[i].category) === CategoryActionType.POI && startTime !== endTime)) { && segments[i].actionType !== ActionType.Full && startTime === endTime)
|| (getCategoryActionType(segments[i].category) === CategoryActionType.POI && startTime !== endTime)
|| (segments[i].actionType === ActionType.Full && (startTime !== 0 || endTime !== 0))) {
//invalid request //invalid request
return { pass: false, errorMessage: "One of your segments times are invalid (too short, startTime before endTime, etc.)", errorCode: 400 }; return { pass: false, errorMessage: "One of your segments times are invalid (too short, startTime before endTime, etc.)", errorCode: 400 };
} }
@@ -394,16 +397,24 @@ async function checkEachSegmentValid(userID: string, videoID: VideoID,
return { pass: false, errorMessage: `POI cannot be that early`, errorCode: 400 }; return { pass: false, errorMessage: `POI cannot be that early`, errorCode: 400 };
} }
if (!isVIP && segments[i].category === "sponsor" && Math.abs(startTime - endTime) < 1) { if (!isVIP && segments[i].category === "sponsor"
&& segments[i].actionType !== ActionType.Full && Math.abs(startTime - endTime) < 1) {
// Too short // Too short
return { pass: false, errorMessage: "Sponsors must be longer than 1 second long", errorCode: 400 }; return { pass: false, errorMessage: "Segments must be longer than 1 second long", errorCode: 400 };
} }
//check if this info has already been submitted before //check if this info has already been submitted before
const duplicateCheck2Row = await db.prepare("get", `SELECT COUNT(*) as count FROM "sponsorTimes" WHERE "startTime" = ? const duplicateCheck2Row = await db.prepare("get", `SELECT "UUID" FROM "sponsorTimes" WHERE "startTime" = ?
and "endTime" = ? and "category" = ? and "actionType" = ? and "videoID" = ? and "service" = ?`, [startTime, endTime, segments[i].category, segments[i].actionType, videoID, service]); and "endTime" = ? and "category" = ? and "actionType" = ? and "videoID" = ? and "service" = ?`, [startTime, endTime, segments[i].category, segments[i].actionType, videoID, service]);
if (duplicateCheck2Row.count > 0) { if (duplicateCheck2Row) {
return { pass: false, errorMessage: "Sponsors has already been submitted before.", errorCode: 409 }; if (segments[i].actionType === ActionType.Full) {
// Forward as vote
await vote(rawIP, duplicateCheck2Row.UUID, paramUserID, 1);
segments[i].ignoreSegment = true;
continue;
} else {
return { pass: false, errorMessage: "Segment has already been submitted before.", errorCode: 409 };
}
} }
} }
@@ -439,13 +450,14 @@ async function checkByAutoModerator(videoID: any, userID: any, segments: Array<a
} }
async function updateDataIfVideoDurationChange(videoID: VideoID, service: Service, videoDuration: VideoDuration, videoDurationParam: VideoDuration) { async function updateDataIfVideoDurationChange(videoID: VideoID, service: Service, videoDuration: VideoDuration, videoDurationParam: VideoDuration) {
let lockedCategoryList = await db.prepare("all", 'SELECT category, reason from "lockCategories" where "videoID" = ? AND "service" = ?', [videoID, service]); let lockedCategoryList = await db.prepare("all", 'SELECT category, "actionType", reason from "lockCategories" where "videoID" = ? AND "service" = ?', [videoID, service]);
const previousSubmissions = await db.prepare("all", const previousSubmissions = await db.prepare("all",
`SELECT "videoDuration", "UUID" `SELECT "videoDuration", "UUID"
FROM "sponsorTimes" FROM "sponsorTimes"
WHERE "videoID" = ? AND "service" = ? AND WHERE "videoID" = ? AND "service" = ? AND
"hidden" = 0 AND "shadowHidden" = 0 AND "hidden" = 0 AND "shadowHidden" = 0 AND
"actionType" != 'full' AND
"votes" > -2 AND "videoDuration" != 0`, "votes" > -2 AND "videoDuration" != 0`,
[videoID, service] [videoID, service]
) as {videoDuration: VideoDuration, UUID: SegmentUUID}[]; ) as {videoDuration: VideoDuration, UUID: SegmentUUID}[];
@@ -573,15 +585,15 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
} }
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { videoID, userID, service, videoDuration, videoDurationParam, segments, userAgent } = preprocessInput(req); let { videoID, userID: paramUserID, service, videoDuration, videoDurationParam, segments, userAgent } = preprocessInput(req);
const invalidCheckResult = checkInvalidFields(videoID, userID, segments); const invalidCheckResult = checkInvalidFields(videoID, paramUserID, segments);
if (!invalidCheckResult.pass) { if (!invalidCheckResult.pass) {
return res.status(invalidCheckResult.errorCode).send(invalidCheckResult.errorMessage); return res.status(invalidCheckResult.errorCode).send(invalidCheckResult.errorMessage);
} }
//hash the userID //hash the userID
userID = await getHashCache(userID); const userID = await getHashCache(paramUserID);
const userWarningCheckResult = await checkUserActiveWarning(userID); const userWarningCheckResult = await checkUserActiveWarning(userID);
if (!userWarningCheckResult.pass) { if (!userWarningCheckResult.pass) {
@@ -589,15 +601,15 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
return res.status(userWarningCheckResult.errorCode).send(userWarningCheckResult.errorMessage); return res.status(userWarningCheckResult.errorCode).send(userWarningCheckResult.errorMessage);
} }
//check if this user is on the vip list
const isVIP = await isUserVIP(userID); const isVIP = await isUserVIP(userID);
const rawIP = getIP(req);
const newData = await updateDataIfVideoDurationChange(videoID, service, videoDuration, videoDurationParam); const newData = await updateDataIfVideoDurationChange(videoID, service, videoDuration, videoDurationParam);
videoDuration = newData.videoDuration; videoDuration = newData.videoDuration;
const { lockedCategoryList, apiVideoInfo } = newData; const { lockedCategoryList, apiVideoInfo } = newData;
// Check if all submissions are correct // Check if all submissions are correct
const segmentCheckResult = await checkEachSegmentValid(userID, videoID, segments, service, isVIP, lockedCategoryList); const segmentCheckResult = await checkEachSegmentValid(rawIP, paramUserID, userID, videoID, segments, service, isVIP, lockedCategoryList);
if (!segmentCheckResult.pass) { if (!segmentCheckResult.pass) {
return res.status(segmentCheckResult.errorCode).send(segmentCheckResult.errorMessage); return res.status(segmentCheckResult.errorCode).send(segmentCheckResult.errorMessage);
} }
@@ -616,7 +628,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
const newSegments = []; const newSegments = [];
//hash the ip 5000 times so no one can get it from the database //hash the ip 5000 times so no one can get it from the database
const hashedIP = await getHashCache(getIP(req) + config.globalSalt); const hashedIP = await getHashCache(rawIP + config.globalSalt);
try { try {
//get current time //get current time
@@ -633,10 +645,16 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
const reputation = await getReputation(userID); const reputation = await getReputation(userID);
for (const segmentInfo of segments) { for (const segmentInfo of segments) {
// Full segments are always rejected since there can only be one, so shadow hide wouldn't work
if (segmentInfo.ignoreSegment
|| (shadowBanRow.userCount && segmentInfo.actionType === ActionType.Full)) {
continue;
}
//this can just be a hash of the data //this can just be a hash of the data
//it's better than generating an actual UUID like what was used before //it's better than generating an actual UUID like what was used before
//also better for duplication checking //also better for duplication checking
const UUID = getSubmissionUUID(videoID, segmentInfo.actionType, userID, parseFloat(segmentInfo.segment[0]), parseFloat(segmentInfo.segment[1]), service); const UUID = getSubmissionUUID(videoID, segmentInfo.category, segmentInfo.actionType, userID, parseFloat(segmentInfo.segment[0]), parseFloat(segmentInfo.segment[1]), service);
const hashedVideoID = getHash(videoID, 1); const hashedVideoID = getHash(videoID, 1);
const startingLocked = isVIP ? 1 : 0; const startingLocked = isVIP ? 1 : 0;

View File

@@ -10,7 +10,7 @@ import { getIP } from "../utils/getIP";
import { getHashCache } from "../utils/getHashCache"; import { getHashCache } from "../utils/getHashCache";
import { config } from "../config"; import { config } from "../config";
import { HashedUserID, UserID } from "../types/user.model"; import { HashedUserID, UserID } from "../types/user.model";
import { Category, CategoryActionType, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash, Visibility, VideoDuration } from "../types/segments.model"; import { Category, CategoryActionType, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash, Visibility, VideoDuration, ActionType } from "../types/segments.model";
import { getCategoryActionType } from "../utils/categoryInfo"; import { getCategoryActionType } from "../utils/categoryInfo";
import { QueryCacher } from "../utils/queryCacher"; import { QueryCacher } from "../utils/queryCacher";
import axios from "axios"; import axios from "axios";
@@ -188,38 +188,38 @@ async function sendWebhooks(voteData: VoteData) {
} }
async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, isOwnSubmission: boolean, category: Category async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, isOwnSubmission: boolean, category: Category
, hashedIP: HashedIP, finalResponse: FinalResponse, res: Response): Promise<Response> { , hashedIP: HashedIP, finalResponse: FinalResponse): Promise<{ status: number, message?: string }> {
// Check if they've already made a vote // Check if they've already made a vote
const usersLastVoteInfo = await privateDB.prepare("get", `select count(*) as votes, category from "categoryVotes" where "UUID" = ? and "userID" = ? group by category`, [UUID, userID]); const usersLastVoteInfo = await privateDB.prepare("get", `select count(*) as votes, category from "categoryVotes" where "UUID" = ? and "userID" = ? group by category`, [UUID, userID]);
if (usersLastVoteInfo?.category === category) { if (usersLastVoteInfo?.category === category) {
// Double vote, ignore // Double vote, ignore
return res.sendStatus(finalResponse.finalStatus); return { status: finalResponse.finalStatus };
} }
const videoInfo = (await db.prepare("get", `SELECT "category", "videoID", "hashedVideoID", "service", "userID", "locked" FROM "sponsorTimes" WHERE "UUID" = ?`, const videoInfo = (await db.prepare("get", `SELECT "category", "videoID", "hashedVideoID", "service", "userID", "locked" FROM "sponsorTimes" WHERE "UUID" = ?`,
[UUID])) as {category: Category, videoID: VideoID, hashedVideoID: VideoIDHash, service: Service, userID: UserID, locked: number}; [UUID])) as {category: Category, videoID: VideoID, hashedVideoID: VideoIDHash, service: Service, userID: UserID, locked: number};
if (!videoInfo) { if (!videoInfo) {
// Submission doesn't exist // Submission doesn't exist
return res.status(400).send("Submission doesn't exist."); return { status: 400, message: "Submission doesn't exist." };
} }
if (!config.categoryList.includes(category)) { if (!config.categoryList.includes(category)) {
return res.status(400).send("Category doesn't exist."); return { status: 400, message: "Category doesn't exist." };
} }
if (getCategoryActionType(category) !== CategoryActionType.Skippable) { if (getCategoryActionType(category) !== CategoryActionType.Skippable) {
return res.status(400).send("Cannot vote for this category"); return { status: 400, message: "Cannot vote for this category" };
} }
// Ignore vote if the next category is locked // Ignore vote if the next category is locked
const nextCategoryLocked = await db.prepare("get", `SELECT "videoID", "category" FROM "lockCategories" WHERE "videoID" = ? AND "service" = ? AND "category" = ?`, [videoInfo.videoID, videoInfo.service, category]); const nextCategoryLocked = await db.prepare("get", `SELECT "videoID", "category" FROM "lockCategories" WHERE "videoID" = ? AND "service" = ? AND "category" = ?`, [videoInfo.videoID, videoInfo.service, category]);
if (nextCategoryLocked && !isVIP) { if (nextCategoryLocked && !isVIP) {
return res.sendStatus(200); return { status: 200 };
} }
// Ignore vote if the segment is locked // Ignore vote if the segment is locked
if (!isVIP && videoInfo.locked === 1) { if (!isVIP && videoInfo.locked === 1) {
return res.sendStatus(200); return { status: 200 };
} }
const nextCategoryInfo = await db.prepare("get", `select votes from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, category]); const nextCategoryInfo = await db.prepare("get", `select votes from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, category]);
@@ -279,7 +279,7 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i
QueryCacher.clearSegmentCache(videoInfo); QueryCacher.clearSegmentCache(videoInfo);
return res.sendStatus(finalResponse.finalStatus); return { status: finalResponse.finalStatus };
} }
export function getUserID(req: Request): UserID { export function getUserID(req: Request): UserID {
@@ -289,16 +289,30 @@ export function getUserID(req: Request): UserID {
export async function voteOnSponsorTime(req: Request, res: Response): Promise<Response> { export async function voteOnSponsorTime(req: Request, res: Response): Promise<Response> {
const UUID = req.query.UUID as SegmentUUID; const UUID = req.query.UUID as SegmentUUID;
const paramUserID = getUserID(req); const paramUserID = getUserID(req);
let type = req.query.type !== undefined ? parseInt(req.query.type as string) : undefined; const type = req.query.type !== undefined ? parseInt(req.query.type as string) : undefined;
const category = req.query.category as Category; const category = req.query.category as Category;
const ip = getIP(req);
const result = await vote(ip, UUID, paramUserID, type, category);
const response = res.status(result.status);
if (result.message) {
return response.send(result.message);
} else if (result.json) {
return response.json(result.json);
} else {
return response.send();
}
}
export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID, type: number, category?: Category): Promise<{ status: number, message?: string, json?: unknown }> {
if (UUID === undefined || paramUserID === undefined || (type === undefined && category === undefined)) { if (UUID === undefined || paramUserID === undefined || (type === undefined && category === undefined)) {
//invalid request //invalid request
return res.sendStatus(400); return { status: 400 };
} }
if (paramUserID.length < 30 && config.mode !== "test") { if (paramUserID.length < 30 && config.mode !== "test") {
// Ignore this vote, invalid // Ignore this vote, invalid
return res.sendStatus(200); return { status: 200 };
} }
//hash the userID //hash the userID
@@ -314,9 +328,6 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
webhookMessage: null webhookMessage: null
}; };
//x-forwarded-for if this server is behind a proxy
const ip = getIP(req);
//hash the ip 5000 times so no one can get it from the database //hash the ip 5000 times so no one can get it from the database
const hashedIP: HashedIP = await getHashCache((ip + config.globalSalt) as IPAddress); const hashedIP: HashedIP = await getHashCache((ip + config.globalSalt) as IPAddress);
@@ -331,7 +342,7 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
// disallow vote types 10/11 // disallow vote types 10/11
if (type === 10 || type === 11) { if (type === 10 || type === 11) {
// no longer allow type 10/11 alternative votes // no longer allow type 10/11 alternative votes
return res.sendStatus(400); return { status: 400 };
} }
// If not upvote // If not upvote
@@ -350,19 +361,20 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
} }
if (type === undefined && category !== undefined) { if (type === undefined && category !== undefined) {
return categoryVote(UUID, nonAnonUserID, isVIP, isOwnSubmission, category, hashedIP, finalResponse, res); return categoryVote(UUID, nonAnonUserID, isVIP, isOwnSubmission, category, hashedIP, finalResponse);
} }
if (type !== undefined && !isVIP && !isOwnSubmission) { if (type !== undefined && !isVIP && !isOwnSubmission) {
// Check if upvoting hidden segment // Check if upvoting hidden segment
const voteInfo = await db.prepare("get", `SELECT votes FROM "sponsorTimes" WHERE "UUID" = ?`, [UUID]); const voteInfo = await db.prepare("get", `SELECT votes, "actionType" FROM "sponsorTimes" WHERE "UUID" = ?`, [UUID]) as
{ votes: number, actionType: ActionType };
if (voteInfo && voteInfo.votes <= -2) { if (voteInfo && voteInfo.votes <= -2 && voteInfo.actionType !== ActionType.Full) {
if (type == 1) { if (type == 1) {
return res.status(403).send("Not allowed to upvote segment with too many downvotes unless you are VIP."); return { status: 403, message: "Not allowed to upvote segment with too many downvotes unless you are VIP." };
} else if (type == 0) { } else if (type == 0) {
// Already downvoted enough, ignore // Already downvoted enough, ignore
return res.sendStatus(200); return { status: 200 };
} }
} }
} }
@@ -374,9 +386,9 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
)); ));
if (warnings.length >= config.maxNumberOfActiveWarnings) { if (warnings.length >= config.maxNumberOfActiveWarnings) {
return res.status(403).send("Vote rejected due to a warning from a moderator. This means that we noticed you were making some common mistakes that are not malicious, and we just want to clarify the rules. " + return { status: 403, message: "Vote rejected due to a warning from a moderator. This means that we noticed you were making some common mistakes that are not malicious, and we just want to clarify the rules. " +
"Could you please send a message in Discord or Matrix so we can further help you?" + "Could you please send a message in Discord or Matrix so we can further help you?" +
`${(warnings[0]?.reason?.length > 0 ? ` Warning reason: '${warnings[0].reason}'` : "")}`); `${(warnings[0]?.reason?.length > 0 ? ` Warning reason: '${warnings[0].reason}'` : "")}` };
} }
const voteTypeEnum = (type == 0 || type == 1 || type == 20) ? voteTypes.normal : voteTypes.incorrect; const voteTypeEnum = (type == 0 || type == 1 || type == 20) ? voteTypes.normal : voteTypes.incorrect;
@@ -400,7 +412,7 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
incrementAmount = 0; incrementAmount = 0;
} else { } else {
//unrecongnised type of vote //unrecongnised type of vote
return res.sendStatus(400); return { status: 400 };
} }
if (votesRow != undefined) { if (votesRow != undefined) {
if (votesRow.type === 1) { if (votesRow.type === 1) {
@@ -447,7 +459,7 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
// Only change the database if they have made a submission before and haven't voted recently // Only change the database if they have made a submission before and haven't voted recently
const ableToVote = isVIP const ableToVote = isVIP
|| (!(isOwnSubmission && incrementAmount > 0) || (!(isOwnSubmission && incrementAmount > 0 && oldIncrementAmount >= 0)
&& (await db.prepare("get", `SELECT "userID" FROM "sponsorTimes" WHERE "userID" = ?`, [nonAnonUserID])) !== undefined && (await db.prepare("get", `SELECT "userID" FROM "sponsorTimes" WHERE "userID" = ?`, [nonAnonUserID])) !== undefined
&& (await db.prepare("get", `SELECT "userID" FROM "shadowBannedUsers" WHERE "userID" = ?`, [nonAnonUserID])) === undefined && (await db.prepare("get", `SELECT "userID" FROM "shadowBannedUsers" WHERE "userID" = ?`, [nonAnonUserID])) === undefined
&& (await privateDB.prepare("get", `SELECT "UUID" FROM "votes" WHERE "UUID" = ? AND "hashedIP" = ? AND "userID" != ?`, [UUID, hashedIP, userID])) === undefined) && (await privateDB.prepare("get", `SELECT "UUID" FROM "votes" WHERE "UUID" = ? AND "hashedIP" = ? AND "userID" != ?`, [UUID, hashedIP, userID])) === undefined)
@@ -502,9 +514,9 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
finalResponse finalResponse
}); });
} }
return res.status(finalResponse.finalStatus).send(finalResponse.finalMessage ?? undefined); return { status: finalResponse.finalStatus, message: finalResponse.finalMessage ?? undefined };
} catch (err) { } catch (err) {
Logger.error(err as string); Logger.error(err as string);
return res.status(500).json({ error: "Internal error creating segment vote" }); return { status: 500, message: finalResponse.finalMessage ?? undefined, json: { error: "Internal error creating segment vote" } };
} }
} }

View File

@@ -13,7 +13,8 @@ export type HashedIP = IPAddress & HashedValue;
export enum ActionType { export enum ActionType {
Skip = "skip", Skip = "skip",
Mute = "mute", Mute = "mute",
Chapter = "chapter" Chapter = "chapter",
Full = "full"
} }
// Uncomment as needed // Uncomment as needed
@@ -32,6 +33,9 @@ export interface IncomingSegment {
actionType: ActionType; actionType: ActionType;
segment: string[]; segment: string[];
description?: string; description?: string;
// Used to remove in pre-check stage
ignoreSegment?: boolean;
} }
export interface Segment { export interface Segment {
@@ -81,6 +85,7 @@ export interface OverlappingSegmentGroup {
export interface VotableObject { export interface VotableObject {
votes: number; votes: number;
reputation: number; reputation: number;
locked: boolean;
} }
export interface VotableObjectWithWeight extends VotableObject { export interface VotableObjectWithWeight extends VotableObject {

View File

@@ -1,15 +1,16 @@
import { getHash } from "./getHash"; import { getHash } from "./getHash";
import { HashedValue } from "../types/hash.model"; import { HashedValue } from "../types/hash.model";
import { ActionType, VideoID, Service } from "../types/segments.model"; import { ActionType, VideoID, Service, Category } from "../types/segments.model";
import { UserID } from "../types/user.model"; import { UserID } from "../types/user.model";
export function getSubmissionUUID( export function getSubmissionUUID(
videoID: VideoID, videoID: VideoID,
category: Category,
actionType: ActionType, actionType: ActionType,
userID: UserID, userID: UserID,
startTime: number, startTime: number,
endTime: number, endTime: number,
service: Service service: Service
) : HashedValue { ) : HashedValue {
return `5${getHash(`${videoID}${startTime}${endTime}${userID}${actionType}${service}`, 1)}` as HashedValue; return `${getHash(`${videoID}${startTime}${endTime}${userID}${category}${actionType}${service}`, 1)}6` as HashedValue;
} }

View File

@@ -18,6 +18,7 @@ describe("getSkipSegmentsByHash", () => {
const requiredSegmentHashVidHash = "17bf8d9090e050257772f8bff277293c29c7ce3b25eb969a8fae111a2434504d"; const requiredSegmentHashVidHash = "17bf8d9090e050257772f8bff277293c29c7ce3b25eb969a8fae111a2434504d";
const differentCategoryVidHash = "7fac44d1ee3257ec7f18953e2b5f991828de6854ad57193d1027c530981a89c0"; const differentCategoryVidHash = "7fac44d1ee3257ec7f18953e2b5f991828de6854ad57193d1027c530981a89c0";
const nonMusicOverlapVidHash = "306151f778f9bfd19872b3ccfc83cbab37c4f370717436bfd85e0a624cd8ba3c"; const nonMusicOverlapVidHash = "306151f778f9bfd19872b3ccfc83cbab37c4f370717436bfd85e0a624cd8ba3c";
const fullCategoryVidHash = "278fa987eebfe07ae3a4a60cf0663989ad874dd0c1f0430831d63c2001567e6f";
before(async () => { before(async () => {
const query = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", views, category, "actionType", "service", "hidden", "shadowHidden", "hashedVideoID", "description") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; const query = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", views, category, "actionType", "service", "hidden", "shadowHidden", "hashedVideoID", "description") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
await db.prepare("run", query, ["getSegmentsByHash-0", 1, 10, 2, 0, "getSegmentsByHash-01", "testman", 0, 50, "sponsor", "skip", "YouTube", 0, 0, getSegmentsByHash0Hash, ""]); await db.prepare("run", query, ["getSegmentsByHash-0", 1, 10, 2, 0, "getSegmentsByHash-01", "testman", 0, 50, "sponsor", "skip", "YouTube", 0, 0, getSegmentsByHash0Hash, ""]);
@@ -50,6 +51,8 @@ describe("getSkipSegmentsByHash", () => {
await db.prepare("run", query, ["differentCategoryVid", 60, 70, 2, 1, "differentCategoryVid-2", "testman", 0, 50, "intro", "skip", "YouTube", 0, 0, differentCategoryVidHash, ""]); await db.prepare("run", query, ["differentCategoryVid", 60, 70, 2, 1, "differentCategoryVid-2", "testman", 0, 50, "intro", "skip", "YouTube", 0, 0, differentCategoryVidHash, ""]);
await db.prepare("run", query, ["nonMusicOverlapVid", 60, 70, 2, 0, "nonMusicOverlapVid-1", "testman", 0, 50, "sponsor", "skip", "YouTube", 0, 0, nonMusicOverlapVidHash, ""]); await db.prepare("run", query, ["nonMusicOverlapVid", 60, 70, 2, 0, "nonMusicOverlapVid-1", "testman", 0, 50, "sponsor", "skip", "YouTube", 0, 0, nonMusicOverlapVidHash, ""]);
await db.prepare("run", query, ["nonMusicOverlapVid", 60, 70, 2, 1, "nonMusicOverlapVid-2", "testman", 0, 50, "music_offtopic", "skip", "YouTube", 0, 0, nonMusicOverlapVidHash, ""]); await db.prepare("run", query, ["nonMusicOverlapVid", 60, 70, 2, 1, "nonMusicOverlapVid-2", "testman", 0, 50, "music_offtopic", "skip", "YouTube", 0, 0, nonMusicOverlapVidHash, ""]);
await db.prepare("run", query, ["fullCategoryVid", 60, 70, 2, 0, "fullCategoryVid-1", "testman", 0, 50, "sponsor", "full", "YouTube", 0, 0, fullCategoryVidHash, ""]);
await db.prepare("run", query, ["fullCategoryVid", 60, 70, 2, 1, "fullCategoryVid-2", "testman", 0, 50, "selfpromo", "full", "YouTube", 0, 0, fullCategoryVidHash, ""]);
}); });
it("Should be able to get a 200", (done) => { it("Should be able to get a 200", (done) => {
@@ -528,6 +531,19 @@ describe("getSkipSegmentsByHash", () => {
.catch(err => done(err)); .catch(err => done(err));
}); });
it("Should only return one segment when fetching full video segments", (done) => {
client.get(`${endpoint}/278f`, { params: { category: ["sponsor", "selfpromo"], actionType: "full" } })
.then(res => {
assert.strictEqual(res.status, 200);
const data = res.data;
assert.strictEqual(data.length, 1);
assert.strictEqual(data[0].segments.length, 1);
assert.strictEqual(data[0].segments[0].category, "selfpromo");
done();
})
.catch(err => done(err));
});
it("Should be able to get specific segments with partial requiredSegments", (done) => { it("Should be able to get specific segments with partial requiredSegments", (done) => {
const requiredSegment1 = "fbf0af454059733c8822f6a4ac8ec568e0787f8c0a5ee915dd5b05e0d7a9a388"; const requiredSegment1 = "fbf0af454059733c8822f6a4ac8ec568e0787f8c0a5ee915dd5b05e0d7a9a388";
const requiredSegment2 = "7e1ebc5194551d2d0a606d64f675e5a14952e4576b2959f8c9d51e316c14f8da"; const requiredSegment2 = "7e1ebc5194551d2d0a606d64f675e5a14952e4576b2959f8c9d51e316c14f8da";

View File

@@ -1,12 +1,12 @@
import { getSubmissionUUID } from "../../src/utils/getSubmissionUUID"; import { getSubmissionUUID } from "../../src/utils/getSubmissionUUID";
import assert from "assert"; import assert from "assert";
import { ActionType, VideoID, Service } from "../../src/types/segments.model"; import { ActionType, VideoID, Service, Category } from "../../src/types/segments.model";
import { UserID } from "../../src/types/user.model"; import { UserID } from "../../src/types/user.model";
describe("getSubmissionUUID", () => { describe("getSubmissionUUID", () => {
it("Should return the hashed value", () => { it("Should return the hashed value", () => {
assert.strictEqual( assert.strictEqual(
getSubmissionUUID("video001" as VideoID, "skip" as ActionType, "testuser001" as UserID, 13.33337, 42.000001, Service.YouTube), getSubmissionUUID("video001" as VideoID, "sponsor" as Category, "skip" as ActionType, "testuser001" as UserID, 13.33337, 42.000001, Service.YouTube),
"529611b4cdd7319e705a32ae9557a02e59c8dbc1306097b2d2d5807c6405e9b1a"); "2a473bca993dd84d8c2f6a4785989b20948dfe0c12c00f6f143bbda9ed561dca6");
}); });
}); });

View File

@@ -3,8 +3,9 @@ import { db } from "../../src/databases/databases";
import assert from "assert"; import assert from "assert";
import { LockCategory } from "../../src/types/segments.model"; import { LockCategory } from "../../src/types/segments.model";
import { client } from "../utils/httpClient"; import { client } from "../utils/httpClient";
import { partialDeepEquals } from "../utils/partialDeepEquals";
const stringDeepEquals = (a: string[] ,b: string[]): boolean => { const stringDeepEquals = (a: string[], b: string[]): boolean => {
let result = true; let result = true;
b.forEach((e) => { b.forEach((e) => {
if (!a.includes(e)) result = false; if (!a.includes(e)) result = false;
@@ -23,18 +24,27 @@ describe("lockCategoriesRecords", () => {
const insertVipUserQuery = 'INSERT INTO "vipUsers" ("userID") VALUES (?)'; const insertVipUserQuery = 'INSERT INTO "vipUsers" ("userID") VALUES (?)';
await db.prepare("run", insertVipUserQuery, [lockVIPUserHash]); await db.prepare("run", insertVipUserQuery, [lockVIPUserHash]);
const insertLockCategoryQuery = 'INSERT INTO "lockCategories" ("userID", "videoID", "category", "reason", "service") VALUES (?, ?, ?, ?, ?)'; const insertLockCategoryQuery = 'INSERT INTO "lockCategories" ("userID", "videoID", "actionType", "category", "reason", "service") VALUES (?, ?, ?, ?, ?, ?)';
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id", "sponsor", "reason-1", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id", "skip", "sponsor", "reason-1", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id", "intro", "reason-1", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id", "mute", "sponsor", "reason-1", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id", "skip", "intro", "reason-1", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id", "mute", "intro", "reason-1", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id-1", "sponsor", "reason-2", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id-1", "skip", "sponsor", "reason-2", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id-1", "intro", "reason-2", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id-1", "mute", "sponsor", "reason-2", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "lockCategoryVideo", "sponsor", "reason-3", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id-1", "skip", "intro", "reason-2", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "no-segments-video-id-1", "mute", "intro", "reason-2", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "lockCategoryVideo", "skip", "sponsor", "reason-3", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "lockCategoryVideo", "mute", "sponsor", "reason-3", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record", "sponsor", "reason-4", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "lockCategoryVideo-2", "skip", "sponsor", "reason-4", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record-1", "sponsor", "reason-5", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record", "skip", "sponsor", "reason-4", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record-1", "intro", "reason-5", "YouTube"]); await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record", "mute", "sponsor", "reason-4", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record-1", "skip", "sponsor", "reason-5", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record-1", "mute", "sponsor", "reason-5", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record-1", "skip", "intro", "reason-5", "YouTube"]);
await db.prepare("run", insertLockCategoryQuery, [lockVIPUserHash, "delete-record-1", "mute", "intro", "reason-5", "YouTube"]);
}); });
it("Should update the database version when starting the application", async () => { it("Should update the database version when starting the application", async () => {
@@ -60,6 +70,32 @@ describe("lockCategoriesRecords", () => {
"outro", "outro",
"shilling", "shilling",
], ],
submittedValues: [
{
actionType: "skip",
category: "outro"
},
{
actionType: "mute",
category: "outro"
},
{
actionType: "skip",
category: "shilling"
},
{
actionType: "mute",
category: "shilling"
},
{
actionType: "skip",
category: "intro"
},
{
actionType: "mute",
category: "intro"
}
]
}; };
client.post(endpoint, json) client.post(endpoint, json)
.then(res => { .then(res => {
@@ -88,15 +124,15 @@ describe("lockCategoriesRecords", () => {
.then(async res => { .then(async res => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const result = await checkLockCategories(videoID); const result = await checkLockCategories(videoID);
assert.strictEqual(result.length, 4); assert.strictEqual(result.length, 8);
const oldRecordNotChangeReason = result.filter(item => const oldRecordNotChangeReason = result.filter(item =>
item.reason === "reason-2" && ["sponsor", "intro"].includes(item.category) item.reason === "reason-2" && ["sponsor", "intro"].includes(item.category)
); );
const newRecordWithEmptyReason = result.filter(item => const newRecordWithEmptyReason = result.filter(item =>
item.reason === "" && ["outro", "shilling"].includes(item.category) item.reason === "" && ["outro", "shilling"].includes(item.category)
); );
assert.strictEqual(newRecordWithEmptyReason.length, 2); assert.strictEqual(newRecordWithEmptyReason.length, 4);
assert.strictEqual(oldRecordNotChangeReason.length, 2); assert.strictEqual(oldRecordNotChangeReason.length, 4);
done(); done();
}) })
.catch(err => done(err)); .catch(err => done(err));
@@ -160,7 +196,7 @@ describe("lockCategoriesRecords", () => {
.then(async res => { .then(async res => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const result = await checkLockCategories(videoID); const result = await checkLockCategories(videoID);
assert.strictEqual(result.length, 4); assert.strictEqual(result.length, 8);
const newRecordWithNewReason = result.filter(item => const newRecordWithNewReason = result.filter(item =>
expectedWithNewReason.includes(item.category) && item.reason === "new reason" expectedWithNewReason.includes(item.category) && item.reason === "new reason"
); );
@@ -168,8 +204,8 @@ describe("lockCategoriesRecords", () => {
item.reason === "reason-2" item.reason === "reason-2"
); );
assert.strictEqual(newRecordWithNewReason.length, 3); assert.strictEqual(newRecordWithNewReason.length, 6);
assert.strictEqual(oldRecordNotChangeReason.length, 1); assert.strictEqual(oldRecordNotChangeReason.length, 2);
done(); done();
}) })
.catch(err => done(err)); .catch(err => done(err));
@@ -187,7 +223,7 @@ describe("lockCategoriesRecords", () => {
.then(async res => { .then(async res => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const result = await checkLockCategories("underscore"); const result = await checkLockCategories("underscore");
assert.strictEqual(result.length, 1); assert.strictEqual(result.length, 2);
done(); done();
}) })
.catch(err => done(err)); .catch(err => done(err));
@@ -205,7 +241,7 @@ describe("lockCategoriesRecords", () => {
.then(async res => { .then(async res => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const result = await checkLockCategories("bothCases"); const result = await checkLockCategories("bothCases");
assert.strictEqual(result.length, 1); assert.strictEqual(result.length, 2);
done(); done();
}) })
.catch(err => done(err)); .catch(err => done(err));
@@ -231,6 +267,41 @@ describe("lockCategoriesRecords", () => {
.catch(err => done(err)); .catch(err => done(err));
}); });
it("Should be able to submit specific action type not in video (sql check)", (done) => {
const videoID = "lockCategoryVideo-2";
const json = {
videoID,
userID: lockVIPUser,
categories: [
"sponsor",
],
actionTypes: [
"mute"
],
reason: "custom-reason",
};
client.post(endpoint, json)
.then(async res => {
assert.strictEqual(res.status, 200);
const result = await checkLockCategories(videoID);
assert.strictEqual(result.length, 2);
assert.ok(partialDeepEquals(result, [
{
category: "sponsor",
actionType: "skip",
reason: "reason-4",
},
{
category: "sponsor",
actionType: "mute",
reason: "custom-reason",
}
]));
done();
})
.catch(err => done(err));
});
it("Should return 400 for missing params", (done) => { it("Should return 400 for missing params", (done) => {
client.post(endpoint, {}) client.post(endpoint, {})
.then(res => { .then(res => {
@@ -365,7 +436,7 @@ describe("lockCategoriesRecords", () => {
.then(async res => { .then(async res => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
const result = await checkLockCategories(videoID); const result = await checkLockCategories(videoID);
assert.strictEqual(result.length, 1); assert.strictEqual(result.length, 2);
done(); done();
}) })
.catch(err => done(err)); .catch(err => done(err));

View File

@@ -16,6 +16,7 @@ describe("postSkipSegments", () => {
// Constant and helpers // Constant and helpers
const submitUserOne = `PostSkipUser1${".".repeat(18)}`; const submitUserOne = `PostSkipUser1${".".repeat(18)}`;
const submitUserTwo = `PostSkipUser2${".".repeat(18)}`; const submitUserTwo = `PostSkipUser2${".".repeat(18)}`;
const submitUserTwoHash = getHash(submitUserTwo);
const submitUserThree = `PostSkipUser3${".".repeat(18)}`; const submitUserThree = `PostSkipUser3${".".repeat(18)}`;
const warnUser01 = "warn-user01-qwertyuiopasdfghjklzxcvbnm"; const warnUser01 = "warn-user01-qwertyuiopasdfghjklzxcvbnm";
@@ -34,8 +35,9 @@ describe("postSkipSegments", () => {
const warnVideoID = "postSkip2"; const warnVideoID = "postSkip2";
const badInputVideoID = "dQw4w9WgXcQ"; const badInputVideoID = "dQw4w9WgXcQ";
const shadowBanVideoID = "postSkipBan"; const shadowBanVideoID = "postSkipBan";
const shadowBanVideoID2 = "postSkipBan2";
const queryDatabase = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]); const queryDatabase = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "votes", "userID", "locked", "category", "actionType" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]);
const queryDatabaseActionType = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category", "actionType" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]); const queryDatabaseActionType = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category", "actionType" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]);
const queryDatabaseChapter = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category", "actionType", "description" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]); const queryDatabaseChapter = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category", "actionType", "description" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]);
const queryDatabaseDuration = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category", "videoDuration" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]); const queryDatabaseDuration = (videoID: string) => db.prepare("get", `SELECT "startTime", "endTime", "locked", "category", "videoDuration" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]);
@@ -54,10 +56,15 @@ describe("postSkipSegments", () => {
}); });
before(() => { before(() => {
const insertSponsorTimeQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "UUID", "userID", "timeSubmitted", views, category, "shadowHidden", "hashedVideoID") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; const insertSponsorTimeQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "UUID", "userID", "timeSubmitted", views, category, "actionType", "videoDuration", "shadowHidden", "hashedVideoID") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
db.prepare("run", insertSponsorTimeQuery, ["80percent_video", 0, 1000, 0, "80percent-uuid-0", submitUserOneHash, 0, 0, "interaction", 0, "80percent_video"]); db.prepare("run", insertSponsorTimeQuery, ["80percent_video", 0, 1000, 0, "80percent-uuid-0", submitUserOneHash, 0, 0, "interaction", "skip", 0, 0, "80percent_video"]);
db.prepare("run", insertSponsorTimeQuery, ["80percent_video", 1001, 1005, 0, "80percent-uuid-1", submitUserOneHash, 0, 0, "interaction", 0, "80percent_video"]); db.prepare("run", insertSponsorTimeQuery, ["80percent_video", 1001, 1005, 0, "80percent-uuid-1", submitUserOneHash, 0, 0, "interaction", "skip", 0, 0, "80percent_video"]);
db.prepare("run", insertSponsorTimeQuery, ["80percent_video", 0, 5000, -2, "80percent-uuid-2", submitUserOneHash, 0, 0, "interaction", 0, "80percent_video"]); db.prepare("run", insertSponsorTimeQuery, ["80percent_video", 0, 5000, -2, "80percent-uuid-2", submitUserOneHash, 0, 0, "interaction", "skip", 0, 0, "80percent_video"]);
db.prepare("run", insertSponsorTimeQuery, ["full_video_segment", 0, 0, 0, "full-video-uuid-0", submitUserTwoHash, 0, 0, "sponsor", "full", 0, 0, "full_video_segment"]);
db.prepare("run", insertSponsorTimeQuery, ["full_video_duration_segment", 0, 0, 0, "full-video-duration-uuid-0", submitUserTwoHash, 0, 0, "sponsor", "full", 123, 0, "full_video_duration_segment"]);
db.prepare("run", insertSponsorTimeQuery, ["full_video_duration_segment", 25, 30, 0, "full-video-duration-uuid-1", submitUserTwoHash, 0, 0, "sponsor", "skip", 123, 0, "full_video_duration_segment"]);
const now = Date.now(); const now = Date.now();
const warnVip01Hash = getHash("warn-vip01-qwertyuiopasdfghjklzxcvbnm"); const warnVip01Hash = getHash("warn-vip01-qwertyuiopasdfghjklzxcvbnm");
@@ -405,6 +412,39 @@ describe("postSkipSegments", () => {
.catch(err => done(err)); .catch(err => done(err));
}); });
it("Should be able to submit with a new duration, and not hide full video segments", async () => {
const videoID = "full_video_duration_segment";
const res = await postSkipSegmentJSON({
userID: submitUserOne,
videoID,
videoDuration: 100,
segments: [{
segment: [20, 30],
category: "sponsor",
}],
});
assert.strictEqual(res.status, 200);
const videoRows = await db.prepare("all", `SELECT "startTime", "endTime", "locked", "category", "actionType", "videoDuration"
FROM "sponsorTimes" WHERE "videoID" = ? AND hidden = 0`, [videoID]);
const hiddenVideoRows = await db.prepare("all", `SELECT "startTime", "endTime", "locked", "category", "videoDuration"
FROM "sponsorTimes" WHERE "videoID" = ? AND hidden = 1`, [videoID]);
assert.strictEqual(videoRows.length, 2);
const expected = {
startTime: 20,
endTime: 30,
locked: 0,
category: "sponsor",
videoDuration: 100
};
assert.ok(partialDeepEquals(videoRows[1], expected));
const fullExpected = {
category: "sponsor",
actionType: "full"
};
assert.ok(partialDeepEquals(videoRows[0], fullExpected));
assert.strictEqual(hiddenVideoRows.length, 1);
});
it("Should be able to submit a single time under a different service (JSON method)", (done) => { it("Should be able to submit a single time under a different service (JSON method)", (done) => {
const videoID = "postSkip7"; const videoID = "postSkip7";
postSkipSegmentJSON({ postSkipSegmentJSON({
@@ -901,6 +941,26 @@ describe("postSkipSegments", () => {
.catch(err => done(err)); .catch(err => done(err));
}); });
it("Should return not be 403 when submitting with locked category but unlocked actionType", (done) => {
const videoID = "lockedVideo";
db.prepare("run", `INSERT INTO "lockCategories" ("userID", "videoID", "category", "reason")
VALUES(?, ?, ?, ?)`, [getHash("VIPUser-lockCategories"), videoID, "sponsor", "Custom Reason"])
.then(() => postSkipSegmentJSON({
userID: submitUserOne,
videoID,
segments: [{
segment: [1, 10],
category: "sponsor",
actionType: "mute"
}],
}))
.then(res => {
assert.strictEqual(res.status, 200);
done();
})
.catch(err => done(err));
});
it("Should return 403 for submiting in lockedCategory", (done) => { it("Should return 403 for submiting in lockedCategory", (done) => {
const videoID = "lockedVideo1"; const videoID = "lockedVideo1";
db.prepare("run", `INSERT INTO "lockCategories" ("userID", "videoID", "category", "reason") db.prepare("run", `INSERT INTO "lockCategories" ("userID", "videoID", "category", "reason")
@@ -1044,6 +1104,77 @@ describe("postSkipSegments", () => {
.catch(err => done(err)); .catch(err => done(err));
}); });
it("Should allow submitting full video sponsor", (done) => {
const videoID = "qqwerth";
postSkipSegmentParam({
videoID,
startTime: 0,
endTime: 0,
category: "sponsor",
actionType: "full",
userID: submitUserTwo
})
.then(async res => {
assert.strictEqual(res.status, 200);
const row = await queryDatabase(videoID);
const expected = {
startTime: 0,
endTime: 0,
votes: 0,
userID: submitUserTwoHash,
category: "sponsor",
actionType: "full"
};
assert.ok(partialDeepEquals(row, expected));
done();
})
.catch(err => done(err));
});
it("Submitting duplicate full video sponsor should count as an upvote", (done) => {
const videoID = "full_video_segment";
postSkipSegmentParam({
videoID,
startTime: 0,
endTime: 0,
category: "sponsor",
actionType: "full",
userID: submitUserOne
})
.then(async res => {
assert.strictEqual(res.status, 200);
const row = await queryDatabase(videoID);
const expected = {
startTime: 0,
endTime: 0,
votes: 1,
userID: submitUserTwoHash,
category: "sponsor",
actionType: "full"
};
assert.ok(partialDeepEquals(row, expected));
done();
})
.catch(err => done(err));
});
it("Should not allow submitting full video sponsor not at zero seconds", (done) => {
const videoID = "qqwerth";
postSkipSegmentParam({
videoID,
startTime: 0,
endTime: 1,
category: "sponsor",
actionType: "full",
userID: submitUserTwo
})
.then(res => {
assert.strictEqual(res.status, 400);
done();
})
.catch(err => done(err));
});
it("Should not be able to submit with colons in timestamps", (done) => { it("Should not be able to submit with colons in timestamps", (done) => {
const videoID = "colon-1"; const videoID = "colon-1";
postSkipSegmentJSON({ postSkipSegmentJSON({
@@ -1085,6 +1216,25 @@ describe("postSkipSegments", () => {
.catch(err => done(err)); .catch(err => done(err));
}); });
it("Should not add full segments to database if user if shadowbanned", (done) => {
const videoID = shadowBanVideoID2;
postSkipSegmentParam({
videoID,
startTime: 0,
endTime: 0,
category: "sponsor",
actionType: "full",
userID: banUser01
})
.then(async res => {
assert.strictEqual(res.status, 200);
const row = await db.prepare("get", `SELECT "startTime", "endTime", "shadowHidden", "userID" FROM "sponsorTimes" WHERE "videoID" = ?`, [videoID]);
assert.strictEqual(row, undefined);
done();
})
.catch(err => done(err));
});
it("Should return 400 if videoID is empty", (done) => { it("Should return 400 if videoID is empty", (done) => {
const videoID = null as string; const videoID = null as string;
postSkipSegmentParam({ postSkipSegmentParam({

View File

@@ -13,7 +13,7 @@ export class YouTubeApiMock {
}; };
} }
if (obj.id === "noDuration") { if (obj.id === "noDuration" || obj.id === "full_video_duration_segment") {
return { return {
err: null, err: null,
data: { data: {