feat: add SeaDex preference support for anime streams (#512)

This commit is contained in:
cats
2025-11-30 02:35:50 +03:00
committed by GitHub
parent 87a352a607
commit b4b69291e6
9 changed files with 331 additions and 6 deletions
+7
View File
@@ -363,6 +363,7 @@ export const UserDataSchema = z.object({
requiredAgeRange: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
ageRangeTypes: z.array(z.enum(['usenet', 'debrid', 'p2p'])).optional(),
digitalReleaseFilter: z.boolean().optional(),
enableSeadex: z.boolean().optional(),
excludeSeasonPacks: z.boolean().optional(),
excludeCached: z.boolean().optional(),
excludeCachedFromAddons: z.array(z.string().min(1)).optional(),
@@ -743,6 +744,12 @@ export const ParsedStreamSchema = z.object({
.optional(),
duration: z.number().optional(),
library: z.boolean().optional(),
seadex: z
.object({
isBest: z.boolean(),
isSeadex: z.boolean(),
})
.optional(),
url: z.string().optional(),
nzbUrl: z.string().optional(),
servers: z.array(z.string().min(1)).optional(),
+4
View File
@@ -104,6 +104,8 @@ export interface ParseValue {
type: string | null;
message: string | null;
proxied: boolean;
seadex: boolean;
seadexBest: boolean;
};
service?: {
id: string | null;
@@ -324,6 +326,8 @@ export abstract class BaseFormatter {
network: stream.parsedFile?.network || null,
container: stream.parsedFile?.container || null,
extension: stream.parsedFile?.extension || null,
seadex: (stream.seadex?.isSeadex && !stream.seadex?.isBest) ?? false,
seadexBest: stream.seadex?.isBest ?? false,
},
addon: {
name: stream.addon?.name || null,
@@ -492,6 +492,25 @@ export abstract class StreamExpressionEngine {
return streams.filter((stream) => stream.library);
};
this.parser.functions.seadex = function (
streams: ParsedStream[],
filterType?: string
) {
if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) {
throw new Error('Your streams input must be an array of streams');
}
const filter = filterType?.toLowerCase() || 'all';
if (filter === 'best') {
// Only return SeaDex "best" releases
return streams.filter((stream) => stream.seadex?.isBest === true);
}
// Return all SeaDex releases (best or regular)
return streams.filter((stream) => stream.seadex?.isSeadex === true);
};
this.parser.functions.message = function (
streams: ParsedStream[],
mode: 'exact' | 'includes',
+107 -6
View File
@@ -8,6 +8,8 @@ import {
compileRegex,
parseRegex,
AnimeDatabase,
IdParser,
SeaDexApi,
} from '../utils/index.js';
import { StreamSelector } from '../parser/streamExpression.js';
@@ -21,10 +23,107 @@ class StreamPrecomputer {
}
public async precompute(streams: ParsedStream[], type: string, id: string) {
const start = Date.now();
const isAnime = AnimeDatabase.getInstance().isAnime(id);
let queryType = type;
if (AnimeDatabase.getInstance().isAnime(id)) {
if (isAnime) {
queryType = `anime.${type}`;
}
await this.precomputeSeaDex(streams, id, isAnime);
await this.precomputePreferredMatches(streams, queryType);
logger.info(
`Precomputed preferred filters in ${getTimeTakenSincePoint(start)}`
);
}
/**
* Precompute SeaDex status for anime streams
* Tags streams with seadex.isBest and seadex.isSeadex based on infoHash matching
*/
private async precomputeSeaDex(
streams: ParsedStream[],
id: string,
isAnime: boolean
) {
if (!isAnime || !this.userData.enableSeadex) {
return;
}
const parsedId = IdParser.parse(id, 'unknown');
if (!parsedId) {
return;
}
const animeDb = AnimeDatabase.getInstance();
const entry = animeDb.getEntryById(parsedId.type, parsedId.value);
const anilistIdRaw = entry?.mappings?.anilistId;
if (!anilistIdRaw) {
logger.debug(
`No AniList ID found for ${parsedId.type}:${parsedId.value}, skipping SeaDex lookup`
);
return;
}
const anilistId =
typeof anilistIdRaw === 'string'
? parseInt(anilistIdRaw, 10)
: anilistIdRaw;
if (isNaN(anilistId)) {
logger.debug(
`Invalid AniList ID ${anilistIdRaw}, skipping SeaDex lookup`
);
return;
}
const seadexResult = await SeaDexApi.getInfoHashesForAnime(anilistId);
if (
seadexResult.bestHashes.size === 0 &&
seadexResult.allHashes.size === 0
) {
logger.debug(`No SeaDex releases found for AniList ID ${anilistId}`);
return;
}
let seadexBestCount = 0;
let seadexCount = 0;
for (const stream of streams) {
const infoHash = stream.torrent?.infoHash?.toLowerCase();
if (!infoHash) {
continue;
}
const isBest = seadexResult.bestHashes.has(infoHash);
const isSeadex = seadexResult.allHashes.has(infoHash);
if (isSeadex) {
stream.seadex = {
isBest,
isSeadex: true,
};
if (isBest) {
seadexBestCount++;
}
seadexCount++;
}
}
if (seadexCount > 0) {
logger.info(
`Tagged ${seadexCount} streams as SeaDex releases (${seadexBestCount} best) for AniList ID ${anilistId}`
);
}
}
/**
* Precompute preferred regex, keyword, and stream expression matches
*/
private async precomputePreferredMatches(
streams: ParsedStream[],
queryType: string
) {
const preferredRegexPatterns =
(await FeatureControl.isRegexAllowed(
this.userData,
@@ -45,10 +144,15 @@ class StreamPrecomputer {
const preferredKeywordsPatterns = this.userData.preferredKeywords
? await formRegexFromKeywords(this.userData.preferredKeywords)
: undefined;
if (!preferredRegexPatterns && !preferredKeywordsPatterns) {
if (
!preferredRegexPatterns &&
!preferredKeywordsPatterns &&
!this.userData.preferredStreamExpressions?.length
) {
return;
}
const start = Date.now();
if (preferredKeywordsPatterns) {
streams.forEach((stream) => {
stream.keywordMatched =
@@ -154,9 +258,6 @@ class StreamPrecomputer {
stream.streamExpressionMatched = streamToConditionIndex.get(stream.id);
}
}
logger.info(
`Precomputed preferred filters in ${getTimeTakenSincePoint(start)}`
);
}
}
+13
View File
@@ -301,6 +301,19 @@ class StreamSorter {
);
return multiplier * -(index === -1 ? Infinity : index);
}
case 'seadex': {
// SeaDex sorting: Best (2) > On SeaDex (1) > Not on SeaDex (0)
if (!stream.seadex) {
return multiplier * 0;
}
if (stream.seadex.isBest) {
return multiplier * 2;
}
if (stream.seadex.isSeadex) {
return multiplier * 1;
}
return multiplier * 0;
}
default:
return 0;
}
+11
View File
@@ -888,6 +888,7 @@ const SORT_CRITERIA = [
'library',
'keyword',
'streamExpressionMatched',
'seadex',
] as const;
export const MIN_SIZE = 0;
@@ -1075,6 +1076,16 @@ export const SORT_CRITERIA_DETAILS: Record<
descendingDescription:
'Streams that match your stream expressions are preferred and ranked by the order of your stream expressions',
},
seadex: {
name: 'SeaDex',
defaultDirection: 'desc',
description:
'Whether the stream is a SeaDex release (curated best anime releases from releases.moe)',
ascendingDescription:
'Non-SeaDex releases are preferred over SeaDex releases',
descendingDescription:
'SeaDex Best releases are preferred, then other SeaDex releases, then non-SeaDex releases',
},
} as const;
const SORT_DIRECTIONS = ['asc', 'desc'] as const;
+1
View File
@@ -18,3 +18,4 @@ export * from './id-parser.js';
export * from './anime-database.js';
export * from './regex.js';
export * from './general.js';
export * from './seadex.js';
+153
View File
@@ -0,0 +1,153 @@
import { createLogger } from './logger.js';
import { Cache } from './cache.js';
import { makeRequest } from './http.js';
import { Env } from './env.js';
const logger = createLogger('seadex');
const SEADEX_API_BASE = 'https://releases.moe/api/collections/entries/records';
const SEADEX_CACHE_TTL = 60 * 60;
// Cache SeaDex results for 1 hour
const seadexCache = Cache.getInstance<string, SeaDexResult>(
'seadex',
SEADEX_CACHE_TTL * 1000,
'memory'
);
export interface SeaDexResult {
bestHashes: Set<string>;
allHashes: Set<string>;
}
interface SeaDexTorrent {
infoHash: string;
isBest: boolean;
}
interface SeaDexEntry {
expand?: {
trs?: SeaDexTorrent[];
};
}
interface SeaDexResponse {
items?: SeaDexEntry[];
}
/**
* SeaDex API Client
* Fetches "best" release info from https://releases.moe/
*
* SeaDex is a curated database of the best anime releases.
* - "Best" releases are marked with isBest=true
* - Other releases on SeaDex are still quality releases, just not the absolute best
* - Redacted hashes (containing "<redacted>") are excluded
*/
export class SeaDexApi {
/**
* Get SeaDex info hashes for an anime by AniList ID
* @param anilistId - The AniList ID of the anime
* @returns Object containing bestHashes (isBest=true) and allHashes (all SeaDex releases)
*/
static async getInfoHashesForAnime(anilistId: number): Promise<SeaDexResult> {
const cacheKey = `anilist-${anilistId}`;
const cached = await seadexCache.get(cacheKey);
if (cached) {
logger.debug(`SeaDex cache hit for AniList ID ${anilistId}`);
return cached;
}
try {
const url = `${SEADEX_API_BASE}?expand=trs&filter=alID=${anilistId}&sort=-trs.isBest`;
logger.debug(`Fetching SeaDex data for AniList ID ${anilistId}`);
const response = await makeRequest(url, {
method: 'GET',
timeout: 10000,
headers: {
Accept: 'application/json',
'User-Agent': Env.DEFAULT_USER_AGENT,
},
});
if (!response.ok) {
logger.warn(
`SeaDex API returned ${response.status} for AniList ID ${anilistId}`
);
return { bestHashes: new Set(), allHashes: new Set() };
}
const data = (await response.json()) as SeaDexResponse;
const items = data?.items;
if (!items || items.length === 0) {
logger.debug(`No SeaDex entries found for AniList ID ${anilistId}`);
const emptyResult: SeaDexResult = {
bestHashes: new Set(),
allHashes: new Set(),
};
await seadexCache.set(cacheKey, emptyResult, SEADEX_CACHE_TTL);
return emptyResult;
}
const bestHashes = new Set<string>();
const allHashes = new Set<string>();
for (const item of items) {
const trsArray = item.expand?.trs;
if (!trsArray) continue;
for (const torrent of trsArray) {
const infoHash = torrent.infoHash?.toLowerCase();
// Skip empty or redacted hashes
if (!infoHash || infoHash.includes('<redacted>') || infoHash === '') {
continue;
}
allHashes.add(infoHash);
if (torrent.isBest) {
bestHashes.add(infoHash);
}
}
}
logger.info(
`Found ${bestHashes.size} best and ${allHashes.size} total SeaDex hashes for AniList ID ${anilistId}`
);
const result: SeaDexResult = { bestHashes, allHashes };
await seadexCache.set(cacheKey, result, SEADEX_CACHE_TTL);
return result;
} catch (error) {
logger.error(
`Failed to fetch SeaDex data for AniList ID ${anilistId}:`,
error instanceof Error ? error.message : String(error)
);
return {
bestHashes: new Set(),
allHashes: new Set(),
};
}
}
/**
* Check if an infoHash is a SeaDex "best" release
*/
static isSeadexBest(infoHash: string, seadexResult: SeaDexResult): boolean {
return seadexResult.bestHashes.has(infoHash.toLowerCase());
}
/**
* Check if an infoHash is on SeaDex (best or regular)
*/
static isOnSeadex(infoHash: string, seadexResult: SeaDexResult): boolean {
return seadexResult.allHashes.has(infoHash.toLowerCase());
}
}
export default SeaDexApi;
@@ -2851,6 +2851,22 @@ function Content() {
}}
/>
</SettingsCard>
<SettingsCard
title="SeaDex Integration"
description="Fetch SeaDex data (releases.moe) for anime to identify best quality releases."
>
<Switch
label="Enable"
side="right"
value={userData.enableSeadex}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
enableSeadex: value,
}));
}}
/>
</SettingsCard>
{/* Only show this setting if its enabled, otherwise hide it. */}
{mode === 'pro' && userData.excludeSeasonPacks && (
<SettingsCard