mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2024-11-10 01:02:30 +01:00
Add random timestamp generation to get branding
This commit is contained in:
parent
8e5be402e1
commit
5834643ba0
6 changed files with 166 additions and 20 deletions
24
package-lock.json
generated
24
package-lock.json
generated
|
@ -20,6 +20,7 @@
|
|||
"pg": "^8.8.0",
|
||||
"rate-limit-redis": "^3.0.1",
|
||||
"redis": "^4.5.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sync-mysql": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -31,6 +32,7 @@
|
|||
"@types/mocha": "^10.0.0",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/pg": "^8.6.5",
|
||||
"@types/seedrandom": "^3.0.5",
|
||||
"@types/sinon": "^10.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "^5.44.0",
|
||||
"@typescript-eslint/parser": "^5.44.0",
|
||||
|
@ -984,6 +986,12 @@
|
|||
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-kopEpYpFQvQdYsZkZVwht/0THHmTFFYXDaqV/lM45eweJ8kcGVDgZHs0RVTolSq55UPZNmjhKc9r7UvLu/mQQg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
|
||||
|
@ -4903,6 +4911,11 @@
|
|||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.3.7",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
|
||||
|
@ -6579,6 +6592,12 @@
|
|||
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"@types/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-kopEpYpFQvQdYsZkZVwht/0THHmTFFYXDaqV/lM45eweJ8kcGVDgZHs0RVTolSq55UPZNmjhKc9r7UvLu/mQQg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/semver": {
|
||||
"version": "7.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
|
||||
|
@ -9442,6 +9461,11 @@
|
|||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.3.7",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
"pg": "^8.8.0",
|
||||
"rate-limit-redis": "^3.0.1",
|
||||
"redis": "^4.5.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sync-mysql": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -41,6 +42,7 @@
|
|||
"@types/mocha": "^10.0.0",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/pg": "^8.6.5",
|
||||
"@types/seedrandom": "^3.0.5",
|
||||
"@types/sinon": "^10.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "^5.44.0",
|
||||
"@typescript-eslint/parser": "^5.44.0",
|
||||
|
|
|
@ -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, BrandingHashDBResult, BrandingResult, ThumbnailDBResult, ThumbnailResult, TitleDBResult, TitleResult } from "../types/branding.model";
|
||||
import { BrandingDBSubmission, BrandingDBSubmissionData, BrandingHashDBResult, BrandingResult, BrandingSegmentDBResult, BrandingSegmentHashDBResult, 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";
|
||||
|
@ -14,6 +14,7 @@ import { Logger } from "../utils/logger";
|
|||
import { promiseOrTimeout } from "../utils/promise";
|
||||
import { QueryCacher } from "../utils/queryCacher";
|
||||
import { brandingHashKey, brandingIPKey, brandingKey } from "../utils/redisKeys";
|
||||
import * as SeedRandom from "seedrandom";
|
||||
|
||||
enum BrandingSubmissionType {
|
||||
Title = "title",
|
||||
|
@ -39,10 +40,25 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
|
|||
{ useReplica: true }
|
||||
) as Promise<ThumbnailDBResult[]>;
|
||||
|
||||
const getBranding = async () => ({
|
||||
titles: await getTitles(),
|
||||
thumbnails: await getThumbnails()
|
||||
});
|
||||
const getSegments = () => db.prepare(
|
||||
"all",
|
||||
`SELECT "startTime", "endTime", "videoDuration" FROM "sponsorTimes"
|
||||
WHERE "votes" >= 0 AND "shadowHidden" = 0 AND "hidden" = 0 AND "actionType" = 'skip' AND "videoID" = ? AND "service" = ?`,
|
||||
[videoID, service],
|
||||
{ useReplica: true }
|
||||
) as Promise<BrandingSegmentDBResult[]>;
|
||||
|
||||
const getBranding = async () => {
|
||||
const titles = getTitles();
|
||||
const thumbnails = getThumbnails();
|
||||
const segments = getSegments();
|
||||
|
||||
return {
|
||||
titles: await titles,
|
||||
thumbnails: await thumbnails,
|
||||
segments: await segments
|
||||
};
|
||||
};
|
||||
|
||||
const brandingTrace = await QueryCacher.getTraced(getBranding, brandingKey(videoID, service));
|
||||
const branding = brandingTrace.data;
|
||||
|
@ -62,7 +78,7 @@ export async function getVideoBranding(res: Response, videoID: VideoID, service:
|
|||
currentIP: null as Promise<HashedIP> | null
|
||||
};
|
||||
|
||||
return filterAndSortBranding(branding.titles, branding.thumbnails, ip, cache);
|
||||
return filterAndSortBranding(videoID, branding.titles, branding.thumbnails, branding.segments, ip, cache);
|
||||
}
|
||||
|
||||
export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, service: Service, ip: IPAddress): Promise<Record<VideoID, BrandingResult>> {
|
||||
|
@ -84,18 +100,28 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
|||
{ useReplica: true }
|
||||
) as Promise<ThumbnailDBResult[]>;
|
||||
|
||||
const getSegments = () => db.prepare(
|
||||
"all",
|
||||
`SELECT "videoID", "startTime", "endTime", "videoDuration" FROM "sponsorTimes"
|
||||
WHERE "votes" >= 0 AND "shadowHidden" = 0 AND "hidden" = 0 AND "actionType" = 'skip' AND "hashedVideoID" LIKE ? AND "service" = ?`,
|
||||
[`${videoHashPrefix}%`, service],
|
||||
{ useReplica: true }
|
||||
) as Promise<BrandingSegmentHashDBResult[]>;
|
||||
|
||||
const branding = await QueryCacher.get(async () => {
|
||||
// Make sure they are both called in parallel
|
||||
const branding = {
|
||||
titles: getTitles(),
|
||||
thumbnails: getThumbnails()
|
||||
thumbnails: getThumbnails(),
|
||||
segments: getSegments()
|
||||
};
|
||||
|
||||
const dbResult: Record<VideoID, BrandingHashDBResult> = {};
|
||||
const initResult = (submission: BrandingDBSubmission) => {
|
||||
const initResult = (submission: BrandingDBSubmissionData) => {
|
||||
dbResult[submission.videoID] = dbResult[submission.videoID] || {
|
||||
titles: [],
|
||||
thumbnails: []
|
||||
thumbnails: [],
|
||||
segments: []
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -108,6 +134,11 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
|||
dbResult[thumbnail.videoID].thumbnails.push(thumbnail);
|
||||
});
|
||||
|
||||
(await branding.segments).map((segment) => {
|
||||
initResult(segment);
|
||||
dbResult[segment.videoID].segments.push(segment);
|
||||
});
|
||||
|
||||
return dbResult;
|
||||
}, brandingHashKey(videoHashPrefix, service));
|
||||
|
||||
|
@ -119,13 +150,17 @@ export async function getVideoBrandingByHash(videoHashPrefix: VideoIDHash, servi
|
|||
const processedResult: Record<VideoID, BrandingResult> = {};
|
||||
await Promise.all(Object.keys(branding).map(async (key) => {
|
||||
const castedKey = key as VideoID;
|
||||
processedResult[castedKey] = await filterAndSortBranding(branding[castedKey].titles, branding[castedKey].thumbnails, ip, cache);
|
||||
processedResult[castedKey] = await filterAndSortBranding(castedKey, branding[castedKey].titles,
|
||||
branding[castedKey].thumbnails, branding[castedKey].segments, ip, cache);
|
||||
}));
|
||||
|
||||
return processedResult;
|
||||
}
|
||||
|
||||
async function filterAndSortBranding(dbTitles: TitleDBResult[], dbThumbnails: ThumbnailDBResult[], ip: IPAddress, cache: { currentIP: Promise<HashedIP> | null }): Promise<BrandingResult> {
|
||||
async function filterAndSortBranding(videoID: VideoID, dbTitles: TitleDBResult[],
|
||||
dbThumbnails: ThumbnailDBResult[], dbSegments: BrandingSegmentDBResult[],
|
||||
ip: IPAddress, cache: { currentIP: Promise<HashedIP> | null }): Promise<BrandingResult> {
|
||||
|
||||
const shouldKeepTitles = shouldKeepSubmission(dbTitles, BrandingSubmissionType.Title, ip, cache);
|
||||
const shouldKeepThumbnails = shouldKeepSubmission(dbThumbnails, BrandingSubmissionType.Thumbnail, ip, cache);
|
||||
|
||||
|
@ -153,7 +188,8 @@ async function filterAndSortBranding(dbTitles: TitleDBResult[], dbThumbnails: Th
|
|||
|
||||
return {
|
||||
titles,
|
||||
thumbnails
|
||||
thumbnails,
|
||||
randomTime: findRandomTime(videoID, dbSegments)
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -180,6 +216,50 @@ async function shouldKeepSubmission(submissions: BrandingDBSubmission[], type: B
|
|||
return (_, index) => shouldKeep[index];
|
||||
}
|
||||
|
||||
export function findRandomTime(videoID: VideoID, segments: BrandingSegmentDBResult[]): number {
|
||||
const randomTime = SeedRandom.alea(videoID)();
|
||||
if (segments.length === 0) return randomTime;
|
||||
|
||||
const videoDuration = segments[0].videoDuration;
|
||||
|
||||
// There are segments, treat this as a relative time in the chopped up video
|
||||
const sorted = segments.sort((a, b) => a.startTime - b.startTime);
|
||||
const emptySegments: [number, number][] = [];
|
||||
let totalTime = 0;
|
||||
|
||||
let nextEndTime = -1;
|
||||
for (const segment of sorted) {
|
||||
if (segment.startTime > nextEndTime) {
|
||||
if (nextEndTime !== -1) {
|
||||
emptySegments.push([nextEndTime, segment.startTime]);
|
||||
totalTime += segment.startTime - nextEndTime;
|
||||
}
|
||||
}
|
||||
|
||||
nextEndTime = Math.max(segment.endTime, nextEndTime);
|
||||
}
|
||||
|
||||
if (nextEndTime < videoDuration) {
|
||||
emptySegments.push([nextEndTime, videoDuration]);
|
||||
totalTime += videoDuration - nextEndTime;
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
for (const segment of emptySegments) {
|
||||
const duration = segment[1] - segment[0];
|
||||
|
||||
if (cursor + duration >= randomTime * totalTime) {
|
||||
// Found it
|
||||
return (segment[0] + (randomTime * totalTime - cursor)) / videoDuration;
|
||||
}
|
||||
|
||||
cursor += duration;
|
||||
}
|
||||
|
||||
// Fallback to just the random time
|
||||
return randomTime;
|
||||
}
|
||||
|
||||
export async function getBranding(req: Request, res: Response) {
|
||||
const videoID: VideoID = req.query.videoID as VideoID;
|
||||
const service: Service = getService(req.query.service as string);
|
||||
|
|
|
@ -3,10 +3,13 @@ import { UserID } from "./user.model";
|
|||
|
||||
export type BrandingUUID = string & { readonly __brandingUUID: unique symbol };
|
||||
|
||||
export interface BrandingDBSubmission {
|
||||
export interface BrandingDBSubmissionData {
|
||||
videoID: VideoID,
|
||||
}
|
||||
|
||||
export interface BrandingDBSubmission extends BrandingDBSubmissionData {
|
||||
shadowHidden: number,
|
||||
UUID: BrandingUUID,
|
||||
videoID: VideoID,
|
||||
hashedVideoID: VideoIDHash
|
||||
}
|
||||
|
||||
|
@ -42,12 +45,14 @@ export interface ThumbnailResult {
|
|||
|
||||
export interface BrandingResult {
|
||||
titles: TitleResult[],
|
||||
thumbnails: ThumbnailResult[]
|
||||
thumbnails: ThumbnailResult[],
|
||||
randomTime: number
|
||||
}
|
||||
|
||||
export interface BrandingHashDBResult {
|
||||
titles: TitleDBResult[],
|
||||
thumbnails: ThumbnailDBResult[]
|
||||
thumbnails: ThumbnailDBResult[],
|
||||
segments: BrandingSegmentDBResult[]
|
||||
}
|
||||
|
||||
export interface OriginalThumbnailSubmission {
|
||||
|
@ -72,4 +77,16 @@ export interface BrandingSubmission {
|
|||
videoID: VideoID;
|
||||
userID: UserID;
|
||||
service: Service;
|
||||
}
|
||||
|
||||
export interface BrandingSegmentDBResult {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
videoDuration: number;
|
||||
}
|
||||
|
||||
export interface BrandingSegmentHashDBResult extends BrandingDBSubmissionData {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
videoDuration: number;
|
||||
}
|
|
@ -18,13 +18,13 @@ export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: S
|
|||
}
|
||||
|
||||
export const brandingKey = (videoID: VideoID, service: Service): string =>
|
||||
`branding.${service}.videoID.${videoID}`;
|
||||
`branding.v2.${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.${service}.${hashedVideoIDPrefix}`;
|
||||
return `branding.v2.${service}.${hashedVideoIDPrefix}`;
|
||||
}
|
||||
|
||||
export const brandingIPKey = (uuid: BrandingUUID): string =>
|
||||
|
|
|
@ -3,7 +3,7 @@ import assert from "assert";
|
|||
import { getHash } from "../../src/utils/getHash";
|
||||
import { db } from "../../src/databases/databases";
|
||||
import { Service } from "../../src/types/segments.model";
|
||||
import { BrandingResult, BrandingUUID } from "../../src/types/branding.model";
|
||||
import { BrandingUUID, ThumbnailResult, TitleResult } from "../../src/types/branding.model";
|
||||
import { partialDeepEquals } from "../utils/partialDeepEquals";
|
||||
|
||||
describe("getBranding", () => {
|
||||
|
@ -11,11 +11,13 @@ describe("getBranding", () => {
|
|||
const videoID2Locked = "videoID2";
|
||||
const videoID2ShadowHide = "videoID3";
|
||||
const videoIDEmpty = "videoID4";
|
||||
const videoIDRandomTime = "videoID5";
|
||||
|
||||
const videoID1Hash = getHash(videoID1, 1).slice(0, 4);
|
||||
const videoID2LockedHash = getHash(videoID2Locked, 1).slice(0, 4);
|
||||
const videoID2ShadowHideHash = getHash(videoID2ShadowHide, 1).slice(0, 4);
|
||||
const videoIDEmptyHash = "aaaa";
|
||||
const videoIDRandomTimeHash = getHash(videoIDRandomTime, 1).slice(0, 4);
|
||||
|
||||
const endpoint = "/api/branding";
|
||||
const getBranding = (params: Record<string, any>) => client({
|
||||
|
@ -97,6 +99,10 @@ describe("getBranding", () => {
|
|||
db.prepare("run", thumbnailVotesQuery, ["UUID22T", 2, 0, 0]),
|
||||
db.prepare("run", thumbnailVotesQuery, ["UUID32T", 1, 0, 1])
|
||||
]);
|
||||
|
||||
const query = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", "views", "category", "actionType", "service", "videoDuration", "hidden", "shadowHidden", "description", "hashedVideoID") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
await db.prepare("run", query, [videoIDRandomTime, 1, 11, 1, 0, "uuidbranding1", "testman", 0, 50, "sponsor", "skip", "YouTube", 100, 0, 0, "", videoIDRandomTimeHash]);
|
||||
await db.prepare("run", query, [videoIDRandomTime, 20, 33, 2, 0, "uuidbranding2", "testman", 0, 50, "intro", "skip", "YouTube", 100, 0, 0, "", videoIDRandomTimeHash]);
|
||||
});
|
||||
|
||||
it("should get top titles and thumbnails", async () => {
|
||||
|
@ -221,7 +227,24 @@ describe("getBranding", () => {
|
|||
assert.strictEqual(result2.status, 404);
|
||||
});
|
||||
|
||||
async function checkVideo(videoID: string, videoIDHash: string, expected: BrandingResult) {
|
||||
it("should get correct random time", async () => {
|
||||
const videoDuration = 100;
|
||||
|
||||
const result1 = await getBranding({ videoID: videoIDRandomTime });
|
||||
const result2 = await getBrandingByHash(videoIDRandomTimeHash, {});
|
||||
|
||||
const randomTime = result1.data.randomTime;
|
||||
assert.strictEqual(randomTime, result2.data[videoIDRandomTime].randomTime);
|
||||
assert.ok(randomTime > 0 && randomTime < 1);
|
||||
|
||||
const timeAbsolute = randomTime * videoDuration;
|
||||
assert.ok(timeAbsolute < 1 || (timeAbsolute > 11 && timeAbsolute < 20) || timeAbsolute > 33);
|
||||
});
|
||||
|
||||
async function checkVideo(videoID: string, videoIDHash: string, expected: {
|
||||
titles: TitleResult[],
|
||||
thumbnails: ThumbnailResult[]
|
||||
}) {
|
||||
const result1 = await getBranding({ videoID });
|
||||
const result2 = await getBrandingByHash(videoIDHash, {});
|
||||
|
||||
|
|
Loading…
Reference in a new issue