From 2773c5f500e506b1fc0111d16b5c4565b7809211 Mon Sep 17 00:00:00 2001 From: Haidang666 Date: Wed, 21 Jul 2021 16:16:58 +0700 Subject: [PATCH 1/4] Update: most upvoted segments on locked videos as locked submissions --- src/utils/reputation.ts | 76 +++++++++++++++++++------------- test/cases/reputation.ts | 95 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 35 deletions(-) diff --git a/src/utils/reputation.ts b/src/utils/reputation.ts index 5d06517..61233e8 100644 --- a/src/utils/reputation.ts +++ b/src/utils/reputation.ts @@ -10,7 +10,8 @@ interface ReputationDBResult { votedSum: number, lockedSum: number, semiOldUpvotedSubmissions: number, - oldUpvotedSubmissions: number + oldUpvotedSubmissions: number, + mostUpvotedInLockedVideoSum: number } export async function getReputation(userID: UserID): Promise { @@ -28,43 +29,58 @@ export async function getReputation(userID: UserID): Promise { SUM(CASE WHEN "timeSubmitted" > 1596240000000 THEN "votes" ELSE 0 END) AS "votedSum", SUM(locked) AS "lockedSum", SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "votes" > 0 THEN 1 ELSE 0 END) AS "semiOldUpvotedSubmissions", - SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "votes" > 0 THEN 1 ELSE 0 END) AS "oldUpvotedSubmissions" + SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "votes" > 0 THEN 1 ELSE 0 END) AS "oldUpvotedSubmissions", + SUM(CASE WHEN "votes" > 0 + AND NOT EXISTS ( + SELECT * FROM "sponsorTimes" as c + WHERE c."videoID" = "a"."videoID" AND c."votes" > "a"."votes" LIMIT 1) + AND EXISTS ( + SELECT * FROM "lockCategories" as l + WHERE l."videoID" = "a"."videoID" AND l."category" = "a"."category" LIMIT 1) + THEN 1 ELSE 0 END) AS "mostUpvotedInLockedVideoSum" FROM "sponsorTimes" as "a" WHERE "userID" = ?`, [userID, weekAgo, pastDate, userID]) as Promise; const result = await QueryCacher.get(fetchFromDB, reputationKey(userID)); - // Grace period - if (result.totalSubmissions < 5) { - return 0; - } - - const downvoteRatio = result.downvotedSubmissions / result.totalSubmissions; - if (downvoteRatio > 0.3) { - return convertRange(Math.min(downvoteRatio, 0.7), 0.3, 0.7, -0.5, -2.5); - } - - const nonSelfDownvoteRatio = result.nonSelfDownvotedSubmissions / result.totalSubmissions; - if (nonSelfDownvoteRatio > 0.05) { - return convertRange(Math.min(nonSelfDownvoteRatio, 0.4), 0.05, 0.4, -0.5, -2.5); - } - - if (result.votedSum < 5) { - return 0; - } - - if (result.oldUpvotedSubmissions < 3) { - if (result.semiOldUpvotedSubmissions > 3) { - return convertRange(Math.min(result.votedSum, 150), 5, 150, 0, 2) + convertRange(Math.min(result.lockedSum ?? 0, 50), 0, 50, 0, 5); - } else { - return 0; - } - } - - return convertRange(Math.min(result.votedSum, 150), 5, 150, 0, 7) + convertRange(Math.min(result.lockedSum ?? 0, 50), 0, 50, 0, 20); + return calculateFromMetrics(result); } +// convert a number from one range to another. function convertRange(value: number, currentMin: number, currentMax: number, targetMin: number, targetMax: number): number { const currentRange = currentMax - currentMin; const targetRange = targetMax - targetMin; return ((value - currentMin) / currentRange) * targetRange + targetMin; } + +export function calculateFromMetrics(metrics: ReputationDBResult): number { + // Grace period + if (metrics.totalSubmissions < 5) { + return 0; + } + + const downvoteRatio = metrics.downvotedSubmissions / metrics.totalSubmissions; + if (downvoteRatio > 0.3) { + return convertRange(Math.min(downvoteRatio, 0.7), 0.3, 0.7, -0.5, -2.5); + } + + const nonSelfDownvoteRatio = metrics.nonSelfDownvotedSubmissions / metrics.totalSubmissions; + if (nonSelfDownvoteRatio > 0.05) { + return convertRange(Math.min(nonSelfDownvoteRatio, 0.4), 0.05, 0.4, -0.5, -2.5); + } + + if (metrics.votedSum < 5) { + return 0; + } + + if (metrics.oldUpvotedSubmissions < 3) { + if (metrics.semiOldUpvotedSubmissions > 3) { + return convertRange(Math.min(metrics.votedSum, 150), 5, 150, 0, 2) + + convertRange(Math.min((metrics.lockedSum ?? 0) + (metrics.mostUpvotedInLockedVideoSum ?? 0), 50), 0, 50, 0, 5); + } else { + return 0; + } + } + + return convertRange(Math.min(metrics.votedSum, 150), 5, 150, 0, 7) + + convertRange(Math.min((metrics.lockedSum ?? 0) + (metrics.mostUpvotedInLockedVideoSum ?? 0), 50), 0, 50, 0, 20); +} \ No newline at end of file diff --git a/test/cases/reputation.ts b/test/cases/reputation.ts index 8f09a41..dd41d29 100644 --- a/test/cases/reputation.ts +++ b/test/cases/reputation.ts @@ -2,7 +2,7 @@ import assert from "assert"; import { db } from "../../src/databases/databases"; import { UserID } from "../../src/types/user.model"; import { getHash } from "../../src/utils/getHash"; -import { getReputation } from "../../src/utils/reputation"; +import { getReputation, calculateFromMetrics } from "../../src/utils/reputation"; const userIDLowSubmissions = "reputation-lowsubmissions" as UserID; const userIDHighDownvotes = "reputation-highdownvotes" as UserID; @@ -12,11 +12,13 @@ const userIDLowSum = "reputation-lowsum" as UserID; const userIDHighRepBeforeManualVote = "reputation-oldhighrep" as UserID; const userIDHighRep = "reputation-highrep" as UserID; const userIDHighRepAndLocked = "reputation-highlockedrep" as UserID; +const userIDHaveMostUpvotedInLockedVideo = "reputation-mostupvotedaslocked" as UserID; describe("reputation", () => { before(async function() { this.timeout(5000); // this preparation takes longer then usual const videoID = "reputation-videoID"; + const videoID2 = "reputation-videoID-2"; const sponsorTimesInsertQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", "views", "category", "service", "videoDuration", "hidden", "shadowHidden", "hashedVideoID") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'; await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 2, 0, "reputation-0-uuid-0", getHash(userIDLowSubmissions), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); @@ -87,6 +89,28 @@ describe("reputation", () => { await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, -1, 0, "reputation-6-uuid-5", getHash(userIDHighRepAndLocked), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 0, 0, "reputation-6-uuid-6", getHash(userIDHighRepAndLocked), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 0, 0, "reputation-6-uuid-7", getHash(userIDHighRepAndLocked), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + + //Record has most upvoted + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 5, 0, "reputation-7-uuid-0", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 101, 0, "reputation-7-uuid-1", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "intro", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID2, 1, 11, 5, 0, "reputation-7-uuid-8", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID2, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID2, 1, 11, 0, 0, "reputation-7-uuid-9", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID2, 1)]); + // other segments + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 2, 0, "reputation-7-uuid-2", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 2, 0, "reputation-7-uuid-3", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 2, 0, "reputation-7-uuid-4", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, -1, 0, "reputation-7-uuid-5", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 0, 0, "reputation-7-uuid-6", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + await db.prepare("run", sponsorTimesInsertQuery, [videoID, 1, 11, 0, 0, "reputation-7-uuid-7", getHash(userIDHaveMostUpvotedInLockedVideo), 1606240000000, 50, "sponsor", "YouTube", 100, 0, 0, getHash(videoID, 1)]); + + // lock video + const insertVipUserQuery = 'INSERT INTO "vipUsers" ("userID") VALUES (?)'; + await db.prepare("run", insertVipUserQuery, [getHash("VIPUser-getLockCategories")]); + + const insertLockCategoryQuery = 'INSERT INTO "lockCategories" ("userID", "videoID", "category", "hashedVideoID") VALUES (?, ?, ?, ?)'; + await db.prepare("run", insertLockCategoryQuery, [getHash("VIPUser-getLockCategories"), videoID, "sponsor", getHash(videoID, 1)]); + await db.prepare("run", insertLockCategoryQuery, [getHash("VIPUser-getLockCategories"), videoID, "intro", getHash(videoID, 1)]); + await db.prepare("run", insertLockCategoryQuery, [getHash("VIPUser-getLockCategories"), videoID2, "sponsor", getHash(videoID2, 1)]); }); it("user in grace period", async () => { @@ -94,11 +118,34 @@ describe("reputation", () => { }); it("user with high downvote ratio", async () => { - assert.strictEqual(await getReputation(getHash(userIDHighDownvotes)), -2.125); + // -2.125 + const metrics = { + totalSubmissions: 8, + downvotedSubmissions: 5, + nonSelfDownvotedSubmissions: 0, + votedSum: -7, + lockedSum: 0, + semiOldUpvotedSubmissions: 1, + oldUpvotedSubmissions: 1, + mostUpvotedInLockedVideoSum: 0 + }; + + assert.strictEqual(await getReputation(getHash(userIDHighDownvotes)), calculateFromMetrics(metrics)); }); it("user with high non self downvote ratio", async () => { - assert.strictEqual(await getReputation(getHash(userIDHighNonSelfDownvotes)), -1.6428571428571428); + // -1.6428571428571428 + const metrics = { + totalSubmissions: 8, + downvotedSubmissions: 2, + nonSelfDownvotedSubmissions: 2, + votedSum: -1, + lockedSum: 0, + semiOldUpvotedSubmissions: 1, + oldUpvotedSubmissions: 1, + mostUpvotedInLockedVideoSum: 0 + }; + assert.strictEqual(await getReputation(getHash(userIDHighNonSelfDownvotes)), calculateFromMetrics(metrics)); }); it("user with mostly new submissions", async () => { @@ -114,11 +161,49 @@ describe("reputation", () => { }); it("user with high reputation", async () => { - assert.strictEqual(await getReputation(getHash(userIDHighRep)), 0.19310344827586207); + // 0.19310344827586207 + const metrics = { + totalSubmissions: 8, + downvotedSubmissions: 1, + nonSelfDownvotedSubmissions: 0, + votedSum: 9, + lockedSum: 0, + semiOldUpvotedSubmissions: 5, + oldUpvotedSubmissions: 5, + mostUpvotedInLockedVideoSum: 0 + }; + + assert.strictEqual(await getReputation(getHash(userIDHighRep)), calculateFromMetrics(metrics)); }); it("user with high reputation and locked segments", async () => { - assert.strictEqual(await getReputation(getHash(userIDHighRepAndLocked)), 1.793103448275862); + // 1.793103448275862 + const metrics = { + totalSubmissions: 8, + downvotedSubmissions: 1, + nonSelfDownvotedSubmissions: 0, + votedSum: 9, + lockedSum: 4, + semiOldUpvotedSubmissions: 5, + oldUpvotedSubmissions: 5, + mostUpvotedInLockedVideoSum: 0 + }; + assert.strictEqual(await getReputation(getHash(userIDHighRepAndLocked)), calculateFromMetrics(metrics)); + }); + + it("user with most upvoted segments in locked video", async () => { + // 6.158620689655172 + const metrics = { + totalSubmissions: 10, + downvotedSubmissions: 1, + nonSelfDownvotedSubmissions: 0, + votedSum: 116, + lockedSum: 0, + semiOldUpvotedSubmissions: 6, + oldUpvotedSubmissions: 6, + mostUpvotedInLockedVideoSum: 2 + }; + assert.strictEqual(await getReputation(getHash(userIDHaveMostUpvotedInLockedVideo)), calculateFromMetrics(metrics)); }); }); From 22debb43747b19a34e9c4d9ff291388843ed357a Mon Sep 17 00:00:00 2001 From: Haidang666 Date: Thu, 22 Jul 2021 14:02:33 +0700 Subject: [PATCH 2/4] Split code in postSkipSegment --- src/routes/postSkipSegments.ts | 386 ++++++++++++++++++++------------- src/utils/isUserVIP.ts | 2 +- 2 files changed, 242 insertions(+), 146 deletions(-) diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index 4f18cd0..1d9304d 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -17,6 +17,19 @@ import { QueryCacher } from "../utils/queryCacher"; import { getReputation } from "../utils/reputation"; import { APIVideoData, APIVideoInfo } from "../types/youtubeApi.model"; import { UserID } from "../types/user.model"; +import { isUserVIP } from "../utils/isUserVIP"; + +type CheckResult = { + pass: boolean, + errorMessage: string, + errorCode: number +}; + +const CHECK_PASS: CheckResult = { + pass: true, + errorMessage: "", + errorCode: 0 +}; async function sendWebhookNotification(userID: string, videoID: string, UUID: string, submissionCount: number, youtubeData: APIVideoData, {submissionStart, submissionEnd}: { submissionStart: number; submissionEnd: number; }, segmentInfo: any) { const row = await db.prepare("get", `SELECT "userName" FROM "userNames" WHERE "userID" = ?`, [userID]); @@ -267,7 +280,7 @@ async function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promi } } -async function checkUserActiveWarning(userID: string): Promise<{ pass: boolean; errorMessage: string; }> { +async function checkUserActiveWarning(userID: string): Promise { const MILLISECONDS_IN_HOUR = 3600000; const now = Date.now(); const warnings = await db.prepare("all", @@ -291,56 +304,15 @@ async function checkUserActiveWarning(userID: string): Promise<{ pass: boolean; return { pass: false, - errorMessage: defaultMessage + (warnings[0]?.reason?.length > 0 ? ` Warning reason: ${warnings[0].reason}` : "") + errorMessage: defaultMessage + (warnings[0]?.reason?.length > 0 ? ` Warning reason: ${warnings[0].reason}` : ""), + errorCode: 403 }; } - return {pass: true, errorMessage: ""}; + return CHECK_PASS; } -function proxySubmission(req: Request) { - fetch(`${config.proxySubmission}/api/skipSegments?userID=${req.query.userID}&videoID=${req.query.videoID}`, { - method: "POST", - body: req.body, - }) - .then(async res => { - Logger.debug(`Proxy Submission: ${res.status} (${(await res.text())})`); - }) - .catch(() => { - Logger.error("Proxy Submission: Failed to make call"); - }); -} - -export async function postSkipSegments(req: Request, res: Response): Promise { - if (config.proxySubmission) { - proxySubmission(req); - } - - const videoID = req.query.videoID || req.body.videoID; - let userID = req.query.userID || req.body.userID; - let service: Service = req.query.service ?? req.body.service ?? Service.YouTube; - if (!Object.values(Service).some((val) => val === service)) { - service = Service.YouTube; - } - const videoDurationParam: VideoDuration = (parseFloat(req.query.videoDuration || req.body.videoDuration) || 0) as VideoDuration; - let videoDuration = videoDurationParam; - - let segments = req.body.segments as IncomingSegment[]; - if (segments === undefined) { - // Use query instead - segments = [{ - segment: [req.query.startTime as string, req.query.endTime as string], - category: req.query.category as Category, - actionType: (req.query.actionType as ActionType) ?? ActionType.Skip - }]; - } - // Add default action type - segments.forEach((segment) => { - if (!Object.values(ActionType).some((val) => val === segment.actionType)){ - segment.actionType = ActionType.Skip; - } - }); - +function checkInvalidFields(videoID: any, userID: any, segments: Array): CheckResult { const invalidFields = []; const errors = []; if (typeof videoID !== "string") { @@ -358,25 +330,100 @@ export async function postSkipSegments(req: Request, res: Response): Promise p + (i !== 0 ? ", " : "") + c, ""); const formattedErrors = errors.reduce((p, c, i) => p + (i !== 0 ? ". " : " ") + c, ""); - return res.status(400).send(`No valid ${formattedFields} field(s) provided.${formattedErrors}`); + return { + pass: false, + errorMessage: `No valid ${formattedFields} field(s) provided.${formattedErrors}`, + errorCode: 400 + }; } - //hash the userID - userID = getHash(userID); + return CHECK_PASS; +} - const warningResult: {pass: boolean, errorMessage: string} = await checkUserActiveWarning(userID); - if (!warningResult.pass) { - Logger.warn(`Caught a submission for 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(403).send(warningResult.errorMessage); +async function checkEachSegmentValid(userID: string, videoID: VideoID + , segments: Array, service: string, isVIP: boolean, lockedCategoryList: Array): Promise { + + for (let i = 0; i < segments.length; i++) { + if (segments[i] === undefined || segments[i].segment === undefined || segments[i].category === undefined) { + //invalid request + return { pass: false, errorMessage: "One of your segments are invalid", errorCode: 400}; + } + + if (!config.categoryList.includes(segments[i].category)) { + return { pass: false, errorMessage: "Category doesn't exist.", errorCode: 400}; + } + + // Reject segment if it's in the locked categories list + if (!isVIP && lockedCategoryList.indexOf(segments[i].category) !== -1) { + // 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}`); + return { pass: false, errorCode: 403, + errorMessage: `New submissions are not allowed for the following category: \ + '${segments[i].category}'. A moderator has decided that no new segments are needed and that all current segments of this category are timed perfectly.\n\n\ + ${(segments[i].category === "sponsor" ? "Maybe the segment you are submitting is a different category that you have not enabled and is not a sponsor. "+ + "Categories that aren't sponsor, such as self-promotion can be enabled in the options.\n\n" : "")}\ + If you believe this is incorrect, please contact someone on discord.gg/SponsorBlock or matrix.to/#/+sponsorblock:ajay.app` + }; + } + + + const startTime = parseFloat(segments[i].segment[0]); + const endTime = parseFloat(segments[i].segment[1]); + + if (isNaN(startTime) || isNaN(endTime) + || startTime === Infinity || endTime === Infinity || startTime < 0 || startTime > endTime + || (getCategoryActionType(segments[i].category) === CategoryActionType.Skippable && startTime === endTime) + || (getCategoryActionType(segments[i].category) === CategoryActionType.POI && startTime !== endTime)) { + //invalid request + return { pass: false, errorMessage: "One of your segments times are invalid (too short, startTime before endTime, etc.)", errorCode: 400}; + } + + if (!isVIP && segments[i].category === "sponsor" && Math.abs(startTime - endTime) < 1) { + // Too short + return { pass: false, errorMessage: "Sponsors must be longer than 1 second long", errorCode: 400}; + } + + //check if this info has already been submitted before + const duplicateCheck2Row = await db.prepare("get", `SELECT COUNT(*) as count FROM "sponsorTimes" WHERE "startTime" = ? + and "endTime" = ? and "category" = ? and "videoID" = ? and "service" = ?`, [startTime, endTime, segments[i].category, videoID, service]); + if (duplicateCheck2Row.count > 0) { + return { pass: false, errorMessage: "Sponsors has already been submitted before.", errorCode: 409}; + } } + return CHECK_PASS; +} + +async function checkByAutoModerator(videoID: any, userID: any, segments: Array, isVIP: boolean, service:string, apiVideoInfo: APIVideoInfo, decreaseVotes: number): Promise { + // Auto moderator check + if (!isVIP && service == Service.YouTube) { + const autoModerateResult = await autoModerateSubmission(apiVideoInfo, {userID, videoID, segments});//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 + return { + pass: false, + errorCode: 403, + errorMessage: `Request rejected by auto moderator: ${autoModerateResult} If this is an issue, send a message on Discord.`, + decreaseVotes + }; + } + } + + return { + ...CHECK_PASS, + decreaseVotes + }; +} + +async function updateDataIfVideoDurationChange(videoID: VideoID, service: string, videoDuration: VideoDuration, videoDurationParam: VideoDuration) { let lockedCategoryList = (await db.prepare("all", 'SELECT category from "lockCategories" where "videoID" = ?', [videoID])).map((list: any) => list.category ); - //check if this user is on the vip list - const isVIP = (await db.prepare("get", `SELECT count(*) as "userCount" FROM "vipUsers" WHERE "userID" = ?`, [userID])).userCount > 0; - - const decreaseVotes = 0; - const previousSubmissions = await db.prepare("all", `SELECT "videoDuration", "UUID" FROM "sponsorTimes" @@ -388,7 +435,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise