From 76cc603a3f52b89ebfc325f68e56e97a8922c6cd Mon Sep 17 00:00:00 2001 From: Michael C Date: Thu, 31 Mar 2022 16:02:50 -0400 Subject: [PATCH 1/5] update auotmod check - remove NB code - reduce complexity + unnecessary iterations - use client duration if given --- src/routes/postSkipSegments.ts | 193 +++++++-------------------------- test/cases/postSkipSegments.ts | 16 --- 2 files changed, 42 insertions(+), 167 deletions(-) diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index d704c85..5ed0efc 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -112,55 +112,6 @@ async function sendWebhooks(apiVideoInfo: APIVideoInfo, userID: string, videoID: } } -async function sendWebhooksNB(userID: string, videoID: string, UUID: string, startTime: number, endTime: number, category: string, probability: number, ytData: any) { - const submissionInfoRow = await db.prepare("get", `SELECT - (select count(1) from "sponsorTimes" where "userID" = ?) count, - (select count(1) from "sponsorTimes" where "userID" = ? and "votes" <= -2) disregarded, - coalesce((select "userName" FROM "userNames" WHERE "userID" = ?), ?) "userName"`, - [userID, userID, userID, userID]); - - let submittedBy: string; - // If a userName was created then show both - if (submissionInfoRow.userName !== userID) { - submittedBy = `${submissionInfoRow.userName}\n${userID}`; - } else { - submittedBy = userID; - } - - // Send discord message - if (config.discordNeuralBlockRejectWebhookURL === null) return; - - axios.post(config.discordNeuralBlockRejectWebhookURL, { - "embeds": [{ - "title": ytData.items[0].snippet.title, - "url": `https://www.youtube.com/watch?v=${videoID}&t=${(parseFloat(startTime.toFixed(0)) - 2)}`, - "description": `**Submission ID:** ${UUID}\ - \n**Timestamp:** ${getFormattedTime(startTime)} to ${getFormattedTime(endTime)}\ - \n**Predicted Probability:** ${probability}\ - \n**Category:** ${category}\ - \n**Submitted by:** ${submittedBy}\ - \n**Total User Submissions:** ${submissionInfoRow.count}\ - \n**Ignored User Submissions:** ${submissionInfoRow.disregarded}`, - "color": 10813440, - "thumbnail": { - "url": ytData.items[0].snippet.thumbnails.maxres ? ytData.items[0].snippet.thumbnails.maxres.url : "", - }, - }] - }) - .then(res => { - if (res.status >= 400) { - Logger.error("Error sending NeuralBlock Discord hook"); - Logger.error(JSON.stringify(res)); - Logger.error("\n"); - } - }) - .catch(err => { - Logger.error("Failed to send NeuralBlock Discord hook."); - Logger.error(JSON.stringify(err)); - Logger.error("\n"); - }); -} - // callback: function(reject: "String containing reason the submission was rejected") // returns: string when an error, false otherwise @@ -168,98 +119,47 @@ async function sendWebhooksNB(userID: string, videoID: string, UUID: string, sta // false for a pass - it was confusing and lead to this bug - any use of this function in // the future could have the same problem. async function autoModerateSubmission(apiVideoInfo: APIVideoInfo, - submission: { videoID: VideoID; userID: UserID; segments: IncomingSegment[], service: Service }) { - if (apiVideoInfo) { + submission: { videoID: VideoID; userID: UserID; segments: IncomingSegment[], service: Service, videoDuration: number }) { + + const apiVideoDuration = (apiVideoInfo: APIVideoInfo) => { + if (!apiVideoInfo) return undefined; const { err, data } = apiVideoInfo; - if (err) return false; + // return undefined if API error + if (err) return undefined; + return data?.lengthSeconds; + }; + // get duration from API + const apiDuration = apiVideoDuration(apiVideoInfo); + // if API fail or returns 0, get duration from client + const duration = apiDuration || submission.videoDuration; + // return false on undefined or 0 + if (!duration) return false; - const duration = apiVideoInfo?.data?.lengthSeconds; - const segments = submission.segments; - let nbString = ""; - for (let i = 0; i < segments.length; i++) { - if (duration == 0) { - // Allow submission if the duration is 0 (bug in youtube api) - return false; - } else { - if (segments[i].category === "sponsor") { - //Prepare timestamps to send to NB all at once - nbString = `${nbString}${segments[i].segment[0]},${segments[i].segment[1]};`; - } - } - } + const segments = submission.segments; + // map all times to float array + const allSegmentTimes = segments.map(segment => [parseFloat(segment.segment[0]), parseFloat(segment.segment[1])]); - // Get all submissions for this user - const allSubmittedByUser = await db.prepare("all", `SELECT "startTime", "endTime" FROM "sponsorTimes" WHERE "userID" = ? and "videoID" = ? and "votes" > -1`, [submission.userID, submission.videoID]); - const allSegmentTimes = []; - if (allSubmittedByUser !== undefined) { - //add segments the user has previously submitted - for (const segmentInfo of allSubmittedByUser) { - allSegmentTimes.push([parseFloat(segmentInfo.startTime), parseFloat(segmentInfo.endTime)]); - } - } + // add previous submissions by this user + const allSubmittedByUser = await db.prepare("all", `SELECT "startTime", "endTime" FROM "sponsorTimes" WHERE "userID" = ? and "videoID" = ? and "votes" > -1`, [submission.userID, submission.videoID]); - //add segments they are trying to add in this submission - for (let i = 0; i < segments.length; i++) { - const startTime = parseFloat(segments[i].segment[0]); - const endTime = parseFloat(segments[i].segment[1]); - allSegmentTimes.push([startTime, endTime]); - } - - //merge all the times into non-overlapping arrays - const allSegmentsSorted = mergeTimeSegments(allSegmentTimes.sort(function (a, b) { - return a[0] - b[0] || a[1] - b[1]; - })); - - const videoDuration = data?.lengthSeconds; - if (videoDuration != 0) { - let allSegmentDuration = 0; - //sum all segment times together - allSegmentsSorted.forEach(segmentInfo => allSegmentDuration += segmentInfo[1] - segmentInfo[0]); - if (allSegmentDuration > (videoDuration / 100) * 80) { - // Reject submission if all segments combine are over 80% of the video - return "Total length of your submitted segments are over 80% of the video."; - } - } - - // Check NeuralBlock - const neuralBlockURL = config.neuralBlockURL; - if (!neuralBlockURL) return false; - const response = await axios.get(`${neuralBlockURL}/api/checkSponsorSegments?vid=${submission.videoID} - &segments=${nbString.substring(0, nbString.length - 1)}`, { validateStatus: () => true }); - if (response.status !== 200) return false; - - const nbPredictions = response.data; - let nbDecision = false; - let predictionIdx = 0; //Keep track because only sponsor categories were submitted - for (let i = 0; i < segments.length; i++) { - if (segments[i].category === "sponsor") { - if (nbPredictions.probabilities[predictionIdx] < 0.70) { - nbDecision = true; // At least one bad entry - const startTime = parseFloat(segments[i].segment[0]); - const endTime = parseFloat(segments[i].segment[1]); - - const UUID = getSubmissionUUID(submission.videoID, segments[i].category, segments[i].actionType, submission.userID, startTime, endTime, submission.service); - // Send to Discord - // 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); - } - predictionIdx++; - } - - } - - if (nbDecision) { - return "Rejected based on NeuralBlock predictions."; - } else { - return false; - } - } else { - Logger.debug("Skipped YouTube API"); - - // Can't moderate the submission without calling the youtube API - // so allow by default. - return false; + if (allSubmittedByUser) { + //add segments the user has previously submitted + const allSubmittedTimes = allSubmittedByUser.map((segment: { startTime: string, endTime: string }) => [parseFloat(segment.startTime), parseFloat(segment.endTime)]); + allSegmentTimes.push(...allSubmittedTimes); } + + //merge all the times into non-overlapping arrays + const allSegmentsSorted = mergeTimeSegments(allSegmentTimes.sort((a, b) => a[0] - b[0] || a[1] - b[1])); + + let allSegmentDuration = 0; + //sum all segment times together + allSegmentsSorted.forEach(segmentInfo => allSegmentDuration += segmentInfo[1] - segmentInfo[0]); + + if (allSegmentDuration > (duration / 100) * 80) { + // Reject submission if all segments combine are over 80% of the video + return "Total length of your submitted segments are over 80% of the video."; + } + return false; } function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise { @@ -310,7 +210,7 @@ function checkInvalidFields(videoID: VideoID, userID: UserID, segments: Incoming invalidFields.push("userID"); if (userID?.length < 30) errors.push(`userID must be at least 30 characters long`); } - if (!Array.isArray(segments) || segments.length < 1) { + if (!Array.isArray(segments) || segments.length == 0) { invalidFields.push("segments"); } // validate start and end times (no : marks) @@ -323,7 +223,7 @@ function checkInvalidFields(videoID: VideoID, userID: UserID, segments: Incoming } if (typeof segmentPair.description !== "string" - || (segmentPair.description.length > 60 && segmentPair.actionType === ActionType.Chapter) + || (segmentPair.actionType === ActionType.Chapter && segmentPair.description.length > 60 ) || (segmentPair.description.length !== 0 && segmentPair.actionType !== ActionType.Chapter)) { invalidFields.push("segment description"); } @@ -425,19 +325,11 @@ async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, user return CHECK_PASS; } -async function checkByAutoModerator(videoID: any, userID: any, segments: Array, isVIP: boolean, service:string, apiVideoInfo: APIVideoInfo, decreaseVotes: number): Promise { +async function checkByAutoModerator(videoID: any, userID: any, segments: Array, isVIP: boolean, service:string, apiVideoInfo: APIVideoInfo, decreaseVotes: number, videoDuration: number): Promise { // Auto moderator check if (!isVIP && service == Service.YouTube) { - const autoModerateResult = await autoModerateSubmission(apiVideoInfo, { userID, videoID, segments, service });//startTime, endTime, category: segments[i].category}); - - if (autoModerateResult == "Rejected based on NeuralBlock predictions.") { - // If NB automod rejects, the submission will start with -2 votes. - // Note, if one submission is bad all submissions will be affected. - // However, this behavior is consistent with other automod functions - // already in place. - //decreaseVotes = -2; //Disable for now - } else if (autoModerateResult) { - //Normal automod behavior + const autoModerateResult = await autoModerateSubmission(apiVideoInfo, { userID, videoID, segments, service, videoDuration });//startTime, endTime, category: segments[i].category}); + if (autoModerateResult) { return { pass: false, errorCode: 403, @@ -619,8 +511,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise { .catch(err => done(err)); }); - it("Should be rejected if NB's predicted probability is <70%.", (done) => { - const videoID = "LevkAjUE6d4"; - postSkipSegmentParam({ - videoID, - startTime: 40, - endTime: 60, - userID: submitUserTwo, - category: "sponsor" - }) - .then(res => { - assert.strictEqual(res.status, 200); - done(); - }) - .catch(err => done(err)); - }); - it("Should be rejected with custom message if user has to many active warnings", (done) => { postSkipSegmentJSON({ userID: warnUser01, From d02d78f325f2ea5f7056c4cadf681302f9be3a3f Mon Sep 17 00:00:00 2001 From: Michael C Date: Thu, 31 Mar 2022 16:43:10 -0400 Subject: [PATCH 2/5] add 80% tempVIP - move isUserTempVIP to own file - reduce allSegmentDuration instead of forEach - don't return decreaseVotes from autoModerator - completely skip autoModCheck if VIP --- src/routes/postSkipSegments.ts | 48 +++++++++++++++------------------ src/routes/voteOnSponsorTime.ts | 13 ++------- src/utils/isUserTempVIP.ts | 19 +++++++++++++ 3 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 src/utils/isUserTempVIP.ts diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 5ed0efc..2d89516 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -16,6 +16,7 @@ import { getReputation } from "../utils/reputation"; import { APIVideoData, APIVideoInfo } from "../types/youtubeApi.model"; import { HashedUserID, UserID } from "../types/user.model"; import { isUserVIP } from "../utils/isUserVIP"; +import { isUserTempVIP } from "../utils/isUserTempVIP"; import { parseUserAgent } from "../utils/userAgent"; import { getService } from "../utils/getService"; import axios from "axios"; @@ -81,19 +82,19 @@ async function sendWebhooks(apiVideoInfo: APIVideoInfo, userID: string, videoID: if (config.discordFirstTimeSubmissionsWebhookURL === null || userSubmissionCountRow.submissionCount > 1) return; axios.post(config.discordFirstTimeSubmissionsWebhookURL, { - "embeds": [{ - "title": data?.title, - "url": `https://www.youtube.com/watch?v=${videoID}&t=${(parseInt(startTime.toFixed(0)) - 2)}s#requiredSegment=${UUID}`, - "description": `Submission ID: ${UUID}\ + embeds: [{ + title: data?.title, + url: `https://www.youtube.com/watch?v=${videoID}&t=${(parseInt(startTime.toFixed(0)) - 2)}s#requiredSegment=${UUID}`, + description: `Submission ID: ${UUID}\ \n\nTimestamp: \ ${getFormattedTime(startTime)} to ${getFormattedTime(endTime)}\ \n\nCategory: ${segmentInfo.category}`, - "color": 10813440, - "author": { - "name": userID, + color: 10813440, + author: { + name: userID, }, - "thumbnail": { - "url": getMaxResThumbnail(data) || "", + thumbnail: { + url: getMaxResThumbnail(data) || "", }, }], }) @@ -151,9 +152,8 @@ async function autoModerateSubmission(apiVideoInfo: APIVideoInfo, //merge all the times into non-overlapping arrays const allSegmentsSorted = mergeTimeSegments(allSegmentTimes.sort((a, b) => a[0] - b[0] || a[1] - b[1])); - let allSegmentDuration = 0; //sum all segment times together - allSegmentsSorted.forEach(segmentInfo => allSegmentDuration += segmentInfo[1] - segmentInfo[0]); + const allSegmentDuration = allSegmentsSorted.reduce((acc, curr) => acc + (curr[1] - curr[0]), 0); if (allSegmentDuration > (duration / 100) * 80) { // Reject submission if all segments combine are over 80% of the video @@ -325,24 +325,20 @@ async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, user return CHECK_PASS; } -async function checkByAutoModerator(videoID: any, userID: any, segments: Array, isVIP: boolean, service:string, apiVideoInfo: APIVideoInfo, decreaseVotes: number, videoDuration: number): Promise { +async function checkByAutoModerator(videoID: any, userID: any, segments: Array, service:string, apiVideoInfo: APIVideoInfo, videoDuration: number): Promise { // Auto moderator check - if (!isVIP && service == Service.YouTube) { + if (service == Service.YouTube) { const autoModerateResult = await autoModerateSubmission(apiVideoInfo, { userID, videoID, segments, service, videoDuration });//startTime, endTime, category: segments[i].category}); if (autoModerateResult) { return { pass: false, errorCode: 403, - errorMessage: `Request rejected by auto moderator: ${autoModerateResult} If this is an issue, send a message on Discord.`, - decreaseVotes + errorMessage: `Request rejected by auto moderator: ${autoModerateResult} If this is an issue, send a message on Discord.` }; } } - return { - ...CHECK_PASS, - decreaseVotes - }; + return CHECK_PASS; } async function updateDataIfVideoDurationChange(videoID: VideoID, service: Service, videoDuration: VideoDuration, videoDurationParam: VideoDuration) { @@ -498,6 +494,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise => { - const apiVideoInfo = await getYouTubeVideoInfo(videoID); - const channelID = apiVideoInfo?.data?.authorId; - const { err, reply } = await redis.getAsync(tempVIPKey(nonAnonUserID)); - - return err || !reply ? false : (reply == channelID); -}; - const videoDurationChanged = (segmentDuration: number, APIDuration: number) => (APIDuration > 0 && Math.abs(segmentDuration - APIDuration) > 2); async function updateSegmentVideoDuration(UUID: SegmentUUID) { diff --git a/src/utils/isUserTempVIP.ts b/src/utils/isUserTempVIP.ts new file mode 100644 index 0000000..dc098e0 --- /dev/null +++ b/src/utils/isUserTempVIP.ts @@ -0,0 +1,19 @@ +import redis from "../utils/redis"; +import { tempVIPKey } from "../utils/redisKeys"; +import { HashedUserID } from "../types/user.model"; +import { YouTubeAPI } from "../utils/youtubeApi"; +import { APIVideoInfo } from "../types/youtubeApi.model"; +import { VideoID } from "../types/segments.model"; +import { config } from "../config"; + +function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise { + return config.newLeafURLs ? YouTubeAPI.listVideos(videoID, ignoreCache) : null; +} + +export const isUserTempVIP = async (hashedUserID: HashedUserID, videoID: VideoID): Promise => { + const apiVideoInfo = await getYouTubeVideoInfo(videoID); + const channelID = apiVideoInfo?.data?.authorId; + const { err, reply } = await redis.getAsync(tempVIPKey(hashedUserID)); + + return err || !reply ? false : (reply == channelID); +}; \ No newline at end of file From d392b1c8fcb7638fa9768863d86535b5e72d32b1 Mon Sep 17 00:00:00 2001 From: Michael C Date: Thu, 31 Mar 2022 16:52:05 -0400 Subject: [PATCH 3/5] remove outdated comments & unnecessary space --- src/routes/postSkipSegments.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 2d89516..3e6a8c0 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -328,7 +328,7 @@ async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, user async function checkByAutoModerator(videoID: any, userID: any, segments: Array, service:string, apiVideoInfo: APIVideoInfo, videoDuration: number): Promise { // Auto moderator check if (service == Service.YouTube) { - const autoModerateResult = await autoModerateSubmission(apiVideoInfo, { userID, videoID, segments, service, videoDuration });//startTime, endTime, category: segments[i].category}); + const autoModerateResult = await autoModerateSubmission(apiVideoInfo, { userID, videoID, segments, service, videoDuration }); if (autoModerateResult) { return { pass: false, @@ -337,7 +337,6 @@ async function checkByAutoModerator(videoID: any, userID: any, segments: Array Date: Thu, 31 Mar 2022 18:03:42 -0400 Subject: [PATCH 4/5] merged changes by @mini-bomba --- src/routes/postSkipSegments.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 3e6a8c0..669bb54 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -141,7 +141,7 @@ async function autoModerateSubmission(apiVideoInfo: APIVideoInfo, const allSegmentTimes = segments.map(segment => [parseFloat(segment.segment[0]), parseFloat(segment.segment[1])]); // add previous submissions by this user - const allSubmittedByUser = await db.prepare("all", `SELECT "startTime", "endTime" FROM "sponsorTimes" WHERE "userID" = ? and "videoID" = ? and "votes" > -1`, [submission.userID, submission.videoID]); + const allSubmittedByUser = await db.prepare("all", `SELECT "startTime", "endTime" FROM "sponsorTimes" WHERE "userID" = ? AND "videoID" = ? AND "votes" > -1 AND "hidden" = 0`, [submission.userID, submission.videoID]); if (allSubmittedByUser) { //add segments the user has previously submitted @@ -302,7 +302,7 @@ async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, user } if (!isVIP && segments[i].category === "sponsor" - && segments[i].actionType !== ActionType.Full && Math.abs(startTime - endTime) < 1) { + && segments[i].actionType !== ActionType.Full && (endTime - startTime) < 1) { // Too short return { pass: false, errorMessage: "Segments must be longer than 1 second long", errorCode: 400 }; } @@ -488,7 +488,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise((prev, val) => `${prev} ${val.category}`, "")}', times: ${segments.reduce((prev, val) => `${prev} ${val.segment}`, "")}`); + Logger.warn(`Caught a submission for a warned user. userID: '${userID}', videoID: '${videoID}', category: '${segments.reduce((prev, val) => `${prev} ${val.category}`, "")}', times: ${segments.reduce((prev, val) => `${prev} ${val.segment}`, "")}`); return res.status(userWarningCheckResult.errorCode).send(userWarningCheckResult.errorMessage); } From b09ed1cbe22e06d53af1ad825c5539b7753a9dec Mon Sep 17 00:00:00 2001 From: "Michael M. Chang" Date: Mon, 11 Apr 2022 01:54:28 -0400 Subject: [PATCH 5/5] Update src/routes/postSkipSegments.ts Co-authored-by: Ajay Ramachandran --- src/routes/postSkipSegments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 669bb54..8ccee51 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -506,7 +506,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise