mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: add SeaDex preference support for anime streams (#512)
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user