refactor: introduce ImdbId and TmdbId

This commit is contained in:
WebStreamr 2025-06-08 13:24:31 +00:00
parent 62f18ece85
commit 374896caca
No known key found for this signature in database
17 changed files with 177 additions and 122 deletions

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, ImdbId, parseImdbId } from '../utils';
import { Fetcher, ImdbId } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class CineHDPlus implements Handler {
return [];
}
const imdbId = parseImdbId(id);
const imdbId = ImdbId.fromString(id);
const seriesPageUrl = await this.fetchSeriesPageUrl(ctx, imdbId);
if (!seriesPageUrl) {

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { ImdbId, parseImdbId, Fetcher } from '../utils';
import { ImdbId, Fetcher } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class Eurostreaming implements Handler {
return [];
}
const imdbId = parseImdbId(id);
const imdbId = ImdbId.fromString(id);
const seriesPageUrl = await this.fetchSeriesPageUrl(ctx, imdbId);
if (!seriesPageUrl) {

View file

@ -1,6 +1,6 @@
import { ContentType } from 'stremio-addon-sdk';
import { Handler } from './types';
import { parseImdbId, Fetcher, getTmdbIdFromImdbId, TmdbId, parseTmdbId } from '../utils';
import { Fetcher, getTmdbIdFromImdbId, ImdbId, TmdbId } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -24,9 +24,9 @@ export class Frembed implements Handler {
readonly handle = async (ctx: Context, _type: string, id: string) => {
let tmdbId: TmdbId;
if (id.startsWith('tt')) {
tmdbId = await getTmdbIdFromImdbId(ctx, this.fetcher, parseImdbId(id));
tmdbId = await getTmdbIdFromImdbId(ctx, this.fetcher, ImdbId.fromString(id));
} else if (/^\d+:/.test(id)) {
tmdbId = parseTmdbId(id);
tmdbId = TmdbId.fromString(id);
} else {
return [];
}

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { Fetcher, ImdbId } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class FrenchCloud implements Handler {
return [];
}
const pageUrl = new URL(`https://frenchcloud.cam/movie/${parseImdbId(id).id}`);
const pageUrl = new URL(`https://frenchcloud.cam/movie/${ImdbId.fromString(id).id}`);
const html = await this.fetcher.text(ctx, pageUrl);
const $ = cheerio.load(html);

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { ImdbId, parseImdbId, Fetcher } from '../utils';
import { ImdbId, Fetcher } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class KinoKiste implements Handler {
return [];
}
const imdbId = parseImdbId(id);
const imdbId = ImdbId.fromString(id);
const seriesPageUrl = await this.fetchSeriesPageUrl(ctx, imdbId);
if (!seriesPageUrl) {

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { Fetcher, ImdbId } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class MeineCloud implements Handler {
return [];
}
const pageUrl = new URL(`https://meinecloud.click/movie/${parseImdbId(id).id}`);
const pageUrl = new URL(`https://meinecloud.click/movie/${ImdbId.fromString(id).id}`);
const html = await this.fetcher.text(ctx, pageUrl);
const $ = cheerio.load(html);

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { Fetcher, ImdbId } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class MostraGuarda implements Handler {
return [];
}
const pageUrl = new URL(`https://mostraguarda.stream/movie/${parseImdbId(id).id}`);
const pageUrl = new URL(`https://mostraguarda.stream/movie/${ImdbId.fromString(id).id}`);
const html = await this.fetcher.text(ctx, pageUrl);
const $ = cheerio.load(html);

View file

@ -6,8 +6,7 @@ import {
getTmdbIdFromImdbId,
getTmdbMovieDetails,
getTmdbTvDetails,
parseImdbId,
parseTmdbId,
ImdbId,
TmdbId,
} from '../utils';
import { ExtractorRegistry } from '../extractor';
@ -35,9 +34,9 @@ export class Soaper implements Handler {
readonly handle = async (ctx: Context, _type: string, id: string) => {
let tmdbId: TmdbId;
if (id.startsWith('tt')) {
tmdbId = await getTmdbIdFromImdbId(ctx, this.fetcher, parseImdbId(id));
tmdbId = await getTmdbIdFromImdbId(ctx, this.fetcher, ImdbId.fromString(id));
} else if (/^\d+:/.test(id)) {
tmdbId = parseTmdbId(id);
tmdbId = TmdbId.fromString(id);
} else {
return [];
}

View file

@ -1,7 +1,7 @@
import { ContentType } from 'stremio-addon-sdk';
import * as cheerio from 'cheerio';
import { Handler } from './types';
import { Fetcher, parseImdbId } from '../utils';
import { Fetcher, ImdbId } from '../utils';
import { ExtractorRegistry } from '../extractor';
import { Context, CountryCode } from '../types';
@ -27,7 +27,7 @@ export class VerHdLink implements Handler {
return [];
}
const pageUrl = new URL(`https://verhdlink.cam/movie/${parseImdbId(id).id}`);
const pageUrl = new URL(`https://verhdlink.cam/movie/${ImdbId.fromString(id).id}`);
const html = await this.fetcher.text(ctx, pageUrl);
const $ = cheerio.load(html);

View file

@ -1,8 +1,8 @@
import { parseImdbId } from './imdb';
import { ImdbId } from './ImdbId';
describe('imdb id parsing', () => {
describe('can be created from string', () => {
test('splits id properly', () => {
const { id, series, episode } = parseImdbId('tt2085059:2:4');
const { id, series, episode } = ImdbId.fromString('tt2085059:2:4');
expect(id).toBe('tt2085059');
expect(series).toBe(2);
@ -10,7 +10,7 @@ describe('imdb id parsing', () => {
});
test('handles weird 0 prefixes in series and episode', () => {
const { id, series, episode } = parseImdbId('tt2085059:02:04');
const { id, series, episode } = ImdbId.fromString('tt2085059:02:04');
expect(id).toBe('tt2085059');
expect(series).toBe(2);
@ -18,7 +18,7 @@ describe('imdb id parsing', () => {
});
test('supports movie with missing series and episode', () => {
const { id, series, episode } = parseImdbId('tt2085059');
const { id, series, episode } = ImdbId.fromString('tt2085059');
expect(id).toBe('tt2085059');
expect(series).toBeUndefined();
@ -27,13 +27,13 @@ describe('imdb id parsing', () => {
test('throws for empty ids', () => {
expect(() => {
parseImdbId('');
ImdbId.fromString('');
}).toThrow('IMDb ID "" is invalid');
});
test('throws for invalid ids', () => {
expect(() => {
parseImdbId('foo');
ImdbId.fromString('foo');
}).toThrow('IMDb ID "foo" is invalid');
});
});

25
src/utils/ImdbId.ts Normal file
View file

@ -0,0 +1,25 @@
export class ImdbId {
public readonly id: string;
public readonly series: number | undefined;
public readonly episode: number | undefined;
constructor(id: string, series: number | undefined, episode: number | undefined) {
this.id = id;
this.series = series;
this.episode = episode;
}
public static fromString(id: string): ImdbId {
const idParts = id.split(':');
if (!idParts[0] || !/^tt\d+$/.test(idParts[0])) {
throw new Error(`IMDb ID "${id}" is invalid`);
}
return new ImdbId(
idParts[0],
idParts[1] ? parseInt(idParts[1]) : undefined,
idParts[2] ? parseInt(idParts[2]) : undefined,
);
}
}

39
src/utils/TmdbId.test.ts Normal file
View file

@ -0,0 +1,39 @@
import { TmdbId } from './TmdbId';
describe('can be created from string', () => {
test('splits id properly', () => {
const { id, series, episode } = TmdbId.fromString('2085059:2:4');
expect(id).toBe(2085059);
expect(series).toBe(2);
expect(episode).toBe(4);
});
test('handles weird 0 prefixes in series and episode', () => {
const { id, series, episode } = TmdbId.fromString('2085059:02:04');
expect(id).toBe(2085059);
expect(series).toBe(2);
expect(episode).toBe(4);
});
test('supports movie with missing series and episode', () => {
const { id, series, episode } = TmdbId.fromString('2085059');
expect(id).toBe(2085059);
expect(series).toBeUndefined();
expect(episode).toBeUndefined();
});
test('throws for empty ids', () => {
expect(() => {
TmdbId.fromString('');
}).toThrow('TMDB ID "" is invalid');
});
test('throws for invalid ids', () => {
expect(() => {
TmdbId.fromString('foo');
}).toThrow('TMDB ID "foo" is invalid');
});
});

25
src/utils/TmdbId.ts Normal file
View file

@ -0,0 +1,25 @@
export class TmdbId {
public readonly id: number;
public readonly series: number | undefined;
public readonly episode: number | undefined;
constructor(id: number, series: number | undefined, episode: number | undefined) {
this.id = id;
this.series = series;
this.episode = episode;
}
public static fromString(id: string): TmdbId {
const idParts = id.split(':');
if (!idParts[0] || !/^\d+$/.test(idParts[0])) {
throw new Error(`TMDB ID "${id}" is invalid`);
}
return new TmdbId(
parseInt(idParts[0]),
idParts[1] ? parseInt(idParts[1]) : undefined,
idParts[2] ? parseInt(idParts[2]) : undefined,
);
}
}

View file

@ -1,15 +0,0 @@
export interface ImdbId { id: string; series: number | undefined; episode: number | undefined }
export const parseImdbId = (id: string): ImdbId => {
const idParts = id.split(':');
if (!idParts[0] || !/^tt\d+$/.test(idParts[0])) {
throw new Error(`IMDb ID "${id}" is invalid`);
}
return {
id: idParts[0],
series: idParts[1] ? parseInt(idParts[1]) : undefined,
episode: idParts[2] ? parseInt(idParts[2]) : undefined,
};
};

View file

@ -2,7 +2,8 @@ export * from './env';
export * from './Fetcher';
export * from './StreamResolver';
export * from './embed';
export * from './imdb';
export * from './ImdbId';
export * from './language';
export * from './manifest';
export * from './tmdb';
export * from './TmdbId';

View file

@ -1,86 +1,82 @@
import { getImdbIdFromTmdbId, getTmdbIdFromImdbId, parseTmdbId } from './tmdb';
import { getImdbIdFromTmdbId, getTmdbIdFromImdbId } from './tmdb';
import { Fetcher } from './Fetcher';
import { Context } from '../types';
import { ImdbId } from './ImdbId';
import { TmdbId } from './TmdbId';
jest.mock('../utils/Fetcher');
// @ts-expect-error No constructor args needed
const fetcher = new Fetcher();
const ctx: Context = { id: 'id', ip: '127.0.0.1', config: { de: 'on' } };
describe('tmdb id parsing', () => {
test('splits id properly', () => {
const { id, series, episode } = parseTmdbId('2085059:2:4');
expect(id).toBe(2085059);
expect(series).toBe(2);
expect(episode).toBe(4);
});
test('handles weird 0 prefixes in series and episode', () => {
const { id, series, episode } = parseTmdbId('2085059:02:04');
expect(id).toBe(2085059);
expect(series).toBe(2);
expect(episode).toBe(4);
});
test('supports movie with missing series and episode', () => {
const { id, series, episode } = parseTmdbId('2085059');
expect(id).toBe(2085059);
expect(series).toBeUndefined();
expect(episode).toBeUndefined();
});
test('throws for empty ids', () => {
expect(() => {
parseTmdbId('');
}).toThrow('TMDB ID "" is invalid');
});
test('throws for invalid ids', () => {
expect(() => {
parseTmdbId('foo');
}).toThrow('TMDB ID "foo" is invalid');
});
});
describe('getTmdbIdFromImdbId', () => {
test('series', async () => {
expect(await getTmdbIdFromImdbId(ctx, fetcher, { id: 'tt2085059', series: 2, episode: 4 }))
.toStrictEqual({ id: 42009, series: 2, episode: 4 });
expect(await getTmdbIdFromImdbId(ctx, fetcher, { id: 'tt2085059', series: 2, episode: 4 }))
.toStrictEqual({ id: 42009, series: 2, episode: 4 }); // from cache
const tmdbId1 = await getTmdbIdFromImdbId(ctx, fetcher, new ImdbId('tt2085059', 2, 4));
expect(tmdbId1).toBeInstanceOf(TmdbId);
expect(tmdbId1).toHaveProperty('id', 42009);
expect(tmdbId1).toHaveProperty('series', 2);
expect(tmdbId1).toHaveProperty('episode', 4);
// from cache
const tmdbId2 = await getTmdbIdFromImdbId(ctx, fetcher, new ImdbId('tt2085059', 2, 4));
expect(tmdbId2).toBeInstanceOf(TmdbId);
expect(tmdbId2).toHaveProperty('id', 42009);
expect(tmdbId2).toHaveProperty('series', 2);
expect(tmdbId2).toHaveProperty('episode', 4);
});
test('movie', async () => {
expect(await getTmdbIdFromImdbId(ctx, fetcher, { id: 'tt29141112', series: undefined, episode: undefined }))
.toStrictEqual({ id: 931944, series: undefined, episode: undefined });
expect(await getTmdbIdFromImdbId(ctx, fetcher, { id: 'tt29141112', series: undefined, episode: undefined }))
.toStrictEqual({ id: 931944, series: undefined, episode: undefined }); // from cache
const tmdbId1 = await getTmdbIdFromImdbId(ctx, fetcher, new ImdbId('tt29141112', undefined, undefined));
expect(tmdbId1).toBeInstanceOf(TmdbId);
expect(tmdbId1).toHaveProperty('id', 931944);
expect(tmdbId1).toHaveProperty('series', undefined);
expect(tmdbId1).toHaveProperty('episode', undefined);
// from cache
const tmdbId2 = await getTmdbIdFromImdbId(ctx, fetcher, new ImdbId('tt29141112', undefined, undefined));
expect(tmdbId2).toBeInstanceOf(TmdbId);
expect(tmdbId2).toHaveProperty('id', 931944);
expect(tmdbId2).toHaveProperty('series', undefined);
expect(tmdbId2).toHaveProperty('episode', undefined);
});
test('throws with invalid imdb id', async () => {
await expect(getTmdbIdFromImdbId(ctx, fetcher, { id: 'tt12345678', series: 1, episode: 1 })).rejects.toThrow('Could not get TMDB ID of IMDb ID "tt12345678"');
await expect(getTmdbIdFromImdbId(ctx, fetcher, new ImdbId('tt12345678', 1, 1))).rejects.toThrow('Could not get TMDB ID of IMDb ID "tt12345678"');
});
});
describe('getImdbIdFromTmdbId', () => {
test('series', async () => {
expect(await getImdbIdFromTmdbId(ctx, fetcher, { id: 42009, series: 2, episode: 4 }))
.toStrictEqual({ id: 'tt2085059', series: 2, episode: 4 });
expect(await getImdbIdFromTmdbId(ctx, fetcher, { id: 42009, series: 2, episode: 4 }))
.toStrictEqual({ id: 'tt2085059', series: 2, episode: 4 }); // from cache
const imdbId1 = await getImdbIdFromTmdbId(ctx, fetcher, new TmdbId(42009, 2, 4));
expect(imdbId1).toBeInstanceOf(ImdbId);
expect(imdbId1).toHaveProperty('id', 'tt2085059');
expect(imdbId1).toHaveProperty('series', 2);
expect(imdbId1).toHaveProperty('episode', 4);
// from cache
const imdbId2 = await getImdbIdFromTmdbId(ctx, fetcher, new TmdbId(42009, 2, 4));
expect(imdbId2).toBeInstanceOf(ImdbId);
expect(imdbId2).toHaveProperty('id', 'tt2085059');
expect(imdbId2).toHaveProperty('series', 2);
expect(imdbId2).toHaveProperty('episode', 4);
});
test('movie', async () => {
expect(await getImdbIdFromTmdbId(ctx, fetcher, { id: 931944, series: undefined, episode: undefined }))
.toStrictEqual({ id: 'tt29141112', series: undefined, episode: undefined });
expect(await getImdbIdFromTmdbId(ctx, fetcher, { id: 931944, series: undefined, episode: undefined }))
.toStrictEqual({ id: 'tt29141112', series: undefined, episode: undefined }); // from cache
const imdbId1 = await getImdbIdFromTmdbId(ctx, fetcher, new TmdbId(931944, undefined, undefined));
expect(imdbId1).toBeInstanceOf(ImdbId);
expect(imdbId1).toHaveProperty('id', 'tt29141112');
expect(imdbId1).toHaveProperty('series', undefined);
expect(imdbId1).toHaveProperty('episode', undefined);
// from cache
const imdbId2 = await getImdbIdFromTmdbId(ctx, fetcher, new TmdbId(931944, undefined, undefined));
expect(imdbId2).toBeInstanceOf(ImdbId);
expect(imdbId2).toHaveProperty('id', 'tt29141112');
expect(imdbId2).toHaveProperty('series', undefined);
expect(imdbId2).toHaveProperty('episode', undefined);
});
test('throws with invalid tmdb id', async () => {
await expect(getImdbIdFromTmdbId(ctx, fetcher, { id: 12345678, series: 1, episode: 1 })).rejects.toThrow(Error);
await expect(getImdbIdFromTmdbId(ctx, fetcher, new TmdbId(12345678, 1, 1))).rejects.toThrow(Error);
});
});

View file

@ -1,10 +1,9 @@
import { ImdbId } from './imdb';
import { ImdbId } from './ImdbId';
import { TmdbId } from './TmdbId';
import { Context } from '../types';
import { Fetcher } from './Fetcher';
import { envGet } from './env';
export interface TmdbId { id: number; series: number | undefined; episode: number | undefined }
interface FindResponsePartial {
movie_results: {
id: number;
@ -28,20 +27,6 @@ interface TvDetailsResponsePartial {
name: string;
}
export const parseTmdbId = (id: string): TmdbId => {
const idParts = id.split(':');
if (!idParts[0] || !/^\d+$/.test(idParts[0])) {
throw new Error(`TMDB ID "${id}" is invalid`);
}
return {
id: parseInt(idParts[0]),
series: idParts[1] ? parseInt(idParts[1]) : undefined,
episode: idParts[2] ? parseInt(idParts[2]) : undefined,
};
};
export const tmdbFetch = async (ctx: Context, fetcher: Fetcher, path: string): Promise<unknown> => {
const config = { 'headers': { Authorization: 'Bearer ' + envGet('TMDB_ACCESS_TOKEN') }, 'Content-Type': 'application/json' };
@ -51,7 +36,7 @@ export const tmdbFetch = async (ctx: Context, fetcher: Fetcher, path: string): P
const imdbTmdbMap = new Map<string, number>();
export const getTmdbIdFromImdbId = async (ctx: Context, fetcher: Fetcher, imdbId: ImdbId): Promise<TmdbId> => {
if (imdbTmdbMap.has(imdbId.id)) {
return { id: imdbTmdbMap.get(imdbId.id) as number, series: imdbId.series, episode: imdbId.episode };
return new TmdbId(imdbTmdbMap.get(imdbId.id) as number, imdbId.series, imdbId.episode);
}
const response = await tmdbFetch(ctx, fetcher, `/find/${imdbId.id}?external_source=imdb_id`) as FindResponsePartial;
@ -63,13 +48,13 @@ export const getTmdbIdFromImdbId = async (ctx: Context, fetcher: Fetcher, imdbId
}
imdbTmdbMap.set(imdbId.id, id);
return { id, series: imdbId.series, episode: imdbId.episode };
return new TmdbId(id, imdbId.series, imdbId.episode);
};
const tmdbImdbMap = new Map<number, string>();
export const getImdbIdFromTmdbId = async (ctx: Context, fetcher: Fetcher, tmdbId: TmdbId): Promise<ImdbId> => {
if (tmdbImdbMap.has(tmdbId.id)) {
return { id: tmdbImdbMap.get(tmdbId.id) as string, series: tmdbId.series, episode: tmdbId.episode };
return new ImdbId(tmdbImdbMap.get(tmdbId.id) as string, tmdbId.series, tmdbId.episode);
}
const type = tmdbId.series ? 'tv' : 'movie';
@ -77,7 +62,7 @@ export const getImdbIdFromTmdbId = async (ctx: Context, fetcher: Fetcher, tmdbId
const response = await tmdbFetch(ctx, fetcher, `/${type}/${tmdbId.id}/external_ids`) as ExternalIdsResponsePartial;
tmdbImdbMap.set(tmdbId.id, response.imdb_id);
return { id: response.imdb_id, series: tmdbId.series, episode: tmdbId.episode };
return new ImdbId(response.imdb_id, tmdbId.series, tmdbId.episode);
};
export const getTmdbMovieDetails = async (ctx: Context, fetcher: Fetcher, tmdbId: TmdbId): Promise<MovieDetailsResponsePartial> => {