From 0187a188fa253f14de7f6b8403cfebc19d5c55cb Mon Sep 17 00:00:00 2001 From: worldInColors Date: Sun, 25 Jan 2026 22:34:26 +0300 Subject: [PATCH 1/4] Add subdetect support --- packages/core/src/db/schemas.ts | 7 + packages/core/src/main.ts | 69 +++++++ packages/core/src/parser/regex.ts | 3 + packages/core/src/utils/index.ts | 2 + packages/core/src/utils/subdetect.ts | 188 ++++++++++++++++++ .../src/components/menu/miscellaneous.tsx | 77 +++++++ packages/server/src/app.ts | 2 + packages/server/src/routes/api/index.ts | 1 + 8 files changed, 349 insertions(+) create mode 100644 packages/core/src/utils/subdetect.ts diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 6c26a7bc6..2f39c255a 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -568,6 +568,13 @@ export const UserDataSchema = z.object({ cacheAndPlay: CacheAndPlaySchema.optional(), autoRemoveDownloads: z.boolean().optional(), + + subdetect: z + .object({ + enabled: z.boolean().optional(), + apiKey: z.string().optional(), + }) + .optional(), }); export type UserData = z.infer; diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 0eb3496f4..d8011a256 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -18,6 +18,7 @@ import { AnimeDatabase, ParsedId, IdParser, + SubDetectService, } from './utils/index.js'; import { Wrapper } from './wrapper.js'; import { PresetManager } from './presets/index.js'; @@ -101,6 +102,7 @@ export class AIOStreams { private deduplicator: Deduplicator; private sorter: Sorter; private precomputer: Precomputer; + private subdetectService: SubDetectService; private addonInitialisationErrors: { addon: Addon | Preset; @@ -121,6 +123,10 @@ export class AIOStreams { this.fetcher = new Fetcher(userData, this.filterer, this.precomputer); this.deduplicator = new Deduplicator(userData); this.sorter = new Sorter(userData); + this.subdetectService = new SubDetectService( + userData.subdetect?.apiKey, + userData.subdetect?.enabled ?? false + ); } private setUserData(userData: UserData) { @@ -2163,6 +2169,66 @@ export class AIOStreams { return { season, episode }; } + private async _enrichWithSubDetectLanguages( + streams: ParsedStream[] + ): Promise { + if (!this.subdetectService.isEnabled()) { + return streams; + } + + const startTime = Date.now(); + + const releaseToStreams = new Map(); + + for (const stream of streams) { + const releaseName = stream.filename || stream.folderName; + if (releaseName) { + const existing = releaseToStreams.get(releaseName) || []; + existing.push(stream); + releaseToStreams.set(releaseName, existing); + } + } + + if (releaseToStreams.size === 0) { + logger.debug('No valid release names found for SubDetect processing'); + return streams; + } + + // Get unique release names + const releaseNames = Array.from(releaseToStreams.keys()); + logger.info( + `Processing ${releaseNames.length} unique release names with SubDetect` + ); + + const detectedLanguages = + await this.subdetectService.detectLanguages(releaseNames); + + // Enrich streams with the languages + let enrichedCount = 0; + for (const [releaseName, languages] of detectedLanguages) { + const matchingStreams = releaseToStreams.get(releaseName); + if (matchingStreams && languages.length > 0) { + for (const stream of matchingStreams) { + if (stream.parsedFile) { + const existingLanguages = stream.parsedFile.languages || []; + const mergedLanguages = [ + ...languages, + ...existingLanguages.filter((l) => !languages.includes(l)), + ]; + stream.parsedFile.languages = mergedLanguages; + enrichedCount++; + } + } + } + } + + logger.info( + `SubDetect enriched ${enrichedCount} streams with language info in ${Date.now() - startTime}ms` + ); + + return streams; + } + private async _processStreams( streams: ParsedStream[], context: StreamContext, @@ -2180,6 +2246,9 @@ export class AIOStreams { processedStreams = await this.deduplicator.deduplicate(processedStreams); + processedStreams = + await this._enrichWithSubDetectLanguages(processedStreams); + if (isMeta) { // Run preferred matching after filter await this.precomputer.precomputePreferred(processedStreams, context); diff --git a/packages/core/src/parser/regex.ts b/packages/core/src/parser/regex.ts index c5deba16a..669a7cb66 100644 --- a/packages/core/src/parser/regex.ts +++ b/packages/core/src/parser/regex.ts @@ -38,6 +38,7 @@ type PARSE_REGEX = { >; encodes: Omit, 'Unknown'>; releaseGroup: RegExp; + sceneRelease: RegExp; }; export const PARSE_REGEX: PARSE_REGEX = { @@ -187,4 +188,6 @@ export const PARSE_REGEX: PARSE_REGEX = { }, releaseGroup: /-[. ]?(?!\d+$|S\d+|\d+x|ep?\d+|[^[]+]$)([^\-. []+[^\-. [)\]\d][^\-. [)\]]*)(?:\[[\w.-]+])?(?=\)|[.-]+\w{2,4}$|$)/i, + + sceneRelease: /-[a-zA-Z0-9]{2,}$/, }; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index a43095da9..21f03face 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -21,3 +21,5 @@ export * from './regex.js'; export * from './general.js'; export * from './seadex.js'; export * from './nzb-proxy.js'; + +export * from './subdetect.js'; diff --git a/packages/core/src/utils/subdetect.ts b/packages/core/src/utils/subdetect.ts new file mode 100644 index 000000000..11f4cee93 --- /dev/null +++ b/packages/core/src/utils/subdetect.ts @@ -0,0 +1,188 @@ +import { makeRequest } from './http.js'; +import { createLogger } from './logger.js'; +import { FULL_LANGUAGE_MAPPING } from './languages.js'; +import { PARSE_REGEX } from '../parser/regex.js'; + +const logger = createLogger('subdetect'); + +const SUBDETECT_API_URL = 'https://subdetect.chromeknight.dev'; +const MAX_BATCH_SIZE = 20; + +/** + * Checks if a filename matches the scene release naming convention. + * Scene releases follow these rules: + * - No spaces (always dots as separators) + * - Must contain dots (Title.Year.Format pattern) + * - Ends with -GroupName (at least 2 characters) + */ +export function isSceneRelease(filename: string): boolean { + if (filename.includes(' ')) return false; + if (!filename.includes('.')) return false; + return PARSE_REGEX.sceneRelease.test(filename); +} + +interface SubDetectResult { + release_name: string; + language_codes: string[]; + nfo_found: boolean; +} + +interface SubDetectProcessResponse { + results: SubDetectResult[]; +} + +interface SubDetectApiKeyResponse { + api_key: string; + created_at: string; +} + +export function convertISO6392ToLanguage(code: string): string | undefined { + const lang = FULL_LANGUAGE_MAPPING.find( + (language) => language.iso_639_2 === code.toLowerCase() + ); + return lang?.english_name?.split('(')?.[0]?.trim(); +} + +export async function generateApiKey(): Promise<{ + apiKey: string; + createdAt: string; +} | null> { + try { + const response = await makeRequest( + `${SUBDETECT_API_URL}/api/generate-key`, + { + method: 'POST', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + logger.error(`Failed to generate SubDetect API key: ${response.status}`); + return null; + } + + const data = (await response.json()) as SubDetectApiKeyResponse; + logger.info('Successfully generated SubDetect API key'); + return { + apiKey: data.api_key, + createdAt: data.created_at, + }; + } catch (error) { + logger.error('Error generating SubDetect API key:', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export async function processReleases( + apiKey: string, + releases: string[] +): Promise> { + const results = new Map(); + + if (!apiKey || releases.length === 0) { + return results; + } + + for (let i = 0; i < releases.length; i += MAX_BATCH_SIZE) { + const batch = releases.slice(i, i + MAX_BATCH_SIZE); + + try { + const response = await makeRequest( + `${SUBDETECT_API_URL}/api/process`, + { + method: 'POST', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + }, + body: JSON.stringify({ releases: batch }), + } + ); + + if (response.status === 429) { + logger.warn( + 'SubDetect rate limit exceeded, skipping remaining batches' + ); + break; + } + + if (response.status === 401) { + logger.error('SubDetect API key is invalid or expired'); + break; + } + + if (!response.ok) { + logger.error(`SubDetect API error: ${response.status}`); + continue; + } + + const data = (await response.json()) as SubDetectProcessResponse; + + for (const result of data.results) { + if (result.nfo_found && result.language_codes.length > 0) { + const languages = result.language_codes + .map((code) => convertISO6392ToLanguage(code)) + .filter((lang): lang is string => lang !== undefined); + + if (languages.length > 0) { + results.set(result.release_name, languages); + } + } + } + } catch (error) { + logger.error('Error processing releases with SubDetect:', { + error: error instanceof Error ? error.message : String(error), + batchSize: batch.length, + }); + } + } + + return results; +} + +export class SubDetectService { + private apiKey: string | undefined; + private enabled: boolean; + + constructor(apiKey?: string, enabled: boolean = false) { + this.apiKey = apiKey; + this.enabled = enabled; + } + + isEnabled(): boolean { + return this.enabled && !!this.apiKey; + } + + /** + * Process release names and return detected languages. + * Returns a map of release name -> detected languages. + */ + async detectLanguages( + releaseNames: string[] + ): Promise> { + if (!this.isEnabled() || !this.apiKey) { + return new Map(); + } + + const uniqueReleases = [ + ...new Set(releaseNames.filter((r) => r && isSceneRelease(r))), + ]; + + if (uniqueReleases.length === 0) { + logger.debug('No scene releases found to process with SubDetect'); + return new Map(); + } + + logger.info( + `Processing ${uniqueReleases.length} scene releases with SubDetect API (filtered from ${releaseNames.length} total)` + ); + + return await processReleases(this.apiKey, uniqueReleases); + } +} diff --git a/packages/frontend/src/components/menu/miscellaneous.tsx b/packages/frontend/src/components/menu/miscellaneous.tsx index ee4f2263e..443a481a4 100644 --- a/packages/frontend/src/components/menu/miscellaneous.tsx +++ b/packages/frontend/src/components/menu/miscellaneous.tsx @@ -1,4 +1,5 @@ 'use client'; +import { useState } from 'react'; import { PageWrapper } from '../shared/page-wrapper'; import { PageControls } from '../shared/page-controls'; import { Switch } from '../ui/switch'; @@ -429,7 +430,83 @@ function Content() { /> )} + {mode === 'pro' && } ); } + +function SubDetectCard() { + const { userData, setUserData } = useUserData(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleToggle = async (enabled: boolean) => { + setError(null); + + // If enabling and no API key, generate one first + if (enabled && !userData.subdetect?.apiKey) { + setIsLoading(true); + try { + const response = await fetch( + 'https://subdetect.chromeknight.dev/api/generate-key', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to generate API key: ${response.status}`); + } + + const data = await response.json(); + + setUserData((prev) => ({ + ...prev, + subdetect: { + apiKey: data.api_key, + enabled: true, + }, + })); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to enable SubDetect' + ); + } finally { + setIsLoading(false); + } + } else { + setUserData((prev) => ({ + ...prev, + subdetect: { + ...prev.subdetect, + enabled, + }, + })); + } + }; + + return ( + + + + {error && ( + +

{error}

+
+ )} +
+ ); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index ed51742f5..75ea35fa4 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -13,6 +13,7 @@ import { animeApi, proxyApi, templatesApi, + subdetectApi, } from './routes/api/index.js'; import { configure, @@ -107,6 +108,7 @@ if (Env.ENABLE_SEARCH_API) { apiRouter.use('/anime', animeApi); apiRouter.use('/proxy', proxyApi); apiRouter.use('/templates', templatesApi); +apiRouter.use('/subdetect', subdetectApi); app.use(`/api/v${constants.API_VERSION}`, apiRouter); // Stremio Routes diff --git a/packages/server/src/routes/api/index.ts b/packages/server/src/routes/api/index.ts index b89ec7640..defaec283 100644 --- a/packages/server/src/routes/api/index.ts +++ b/packages/server/src/routes/api/index.ts @@ -11,3 +11,4 @@ export { default as searchApi } from './search.js'; export { default as animeApi } from './anime.js'; export { default as proxyApi } from './proxy.js'; export { default as templatesApi } from './templates.js'; +export { default as subdetectApi } from './subdetect.js'; From 6fe056ac820b3381cd30a9aa915d7794281e982e Mon Sep 17 00:00:00 2001 From: worldInColors Date: Sun, 25 Jan 2026 22:38:52 +0300 Subject: [PATCH 2/4] Remove unused function --- packages/core/src/utils/subdetect.ts | 56 ++++--------------------- packages/server/src/routes/api/index.ts | 1 - 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/packages/core/src/utils/subdetect.ts b/packages/core/src/utils/subdetect.ts index 11f4cee93..b3e7cdb0f 100644 --- a/packages/core/src/utils/subdetect.ts +++ b/packages/core/src/utils/subdetect.ts @@ -43,41 +43,6 @@ export function convertISO6392ToLanguage(code: string): string | undefined { return lang?.english_name?.split('(')?.[0]?.trim(); } -export async function generateApiKey(): Promise<{ - apiKey: string; - createdAt: string; -} | null> { - try { - const response = await makeRequest( - `${SUBDETECT_API_URL}/api/generate-key`, - { - method: 'POST', - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - }, - } - ); - - if (!response.ok) { - logger.error(`Failed to generate SubDetect API key: ${response.status}`); - return null; - } - - const data = (await response.json()) as SubDetectApiKeyResponse; - logger.info('Successfully generated SubDetect API key'); - return { - apiKey: data.api_key, - createdAt: data.created_at, - }; - } catch (error) { - logger.error('Error generating SubDetect API key:', { - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - export async function processReleases( apiKey: string, releases: string[] @@ -92,18 +57,15 @@ export async function processReleases( const batch = releases.slice(i, i + MAX_BATCH_SIZE); try { - const response = await makeRequest( - `${SUBDETECT_API_URL}/api/process`, - { - method: 'POST', - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - }, - body: JSON.stringify({ releases: batch }), - } - ); + const response = await makeRequest(`${SUBDETECT_API_URL}/api/process`, { + method: 'POST', + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + }, + body: JSON.stringify({ releases: batch }), + }); if (response.status === 429) { logger.warn( diff --git a/packages/server/src/routes/api/index.ts b/packages/server/src/routes/api/index.ts index defaec283..b89ec7640 100644 --- a/packages/server/src/routes/api/index.ts +++ b/packages/server/src/routes/api/index.ts @@ -11,4 +11,3 @@ export { default as searchApi } from './search.js'; export { default as animeApi } from './anime.js'; export { default as proxyApi } from './proxy.js'; export { default as templatesApi } from './templates.js'; -export { default as subdetectApi } from './subdetect.js'; From 2c083210dab2c68afe5b24e174169b3e36c264ae Mon Sep 17 00:00:00 2001 From: worldInColors Date: Sun, 25 Jan 2026 22:51:39 +0300 Subject: [PATCH 3/4] Remove unused code --- packages/core/src/utils/subdetect.ts | 5 ----- packages/server/src/app.ts | 2 -- 2 files changed, 7 deletions(-) diff --git a/packages/core/src/utils/subdetect.ts b/packages/core/src/utils/subdetect.ts index b3e7cdb0f..a579a73a2 100644 --- a/packages/core/src/utils/subdetect.ts +++ b/packages/core/src/utils/subdetect.ts @@ -31,11 +31,6 @@ interface SubDetectProcessResponse { results: SubDetectResult[]; } -interface SubDetectApiKeyResponse { - api_key: string; - created_at: string; -} - export function convertISO6392ToLanguage(code: string): string | undefined { const lang = FULL_LANGUAGE_MAPPING.find( (language) => language.iso_639_2 === code.toLowerCase() diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 75ea35fa4..ed51742f5 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -13,7 +13,6 @@ import { animeApi, proxyApi, templatesApi, - subdetectApi, } from './routes/api/index.js'; import { configure, @@ -108,7 +107,6 @@ if (Env.ENABLE_SEARCH_API) { apiRouter.use('/anime', animeApi); apiRouter.use('/proxy', proxyApi); apiRouter.use('/templates', templatesApi); -apiRouter.use('/subdetect', subdetectApi); app.use(`/api/v${constants.API_VERSION}`, apiRouter); // Stremio Routes From 02d373ac458393eb50e547350edc9534277fc8cc Mon Sep 17 00:00:00 2001 From: worldInColors Date: Sun, 25 Jan 2026 22:56:36 +0300 Subject: [PATCH 4/4] Handle subdetect call failing --- packages/core/src/main.ts | 87 +++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index d8011a256..5ba382ed5 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -2176,57 +2176,64 @@ export class AIOStreams { return streams; } - const startTime = Date.now(); + try { + const startTime = Date.now(); - const releaseToStreams = new Map(); + const releaseToStreams = new Map(); - for (const stream of streams) { - const releaseName = stream.filename || stream.folderName; - if (releaseName) { - const existing = releaseToStreams.get(releaseName) || []; - existing.push(stream); - releaseToStreams.set(releaseName, existing); + for (const stream of streams) { + const releaseName = stream.filename || stream.folderName; + if (releaseName) { + const existing = releaseToStreams.get(releaseName) || []; + existing.push(stream); + releaseToStreams.set(releaseName, existing); + } } - } - if (releaseToStreams.size === 0) { - logger.debug('No valid release names found for SubDetect processing'); - return streams; - } + if (releaseToStreams.size === 0) { + logger.debug('No valid release names found for SubDetect processing'); + return streams; + } - // Get unique release names - const releaseNames = Array.from(releaseToStreams.keys()); - logger.info( - `Processing ${releaseNames.length} unique release names with SubDetect` - ); + // Get unique release names + const releaseNames = Array.from(releaseToStreams.keys()); + logger.info( + `Processing ${releaseNames.length} unique release names with SubDetect` + ); - const detectedLanguages = - await this.subdetectService.detectLanguages(releaseNames); - - // Enrich streams with the languages - let enrichedCount = 0; - for (const [releaseName, languages] of detectedLanguages) { - const matchingStreams = releaseToStreams.get(releaseName); - if (matchingStreams && languages.length > 0) { - for (const stream of matchingStreams) { - if (stream.parsedFile) { - const existingLanguages = stream.parsedFile.languages || []; - const mergedLanguages = [ - ...languages, - ...existingLanguages.filter((l) => !languages.includes(l)), - ]; - stream.parsedFile.languages = mergedLanguages; - enrichedCount++; + const detectedLanguages = + await this.subdetectService.detectLanguages(releaseNames); + + // Enrich streams with the languages + let enrichedCount = 0; + for (const [releaseName, languages] of detectedLanguages) { + const matchingStreams = releaseToStreams.get(releaseName); + if (matchingStreams && languages.length > 0) { + for (const stream of matchingStreams) { + if (stream.parsedFile) { + const existingLanguages = stream.parsedFile.languages || []; + const mergedLanguages = [ + ...languages, + ...existingLanguages.filter((l) => !languages.includes(l)), + ]; + stream.parsedFile.languages = mergedLanguages; + enrichedCount++; + } } } } - } - logger.info( - `SubDetect enriched ${enrichedCount} streams with language info in ${Date.now() - startTime}ms` - ); + logger.info( + `SubDetect enriched ${enrichedCount} streams with language info in ${Date.now() - startTime}ms` + ); - return streams; + return streams; + } catch (error) { + logger.error('SubDetect enrichment failed, continuing:', { + error: error instanceof Error ? error.message : String(error), + }); + return streams; + } } private async _processStreams(