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..5ba382ed5 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,73 @@ export class AIOStreams { return { season, episode }; } + private async _enrichWithSubDetectLanguages( + streams: ParsedStream[] + ): Promise { + if (!this.subdetectService.isEnabled()) { + return streams; + } + + try { + 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; + } catch (error) { + logger.error('SubDetect enrichment failed, continuing:', { + error: error instanceof Error ? error.message : String(error), + }); + return streams; + } + } + private async _processStreams( streams: ParsedStream[], context: StreamContext, @@ -2180,6 +2253,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..a579a73a2 --- /dev/null +++ b/packages/core/src/utils/subdetect.ts @@ -0,0 +1,145 @@ +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[]; +} + +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 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}

+
+ )} +
+ ); +}