mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2024-11-10 09:07:47 +01:00
commit
38360f379f
15 changed files with 303 additions and 192 deletions
|
@ -22,7 +22,7 @@
|
||||||
"mode": "development",
|
"mode": "development",
|
||||||
"readOnly": false,
|
"readOnly": false,
|
||||||
"webhooks": [],
|
"webhooks": [],
|
||||||
"categoryList": ["sponsor", "intro", "outro", "interaction", "selfpromo", "music_offtopic"], // List of supported categories any other category will be rejected
|
"categoryList": ["sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic", "highlight"], // List of supported categories any other category will be rejected
|
||||||
"getTopUsersCacheTimeMinutes": 5, // cacheTime for getTopUsers result in minutes
|
"getTopUsersCacheTimeMinutes": 5, // cacheTime for getTopUsers result in minutes
|
||||||
"maxNumberOfActiveWarnings": 3, // Users with this number of warnings will be blocked until warnings expire
|
"maxNumberOfActiveWarnings": 3, // Users with this number of warnings will be blocked until warnings expire
|
||||||
"hoursAfterWarningExpire": 24,
|
"hoursAfterWarningExpire": 24,
|
||||||
|
|
|
@ -16,7 +16,7 @@ addDefaults(config, {
|
||||||
privateDBSchema: "./databases/_private.db.sql",
|
privateDBSchema: "./databases/_private.db.sql",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
webhooks: [],
|
webhooks: [],
|
||||||
categoryList: ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic"],
|
categoryList: ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic", "highlight"],
|
||||||
maxNumberOfActiveWarnings: 3,
|
maxNumberOfActiveWarnings: 3,
|
||||||
hoursAfterWarningExpires: 24,
|
hoursAfterWarningExpires: 24,
|
||||||
adminUserID: "",
|
adminUserID: "",
|
||||||
|
|
|
@ -4,7 +4,8 @@ import { config } from '../config';
|
||||||
import { db, privateDB } from '../databases/databases';
|
import { db, privateDB } from '../databases/databases';
|
||||||
import { skipSegmentsHashKey, skipSegmentsKey } from '../middleware/redisKeys';
|
import { skipSegmentsHashKey, skipSegmentsKey } from '../middleware/redisKeys';
|
||||||
import { SBRecord } from '../types/lib.model';
|
import { SBRecord } from '../types/lib.model';
|
||||||
import { Category, DBSegment, HashedIP, IPAddress, OverlappingSegmentGroup, Segment, SegmentCache, Service, VideoData, VideoID, VideoIDHash, Visibility, VotableObject } from "../types/segments.model";
|
import { Category, CategoryActionType, DBSegment, HashedIP, IPAddress, OverlappingSegmentGroup, Segment, SegmentCache, Service, VideoData, VideoID, VideoIDHash, Visibility, VotableObject } from "../types/segments.model";
|
||||||
|
import { getCategoryActionType } from '../utils/categoryInfo';
|
||||||
import { getHash } from '../utils/getHash';
|
import { getHash } from '../utils/getHash';
|
||||||
import { getIP } from '../utils/getIP';
|
import { getIP } from '../utils/getIP';
|
||||||
import { Logger } from '../utils/logger';
|
import { Logger } from '../utils/logger';
|
||||||
|
@ -40,7 +41,8 @@ async function prepareCategorySegments(req: Request, videoID: VideoID, category:
|
||||||
|
|
||||||
const filteredSegments = segments.filter((_, index) => shouldFilter[index]);
|
const filteredSegments = segments.filter((_, index) => shouldFilter[index]);
|
||||||
|
|
||||||
return chooseSegments(filteredSegments).map((chosenSegment) => ({
|
const maxSegments = getCategoryActionType(category) === CategoryActionType.Skippable ? 32 : 1
|
||||||
|
return chooseSegments(filteredSegments, maxSegments).map((chosenSegment) => ({
|
||||||
category,
|
category,
|
||||||
segment: [chosenSegment.startTime, chosenSegment.endTime],
|
segment: [chosenSegment.startTime, chosenSegment.endTime],
|
||||||
UUID: chosenSegment.UUID,
|
UUID: chosenSegment.UUID,
|
||||||
|
@ -206,7 +208,7 @@ function getWeightedRandomChoice<T extends VotableObject>(choices: T[], amountOf
|
||||||
//Only one similar time will be returned, randomly generated based on the sqrt of votes.
|
//Only one similar time will be returned, randomly generated based on the sqrt of votes.
|
||||||
//This allows new less voted items to still sometimes appear to give them a chance at getting votes.
|
//This allows new less voted items to still sometimes appear to give them a chance at getting votes.
|
||||||
//Segments with less than -1 votes are already ignored before this function is called
|
//Segments with less than -1 votes are already ignored before this function is called
|
||||||
function chooseSegments(segments: DBSegment[]): DBSegment[] {
|
function chooseSegments(segments: DBSegment[], max: number): DBSegment[] {
|
||||||
//Create groups of segments that are similar to eachother
|
//Create groups of segments that are similar to eachother
|
||||||
//Segments must be sorted by their startTime so that we can build groups chronologically:
|
//Segments must be sorted by their startTime so that we can build groups chronologically:
|
||||||
//1. As long as the segments' startTime fall inside the currentGroup, we keep adding them to that group
|
//1. As long as the segments' startTime fall inside the currentGroup, we keep adding them to that group
|
||||||
|
@ -240,8 +242,8 @@ function chooseSegments(segments: DBSegment[]): DBSegment[] {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
//if there are too many groups, find the best 8
|
//if there are too many groups, find the best ones
|
||||||
return getWeightedRandomChoice(overlappingSegmentsGroups, 32).map(
|
return getWeightedRandomChoice(overlappingSegmentsGroups, max).map(
|
||||||
//randomly choose 1 good segment per group and return them
|
//randomly choose 1 good segment per group and return them
|
||||||
group => getWeightedRandomChoice(group.segments, 1)[0],
|
group => getWeightedRandomChoice(group.segments, 1)[0],
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {db, privateDB} from '../databases/databases';
|
||||||
import {YouTubeAPI} from '../utils/youtubeApi';
|
import {YouTubeAPI} from '../utils/youtubeApi';
|
||||||
import {getSubmissionUUID} from '../utils/getSubmissionUUID';
|
import {getSubmissionUUID} from '../utils/getSubmissionUUID';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import isoDurations from 'iso8601-duration';
|
import isoDurations, { end } from 'iso8601-duration';
|
||||||
import {getHash} from '../utils/getHash';
|
import {getHash} from '../utils/getHash';
|
||||||
import {getIP} from '../utils/getIP';
|
import {getIP} from '../utils/getIP';
|
||||||
import {getFormattedTime} from '../utils/getFormattedTime';
|
import {getFormattedTime} from '../utils/getFormattedTime';
|
||||||
|
@ -13,12 +13,13 @@ import {dispatchEvent} from '../utils/webhookUtils';
|
||||||
import {Request, Response} from 'express';
|
import {Request, Response} from 'express';
|
||||||
import { skipSegmentsHashKey, skipSegmentsKey } from '../middleware/redisKeys';
|
import { skipSegmentsHashKey, skipSegmentsKey } from '../middleware/redisKeys';
|
||||||
import redis from '../utils/redis';
|
import redis from '../utils/redis';
|
||||||
import { Category, IncomingSegment, Segment, SegmentUUID, Service, VideoDuration, VideoID } from '../types/segments.model';
|
import { Category, CategoryActionType, IncomingSegment, Segment, SegmentUUID, Service, VideoDuration, VideoID } from '../types/segments.model';
|
||||||
import { deleteLockCategories } from './deleteLockCategories';
|
import { deleteLockCategories } from './deleteLockCategories';
|
||||||
|
import { getCategoryActionType } from '../utils/categoryInfo';
|
||||||
|
|
||||||
interface APIVideoInfo {
|
interface APIVideoInfo {
|
||||||
err: string | boolean,
|
err: string | boolean,
|
||||||
data: any
|
data?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWebhookNotification(userID: string, videoID: string, UUID: string, submissionCount: number, youtubeData: any, {submissionStart, submissionEnd}: { submissionStart: number; submissionEnd: number; }, segmentInfo: any) {
|
async function sendWebhookNotification(userID: string, videoID: string, UUID: string, submissionCount: number, youtubeData: any, {submissionStart, submissionEnd}: { submissionStart: number; submissionEnd: number; }, segmentInfo: any) {
|
||||||
|
@ -275,11 +276,9 @@ function getYouTubeVideoDuration(apiVideoInfo: APIVideoInfo): VideoDuration {
|
||||||
return duration ? isoDurations.toSeconds(isoDurations.parse(duration)) as VideoDuration : null;
|
return duration ? isoDurations.toSeconds(isoDurations.parse(duration)) as VideoDuration : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getYouTubeVideoInfo(videoID: VideoID): Promise<APIVideoInfo> {
|
async function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<APIVideoInfo> {
|
||||||
if (config.youtubeAPIKey !== null) {
|
if (config.youtubeAPIKey !== null) {
|
||||||
return new Promise((resolve) => {
|
return YouTubeAPI.listVideos(videoID, ignoreCache);
|
||||||
YouTubeAPI.listVideos(videoID, (err: any, data: any) => resolve({err, data}));
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -367,9 +366,16 @@ export async function postSkipSegments(req: Request, res: Response) {
|
||||||
|
|
||||||
const decreaseVotes = 0;
|
const decreaseVotes = 0;
|
||||||
|
|
||||||
|
const previousSubmissions = await db.prepare('all', `SELECT "videoDuration", "UUID" FROM "sponsorTimes" WHERE "videoID" = ? AND "service" = ? AND "hidden" = 0
|
||||||
|
AND "shadowHidden" = 0 AND "votes" >= 0 AND "videoDuration" != 0`, [videoID, service]) as
|
||||||
|
{videoDuration: VideoDuration, UUID: SegmentUUID}[];
|
||||||
|
// If the video's duration is changed, then the video should be unlocked and old submissions should be hidden
|
||||||
|
const videoDurationChanged = (videoDuration: number) => previousSubmissions.length > 0 && !previousSubmissions.some((e) => Math.abs(videoDuration - e.videoDuration) < 2);
|
||||||
|
|
||||||
let apiVideoInfo: APIVideoInfo = null;
|
let apiVideoInfo: APIVideoInfo = null;
|
||||||
if (service == Service.YouTube) {
|
if (service == Service.YouTube) {
|
||||||
apiVideoInfo = await getYouTubeVideoInfo(videoID);
|
// Don't use cache if we don't know the video duraton, or the client claims that it has changed
|
||||||
|
apiVideoInfo = await getYouTubeVideoInfo(videoID, !videoDuration || videoDurationChanged(videoDuration));
|
||||||
}
|
}
|
||||||
const apiVideoDuration = getYouTubeVideoDuration(apiVideoInfo);
|
const apiVideoDuration = getYouTubeVideoDuration(apiVideoInfo);
|
||||||
if (!videoDuration || (apiVideoDuration && Math.abs(videoDuration - apiVideoDuration) > 2)) {
|
if (!videoDuration || (apiVideoDuration && Math.abs(videoDuration - apiVideoDuration) > 2)) {
|
||||||
|
@ -377,12 +383,7 @@ export async function postSkipSegments(req: Request, res: Response) {
|
||||||
videoDuration = apiVideoDuration || 0 as VideoDuration;
|
videoDuration = apiVideoDuration || 0 as VideoDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSubmissions = await db.prepare('all', `SELECT "videoDuration", "UUID" FROM "sponsorTimes" WHERE "videoID" = ? AND "service" = ? AND "hidden" = 0
|
if (videoDurationChanged(videoDuration)) {
|
||||||
AND "shadowHidden" = 0 AND "votes" >= 0 AND "videoDuration" != 0`, [videoID, service]) as
|
|
||||||
{videoDuration: VideoDuration, UUID: SegmentUUID}[];
|
|
||||||
// If the video's duration is changed, then the video should be unlocked and old submissions should be hidden
|
|
||||||
const videoDurationChanged = previousSubmissions.length > 0 && !previousSubmissions.some((e) => Math.abs(videoDuration - e.videoDuration) < 2);
|
|
||||||
if (videoDurationChanged) {
|
|
||||||
// Hide all previous submissions
|
// Hide all previous submissions
|
||||||
for (const submission of previousSubmissions) {
|
for (const submission of previousSubmissions) {
|
||||||
await db.prepare('run', `UPDATE "sponsorTimes" SET "hidden" = 1 WHERE "UUID" = ?`, [submission.UUID]);
|
await db.prepare('run', `UPDATE "sponsorTimes" SET "hidden" = 1 WHERE "UUID" = ?`, [submission.UUID]);
|
||||||
|
@ -411,10 +412,10 @@ export async function postSkipSegments(req: Request, res: Response) {
|
||||||
// TODO: Do something about the fradulent submission
|
// TODO: Do something about the fradulent submission
|
||||||
Logger.warn("Caught a no-segment submission. userID: '" + userID + "', videoID: '" + videoID + "', category: '" + segments[i].category + "'");
|
Logger.warn("Caught a no-segment submission. userID: '" + userID + "', videoID: '" + videoID + "', category: '" + segments[i].category + "'");
|
||||||
res.status(403).send(
|
res.status(403).send(
|
||||||
"Request rejected by auto moderator: New submissions are not allowed for the following category: '"
|
"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 + "'. 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. " +
|
+ (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 " : "")
|
"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",
|
+ "If you believe this is incorrect, please contact someone on discord.gg/SponsorBlock or matrix.to/#/+sponsorblock:ajay.app",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
@ -425,7 +426,9 @@ export async function postSkipSegments(req: Request, res: Response) {
|
||||||
let endTime = parseFloat(segments[i].segment[1]);
|
let endTime = parseFloat(segments[i].segment[1]);
|
||||||
|
|
||||||
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.POI && startTime !== endTime)) {
|
||||||
//invalid request
|
//invalid request
|
||||||
res.status(400).send("One of your segments times are invalid (too short, startTime before endTime, etc.)");
|
res.status(400).send("One of your segments times are invalid (too short, startTime before endTime, etc.)");
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -13,7 +13,8 @@ import {config} from '../config';
|
||||||
import { UserID } from '../types/user.model';
|
import { UserID } from '../types/user.model';
|
||||||
import redis from '../utils/redis';
|
import redis from '../utils/redis';
|
||||||
import { skipSegmentsHashKey, skipSegmentsKey } from '../middleware/redisKeys';
|
import { skipSegmentsHashKey, skipSegmentsKey } from '../middleware/redisKeys';
|
||||||
import { Category, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash } from '../types/segments.model';
|
import { Category, CategoryActionType, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash } from '../types/segments.model';
|
||||||
|
import { getCategoryActionType } from '../utils/categoryInfo';
|
||||||
|
|
||||||
const voteTypes = {
|
const voteTypes = {
|
||||||
normal: 0,
|
normal: 0,
|
||||||
|
@ -59,90 +60,89 @@ async function sendWebhooks(voteData: VoteData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.youtubeAPIKey !== null) {
|
if (config.youtubeAPIKey !== null) {
|
||||||
YouTubeAPI.listVideos(submissionInfoRow.videoID, (err, data) => {
|
const { err, data } = await YouTubeAPI.listVideos(submissionInfoRow.videoID);
|
||||||
if (err || data.items.length === 0) {
|
|
||||||
err && Logger.error(err.toString());
|
if (err || data.items.length === 0) {
|
||||||
return;
|
if (err) Logger.error(err.toString());
|
||||||
}
|
return;
|
||||||
const isUpvote = voteData.incrementAmount > 0;
|
}
|
||||||
// Send custom webhooks
|
const isUpvote = voteData.incrementAmount > 0;
|
||||||
dispatchEvent(isUpvote ? "vote.up" : "vote.down", {
|
// Send custom webhooks
|
||||||
|
dispatchEvent(isUpvote ? "vote.up" : "vote.down", {
|
||||||
|
"user": {
|
||||||
|
"status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission),
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"id": submissionInfoRow.videoID,
|
||||||
|
"title": data.items[0].snippet.title,
|
||||||
|
"url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID,
|
||||||
|
"thumbnail": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
|
||||||
|
},
|
||||||
|
"submission": {
|
||||||
|
"UUID": voteData.UUID,
|
||||||
|
"views": voteData.row.views,
|
||||||
|
"category": voteData.category,
|
||||||
|
"startTime": submissionInfoRow.startTime,
|
||||||
|
"endTime": submissionInfoRow.endTime,
|
||||||
"user": {
|
"user": {
|
||||||
"status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission),
|
"UUID": submissionInfoRow.userID,
|
||||||
},
|
"username": submissionInfoRow.userName,
|
||||||
"video": {
|
"submissions": {
|
||||||
"id": submissionInfoRow.videoID,
|
"total": submissionInfoRow.count,
|
||||||
"title": data.items[0].snippet.title,
|
"ignored": submissionInfoRow.disregarded,
|
||||||
"url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID,
|
|
||||||
"thumbnail": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
|
|
||||||
},
|
|
||||||
"submission": {
|
|
||||||
"UUID": voteData.UUID,
|
|
||||||
"views": voteData.row.views,
|
|
||||||
"category": voteData.category,
|
|
||||||
"startTime": submissionInfoRow.startTime,
|
|
||||||
"endTime": submissionInfoRow.endTime,
|
|
||||||
"user": {
|
|
||||||
"UUID": submissionInfoRow.userID,
|
|
||||||
"username": submissionInfoRow.userName,
|
|
||||||
"submissions": {
|
|
||||||
"total": submissionInfoRow.count,
|
|
||||||
"ignored": submissionInfoRow.disregarded,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"votes": {
|
},
|
||||||
"before": voteData.row.votes,
|
"votes": {
|
||||||
"after": (voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount),
|
"before": voteData.row.votes,
|
||||||
},
|
"after": (voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount),
|
||||||
});
|
},
|
||||||
|
|
||||||
// Send discord message
|
|
||||||
if (webhookURL !== null && !isUpvote) {
|
|
||||||
fetch(webhookURL, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
"embeds": [{
|
|
||||||
"title": data.items[0].snippet.title,
|
|
||||||
"url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID
|
|
||||||
+ "&t=" + (submissionInfoRow.startTime.toFixed(0) - 2),
|
|
||||||
"description": "**" + voteData.row.votes + " Votes Prior | " +
|
|
||||||
(voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount) + " Votes Now | " + voteData.row.views
|
|
||||||
+ " Views**\n\n**Submission ID:** " + voteData.UUID
|
|
||||||
+ "\n**Category:** " + submissionInfoRow.category
|
|
||||||
+ "\n\n**Submitted by:** " + submissionInfoRow.userName + "\n " + submissionInfoRow.userID
|
|
||||||
+ "\n\n**Total User Submissions:** " + submissionInfoRow.count
|
|
||||||
+ "\n**Ignored User Submissions:** " + submissionInfoRow.disregarded
|
|
||||||
+ "\n\n**Timestamp:** " +
|
|
||||||
getFormattedTime(submissionInfoRow.startTime) + " to " + getFormattedTime(submissionInfoRow.endTime),
|
|
||||||
"color": 10813440,
|
|
||||||
"author": {
|
|
||||||
"name": voteData.finalResponse?.finalMessage ?? getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission),
|
|
||||||
},
|
|
||||||
"thumbnail": {
|
|
||||||
"url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(async res => {
|
|
||||||
if (res.status >= 400) {
|
|
||||||
Logger.error("Error sending reported submission Discord hook");
|
|
||||||
Logger.error(JSON.stringify((await res.text())));
|
|
||||||
Logger.error("\n");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
Logger.error("Failed to send reported submission Discord hook.");
|
|
||||||
Logger.error(JSON.stringify(err));
|
|
||||||
Logger.error("\n");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send discord message
|
||||||
|
if (webhookURL !== null && !isUpvote) {
|
||||||
|
fetch(webhookURL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
"embeds": [{
|
||||||
|
"title": data.items[0].snippet.title,
|
||||||
|
"url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID
|
||||||
|
+ "&t=" + (submissionInfoRow.startTime.toFixed(0) - 2),
|
||||||
|
"description": "**" + voteData.row.votes + " Votes Prior | " +
|
||||||
|
(voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount) + " Votes Now | " + voteData.row.views
|
||||||
|
+ " Views**\n\n**Submission ID:** " + voteData.UUID
|
||||||
|
+ "\n**Category:** " + submissionInfoRow.category
|
||||||
|
+ "\n\n**Submitted by:** " + submissionInfoRow.userName + "\n " + submissionInfoRow.userID
|
||||||
|
+ "\n\n**Total User Submissions:** " + submissionInfoRow.count
|
||||||
|
+ "\n**Ignored User Submissions:** " + submissionInfoRow.disregarded
|
||||||
|
+ "\n\n**Timestamp:** " +
|
||||||
|
getFormattedTime(submissionInfoRow.startTime) + " to " + getFormattedTime(submissionInfoRow.endTime),
|
||||||
|
"color": 10813440,
|
||||||
|
"author": {
|
||||||
|
"name": voteData.finalResponse?.finalMessage ?? getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission),
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status >= 400) {
|
||||||
|
Logger.error("Error sending reported submission Discord hook");
|
||||||
|
Logger.error(JSON.stringify((await res.text())));
|
||||||
|
Logger.error("\n");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
Logger.error("Failed to send reported submission Discord hook.");
|
||||||
|
Logger.error(JSON.stringify(err));
|
||||||
|
Logger.error("\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -170,6 +170,10 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i
|
||||||
res.status(400).send("Category doesn't exist.");
|
res.status(400).send("Category doesn't exist.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (getCategoryActionType(category) !== CategoryActionType.Skippable) {
|
||||||
|
res.status(400).send("Cannot vote for this category");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { SBRecord } from "./lib.model";
|
||||||
export type SegmentUUID = string & { __segmentUUIDBrand: unknown };
|
export type SegmentUUID = string & { __segmentUUIDBrand: unknown };
|
||||||
export type VideoID = string & { __videoIDBrand: unknown };
|
export type VideoID = string & { __videoIDBrand: unknown };
|
||||||
export type VideoDuration = number & { __videoDurationBrand: unknown };
|
export type VideoDuration = number & { __videoDurationBrand: unknown };
|
||||||
export type Category = string & { __categoryBrand: unknown };
|
export type Category = ("sponsor" | "selfpromo" | "interaction" | "intro" | "outro" | "preview" | "music_offtopic" | "highlight") & { __categoryBrand: unknown };
|
||||||
export type VideoIDHash = VideoID & HashedValue;
|
export type VideoIDHash = VideoID & HashedValue;
|
||||||
export type IPAddress = string & { __ipAddressBrand: unknown };
|
export type IPAddress = string & { __ipAddressBrand: unknown };
|
||||||
export type HashedIP = IPAddress & HashedValue;
|
export type HashedIP = IPAddress & HashedValue;
|
||||||
|
@ -72,4 +72,9 @@ export interface VideoData {
|
||||||
export interface SegmentCache {
|
export interface SegmentCache {
|
||||||
shadowHiddenSegmentIPs: SBRecord<VideoID, {hashedIP: HashedIP}[]>,
|
shadowHiddenSegmentIPs: SBRecord<VideoID, {hashedIP: HashedIP}[]>,
|
||||||
userHashedIP?: HashedIP
|
userHashedIP?: HashedIP
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CategoryActionType {
|
||||||
|
Skippable,
|
||||||
|
POI
|
||||||
}
|
}
|
10
src/utils/categoryInfo.ts
Normal file
10
src/utils/categoryInfo.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { Category, CategoryActionType } from "../types/segments.model";
|
||||||
|
|
||||||
|
export function getCategoryActionType(category: Category): CategoryActionType {
|
||||||
|
switch (category) {
|
||||||
|
case "highlight":
|
||||||
|
return CategoryActionType.POI;
|
||||||
|
default:
|
||||||
|
return CategoryActionType.Skippable;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import {config} from '../config';
|
import {config} from '../config';
|
||||||
import {Logger} from '../utils/logger';
|
import {Logger} from '../utils/logger';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
import AbortController from "abort-controller";
|
||||||
|
|
||||||
function getVoteAuthorRaw(submissionCount: number, isVIP: boolean, isOwnSubmission: boolean): string {
|
function getVoteAuthorRaw(submissionCount: number, isVIP: boolean, isOwnSubmission: boolean): string {
|
||||||
if (isOwnSubmission) {
|
if (isOwnSubmission) {
|
||||||
|
@ -30,7 +31,8 @@ function dispatchEvent(scope: string, data: any): void {
|
||||||
let webhooks = config.webhooks;
|
let webhooks = config.webhooks;
|
||||||
if (webhooks === undefined || webhooks.length === 0) return;
|
if (webhooks === undefined || webhooks.length === 0) return;
|
||||||
Logger.debug("Dispatching webhooks");
|
Logger.debug("Dispatching webhooks");
|
||||||
webhooks.forEach(webhook => {
|
|
||||||
|
for (const webhook of webhooks) {
|
||||||
let webhookURL = webhook.url;
|
let webhookURL = webhook.url;
|
||||||
let authKey = webhook.key;
|
let authKey = webhook.key;
|
||||||
let scopes = webhook.scopes || [];
|
let scopes = webhook.scopes || [];
|
||||||
|
@ -43,13 +45,13 @@ function dispatchEvent(scope: string, data: any): void {
|
||||||
"Authorization": authKey,
|
"Authorization": authKey,
|
||||||
"Event-Type": scope, // Maybe change this in the future?
|
"Event-Type": scope, // Maybe change this in the future?
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
Logger.warn('Couldn\'t send webhook to ' + webhook.url);
|
Logger.warn('Couldn\'t send webhook to ' + webhook.url);
|
||||||
Logger.warn(err);
|
Logger.warn(err);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -10,43 +10,45 @@ _youTubeAPI.authenticate({
|
||||||
});
|
});
|
||||||
|
|
||||||
export class YouTubeAPI {
|
export class YouTubeAPI {
|
||||||
static listVideos(videoID: string, callback: (err: string | boolean, data: any) => void) {
|
static async listVideos(videoID: string, ignoreCache = false): Promise<{err: string | boolean, data?: any}> {
|
||||||
const part = 'contentDetails,snippet';
|
const part = 'contentDetails,snippet';
|
||||||
if (!videoID || videoID.length !== 11 || videoID.includes(".")) {
|
if (!videoID || videoID.length !== 11 || videoID.includes(".")) {
|
||||||
callback("Invalid video ID", undefined);
|
return { err: "Invalid video ID" };
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const redisKey = "youtube.video." + videoID;
|
const redisKey = "youtube.video." + videoID;
|
||||||
redis.get(redisKey, (getErr, result) => {
|
if (!ignoreCache) {
|
||||||
if (getErr || !result) {
|
const {err, reply} = await redis.getAsync(redisKey);
|
||||||
|
|
||||||
|
if (!err && reply) {
|
||||||
Logger.debug("redis: no cache for video information: " + videoID);
|
Logger.debug("redis: no cache for video information: " + videoID);
|
||||||
_youTubeAPI.videos.list({
|
|
||||||
part,
|
return { err: err?.message, data: JSON.parse(reply) }
|
||||||
id: videoID,
|
|
||||||
}, (ytErr: boolean | string, { data }: any) => {
|
|
||||||
if (!ytErr) {
|
|
||||||
// Only set cache if data returned
|
|
||||||
if (data.items.length > 0) {
|
|
||||||
redis.set(redisKey, JSON.stringify(data), (setErr) => {
|
|
||||||
if (setErr) {
|
|
||||||
Logger.warn(setErr.message);
|
|
||||||
} else {
|
|
||||||
Logger.debug("redis: video information cache set for: " + videoID);
|
|
||||||
}
|
|
||||||
callback(false, data); // don't fail
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
callback(false, data); // don't fail
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callback(ytErr, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Logger.debug("redis: fetched video information from cache: " + videoID);
|
|
||||||
callback(getErr?.message, JSON.parse(result));
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
const { ytErr, data } = await new Promise((resolve) => _youTubeAPI.videos.list({
|
||||||
|
part,
|
||||||
|
id: videoID,
|
||||||
|
}, (ytErr: boolean | string, { data }: any) => resolve({ytErr, data})));
|
||||||
|
|
||||||
|
if (!ytErr) {
|
||||||
|
// Only set cache if data returned
|
||||||
|
if (data.items.length > 0) {
|
||||||
|
const { err: setErr } = await redis.setAsync(redisKey, JSON.stringify(data));
|
||||||
|
|
||||||
|
if (setErr) {
|
||||||
|
Logger.warn(setErr.message);
|
||||||
|
} else {
|
||||||
|
Logger.debug("redis: video information cache set for: " + videoID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { err: false, data }; // don't fail
|
||||||
|
} else {
|
||||||
|
return { err: false, data }; // don't fail
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { err: ytErr, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,16 +40,9 @@
|
||||||
"vote.up",
|
"vote.up",
|
||||||
"vote.down"
|
"vote.down"
|
||||||
]
|
]
|
||||||
}, {
|
|
||||||
"url": "http://unresolvable.host:8081/FailedWebhook",
|
|
||||||
"key": "superSecretKey",
|
|
||||||
"scopes": [
|
|
||||||
"vote.up",
|
|
||||||
"vote.down"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categoryList": ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic"],
|
"categoryList": ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic", "highlight"],
|
||||||
"maxNumberOfActiveWarnings": 3,
|
"maxNumberOfActiveWarnings": 3,
|
||||||
"hoursAfterWarningExpires": 24,
|
"hoursAfterWarningExpires": 24,
|
||||||
"rateLimit": {
|
"rateLimit": {
|
||||||
|
|
|
@ -19,6 +19,9 @@ describe('getSegmentsByHash', () => {
|
||||||
await db.prepare("run", startOfQuery + "('getSegmentsByHash-noMatchHash', 40, 50, 2, 'getSegmentsByHash-noMatchHash', 'testman', 0, 50, 'sponsor', 'YouTube', 0, 0, 'fdaffnoMatchHash')"); // hash = fdaff4dee1043451faa7398324fb63d8618ebcd11bddfe0491c488db12c6c910
|
await db.prepare("run", startOfQuery + "('getSegmentsByHash-noMatchHash', 40, 50, 2, 'getSegmentsByHash-noMatchHash', 'testman', 0, 50, 'sponsor', 'YouTube', 0, 0, 'fdaffnoMatchHash')"); // hash = fdaff4dee1043451faa7398324fb63d8618ebcd11bddfe0491c488db12c6c910
|
||||||
await db.prepare("run", startOfQuery + "('getSegmentsByHash-1', 60, 70, 2, 'getSegmentsByHash-1', 'testman', 0, 50, 'sponsor', 'YouTube', 0, 0, '" + getHash('getSegmentsByHash-1', 1) + "')"); // hash = 3272fa85ee0927f6073ef6f07ad5f3146047c1abba794cfa364d65ab9921692b
|
await db.prepare("run", startOfQuery + "('getSegmentsByHash-1', 60, 70, 2, 'getSegmentsByHash-1', 'testman', 0, 50, 'sponsor', 'YouTube', 0, 0, '" + getHash('getSegmentsByHash-1', 1) + "')"); // hash = 3272fa85ee0927f6073ef6f07ad5f3146047c1abba794cfa364d65ab9921692b
|
||||||
await db.prepare("run", startOfQuery + "('onlyHidden', 60, 70, 2, 'onlyHidden', 'testman', 0, 50, 'sponsor', 'YouTube', 1, 0, '" + getHash('onlyHidden', 1) + "')"); // hash = f3a199e1af001d716cdc6599360e2b062c2d2b3fa2885f6d9d2fd741166cbbd3
|
await db.prepare("run", startOfQuery + "('onlyHidden', 60, 70, 2, 'onlyHidden', 'testman', 0, 50, 'sponsor', 'YouTube', 1, 0, '" + getHash('onlyHidden', 1) + "')"); // hash = f3a199e1af001d716cdc6599360e2b062c2d2b3fa2885f6d9d2fd741166cbbd3
|
||||||
|
await db.prepare("run", startOfQuery + "('highlightVid', 60, 60, 2, 'highlightVid-1', 'testman', 0, 50, 'highlight', 'YouTube', 0, 0, '" + getHash('highlightVid', 1) + "')"); // hash = c962d387a9e50170c9118405d20b1081cee8659cd600b856b511f695b91455cb
|
||||||
|
await db.prepare("run", startOfQuery + "('highlightVid', 70, 70, 2, 'highlightVid-2', 'testman', 0, 50, 'highlight', 'YouTube', 0, 0, '" + getHash('highlightVid', 1) + "')"); // hash = c962d387a9e50170c9118405d20b1081cee8659cd600b856b511f695b91455cb
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should be able to get a 200', (done: Done) => {
|
it('Should be able to get a 200', (done: Done) => {
|
||||||
|
@ -158,7 +161,7 @@ describe('getSegmentsByHash', () => {
|
||||||
if (res.status !== 200) done("non 200 status code, was " + res.status);
|
if (res.status !== 200) done("non 200 status code, was " + res.status);
|
||||||
else {
|
else {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body.length !== 1) done("expected 2 videos, got " + body.length);
|
if (body.length !== 1) done("expected 1 video, got " + body.length);
|
||||||
else if (body[0].segments.length !== 1) done("expected 1 segments for first video, got " + body[0].segments.length);
|
else if (body[0].segments.length !== 1) done("expected 1 segments for first video, got " + body[0].segments.length);
|
||||||
else if (body[0].segments[0].UUID !== 'getSegmentsByHash-0-0-1') done("both segments are not sponsor");
|
else if (body[0].segments[0].UUID !== 'getSegmentsByHash-0-0-1') done("both segments are not sponsor");
|
||||||
else done();
|
else done();
|
||||||
|
@ -167,6 +170,20 @@ describe('getSegmentsByHash', () => {
|
||||||
.catch(err => done("Couldn't call endpoint"));
|
.catch(err => done("Couldn't call endpoint"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should only return one segment when fetching highlight segments', (done: Done) => {
|
||||||
|
fetch(getbaseURL() + '/api/skipSegments/c962?category=highlight')
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status !== 200) done("non 200 status code, was " + res.status);
|
||||||
|
else {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.length !== 1) done("expected 1 video, got " + body.length);
|
||||||
|
else if (body[0].segments.length !== 1) done("expected 1 segment, got " + body[0].segments.length);
|
||||||
|
else done();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => done("Couldn't call endpoint"));
|
||||||
|
});
|
||||||
|
|
||||||
it('Should be able to post a segment and get it using endpoint', (done: Done) => {
|
it('Should be able to post a segment and get it using endpoint', (done: Done) => {
|
||||||
let testID = 'abc123goodVideo';
|
let testID = 'abc123goodVideo';
|
||||||
fetch(getbaseURL() + "/api/postVideoSponsorTimes", {
|
fetch(getbaseURL() + "/api/postVideoSponsorTimes", {
|
||||||
|
|
|
@ -500,6 +500,51 @@ describe('postSkipSegments', () => {
|
||||||
.catch(err => done("Couldn't call endpoint"));
|
.catch(err => done("Couldn't call endpoint"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should be rejected if segment starts and ends at the same time', (done: Done) => {
|
||||||
|
fetch(getbaseURL()
|
||||||
|
+ "/api/skipSegments?videoID=qqwerty&startTime=90&endTime=90&userID=testing&category=intro", {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status === 400) done(); // pass
|
||||||
|
else {
|
||||||
|
const body = await res.text();
|
||||||
|
done("non 400 status code: " + res.status + " (" + body + ")");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => done("Couldn't call endpoint"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should be accepted if highlight segment starts and ends at the same time', (done: Done) => {
|
||||||
|
fetch(getbaseURL()
|
||||||
|
+ "/api/skipSegments?videoID=qqwerty&startTime=30&endTime=30&userID=testing&category=highlight", {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status === 200) done(); // pass
|
||||||
|
else {
|
||||||
|
const body = await res.text();
|
||||||
|
done("non 200 status code: " + res.status + " (" + body + ")");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => done("Couldn't call endpoint"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should be rejected if highlight segment doesn\'t start and end at the same time', (done: Done) => {
|
||||||
|
fetch(getbaseURL()
|
||||||
|
+ "/api/skipSegments?videoID=qqwerty&startTime=30&endTime=30.5&userID=testing&category=highlight", {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status === 400) done(); // pass
|
||||||
|
else {
|
||||||
|
const body = await res.text();
|
||||||
|
done("non 400 status code: " + res.status + " (" + body + ")");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => done("Couldn't call endpoint"));
|
||||||
|
});
|
||||||
|
|
||||||
it('Should be rejected if a sponsor is less than 1 second', (done: Done) => {
|
it('Should be rejected if a sponsor is less than 1 second', (done: Done) => {
|
||||||
fetch(getbaseURL()
|
fetch(getbaseURL()
|
||||||
+ "/api/skipSegments?videoID=qqwerty&startTime=30&endTime=30.5&userID=testing", {
|
+ "/api/skipSegments?videoID=qqwerty&startTime=30&endTime=30.5&userID=testing", {
|
||||||
|
|
|
@ -264,6 +264,24 @@ describe('voteOnSponsorTime', () => {
|
||||||
.catch(err => done(err));
|
.catch(err => done(err));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should not able to change to highlight category', (done: Done) => {
|
||||||
|
fetch(getbaseURL()
|
||||||
|
+ "/api/voteOnSponsorTime?userID=randomID2&UUID=incorrect-category&category=highlight")
|
||||||
|
.then(async res => {
|
||||||
|
if (res.status === 400) {
|
||||||
|
let row = await db.prepare('get', `SELECT "category" FROM "sponsorTimes" WHERE "UUID" = ?`, ["incorrect-category"]);
|
||||||
|
if (row.category === "sponsor") {
|
||||||
|
done();
|
||||||
|
} else {
|
||||||
|
done("Vote did not succeed. Submission went from sponsor to " + row.category);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
done("Status code was " + res.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => done(err));
|
||||||
|
});
|
||||||
|
|
||||||
it('Should be able to change your vote for a category and it should add your vote to the database', (done: Done) => {
|
it('Should be able to change your vote for a category and it should add your vote to the database', (done: Done) => {
|
||||||
fetch(getbaseURL()
|
fetch(getbaseURL()
|
||||||
+ "/api/voteOnSponsorTime?userID=randomID2&UUID=vote-uuid-4&category=outro")
|
+ "/api/voteOnSponsorTime?userID=randomID2&UUID=vote-uuid-4&category=outro")
|
||||||
|
|
|
@ -7,4 +7,4 @@ export function getbaseURL() {
|
||||||
/**
|
/**
|
||||||
* Duplicated from Mocha types. TypeScript doesn't infer that type by itself for some reason.
|
* Duplicated from Mocha types. TypeScript doesn't infer that type by itself for some reason.
|
||||||
*/
|
*/
|
||||||
export type Done = (err?: any) => void;
|
export type Done = (err?: any) => void;
|
|
@ -9,61 +9,71 @@ YouTubeAPI.videos.list({
|
||||||
|
|
||||||
|
|
||||||
export class YouTubeApiMock {
|
export class YouTubeApiMock {
|
||||||
static listVideos(videoID: string, callback: (ytErr: any, data: any) => void) {
|
static async listVideos(videoID: string, ignoreCache = false): Promise<{err: string | boolean, data?: any}> {
|
||||||
const obj = {
|
const obj = {
|
||||||
id: videoID
|
id: videoID
|
||||||
};
|
};
|
||||||
|
|
||||||
if (obj.id === "knownWrongID") {
|
if (obj.id === "knownWrongID") {
|
||||||
callback(undefined, {
|
return {
|
||||||
pageInfo: {
|
err: null,
|
||||||
totalResults: 0,
|
data: {
|
||||||
},
|
pageInfo: {
|
||||||
items: [],
|
totalResults: 0,
|
||||||
});
|
},
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (obj.id === "noDuration") {
|
if (obj.id === "noDuration") {
|
||||||
callback(undefined, {
|
return {
|
||||||
pageInfo: {
|
err: null,
|
||||||
totalResults: 1,
|
data: {
|
||||||
},
|
pageInfo: {
|
||||||
items: [
|
totalResults: 1,
|
||||||
{
|
},
|
||||||
contentDetails: {
|
items: [
|
||||||
duration: "PT0S",
|
{
|
||||||
},
|
contentDetails: {
|
||||||
snippet: {
|
duration: "PT0S",
|
||||||
title: "Example Title",
|
},
|
||||||
thumbnails: {
|
snippet: {
|
||||||
maxres: {
|
title: "Example Title",
|
||||||
url: "https://sponsor.ajay.app/LogoSponsorBlockSimple256px.png",
|
thumbnails: {
|
||||||
|
maxres: {
|
||||||
|
url: "https://sponsor.ajay.app/LogoSponsorBlockSimple256px.png",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
}
|
||||||
});
|
};
|
||||||
} else {
|
} else {
|
||||||
callback(undefined, {
|
return {
|
||||||
pageInfo: {
|
err: null,
|
||||||
totalResults: 1,
|
data: {
|
||||||
},
|
pageInfo: {
|
||||||
items: [
|
totalResults: 1,
|
||||||
{
|
},
|
||||||
contentDetails: {
|
items: [
|
||||||
duration: "PT1H23M30S",
|
{
|
||||||
},
|
contentDetails: {
|
||||||
snippet: {
|
duration: "PT1H23M30S",
|
||||||
title: "Example Title",
|
},
|
||||||
thumbnails: {
|
snippet: {
|
||||||
maxres: {
|
title: "Example Title",
|
||||||
url: "https://sponsor.ajay.app/LogoSponsorBlockSimple256px.png",
|
thumbnails: {
|
||||||
|
maxres: {
|
||||||
|
url: "https://sponsor.ajay.app/LogoSponsorBlockSimple256px.png",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue