diff --git a/src/addon.ts b/src/addon.ts index eb8fd87..ec8a67e 100644 --- a/src/addon.ts +++ b/src/addon.ts @@ -1,12 +1,17 @@ import { ContentType, Manifest, addonBuilder } from 'stremio-addon-sdk'; -import { Handler, HandlerStream, handleKinoKiste, handleMeineCloud } from './handler'; -import { fulfillAllPromises, logInfo } from './utils'; +import { Handler, HandlerStream, KinoKiste, MeineCloud } from './handler'; +import { fulfillAllPromises, iso2ToFlag, logInfo } from './utils'; + +const handlers: Handler[] = [ + new KinoKiste(), + new MeineCloud(), +]; const manifest: Manifest = { id: process.env['MANIFEST_ID'] || '', version: '0.0.1', name: process.env['MANIFEST_NAME'] || '', - description: 'Provides HTTP URLs from streaming websites. Currently supports KinoKiste (DE) and MeineCloud (DE).', + description: `Provides HTTP URLs from streaming websites.`, resources: [ 'stream', ], @@ -21,28 +26,21 @@ const manifest: Manifest = { configurable: true, configurationRequired: true, }, - config: [ - { - key: 'de', - type: 'checkbox', - title: '🇩🇪 DE | KinoKiste, MeineCloud', - }, - ], + config: [], }; + +handlers.forEach((handler) => { + manifest.config?.push({ + key: handler.id, + type: 'checkbox', + title: `${handler.languages.map(language => iso2ToFlag(language) + ' ' + language.toUpperCase()).join(', ')} | ${handler.label}`, + }); +}); + const builder = new addonBuilder(manifest); type RequestConfig = Record; -const collectHandlers = (config: RequestConfig): Handler[] => { - const handlers: Handler[] = []; - - if ('de' in config) { - handlers.push(handleKinoKiste, handleMeineCloud); - } - - return handlers; -}; - const sortStreams = (streams: HandlerStream[]): void => { streams.sort((a, b) => { const resolutionComparison = parseInt(b.resolution) - parseInt(a.resolution); @@ -57,8 +55,8 @@ const sortStreams = (streams: HandlerStream[]): void => { builder.defineStreamHandler(async (args: { type: ContentType; id: string; config?: RequestConfig }) => { logInfo(`Search stream for type "${args.type}" and id "${args.id}"`); - const handlers = collectHandlers(args.config || {}); - if (handlers.length === 0) { + const selectedHandlers = handlers.filter(handler => handler.id in (args.config || {})); + if (selectedHandlers.length === 0) { logInfo('No handlers configured, bail out'); return { @@ -71,9 +69,13 @@ builder.defineStreamHandler(async (args: { type: ContentType; id: string; config } const streams: HandlerStream[] = []; - const handlerPromises = handlers.map(async (handler) => { - const handlerStreams = await handler(args); - logInfo(`${handler.name} returned ${handlerStreams.length} streams`); + const handlerPromises = selectedHandlers.map(async (handler) => { + if (!handler.contentTypes.includes(args.type)) { + return; + } + + const handlerStreams = await handler.handle(args.id); + logInfo(`${handler.id} returned ${handlerStreams.length} streams`); streams.push(...handlerStreams); }); diff --git a/src/handler/kinokiste.test.ts b/src/handler/KinoKiste.test.ts similarity index 74% rename from src/handler/kinokiste.test.ts rename to src/handler/KinoKiste.test.ts index de4e7b6..0a073b5 100644 --- a/src/handler/kinokiste.test.ts +++ b/src/handler/KinoKiste.test.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import slugify from 'slugify'; +import { KinoKiste } from './KinoKiste'; import { cachedFetchText } from '../utils'; -import { handleKinoKiste } from './kinokiste'; + +const kinokiste = new KinoKiste(); jest.mock('./../utils/fetch', () => ({ cachedFetchText: jest.fn(), @@ -11,26 +13,20 @@ jest.mock('./../utils/fetch', () => ({ ); describe('KinoKiste', () => { - test('does not handle movies', async () => { - const streams = await handleKinoKiste({ type: 'movie', id: 'tt29141112' }); - - expect(streams).toHaveLength(0); - }); - test('does not handle non imdb series', async () => { - const streams = await handleKinoKiste({ type: 'series', id: 'kitsu:123' }); + const streams = await kinokiste.handle('kitsu:123'); expect(streams).toHaveLength(0); }); test('handles non-existent series gracefully', async () => { - const streams = await handleKinoKiste({ type: 'series', id: 'tt12345678:1:1' }); + const streams = await kinokiste.handle('tt12345678:1:1'); expect(streams).toHaveLength(0); }); test('handle imdb black mirror s2e4', async () => { - const streams = await handleKinoKiste({ type: 'series', id: 'tt2085059:2:4' }); + const streams = await kinokiste.handle('tt2085059:2:4'); expect(streams).toHaveLength(2); expect(streams[0]).toStrictEqual({ diff --git a/src/handler/KinoKiste.ts b/src/handler/KinoKiste.ts new file mode 100644 index 0000000..87b1a66 --- /dev/null +++ b/src/handler/KinoKiste.ts @@ -0,0 +1,76 @@ +import * as cheerio from 'cheerio'; +import slugify from 'slugify'; +import { Handler } from './types'; +import { ImdbId, cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils'; + +export class KinoKiste implements Handler { + readonly id = 'kinokiste'; + + readonly label = 'KinoKiste'; + + readonly contentTypes = ['series']; + + readonly languages = ['de']; + + readonly handle = async (id: string) => { + if (!id.startsWith('tt')) { + return Promise.resolve([]); + } + + const imdbId = parseImdbId(id); + + const seriesPageUrl = await this.fetchSeriesPageUrl(imdbId); + if (!seriesPageUrl) { + return Promise.resolve([]); + } + + const streamsPromises = (await this.fetchStreamData(imdbId, seriesPageUrl)) + .map(async ({ group, url }) => { + const { url: finalUrl, resolution, size } = await parsePackedEmbed(url); + + return { + url: finalUrl, + name: `WebStreamr DE | ${resolution}`, + title: `${this.label} - ${group} | 💾 ${size} | 🇩🇪`, + behaviorHints: { + group: `webstreamr-${this.id}-${group}`, + }, + resolution, + size, + }; + }); + + return fulfillAllPromises(streamsPromises); + }; + + private fetchSeriesPageUrl = async (imdbId: ImdbId): Promise => { + const html = await cachedFetchText(`https://kinokiste.live/serien/?do=search&subaction=search&story=${imdbId.id}`); + + const $ = cheerio.load(html); + + return $('.item-video a[href]:first') + .map((_i, el) => $(el).attr('href')) + .get(0); + }; + + private fetchStreamData = async (imdbId: ImdbId, seriesPageUrl: string): Promise<{ group: string; url: string }[]> => { + const html = await cachedFetchText(seriesPageUrl); + + const $ = cheerio.load(html); + + return $(`[data-num="${imdbId.series}x${imdbId.episode}"]`).map((_i, urlWrapperElement) => { + return $(urlWrapperElement).siblings('.mirrors').children('[data-link]') + .map((_i, urlElement) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + group: slugify($(urlElement).attr('data-m')!), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + url: $(urlElement).attr('data-link')! + .replace(/^(https:)?\/\//, 'https://') + .replace('/e/', '/') + .replace('/embed-', '/'), + })) + .toArray() + .filter(({ url }) => url.match(/dropload|supervideo/)); + }).toArray(); + }; +} diff --git a/src/handler/meinecloud.test.ts b/src/handler/MeineCloud.test.ts similarity index 74% rename from src/handler/meinecloud.test.ts rename to src/handler/MeineCloud.test.ts index 69d4ae4..8851bec 100644 --- a/src/handler/meinecloud.test.ts +++ b/src/handler/MeineCloud.test.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import slugify from 'slugify'; +import { MeineCloud } from './MeineCloud'; import { cachedFetchText } from '../utils'; -import { handleMeineCloud } from './meinecloud'; + +const meinecloud = new MeineCloud(); jest.mock('./../utils/fetch', () => ({ cachedFetchText: jest.fn(), @@ -11,26 +13,20 @@ jest.mock('./../utils/fetch', () => ({ ); describe('MeineCloud', () => { - test('does not handle series', async () => { - const streams = await handleMeineCloud({ type: 'series', id: 'tt2085059:2:4' }); - - expect(streams).toHaveLength(0); - }); - test('does not handle non imdb movies', async () => { - const streams = await handleMeineCloud({ type: 'movie', id: 'kitsu:123' }); + const streams = await meinecloud.handle('kitsu:123'); expect(streams).toHaveLength(0); }); test('handles non-existent movies gracefully', async () => { - const streams = await handleMeineCloud({ type: 'movie', id: 'tt12345678' }); + const streams = await meinecloud.handle('tt12345678'); expect(streams).toHaveLength(0); }); test('handle imdb the devil\'s bath', async () => { - const streams = await handleMeineCloud({ type: 'movie', id: 'tt29141112' }); + const streams = await meinecloud.handle('tt29141112'); expect(streams).toHaveLength(2); expect(streams[0]).toStrictEqual({ diff --git a/src/handler/MeineCloud.ts b/src/handler/MeineCloud.ts new file mode 100644 index 0000000..1fab8b7 --- /dev/null +++ b/src/handler/MeineCloud.ts @@ -0,0 +1,51 @@ +import * as cheerio from 'cheerio'; +import slugify from 'slugify'; +import { Handler } from './types'; +import { cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils'; + +export class MeineCloud implements Handler { + readonly id = 'meinecloud'; + + readonly label = 'MeineCloud'; + + readonly contentTypes = ['movie']; + + readonly languages = ['de']; + + readonly handle = async (id: string) => { + if (!id.startsWith('tt')) { + return Promise.resolve([]); + } + + const html = await cachedFetchText(`https://meinecloud.click/movie/${parseImdbId(id).id}`); + + const $ = cheerio.load(html); + const streamsPromises = $('[data-link!=""]') + .map((_i, el) => ({ + group: slugify($(el).text()), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + url: $(el).attr('data-link')! + .replace(/^(https:)?\/\//, 'https://') + .replace('/e/', '/') + .replace('/embed-', '/'), + })) + .toArray() + .filter(({ url }) => url.match(/dropload|supervideo/)) + .map(async ({ group, url }) => { + const { url: finalUrl, resolution, size } = await parsePackedEmbed(url); + + return { + url: finalUrl, + name: `WebStreamr DE | ${resolution}`, + title: `${this.label} - ${group} | 💾 ${size} | 🇩🇪`, + behaviorHints: { + group: `webstreamr-${this.id}-${group}`, + }, + resolution, + size, + }; + }); + + return fulfillAllPromises(streamsPromises); + }; +} diff --git a/src/handler/index.ts b/src/handler/index.ts index e1b3513..023ea97 100644 --- a/src/handler/index.ts +++ b/src/handler/index.ts @@ -1,3 +1,3 @@ -export * from './kinokiste'; -export * from './meinecloud'; +export * from './KinoKiste'; +export * from './MeineCloud'; export * from './types'; diff --git a/src/handler/kinokiste.ts b/src/handler/kinokiste.ts deleted file mode 100644 index afaa551..0000000 --- a/src/handler/kinokiste.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as cheerio from 'cheerio'; -import slugify from 'slugify'; -import { ImdbId, cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils'; -import { Handler } from './types'; - -const fetchSeriesPageUrl = async (imdbId: ImdbId): Promise => { - const html = await cachedFetchText(`https://kinokiste.live/serien/?do=search&subaction=search&story=${imdbId.id}`); - - const $ = cheerio.load(html); - - return $('.item-video a[href]:first') - .map((_i, el) => $(el).attr('href')) - .get(0); -}; - -const fetchStreamData = async (imdbId: ImdbId, seriesPageUrl: string): Promise<{ group: string; url: string }[]> => { - const html = await cachedFetchText(seriesPageUrl); - - const $ = cheerio.load(html); - - return $(`[data-num="${imdbId.series}x${imdbId.episode}"]`).map((_i, urlWrapperElement) => { - return $(urlWrapperElement).siblings('.mirrors').children('[data-link]') - .map((_i, urlElement) => ({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - group: slugify($(urlElement).attr('data-m')!), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - url: $(urlElement).attr('data-link')! - .replace(/^(https:)?\/\//, 'https://') - .replace('/e/', '/') - .replace('/embed-', '/'), - })) - .toArray() - .filter(({ url }) => url.match(/dropload|supervideo/)); - }).toArray(); -}; - -export const handleKinoKiste: Handler = async ({ type, id }) => { - if (type !== 'series' || !id.startsWith('tt')) { - return Promise.resolve([]); - } - - const imdbId = parseImdbId(id); - - const seriesPageUrl = await fetchSeriesPageUrl(imdbId); - if (!seriesPageUrl) { - return Promise.resolve([]); - } - - const streamsPromises = (await fetchStreamData(imdbId, seriesPageUrl)) - .map(async ({ group, url }) => { - const { url: finalUrl, resolution, size } = await parsePackedEmbed(url); - - return { - url: finalUrl, - name: `WebStreamr DE | ${resolution}`, - title: `KinoKiste - ${group} | 💾 ${size} | 🇩🇪`, - behaviorHints: { - group: `webstreamr-kinokiste-${group}`, - }, - resolution, - size, - }; - }); - - return fulfillAllPromises(streamsPromises); -}; diff --git a/src/handler/meinecloud.ts b/src/handler/meinecloud.ts deleted file mode 100644 index 5da6170..0000000 --- a/src/handler/meinecloud.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as cheerio from 'cheerio'; -import slugify from 'slugify'; -import { Handler } from './types'; -import { cachedFetchText, fulfillAllPromises, parseImdbId, parsePackedEmbed } from '../utils'; - -export const handleMeineCloud: Handler = async ({ type, id }) => { - if (type !== 'movie' || !id.startsWith('tt')) { - return Promise.resolve([]); - } - - const imdbId = parseImdbId(id); - const html = await cachedFetchText(`https://meinecloud.click/movie/${imdbId.id}`); - - const $ = cheerio.load(html); - const streamsPromises = $('[data-link!=""]') - .map((_i, el) => ({ - group: slugify($(el).text()), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - url: $(el).attr('data-link')! - .replace(/^(https:)?\/\//, 'https://') - .replace('/e/', '/') - .replace('/embed-', '/'), - })) - .toArray() - .filter(({ url }) => url.match(/dropload|supervideo/)) - .map(async ({ group, url }) => { - const { url: finalUrl, resolution, size } = await parsePackedEmbed(url); - - return { - url: finalUrl, - name: `WebStreamr DE | ${resolution}`, - title: `MeineCloud - ${group} | 💾 ${size} | 🇩🇪`, - behaviorHints: { - group: `webstreamr-meinecloud-${group}`, - }, - resolution, - size, - }; - }); - - return fulfillAllPromises(streamsPromises); -}; diff --git a/src/handler/types.ts b/src/handler/types.ts index 9e25898..7678f7a 100644 --- a/src/handler/types.ts +++ b/src/handler/types.ts @@ -1,5 +1,15 @@ -import { ContentType, Stream } from 'stremio-addon-sdk'; +import { Stream } from 'stremio-addon-sdk'; export type HandlerStream = Stream & { resolution: string; size: string }; -export type Handler = (args: { type: ContentType; id: string }) => Promise<(HandlerStream)[]>; +export interface Handler { + readonly id: string; + + readonly label: string; + + readonly contentTypes: string[]; + + readonly languages: string[]; + + readonly handle: (id: string) => Promise; +} diff --git a/src/server.ts b/src/server.ts index 376c231..b6828a1 100755 --- a/src/server.ts +++ b/src/server.ts @@ -2,4 +2,7 @@ import { serveHTTP } from 'stremio-addon-sdk'; import addonInterface from './addon'; -serveHTTP(addonInterface, { port: parseInt(process.env['PORT'] || '51546'), cacheMaxAge: 3600 }); +serveHTTP(addonInterface, { + cacheMaxAge: process.env['NODE_ENV'] === 'production' ? 3600 : undefined, + port: parseInt(process.env['PORT'] || '51546'), +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index e515a75..4231286 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './embed'; export * from './fetch'; export * from './imdb'; +export * from './language'; export * from './log'; export * from './promise'; diff --git a/src/utils/language.test.ts b/src/utils/language.test.ts new file mode 100644 index 0000000..1e48036 --- /dev/null +++ b/src/utils/language.test.ts @@ -0,0 +1,12 @@ +import { iso2ToFlag } from './language'; + +describe('iso2ToFlag', () => { + test('returns flag for known language', () => { + expect(iso2ToFlag('DE')).toBe('🇩🇪'); + expect(iso2ToFlag('de')).toBe('🇩🇪'); + }); + + test('returns "?" for unknown language', () => { + expect(iso2ToFlag('XX')).toBe('?'); + }); +}); diff --git a/src/utils/language.ts b/src/utils/language.ts new file mode 100644 index 0000000..9dc53d1 --- /dev/null +++ b/src/utils/language.ts @@ -0,0 +1,9 @@ +export const iso2ToFlag = (iso2: string): string => { + const iso2Normalized = iso2.toLowerCase(); + + if (iso2Normalized === 'de') { + return '🇩🇪'; + } + + return '?'; +};