Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/core/src/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof UserDataSchema>;
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
AnimeDatabase,
ParsedId,
IdParser,
SubDetectService,
} from './utils/index.js';
import { Wrapper } from './wrapper.js';
import { PresetManager } from './presets/index.js';
Expand Down Expand Up @@ -101,6 +102,7 @@ export class AIOStreams {
private deduplicator: Deduplicator;
private sorter: Sorter;
private precomputer: Precomputer;
private subdetectService: SubDetectService;

private addonInitialisationErrors: {
addon: Addon | Preset;
Expand All @@ -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) {
Expand Down Expand Up @@ -2163,6 +2169,73 @@ export class AIOStreams {
return { season, episode };
}

private async _enrichWithSubDetectLanguages(
streams: ParsedStream[]
): Promise<ParsedStream[]> {
if (!this.subdetectService.isEnabled()) {
return streams;
}

try {
const startTime = Date.now();

const releaseToStreams = new Map<string, ParsedStream[]>();

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,
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/parser/regex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type PARSE_REGEX = {
>;
encodes: Omit<Record<(typeof constants.ENCODES)[number], RegExp>, 'Unknown'>;
releaseGroup: RegExp;
sceneRelease: RegExp;
};

export const PARSE_REGEX: PARSE_REGEX = {
Expand Down Expand Up @@ -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,}$/,
};
2 changes: 2 additions & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
145 changes: 145 additions & 0 deletions packages/core/src/utils/subdetect.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, string[]>> {
const results = new Map<string, string[]>();

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<Map<string, string[]>> {
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);
}
}
77 changes: 77 additions & 0 deletions packages/frontend/src/components/menu/miscellaneous.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -429,7 +430,83 @@ function Content() {
/>
</SettingsCard>
)}
{mode === 'pro' && <SubDetectCard />}
</div>
</>
);
}

function SubDetectCard() {
const { userData, setUserData } = useUserData();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<SettingsCard
title="SubDetect - Subtitle Language Detection"
description="Automatically detect embedded subtitle languages from scene release NFO files. Languages detected by SubDetect take priority over parsed languages."
>
<Switch
label="Enable"
side="right"
disabled={isLoading}
value={userData.subdetect?.enabled ?? false}
onValueChange={handleToggle}
/>

{error && (
<Alert intent="alert-basic">
<p className="text-sm">{error}</p>
</Alert>
)}
</SettingsCard>
);
}