Organized the differences between server and docker versions.
We are launching a docker version (server version) today so we want to just organize the repo so its easier to navigate.
This commit is contained in:
450
docker/src/api/anime/anime.service.ts
Normal file
450
docker/src/api/anime/anime.service.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { getCache, setCache, getCachedExtension, cacheExtension, getExtensionTitle } from '../../shared/queries';
|
||||
import { queryAll, queryOne } from '../../shared/database';
|
||||
import {Anime, Episode, Extension, StreamData} from '../types';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const TTL = 60 * 60 * 6;
|
||||
|
||||
const ANILIST_URL = "https://graphql.anilist.co";
|
||||
|
||||
const MEDIA_FIELDS = `
|
||||
id
|
||||
idMal
|
||||
title { romaji english native userPreferred }
|
||||
type
|
||||
format
|
||||
status
|
||||
description
|
||||
startDate { year month day }
|
||||
endDate { year month day }
|
||||
season
|
||||
seasonYear
|
||||
episodes
|
||||
duration
|
||||
chapters
|
||||
volumes
|
||||
countryOfOrigin
|
||||
isLicensed
|
||||
source
|
||||
hashtag
|
||||
trailer { id site thumbnail }
|
||||
updatedAt
|
||||
coverImage { extraLarge large medium color }
|
||||
bannerImage
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
isLocked
|
||||
trending
|
||||
favourites
|
||||
isAdult
|
||||
siteUrl
|
||||
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult }
|
||||
relations {
|
||||
edges {
|
||||
relationType
|
||||
node {
|
||||
id
|
||||
title { romaji }
|
||||
type
|
||||
format
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
studios {
|
||||
edges {
|
||||
isMain
|
||||
node { id name isAnimationStudio }
|
||||
}
|
||||
}
|
||||
nextAiringEpisode { airingAt timeUntilAiring episode }
|
||||
externalLinks { id url site type language color icon notes }
|
||||
rankings { id rank type format year season allTime context }
|
||||
stats {
|
||||
scoreDistribution { score amount }
|
||||
statusDistribution { status amount }
|
||||
}
|
||||
recommendations(perPage: 7, sort: RATING_DESC) {
|
||||
nodes {
|
||||
mediaRecommendation {
|
||||
id
|
||||
title { romaji }
|
||||
coverImage { medium }
|
||||
format
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function fetchAniList(query: string, variables: any) {
|
||||
const res = await fetch(ANILIST_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
return json?.data;
|
||||
}
|
||||
|
||||
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
|
||||
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
|
||||
|
||||
if (row) return JSON.parse(row.full_data);
|
||||
|
||||
const query = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, { id: Number(id) });
|
||||
if (!data?.Media) return { error: "Anime not found" };
|
||||
|
||||
await queryOne(
|
||||
"INSERT INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
data.Media.id,
|
||||
data.Media.title?.english || data.Media.title?.romaji,
|
||||
data.Media.updatedAt || 0,
|
||||
JSON.stringify(data.Media)
|
||||
]
|
||||
);
|
||||
|
||||
return data.Media;
|
||||
}
|
||||
|
||||
export async function getTrendingAnime(): Promise<Anime[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data, updated_at FROM trending ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||
if (!expired) {
|
||||
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await queryOne("DELETE FROM trending");
|
||||
let rank = 1;
|
||||
|
||||
for (const anime of list) {
|
||||
await queryOne(
|
||||
"INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[rank++, anime.id, JSON.stringify(anime), now]
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function getTopAiringAnime(): Promise<Anime[]> {
|
||||
const rows = await queryAll(
|
||||
"SELECT full_data, updated_at FROM top_airing ORDER BY rank ASC LIMIT 10"
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
|
||||
if (!expired) {
|
||||
return rows.map((r: { full_data: string }) => JSON.parse(r.full_data));
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
query {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(query, {});
|
||||
const list = data?.Page?.media || [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await queryOne("DELETE FROM top_airing");
|
||||
let rank = 1;
|
||||
|
||||
for (const anime of list) {
|
||||
await queryOne(
|
||||
"INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
||||
[rank++, anime.id, JSON.stringify(anime), now]
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function searchAnimeLocal(query: string): Promise<Anime[]> {
|
||||
if (!query || query.length < 2) return [];
|
||||
|
||||
const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`;
|
||||
const rows = await queryAll(sql, [`%${query}%`]);
|
||||
|
||||
const localResults: Anime[] = rows
|
||||
.map((r: { full_data: string }) => JSON.parse(r.full_data))
|
||||
.filter((anime: { title: { english: any; romaji: any; native: any; }; synonyms: any; }) => {
|
||||
const q = query.toLowerCase();
|
||||
const titles = [
|
||||
anime.title?.english,
|
||||
anime.title?.romaji,
|
||||
anime.title?.native,
|
||||
...(anime.synonyms || [])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(t => t!.toLowerCase());
|
||||
|
||||
return titles.some(t => t.includes(q));
|
||||
})
|
||||
.slice(0, 10);
|
||||
|
||||
if (localResults.length >= 5) {
|
||||
return localResults;
|
||||
}
|
||||
|
||||
const gql = `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(type: ANIME, search: $search) { ${MEDIA_FIELDS} }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await fetchAniList(gql, { search: query });
|
||||
const remoteResults: Anime[] = data?.Page?.media || [];
|
||||
|
||||
for (const anime of remoteResults) {
|
||||
await queryOne(
|
||||
"INSERT OR IGNORE INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
anime.id,
|
||||
anime.title?.english || anime.title?.romaji,
|
||||
anime.updatedAt || 0,
|
||||
JSON.stringify(anime)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const merged = [...localResults];
|
||||
|
||||
for (const anime of remoteResults) {
|
||||
if (!merged.find(a => a.id === anime.id)) {
|
||||
merged.push(anime);
|
||||
}
|
||||
if (merged.length >= 10) break;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise<Anime | { error: string }> {
|
||||
if (!ext) return { error: "not found" };
|
||||
|
||||
const extName = ext.constructor.name;
|
||||
|
||||
const cached = await getCachedExtension(extName, id);
|
||||
if (cached) {
|
||||
try {
|
||||
console.log(`[${extName}] Metadata cache hit for ID: ${id}`);
|
||||
return JSON.parse(cached.metadata) as Anime;
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if ((ext.type === 'anime-board') && ext.getMetadata) {
|
||||
try {
|
||||
const match = await ext.getMetadata(id);
|
||||
|
||||
if (match) {
|
||||
const normalized: any = {
|
||||
title: match.title ?? "Unknown",
|
||||
summary: match.summary ?? "No summary available",
|
||||
episodes: Number(match.episodes) || 0,
|
||||
characters: Array.isArray(match.characters) ? match.characters : [],
|
||||
season: match.season ?? null,
|
||||
status: match.status ?? "Unknown",
|
||||
studio: match.studio ?? "Unknown",
|
||||
score: Number(match.score) || 0,
|
||||
year: match.year ?? null,
|
||||
genres: Array.isArray(match.genres) ? match.genres : [],
|
||||
image: match.image ?? ""
|
||||
};
|
||||
|
||||
await cacheExtension(extName, id, normalized.title, normalized);
|
||||
return normalized;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Extension getMetadata failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "not found" };
|
||||
}
|
||||
|
||||
export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string) {
|
||||
if (!ext) return [];
|
||||
|
||||
if (ext.type === 'anime-board' && ext.search) {
|
||||
try {
|
||||
console.log(`[${name}] Searching for anime: ${query}`);
|
||||
const matches = await ext.search({
|
||||
query: query,
|
||||
media: {
|
||||
romajiTitle: query,
|
||||
englishTitle: query,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
return matches.map(m => ({
|
||||
id: m.id,
|
||||
extensionName: name,
|
||||
title: { romaji: m.title, english: m.title, native: null },
|
||||
coverImage: { large: m.image || '' },
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: 'ANIME',
|
||||
seasonYear: null,
|
||||
isExtensionResult: true,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Extension search failed for ${name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function searchEpisodesInExtension(ext: Extension | null, name: string, query: string): Promise<Episode[]> {
|
||||
if (!ext) return [];
|
||||
|
||||
const cacheKey = `anime:episodes:${name}:${query}`;
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Episodes cache hit for: ${query}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as Episode[];
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached episodes:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Episodes cache expired for: ${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") {
|
||||
try {
|
||||
const title = await getExtensionTitle(name, query);
|
||||
let mediaId: string;
|
||||
|
||||
if (!title) {
|
||||
const matches = await ext.search({
|
||||
query,
|
||||
media: {
|
||||
romajiTitle: query,
|
||||
englishTitle: query,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (!matches || matches.length === 0) return [];
|
||||
|
||||
const res = matches[0];
|
||||
if (!res?.id) return [];
|
||||
|
||||
mediaId = res.id;
|
||||
|
||||
} else {
|
||||
mediaId = query;
|
||||
}
|
||||
|
||||
const chapterList = await ext.findEpisodes(mediaId);
|
||||
|
||||
if (!Array.isArray(chapterList)) return [];
|
||||
|
||||
const result: Episode[] = chapterList.map(ep => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
url: ep.url,
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(`Extension search failed for ${name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string): Promise<StreamData> {
|
||||
const providerName = extension.constructor.name;
|
||||
|
||||
const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`;
|
||||
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as StreamData;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached stream data:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!extension.findEpisodes || !extension.findEpisodeServer) {
|
||||
throw new Error("Extension doesn't support required methods");
|
||||
}
|
||||
let episodes;
|
||||
|
||||
if (source === "anilist"){
|
||||
const anime: any = await getAnimeById(id)
|
||||
episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji);
|
||||
}
|
||||
else{
|
||||
episodes = await extension.findEpisodes(id);
|
||||
}
|
||||
const targetEp = episodes.find(e => e.number === parseInt(episode));
|
||||
|
||||
if (!targetEp) {
|
||||
throw new Error("Episode not found");
|
||||
}
|
||||
|
||||
const serverName = server || "default";
|
||||
const streamData = await extension.findEpisodeServer(targetEp, serverName);
|
||||
|
||||
await setCache(cacheKey, streamData, CACHE_TTL_MS);
|
||||
return streamData;
|
||||
}
|
||||
Reference in New Issue
Block a user