mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2025-12-25 08:58:23 +03:00
Merge branch 'master' of https://github.com/ajayyy/SponsorBlockServer into fullVideoLabels
This commit is contained in:
@@ -1,34 +1,45 @@
|
||||
import LRU from "@ajayyy/lru-diskcache";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
let DiskCache: LRU<string, string>;
|
||||
class DiskCache {
|
||||
async set(key: string, value: unknown): Promise<boolean> {
|
||||
if (!config.diskCacheURL) return false;
|
||||
|
||||
if (config.diskCache) {
|
||||
DiskCache = new LRU("./databases/cache", config.diskCache);
|
||||
DiskCache.init();
|
||||
} else {
|
||||
DiskCache = {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// constructor(rootPath, options): {};
|
||||
try {
|
||||
const result = await axios.post(`${config.diskCacheURL}/api/v1/item`, {
|
||||
key,
|
||||
value
|
||||
});
|
||||
|
||||
init(): void { return; },
|
||||
return result.status === 200;
|
||||
} catch (err) {
|
||||
const response = (err as AxiosError).response;
|
||||
if (!response || response.status !== 404) {
|
||||
Logger.error(`DiskCache: Error setting key ${key}: ${err}`);
|
||||
}
|
||||
|
||||
reset(): void { return; },
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
has(key: string): boolean { return false; },
|
||||
async get(key: string): Promise<unknown> {
|
||||
if (!config.diskCacheURL) return null;
|
||||
|
||||
get(key: string, opts?: {encoding?: string}): string { return null; },
|
||||
try {
|
||||
const result = await axios.get(`${config.diskCacheURL}/api/v1/item?key=${key}`, { timeout: 500 });
|
||||
|
||||
// Returns size
|
||||
set(key: string, dataOrSteam: string): Promise<number> { return new Promise(() => 0); },
|
||||
return result.status === 200 ? result.data : null;
|
||||
} catch (err) {
|
||||
const response = (err as AxiosError).response;
|
||||
if (!response || response.status !== 404) {
|
||||
Logger.error(`DiskCache: Error getting key ${key}: ${err}`);
|
||||
}
|
||||
|
||||
del(key: string): void { return; },
|
||||
|
||||
size(): number { return 0; },
|
||||
|
||||
prune(): void {return; },
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DiskCache;
|
||||
const diskCache = new DiskCache();
|
||||
export default diskCache;
|
||||
11
src/utils/features.ts
Normal file
11
src/utils/features.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { db } from "../databases/databases";
|
||||
import { Feature, HashedUserID } from "../types/user.model";
|
||||
import { QueryCacher } from "./queryCacher";
|
||||
import { userFeatureKey } from "./redisKeys";
|
||||
|
||||
export async function hasFeature(userID: HashedUserID, feature: Feature): Promise<boolean> {
|
||||
return await QueryCacher.get(async () => {
|
||||
const result = await db.prepare("get", 'SELECT "feature" from "userFeatures" WHERE "userID" = ? AND "feature" = ?', [userID, feature], { useReplica: true });
|
||||
return !!result;
|
||||
}, userFeatureKey(userID, feature));
|
||||
}
|
||||
@@ -26,11 +26,13 @@ async function getFromRedis<T extends string>(key: HashedValue): Promise<T & Has
|
||||
Logger.debug(`Got data from redis: ${reply}`);
|
||||
return reply as T & HashedValue;
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
} catch (err) {
|
||||
Logger.error(err as string);
|
||||
}
|
||||
|
||||
// Otherwise, calculate it
|
||||
const data = getHash(key, cachedHashTimes);
|
||||
redis.set(key, data);
|
||||
redis.set(redisKey, data).catch((err) => Logger.error(err));
|
||||
|
||||
return data as T & HashedValue;
|
||||
}
|
||||
59
src/utils/getVideoDetails.ts
Normal file
59
src/utils/getVideoDetails.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { config } from "../config";
|
||||
import { innerTubeVideoDetails } from "../types/innerTubeApi.model";
|
||||
import { APIVideoData } from "../types/youtubeApi.model";
|
||||
import { YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { getPlayerData } from "../utils/innerTubeAPI";
|
||||
|
||||
export interface videoDetails {
|
||||
videoId: string,
|
||||
duration: number,
|
||||
authorId: string,
|
||||
authorName: string,
|
||||
title: string,
|
||||
published: number,
|
||||
thumbnails: {
|
||||
url: string,
|
||||
width: number,
|
||||
height: number,
|
||||
}[]
|
||||
}
|
||||
|
||||
const convertFromInnerTube = (input: innerTubeVideoDetails): videoDetails => ({
|
||||
videoId: input.videoId,
|
||||
duration: Number(input.lengthSeconds),
|
||||
authorId: input.channelId,
|
||||
authorName: input.author,
|
||||
title: input.title,
|
||||
published: new Date(input.publishDate).getTime()/1000,
|
||||
thumbnails: input.thumbnail.thumbnails
|
||||
});
|
||||
|
||||
const convertFromNewLeaf = (input: APIVideoData): videoDetails => ({
|
||||
videoId: input.videoId,
|
||||
duration: input.lengthSeconds,
|
||||
authorId: input.authorId,
|
||||
authorName: input.author,
|
||||
title: input.title,
|
||||
published: input.published,
|
||||
thumbnails: input.videoThumbnails
|
||||
});
|
||||
|
||||
async function newLeafWrapper(videoId: string, ignoreCache: boolean) {
|
||||
const result = await YouTubeAPI.listVideos(videoId, ignoreCache);
|
||||
return result?.data ?? Promise.reject();
|
||||
}
|
||||
|
||||
export function getVideoDetails(videoId: string, ignoreCache = false): Promise<videoDetails> {
|
||||
if (!config.newLeafURLs) {
|
||||
return getPlayerData(videoId, ignoreCache)
|
||||
.then(data => convertFromInnerTube(data));
|
||||
}
|
||||
return Promise.any([
|
||||
newLeafWrapper(videoId, ignoreCache)
|
||||
.then(videoData => convertFromNewLeaf(videoData)),
|
||||
getPlayerData(videoId, ignoreCache)
|
||||
.then(data => convertFromInnerTube(data))
|
||||
]).catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
58
src/utils/innerTubeAPI.ts
Normal file
58
src/utils/innerTubeAPI.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios from "axios";
|
||||
import { Logger } from "./logger";
|
||||
import { innerTubeVideoDetails } from "../types/innerTubeApi.model";
|
||||
import DiskCache from "./diskCache";
|
||||
|
||||
async function getFromITube (videoID: string): Promise<innerTubeVideoDetails> {
|
||||
// start subrequest
|
||||
const url = "https://www.youtube.com/youtubei/v1/player";
|
||||
const data = {
|
||||
context: {
|
||||
client: {
|
||||
clientName: "WEB",
|
||||
clientVersion: "2.20211129.09.00"
|
||||
}
|
||||
},
|
||||
videoId: videoID
|
||||
};
|
||||
const result = await axios.post(url, data, {
|
||||
timeout: 3500
|
||||
});
|
||||
if (result.status === 200) {
|
||||
return result.data.videoDetails;
|
||||
} else {
|
||||
return Promise.reject(result.status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayerData (videoID: string, ignoreCache = false): Promise<innerTubeVideoDetails> {
|
||||
if (!videoID || videoID.length !== 11 || videoID.includes(".")) {
|
||||
return Promise.reject("Invalid video ID");
|
||||
}
|
||||
|
||||
const cacheKey = `yt.itube.video.${videoID}`;
|
||||
if (!ignoreCache) { // try fetching from cache
|
||||
try {
|
||||
const data = await DiskCache.get(cacheKey);
|
||||
if (data) {
|
||||
Logger.debug(`InnerTube API: cache used for video information: ${videoID}`);
|
||||
return data as innerTubeVideoDetails;
|
||||
}
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const data = await getFromITube(videoID)
|
||||
.catch(err => {
|
||||
Logger.warn(`InnerTube API Error for ${videoID}: ${err}`);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
DiskCache.set(cacheKey, data)
|
||||
.then(() => Logger.debug(`InnerTube API: video information cache set for: ${videoID}`))
|
||||
.catch((err: any) => Logger.warn(err));
|
||||
return data;
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import redis from "../utils/redis";
|
||||
import { tempVIPKey } from "../utils/redisKeys";
|
||||
import { HashedUserID } from "../types/user.model";
|
||||
import { YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { APIVideoInfo } from "../types/youtubeApi.model";
|
||||
import { VideoID } from "../types/segments.model";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<APIVideoInfo> {
|
||||
return config.newLeafURLs ? YouTubeAPI.listVideos(videoID, ignoreCache) : null;
|
||||
}
|
||||
import { getVideoDetails } from "./getVideoDetails";
|
||||
|
||||
export const isUserTempVIP = async (hashedUserID: HashedUserID, videoID: VideoID): Promise<boolean> => {
|
||||
const apiVideoInfo = await getYouTubeVideoInfo(videoID);
|
||||
const channelID = apiVideoInfo?.data?.authorId;
|
||||
const apiVideoDetails = await getVideoDetails(videoID);
|
||||
const channelID = apiVideoDetails?.authorId;
|
||||
try {
|
||||
const reply = await redis.get(tempVIPKey(hashedUserID));
|
||||
return reply && reply == channelID;
|
||||
|
||||
@@ -2,5 +2,6 @@ import { db } from "../databases/databases";
|
||||
import { HashedUserID } from "../types/user.model";
|
||||
|
||||
export async function isUserVIP(userID: HashedUserID): Promise<boolean> {
|
||||
return (await db.prepare("get", `SELECT count(*) as "userCount" FROM "vipUsers" WHERE "userID" = ? LIMIT 1`, [userID])).userCount > 0;
|
||||
return (await db.prepare("get", `SELECT count(*) as "userCount" FROM "vipUsers" WHERE "userID" = ? LIMIT 1`,
|
||||
[userID]))?.userCount > 0;
|
||||
}
|
||||
|
||||
37
src/utils/permissions.ts
Normal file
37
src/utils/permissions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { config } from "../config";
|
||||
import { db } from "../databases/databases";
|
||||
import { Category } from "../types/segments.model";
|
||||
import { Feature, HashedUserID } from "../types/user.model";
|
||||
import { hasFeature } from "./features";
|
||||
import { isUserVIP } from "./isUserVIP";
|
||||
import { oneOf } from "./promise";
|
||||
import { getReputation } from "./reputation";
|
||||
|
||||
interface CanSubmitResult {
|
||||
canSubmit: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
async function lowDownvotes(userID: HashedUserID): Promise<boolean> {
|
||||
const result = await db.prepare("get", `SELECT count(*) as "submissionCount", SUM(CASE WHEN "votes" < 0 AND "views" > 5 THEN 1 ELSE 0 END) AS "downvotedSubmissions" FROM "sponsorTimes" WHERE "userID" = ?`
|
||||
, [userID], { useReplica: true });
|
||||
|
||||
return result.submissionCount > 100 && result.downvotedSubmissions / result.submissionCount < 0.15;
|
||||
}
|
||||
|
||||
export async function canSubmit(userID: HashedUserID, category: Category): Promise<CanSubmitResult> {
|
||||
switch (category) {
|
||||
case "chapter":
|
||||
return {
|
||||
canSubmit: await oneOf([isUserVIP(userID),
|
||||
lowDownvotes(userID),
|
||||
(async () => (await getReputation(userID)) > config.minReputationToSubmitChapter)(),
|
||||
hasFeature(userID, Feature.ChapterSubmitter)
|
||||
])
|
||||
};
|
||||
default:
|
||||
return {
|
||||
canSubmit: true
|
||||
};
|
||||
}
|
||||
}
|
||||
75
src/utils/promise.ts
Normal file
75
src/utils/promise.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Logger } from "./logger";
|
||||
|
||||
export class PromiseTimeoutError<T> extends Error {
|
||||
promise?: Promise<T>;
|
||||
|
||||
constructor(promise?: Promise<T>) {
|
||||
super("Promise timed out");
|
||||
|
||||
this.promise = promise;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromiseWithState<T> extends Promise<T> {
|
||||
isResolved: boolean;
|
||||
isRejected: boolean;
|
||||
}
|
||||
|
||||
export function promiseOrTimeout<T>(promise: Promise<T>, timeout?: number): Promise<T> {
|
||||
return Promise.race([timeoutPomise<T>(timeout), promise]);
|
||||
}
|
||||
|
||||
export function timeoutPomise<T>(timeout?: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
reject(new PromiseTimeoutError());
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function savePromiseState<T>(promise: Promise<T>): PromiseWithState<T> {
|
||||
const p = promise as PromiseWithState<T>;
|
||||
p.isResolved = false;
|
||||
p.isRejected = false;
|
||||
|
||||
p.then(() => {
|
||||
p.isResolved = true;
|
||||
}).catch(() => {
|
||||
p.isRejected = true;
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows rejection or resolve
|
||||
* Allows past resolves too, but not past rejections
|
||||
*/
|
||||
export function nextFulfilment<T>(promises: PromiseWithState<T>[]): Promise<T> {
|
||||
return Promise.race(promises.filter((p) => !p.isRejected));
|
||||
}
|
||||
|
||||
export function oneOf<T>(promises: Promise<T>[]): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let fulfilments = 0;
|
||||
for (const promise of promises) {
|
||||
promise.then((result) => {
|
||||
fulfilments++;
|
||||
|
||||
if (result || fulfilments === promises.length) {
|
||||
resolve(result);
|
||||
}
|
||||
}).catch((err) => {
|
||||
fulfilments++;
|
||||
|
||||
if (fulfilments === promises.length) {
|
||||
reject(err);
|
||||
} else {
|
||||
Logger.error(`oneOf ignore error (promise): ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import redis from "../utils/redis";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { skipSegmentsHashKey, skipSegmentsKey, reputationKey, ratingHashKey, skipSegmentGroupsKey } from "./redisKeys";
|
||||
import { skipSegmentsHashKey, skipSegmentsKey, reputationKey, ratingHashKey, skipSegmentGroupsKey, userFeatureKey } from "./redisKeys";
|
||||
import { Service, VideoID, VideoIDHash } from "../types/segments.model";
|
||||
import { UserID } from "../types/user.model";
|
||||
import { Feature, HashedUserID, UserID } from "../types/user.model";
|
||||
import { config } from "../config";
|
||||
|
||||
async function get<T>(fetchFromDB: () => Promise<T>, key: string): Promise<T> {
|
||||
try {
|
||||
@@ -16,7 +17,7 @@ async function get<T>(fetchFromDB: () => Promise<T>, key: string): Promise<T> {
|
||||
|
||||
const data = await fetchFromDB();
|
||||
|
||||
redis.set(key, JSON.stringify(data));
|
||||
redis.setEx(key, config.redis?.expiryTime, JSON.stringify(data)).catch((err) => Logger.error(err));
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -52,7 +53,7 @@ async function getAndSplit<T, U extends string>(fetchFromDB: (values: U[]) => Pr
|
||||
if (valuesToBeFetched.length > 0) {
|
||||
data = await fetchFromDB(valuesToBeFetched);
|
||||
|
||||
new Promise(() => {
|
||||
void new Promise(() => {
|
||||
const newResults: Record<string, T[]> = {};
|
||||
for (const item of data) {
|
||||
const splitValue = (item as unknown as Record<string, string>)[splitKey];
|
||||
@@ -67,7 +68,7 @@ async function getAndSplit<T, U extends string>(fetchFromDB: (values: U[]) => Pr
|
||||
}
|
||||
|
||||
for (const key in newResults) {
|
||||
redis.set(key, JSON.stringify(newResults[key]));
|
||||
redis.setEx(key, config.redis?.expiryTime, JSON.stringify(newResults[key])).catch((err) => Logger.error(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -77,22 +78,27 @@ async function getAndSplit<T, U extends string>(fetchFromDB: (values: U[]) => Pr
|
||||
|
||||
function clearSegmentCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID?: UserID; }): void {
|
||||
if (videoInfo) {
|
||||
redis.del(skipSegmentsKey(videoInfo.videoID, videoInfo.service));
|
||||
redis.del(skipSegmentGroupsKey(videoInfo.videoID, videoInfo.service));
|
||||
redis.del(skipSegmentsHashKey(videoInfo.hashedVideoID, videoInfo.service));
|
||||
if (videoInfo.userID) redis.del(reputationKey(videoInfo.userID));
|
||||
redis.del(skipSegmentsKey(videoInfo.videoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
redis.del(skipSegmentGroupsKey(videoInfo.videoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
redis.del(skipSegmentsHashKey(videoInfo.hashedVideoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
if (videoInfo.userID) redis.del(reputationKey(videoInfo.userID)).catch((err) => Logger.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
function clearRatingCache(videoInfo: { hashedVideoID: VideoIDHash; service: Service;}): void {
|
||||
if (videoInfo) {
|
||||
redis.del(ratingHashKey(videoInfo.hashedVideoID, videoInfo.service));
|
||||
redis.del(ratingHashKey(videoInfo.hashedVideoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
function clearFeatureCache(userID: HashedUserID, feature: Feature): void {
|
||||
redis.del(userFeatureKey(userID, feature)).catch((err) => Logger.error(err));
|
||||
}
|
||||
|
||||
export const QueryCacher = {
|
||||
get,
|
||||
getAndSplit,
|
||||
clearSegmentCache,
|
||||
clearRatingCache
|
||||
clearRatingCache,
|
||||
clearFeatureCache
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { config } from "../config";
|
||||
import { Logger } from "./logger";
|
||||
import { createClient } from "redis";
|
||||
import { RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply } from "@node-redis/client/dist/lib/commands";
|
||||
import { ClientCommandOptions } from "@node-redis/client/dist/lib/client";
|
||||
import { RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply } from "@redis/client/dist/lib/commands";
|
||||
import { RedisClientOptions } from "@redis/client/dist/lib/client";
|
||||
import { RedisReply } from "rate-limit-redis";
|
||||
|
||||
interface RedisSB {
|
||||
@@ -11,7 +11,7 @@ interface RedisSB {
|
||||
setEx(key: RedisCommandArgument, seconds: number, value: RedisCommandArgument): Promise<string>;
|
||||
del(...keys: [RedisCommandArgument]): Promise<number>;
|
||||
increment?(key: RedisCommandArgument): Promise<RedisCommandRawReply[]>;
|
||||
sendCommand(args: RedisCommandArguments, options?: ClientCommandOptions): Promise<RedisReply>;
|
||||
sendCommand(args: RedisCommandArguments, options?: RedisClientOptions): Promise<RedisReply>;
|
||||
quit(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,20 +28,19 @@ let exportClient: RedisSB = {
|
||||
if (config.redis?.enabled) {
|
||||
Logger.info("Connected to redis");
|
||||
const client = createClient(config.redis);
|
||||
client.connect();
|
||||
exportClient = client;
|
||||
void client.connect(); // void as we don't care about the promise
|
||||
exportClient = client as RedisSB;
|
||||
|
||||
const timeoutDuration = 200;
|
||||
const get = client.get.bind(client);
|
||||
exportClient.get = (key) => new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(), timeoutDuration);
|
||||
const timeout = config.redis.getTimeout ? setTimeout(() => reject(), config.redis.getTimeout) : null;
|
||||
get(key).then((reply) => {
|
||||
clearTimeout(timeout);
|
||||
if (timeout !== null) clearTimeout(timeout);
|
||||
resolve(reply);
|
||||
}).catch((err) => reject(err));
|
||||
});
|
||||
exportClient.increment = (key) => new Promise((resolve, reject) =>
|
||||
client.multi()
|
||||
void client.multi()
|
||||
.incr(key)
|
||||
.expire(key, 60)
|
||||
.exec()
|
||||
@@ -49,7 +48,10 @@ if (config.redis?.enabled) {
|
||||
.catch((err) => reject(err))
|
||||
);
|
||||
client.on("error", function(error) {
|
||||
Logger.error(error);
|
||||
Logger.error(`Redis Error: ${error}`);
|
||||
});
|
||||
client.on("reconnect", () => {
|
||||
Logger.info("Redis: trying to reconnect");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service, VideoID, VideoIDHash } from "../types/segments.model";
|
||||
import { HashedUserID, UserID } from "../types/user.model";
|
||||
import { Feature, HashedUserID, UserID } from "../types/user.model";
|
||||
import { HashedValue } from "../types/hash.model";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
@@ -46,4 +46,8 @@ export function videoLabelsHashKey(hashedVideoIDPrefix: VideoIDHash, service: Se
|
||||
if (hashedVideoIDPrefix.length !== 4) Logger.warn(`Redis skip segment hash-prefix key is not length 4! ${hashedVideoIDPrefix}`);
|
||||
|
||||
return `labels.v1.${service}.${hashedVideoIDPrefix}`;
|
||||
}
|
||||
|
||||
export function userFeatureKey (userID: HashedUserID, feature: Feature): string {
|
||||
return `user.${userID}.feature.${feature}`;
|
||||
}
|
||||
@@ -28,9 +28,9 @@ export async function getReputation(userID: UserID): Promise<number> {
|
||||
THEN 1 ELSE 0 END) AS "nonSelfDownvotedSubmissions",
|
||||
SUM(CASE WHEN "timeSubmitted" > 1596240000000 THEN "votes" ELSE 0 END) AS "votedSum",
|
||||
SUM(locked) AS "lockedSum",
|
||||
SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "actionType" != 'full' AND "votes" > 0 THEN 1 ELSE 0 END) AS "semiOldUpvotedSubmissions",
|
||||
SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "actionType" != 'full' AND "votes" > 0 THEN 1 ELSE 0 END) AS "oldUpvotedSubmissions",
|
||||
SUM(CASE WHEN "votes" > 0 AND "actionType" != 'full'
|
||||
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 "votes" > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT * FROM "sponsorTimes" as c
|
||||
WHERE (c."votes" > "a"."votes" OR c."locked" > "a"."locked") AND
|
||||
@@ -40,7 +40,7 @@ export async function getReputation(userID: UserID): Promise<number> {
|
||||
SELECT * FROM "lockCategories" as l
|
||||
WHERE l."videoID" = "a"."videoID" AND l."service" = "a"."service" 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<ReputationDBResult>;
|
||||
FROM "sponsorTimes" as "a" WHERE "userID" = ? AND "actionType" != 'full'`, [userID, weekAgo, pastDate, userID], { useReplica: true }) as Promise<ReputationDBResult>;
|
||||
|
||||
const result = await QueryCacher.get(fetchFromDB, reputationKey(userID));
|
||||
|
||||
@@ -55,19 +55,21 @@ function convertRange(value: number, currentMin: number, currentMax: number, tar
|
||||
}
|
||||
|
||||
export function calculateReputationFromMetrics(metrics: ReputationDBResult): number {
|
||||
if (!metrics) return 0;
|
||||
|
||||
// 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);
|
||||
if (downvoteRatio > 0.5) {
|
||||
return convertRange(Math.min(downvoteRatio, 0.7), 0.5, 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 (nonSelfDownvoteRatio > 0.3) {
|
||||
return convertRange(Math.min(nonSelfDownvoteRatio, 0.4), 0.3, 0.4, -0.5, -2.5);
|
||||
}
|
||||
|
||||
if (metrics.votedSum < 5) {
|
||||
|
||||
144
src/utils/tokenUtils.ts
Normal file
144
src/utils/tokenUtils.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import axios from "axios";
|
||||
import { config } from "../config";
|
||||
import { privateDB } from "../databases/databases";
|
||||
import { Logger } from "./logger";
|
||||
import FormData from "form-data";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
export enum TokenType {
|
||||
patreon = "patreon",
|
||||
local = "local",
|
||||
gumroad = "gumroad"
|
||||
}
|
||||
|
||||
export enum PatronStatus {
|
||||
active = "active_patron",
|
||||
declined = "declined_patron",
|
||||
former = "former_patron",
|
||||
}
|
||||
|
||||
export interface PatreonIdentityData {
|
||||
included: Array<{
|
||||
attributes: {
|
||||
currently_entitled_amount_cents: number,
|
||||
campaign_lifetime_support_cents: number,
|
||||
pledge_relationship_start: number,
|
||||
patron_status: PatronStatus,
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export async function createAndSaveToken(type: TokenType, code?: string): Promise<string> {
|
||||
switch(type) {
|
||||
case TokenType.patreon: {
|
||||
const domain = "https://www.patreon.com";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("code", code);
|
||||
formData.append("client_id", config.patreon.clientId);
|
||||
formData.append("client_secret", config.patreon.clientSecret);
|
||||
formData.append("grant_type", "authorization_code");
|
||||
formData.append("redirect_uri", config.patreon.redirectUri);
|
||||
|
||||
const result = await axios.request({
|
||||
url: `${domain}/api/oauth2/token`,
|
||||
data: formData,
|
||||
method: "POST",
|
||||
headers: formData.getHeaders()
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
const licenseKey = generateToken();
|
||||
const time = Date.now();
|
||||
|
||||
await privateDB.prepare("run", `INSERT INTO "licenseKeys"("licenseKey", "time", "type") VALUES(?, ?, ?)`, [licenseKey, time, type]);
|
||||
await privateDB.prepare("run", `INSERT INTO "oauthLicenseKeys"("licenseKey", "accessToken", "refreshToken", "expiresIn") VALUES(?, ?, ?, ?)`
|
||||
, [licenseKey, result.data.access_token, result.data.refresh_token, result.data.expires_in]);
|
||||
|
||||
|
||||
return licenseKey;
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error(`token creation: ${e}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case TokenType.local: {
|
||||
const licenseKey = generateToken();
|
||||
const time = Date.now();
|
||||
|
||||
await privateDB.prepare("run", `INSERT INTO "licenseKeys"("licenseKey", "time", "type") VALUES(?, ?, ?)`, [licenseKey, time, type]);
|
||||
|
||||
return licenseKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function refreshToken(type: TokenType, licenseKey: string, refreshToken: string): Promise<boolean> {
|
||||
switch(type) {
|
||||
case TokenType.patreon: {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("refreshToken", refreshToken);
|
||||
formData.append("client_id", config.patreon.clientId);
|
||||
formData.append("client_secret", config.patreon.clientSecret);
|
||||
formData.append("grant_type", "refresh_token");
|
||||
|
||||
const domain = "https://www.patreon.com";
|
||||
const result = await axios.request({
|
||||
url: `${domain}/api/oauth2/token`,
|
||||
data: formData,
|
||||
method: "POST",
|
||||
headers: formData.getHeaders()
|
||||
});
|
||||
|
||||
if (result.status === 200) {
|
||||
await privateDB.prepare("run", `UPDATE "oauthLicenseKeys" SET "accessToken" = ?, "refreshToken" = ?, "expiresIn" = ? WHERE "licenseKey" = ?`
|
||||
, [result.data.access_token, result.data.refresh_token, result.data.expires_in, licenseKey]);
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error(`token refresh: ${e}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function generateToken(length = 40): string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset[randomInt(charset.length)];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getPatreonIdentity(accessToken: string): Promise<PatreonIdentityData> {
|
||||
try {
|
||||
const identityRequest = await axios.get(`https://www.patreon.com/api/oauth2/v2/identity?include=memberships&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents,campaign_lifetime_support_cents,pledge_relationship_start`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (identityRequest.status === 200) {
|
||||
return identityRequest.data;
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error(`identity request: ${e}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class YouTubeAPI {
|
||||
|
||||
if (data) {
|
||||
Logger.debug(`YouTube API: cache used for video information: ${videoID}`);
|
||||
return { err: null, data: JSON.parse(data) };
|
||||
return { err: null, data: data as APIVideoData };
|
||||
}
|
||||
} catch (err) {
|
||||
return { err: err as string | boolean, data: null };
|
||||
@@ -38,9 +38,9 @@ export class YouTubeAPI {
|
||||
return { err: data.error, data: null };
|
||||
}
|
||||
const apiResult = data as APIVideoData;
|
||||
DiskCache.set(cacheKey, JSON.stringify(apiResult))
|
||||
.catch((err: any) => Logger.warn(err))
|
||||
.then(() => Logger.debug(`YouTube API: video information cache set for: ${videoID}`));
|
||||
DiskCache.set(cacheKey, apiResult)
|
||||
.then(() => Logger.debug(`YouTube API: video information cache set for: ${videoID}`))
|
||||
.catch((err: any) => Logger.warn(err));
|
||||
|
||||
return { err: false, data: apiResult };
|
||||
} else {
|
||||
@@ -52,6 +52,5 @@ export class YouTubeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaxResThumbnail(apiInfo: APIVideoData): string | void {
|
||||
return apiInfo?.videoThumbnails?.find((elem) => elem.quality === "maxres")?.second__originalUrl;
|
||||
}
|
||||
export const getMaxResThumbnail = (videoID: string): string =>
|
||||
`https://i.ytimg.com/vi/${videoID}/maxresdefault.jpg`;
|
||||
Reference in New Issue
Block a user