mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2026-03-17 00:32:20 +03:00
Add casual mode endpoint
This commit is contained in:
@@ -59,6 +59,7 @@ import { getFeatureFlag } from "./routes/getFeatureFlag";
|
||||
import { getReady } from "./routes/getReady";
|
||||
import { getMetrics } from "./routes/getMetrics";
|
||||
import { getSegmentID } from "./routes/getSegmentID";
|
||||
import { postCasual } from "./routes/postCasual";
|
||||
|
||||
export function createServer(callback: () => void): Server {
|
||||
// Create a service (the app object is just a callback).
|
||||
@@ -234,6 +235,8 @@ function setupRoutes(router: Router, server: Server) {
|
||||
router.get("/api/branding/:prefix", getBrandingByHashEndpoint);
|
||||
router.post("/api/branding", postBranding);
|
||||
|
||||
router.post("/api/casual", postCasual);
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (config.postgres?.enabled) {
|
||||
router.get("/database", (req, res) => dumpDatabase(req, res, true));
|
||||
|
||||
@@ -20,6 +20,7 @@ addDefaults(config, {
|
||||
readOnly: false,
|
||||
webhooks: [],
|
||||
categoryList: ["sponsor", "selfpromo", "exclusive_access", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight", "chapter"],
|
||||
casualCategoryList: ["funny", "creative", "clever", "descriptive", "other"],
|
||||
categorySupport: {
|
||||
sponsor: ["skip", "mute", "full"],
|
||||
selfpromo: ["skip", "mute", "full"],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isEmpty } from "lodash";
|
||||
import { config } from "../config";
|
||||
import { db, privateDB } from "../databases/databases";
|
||||
import { Postgres } from "../databases/Postgres";
|
||||
import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model";
|
||||
import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, CasualVoteDBResult, CasualVoteHashDBResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model";
|
||||
import { HashedIP, IPAddress, Service, VideoID, VideoIDHash, Visibility } from "../types/segments.model";
|
||||
import { shuffleArray } from "../utils/array";
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
@@ -51,10 +51,20 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
|
||||
{ useReplica: true }
|
||||
) as Promise<BrandingSegmentDBResult[]>;
|
||||
|
||||
const getCasualVotes = () => db.prepare(
|
||||
"all",
|
||||
`SELECT "category", "upvotes", "downvotes" FROM "casualVotes"
|
||||
WHERE "videoID" = ? AND "service" = ?
|
||||
ORDER BY "timeSubmitted" ASC`,
|
||||
[videoID, service],
|
||||
{ useReplica: true }
|
||||
) as Promise<CasualVoteDBResult[]>;
|
||||
|
||||
const getBranding = async () => {
|
||||
const titles = getTitles();
|
||||
const thumbnails = getThumbnails();
|
||||
const segments = getSegments();
|
||||
const casualVotes = getCasualVotes();
|
||||
|
||||
for (const title of await titles) {
|
||||
title.title = title.title.replace("<", "‹");
|
||||
@@ -63,7 +73,8 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
|
||||
return {
|
||||
titles: await titles,
|
||||
thumbnails: await thumbnails,
|
||||
segments: await segments
|
||||
segments: await segments,
|
||||
casualVotes: await casualVotes
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,7 +96,8 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
|
||||
currentIP: null as Promise<HashedIP> | null
|
||||
};
|
||||
|
||||
return filterAndSortBranding(videoID, returnUserID, fetchAll, branding.titles, branding.thumbnails, branding.segments, ip, cache);
|
||||
return filterAndSortBranding(videoID, returnUserID, fetchAll, branding.titles,
|
||||
branding.thumbnails, branding.segments, branding.casualVotes, ip, cache);
|
||||
}
|
||||
|
||||
export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, service: Service, ip: IPAddress, returnUserID: boolean, fetchAll: boolean): Promise<Record<VideoID, BrandingResult>> {
|
||||
@@ -117,12 +129,22 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
||||
{ useReplica: true }
|
||||
) as Promise<BrandingSegmentHashDBResult[]>;
|
||||
|
||||
const getCasualVotes = () => db.prepare(
|
||||
"all",
|
||||
`SELECT "videoID", "category", "upvotes", "downvotes" FROM "casualVotes"
|
||||
WHERE "hashedVideoID" LIKE ? AND "service" = ?
|
||||
ORDER BY "timeSubmitted" ASC`,
|
||||
[`${videoHashPrefix}%`, service],
|
||||
{ useReplica: true }
|
||||
) as Promise<CasualVoteHashDBResult[]>;
|
||||
|
||||
const branding = await QueryCacher.get(async () => {
|
||||
// Make sure they are both called in parallel
|
||||
const branding = {
|
||||
titles: getTitles(),
|
||||
thumbnails: getThumbnails(),
|
||||
segments: getSegments()
|
||||
segments: getSegments(),
|
||||
casualVotes: getCasualVotes()
|
||||
};
|
||||
|
||||
const dbResult: Record<VideoID, BrandingHashDBResult> = {};
|
||||
@@ -130,7 +152,8 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
||||
dbResult[submission.videoID] = dbResult[submission.videoID] || {
|
||||
titles: [],
|
||||
thumbnails: [],
|
||||
segments: []
|
||||
segments: [],
|
||||
casualVotes: []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,6 +173,11 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
||||
dbResult[segment.videoID].segments.push(segment);
|
||||
});
|
||||
|
||||
(await branding.casualVotes).forEach((casualVote) => {
|
||||
initResult(casualVote);
|
||||
dbResult[casualVote.videoID].casualVotes.push(casualVote);
|
||||
});
|
||||
|
||||
return dbResult;
|
||||
}, brandingHashKey(videoHashPrefix, service));
|
||||
|
||||
@@ -162,14 +190,14 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
||||
await Promise.all(Object.keys(branding).map(async (key) => {
|
||||
const castedKey = key as VideoID;
|
||||
processedResult[castedKey] = await filterAndSortBranding(castedKey, returnUserID, fetchAll, branding[castedKey].titles,
|
||||
branding[castedKey].thumbnails, branding[castedKey].segments, ip, cache);
|
||||
branding[castedKey].thumbnails, branding[castedKey].segments, branding[castedKey].casualVotes, ip, cache);
|
||||
}));
|
||||
|
||||
return processedResult;
|
||||
}
|
||||
|
||||
async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fetchAll: boolean, dbTitles: TitleDBResult[],
|
||||
dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[],
|
||||
dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[], dbCasualVotes: CasualVoteDBResult[],
|
||||
ip: IPAddress, cache: { currentIP: Promise<HashedIP> | null }): Promise<BrandingResult> {
|
||||
|
||||
const shouldKeepTitles = shouldKeepSubmission(dbTitles, BrandingSubmissionType.Title, ip, cache);
|
||||
@@ -202,11 +230,17 @@ async function filterAndSortBranding(videoID: VideoID, returnUserID: boolean, fe
|
||||
}))
|
||||
.filter((a) => (fetchAll && !a.original) || a.votes >= 1 || (a.votes >= 0 && !a.original) || a.locked) as ThumbnailResult[];
|
||||
|
||||
const casualVotes = dbCasualVotes.map((r) => ({
|
||||
id: r.category,
|
||||
count: r.upvotes - r.downvotes
|
||||
})).filter((a) => a.count > 0);
|
||||
|
||||
const videoDuration = dbSegments.filter(s => s.videoDuration !== 0)[0]?.videoDuration ?? null;
|
||||
|
||||
return {
|
||||
titles,
|
||||
thumbnails,
|
||||
casualVotes,
|
||||
randomTime: findRandomTime(videoID, dbSegments, videoDuration),
|
||||
videoDuration: videoDuration,
|
||||
};
|
||||
@@ -303,7 +337,7 @@ export async function getBranding(req: Request, res: Response) {
|
||||
.then(etag => res.set("ETag", etag))
|
||||
.catch(() => null);
|
||||
|
||||
const status = result.titles.length > 0 || result.thumbnails.length > 0 ? 200 : 404;
|
||||
const status = result.titles.length > 0 || result.thumbnails.length > 0 || result.casualVotes.length > 0 ? 200 : 404;
|
||||
return res.status(status).json(result);
|
||||
} catch (e) {
|
||||
Logger.error(e as string);
|
||||
|
||||
111
src/routes/postCasual.ts
Normal file
111
src/routes/postCasual.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Request, Response } from "express";
|
||||
import { config } from "../config";
|
||||
import { db, privateDB } from "../databases/databases";
|
||||
|
||||
import { BrandingUUID, CasualCategory, CasualVoteSubmission } from "../types/branding.model";
|
||||
import { HashedIP, IPAddress, Service, VideoID } from "../types/segments.model";
|
||||
import { HashedUserID } from "../types/user.model";
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
import { getIP } from "../utils/getIP";
|
||||
import { getService } from "../utils/getService";
|
||||
import { Logger } from "../utils/logger";
|
||||
import crypto from "crypto";
|
||||
import { QueryCacher } from "../utils/queryCacher";
|
||||
import { acquireLock } from "../utils/redisLock";
|
||||
import { checkBanStatus } from "../utils/checkBan";
|
||||
|
||||
enum CasualVoteType {
|
||||
Upvote = 1,
|
||||
Downvote = 2
|
||||
}
|
||||
|
||||
interface ExistingVote {
|
||||
UUID: BrandingUUID;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export async function postCasual(req: Request, res: Response) {
|
||||
const { videoID, userID, downvote, category } = req.body as CasualVoteSubmission;
|
||||
const service = getService(req.body.service);
|
||||
|
||||
if (!videoID || !userID || userID.length < 30 || !service || !category) {
|
||||
return res.status(400).send("Bad Request");
|
||||
}
|
||||
if (!config.casualCategoryList.includes(category)) {
|
||||
return res.status(400).send("Invalid category");
|
||||
}
|
||||
|
||||
try {
|
||||
const hashedUserID = await getHashCache(userID);
|
||||
const hashedVideoID = await getHashCache(videoID, 1);
|
||||
const hashedIP = await getHashCache(getIP(req) + config.globalSalt as IPAddress);
|
||||
const isBanned = await checkBanStatus(hashedUserID, hashedIP);
|
||||
|
||||
const lock = await acquireLock(`postCasual:${videoID}.${hashedUserID}`);
|
||||
if (!lock.status) {
|
||||
res.status(429).send("Vote already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBanned) {
|
||||
return res.status(200).send("OK");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const voteType: CasualVoteType = downvote ? CasualVoteType.Downvote : CasualVoteType.Upvote;
|
||||
|
||||
const existingUUID = (await db.prepare("get", `SELECT "UUID" from "casualVotes" where "videoID" = ? AND "category" = ?`, [videoID, category]))?.UUID;
|
||||
const UUID = existingUUID || crypto.randomUUID();
|
||||
|
||||
const alreadyVotedTheSame = await handleExistingVotes(videoID, service, UUID, hashedUserID, hashedIP, category, voteType, now);
|
||||
if (existingUUID) {
|
||||
if (!alreadyVotedTheSame) {
|
||||
if (downvote) {
|
||||
await db.prepare("run", `UPDATE "casualVotes" SET "downvotes" = "downvotes" + 1 WHERE "UUID" = ?`, [UUID]);
|
||||
} else {
|
||||
await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" + 1 WHERE "UUID" = ?`, [UUID]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (downvote) {
|
||||
throw new Error("Title submission doesn't exist");
|
||||
}
|
||||
|
||||
await db.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "hashedVideoID", "timeSubmitted", "UUID", "category", "upvotes", "downvotes") VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[videoID, service, hashedVideoID, now, UUID, category, downvote ? 0 : 1, downvote ? 1 : 0]);
|
||||
}
|
||||
|
||||
//todo: cache clearing
|
||||
QueryCacher.clearBrandingCache({ videoID, hashedVideoID, service });
|
||||
|
||||
res.status(200).send("OK");
|
||||
|
||||
lock.unlock();
|
||||
} catch (e) {
|
||||
Logger.error(e as string);
|
||||
res.status(500).send("Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExistingVotes(videoID: VideoID, service: Service, UUID: string,
|
||||
hashedUserID: HashedUserID, hashedIP: HashedIP, category: CasualCategory, voteType: CasualVoteType, now: number): Promise<boolean> {
|
||||
const existingVote = await privateDB.prepare("get", `SELECT "UUID", "type" from "casualVotes" WHERE "videoID" = ? AND "service" = ? AND "userID" = ? AND category = ?`, [videoID, service, hashedUserID, category]) as ExistingVote;
|
||||
if (existingVote) {
|
||||
if (existingVote.type === voteType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existingVote.type === CasualVoteType.Upvote) {
|
||||
await db.prepare("run", `UPDATE "casualVotes" SET "upvotes" = "upvotes" - 1 WHERE "UUID" = ?`, [UUID]);
|
||||
} else {
|
||||
await db.prepare("run", `UPDATE "casualVotes" SET "downvotes" = "downvotes" - 1 WHERE "UUID" = ?`, [UUID]);
|
||||
}
|
||||
|
||||
await privateDB.prepare("run", `DELETE FROM "casualVotes" WHERE "UUID" = ?`, [existingVote.UUID]);
|
||||
}
|
||||
|
||||
await privateDB.prepare("run", `INSERT INTO "casualVotes" ("videoID", "service", "userID", "hashedIP", "category", "type", "timeSubmitted") VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[videoID, service, hashedUserID, hashedIP, category, voteType, now]);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { UserID } from "./user.model";
|
||||
|
||||
export type BrandingUUID = string & { readonly __brandingUUID: unique symbol };
|
||||
|
||||
export type CasualCategory = ("funny" | "creative" | "clever" | "descriptive" | "other") & { __casualCategoryBrand: unknown };
|
||||
|
||||
export interface BrandingDBSubmissionData {
|
||||
videoID: VideoID,
|
||||
}
|
||||
@@ -50,17 +52,24 @@ export interface ThumbnailResult {
|
||||
userID?: UserID
|
||||
}
|
||||
|
||||
export interface CasualVote {
|
||||
id: string,
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface BrandingResult {
|
||||
titles: TitleResult[],
|
||||
thumbnails: ThumbnailResult[],
|
||||
casualVotes: CasualVote[],
|
||||
randomTime: number,
|
||||
videoDuration: number | null
|
||||
}
|
||||
|
||||
export interface BrandingHashDBResult {
|
||||
titles: TitleDBResult[],
|
||||
thumbnails: ThumbnailDBResult[],
|
||||
segments: BrandingSegmentDBResult[]
|
||||
titles: TitleDBResult[];
|
||||
thumbnails: ThumbnailDBResult[];
|
||||
segments: BrandingSegmentDBResult[];
|
||||
casualVotes: CasualVoteDBResult[];
|
||||
}
|
||||
|
||||
export interface OriginalThumbnailSubmission {
|
||||
@@ -89,6 +98,15 @@ export interface BrandingSubmission {
|
||||
downvote: boolean | undefined;
|
||||
videoDuration: number | undefined;
|
||||
wasWarned: boolean | undefined;
|
||||
casualMode: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface CasualVoteSubmission {
|
||||
videoID: VideoID;
|
||||
userID: UserID;
|
||||
service: Service;
|
||||
downvote: boolean | undefined;
|
||||
category: CasualCategory;
|
||||
}
|
||||
|
||||
export interface BrandingSegmentDBResult {
|
||||
@@ -98,9 +116,21 @@ export interface BrandingSegmentDBResult {
|
||||
videoDuration: number;
|
||||
}
|
||||
|
||||
export interface CasualVoteDBResult {
|
||||
category: CasualCategory;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
}
|
||||
|
||||
export interface BrandingSegmentHashDBResult extends BrandingDBSubmissionData {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
category: Category;
|
||||
videoDuration: number;
|
||||
}
|
||||
|
||||
export interface CasualVoteHashDBResult extends BrandingDBSubmissionData {
|
||||
category: CasualCategory;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export interface SBSConfig {
|
||||
readOnly: boolean;
|
||||
webhooks: WebhookConfig[];
|
||||
categoryList: string[];
|
||||
casualCategoryList: string[];
|
||||
deArrowTypes: DeArrowType[];
|
||||
categorySupport: Record<string, string[]>;
|
||||
maxTitleLength: number;
|
||||
|
||||
@@ -19,13 +19,13 @@ export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: S
|
||||
}
|
||||
|
||||
export const brandingKey = (videoID: VideoID, service: Service): string =>
|
||||
`branding.v2.${service}.videoID.${videoID}`;
|
||||
`branding.v3.${service}.videoID.${videoID}`;
|
||||
|
||||
export function brandingHashKey(hashedVideoIDPrefix: VideoIDHash, service: Service): string {
|
||||
hashedVideoIDPrefix = hashedVideoIDPrefix.substring(0, 4) as VideoIDHash;
|
||||
if (hashedVideoIDPrefix.length !== 4) Logger.warn(`Redis skip segment hash-prefix key is not length 4! ${hashedVideoIDPrefix}`);
|
||||
|
||||
return `branding.v2.${service}.${hashedVideoIDPrefix}`;
|
||||
return `branding.v3.${service}.${hashedVideoIDPrefix}`;
|
||||
}
|
||||
|
||||
export const brandingIPKey = (uuid: BrandingUUID): string =>
|
||||
|
||||
Reference in New Issue
Block a user