Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/core/src/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ const CatalogModification = z.object({
disableSearch: z.boolean().optional(), // disable the search for the catalog
onlyOnSearch: z.boolean().optional(), // only show the catalog on search results - mutually exclusive with onlyOnDiscover, only available when the catalog has a non-required search extra
enabled: z.boolean().optional(), // enable or disable the catalog
rpdb: z.boolean().optional(), // use rpdb for posters if supported
usePosterService: z.boolean().optional(), // use rpdb or top poster for posters if supported
overrideType: z.string().min(1).optional(), // override the type of the catalog
hideable: z.boolean().optional(), // hide the catalog from the home page
searchable: z.boolean().optional(), // property of whether the catalog is searchable (not a search only catalog)
Expand Down Expand Up @@ -476,7 +476,10 @@ export const UserDataSchema = z.object({
uncachedAnime: z.array(SortCriterion).optional(),
}),
rpdbApiKey: z.string().optional(),
rpdbUseRedirectApi: z.boolean().optional(),
// rpdbUseRedirectApi: z.boolean().optional(),
topPosterApiKey: z.string().optional(),
posterService: z.enum(['rpdb', 'top-poster']).optional(),
usePosterRedirectApi: z.boolean().optional(),
formatter: Formatter,
proxy: StreamProxyConfig.optional(),
resultLimits: ResultLimitOptions.optional(),
Expand Down Expand Up @@ -1114,6 +1117,11 @@ export const RPDBIsValidResponse = z.object({
});
export type RPDBIsValidResponse = z.infer<typeof RPDBIsValidResponse>;

export const TopPosterIsValidResponse = z.object({
valid: z.boolean(),
});
export type TopPosterIsValidResponse = z.infer<typeof TopPosterIsValidResponse>;

export const TemplateSchema = z.object({
metadata: z.object({
id: z
Expand Down
57 changes: 39 additions & 18 deletions packages/core/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
Subtitle,
} from './db/schemas.js';
import { createProxy } from './proxy/index.js';
import { TopPoster } from './utils/top-poster.js';
import { RPDB } from './utils/rpdb.js';
import { FeatureControl } from './utils/feature.js';
import Proxifier from './streams/proxifier.js';
Expand Down Expand Up @@ -459,9 +460,13 @@ export class AIOStreams {
catalog = catalog.reverse();
}

// Apply poster modifications (RPDB only if modification has rpdb enabled)
const applyRpdb = modification?.rpdb === true;
catalog = await this.applyPosterModifications(catalog, type, applyRpdb);
// Apply poster modifications (usePosterService only if modification has usePosterService enabled)
const applyPosterService = modification?.usePosterService === true;
catalog = await this.applyPosterModifications(
catalog,
type,
applyPosterService
);

return catalog;
}
Expand Down Expand Up @@ -915,37 +920,53 @@ export class AIOStreams {
private async applyPosterModifications(
items: MetaPreview[],
type: string,
applyRpdb: boolean = true
applyPosterService: boolean = true
): Promise<MetaPreview[]> {
const rpdbApiKey = applyRpdb ? this.userData.rpdbApiKey : undefined;
const rpdbApi = rpdbApiKey ? new RPDB(rpdbApiKey) : undefined;
const posterService = applyPosterService
? this.userData.posterService ||
(this.userData.rpdbApiKey ? 'rpdb' : undefined)
: undefined;
const posterApiKey =
posterService === 'rpdb'
? this.userData.rpdbApiKey
: posterService === 'top-poster'
? this.userData.topPosterApiKey
: undefined;
const posterApi = posterApiKey
? posterService === 'rpdb'
? new RPDB(posterApiKey)
: posterService === 'top-poster'
? new TopPoster(posterApiKey)
: undefined
: undefined;

return Promise.all(
items.map(async (item) => {
if (rpdbApiKey && item.poster) {
if (posterApi && item.poster) {
let posterUrl = item.poster;
if (posterUrl.includes('api.ratingposterdb.com')) {
// already a RPDB poster
} else if (
this.userData.rpdbUseRedirectApi !== false &&
Env.BASE_URL
if (
posterUrl.includes('api.ratingposterdb.com') ||
posterUrl.includes('api.top-streaming.stream')
) {
// already a poster from a poster service, do nothing.
} else if (this.userData.usePosterRedirectApi) {
const itemId = (item as any).imdb_id || item.id;
const url = new URL(Env.BASE_URL);
url.pathname = '/api/v1/rpdb';
url.pathname =
posterService === 'rpdb' ? '/api/v1/rpdb' : '/api/v1/top-poster';
url.searchParams.set('id', itemId);
url.searchParams.set('type', type);
url.searchParams.set('fallback', item.poster);
url.searchParams.set('apiKey', rpdbApiKey);
url.searchParams.set('apiKey', posterApiKey!);
posterUrl = url.toString();
} else if (rpdbApi) {
const rpdbPosterUrl = await rpdbApi.getPosterUrl(
} else if (posterApi) {
const servicePosterUrl = await posterApi.getPosterUrl(
type,
(item as any).imdb_id || item.id,
false
);
if (rpdbPosterUrl) {
posterUrl = rpdbPosterUrl;
if (servicePosterUrl) {
posterUrl = servicePosterUrl;
}
}
item.poster = posterUrl;
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,25 @@ export function applyMigrations(config: any): UserData {
}));
}

// migrate rpdbUseRedirectApi to usePosterRedirectApi
if (
config.rpdbUseRedirectApi !== undefined &&
config.usePosterRedirectApi === undefined
) {
config.usePosterRedirectApi = config.rpdbUseRedirectApi;
delete config.rpdbUseRedirectApi;
}

// migrate 'rpdb' to 'usePosterService' in all catalog modifications
if (Array.isArray(config.catalogModifications)) {
for (const mod of config.catalogModifications) {
if (mod.usePosterService === undefined && mod.rpdb === true) {
mod.usePosterService = true;
}
delete mod.rpdb;
}
}

return config;
}

Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,11 @@ const SERVICE_DETAILS: Record<
};

const TOP_LEVEL_OPTION_DETAILS: Record<
'tmdbApiKey' | 'tmdbAccessToken' | 'rpdbApiKey' | 'tvdbApiKey',
| 'tmdbApiKey'
| 'tmdbAccessToken'
| 'rpdbApiKey'
| 'tvdbApiKey'
| 'topPosterApiKey',
{
name: string;
description: string;
Expand All @@ -736,6 +740,11 @@ const TOP_LEVEL_OPTION_DETAILS: Record<
description:
'Get your free API key from [here](https://ratingposterdb.com/api-key/) for posters with ratings.',
},
topPosterApiKey: {
name: 'Top Poster API Key',
description:
'Get your free API key from [here](https://api.top-streaming.stream/user/register) for posters with ratings.',
},
tvdbApiKey: {
name: 'TVDB API Key',
description:
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './dsu.js';
export * from './startup.js';
export * from './extras.js';
export * from './rpdb.js';
export * from './top-poster.js';
export * from './distributed-lock.js';
export * from './id-parser.js';
export * from './anime-database.js';
Expand Down
162 changes: 162 additions & 0 deletions packages/core/src/utils/top-poster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Cache } from './cache.js';
import { makeRequest } from './http.js';
import { TopPosterIsValidResponse } from '../db/schemas.js';
import { Env } from './env.js';
import { IdParser } from './id-parser.js';
import { AnimeDatabase } from './anime-database.js';

const apiKeyValidationCache = Cache.getInstance<string, boolean>(
'topPosterApiKey'
);
const posterCheckCache = Cache.getInstance<string, string>('topPosterCheck');

export class TopPoster {
private readonly apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey.trim();
if (!this.apiKey) {
throw new Error('Top Poster API key is not set');
}
}

public async validateApiKey(): Promise<boolean> {
const cached = await apiKeyValidationCache.get(this.apiKey);
if (cached !== undefined) {
return cached;
}

let response;
try {
response = await makeRequest(
`https://api.top-streaming.stream/auth/verify/${this.apiKey}`,
{
timeout: 10000,
ignoreRecursion: true,
}
);
} catch (error: any) {
// Differentiate network errors from API errors
throw new Error(`Failed to connect to Top Poster API: ${error.message}`);
}

if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid Top Poster API key');
} else if (response.status === 429) {
throw new Error('Top Poster API rate limit exceeded');
} else {
throw new Error(
`Top Poster API returned an unexpected status: ${response.status} - ${response.statusText}`
);
}
}

let data;
try {
data = TopPosterIsValidResponse.parse(await response.json());
} catch (error: any) {
throw new Error(
`Top Poster API returned malformed JSON: ${error.message}`
);
}

if (!data.valid) {
throw new Error('Invalid Top Poster API key');
}

apiKeyValidationCache.set(
this.apiKey,
data.valid,
Env.RPDB_API_KEY_VALIDITY_CACHE_TTL
);
return data.valid;
}
/**
*
* @param id - the id of the item to get the poster for, if it is of a supported type, the top poster will be returned, otherwise null
*/
private parseId(
type: string,
id: string
): { idType: 'tmdb' | 'imdb' | 'tvdb'; idValue: string } | null {
const parsedId = IdParser.parse(id, type);
if (!parsedId) return null;

let idType: 'tmdb' | 'imdb' | 'tvdb' | null = null;
let idValue: string | null = null;

switch (parsedId.type) {
case 'themoviedbId':
idType = 'tmdb';
idValue = `${type}-${parsedId.value}`;
break;
case 'imdbId':
idType = 'imdb';
idValue = parsedId.value.toString();
break;
case 'thetvdbId':
if (type === 'movie') return null; // tvdb not supported for movies
idType = 'tvdb';
idValue = parsedId.value.toString();
break;
default: {
// Try to map unsupported id types
const entry = AnimeDatabase.getInstance().getEntryById(
parsedId.type,
parsedId.value
);
if (!entry) return null;

if (entry.mappings?.thetvdbId && type === 'series') {
idType = 'tvdb';
idValue = `${entry.mappings.thetvdbId}`;
} else if (entry.mappings?.themoviedbId) {
idType = 'tmdb';
idValue = `${type}-${entry.mappings.themoviedbId}`;
} else if (entry.mappings?.imdbId) {
idType = 'imdb';
idValue = entry.mappings.imdbId.toString();
} else {
return null;
}
break;
}
}
if (!idType || !idValue) return null;
return { idType, idValue };
}
public async getPosterUrl(
type: string,
id: string,
checkExists: boolean = true
): Promise<string | null> {
const parsed = this.parseId(type, id);
if (!parsed) return null;
const { idType, idValue } = parsed;

const cacheKey = `${type}-${id}-${this.apiKey}`;
const cached = await posterCheckCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const posterUrl = `https://api.top-streaming.stream/${this.apiKey}/${idType}/poster-default/${idValue}.jpg?fallback=true`;
if (!checkExists) {
return posterUrl;
}
try {
const response = await makeRequest(posterUrl, {
method: 'HEAD',
timeout: 3000,
ignoreRecursion: true,
});
if (!response.ok) {
return null;
}
} catch (error) {
return null;
}
posterCheckCache.set(cacheKey, posterUrl, 24 * 60 * 60);
return posterUrl;
}
}
Loading