Merge branch 'master' of https://github.com/ajayyy/SponsorBlockServer into fullVideoLabels

This commit is contained in:
Michael C
2022-09-30 19:21:54 -04:00
108 changed files with 5844 additions and 4299 deletions

View File

@@ -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
View 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));
}

View File

@@ -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;
}

View 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
View 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);
}
}

View File

@@ -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;

View File

@@ -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
View 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
View 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}`);
}
});
}
});
}

View File

@@ -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
};

View File

@@ -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");
});
}

View File

@@ -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}`;
}

View File

@@ -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
View 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;
}

View File

@@ -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`;