From b4b69291e601452799393fa57ced497bbe133ecc Mon Sep 17 00:00:00 2001 From: cats <113775689+worldInColors@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:35:50 +0300 Subject: [PATCH] feat: add SeaDex preference support for anime streams (#512) --- packages/core/src/db/schemas.ts | 7 + packages/core/src/formatters/base.ts | 4 + packages/core/src/parser/streamExpression.ts | 19 +++ packages/core/src/streams/precomputer.ts | 113 ++++++++++++- packages/core/src/streams/sorter.ts | 13 ++ packages/core/src/utils/constants.ts | 11 ++ packages/core/src/utils/index.ts | 1 + packages/core/src/utils/seadex.ts | 153 ++++++++++++++++++ .../frontend/src/components/menu/filters.tsx | 16 ++ 9 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/utils/seadex.ts diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 4c115a1f..24dc472b 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -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(), diff --git a/packages/core/src/formatters/base.ts b/packages/core/src/formatters/base.ts index 8342d9f7..0d8f1c43 100644 --- a/packages/core/src/formatters/base.ts +++ b/packages/core/src/formatters/base.ts @@ -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, diff --git a/packages/core/src/parser/streamExpression.ts b/packages/core/src/parser/streamExpression.ts index 5905ab91..403b015f 100644 --- a/packages/core/src/parser/streamExpression.ts +++ b/packages/core/src/parser/streamExpression.ts @@ -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', diff --git a/packages/core/src/streams/precomputer.ts b/packages/core/src/streams/precomputer.ts index cee5fc54..7d77d700 100644 --- a/packages/core/src/streams/precomputer.ts +++ b/packages/core/src/streams/precomputer.ts @@ -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)}` - ); } } diff --git a/packages/core/src/streams/sorter.ts b/packages/core/src/streams/sorter.ts index d30c41ee..f03784ae 100644 --- a/packages/core/src/streams/sorter.ts +++ b/packages/core/src/streams/sorter.ts @@ -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; } diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index f48ee862..ae98fb06 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -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; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 95dd4203..9ac1820b 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -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'; diff --git a/packages/core/src/utils/seadex.ts b/packages/core/src/utils/seadex.ts new file mode 100644 index 00000000..4b1b7fc3 --- /dev/null +++ b/packages/core/src/utils/seadex.ts @@ -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( + 'seadex', + SEADEX_CACHE_TTL * 1000, + 'memory' +); + +export interface SeaDexResult { + bestHashes: Set; + allHashes: Set; +} + +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 "") 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 { + 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(); + const allHashes = new Set(); + + 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('') || 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; diff --git a/packages/frontend/src/components/menu/filters.tsx b/packages/frontend/src/components/menu/filters.tsx index 571cf70b..9968f17b 100644 --- a/packages/frontend/src/components/menu/filters.tsx +++ b/packages/frontend/src/components/menu/filters.tsx @@ -2851,6 +2851,22 @@ function Content() { }} /> + + { + setUserData((prev) => ({ + ...prev, + enableSeadex: value, + })); + }} + /> + {/* Only show this setting if its enabled, otherwise hide it. */} {mode === 'pro' && userData.excludeSeasonPacks && (