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:
2025-12-16 21:50:22 -05:00
parent b86f14a8f2
commit 28ff6ccc68
193 changed files with 23188 additions and 5 deletions

View File

@@ -0,0 +1,564 @@
import { queryOne } from '../../shared/database';
const USER_DB = 'userdata';
// Configuración de reintentos
const RETRY_CONFIG = {
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffMultiplier: 2
};
// Helper para hacer requests con reintentos y manejo de errores
async function fetchWithRetry(
url: string,
options: RequestInit,
retries = RETRY_CONFIG.maxRetries
): Promise<Response> {
let lastError: Error | null = null;
let delay = RETRY_CONFIG.initialDelay;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
// Si es rate limit (429), esperamos más tiempo
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay;
if (attempt < retries) {
console.warn(`Rate limited. Esperando ${waitTime}ms antes de reintentar...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
}
// Si es un error de servidor (5xx), reintentamos
if (response.status >= 500 && attempt < retries) {
console.warn(`Error del servidor (${response.status}). Reintentando en ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < retries && (
error instanceof Error && (
error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network')
)
)) {
console.warn(`Error de conexión (intento ${attempt + 1}/${retries + 1}). Reintentando en ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
throw error;
}
}
throw lastError || new Error('Request failed after all retries');
}
export async function getUserAniList(appUserId: number) {
try {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [appUserId], USER_DB) as any;
if (!integration) return [];
const { access_token, anilist_user_id } = integration;
if (!access_token || !anilist_user_id) return [];
const query = `
query ($userId: Int) {
anime: MediaListCollection(userId: $userId, type: ANIME) {
lists {
entries {
media {
id
title { romaji english userPreferred }
coverImage { extraLarge }
episodes
nextAiringEpisode { episode }
}
status
progress
score
repeat
notes
private
startedAt { year month day }
completedAt { year month day }
}
}
}
manga: MediaListCollection(userId: $userId, type: MANGA) {
lists {
entries {
media {
id
type
format
title { romaji english userPreferred }
coverImage { extraLarge }
chapters
volumes
}
status
progress
score
repeat
notes
private
startedAt { year month day }
completedAt { year month day }
}
}
}
}
`;
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query,
variables: { userId: anilist_user_id }
}),
});
if (!res.ok) throw new Error(`AniList API error: ${res.status}`);
const json = await res.json();
if (json?.errors?.length) throw new Error(json.errors[0].message);
const fromFuzzy = (d: any) => {
if (!d?.year) return null;
const m = String(d.month || 1).padStart(2, '0');
const day = String(d.day || 1).padStart(2, '0');
return `${d.year}-${m}-${day}`;
};
const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => {
const result: any[] = [];
for (const list of lists || []) {
for (const entry of list.entries || []) {
const media = entry.media;
const totalEpisodes =
media?.episodes ||
(media?.nextAiringEpisode?.episode
? media.nextAiringEpisode.episode - 1
: 0);
const totalChapters =
media?.chapters ||
(media?.volumes ? media.volumes * 10 : 0);
const resolvedType =
type === 'MANGA' &&
(media?.format === 'LIGHT_NOVEL' || media?.format === 'NOVEL')
? 'NOVEL'
: type;
result.push({
user_id: appUserId,
entry_id: media.id,
source: 'anilist',
// ✅ AHORA TU FRONT RECIBE NOVEL
entry_type: resolvedType,
status: entry.status,
progress: entry.progress || 0,
score: entry.score || null,
start_date: fromFuzzy(entry.startedAt),
end_date: fromFuzzy(entry.completedAt),
repeat_count: entry.repeat || 0,
notes: entry.notes || null,
is_private: entry.private ? 1 : 0,
title: media?.title?.userPreferred
|| media?.title?.english
|| media?.title?.romaji
|| 'Unknown Title',
poster: media?.coverImage?.extraLarge
|| 'https://placehold.co/400x600?text=No+Cover',
total_episodes: resolvedType === 'ANIME' ? totalEpisodes : undefined,
total_chapters: resolvedType !== 'ANIME' ? totalChapters : undefined,
updated_at: new Date().toISOString()
});
}
}
return result;
};
return [
...normalize(json?.data?.anime?.lists, 'ANIME'),
...normalize(json?.data?.manga?.lists, 'MANGA')
];
} catch (error) {
console.error('Error fetching AniList data:', error);
return [];
}
}
export async function updateAniListEntry(token: string, params: {
mediaId: number | string;
status?: string | null;
progress?: number | null;
score?: number | null;
start_date?: string | null; // YYYY-MM-DD
end_date?: string | null; // YYYY-MM-DD
repeat_count?: number | null;
notes?: string | null;
is_private?: boolean | number | null;
}) {
try {
if (!token) throw new Error('AniList token is required');
const mutation = `
mutation (
$mediaId: Int,
$status: MediaListStatus,
$progress: Int,
$score: Float,
$startedAt: FuzzyDateInput,
$completedAt: FuzzyDateInput,
$repeat: Int,
$notes: String,
$private: Boolean
) {
SaveMediaListEntry (
mediaId: $mediaId,
status: $status,
progress: $progress,
score: $score,
startedAt: $startedAt,
completedAt: $completedAt,
repeat: $repeat,
notes: $notes,
private: $private
) {
id
status
progress
score
startedAt { year month day }
completedAt { year month day }
repeat
notes
private
}
}
`;
const toFuzzyDate = (dateStr?: string | null) => {
if (!dateStr) return null;
const [year, month, day] = dateStr.split('-').map(Number);
return { year, month, day };
};
const variables: any = {
mediaId: Number(params.mediaId),
status: params.status ?? undefined,
progress: params.progress ?? undefined,
score: params.score ?? undefined,
startedAt: toFuzzyDate(params.start_date),
completedAt: toFuzzyDate(params.end_date),
repeat: params.repeat_count ?? undefined,
notes: params.notes ?? undefined,
private: typeof params.is_private === 'boolean'
? params.is_private
: params.is_private === 1
};
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ query: mutation, variables }),
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`AniList update failed: ${res.status} - ${errorText}`);
}
const json = await res.json();
if (json?.errors?.length) {
throw new Error(`AniList GraphQL error: ${json.errors[0].message}`);
}
return json.data?.SaveMediaListEntry || null;
} catch (error) {
console.error('Error updating AniList entry:', error);
throw error;
}
}
export async function deleteAniListEntry(token: string, mediaId: number) {
if (!token) throw new Error("AniList token required");
try {
// 1⃣ OBTENER VIEWER
const viewerQuery = `query { Viewer { id name } }`;
const vRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ query: viewerQuery }),
});
const vJson = await vRes.json();
const userId = vJson?.data?.Viewer?.id;
if (!userId) throw new Error("Invalid AniList token");
// 2⃣ DETECTAR TIPO REAL DEL MEDIA
const mediaQuery = `
query ($id: Int) {
Media(id: $id) {
id
type
}
}
`;
const mTypeRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: mediaQuery,
variables: { id: mediaId }
}),
});
const mTypeJson = await mTypeRes.json();
const mediaType = mTypeJson?.data?.Media?.type;
if (!mediaType) {
throw new Error("Media not found in AniList");
}
// 3⃣ BUSCAR ENTRY CON TIPO REAL
const listQuery = `
query ($userId: Int, $mediaId: Int, $type: MediaType) {
MediaList(userId: $userId, mediaId: $mediaId, type: $type) {
id
}
}
`;
const qRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: listQuery,
variables: {
userId,
mediaId,
type: mediaType
}
}),
});
const qJson = await qRes.json();
const listEntryId = qJson?.data?.MediaList?.id;
if (!listEntryId) {
throw new Error("Entry not found in user's AniList");
}
// 4⃣ BORRAR
const mutation = `
mutation ($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}
`;
const delRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: mutation,
variables: { id: listEntryId }
}),
});
const delJson = await delRes.json();
if (delJson?.errors?.length) {
throw new Error(delJson.errors[0].message);
}
return true;
} catch (err) {
console.error("AniList DELETE failed:", err);
throw err;
}
}
export async function getSingleAniListEntry(
token: string,
mediaId: number,
type: 'ANIME' | 'MANGA'
) {
try {
if (!token) {
throw new Error('AniList token is required');
}
// 1⃣ Obtener userId desde el token
const viewerRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const viewerJson = await viewerRes.json();
const userId = viewerJson?.data?.Viewer?.id;
if (!userId) {
throw new Error('Failed to get AniList userId');
}
// 2⃣ Query correcta con userId
const query = `
query ($mediaId: Int, $type: MediaType, $userId: Int) {
MediaList(mediaId: $mediaId, type: $type, userId: $userId) {
id
status
progress
score
repeat
private
notes
startedAt { year month day }
completedAt { year month day }
}
}
`;
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query,
variables: { mediaId, type, userId }
})
});
if (res.status === 404) {
return null; // ✅ No existe entry todavía → es totalmente válido
}
if (!res.ok) {
const errorText = await res.text();
throw new Error(`AniList fetch failed: ${res.status} - ${errorText}`);
}
const json = await res.json();
if (json?.errors?.length) {
if (json.errors[0].status === 404) return null;
throw new Error(`GraphQL error: ${json.errors[0].message}`);
}
const entry = json?.data?.MediaList;
if (!entry) return null;
return {
entry_id: mediaId,
source: 'anilist',
entry_type: type,
status: entry.status,
progress: entry.progress || 0,
score: entry.score ?? null,
start_date: entry.startedAt?.year
? `${entry.startedAt.year}-${String(entry.startedAt.month).padStart(2, '0')}-${String(entry.startedAt.day).padStart(2, '0')}`
: null,
end_date: entry.completedAt?.year
? `${entry.completedAt.year}-${String(entry.completedAt.month).padStart(2, '0')}-${String(entry.completedAt.day).padStart(2, '0')}`
: null,
repeat_count: entry.repeat || 0,
notes: entry.notes || null,
is_private: entry.private ? 1 : 0,
};
} catch (error) {
console.error('Error fetching single AniList entry:', error);
throw error;
}
}

View File

@@ -0,0 +1,91 @@
import { FastifyInstance } from "fastify";
import { run } from "../../shared/database";
async function anilist(fastify: FastifyInstance) {
fastify.get("/anilist", async (request, reply) => {
try {
const { code, state } = request.query as { code?: string; state?: string };
if (!code) return reply.status(400).send("No code");
if (!state) return reply.status(400).send("No user state");
const userId = Number(state);
if (!userId || isNaN(userId)) {
return reply.status(400).send("Invalid user id");
}
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.ANILIST_CLIENT_ID,
client_secret: process.env.ANILIST_CLIENT_SECRET,
redirect_uri: "http://localhost:54322/api/anilist",
code
})
});
const tokenData = await tokenRes.json();
if (!tokenData.access_token) {
console.error("AniList token error:", tokenData);
return reply.status(500).send("Failed to get AniList token");
}
const userRes = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const userData = await userRes.json();
const anilistUserId = userData?.data?.Viewer?.id;
if (!anilistUserId) {
console.error("AniList Viewer error:", userData);
return reply.status(500).send("Failed to fetch AniList user");
}
const expiresAt = new Date(
Date.now() + tokenData.expires_in * 1000
).toISOString();
await run(
`
INSERT INTO UserIntegration
(user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
token_type = excluded.token_type,
anilist_user_id = excluded.anilist_user_id,
expires_at = excluded.expires_at
`,
[
userId,
"AniList",
tokenData.access_token,
tokenData.refresh_token,
tokenData.token_type,
anilistUserId,
expiresAt
],
"userdata"
);
return reply.redirect("http://localhost:54322/?anilist=success");
} catch (e) {
console.error("AniList error:", e);
return reply.redirect("http://localhost:54322/?anilist=error");
}
});
}
export default anilist;

View File

@@ -0,0 +1,107 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import * as animeService from './anime.service';
import {getExtension} from '../../shared/extensions';
import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types';
export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
try {
const { id } = req.params;
const source = req.query.source;
let anime: Anime | { error: string };
if (source === 'anilist') {
anime = await animeService.getAnimeById(id);
} else {
const ext = getExtension(source);
anime = await animeService.getAnimeInfoExtension(ext, id)
}
return anime;
} catch (err) {
return { error: "Database error" };
}
}
export async function getAnimeEpisodes(req: AnimeRequest, reply: FastifyReply) {
try {
const { id } = req.params;
const source = req.query.source || 'anilist';
const ext = getExtension(source);
return await animeService.searchEpisodesInExtension(
ext,
source,
id
);
} catch (err) {
return { error: "Database error" };
}
}
export async function getTrending(req: FastifyRequest, reply: FastifyReply) {
try {
const results = await animeService.getTrendingAnime();
return { results };
} catch (err) {
return { results: [] };
}
}
export async function getTopAiring(req: FastifyRequest, reply: FastifyReply) {
try {
const results = await animeService.getTopAiringAnime();
return { results };
} catch (err) {
return { results: [] };
}
}
export async function search(req: SearchRequest, reply: FastifyReply) {
try {
const query = req.query.q;
const results = await animeService.searchAnimeLocal(query);
if (results.length > 0) {
return { results: results };
}
} catch (err) {
return { results: [] };
}
}
export async function searchInExtension(req: any, reply: FastifyReply) {
try {
const extensionName = req.params.extension;
const query = req.query.q;
const ext = getExtension(extensionName);
if (!ext) return { results: [] };
const results = await animeService.searchAnimeInExtension(ext, extensionName, query);
return { results };
} catch {
return { results: [] };
}
}
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
try {
const { animeId, episode, server, category, ext, source } = req.query;
const extension = getExtension(ext);
if (!extension) return { error: "Extension not found" };
return await animeService.getStreamData(
extension,
episode,
animeId,
source,
server,
category
);
} catch (err) {
const error = err as Error;
return { error: error.message };
}
}

View File

@@ -0,0 +1,14 @@
import { FastifyInstance } from 'fastify';
import * as controller from './anime.controller';
async function animeRoutes(fastify: FastifyInstance) {
fastify.get('/anime/:id', controller.getAnime);
fastify.get('/anime/:id/:episodes', controller.getAnimeEpisodes);
fastify.get('/trending', controller.getTrending);
fastify.get('/top-airing', controller.getTopAiring);
fastify.get('/search', controller.search);
fastify.get('/search/:extension', controller.searchInExtension);
fastify.get('/watch/stream', controller.getWatchStream);
}
export default animeRoutes;

View 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;
}

View File

@@ -0,0 +1,116 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import * as booksService from './books.service';
import {getExtension} from '../../shared/extensions';
import {BookRequest, ChapterRequest, SearchRequest} from '../types';
export async function getBook(req: any, reply: FastifyReply) {
try {
const { id } = req.params;
const source = req.query.source;
let book;
if (source === 'anilist') {
book = await booksService.getBookById(id);
} else {
const ext = getExtension(source);
const result = await booksService.getBookInfoExtension(ext, id);
book = result || null;
}
return book;
} catch (err) {
return { error: (err as Error).message };
}
}
export async function getTrending(req: FastifyRequest, reply: FastifyReply) {
try {
const results = await booksService.getTrendingBooks();
return { results };
} catch (err) {
return { results: [] };
}
}
export async function getPopular(req: FastifyRequest, reply: FastifyReply) {
try {
const results = await booksService.getPopularBooks();
return { results };
} catch (err) {
return { results: [] };
}
}
export async function searchBooks(req: SearchRequest, reply: FastifyReply) {
try {
const query = req.query.q;
const dbResults = await booksService.searchBooksLocal(query);
if (dbResults.length > 0) {
return { results: dbResults };
}
console.log(`[Books] Local DB miss for "${query}", fetching live...`);
const anilistResults = await booksService.searchBooksAniList(query);
if (anilistResults.length > 0) {
return { results: anilistResults };
}
return { results: [] };
} catch (e) {
const error = e as Error;
console.error("Search Error:", error.message);
return { results: [] };
}
}
export async function searchBooksInExtension(req: any, reply: FastifyReply) {
try {
const extensionName = req.params.extension;
const query = req.query.q;
const ext = getExtension(extensionName);
if (!ext) return { results: [] };
const results = await booksService.searchBooksInExtension(ext, extensionName, query);
return { results };
} catch (e) {
const error = e as Error;
console.error("Search Error:", error.message);
return { results: [] };
}
}
export async function getChapters(req: any, reply: FastifyReply) {
try {
const { id } = req.params;
const source = req.query.source || 'anilist';
const isExternal = source !== 'anilist';
return await booksService.getChaptersForBook(id, isExternal);
} catch {
return { chapters: [] };
}
}
export async function getChapterContent(req: any, reply: FastifyReply) {
try {
const { bookId, chapter, provider } = req.params;
const source = req.query.source || 'anilist';
const content = await booksService.getChapterContent(
bookId,
chapter,
provider,
source
);
return reply.send(content);
} catch (err) {
console.error("getChapterContent error:", (err as Error).message);
return reply.code(500).send({ error: "Error loading chapter" });
}
}

View File

@@ -0,0 +1,14 @@
import { FastifyInstance } from 'fastify';
import * as controller from './books.controller';
async function booksRoutes(fastify: FastifyInstance) {
fastify.get('/book/:id', controller.getBook);
fastify.get('/books/trending', controller.getTrending);
fastify.get('/books/popular', controller.getPopular);
fastify.get('/search/books', controller.searchBooks);
fastify.get('/search/books/:extension', controller.searchBooksInExtension);
fastify.get('/book/:id/chapters', controller.getChapters);
fastify.get('/book/:bookId/:chapter/:provider', controller.getChapterContent);
}
export default booksRoutes;

View File

@@ -0,0 +1,572 @@
import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries';
import { queryOne, queryAll, run } from '../../shared/database';
import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions';
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co";
async function fetchAniList(query: string, variables: any) {
const res = await fetch(ANILIST_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query, variables })
});
if (!res.ok) {
throw new Error(`AniList error ${res.status}`);
}
const json = await res.json();
return json?.data;
}
const MEDIA_FIELDS = `
id
title {
romaji
english
native
userPreferred
}
type
format
status
description
startDate { year month day }
endDate { year month day }
season
seasonYear
episodes
chapters
volumes
duration
genres
synonyms
averageScore
popularity
favourites
isAdult
siteUrl
coverImage {
extraLarge
large
medium
color
}
bannerImage
updatedAt
`;
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
const row = await queryOne(
"SELECT full_data FROM books WHERE id = ?",
[id]
);
if (row) {
return JSON.parse(row.full_data);
}
try {
console.log(`[Book] Local miss for ID ${id}, fetching live...`);
const query = `
query ($id: Int) {
Media(id: $id, type: MANGA) {
id idMal title { romaji english native userPreferred } type format status description
startDate { year month day } endDate { year month day } season seasonYear seasonInt
episodes duration chapters volumes countryOfOrigin isLicensed source hashtag
trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color }
bannerImage genres synonyms averageScore meanScore popularity isLocked trending favourites
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult userId }
relations { edges { relationType node { id title { romaji } } } }
characters(page: 1, perPage: 10) { nodes { id name { full } } }
studios { nodes { id name isAnimationStudio } }
isAdult nextAiringEpisode { airingAt timeUntilAiring episode }
externalLinks { url site }
rankings { id rank type format year season allTime context }
}
}`;
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
query,
variables: { id: parseInt(id.toString()) }
})
});
const data = await response.json();
if (data?.data?.Media) {
const media = data.data.Media;
const insertSql = `
INSERT INTO books (id, title, updatedAt, full_data)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = EXCLUDED.title,
updatedAt = EXCLUDED.updatedAt,
full_data = EXCLUDED.full_data;
`;
await run(insertSql, [
media.id,
media.title?.userPreferred || media.title?.romaji || media.title?.english || null,
media.updatedAt || Math.floor(Date.now() / 1000),
JSON.stringify(media)
]);
return media;
}
} catch (e) {
console.error("Fetch error:", e);
}
return { error: "Book not found" };
}
export async function getTrendingBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data, updated_at FROM trending_books 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: MANGA, 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_books");
let rank = 1;
for (const book of list) {
await queryOne(
"INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, book.id, JSON.stringify(book), now]
);
}
return list;
}
export async function getPopularBooks(): Promise<Book[]> {
const rows = await queryAll(
"SELECT full_data, updated_at FROM popular_books 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: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await queryOne("DELETE FROM popular_books");
let rank = 1;
for (const book of list) {
await queryOne(
"INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
[rank++, book.id, JSON.stringify(book), now]
);
}
return list;
}
export async function searchBooksLocal(query: string): Promise<Book[]> {
if (!query || query.length < 2) {
return [];
}
const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`;
const rows = await queryAll(sql, [`%${query}%`]);
const results: Book[] = rows.map((row: { full_data: string; }) => JSON.parse(row.full_data));
const clean = results.filter(book => {
const searchTerms = [
book.title.english,
book.title.romaji,
book.title.native,
...(book.synonyms || [])
].filter(Boolean).map(t => t!.toLowerCase());
return searchTerms.some(term => term.includes(query.toLowerCase()));
});
return clean.slice(0, 10);
}
export async function searchBooksAniList(query: string): Promise<Book[]> {
const gql = `
query ($search: String) {
Page(page: 1, perPage: 5) {
media(search: $search, type: MANGA, isAdult: false) {
id title { romaji english native }
coverImage { extraLarge large }
bannerImage description averageScore format
seasonYear startDate { year }
}
}
}`;
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ query: gql, variables: { search: query } })
});
const liveData = await response.json();
if (liveData.data && liveData.data.Page.media.length > 0) {
return liveData.data.Page.media;
}
return [];
}
export async function getBookInfoExtension(ext: Extension | null, id: string): Promise<any[]> {
if (!ext) return [];
const extName = ext.constructor.name;
const cached = await getCachedExtension(extName, id);
if (cached) {
try {
return JSON.parse(cached.metadata);
} catch {}
}
if (ext.type === 'book-board' && ext.getMetadata) {
try {
const info = await ext.getMetadata(id);
if (info) {
const normalized = {
id: info.id ?? id,
title: info.title ?? "",
format: info.format ?? "",
score: typeof info.score === "number" ? info.score : null,
genres: Array.isArray(info.genres) ? info.genres : [],
status: info.status ?? "",
published: info.published ?? "",
summary: info.summary ?? "",
chapters: Number.isFinite(info.chapters) ? info.chapters : 1,
image: typeof info.image === "string" ? info.image : ""
};
await cacheExtension(extName, id, normalized.title, normalized);
return [normalized];
}
} catch (e) {
console.error(`Extension getInfo failed:`, e);
}
}
return [];
}
export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise<Book[]> {
if (!ext) return [];
if ((ext.type === 'book-board') && ext.search) {
try {
console.log(`[${name}] Searching for book: ${query}`);
const matches = await ext.search({
query: query,
media: {
romajiTitle: query,
englishTitle: query,
startDate: { year: 0, month: 0, day: 0 }
}
});
if (matches?.length) {
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: m.format,
seasonYear: null,
isExtensionResult: true
}));
}
} catch (e) {
console.error(`Extension search failed for ${name}:`, e);
}
}
return [];
}
async function fetchBookMetadata(id: string): Promise<Book | null> {
try {
const query = `query ($id: Int) {
Media(id: $id, type: MANGA) {
title { romaji english }
startDate { year month day }
}
}`;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { id: parseInt(id) } })
});
const d = await res.json();
return d.data?.Media || null;
} catch (e) {
console.error("Failed to fetch book metadata:", e);
return null;
}
}
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise<ChapterWithProvider[]> {
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`;
const cached = await getCache(cacheKey);
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
try {
return JSON.parse(cached.result) as ChapterWithProvider[];
} catch (e) {
console.error(`[${name}] Error parsing cached chapters:`, e);
}
} else {
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
}
}
try {
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
let mediaId: string;
if (search) {
const matches = await ext.search!({
query: searchTitle,
media: {
romajiTitle: searchTitle,
englishTitle: searchTitle,
startDate: { year: 0, month: 0, day: 0 }
}
});
const best = matches?.[0];
if (!best) { return [] }
mediaId = best.id;
} else {
const match = await ext.getMetadata(searchTitle);
mediaId = match.id;
}
const chaps = await ext.findChapters!(mediaId);
if (!chaps?.length){
return [];
}
console.log(`[${name}] Found ${chaps.length} chapters.`);
const result: ChapterWithProvider[] = chaps.map((ch) => ({
id: ch.id,
number: parseFloat(ch.number.toString()),
title: ch.title,
date: ch.releaseDate,
provider: name,
index: ch.index
}));
await setCache(cacheKey, result, CACHE_TTL_MS);
return result;
} catch (e) {
const error = e as Error;
console.error(`Failed to fetch chapters from ${name}:`, error.message);
return [];
}
}
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> {
let bookData: Book | null = null;
let searchTitle: string = "";
if (!ext) {
const result = await getBookById(id);
if (!result || "error" in result) return { chapters: [] }
bookData = result;
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[];
searchTitle = titles[0];
}
const bookExtensions = getBookExtensionsMap();
let extension;
if (!searchTitle) {
for (const [name, ext] of bookExtensions) {
const title = await getExtensionTitle(name, id)
if (title){
searchTitle = title;
extension = name;
}
}
}
const allChapters: any[] = [];
let exts = "anilist";
if (ext) exts = "ext";
for (const [name, ext] of bookExtensions) {
if (onlyProvider && name !== onlyProvider) continue;
if (name == extension) {
const chapters = await searchChaptersInExtension(ext, name, id, false, exts);
allChapters.push(...chapters);
} else {
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts);
allChapters.push(...chapters);
}
}
return {
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
};
}
export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string, source: string): Promise<ChapterContent> {
const extensions = getAllExtensions();
const ext = extensions.get(providerName);
if (!ext) {
throw new Error("Provider not found");
}
const contentCacheKey = `content:${providerName}:${source}:${bookId}:${chapterIndex}`;
const cachedContent = await getCache(contentCacheKey);
if (cachedContent) {
const isExpired = Date.now() - cachedContent.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${providerName}] Content cache hit for Book ID ${bookId}, Index ${chapterIndex}`);
try {
return JSON.parse(cachedContent.result) as ChapterContent;
} catch (e) {
console.error(`[${providerName}] Error parsing cached content:`, e);
}
} else {
console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterIndex}`);
}
}
const isExternal = source !== 'anilist';
const chapterList = await getChaptersForBook(bookId, isExternal, providerName);
if (!chapterList?.chapters || chapterList.chapters.length === 0) {
throw new Error("Chapters not found");
}
const providerChapters = chapterList.chapters.filter(c => c.provider === providerName);
const index = parseInt(chapterIndex, 10);
if (Number.isNaN(index)) {
throw new Error("Invalid chapter index");
}
if (!providerChapters[index]) {
throw new Error("Chapter index out of range");
}
const selectedChapter = providerChapters[index];
const chapterId = selectedChapter.id;
const chapterTitle = selectedChapter.title || null;
const chapterNumber = typeof selectedChapter.number === 'number' ? selectedChapter.number : index;
try {
if (!ext.findChapterPages) {
throw new Error("Extension doesn't support findChapterPages");
}
let contentResult: ChapterContent;
if (ext.mediaType === "manga") {
const pages = await ext.findChapterPages(chapterId);
contentResult = {
type: "manga",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider: providerName,
pages
};
} else if (ext.mediaType === "ln") {
const content = await ext.findChapterPages(chapterId);
contentResult = {
type: "ln",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider: providerName,
content
};
} else {
throw new Error("Unknown mediaType");
}
await setCache(contentCacheKey, contentResult, CACHE_TTL_MS);
return contentResult;
} catch (err) {
const error = err as Error;
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
throw err;
}
}

View File

@@ -0,0 +1,85 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions';
import { ExtensionNameRequest } from '../types';
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
return { extensions: getExtensionsList() };
}
export async function getAnimeExtensions(req: FastifyRequest, reply: FastifyReply) {
const animeExtensions = getAnimeExtensionsMap();
return { extensions: Array.from(animeExtensions.keys()) };
}
export async function getBookExtensions(req: FastifyRequest, reply: FastifyReply) {
const bookExtensions = getBookExtensionsMap();
return { extensions: Array.from(bookExtensions.keys()) };
}
export async function getGalleryExtensions(req: FastifyRequest, reply: FastifyReply) {
const galleryExtensions = getGalleryExtensionsMap();
return { extensions: Array.from(galleryExtensions.keys()) };
}
export async function getExtensionSettings(req: ExtensionNameRequest, reply: FastifyReply) {
const { name } = req.params;
const ext = getExtension(name);
if (!ext) {
return { error: "Extension not found" };
}
if (!ext.getSettings) {
return { episodeServers: ["default"], supportsDub: false };
}
return ext.getSettings();
}
export async function installExtension(req: any, reply: FastifyReply) {
const { fileName } = req.body;
if (!fileName || !fileName.endsWith('.js')) {
return reply.code(400).send({ error: "Invalid extension fileName provided" });
}
try {
const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}`
await saveExtensionFile(fileName, downloadUrl);
req.server.log.info(`Extension installed: ${fileName}`);
return reply.code(200).send({ success: true, message: `Extension ${fileName} installed successfully.` });
} catch (error) {
req.server.log.error(`Failed to install extension ${fileName}:`, error);
return reply.code(500).send({ success: false, error: `Failed to install extension ${fileName}.` });
}
}
export async function uninstallExtension(req: any, reply: FastifyReply) {
const { fileName } = req.body;
if (!fileName || !fileName.endsWith('.js')) {
return reply.code(400).send({ error: "Invalid extension fileName provided" });
}
try {
await deleteExtensionFile(fileName);
req.server.log.info(`Extension uninstalled: ${fileName}`);
return reply.code(200).send({ success: true, message: `Extension ${fileName} uninstalled successfully.` });
} catch (error) {
// @ts-ignore
if (error.code === 'ENOENT') {
return reply.code(200).send({ success: true, message: `Extension ${fileName} already uninstalled (file not found).` });
}
req.server.log.error(`Failed to uninstall extension ${fileName}:`, error);
return reply.code(500).send({ success: false, error: `Failed to uninstall extension ${fileName}.` });
}
}

View File

@@ -0,0 +1,14 @@
import { FastifyInstance } from 'fastify';
import * as controller from './extensions.controller';
async function extensionsRoutes(fastify: FastifyInstance) {
fastify.get('/extensions', controller.getExtensions);
fastify.get('/extensions/anime', controller.getAnimeExtensions);
fastify.get('/extensions/book', controller.getBookExtensions);
fastify.get('/extensions/gallery', controller.getGalleryExtensions);
fastify.get('/extensions/:name/settings', controller.getExtensionSettings);
fastify.post('/extensions/install', controller.installExtension);
fastify.post('/extensions/uninstall', controller.uninstallExtension);
}
export default extensionsRoutes;

View File

@@ -0,0 +1,126 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import * as galleryService from './gallery.service';
export async function searchInExtension(req: any, reply: FastifyReply) {
try {
const provider = req.query.provider;
const query = req.query.q || '';
const page = parseInt(req.query.page as string) || 1;
const perPage = parseInt(req.query.perPage as string) || 48;
if (!provider) {
return reply.code(400).send({ error: "Missing provider" });
}
return await galleryService.searchInExtension(provider, query, page, perPage);
} catch (err) {
console.error("Gallery SearchInExtension Error:", (err as Error).message);
return {
results: [],
total: 0,
page: 1,
hasNextPage: false
};
}
}
export async function getInfo(req: any, reply: FastifyReply) {
try {
const { id } = req.params;
const provider = req.query.provider;
return await galleryService.getGalleryInfo(id, provider);
} catch (err) {
const error = err as Error;
console.error("Gallery Info Error:", error.message);
return reply.code(404).send({ error: "Gallery item not found" });
}
}
export async function getFavorites(req: any, reply: FastifyReply) {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" });
const favorites = await galleryService.getFavorites(req.user.id);
return { favorites };
} catch (err) {
console.error("Get Favorites Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to retrieve favorites" });
}
}
export async function getFavoriteById(req: any, reply: FastifyReply) {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" });
const { id } = req.params as { id: string };
const favorite = await galleryService.getFavoriteById(id, req.user.id);
if (!favorite) {
return reply.code(404).send({ error: "Favorite not found" });
}
return { favorite };
} catch (err) {
console.error("Get Favorite By ID Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to retrieve favorite" });
}
}
export async function addFavorite(req: any, reply: FastifyReply) {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" });
const { id, title, image_url, thumbnail_url, tags, provider, headers } = req.body;
if (!id || !title || !image_url || !thumbnail_url) {
return reply.code(400).send({
error: "Missing required fields"
});
}
const result = await galleryService.addFavorite({
id,
user_id: req.user.id,
title,
image_url,
thumbnail_url,
tags: tags || '',
provider: provider || "",
headers: headers || ""
});
if (result.success) {
return reply.code(201).send(result);
} else {
return reply.code(409).send(result);
}
} catch (err) {
console.error("Add Favorite Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to add favorite" });
}
}
export async function removeFavorite(req: any, reply: FastifyReply) {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" });
const { id } = req.params;
const result = await galleryService.removeFavorite(id, req.user.id);
if (result.success) {
return { success: true, message: "Favorite removed successfully" };
} else {
return reply.code(404).send({ error: "Favorite not found" });
}
} catch (err) {
console.error("Remove Favorite Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to remove favorite" });
}
}

View File

@@ -0,0 +1,13 @@
import { FastifyInstance } from 'fastify';
import * as controller from './gallery.controller';
async function galleryRoutes(fastify: FastifyInstance) {
fastify.get('/gallery/fetch/:id', controller.getInfo);
fastify.get('/gallery/search/provider', controller.searchInExtension);
fastify.get('/gallery/favorites', controller.getFavorites);
fastify.get('/gallery/favorites/:id', controller.getFavoriteById);
fastify.post('/gallery/favorites', controller.addFavorite);
fastify.delete('/gallery/favorites/:id', controller.removeFavorite);
}
export default galleryRoutes;

View File

@@ -0,0 +1,178 @@
import { getAllExtensions, getExtension } from '../../shared/extensions';
import { GallerySearchResult, GalleryInfo, Favorite, FavoriteResult } from '../types';
import { getDatabase } from '../../shared/database';
export async function getGalleryInfo(id: string, providerName?: string): Promise<any> {
const extensions = getAllExtensions();
if (providerName) {
const ext = extensions.get(providerName);
if (ext && ext.type === 'image-board' && ext.getInfo) {
try {
console.log(`[Gallery] Getting info from ${providerName} for: ${id}`);
const info = await ext.getInfo(id);
return {
id: info.id ?? id,
provider: providerName,
image: info.image,
tags: info.tags,
title: info.title,
headers: info.headers
};
} catch (e) {
const error = e as Error;
console.error(`[Gallery] Failed to get info from ${providerName}:`, error.message);
throw new Error(`Failed to get gallery info from ${providerName}`);
}
}
throw new Error("Provider not found or doesn't support getInfo");
}
for (const [name, ext] of extensions) {
if (ext.type === 'gallery' && ext.getInfo) {
try {
console.log(`[Gallery] Trying to get info from ${name} for: ${id}`);
const info = await ext.getInfo(id);
return {
...info,
provider: name
};
} catch {
continue;
}
}
}
throw new Error("Gallery item not found in any extension");
}
export async function searchInExtension(providerName: string, query: string, page: number = 1, perPage: number = 48): Promise<any> {
const ext = getExtension(providerName);
try {
console.log(`[Gallery] Searching ONLY in ${providerName} for: ${query}`);
const results = await ext.search(query, page, perPage);
const normalizedResults = (results?.results ?? []).map((r: any) => ({
id: r.id,
image: r.image,
tags: r.tags,
title: r.title,
headers: r.headers,
provider: providerName
}));
return {
page: results.page ?? page,
hasNextPage: !!results.hasNextPage,
results: normalizedResults
};
} catch (e) {
const error = e as Error;
console.error(`[Gallery] Search failed in ${providerName}:`, error.message);
return {
total: 0,
next: 0,
previous: 0,
pages: 0,
page,
hasNextPage: false,
results: []
};
}
}
export async function getFavorites(userId: number): Promise<Favorite[]> {
const db = getDatabase("favorites");
return new Promise((resolve) => {
db.all(
'SELECT * FROM favorites WHERE user_id = ?',
[userId],
(err: Error | null, rows: Favorite[]) => {
if (err) {
console.error('Error getting favorites:', err.message);
resolve([]);
} else {
resolve(rows);
}
}
);
});
}
export async function getFavoriteById(id: string, userId: number): Promise<Favorite | null> {
const db = getDatabase("favorites");
return new Promise((resolve) => {
db.get(
'SELECT * FROM favorites WHERE id = ? AND user_id = ?',
[id, userId],
(err: Error | null, row: Favorite | undefined) => {
if (err) {
console.error('Error getting favorite by id:', err.message);
resolve(null);
} else {
resolve(row || null);
}
}
);
});
}
export async function addFavorite(fav: Favorite & { user_id: number }): Promise<FavoriteResult> {
const db = getDatabase("favorites");
return new Promise((resolve) => {
const stmt = `
INSERT INTO favorites (id, user_id, title, image_url, thumbnail_url, tags, headers, provider)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(
stmt,
[
fav.id,
fav.user_id,
fav.title,
fav.image_url,
fav.thumbnail_url,
fav.tags || "",
fav.headers || "",
fav.provider || ""
],
function (err: any) {
if (err) {
if (err.code && err.code.includes('SQLITE_CONSTRAINT')) {
resolve({ success: false, error: 'Item is already a favorite.' });
} else {
console.error('Error adding favorite:', err.message);
resolve({ success: false, error: err.message });
}
} else {
resolve({ success: true, id: fav.id });
}
}
);
});
}
export async function removeFavorite(id: string, userId: number): Promise<FavoriteResult> {
const db = getDatabase("favorites");
return new Promise((resolve) => {
const stmt = 'DELETE FROM favorites WHERE id = ? AND user_id = ?';
db.run(stmt, [id, userId], function (err: Error | null) {
if (err) {
console.error('Error removing favorite:', err.message);
resolve({ success: false, error: err.message });
} else {
// @ts-ignore
resolve({ success: this.changes > 0 });
}
});
});
}

View File

@@ -0,0 +1,166 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import * as listService from './list.service';
interface UserRequest extends FastifyRequest {
user?: { id: number };
}
interface EntryParams {
entryId: string;
}
interface SingleEntryQuery {
source: string;
entry_type: string;
}
export async function getList(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
try {
const results = await listService.getUserList(userId);
return { results };
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve list" });
}
}
export async function getSingleEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const { entryId } = req.params as EntryParams;
const { source, entry_type } = req.query as SingleEntryQuery;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!entryId || !source || !entry_type) {
return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." });
}
try {
const entry = await listService.getSingleListEntry(
userId,
entryId,
source,
entry_type
);
if (!entry) {
return reply.code(404).send({ found: false, message: "Entry not found in user list." });
}
return { found: true, entry: entry };
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve list entry" });
}
}
export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const body = req.body as any;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!body.entry_id || !body.source || !body.status || !body.entry_type) {
return reply.code(400).send({
error: "Missing required fields (entry_id, source, status, entry_type)."
});
}
try {
const entryData = {
user_id: userId,
entry_id: body.entry_id,
external_id: body.external_id,
source: body.source,
entry_type: body.entry_type,
status: body.status,
progress: body.progress || 0,
score: body.score || null,
start_date: body.start_date || null,
end_date: body.end_date || null,
repeat_count: body.repeat_count ?? 0,
notes: body.notes || null,
is_private: body.is_private ?? 0
};
const result = await listService.upsertListEntry(entryData);
return { success: true, changes: result.changes };
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to save list entry" });
}
}
export async function deleteEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const { entryId } = req.params as EntryParams;
const { source } = req.query as { source?: string }; // ✅ VIENE DEL FRONT
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!entryId || !source) {
return reply.code(400).send({ error: "Missing entryId or source." });
}
try {
const result = await listService.deleteListEntry(
userId,
entryId,
source
);
if (result.success) {
return { success: true, external: result.external };
} else {
return reply.code(404).send({
error: "Entry not found or unauthorized to delete."
});
}
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to delete list entry" });
}
}
export async function getListByFilter(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const { status, entry_type } = req.query as any;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!status && !entry_type) {
return reply.code(400).send({
error: "At least one filter is required (status or entry_type)."
});
}
try {
const results = await listService.getUserListByFilter(
userId,
status,
entry_type
);
return { results };
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve filtered list" });
}
}

View File

@@ -0,0 +1,12 @@
import { FastifyInstance } from 'fastify';
import * as controller from './list.controller';
async function listRoutes(fastify: FastifyInstance) {
fastify.get('/list', controller.getList);
fastify.get('/list/entry/:entryId', controller.getSingleEntry);
fastify.post('/list/entry', controller.upsertEntry);
fastify.delete('/list/entry/:entryId', controller.deleteEntry);
fastify.get('/list/filter', controller.getListByFilter);
}
export default listRoutes;

View File

@@ -0,0 +1,584 @@
import {queryAll, run, queryOne} from '../../shared/database';
import {getExtension} from '../../shared/extensions';
import * as animeService from '../anime/anime.service';
import * as booksService from '../books/books.service';
import * as aniListService from '../anilist/anilist.service';
interface ListEntryData {
entry_type: any;
user_id: number;
entry_id: number;
source: string;
status: string;
progress: number;
score: number | null;
}
const USER_DB = 'userdata';
export async function upsertListEntry(entry: any) {
const {
user_id,
entry_id,
source,
entry_type,
status,
progress,
score,
start_date,
end_date,
repeat_count,
notes,
is_private
} = entry;
let prev: any = null;
try {
prev = await getSingleListEntry(user_id, entry_id, source, entry_type);
} catch {
prev = null;
}
const isNew = !prev;
if (!isNew && prev?.progress != null && progress < prev.progress) {
return { changes: 0, ignored: true };
}
const today = new Date().toISOString().slice(0, 10);
if (prev?.start_date && !entry.start_date) {
entry.start_date = prev.start_date;
}
if (!prev?.start_date && progress === 1) {
entry.start_date = today;
}
const total =
prev?.total_episodes ??
prev?.total_chapters ??
null;
if (total && progress >= total) {
entry.status = 'COMPLETED';
entry.end_date = today;
}
if (source === 'anilist') {
const token = await getActiveAccessToken(user_id);
if (token) {
try {
const result = await aniListService.updateAniListEntry(token, {
mediaId: entry.entry_id,
status: entry.status,
progress: entry.progress,
score: entry.score,
start_date: entry.start_date,
end_date: entry.end_date,
repeat_count: entry.repeat_count,
notes: entry.notes,
is_private: entry.is_private
});
return { changes: 0, external: true, anilistResult: result };
} catch (err) {
console.error("Error actualizando AniList:", err);
}
}
}
const sql = `
INSERT INTO ListEntry
(
user_id, entry_id, source, entry_type, status,
progress, score,
start_date, end_date, repeat_count, notes, is_private,
updated_at
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, entry_id) DO UPDATE SET
source = EXCLUDED.source,
entry_type = EXCLUDED.entry_type,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
score = EXCLUDED.score,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
repeat_count = EXCLUDED.repeat_count,
notes = EXCLUDED.notes,
is_private = EXCLUDED.is_private,
updated_at = CURRENT_TIMESTAMP;
`;
const params = [
entry.user_id,
entry.entry_id,
entry.source,
entry.entry_type,
entry.status,
entry.progress,
entry.score ?? null,
entry.start_date || null,
entry.end_date || null,
entry.repeat_count ?? 0,
entry.notes || null,
entry.is_private ?? 0
];
try {
const result = await run(sql, params, USER_DB);
return { changes: result.changes, lastID: result.lastID, external: false };
} catch (error) {
console.error("Error al guardar la entrada de lista:", error);
throw new Error("Error en la base de datos al guardar la entrada.");
}
}
export async function getUserList(userId: number): Promise<any> {
const sql = `
SELECT * FROM ListEntry
WHERE user_id = ?
ORDER BY updated_at DESC;
`;
try {
const dbList = await queryAll(sql, [userId], USER_DB) as ListEntryData[];
const connected = await isConnected(userId);
let finalList: any[] = [...dbList];
if (connected) {
const anilistEntries = await aniListService.getUserAniList(userId);
const localWithoutAnilist = dbList.filter(
entry => entry.source !== 'anilist'
);
finalList = [...anilistEntries, ...localWithoutAnilist];
}
const enrichedListPromises = finalList.map(async (entry) => {
if (entry.source === 'anilist' && connected) {
let finalTitle = entry.title;
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
};
}
let contentDetails: any | null = null;
const id = entry.entry_id;
const type = entry.entry_type;
const ext = getExtension(entry.source);
try {
if (type === 'ANIME') {
if(entry.source === 'anilist') {
const anime: any = await animeService.getAnimeById(id);
contentDetails = {
title: anime?.title.english || 'Unknown Anime Title',
poster: anime?.coverImage?.extraLarge || '',
total_episodes: anime?.episodes || 0,
};
}
else{
const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString());
contentDetails = {
title: anime?.title || 'Unknown Anime Title',
poster: anime?.image || 'https://placehold.co/400x600?text=No+Cover',
total_episodes: anime?.episodes || 0,
};
}
} else if (type === 'MANGA' || type === 'NOVEL') {
if(entry.source === 'anilist') {
const book: any = await booksService.getBookById(id);
contentDetails = {
title: book?.title.english || 'Unknown Book Title',
poster: book?.coverImage?.extraLarge || 'https://placehold.co/400x600?text=No+Cover',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
else{
const book: any = await booksService.getBookInfoExtension(ext, id.toString());
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
}
} catch {
contentDetails = {
title: 'Error Loading Details',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
return await Promise.all(enrichedListPromises);
} catch (error) {
console.error("Error al obtener la lista del usuario:", error);
throw new Error("Error getting list.");
}
}
export async function deleteListEntry(
userId: number,
entryId: string | number,
source: string
) {
if (source === 'anilist') {
const token = await getActiveAccessToken(userId);
if (token) {
try {
await aniListService.deleteAniListEntry(
token,
Number(entryId),
);
return { success: true, external: true };
} catch (err) {
console.error("Error borrando en AniList:", err);
}
}
}
const sql = `
DELETE FROM ListEntry
WHERE user_id = ? AND entry_id = ?;
`;
const result = await run(sql, [userId, entryId], USER_DB);
return { success: result.changes > 0, changes: result.changes, external: false };
}
export async function getSingleListEntry(
userId: number,
entryId: string | number,
source: string,
entryType: string
): Promise<any> {
const localSql = `
SELECT * FROM ListEntry
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
`;
const localResult = await queryAll(
localSql,
[userId, entryId, source, entryType],
USER_DB
) as any[];
if (localResult.length > 0) {
const entry = localResult[0];
const contentDetails: any =
entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
if (source === 'anilist') {
const connected = await isConnected(userId);
if (!connected) return null;
const sql = `
SELECT access_token
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [userId], USER_DB) as any;
if (!integration?.access_token) return null;
if (entryType === 'NOVEL') {entryType = 'MANGA'}
const aniEntry = await aniListService.getSingleAniListEntry(
integration.access_token,
Number(entryId),
entryType as any
);
if (!aniEntry) return null;
const contentDetails: any =
entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
user_id: userId,
...aniEntry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
return null;
}
export async function getActiveAccessToken(userId: number): Promise<string | null> {
const sql = `
SELECT access_token, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
try {
const integration = await queryOne(sql, [userId], USER_DB) as any | null;
if (!integration) {
return null;
}
const expiryDate = new Date(integration.expires_at);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
if (expiryDate.getTime() < (now.getTime() + fiveMinutes)) {
console.log(`AniList token for user ${userId} expired or near expiry.`);
return null;
}
return integration.access_token;
} catch (error) {
console.error("Error al verificar la integración de AniList:", error);
return null;
}
}
export async function isConnected(userId: number): Promise<boolean> {
const token = await getActiveAccessToken(userId);
return !!token;
}
export async function getUserListByFilter(
userId: number,
status?: string,
entryType?: string
): Promise<any> {
let sql = `
SELECT * FROM ListEntry
WHERE user_id = ?
ORDER BY updated_at DESC;
`;
const params: any[] = [userId];
try {
const dbList = await queryAll(sql, params, USER_DB) as ListEntryData[];
const connected = await isConnected(userId);
const statusMap: any = {
watching: 'CURRENT',
reading: 'CURRENT',
completed: 'COMPLETED',
paused: 'PAUSED',
dropped: 'DROPPED',
planning: 'PLANNING'
};
const mappedStatus = status ? statusMap[status.toLowerCase()] : null;
let finalList: any[] = [];
const filteredLocal = dbList.filter((entry) => {
if (mappedStatus && entry.status !== mappedStatus) return false;
if (entryType) {
if (entryType === 'MANGA') {
if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false;
} else {
if (entry.entry_type !== entryType) return false;
}
}
return true;
});
let filteredAniList: any[] = [];
if (connected) {
const anilistEntries = await aniListService.getUserAniList(userId);
filteredAniList = anilistEntries.filter((entry: any) => {
if (mappedStatus && entry.status !== mappedStatus) return false;
if (entryType) {
if (entryType === 'MANGA') {
if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false;
} else {
if (entry.entry_type !== entryType) return false;
}
}
return true;
});
}
finalList = [...filteredAniList, ...filteredLocal];
const enrichedListPromises = finalList.map(async (entry) => {
if (entry.source === 'anilist') {
let finalTitle = entry.title;
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
};
}
let contentDetails: any | null = null;
const id = entry.entry_id;
const type = entry.entry_type;
const ext = getExtension(entry.source);
try {
if (type === 'ANIME') {
const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString());
contentDetails = {
title: anime?.title || 'Unknown Anime Title',
poster: anime?.image || '',
total_episodes: anime?.episodes || 0,
};
} else if (type === 'MANGA' || type === 'NOVEL') {
const book: any = await booksService.getBookInfoExtension(ext, id.toString());
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
} catch {
contentDetails = {
title: 'Error Loading Details',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
return await Promise.all(enrichedListPromises);
} catch (error) {
console.error("Error al filtrar la lista del usuario:", error);
throw new Error("Error en la base de datos al obtener la lista filtrada.");
}
}

View File

@@ -0,0 +1,60 @@
import {FastifyReply} from 'fastify';
import {processM3U8Content, proxyRequest, streamToReadable} from './proxy.service';
import {ProxyRequest} from '../types';
export async function handleProxy(req: ProxyRequest, reply: FastifyReply) {
const { url, referer, origin, userAgent } = req.query;
if (!url) {
return reply.code(400).send({ error: "No URL provided" });
}
try {
const { response, contentType, isM3U8, contentLength } = await proxyRequest(url, {
referer,
origin,
userAgent
});
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Range');
reply.header('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
if (contentType) {
reply.header('Content-Type', contentType);
}
if (contentLength) {
reply.header('Content-Length', contentLength);
}
if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) {
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
}
reply.header('Accept-Ranges', 'bytes');
if (isM3U8) {
const text = await response.text();
const baseUrl = new URL(response.url);
const processedContent = processM3U8Content(text, baseUrl, {
referer,
origin,
userAgent
});
return reply.send(processedContent);
}
return reply.send(streamToReadable(response.body!));
} catch (err) {
req.server.log.error(err);
if (!reply.sent) {
return reply.code(500).send({ error: "Internal Server Error" });
}
}
}

View File

@@ -0,0 +1,8 @@
import { FastifyInstance } from 'fastify';
import { handleProxy } from './proxy.controller';
async function proxyRoutes(fastify: FastifyInstance) {
fastify.get('/proxy', handleProxy);
}
export default proxyRoutes;

View File

@@ -0,0 +1,138 @@
import { Readable } from 'stream';
interface ProxyHeaders {
referer?: string;
origin?: string;
userAgent?: string;
}
interface ProxyResponse {
response: Response;
contentType: string | null;
isM3U8: boolean;
contentLength: string | null;
}
export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders): Promise<ProxyResponse> {
const headers: Record<string, string> = {
'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'identity',
'Connection': 'keep-alive'
};
if (referer) headers['Referer'] = referer;
if (origin) headers['Origin'] = origin;
let lastError: Error | null = null;
const maxRetries = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
const response = await fetch(url, {
headers,
redirect: 'follow',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404 || response.status === 403) {
throw new Error(`Proxy Error: ${response.status} ${response.statusText}`);
}
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
throw new Error(`Proxy Error: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8');
return {
response,
contentType,
isM3U8,
contentLength
};
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries - 1) {
throw lastError;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
throw lastError || new Error('Unknown error in proxyRequest');
}
export function processM3U8Content(text: string, baseUrl: URL, { referer, origin, userAgent }: ProxyHeaders): string {
return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => {
line = line.trim();
let absoluteUrl: string;
try {
absoluteUrl = new URL(line, baseUrl).href;
} catch (e) {
return line;
}
const proxyParams = new URLSearchParams();
proxyParams.set('url', absoluteUrl);
if (referer) proxyParams.set('referer', referer);
if (origin) proxyParams.set('origin', origin);
if (userAgent) proxyParams.set('userAgent', userAgent);
return `/api/proxy?${proxyParams.toString()}`;
});
}
export function streamToReadable(webStream: ReadableStream): Readable {
const reader = webStream.getReader();
let readTimeout: NodeJS.Timeout;
return new Readable({
async read() {
try {
const timeoutPromise = new Promise((_, reject) => {
readTimeout = setTimeout(() => reject(new Error('Stream read timeout')), 10000);
});
const readPromise = reader.read();
const { done, value } = await Promise.race([readPromise, timeoutPromise]) as any;
clearTimeout(readTimeout);
if (done) {
this.push(null);
} else {
this.push(Buffer.from(value));
}
} catch (error) {
clearTimeout(readTimeout);
this.destroy(error as Error);
}
},
destroy(error, callback) {
clearTimeout(readTimeout);
reader.cancel().then(() => callback(error)).catch(callback);
}
});
}

294
docker/src/api/types.ts Normal file
View File

@@ -0,0 +1,294 @@
import { FastifyRequest, FastifyReply } from 'fastify';
export interface AnimeTitle {
romaji: string;
english: string | null;
native: string | null;
userPreferred?: string;
}
export interface CoverImage {
extraLarge?: string;
large: string;
medium?: string;
color?: string;
}
export interface StartDate {
year: number;
month: number;
day: number;
}
export interface Anime {
updatedAt: any;
id: number | string;
title: AnimeTitle;
coverImage: CoverImage;
bannerImage?: string;
description?: string;
averageScore: number | null;
format: string;
seasonYear: number | null;
startDate?: StartDate;
synonyms?: string[];
extensionName?: string;
isExtensionResult?: boolean;
}
export interface Book {
id: number | string;
title: AnimeTitle;
coverImage: CoverImage;
bannerImage?: string;
description?: string;
averageScore: number | null;
format: string;
seasonYear: number | null;
startDate?: StartDate;
synonyms?: string[];
extensionName?: string;
isExtensionResult?: boolean;
}
export interface ExtensionSearchOptions {
query: string;
dub?: boolean;
media?: {
romajiTitle: string;
englishTitle: string;
startDate: StartDate;
};
}
export interface ExtensionSearchResult {
format: string;
headers: any;
id: string;
title: string;
image?: string;
rating?: number;
score?: number;
}
export interface Episode {
url: string;
id: string;
number: number;
title?: string;
}
export interface Chapter {
index: number;
id: string;
number: string | number;
title?: string;
releaseDate?: string;
}
export interface ChapterWithProvider extends Chapter {
provider: string;
date?: string;
}
export interface Extension {
getMetadata: any;
type: 'anime-board' | 'book-board' | 'manga-board';
mediaType?: 'manga' | 'ln';
search?: (options: ExtensionSearchOptions) => Promise<ExtensionSearchResult[]>;
findEpisodes?: (id: string) => Promise<Episode[]>;
findEpisodeServer?: (episode: Episode, server: string) => Promise<any>;
findChapters?: (id: string) => Promise<Chapter[]>;
findChapterPages?: (chapterId: string) => Promise<any>;
getSettings?: () => ExtensionSettings;
}
export interface ExtensionSettings {
episodeServers: string[];
supportsDub: boolean;
}
export interface StreamData {
url?: string;
sources?: any[];
subtitles?: any[];
}
export interface MangaChapterContent {
type: 'manga';
chapterId: string;
title: string | null;
number: number;
provider: string;
pages: any[];
}
export interface LightNovelChapterContent {
type: 'ln';
chapterId: string;
title: string | null;
number: number;
provider: string;
content: any;
}
export type ChapterContent = MangaChapterContent | LightNovelChapterContent;
export interface AnimeParams {
id: string;
}
export interface AnimeQuery {
source?: string;
}
export interface SearchQuery {
q: string;
}
export interface ExtensionNameParams {
name: string;
}
export interface WatchStreamQuery {
source: string;
animeId: string;
episode: string;
server?: string;
category?: string;
ext: string;
}
export interface BookParams {
id: string;
}
export interface BookQuery {
ext?: string;
}
export interface ChapterParams {
bookId: string;
chapter: string;
provider: string;
}
export interface ProxyQuery {
url: string;
referer?: string;
origin?: string;
userAgent?: string;
}
export type AnimeRequest = FastifyRequest<{
Params: AnimeParams;
Querystring: AnimeQuery;
}>;
export type SearchRequest = FastifyRequest<{
Querystring: SearchQuery;
}>;
export type ExtensionNameRequest = FastifyRequest<{
Params: ExtensionNameParams;
}>;
export type WatchStreamRequest = FastifyRequest<{
Querystring: WatchStreamQuery;
}>;
export type BookRequest = FastifyRequest<{
Params: BookParams;
Querystring: BookQuery;
}>;
export type ChapterRequest = FastifyRequest<{
Params: ChapterParams;
}>;
export type ProxyRequest = FastifyRequest<{
Querystring: ProxyQuery;
}>;
export interface GalleryItemPreview {
id: string;
image: string;
tags: string[];
type: 'preview';
provider?: string;
}
export interface GallerySearchResult {
total: number;
next: number;
previous: number;
pages: number;
page: number;
hasNextPage: boolean;
results: GalleryItemPreview[];
}
export interface GalleryInfo {
id: string;
fullImage: string;
resizedImageUrl: string;
tags: string[];
createdAt: string | null;
publishedBy: string;
rating: string;
comments: any[];
provider?: string;
}
export interface GalleryExtension {
type: 'gallery';
search: (query: string, page: number, perPage: number) => Promise<GallerySearchResult>;
getInfo: (id: string) => Promise<Omit<GalleryInfo, 'provider'>>;
}
export interface GallerySearchRequest extends FastifyRequest {
query: {
q?: string;
page?: string;
perPage?: string;
};
}
export interface GalleryInfoRequest extends FastifyRequest {
params: {
id: string;
};
query: {
provider?: string;
};
}
export interface AddFavoriteBody {
id: string;
title: string;
image_url: string;
thumbnail_url: string;
tags?: string;
provider: string;
headers: string;
}
export interface RemoveFavoriteParams {
id: string;
}
export interface Favorite {
id: string;
title: string;
image_url: string;
thumbnail_url: string;
tags: string;
provider: string;
headers: string;
}
export interface FavoriteResult {
success: boolean;
error?: string;
id?: string;
}

View File

@@ -0,0 +1,269 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import * as userService from './user.service';
import {queryOne} from '../../shared/database';
import jwt from "jsonwebtoken";
interface UserIdParams { id: string; }
interface CreateUserBody {
username: string;
profilePictureUrl?: string;
password?: string;
}
interface UpdateUserBody {
username?: string;
profilePictureUrl?: string | null;
password?: string | null;
}
interface LoginBody {
userId: number;
password?: string;
}
interface DBRunResult { changes: number; lastID: number; }
export async function getMe(req: any, reply: any) {
const userId = req.user?.id;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
const user = await queryOne(
`SELECT username, profile_picture_url FROM User WHERE id = ?`,
[userId],
'userdata'
);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
return reply.send({
username: user.username,
avatar: user.profile_picture_url
});
}
export async function login(req: FastifyRequest, reply: FastifyReply) {
const { userId, password } = req.body as LoginBody;
if (!userId || typeof userId !== "number" || userId <= 0) {
return reply.code(400).send({ error: "Invalid userId provided" });
}
const user = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found in local database" });
}
// Si el usuario tiene contraseña, debe proporcionarla
if (user.has_password) {
if (!password) {
return reply.code(401).send({
error: "Password required",
requiresPassword: true
});
}
const isValid = await userService.verifyPassword(userId, password);
if (!isValid) {
return reply.code(401).send({ error: "Incorrect password" });
}
}
const token = jwt.sign(
{ id: userId },
process.env.JWT_SECRET!,
{ expiresIn: "7d" }
);
return reply.code(200).send({
success: true,
token
});
}
export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) {
try {
const users: any = await userService.getAllUsers();
return { users };
} catch (err) {
console.error("Get All Users Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to retrieve user list" });
}
}
export async function createUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { username, profilePictureUrl, password } = req.body as CreateUserBody;
if (!username) {
return reply.code(400).send({ error: "Missing required field: username" });
}
const result: any = await userService.createUser(username, profilePictureUrl, password);
return reply.code(201).send({
success: true,
userId: result.lastID,
username
});
} catch (err) {
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
return reply.code(409).send({ error: "Username already exists." });
}
console.error("Create User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to create user" });
}
}
export async function getUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as UserIdParams;
const userId = parseInt(id, 10);
const user: any = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
return { user };
} catch (err) {
console.error("Get User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to retrieve user" });
}
}
export async function updateUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as UserIdParams;
const userId = parseInt(id, 10);
const updates = req.body as UpdateUserBody;
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: "No update fields provided" });
}
const result: DBRunResult = await userService.updateUser(userId, updates);
if (result && result.changes > 0) {
return { success: true, message: "User updated successfully" };
} else {
return reply.code(404).send({ error: "User not found or nothing to update" });
}
} catch (err) {
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
return reply.code(409).send({ error: "Username already exists or is invalid." });
}
console.error("Update User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to update user" });
}
}
export async function deleteUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const result = await userService.deleteUser(userId);
if (result && result.changes > 0) {
return { success: true, message: "User deleted successfully" };
} else {
return reply.code(404).send({ error: "User not found" });
}
} catch (err) {
console.error("Delete User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to delete user" });
}
}
export async function getIntegrationStatus(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const integration = await userService.getAniListIntegration(userId);
return reply.code(200).send(integration);
} catch (err) {
console.error("Get Integration Status Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to check integration status" });
}
}
export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const result = await userService.removeAniListIntegration(userId);
if (result.changes === 0) {
return reply.code(404).send({ error: "AniList integration not found" });
}
return reply.send({ success: true });
} catch (err) {
console.error("Disconnect AniList Error:", err);
return reply.code(500).send({ error: "Failed to disconnect AniList" });
}
}
export async function changePassword(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const { currentPassword, newPassword } = req.body as {
currentPassword?: string;
newPassword: string | null;
};
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const user = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
// Si el usuario tiene contraseña actual, debe proporcionar la contraseña actual
if (user.has_password && currentPassword) {
const isValid = await userService.verifyPassword(userId, currentPassword);
if (!isValid) {
return reply.code(401).send({ error: "Current password is incorrect" });
}
}
// Actualizar la contraseña (null para eliminarla, string para establecerla)
await userService.updateUser(userId, { password: newPassword });
return reply.send({
success: true,
message: newPassword ? "Password updated successfully" : "Password removed successfully"
});
} catch (err) {
console.error("Change Password Error:", err);
return reply.code(500).send({ error: "Failed to change password" });
}
}

View File

@@ -0,0 +1,17 @@
import { FastifyInstance } from 'fastify';
import * as controller from './user.controller';
async function userRoutes(fastify: FastifyInstance) {
fastify.get('/me',controller.getMe);
fastify.post("/login", controller.login);
fastify.get('/users', controller.getAllUsers);
fastify.post('/users', { bodyLimit: 1024 * 1024 * 50 }, controller.createUser);
fastify.get('/users/:id', controller.getUser);
fastify.put('/users/:id', { bodyLimit: 1024 * 1024 * 50 }, controller.updateUser);
fastify.delete('/users/:id', controller.deleteUser);
fastify.get('/users/:id/integration', controller.getIntegrationStatus);
fastify.delete('/users/:id/integration', controller.disconnectAniList);
fastify.put('/users/:id/password', controller.changePassword);
}
export default userRoutes;

View File

@@ -0,0 +1,186 @@
import {queryAll, queryOne, run} from '../../shared/database';
import bcrypt from 'bcrypt';
const USER_DB_NAME = 'userdata';
const SALT_ROUNDS = 10;
interface User {
id: number;
username: string;
profile_picture_url: string | null;
has_password: boolean;
}
export async function userExists(id: number): Promise<boolean> {
const sql = 'SELECT 1 FROM User WHERE id = ?';
const row = await queryOne(sql, [id], USER_DB_NAME);
return !!row;
}
export async function createUser(username: string, profilePictureUrl?: string, password?: string): Promise<{ lastID: number }> {
let passwordHash = null;
if (password && password.trim()) {
passwordHash = await bcrypt.hash(password.trim(), SALT_ROUNDS);
}
const sql = `
INSERT INTO User (username, profile_picture_url, password_hash)
VALUES (?, ?, ?)
`;
const params = [username, profilePictureUrl || null, passwordHash];
const result = await run(sql, params, USER_DB_NAME);
return { lastID: result.lastID };
}
export async function updateUser(userId: number, updates: any): Promise<any> {
const fields: string[] = [];
const values: (string | number | null)[] = [];
if (updates.username !== undefined) {
fields.push('username = ?');
values.push(updates.username);
}
if (updates.profilePictureUrl !== undefined) {
fields.push('profile_picture_url = ?');
values.push(updates.profilePictureUrl);
}
if (updates.password !== undefined) {
if (updates.password === null || updates.password === '') {
// Eliminar contraseña
fields.push('password_hash = ?');
values.push(null);
} else {
// Actualizar contraseña
const hash = await bcrypt.hash(updates.password.trim(), SALT_ROUNDS);
fields.push('password_hash = ?');
values.push(hash);
}
}
if (fields.length === 0) {
return { changes: 0, lastID: userId };
}
const setClause = fields.join(', ');
const sql = `UPDATE User SET ${setClause} WHERE id = ?`;
values.push(userId);
return await run(sql, values, USER_DB_NAME);
}
export async function deleteUser(userId: number): Promise<any> {
await run(
`DELETE FROM ListEntry WHERE user_id = ?`,
[userId],
USER_DB_NAME
);
await run(
`DELETE FROM UserIntegration WHERE user_id = ?`,
[userId],
USER_DB_NAME
);
await run(
`DELETE FROM favorites WHERE user_id = ?`,
[userId],
'favorites'
);
const result = await run(
`DELETE FROM User WHERE id = ?`,
[userId],
USER_DB_NAME
);
return result;
}
export async function getAllUsers(): Promise<User[]> {
const sql = `
SELECT
id,
username,
profile_picture_url,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
FROM User
ORDER BY id
`;
const users = await queryAll(sql, [], USER_DB_NAME);
return users.map((user: any) => ({
id: user.id,
username: user.username,
profile_picture_url: user.profile_picture_url || null,
has_password: !!user.has_password
})) as User[];
}
export async function getUserById(id: number): Promise<User | null> {
const sql = `
SELECT
id,
username,
profile_picture_url,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
FROM User
WHERE id = ?
`;
const user = await queryOne(sql, [id], USER_DB_NAME);
if (!user) return null;
return {
id: user.id,
username: user.username,
profile_picture_url: user.profile_picture_url || null,
has_password: !!user.has_password
};
}
export async function verifyPassword(userId: number, password: string): Promise<boolean> {
const sql = 'SELECT password_hash FROM User WHERE id = ?';
const user = await queryOne(sql, [userId], USER_DB_NAME);
if (!user || !user.password_hash) {
return false;
}
return await bcrypt.compare(password, user.password_hash);
}
export async function getAniListIntegration(userId: number) {
const sql = `
SELECT anilist_user_id, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = ?
`;
const row = await queryOne(sql, [userId, "AniList"], USER_DB_NAME);
if (!row) {
return { connected: false };
}
return {
connected: true,
anilistUserId: row.anilist_user_id,
expiresAt: row.expires_at
};
}
export async function removeAniListIntegration(userId: number) {
const sql = `
DELETE FROM UserIntegration
WHERE user_id = ? AND platform = ?
`;
return run(sql, [userId, "AniList"], USER_DB_NAME);
}

View File

@@ -0,0 +1,301 @@
let animeData = null;
let extensionName = null;
let animeId = null;
const episodePagination = Object.create(PaginationManager);
episodePagination.init(12, renderEpisodes);
YouTubePlayerUtils.init('player');
document.addEventListener('DOMContentLoaded', () => {
loadAnime();
setupDescriptionModal();
setupEpisodeSearch();
});
async function loadAnime() {
try {
const urlData = URLUtils.parseEntityPath('anime');
if (!urlData) {
showError("Invalid URL");
return;
}
extensionName = urlData.extensionName;
animeId = urlData.entityId;
const fetchUrl = extensionName
? `/api/anime/${animeId}?source=${extensionName}`
: `/api/anime/${animeId}?source=anilist`;
const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() });
const data = await res.json();
if (data.error) {
showError("Anime Not Found");
return;
}
animeData = data;
const metadata = MediaMetadataUtils.formatAnimeData(data, !!extensionName);
updatePageTitle(metadata.title);
updateMetadata(metadata);
updateDescription(data.description || data.summary);
updateCharacters(metadata.characters);
updateExtensionPill();
setupWatchButton();
const hasTrailer = YouTubePlayerUtils.playTrailer(
metadata.trailer,
'player',
metadata.banner
);
setupEpisodes(metadata.episodes);
await setupAddToListButton();
} catch (err) {
console.error('Error loading anime:', err);
showError("Error loading anime");
}
}
function updatePageTitle(title) {
document.title = `${title} | WaifuBoard`;
document.getElementById('title').innerText = title;
}
function updateMetadata(metadata) {
if (metadata.poster) {
document.getElementById('poster').src = metadata.poster;
}
document.getElementById('score').innerText = `${metadata.score}% Score`;
document.getElementById('year').innerText = metadata.year;
document.getElementById('genres').innerText = metadata.genres;
document.getElementById('format').innerText = metadata.format;
document.getElementById('status').innerText = metadata.status;
document.getElementById('season').innerText = metadata.season;
document.getElementById('studio').innerText = metadata.studio;
document.getElementById('episodes').innerText = metadata.episodes;
}
function updateDescription(rawDescription) {
const desc = MediaMetadataUtils.truncateDescription(rawDescription, 4);
document.getElementById('description-preview').innerHTML = desc.short;
document.getElementById('full-description').innerHTML = desc.full;
const readMoreBtn = document.getElementById('read-more-btn');
if (desc.isTruncated) {
readMoreBtn.style.display = 'inline-flex';
} else {
readMoreBtn.style.display = 'none';
}
}
function updateCharacters(characters) {
const container = document.getElementById('char-list');
container.innerHTML = '';
if (characters.length > 0) {
characters.forEach(char => {
container.innerHTML += `
<div class="character-item">
<div class="char-dot"></div> ${char.name}
</div>`;
});
} else {
container.innerHTML = `
<div class="character-item" style="color: #666;">
No character data available
</div>`;
}
}
function updateExtensionPill() {
const pill = document.getElementById('extension-pill');
if (!pill) return;
if (extensionName) {
pill.textContent = extensionName.charAt(0).toUpperCase() + extensionName.slice(1).toLowerCase();
pill.style.display = 'inline-flex';
} else {
pill.style.display = 'none';
}
}
function setupWatchButton() {
const watchBtn = document.getElementById('watch-btn');
if (watchBtn) {
watchBtn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, 1, extensionName);
window.location.href = url;
};
}
}
async function setupAddToListButton() {
const btn = document.getElementById('add-to-list-btn');
if (!btn || !animeData) return;
ListModalManager.currentData = animeData;
const entryType = ListModalManager.getEntryType(animeData);
await ListModalManager.checkIfInList(animeId, extensionName || 'anilist', entryType);
const tempBtn = document.querySelector('.hero-buttons .btn-blur');
if (tempBtn) {
ListModalManager.updateButton('.hero-buttons .btn-blur');
} else {
updateCustomAddButton();
}
btn.onclick = () => ListModalManager.open(animeData, extensionName || 'anilist');
}
function updateCustomAddButton() {
const btn = document.getElementById('add-to-list-btn');
if (!btn) return;
if (ListModalManager.isInList) {
btn.innerHTML = `
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
In Your List
`;
btn.style.background = 'rgba(34, 197, 94, 0.2)';
btn.style.color = '#22c55e';
btn.style.borderColor = 'rgba(34, 197, 94, 0.3)';
} else {
btn.innerHTML = '+ Add to List';
btn.style.background = null;
btn.style.color = null;
btn.style.borderColor = null;
}
}
function setupEpisodes(totalEpisodes) {
const limitedTotal = Math.min(Math.max(totalEpisodes, 1), 5000);
episodePagination.setTotalItems(limitedTotal);
renderEpisodes();
}
function renderEpisodes() {
const grid = document.getElementById('episodes-grid');
if (!grid) return;
grid.innerHTML = '';
const range = episodePagination.getPageRange();
const start = range.start + 1;
const end = range.end;
for (let i = start; i <= end; i++) {
createEpisodeButton(i, grid);
}
episodePagination.renderControls(
'pagination-controls',
'page-info',
'prev-page',
'next-page'
);
}
function createEpisodeButton(num, container) {
const btn = document.createElement('div');
btn.className = 'episode-btn';
btn.innerText = `Ep ${num}`;
btn.onclick = () => {
const url = URLUtils.buildWatchUrl(animeId, num, extensionName);
window.location.href = url;
};
container.appendChild(btn);
}
function setupDescriptionModal() {
const modal = document.getElementById('desc-modal');
if (!modal) return;
modal.addEventListener('click', (e) => {
if (e.target.id === 'desc-modal') {
closeDescriptionModal();
}
});
}
function openDescriptionModal() {
document.getElementById('desc-modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeDescriptionModal() {
document.getElementById('desc-modal').classList.remove('active');
document.body.style.overflow = '';
}
function setupEpisodeSearch() {
const searchInput = document.getElementById('ep-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
const grid = document.getElementById('episodes-grid');
const totalEpisodes = episodePagination.totalItems;
if (val > 0 && val <= totalEpisodes) {
grid.innerHTML = '';
createEpisodeButton(val, grid);
document.getElementById('pagination-controls').style.display = 'none';
} else if (!e.target.value) {
renderEpisodes();
} else {
grid.innerHTML = '<div style="color:#666; width:100%; text-align:center;">Episode not found</div>';
document.getElementById('pagination-controls').style.display = 'none';
}
});
}
function showError(message) {
document.getElementById('title').innerText = message;
}
function saveToList() {
if (!animeId) return;
ListModalManager.save(animeId, extensionName || 'anilist');
}
function deleteFromList() {
if (!animeId) return;
ListModalManager.delete(animeId, extensionName || 'anilist');
}
function closeAddToListModal() {
ListModalManager.close();
}
window.openDescriptionModal = openDescriptionModal;
window.closeDescriptionModal = closeDescriptionModal;
window.changePage = (delta) => {
if (delta > 0) episodePagination.nextPage();
else episodePagination.prevPage();
};

View File

@@ -0,0 +1,194 @@
let trendingAnimes = [];
let currentHeroIndex = 0;
let player;
let heroInterval;
document.addEventListener('DOMContentLoaded', () => {
SearchManager.init('#search-input', '#search-results', 'anime');
ContinueWatchingManager.load('my-status', 'watching', 'ANIME');
fetchContent();
setInterval(() => fetchContent(true), 60000);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrapper')) {
const searchResults = document.getElementById('search-results');
const searchInput = document.getElementById('search-input');
searchResults.classList.remove('active');
searchInput.style.borderRadius = '99px';
}
});
function scrollCarousel(id, direction) {
const container = document.getElementById(id);
if(container) {
const scrollAmount = container.clientWidth * 0.75;
container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' });
}
}
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
function onYouTubeIframeAPIReady() {
player = new YT.Player('player', {
height: '100%',
width: '100%',
playerVars: {
'autoplay': 1,
'controls': 0,
'mute': 1,
'loop': 1,
'showinfo': 0,
'modestbranding': 1
},
events: {
'onReady': (e) => {
e.target.mute();
if(trendingAnimes.length) updateHeroVideo(trendingAnimes[currentHeroIndex]);
}
}
});
}
async function fetchContent(isUpdate = false) {
try {
const trendingRes = await fetch('/api/trending');
const trendingData = await trendingRes.json();
if (trendingData.results && trendingData.results.length > 0) {
trendingAnimes = trendingData.results;
if (!isUpdate) {
updateHeroUI(trendingAnimes[0]);
startHeroCycle();
}
renderList('trending', trendingAnimes);
} else if (!isUpdate) {
setTimeout(() => fetchContent(false), 2000);
}
const topRes = await fetch('/api/top-airing');
const topData = await topRes.json();
if (topData.results && topData.results.length > 0) {
renderList('top-airing', topData.results);
}
} catch (e) {
console.error("Fetch Error:", e);
if(!isUpdate) setTimeout(() => fetchContent(false), 5000);
}
}
function startHeroCycle() {
if(heroInterval) clearInterval(heroInterval);
heroInterval = setInterval(() => {
if(trendingAnimes.length > 0) {
currentHeroIndex = (currentHeroIndex + 1) % trendingAnimes.length;
updateHeroUI(trendingAnimes[currentHeroIndex]);
}
}, 10000);
}
async function updateHeroUI(anime) {
if(!anime) return;
const title = anime.title.english || anime.title.romaji || "Unknown Title";
const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A';
const year = anime.seasonYear || '';
const type = anime.format || 'TV';
const desc = anime.description || 'No description available.';
const poster = anime.coverImage ? anime.coverImage.extraLarge : '';
const banner = anime.bannerImage || poster;
document.getElementById('hero-title').innerText = title;
document.getElementById('hero-desc').innerHTML = desc;
document.getElementById('hero-score').innerText = score;
document.getElementById('hero-year').innerText = year;
document.getElementById('hero-type').innerText = type;
document.getElementById('hero-poster').src = poster;
const watchBtn = document.getElementById('watch-btn');
if(watchBtn) watchBtn.onclick = () => window.location.href = `/anime/${anime.id}`;
const addToListBtn = document.querySelector('.hero-buttons .btn-blur');
if(addToListBtn) {
ListModalManager.currentData = anime;
const entryType = ListModalManager.getEntryType(anime);
await ListModalManager.checkIfInList(anime.id, 'anilist', entryType);
ListModalManager.updateButton();
addToListBtn.onclick = () => ListModalManager.open(anime, 'anilist');
}
const bgImg = document.getElementById('hero-bg-media');
if(bgImg && bgImg.src !== banner) bgImg.src = banner;
updateHeroVideo(anime);
document.getElementById('hero-loading-ui').style.display = 'none';
document.getElementById('hero-real-ui').style.display = 'block';
}
function updateHeroVideo(anime) {
if (!player || !player.loadVideoById) return;
const videoContainer = document.getElementById('player');
if (anime.trailer && anime.trailer.site === 'youtube' && anime.trailer.id) {
if(player.getVideoData && player.getVideoData().video_id !== anime.trailer.id) {
player.loadVideoById(anime.trailer.id);
player.mute();
}
videoContainer.style.opacity = "1";
} else {
videoContainer.style.opacity = "0";
player.stopVideo();
}
}
function renderList(id, list) {
const container = document.getElementById(id);
const firstId = list.length > 0 ? list[0].id : null;
const currentFirstId = container.firstElementChild?.dataset?.id;
if (currentFirstId && parseInt(currentFirstId) === firstId && container.children.length === list.length) {
return;
}
container.innerHTML = '';
list.forEach(anime => {
const title = anime.title.english || anime.title.romaji || "Unknown Title";
const cover = anime.coverImage ? anime.coverImage.large : '';
const ep = anime.nextAiringEpisode ? 'Ep ' + anime.nextAiringEpisode.episode : (anime.episodes ? anime.episodes + ' Eps' : 'TV');
const score = anime.averageScore || '--';
const el = document.createElement('div');
el.className = 'card';
el.dataset.id = anime.id;
el.onclick = () => window.location.href = `/anime/${anime.id}`;
el.innerHTML = `
<div class="card-img-wrap"><img src="${cover}" loading="lazy"></div>
<div class="card-content">
<h3>${title}</h3>
<p>${score}% • ${ep}</p>
</div>
`;
container.appendChild(el);
});
}
function saveToList() {
const animeId = ListModalManager.currentData ? ListModalManager.currentData.id : null;
if (!animeId) return;
ListModalManager.save(animeId, 'anilist');
}
function deleteFromList() {
const animeId = ListModalManager.currentData ? ListModalManager.currentData.id : null;
if (!animeId) return;
ListModalManager.delete(animeId, 'anilist');
}
function closeAddToListModal() {
ListModalManager.close();
}

View File

@@ -0,0 +1,431 @@
const pathParts = window.location.pathname.split('/');
const animeId = pathParts[2];
const currentEpisode = parseInt(pathParts[3]);
let audioMode = 'sub';
let currentExtension = '';
let plyrInstance;
let hlsInstance;
let totalEpisodes = 0;
const params = new URLSearchParams(window.location.search);
const firstKey = params.keys().next().value;
let extName;
if (firstKey) extName = firstKey;
const href = extName
? `/anime/${extName}/${animeId}`
: `/anime/${animeId}`;
document.getElementById('back-link').href = href;
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
async function loadMetadata() {
try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist";
const res = await fetch(`/api/anime/${animeId}${extQuery}`);
const data = await res.json();
if (data.error) {
console.error("Error from API:", data.error);
return;
}
const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
let title = '';
let description = '';
let coverImage = '';
let averageScore = '';
let format = '';
let seasonYear = '';
let season = '';
let episodesCount = 0;
let characters = [];
if (isAnilistFormat) {
title = data.title.romaji || data.title.english || data.title.native || 'Anime Title';
description = data.description || 'No description available.';
coverImage = data.coverImage?.large || data.coverImage?.medium || '';
averageScore = data.averageScore ? `${data.averageScore}%` : '--';
format = data.format || '--';
season = data.season ? data.season.charAt(0) + data.season.slice(1).toLowerCase() : '';
seasonYear = data.seasonYear || '';
} else {
title = data.title || 'Anime Title';
description = data.summary || 'No description available.';
coverImage = data.image || '';
averageScore = data.score ? `${Math.round(data.score * 10)}%` : '--';
format = '--';
season = data.season || '';
seasonYear = data.year || '';
}
document.getElementById('anime-title-details').innerText = title;
document.getElementById('anime-title-details2').innerText = title;
document.title = `Watching ${title} - Ep ${currentEpisode}`;
fetch("/api/rpc", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
details: title,
state: `Episode ${currentEpisode}`,
mode: "watching"
})
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = description;
document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.';
document.getElementById('detail-format').innerText = format;
document.getElementById('detail-score').innerText = averageScore;
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
if (extName) {
await loadExtensionEpisodes();
} else {
if (data.nextAiringEpisode?.episode) {
totalEpisodes = data.nextAiringEpisode.episode - 1;
} else if (data.episodes) {
totalEpisodes = data.episodes;
} else {
totalEpisodes = 12;
}
const simpleEpisodes = [];
for (let i = 1; i <= totalEpisodes; i++) {
simpleEpisodes.push({
number: i,
title: null,
thumbnail: null,
isDub: false
});
}
populateEpisodeCarousel(simpleEpisodes);
}
if (currentEpisode >= totalEpisodes && totalEpisodes > 0) {
document.getElementById('next-btn').disabled = true;
}
} catch (error) {
console.error('Error loading metadata:', error);
}
}
async function loadExtensionEpisodes() {
try {
const extQuery = extName ? `?source=${extName}` : "?source=anilist";
const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
const data = await res.json();
totalEpisodes = Array.isArray(data) ? data.length : 0;
if (Array.isArray(data) && data.length > 0) {
populateEpisodeCarousel(data);
} else {
const fallback = [];
for (let i = 1; i <= totalEpisodes; i++) {
fallback.push({ number: i, title: null, thumbnail: null });
}
populateEpisodeCarousel(fallback);
}
} catch (e) {
console.error("Error cargando episodios por extensión:", e);
totalEpisodes = 0;
}
}
function populateEpisodeCarousel(episodesData) {
const carousel = document.getElementById('episode-carousel');
carousel.innerHTML = '';
episodesData.forEach((ep, index) => {
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
if (!epNumber) return;
const extParam = extName ? `?${extName}` : "";
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
const link = document.createElement('a');
link.href = `/watch/${animeId}/${epNumber}${extParam}`;
link.classList.add('carousel-item');
link.dataset.episode = epNumber;
if (!hasThumbnail) link.classList.add('no-thumbnail');
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
const imgContainer = document.createElement('div');
imgContainer.classList.add('carousel-item-img-container');
if (hasThumbnail) {
const img = document.createElement('img');
img.classList.add('carousel-item-img');
img.src = ep.thumbnail;
img.alt = `Episode ${epNumber} Thumbnail`;
imgContainer.appendChild(img);
}
link.appendChild(imgContainer);
const info = document.createElement('div');
info.classList.add('carousel-item-info');
const title = document.createElement('p');
title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`;
info.appendChild(title);
link.appendChild(info);
carousel.appendChild(link);
});
}
async function loadExtensions() {
try {
const res = await fetch('/api/extensions/anime');
const data = await res.json();
const select = document.getElementById('extension-select');
if (data.extensions && data.extensions.length > 0) {
select.innerHTML = '';
data.extensions.forEach(ext => {
const opt = document.createElement('option');
opt.value = opt.innerText = ext;
select.appendChild(opt);
});
if (typeof extName === 'string' && data.extensions.includes(extName)) {
select.value = extName;
} else {
select.selectedIndex = 0;
}
currentExtension = select.value;
onExtensionChange();
} else {
select.innerHTML = '<option>No Extensions</option>';
select.disabled = true;
setLoading("No anime extensions found.");
}
} catch (error) {
console.error("Extension Error:", error);
}
}
async function onExtensionChange() {
const select = document.getElementById('extension-select');
currentExtension = select.value;
setLoading("Fetching extension settings...");
try {
const res = await fetch(`/api/extensions/${currentExtension}/settings`);
const settings = await res.json();
const toggle = document.getElementById('sd-toggle');
if (settings.supportsDub) {
toggle.style.display = 'flex';
setAudioMode('sub');
} else {
toggle.style.display = 'none';
setAudioMode('sub');
}
const serverSelect = document.getElementById('server-select');
serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) {
settings.episodeServers.forEach(srv => {
const opt = document.createElement('option');
opt.value = srv;
opt.innerText = srv;
serverSelect.appendChild(opt);
});
serverSelect.style.display = 'block';
} else {
serverSelect.style.display = 'none';
}
loadStream();
} catch (error) {
console.error(error);
setLoading("Failed to load extension settings.");
}
}
function toggleAudioMode() {
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(newMode);
loadStream();
}
function setAudioMode(mode) {
audioMode = mode;
const toggle = document.getElementById('sd-toggle');
const subOpt = document.getElementById('opt-sub');
const dubOpt = document.getElementById('opt-dub');
toggle.setAttribute('data-state', mode);
subOpt.classList.toggle('active', mode === 'sub');
dubOpt.classList.toggle('active', mode === 'dub');
}
async function loadStream() {
if (!currentExtension) return;
const serverSelect = document.getElementById('server-select');
const server = serverSelect.value || "default";
setLoading(`Loading stream (${audioMode})...`);
try {
let sourc = "&source=anilist";
if (extName){
sourc = `&source=${extName}`;
}
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
setLoading(`Error: ${data.error}`);
return;
}
if (!data.videoSources || data.videoSources.length === 0) {
setLoading("No video sources found.");
return;
}
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
const headers = data.headers || {};
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles);
document.getElementById('loading-overlay').style.display = 'none';
} catch (error) {
setLoading("Stream error. Check console.");
console.error(error);
}
}
function playVideo(url, subtitles = []) {
const video = document.getElementById('player');
if (Hls.isSupported()) {
if (hlsInstance) hlsInstance.destroy();
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
}
if (plyrInstance) plyrInstance.destroy();
while (video.textTracks.length > 0) {
video.removeChild(video.textTracks[0]);
}
subtitles.forEach(sub => {
if (!sub.url) return;
const track = document.createElement('track');
track.kind = 'captions';
track.label = sub.language || 'Unknown';
track.srclang = (sub.language || '').slice(0, 2).toLowerCase();
track.src = sub.url;
if (sub.default || sub.language?.toLowerCase().includes('english')) track.default = true;
video.appendChild(track);
});
plyrInstance = new Plyr(video, {
captions: { active: true, update: true, language: 'en' },
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed']
});
let alreadyTriggered = false;
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const percent = (video.currentTime / video.duration) * 100;
if (percent >= 80 && !alreadyTriggered) {
alreadyTriggered = true;
sendProgress();
}
});
video.play().catch(() => console.log("Autoplay blocked"));
}
function setLoading(message) {
const overlay = document.getElementById('loading-overlay');
const text = document.getElementById('loading-text');
overlay.style.display = 'flex';
text.innerText = message;
}
const extParam = extName ? `?${extName}` : "";
document.getElementById('prev-btn').onclick = () => {
if (currentEpisode > 1) {
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
}
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
}
};
if (currentEpisode <= 1) {
document.getElementById('prev-btn').disabled = true;
}
async function sendProgress() {
const token = localStorage.getItem('token');
if (!token) return;
const source = extName
? extName
: "anilist";
const body = {
entry_id: animeId,
source: source,
entry_type: "ANIME",
status: 'CURRENT',
progress: source === 'anilist'
? Math.floor(currentEpisode)
: currentEpisode
};
try {
await fetch('/api/list/entry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
} catch (err) {
console.error('Error updating progress:', err);
}
}
loadMetadata();
loadExtensions();

View File

@@ -0,0 +1,81 @@
;(() => {
const token = localStorage.getItem("token")
if (!token && window.location.pathname !== "/") {
window.location.href = "/"
}
})()
async function loadMeUI() {
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!res.ok) return
const user = await res.json()
const navUser = document.getElementById("nav-user")
const navUsername = document.getElementById("nav-username")
const navAvatar = document.getElementById("nav-avatar")
const dropdownAvatar = document.getElementById("dropdown-avatar")
if (!navUser || !navUsername || !navAvatar) return
navUser.style.display = "flex"
navUsername.textContent = user.username
const avatarUrl = user.avatar || "/public/assets/avatar.png"
navAvatar.src = avatarUrl
if (dropdownAvatar) {
dropdownAvatar.src = avatarUrl
}
setupDropdown()
} catch (e) {
console.error("Failed to load user UI:", e)
}
}
function setupDropdown() {
const userAvatarBtn = document.querySelector(".user-avatar-btn")
const navDropdown = document.getElementById("nav-dropdown")
const navLogout = document.getElementById("nav-logout")
if (!userAvatarBtn || !navDropdown || !navLogout) return
userAvatarBtn.addEventListener("click", (e) => {
e.stopPropagation()
navDropdown.classList.toggle("active")
})
document.addEventListener("click", (e) => {
if (!navDropdown.contains(e.target)) {
navDropdown.classList.remove("active")
}
})
navDropdown.addEventListener("click", (e) => {
e.stopPropagation()
})
navLogout.addEventListener("click", () => {
localStorage.removeItem("token")
window.location.href = "/"
})
const dropdownLinks = navDropdown.querySelectorAll("a.dropdown-item")
dropdownLinks.forEach((link) => {
link.addEventListener("click", () => {
navDropdown.classList.remove("active")
})
})
}
loadMeUI()

View File

@@ -0,0 +1,342 @@
let bookData = null;
let extensionName = null;
let bookId = null;
let bookSlug = null;
let allChapters = [];
let filteredChapters = [];
const chapterPagination = Object.create(PaginationManager);
chapterPagination.init(12, () => renderChapterTable());
document.addEventListener('DOMContentLoaded', () => {
init();
setupModalClickOutside();
});
async function init() {
try {
const urlData = URLUtils.parseEntityPath('book');
if (!urlData) {
showError("Book Not Found");
return;
}
extensionName = urlData.extensionName;
bookId = urlData.entityId;
bookSlug = urlData.slug;
await loadBookMetadata();
await loadChapters();
await setupAddToListButton();
} catch (err) {
console.error("Metadata Error:", err);
showError("Error loading book");
}
}
async function loadBookMetadata() {
const source = extensionName || 'anilist';
const fetchUrl = `/api/book/${bookId}?source=${source}`;
const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() });
const data = await res.json();
if (data.error || !data) {
showError("Book Not Found");
return;
}
const raw = Array.isArray(data) ? data[0] : data;
bookData = raw;
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
updatePageTitle(metadata.title);
updateMetadata(metadata);
updateExtensionPill();
}
function updatePageTitle(title) {
document.title = `${title} | WaifuBoard Books`;
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = title;
}
function updateMetadata(metadata) {
const descEl = document.getElementById('description');
if (descEl) descEl.innerHTML = metadata.description;
const scoreEl = document.getElementById('score');
if (scoreEl) {
scoreEl.innerText = extensionName
? `${metadata.score}`
: `${metadata.score}% Score`;
}
const pubEl = document.getElementById('published-date');
if (pubEl) pubEl.innerText = metadata.year;
const statusEl = document.getElementById('status');
if (statusEl) statusEl.innerText = metadata.status;
const formatEl = document.getElementById('format');
if (formatEl) formatEl.innerText = metadata.format;
const chaptersEl = document.getElementById('chapters');
if (chaptersEl) chaptersEl.innerText = metadata.chapters;
const genresEl = document.getElementById('genres');
if (genresEl) genresEl.innerText = metadata.genres;
const posterEl = document.getElementById('poster');
if (posterEl) posterEl.src = metadata.poster;
const heroBgEl = document.getElementById('hero-bg');
if (heroBgEl) heroBgEl.src = metadata.banner;
}
function updateExtensionPill() {
const pill = document.getElementById('extension-pill');
if (!pill) return;
if (extensionName) {
pill.textContent = extensionName.charAt(0).toUpperCase() + extensionName.slice(1).toLowerCase();
pill.style.display = 'inline-flex';
} else {
pill.style.display = 'none';
}
}
async function setupAddToListButton() {
const btn = document.getElementById('add-to-list-btn');
if (!btn || !bookData) return;
ListModalManager.currentData = bookData;
const entryType = ListModalManager.getEntryType(bookData);
const idForCheck = extensionName ? bookSlug : bookId;
await ListModalManager.checkIfInList(
idForCheck,
extensionName || 'anilist',
entryType
);
updateCustomAddButton();
btn.onclick = () => ListModalManager.open(bookData, extensionName || 'anilist');
}
function updateCustomAddButton() {
const btn = document.getElementById('add-to-list-btn');
if (!btn) return;
if (ListModalManager.isInList) {
btn.innerHTML = `
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
In Your Library
`;
btn.style.background = 'rgba(34, 197, 94, 0.2)';
btn.style.color = '#22c55e';
btn.style.borderColor = 'rgba(34, 197, 94, 0.3)';
} else {
btn.innerHTML = '+ Add to Library';
btn.style.background = null;
btn.style.color = null;
btn.style.borderColor = null;
}
}
async function loadChapters() {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extensions for chapters...</td></tr>';
try {
const source = extensionName || 'anilist';
const fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
const res = await fetch(fetchUrl, { headers: AuthUtils.getSimpleAuthHeaders() });
const data = await res.json();
allChapters = data.chapters || [];
filteredChapters = [...allChapters];
applyChapterFilter();
const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found on loaded extensions.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found";
return;
}
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
setupProviderFilter();
setupReadButton();
chapterPagination.setTotalItems(filteredChapters.length);
renderChapterTable();
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; color: #ef4444;">Error loading chapters.</td></tr>';
console.error(err);
}
}
function applyChapterFilter() {
const chapterParam = URLUtils.getQueryParam('chapter');
if (!chapterParam) return;
const chapterNumber = parseFloat(chapterParam);
if (isNaN(chapterNumber)) return;
filteredChapters = allChapters.filter(
ch => parseFloat(ch.number) === chapterNumber
);
chapterPagination.reset();
}
function setupProviderFilter() {
const select = document.getElementById('provider-filter');
if (!select) return;
const providers = [...new Set(allChapters.map(ch => ch.provider))];
if (providers.length === 0) return;
select.style.display = 'inline-block';
select.innerHTML = '<option value="all">All Providers</option>';
providers.forEach(prov => {
const opt = document.createElement('option');
opt.value = prov;
opt.innerText = prov;
select.appendChild(opt);
});
if (extensionName) {
const extensionProvider = providers.find(
p => p.toLowerCase() === extensionName.toLowerCase()
);
if (extensionProvider) {
select.value = extensionProvider;
filteredChapters = allChapters.filter(ch => ch.provider === extensionProvider);
}
}
select.onchange = (e) => {
const selected = e.target.value;
if (selected === 'all') {
filteredChapters = [...allChapters];
} else {
filteredChapters = allChapters.filter(ch => ch.provider === selected);
}
chapterPagination.reset();
chapterPagination.setTotalItems(filteredChapters.length);
renderChapterTable();
};
}
function setupReadButton() {
const readBtn = document.getElementById('read-start-btn');
if (!readBtn || filteredChapters.length === 0) return;
const firstChapter = filteredChapters[0];
readBtn.onclick = () => openReader(0, firstChapter.provider);
}
function renderChapterTable() {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
tbody.innerHTML = '';
if (filteredChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters match this filter.</td></tr>';
chapterPagination.renderControls(
'pagination',
'page-info',
'prev-page',
'next-page'
);
return;
}
const pageItems = chapterPagination.getCurrentPageItems(filteredChapters);
pageItems.forEach((ch) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${ch.number}</td>
<td>${ch.title || 'Chapter ' + ch.number}</td>
<td><span class="pill" style="font-size:0.75rem;">${ch.provider}</span></td>
<td>
<button class="read-btn-small" onclick="openReader('${ch.index}', '${ch.provider}')">
Read
</button>
</td>
`;
tbody.appendChild(row);
});
chapterPagination.renderControls(
'pagination',
'page-info',
'prev-page',
'next-page'
);
}
function openReader(chapterId, provider) {
window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName);
}
function setupModalClickOutside() {
const modal = document.getElementById('add-list-modal');
if (!modal) return;
modal.addEventListener('click', (e) => {
if (e.target.id === 'add-list-modal') {
ListModalManager.close();
}
});
}
function showError(message) {
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = message;
}
function saveToList() {
const idToSave = extensionName ? bookSlug : bookId;
ListModalManager.save(idToSave, extensionName || 'anilist');
}
function deleteFromList() {
const idToDelete = extensionName ? bookSlug : bookId;
ListModalManager.delete(idToDelete, extensionName || 'anilist');
}
function closeAddToListModal() {
ListModalManager.close();
}
window.openReader = openReader;
window.saveToList = saveToList;
window.deleteFromList = deleteFromList;
window.closeAddToListModal = closeAddToListModal;

View File

@@ -0,0 +1,134 @@
let trendingBooks = [];
let currentHeroIndex = 0;
let heroInterval;
document.addEventListener('DOMContentLoaded', () => {
SearchManager.init('#search-input', '#search-results', 'book');
ContinueWatchingManager.load('my-status-books', 'reading', 'MANGA');
init();
});
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
if (window.scrollY > 50) nav.classList.add('scrolled');
else nav.classList.remove('scrolled');
});
function scrollCarousel(id, direction) {
const container = document.getElementById(id);
if(container) {
const scrollAmount = container.clientWidth * 0.75;
container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' });
}
}
async function init() {
try {
const res = await fetch('/api/books/trending');
const data = await res.json();
if (data.results && data.results.length > 0) {
trendingBooks = data.results;
updateHeroUI(trendingBooks[0]);
renderList('trending', trendingBooks);
startHeroCycle();
}
const resPop = await fetch('/api/books/popular');
const dataPop = await resPop.json();
if (dataPop.results) renderList('popular', dataPop.results);
} catch (e) {
console.error("Books Error:", e);
}
}
function startHeroCycle() {
if(heroInterval) clearInterval(heroInterval);
heroInterval = setInterval(() => {
if(trendingBooks.length > 0) {
currentHeroIndex = (currentHeroIndex + 1) % trendingBooks.length;
updateHeroUI(trendingBooks[currentHeroIndex]);
}
}, 8000);
}
async function updateHeroUI(book) {
if(!book) return;
const title = book.title.english || book.title.romaji;
const desc = book.description || "No description available.";
const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || '';
const banner = book.bannerImage || poster;
document.getElementById('hero-title').innerText = title;
document.getElementById('hero-desc').innerHTML = desc;
document.getElementById('hero-score').innerText = (book.averageScore || '?') + '% Score';
document.getElementById('hero-year').innerText = (book.startDate && book.startDate.year) ? book.startDate.year : '????';
document.getElementById('hero-type').innerText = book.format || 'MANGA';
const heroPoster = document.getElementById('hero-poster');
if(heroPoster) heroPoster.src = poster;
const bg = document.getElementById('hero-bg-media');
if(bg) bg.src = banner;
const readBtn = document.getElementById('read-btn');
if (readBtn) {
readBtn.onclick = () => window.location.href = `/book/${book.id}`;
}
const addToListBtn = document.querySelector('.hero-buttons .btn-blur');
if(addToListBtn) {
ListModalManager.currentData = book;
const entryType = ListModalManager.getEntryType(book);
await ListModalManager.checkIfInList(book.id, 'anilist', entryType);
ListModalManager.updateButton();
addToListBtn.onclick = () => ListModalManager.open(book, 'anilist');
}
}
function renderList(id, list) {
const container = document.getElementById(id);
container.innerHTML = '';
list.forEach(book => {
const title = book.title.english || book.title.romaji;
const cover = book.coverImage ? book.coverImage.large : '';
const score = book.averageScore || '--';
const type = book.format || 'Book';
const el = document.createElement('div');
el.className = 'card';
el.onclick = () => {
window.location.href = `/book/${book.id}`;
};
el.innerHTML = `
<div class="card-img-wrap"><img src="${cover}" loading="lazy"></div>
<div class="card-content">
<h3>${title}</h3>
<p>${score}% • ${type}</p>
</div>
`;
container.appendChild(el);
});
}
function saveToList() {
const bookId = ListModalManager.currentData ? ListModalManager.currentData.id : null;
if (!bookId) return;
ListModalManager.save(bookId, 'anilist');
}
function deleteFromList() {
const bookId = ListModalManager.currentData ? ListModalManager.currentData.id : null;
if (!bookId) return;
ListModalManager.delete(bookId, 'anilist');
}
function closeAddToListModal() {
ListModalManager.close();
}

View File

@@ -0,0 +1,695 @@
const reader = document.getElementById('reader');
const panel = document.getElementById('settings-panel');
const overlay = document.getElementById('overlay');
const settingsBtn = document.getElementById('settings-btn');
const closePanel = document.getElementById('close-panel');
const chapterLabel = document.getElementById('chapter-label');
const prevBtn = document.getElementById('prev-chapter');
const nextBtn = document.getElementById('next-chapter');
const lnSettings = document.getElementById('ln-settings');
const mangaSettings = document.getElementById('manga-settings');
const config = {
ln: {
fontSize: 18,
lineHeight: 1.8,
maxWidth: 750,
fontFamily: '"Georgia", serif',
textColor: '#e5e7eb',
bg: '#14141b',
textAlign: 'justify'
},
manga: {
direction: 'rtl',
mode: 'auto',
spacing: 16,
imageFit: 'screen',
preloadCount: 3
}
};
let currentType = null;
let currentPages = [];
let observer = null;
const parts = window.location.pathname.split('/');
const bookId = parts[4];
let chapter = parts[3];
let provider = parts[2];
function loadConfig() {
try {
const saved = localStorage.getItem('readerConfig');
if (saved) {
const parsed = JSON.parse(saved);
Object.assign(config.ln, parsed.ln || {});
Object.assign(config.manga, parsed.manga || {});
}
} catch (e) {
console.error('Error loading config:', e);
}
updateUIFromConfig();
}
function saveConfig() {
try {
localStorage.setItem('readerConfig', JSON.stringify(config));
} catch (e) {
console.error('Error saving config:', e);
}
}
function updateUIFromConfig() {
document.getElementById('font-size').value = config.ln.fontSize;
document.getElementById('font-size-value').textContent = config.ln.fontSize + 'px';
document.getElementById('line-height').value = config.ln.lineHeight;
document.getElementById('line-height-value').textContent = config.ln.lineHeight;
document.getElementById('max-width').value = config.ln.maxWidth;
document.getElementById('max-width-value').textContent = config.ln.maxWidth + 'px';
document.getElementById('font-family').value = config.ln.fontFamily;
document.getElementById('text-color').value = config.ln.textColor;
document.getElementById('bg-color').value = config.ln.bg;
document.querySelectorAll('[data-align]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === config.ln.textAlign);
});
document.getElementById('display-mode').value = config.manga.mode;
document.getElementById('image-fit').value = config.manga.imageFit;
document.getElementById('page-spacing').value = config.manga.spacing;
document.getElementById('page-spacing-value').textContent = config.manga.spacing + 'px';
document.getElementById('preload-count').value = config.manga.preloadCount;
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.direction === config.manga.direction);
});
}
function applyStyles() {
if (currentType === 'ln') {
document.documentElement.style.setProperty('--ln-font-size', config.ln.fontSize + 'px');
document.documentElement.style.setProperty('--ln-line-height', config.ln.lineHeight);
document.documentElement.style.setProperty('--ln-max-width', config.ln.maxWidth + 'px');
document.documentElement.style.setProperty('--ln-font-family', config.ln.fontFamily);
document.documentElement.style.setProperty('--ln-text-color', config.ln.textColor);
document.documentElement.style.setProperty('--bg-base', config.ln.bg);
document.documentElement.style.setProperty('--ln-text-align', config.ln.textAlign);
}
if (currentType === 'manga') {
document.documentElement.style.setProperty('--page-spacing', config.manga.spacing + 'px');
document.documentElement.style.setProperty('--page-max-width', 900 + 'px');
document.documentElement.style.setProperty('--manga-max-width', 1400 + 'px');
const viewportHeight = window.innerHeight - 64 - 32;
document.documentElement.style.setProperty('--viewport-height', viewportHeight + 'px');
}
}
function updateSettingsVisibility() {
lnSettings.classList.toggle('hidden', currentType !== 'ln');
mangaSettings.classList.toggle('hidden', currentType !== 'manga');
}
async function loadChapter() {
reader.innerHTML = `
<div class="loading-container">
<div class="loading-spinner"></div>
<span>Loading chapter...</span>
</div>
`;
const urlParams = new URLSearchParams(window.location.search);
let source = urlParams.get('source');
if (!source) {
source = 'anilist';
}
const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
try {
const res = await fetch(newEndpoint);
const data = await res.json();
if (data.title) {
chapterLabel.textContent = data.title;
document.title = data.title;
} else {
chapterLabel.textContent = `Chapter ${chapter}`;
document.title = `Chapter ${chapter}`;
}
setupProgressTracking(data, source);
const res2 = await fetch(`/api/book/${bookId}?source=${source}`);
const data2 = await res2.json();
fetch("/api/rpc", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
details: data2.title.romaji ?? data2.title,
state: `Chapter ${data.title}`,
mode: "reading"
})
});
if (data.error) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Error: ${data.error}</span>
</div>
`;
return;
}
currentType = data.type;
updateSettingsVisibility();
applyStyles();
reader.innerHTML = '';
if (data.type === 'manga') {
currentPages = data.pages || [];
loadManga(currentPages);
} else if (data.type === 'ln') {
loadLN(data.content);
}
} catch (error) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Error loading chapter: ${error.message}</span>
</div>
`;
}
}
function loadManga(pages) {
if (!pages || pages.length === 0) {
reader.innerHTML = '<div class="loading-container"><span>No pages found</span></div>';
return;
}
const container = document.createElement('div');
container.className = 'manga-container';
let isLongStrip = false;
if (config.manga.mode === 'longstrip') {
isLongStrip = true;
} else if (config.manga.mode === 'auto' && detectLongStrip(pages)) {
isLongStrip = true;
}
const useDouble = config.manga.mode === 'double' ||
(config.manga.mode === 'auto' && !isLongStrip && shouldUseDoublePage(pages));
if (useDouble) {
loadDoublePage(container, pages);
} else {
loadSinglePage(container, pages);
}
reader.appendChild(container);
setupLazyLoading();
enableMangaPageNavigation();
}
function shouldUseDoublePage(pages) {
if (pages.length <= 5) return false;
const widePages = pages.filter(p => {
if (!p.height || !p.width) return false;
const ratio = p.width / p.height;
return ratio > 1.3;
});
if (widePages.length > pages.length * 0.3) return false;
return true;
}
function loadSinglePage(container, pages) {
pages.forEach((page, index) => {
const img = createImageElement(page, index);
container.appendChild(img);
});
}
function loadDoublePage(container, pages) {
let i = 0;
while (i < pages.length) {
const currentPage = pages[i];
const nextPage = pages[i + 1];
const isWide = currentPage.width && currentPage.height &&
(currentPage.width / currentPage.height) > 1.1;
if (isWide) {
const img = createImageElement(currentPage, i);
container.appendChild(img);
i++;
} else {
const doubleContainer = document.createElement('div');
doubleContainer.className = 'double-container';
const leftPage = createImageElement(currentPage, i);
if (nextPage) {
const nextIsWide = nextPage.width && nextPage.height &&
(nextPage.width / nextPage.height) > 1.3;
if (nextIsWide) {
const singleImg = createImageElement(currentPage, i);
container.appendChild(singleImg);
i++;
} else {
const rightPage = createImageElement(nextPage, i + 1);
if (config.manga.direction === 'rtl') {
doubleContainer.appendChild(rightPage);
doubleContainer.appendChild(leftPage);
} else {
doubleContainer.appendChild(leftPage);
doubleContainer.appendChild(rightPage);
}
container.appendChild(doubleContainer);
i += 2;
}
} else {
const singleImg = createImageElement(currentPage, i);
container.appendChild(singleImg);
i++;
}
}
}
}
function createImageElement(page, index) {
const img = document.createElement('img');
img.className = 'page-img';
img.dataset.index = index;
const url = buildProxyUrl(page.url, page.headers);
const placeholder = "/public/assets/placeholder.svg";
img.onerror = () => {
if (img.src !== placeholder) {
img.src = placeholder;
}
};
if (config.manga.mode === 'longstrip' && index > 0) {
img.classList.add('longstrip-fit');
} else {
if (config.manga.imageFit === 'width') img.classList.add('fit-width');
else if (config.manga.imageFit === 'height') img.classList.add('fit-height');
else if (config.manga.imageFit === 'screen') img.classList.add('fit-screen');
}
if (index < config.manga.preloadCount) {
img.src = url;
} else {
img.dataset.src = url;
img.loading = 'lazy';
}
img.alt = `Page ${index + 1}`;
return img;
}
function buildProxyUrl(url, headers = {}) {
const params = new URLSearchParams({
url
});
if (headers.referer) params.append('referer', headers.referer);
if (headers['user-agent']) params.append('ua', headers['user-agent']);
if (headers.cookie) params.append('cookie', headers.cookie);
return `/api/proxy?${params.toString()}`;
}
function detectLongStrip(pages) {
if (!pages || pages.length === 0) return false;
const relevant = pages.slice(1);
const tall = relevant.filter(p => p.height && p.width && (p.height / p.width) > 2);
return tall.length >= 2 || (tall.length / relevant.length) > 0.3;
}
function setupLazyLoading() {
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
observer.unobserve(img);
}
}
});
}, {
rootMargin: '200px'
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
}
function loadLN(html) {
const div = document.createElement('div');
div.className = 'ln-content';
div.innerHTML = html;
reader.appendChild(div);
}
document.getElementById('font-size').addEventListener('input', (e) => {
config.ln.fontSize = parseInt(e.target.value);
document.getElementById('font-size-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('line-height').addEventListener('input', (e) => {
config.ln.lineHeight = parseFloat(e.target.value);
document.getElementById('line-height-value').textContent = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('max-width').addEventListener('input', (e) => {
config.ln.maxWidth = parseInt(e.target.value);
document.getElementById('max-width-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('font-family').addEventListener('change', (e) => {
config.ln.fontFamily = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('text-color').addEventListener('change', (e) => {
config.ln.textColor = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('bg-color').addEventListener('change', (e) => {
config.ln.bg = e.target.value;
applyStyles();
saveConfig();
});
document.querySelectorAll('[data-align]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-align]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.ln.textAlign = btn.dataset.align;
applyStyles();
saveConfig();
});
});
document.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => {
const preset = btn.dataset.preset;
const presets = {
dark: { bg: '#14141b', textColor: '#e5e7eb' },
sepia: { bg: '#f4ecd8', textColor: '#5c472d' },
light: { bg: '#fafafa', textColor: '#1f2937' },
amoled: { bg: '#000000', textColor: '#ffffff' }
};
if (presets[preset]) {
Object.assign(config.ln, presets[preset]);
document.getElementById('bg-color').value = config.ln.bg;
document.getElementById('text-color').value = config.ln.textColor;
applyStyles();
saveConfig();
}
});
});
document.getElementById('display-mode').addEventListener('change', (e) => {
config.manga.mode = e.target.value;
saveConfig();
loadChapter();
});
document.getElementById('image-fit').addEventListener('change', (e) => {
config.manga.imageFit = e.target.value;
saveConfig();
loadChapter();
});
document.getElementById('page-spacing').addEventListener('input', (e) => {
config.manga.spacing = parseInt(e.target.value);
document.getElementById('page-spacing-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('preload-count').addEventListener('change', (e) => {
config.manga.preloadCount = parseInt(e.target.value);
saveConfig();
});
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.manga.direction = btn.dataset.direction;
saveConfig();
loadChapter();
});
});
prevBtn.addEventListener('click', () => {
const current = parseInt(chapter);
if (current <= 0) return;
const newChapter = String(current - 1);
updateURL(newChapter);
window.scrollTo(0, 0);
loadChapter();
});
nextBtn.addEventListener('click', () => {
const newChapter = String(parseInt(chapter) + 1);
updateURL(newChapter);
window.scrollTo(0, 0);
loadChapter();
});
function updateURL(newChapter) {
chapter = newChapter;
const urlParams = new URLSearchParams(window.location.search);
let source = urlParams.get('source');
let src;
if (source === 'anilist') {
src= "?source=anilist"
} else {
src= `?source=${source}`
}
const newUrl = `/read/${provider}/${chapter}/${bookId}${src}`;
window.history.pushState({}, '', newUrl);
}
document.getElementById('back-btn').addEventListener('click', () => {
const parts = window.location.pathname.split('/');
const mangaId = parts[4];
const urlParams = new URLSearchParams(window.location.search);
let source = urlParams.get('source');
if (source === 'anilist') {
window.location.href = `/book/${mangaId}`;
} else {
window.location.href = `/book/${source}/${mangaId}`;
}
});
settingsBtn.addEventListener('click', () => {
panel.classList.add('open');
overlay.classList.add('active');
});
closePanel.addEventListener('click', closeSettings);
overlay.addEventListener('click', closeSettings);
function closeSettings() {
panel.classList.remove('open');
overlay.classList.remove('active');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('open')) {
closeSettings();
}
});
function enableMangaPageNavigation() {
if (currentType !== 'manga') return;
const logicalPages = [];
document.querySelectorAll('.manga-container > *').forEach(el => {
if (el.classList.contains('double-container')) {
logicalPages.push(el);
} else if (el.tagName === 'IMG') {
logicalPages.push(el);
}
});
if (logicalPages.length === 0) return;
function scrollToLogical(index) {
if (index < 0 || index >= logicalPages.length) return;
const topBar = document.querySelector('.top-bar');
const offset = topBar ? -topBar.offsetHeight : 0;
const y = logicalPages[index].getBoundingClientRect().top
+ window.pageYOffset
+ offset;
window.scrollTo({
top: y,
behavior: 'smooth'
});
}
function getCurrentLogicalIndex() {
let closest = 0;
let minDist = Infinity;
logicalPages.forEach((el, i) => {
const rect = el.getBoundingClientRect();
const dist = Math.abs(rect.top);
if (dist < minDist) {
minDist = dist;
closest = i;
}
});
return closest;
}
const rtl = () => config.manga.direction === 'rtl';
document.addEventListener('keydown', (e) => {
if (currentType !== 'manga') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const index = getCurrentLogicalIndex();
if (e.key === 'ArrowLeft') {
scrollToLogical(rtl() ? index + 1 : index - 1);
}
if (e.key === 'ArrowRight') {
scrollToLogical(rtl() ? index - 1 : index + 1);
}
});
reader.addEventListener('click', (e) => {
if (currentType !== 'manga') return;
const bounds = reader.getBoundingClientRect();
const x = e.clientX - bounds.left;
const half = bounds.width / 2;
const index = getCurrentLogicalIndex();
if (x < half) {
scrollToLogical(rtl() ? index + 1 : index - 1);
} else {
scrollToLogical(rtl() ? index - 1 : index + 1);
}
});
}
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
applyStyles();
}, 250);
});
let progressSaved = false;
function setupProgressTracking(data, source) {
progressSaved = false;
async function sendProgress(chapterNumber) {
const token = localStorage.getItem('token');
if (!token) return;
const body = {
entry_id: bookId,
source: source,
entry_type: data.type === 'manga' ? 'MANGA' : 'NOVEL',
status: 'CURRENT',
progress: source === 'anilist'
? Math.floor(chapterNumber)
: chapterNumber
};
try {
await fetch('/api/list/entry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
} catch (err) {
console.error('Error updating progress:', err);
}
}
function checkProgress() {
const scrollTop = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const percent = scrollHeight > 0 ? scrollTop / scrollHeight : 0;
if (percent >= 0.8 && !progressSaved) {
progressSaved = true;
const chapterNumber = (typeof data.number !== 'undefined' && data.number !== null)
? data.number
: Number(chapter);
sendProgress(chapterNumber);
window.removeEventListener('scroll', checkProgress);
}
}
window.removeEventListener('scroll', checkProgress);
window.addEventListener('scroll', checkProgress);
}
if (!bookId || !chapter || !provider) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Missing required parameters (bookId, chapter, provider)</span>
</div>
`;
} else {
loadConfig();
loadChapter();
}

View File

@@ -0,0 +1,391 @@
const providerSelector = document.getElementById('provider-selector');
const searchInput = document.getElementById('main-search-input');
const resultsContainer = document.getElementById('gallery-results');
let currentPage = 1;
let currentProvider = '';
let currentQuery = '';
const perPage = 48;
let isLoading = false;
let favorites = new Set();
let favoritesMode = false;
let msnry = null;
const sentinel = document.createElement('div');
sentinel.id = 'infinite-scroll-sentinel';
sentinel.style.height = '1px';
sentinel.style.marginBottom = '300px';
sentinel.style.display = 'none';
resultsContainer.parentNode.appendChild(sentinel);
function getAuthHeaders(extra = {}) {
const token = localStorage.getItem("token");
return token
? { ...extra, Authorization: `Bearer ${token}` }
: extra;
}
function initializeMasonry() {
if (typeof Masonry === 'undefined') {
setTimeout(initializeMasonry, 100);
return;
}
if (msnry) msnry.destroy();
msnry = new Masonry(resultsContainer, {
itemSelector: '.gallery-card',
columnWidth: '.gallery-card',
percentPosition: true,
gutter: 0,
transitionDuration: '0.4s'
});
msnry.layout();
}
initializeMasonry();
function getTagsArray(item) {
if (Array.isArray(item.tags)) return item.tags;
if (typeof item.tags === 'string') {
return item.tags
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
}
return [];
}
function getProxiedImageUrl(item) {
const imageUrl = item.image || item.image_url;
if (!imageUrl || !item.headers) {
return imageUrl;
}
let proxyUrl = `/api/proxy?url=${encodeURIComponent(imageUrl)}`;
const headers = item.headers;
const lowerCaseHeaders = {};
for (const key in headers) {
if (Object.prototype.hasOwnProperty.call(headers, key)) {
lowerCaseHeaders[key.toLowerCase()] = headers[key];
}
}
const referer = lowerCaseHeaders.referer;
if (referer) {
proxyUrl += `&referer=${encodeURIComponent(referer)}`;
}
const origin = lowerCaseHeaders.origin;
if (origin) {
proxyUrl += `&origin=${encodeURIComponent(origin)}`;
}
const userAgent = lowerCaseHeaders['user-agent'];
if (userAgent) {
proxyUrl += `&userAgent=${encodeURIComponent(userAgent)}`;
}
return proxyUrl;
}
async function loadFavorites() {
try {
const res = await fetch('/api/gallery/favorites', {
headers: getAuthHeaders()
});
if (!res.ok) return;
const data = await res.json();
favorites.clear();
(data.favorites || []).forEach(fav => favorites.add(fav.id));
} catch (err) {
console.error('Error loading favorites:', err);
}
}
async function toggleFavorite(item) {
const id = item.id;
const isFav = favorites.has(id);
try {
if (isFav) {
await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
favorites.delete(id);
} else {
const tagsArray = getTagsArray(item);
const serializedHeaders = item.headers ? JSON.stringify(item.headers) : "";
await fetch('/api/gallery/favorites', {
method: 'POST',
headers: getAuthHeaders({
'Content-Type': 'application/json'
}),
body: JSON.stringify({
id: item.id,
title: item.title || tagsArray.join(', ') || 'Untitled',
image_url: item.image || item.image_url,
thumbnail_url: item.image || item.image_url,
tags: tagsArray.join(','),
provider: item.provider || "",
headers: serializedHeaders
})
});
favorites.add(id);
}
document.querySelectorAll(`.gallery-card[data-id="${CSS.escape(id)}"] .fav-btn`).forEach(btn => {
btn.classList.toggle('favorited', !isFav);
btn.innerHTML = !isFav
? '<i class="fas fa-heart"></i>'
: '<i class="far fa-heart"></i>';
});
if (providerSelector.value === 'favorites' && isFav) {
setTimeout(() => searchGallery(false), 300);
}
} catch (err) {
console.error('Error toggling favorite:', err);
alert('Error updating favorite');
}
}
function createGalleryCard(item, isLoadMore = false) {
const card = document.createElement('a');
card.className = 'gallery-card grid-item';
card.href = `/gallery/${item.provider || currentProvider || 'unknown'}/${encodeURIComponent(item.id)}`;
card.dataset.id = item.id;
const img = document.createElement('img');
img.className = 'gallery-card-img';
img.src = getProxiedImageUrl(item);
img.alt = getTagsArray(item).join(', ') || 'Image';
if (isLoadMore) {
img.loading = 'lazy';
}
const favBtn = document.createElement('button');
favBtn.className = 'fav-btn';
const isFav = favorites.has(item.id);
favBtn.classList.toggle('favorited', isFav);
favBtn.innerHTML = isFav ? '<i class="fas fa-heart"></i>' : '<i class="far fa-heart"></i>';
favBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
toggleFavorite(item);
};
card.appendChild(favBtn);
card.appendChild(img);
if (currentProvider !== 'favorites') {
const badge = document.createElement('div');
badge.className = 'provider-badge';
badge.textContent = item.provider || 'Global';
card.appendChild(badge);
} else {
const badge = document.createElement('div');
badge.className = 'provider-badge';
badge.textContent = 'Favorites';
badge.style.background = 'rgba(255,107,107,0.7)';
card.appendChild(badge);
}
img.onload = img.onerror = () => {
card.classList.add('is-loaded');
if (msnry) msnry.layout();
};
return card;
}
function showSkeletons(count, append = false) {
const skeleton = `<div class="gallery-card skeleton grid-item"><div class="poster-skeleton"></div></div>`;
if (!append) {
resultsContainer.innerHTML = '';
initializeMasonry();
}
const elements = [];
for (let i = 0; i < count; i++) {
const div = document.createElement('div');
div.innerHTML = skeleton;
const el = div.firstChild;
resultsContainer.appendChild(el);
elements.push(el);
}
if (msnry) {
msnry.appended(elements);
msnry.layout();
}
sentinel.style.display = 'none';
observer.unobserve(sentinel);
}
async function searchGallery(isLoadMore = false) {
if (isLoading) return;
const query = searchInput.value.trim();
const provider = providerSelector.value;
const page = isLoadMore ? currentPage + 1 : 1;
favoritesMode = (provider === 'favorites');
currentQuery = query;
currentProvider = provider;
if (!isLoadMore) {
currentPage = 1;
showSkeletons(perPage);
} else {
showSkeletons(8, true);
}
isLoading = true;
try {
let data;
if (favoritesMode) {
const res = await fetch('/api/gallery/favorites', {
headers: getAuthHeaders()
});
data = await res.json();
let favoritesResults = data.favorites || [];
if (query) {
const lowerQuery = query.toLowerCase();
favoritesResults = favoritesResults.filter(fav =>
(fav.title && fav.title.toLowerCase().includes(lowerQuery)) ||
(fav.tags && fav.tags.toLowerCase().includes(lowerQuery))
);
}
data.results = favoritesResults.map(fav => ({
id: fav.id,
image: fav.image_url,
image_url: fav.image_url,
provider: fav.provider,
tags: fav.tags
}));
data.hasNextPage = false;
} else if (provider && provider !== '') {
const res = await fetch(`/api/gallery/search/provider?provider=${provider}&q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`);
data = await res.json();
} else {
const res = await fetch(`/api/gallery/search?q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`);
data = await res.json();
}
const results = data.results || [];
const skeletons = resultsContainer.querySelectorAll('.gallery-card.skeleton');
const toRemove = isLoadMore ? Array.from(skeletons).slice(-8) : skeletons;
if (msnry) msnry.remove(toRemove);
toRemove.forEach(el => el.remove());
const newCards = results.map((item, index) => createGalleryCard(item, isLoadMore));
newCards.forEach(card => resultsContainer.appendChild(card));
if (msnry) msnry.appended(newCards);
if (results.length === 0 && !isLoadMore) {
const msg = favoritesMode
? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet')
: 'No results found';
resultsContainer.innerHTML = `<p style="text-align:center;color:var(--text-secondary);padding:4rem;font-size:1.1rem;">${msg}</p>`;
}
if (msnry) msnry.layout();
if (!favoritesMode && data.hasNextPage) {
sentinel.style.display = 'block';
observer.observe(sentinel);
} else {
sentinel.style.display = 'none';
observer.unobserve(sentinel);
}
if (isLoadMore) currentPage++;
else currentPage = 1;
} catch (err) {
console.error('Error:', err);
if (!isLoadMore) {
resultsContainer.innerHTML = '<p style="text-align:center;color:red;padding:4rem;">Error loading gallery</p>';
}
} finally {
isLoading = false;
}
}
async function loadExtensions() {
try {
const res = await fetch('/api/extensions/gallery');
const data = await res.json();
providerSelector.innerHTML = '';
const favoritesOption = document.createElement('option');
favoritesOption.value = 'favorites';
favoritesOption.textContent = 'Favorites';
providerSelector.appendChild(favoritesOption);
(data.extensions || []).forEach(ext => {
const opt = document.createElement('option');
opt.value = ext;
opt.textContent = ext.charAt(0).toUpperCase() + ext.slice(1);
providerSelector.appendChild(opt);
});
} catch (err) {
console.error('Error loading extensions:', err);
}
}
providerSelector.addEventListener('change', () => {
if (providerSelector.value === 'favorites') {
searchInput.placeholder = "Search in favorites...";
} else {
searchInput.placeholder = "Search in gallery...";
}
searchGallery(false);
});
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchGallery(false);
}, 500);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
clearTimeout(searchTimeout);
searchGallery(false);
}
});
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && !isLoading && !favoritesMode) {
observer.unobserve(sentinel);
searchGallery(true);
}
}, { rootMargin: '1000px' });
loadFavorites().then(() => {
loadExtensions().then(() => {
searchGallery(false);
});
});
window.addEventListener('scroll', () => {
document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50);
});

View File

@@ -0,0 +1,320 @@
const itemMainContentContainer = document.getElementById('item-main-content');
let currentItem = null;
function getAuthHeaders(extra = {}) {
const token = localStorage.getItem("token");
return token
? { ...extra, Authorization: `Bearer ${token}` }
: extra;
}
function getProxiedItemUrl(url, headers = null) {
if (!url || !headers) {
return url;
}
let proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
const lowerCaseHeaders = {};
for (const key in headers) {
if (Object.prototype.hasOwnProperty.call(headers, key)) {
lowerCaseHeaders[key.toLowerCase()] = headers[key];
}
}
const referer = lowerCaseHeaders.referer;
if (referer) {
proxyUrl += `&referer=${encodeURIComponent(referer)}`;
}
const origin = lowerCaseHeaders.origin;
if (origin) {
proxyUrl += `&origin=${encodeURIComponent(origin)}`;
}
const userAgent = lowerCaseHeaders['user-agent'];
if (userAgent) {
proxyUrl += `&userAgent=${encodeURIComponent(userAgent)}`;
}
return proxyUrl;
}
function getUrlParams() {
const path = window.location.pathname.split('/').filter(s => s);
if (path.length < 3 || path[0] !== 'gallery') return null;
if (path[1] === 'favorites' && path[2]) {
return { fromFavorites: true, id: path[2] };
} else {
return { provider: path[1], id: path.slice(2).join('/') };
}
}
async function toggleFavorite() {
if (!currentItem?.id) return;
const btn = document.getElementById('fav-btn');
const wasFavorited = btn.classList.contains('favorited');
try {
if (wasFavorited) {
await fetch(`/api/gallery/favorites/${encodeURIComponent(currentItem.id)}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
} else {
const serializedHeaders = currentItem.headers ? JSON.stringify(currentItem.headers) : "";
const tagsString = Array.isArray(currentItem.tags) ? currentItem.tags.join(',') : (currentItem.tags || '');
await fetch('/api/gallery/favorites', {
method: 'POST',
headers: getAuthHeaders({
'Content-Type': 'application/json'
}),
body: JSON.stringify({
id: currentItem.id,
title: currentItem.title || 'Waifu',
image_url: currentItem.originalImage,
thumbnail_url: currentItem.originalImage,
tags: tagsString,
provider: currentItem.provider || "",
headers: serializedHeaders
})
});
}
btn.classList.toggle('favorited', !wasFavorited);
btn.innerHTML = !wasFavorited
? `<i class="fas fa-heart"></i> Saved!`
: `<i class="far fa-heart"></i> Save Image`;
} catch (err) {
console.error('Error toggling favorite:', err);
alert('Error updating favorites');
}
}
function copyLink() {
navigator.clipboard.writeText(window.location.href);
const btn = document.getElementById('copy-link-btn');
const old = btn.innerHTML;
btn.innerHTML = `<i class="fas fa-check"></i> Copied!`;
setTimeout(() => btn.innerHTML = old, 2000);
}
async function loadSimilarImages(item) {
if (!item.tags || item.tags.length === 0) {
document.getElementById('similar-section').innerHTML = '<p style="text-align:center;color:var(--color-text-secondary);padding:3rem 0;">No tags available to search for similar images.</p>';
return;
}
const firstTag = item.tags[0];
const container = document.getElementById('similar-section');
try {
const res = await fetch(`/api/gallery/search?q=${encodeURIComponent(firstTag)}&perPage=20`);
if (!res.ok) throw new Error();
const data = await res.json();
const results = (data.results || [])
.filter(r => r.id !== item.id)
.slice(0, 15);
if (results.length === 0) {
container.innerHTML = '<p style="text-align:center;color:var(--color-text-secondary);padding:3rem 0;">No similar images found.</p>';
return;
}
container.innerHTML = `
<h3 style="text-align:center;margin:2rem 0 1.5rem;font-size:1.7rem;">
More images tagged with "<span style="color:var(--color-primary);">${firstTag}</span>"
</h3>
<div class="similar-grid">
${results.map(img => {
const imageUrl = img.image || img.image_url || img.thumbnail_url;
const proxiedUrl = getProxiedItemUrl(imageUrl, img.headers);
return `
<a href="/gallery/${img.provider || 'unknown'}/${encodeURIComponent(img.id)}" class="similar-card">
<img src="${proxiedUrl}" alt="Similar" loading="lazy">
<div class="similar-overlay">${img.provider || 'Global'}</div>
</a>
`;
}).join('')}
</div>
`;
} catch (err) {
container.innerHTML = '<p style="text-align:center;color:var(--color-text-secondary);opacity:0.6;padding:3rem 0;">Could not load similar images.</p>';
}
}
function renderItem(item) {
const proxiedFullImage = getProxiedItemUrl(item.fullImage, item.headers);
let sourceText;
if (item.fromFavorites) {
sourceText = item.headers && item.provider && item.provider !== 'Favorites'
? `Source: ${item.provider}`
: 'Favorites';
} else {
sourceText = `Source: ${item.provider}`;
}
const originalProviderText = (item.fromFavorites && item.provider && item.provider !== 'Favorites')
? ` (Original: ${item.provider})`
: '';
itemMainContentContainer.innerHTML = `
<div class="item-content-flex-wrapper">
<div class="image-col">
<img class="item-image loaded" src="${proxiedFullImage}" alt="${item.title}">
</div>
<div class="info-col">
<div class="info-header">
<span class="provider-name">
${sourceText}${originalProviderText}
</span>
<h1>${item.title}</h1>
</div>
<div class="actions-row">
<button id="fav-btn" class="action-btn fav-action">
<i class="far fa-heart"></i> Save Image
</button>
<button id="copy-link-btn" class="action-btn copy-link-btn">
<i class="fas fa-link"></i> Copy Link
</button>
</div>
<div class="tags-section">
<h3>Tags</h3>
<div class="tag-list">
${item.tags.length > 0
? item.tags.map(tag => `
<a class="tag-item">${tag}</a>
`).join('')
: '<span style="opacity:0.6;color:var(--color-text-secondary);">No tags</span>'
}
</div>
</div>
</div>
</div>
`;
document.getElementById('fav-btn').addEventListener('click', toggleFavorite);
document.getElementById('copy-link-btn').addEventListener('click', copyLink);
loadSimilarImages(item);
}
function renderError(msg) {
itemMainContentContainer.innerHTML = `
<div style="text-align:center;padding:8rem 1rem;">
<i class="fa-solid fa-heart-crack" style="font-size:5rem;color:#ff6b6b;opacity:0.7;"></i>
<h2 style="margin:2rem 0;color:var(--color-text-primary);">Image Not Available</h2>
<p style="color:var(--color-text-secondary);max-width:600px;margin:0 auto 2rem;">${msg}</p>
<a href="/gallery" style="padding:1rem 2.5rem;background:var(--color-primary);color:white;border-radius:99px;text-decoration:none;font-weight:600;">
Back to Gallery
</a>
</div>
`;
document.getElementById('similar-section').style.display = 'none';
}
async function loadFromFavorites(id) {
try {
const res = await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, {
headers: getAuthHeaders()
});
if (!res.ok) throw new Error('Not found');
const { favorite: fav } = await res.json();
const item = {
id: fav.id,
title: fav.title || 'No title',
fullImage: fav.image_url,
originalImage: fav.image_url,
tags: typeof fav.tags === 'string' ? fav.tags.split(',').map(t => t.trim()).filter(Boolean) : (fav.tags || []),
provider: 'Favorites',
fromFavorites: true,
headers: fav.headers
};
currentItem = item;
renderItem(item);
document.getElementById('page-title').textContent = `WaifuBoard - ${item.title}`;
document.getElementById('fav-btn')?.classList.add('favorited');
const btn = document.getElementById('fav-btn');
if (btn) {
btn.innerHTML = `<i class="fa-solid fa-heart"></i> Saved!`;
}
} catch (err) {
renderError('This image is no longer in your favorites.');
}
}
async function loadFromProvider(provider, id) {
try {
const res = await fetch(`/api/gallery/fetch/${id}?provider=${provider}`);
if (!res.ok) throw new Error();
const data = await res.json();
if (!data.image) throw new Error();
const item = {
id,
title: data.title || 'Beautiful Art',
fullImage: data.image,
originalImage: data.image,
tags: data.tags || [],
provider,
headers: data.headers
};
currentItem = item;
renderItem(item);
document.getElementById('page-title').textContent = `WaifuBoard - ${item.title}`;
const favRes = await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, {
headers: getAuthHeaders()
});
if (favRes.ok) {
document.getElementById('fav-btn')?.classList.add('favorited');
const btn = document.getElementById('fav-btn');
if (btn) {
btn.innerHTML = `<i class="fa-solid fa-heart"></i> Saved!`;
}
}
} catch (err) {
renderError('Image not found.');
}
}
if (itemMainContentContainer) {
const params = getUrlParams();
if (!params) {
renderError('Invalid URL');
} else if (params.fromFavorites) {
loadFromFavorites(params.id);
} else {
loadFromProvider(params.provider, params.id);
}
} else {
document.getElementById('item-content').innerHTML = `<p style="text-align:center;padding:5rem;color:red;">Error: HTML container 'item-main-content' not found. Please update gallery-image.html.</p>`;
document.getElementById('similar-section').style.display = 'none';
}
window.addEventListener('scroll', () => {
document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50);
});

368
docker/src/scripts/list.js Normal file
View File

@@ -0,0 +1,368 @@
const API_BASE = '/api';
let currentList = [];
let filteredList = [];
document.addEventListener('DOMContentLoaded', async () => {
await loadList();
setupEventListeners();
});
function getEntryLink(item) {
const isAnime = item.entry_type?.toUpperCase() === 'ANIME';
const baseRoute = isAnime ? '/anime' : '/book';
const source = item.source || 'anilist';
if (source === 'anilist') {
return `${baseRoute}/${item.entry_id}`;
} else {
return `${baseRoute}/${source}/${item.entry_id}`;
}
}
async function populateSourceFilter() {
const select = document.getElementById('source-filter');
if (!select) return;
select.innerHTML = `
<option value="all">All Sources</option>
<option value="anilist">AniList</option>
`;
try {
const response = await fetch(`${API_BASE}/extensions`);
if (response.ok) {
const data = await response.json();
const extensions = data.extensions || [];
extensions.forEach(extName => {
if (extName.toLowerCase() !== 'anilist' && extName.toLowerCase() !== 'local') {
const option = document.createElement('option');
option.value = extName;
option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1);
select.appendChild(option);
}
});
}
} catch (error) {
console.error('Error loading extensions:', error);
}
}
function updateLocalList(entryData, action) {
const entryId = entryData.entry_id;
const source = entryData.source;
const findIndex = (list) => list.findIndex(e =>
e.entry_id === entryId && e.source === source
);
const currentIndex = findIndex(currentList);
if (currentIndex !== -1) {
if (action === 'update') {
currentList[currentIndex] = { ...currentList[currentIndex], ...entryData };
} else if (action === 'delete') {
currentList.splice(currentIndex, 1);
}
} else if (action === 'update') {
currentList.push(entryData);
}
filteredList = [...currentList];
updateStats();
applyFilters();
window.ListModalManager.close();
}
function setupEventListeners() {
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
const container = document.getElementById('list-container');
if (view === 'list') {
container.classList.add('list-view');
} else {
container.classList.remove('list-view');
}
});
});
document.getElementById('status-filter').addEventListener('change', applyFilters);
document.getElementById('source-filter').addEventListener('change', applyFilters);
document.getElementById('type-filter').addEventListener('change', applyFilters);
document.getElementById('sort-filter').addEventListener('change', applyFilters);
document.querySelector('.search-input').addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
if (query) {
filteredList = currentList.filter(item =>
item.title?.toLowerCase().includes(query)
);
} else {
filteredList = [...currentList];
}
applyFilters();
});
document.getElementById('modal-save-btn')?.addEventListener('click', async () => {
const entryToSave = window.ListModalManager.currentEntry || window.ListModalManager.currentData;
if (!entryToSave) return;
const success = await window.ListModalManager.save(entryToSave.entry_id, entryToSave.source);
if (success) {
const updatedEntry = window.ListModalManager.currentEntry;
updatedEntry.updated_at = new Date().toISOString();
updateLocalList(updatedEntry, 'update');
}
});
document.getElementById('modal-delete-btn')?.addEventListener('click', async () => {
const entryToDelete = window.ListModalManager.currentEntry || window.ListModalManager.currentData;
if (!entryToDelete) return;
const success = await window.ListModalManager.delete(entryToDelete.entry_id, entryToDelete.source);
if (success) {
updateLocalList(entryToDelete, 'delete');
}
});
document.getElementById('add-list-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'add-list-modal') {
window.ListModalManager.close();
}
});
}
async function loadList() {
const loadingState = document.getElementById('loading-state');
const emptyState = document.getElementById('empty-state');
const container = document.getElementById('list-container');
await populateSourceFilter();
try {
loadingState.style.display = 'flex';
emptyState.style.display = 'none';
container.innerHTML = '';
const response = await fetch(`${API_BASE}/list`, {
headers: window.AuthUtils.getSimpleAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load list');
}
const data = await response.json();
currentList = data.results || [];
filteredList = [...currentList];
loadingState.style.display = 'none';
if (currentList.length === 0) {
emptyState.style.display = 'flex';
} else {
updateStats();
applyFilters();
}
} catch (error) {
console.error('Error loading list:', error);
loadingState.style.display = 'none';
if (window.NotificationUtils) {
window.NotificationUtils.error('Failed to load your list. Please try again.');
} else {
alert('Failed to load your list. Please try again.');
}
}
}
function updateStats() {
const total = currentList.length;
const watching = currentList.filter(item => item.status === 'WATCHING').length;
const completed = currentList.filter(item => item.status === 'COMPLETED').length;
const planning = currentList.filter(item => item.status === 'PLANNING').length;
document.getElementById('total-count').textContent = total;
document.getElementById('watching-count').textContent = watching;
document.getElementById('completed-count').textContent = completed;
document.getElementById('planned-count').textContent = planning;
}
function applyFilters() {
const statusFilter = document.getElementById('status-filter').value;
const sourceFilter = document.getElementById('source-filter').value;
const typeFilter = document.getElementById('type-filter').value;
const sortFilter = document.getElementById('sort-filter').value;
let filtered = [...filteredList];
if (statusFilter !== 'all') {
filtered = filtered.filter(item => item.status === statusFilter);
}
if (sourceFilter !== 'all') {
filtered = filtered.filter(item => item.source === sourceFilter);
}
if (typeFilter !== 'all') {
filtered = filtered.filter(item => item.entry_type === typeFilter);
}
switch (sortFilter) {
case 'title':
filtered.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
break;
case 'score':
filtered.sort((a, b) => (b.score || 0) - (a.score || 0));
break;
case 'progress':
filtered.sort((a, b) => (b.progress || 0) - (a.progress || 0));
break;
case 'updated':
default:
filtered.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
break;
}
renderList(filtered);
}
function renderList(items) {
const container = document.getElementById('list-container');
container.innerHTML = '';
if (items.length === 0) {
if (currentList.length === 0) {
document.getElementById('empty-state').style.display = 'flex';
} else {
container.innerHTML = '<div class="empty-state"><p>No entries match your filters</p></div>';
}
return;
}
document.getElementById('empty-state').style.display = 'none';
items.forEach(item => {
const element = createListItem(item);
container.appendChild(element);
});
}
function createListItem(item) {
const div = document.createElement('div');
div.className = 'list-item';
const itemLink = getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.png';
const progress = item.progress || 0;
const totalUnits = item.entry_type === 'ANIME' ?
item.total_episodes || 0 :
item.total_chapters || 0;
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null;
const repeatCount = item.repeat_count || 0;
const entryType = (item.entry_type).toUpperCase();
let unitLabel = 'units';
if (entryType === 'ANIME') {
unitLabel = 'episodes';
} else if (entryType === 'MANGA') {
unitLabel = 'chapters';
} else if (entryType === 'NOVEL') {
unitLabel = 'chapters/volumes';
}
const statusLabels = {
'CURRENT': entryType === 'ANIME' ? 'Watching' : 'Reading',
'COMPLETED': 'Completed',
'PLANNING': 'Planning',
'PAUSED': 'Paused',
'DROPPED': 'Dropped',
'REPEATING': entryType === 'ANIME' ? 'Rewatching' : 'Rereading'
};
const extraInfo = [];
if (repeatCount > 0) {
extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`);
}
if (item.is_private) {
extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
}
const entryDataString = JSON.stringify(item).replace(/'/g, '&#39;');
div.innerHTML = `
<a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
</a>
<div class="item-content">
<div>
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
<h3 class="item-title">${item.title || 'Unknown Title'}</h3>
</a>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
${extraInfo.join('')}
</div>
</div>
<div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel}</span> ${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
</div>
</div>
</div>
<button class="edit-icon-btn" data-entry='${entryDataString}'>
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/>
</svg>
</button>
`;
const editBtn = div.querySelector('.edit-icon-btn');
editBtn.addEventListener('click', (e) => {
try {
const entryData = JSON.parse(e.currentTarget.dataset.entry);
window.ListModalManager.isInList = true;
window.ListModalManager.currentEntry = entryData;
window.ListModalManager.currentData = entryData;
window.ListModalManager.open(entryData, entryData.source);
} catch (error) {
console.error('Error parsing entry data for modal:', error);
if (window.NotificationUtils) {
window.NotificationUtils.error('Could not open modal. Check HTML form IDs.');
}
}
});
return div;
}

View File

@@ -0,0 +1,422 @@
const GITEA_INSTANCE = 'https://git.waifuboard.app';
const REPO_OWNER = 'ItsSkaiya';
const REPO_NAME = 'WaifuBoard-Extensions';
let DETECTED_BRANCH = 'main';
const API_URL_BASE = `${GITEA_INSTANCE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents`;
const INSTALLED_EXTENSIONS_API = '/api/extensions';
const extensionsGrid = document.getElementById('extensions-grid');
const filterSelect = document.getElementById('extension-filter');
let allExtensionsData = [];
const customModal = document.getElementById('customModal');
const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage');
function getRawUrl(filename) {
const targetUrl = `${GITEA_INSTANCE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/${filename}`;
const encodedUrl = encodeURIComponent(targetUrl);
return `/api/proxy?url=${encodedUrl}`;
}
function updateExtensionState(fileName, installed) {
const ext = allExtensionsData.find(e => e.fileName === fileName);
if (!ext) return;
ext.isInstalled = installed;
ext.isLocal = installed && ext.isLocal;
filterAndRenderExtensions(filterSelect?.value || 'All');
}
function formatExtensionName(fileName) {
return fileName.replace('.js', '')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^[a-z]/, (char) => char.toUpperCase());
}
function getIconUrl(extensionDetails) {
return extensionDetails;
}
async function getExtensionDetails(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const text = await res.text();
const regex = /(?:this\.|const\s+|let\s+|var\s+)?baseUrl\s*=\s*(["'`])(.*?)\1/i;
const match = text.match(regex);
let finalHostname = null;
if (match && match[2]) {
let rawUrl = match[2].trim();
if (!rawUrl.startsWith('http')) rawUrl = 'https://' + rawUrl;
try {
const urlObj = new URL(rawUrl);
finalHostname = urlObj.hostname;
} catch(e) {
console.warn(`Could not parse baseUrl: ${rawUrl}`);
}
}
const classMatch = text.match(/class\s+(\w+)/);
const name = classMatch ? classMatch[1] : null;
let type = 'Image';
if (text.includes('type = "book-board"') || text.includes("type = 'book-board'")) type = 'Book';
else if (text.includes('type = "anime-board"') || text.includes("type = 'anime-board'")) type = 'Anime';
return { baseUrl: finalHostname, name, type };
} catch (e) {
return { baseUrl: null, name: null, type: 'Unknown' };
}
}
function showCustomModal(title, message, isConfirm = false) {
return new Promise(resolve => {
modalTitle.textContent = title;
modalMessage.textContent = message;
const currentConfirmButton = document.getElementById('modalConfirmButton');
const currentCloseButton = document.getElementById('modalCloseButton');
const newConfirmButton = currentConfirmButton.cloneNode(true);
currentConfirmButton.parentNode.replaceChild(newConfirmButton, currentConfirmButton);
const newCloseButton = currentCloseButton.cloneNode(true);
currentCloseButton.parentNode.replaceChild(newCloseButton, currentCloseButton);
if (isConfirm) {
newConfirmButton.classList.remove('hidden');
newConfirmButton.textContent = 'Confirm';
newCloseButton.textContent = 'Cancel';
} else {
newConfirmButton.classList.add('hidden');
newCloseButton.textContent = 'Close';
}
const closeModal = (confirmed) => {
customModal.classList.add('hidden');
resolve(confirmed);
};
newConfirmButton.onclick = () => closeModal(true);
newCloseButton.onclick = () => closeModal(false);
customModal.classList.remove('hidden');
});
}
function renderExtensionCard(extension, isInstalled, isLocalOnly = false) {
const extensionName = formatExtensionName(extension.fileName || extension.name);
const extensionType = extension.type || 'Unknown';
let iconUrl;
if (extension.baseUrl && extension.baseUrl !== 'Local Install') {
iconUrl = `https://www.google.com/s2/favicons?domain=${extension.baseUrl}&sz=128`;
} else {
const displayName = extensionName.replace(/\s/g, '+');
iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`;
}
const card = document.createElement('div');
card.className = `extension-card grid-item extension-type-${extensionType.toLowerCase()}`;
card.dataset.path = extension.fileName || extension.name;
card.dataset.type = extensionType;
let buttonHtml;
let badgeHtml = '';
if (isInstalled) {
if (isLocalOnly) {
badgeHtml = '<span class="extension-status-badge badge-local">Local</span>';
} else {
badgeHtml = '<span class="extension-status-badge badge-installed">Installed</span>';
}
buttonHtml = `
<button class="extension-action-button btn-uninstall" data-action="uninstall">Uninstall</button>
`;
} else {
buttonHtml = `
<button class="extension-action-button btn-install" data-action="install">Install</button>
`;
}
card.innerHTML = `
<img class="extension-icon" src="${iconUrl}" alt="${extensionName} Icon" onerror="this.onerror=null; this.src='https://ui-avatars.com/api/?name=E&background=1f2937&color=fff&length=1'">
<div class="card-content-wrapper">
<h3 class="extension-name" title="${extensionName}">${extensionName}</h3>
${badgeHtml}
</div>
${buttonHtml}
`;
const installButton = card.querySelector('[data-action="install"]');
const uninstallButton = card.querySelector('[data-action="uninstall"]');
if (installButton) {
installButton.addEventListener('click', async () => {
try {
const response = await fetch('/api/extensions/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: extension.fileName }),
});
const result = await response.json();
if (response.ok) {
updateExtensionState(extension.fileName, true);
await showCustomModal(
'Installation Successful',
`${extensionName} has been successfully installed.`,
false
);
} else {
await showCustomModal(
'Installation Failed',
`Installation failed: ${result.error || 'Unknown error.'}`,
false
);
}
} catch (error) {
await showCustomModal(
'Installation Failed',
`Network error during installation.`,
false
);
}
});
}
if (uninstallButton) {
uninstallButton.addEventListener('click', async () => {
const confirmed = await showCustomModal(
'Confirm Uninstallation',
`Are you sure you want to uninstall ${extensionName}?`,
true
);
if (!confirmed) return;
try {
const response = await fetch('/api/extensions/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: extension.fileName }),
});
const result = await response.json();
if (response.ok) {
updateExtensionState(extension.fileName, false);
await showCustomModal(
'Uninstallation Successful',
`${extensionName} has been successfully uninstalled.`,
false
);
} else {
await showCustomModal(
'Uninstallation Failed',
`Uninstallation failed: ${result.error || 'Unknown error.'}`,
false
);
}
} catch (error) {
await showCustomModal(
'Uninstallation Failed',
`Network error during uninstallation.`,
false
);
}
});
}
extensionsGrid.appendChild(card);
}
async function getInstalledExtensions() {
console.log(`Fetching installed extensions from: ${INSTALLED_EXTENSIONS_API}`);
try {
const response = await fetch(INSTALLED_EXTENSIONS_API);
if (!response.ok) {
console.error(`Error fetching installed extensions. Status: ${response.status}`);
return new Set();
}
const data = await response.json();
if (!data.extensions || !Array.isArray(data.extensions)) {
console.error("Invalid response format from /api/extensions: 'extensions' array missing or incorrect.");
return new Set();
}
const installedFileNames = data.extensions
.map(name => `${name.toLowerCase()}.js`);
return new Set(installedFileNames);
} catch (error) {
console.error('Network or JSON parsing error during fetch of installed extensions:', error);
return new Set();
}
}
function filterAndRenderExtensions(filterType) {
extensionsGrid.innerHTML = '';
if (!allExtensionsData || allExtensionsData.length === 0) {
console.log('No extension data to filter.');
return;
}
const filteredExtensions = allExtensionsData.filter(ext =>
filterType === 'All' || ext.type === filterType || (ext.isLocal && filterType === 'Local')
);
filteredExtensions.forEach(ext => {
renderExtensionCard(ext, ext.isInstalled, ext.isLocal);
});
if (filteredExtensions.length === 0) {
extensionsGrid.innerHTML = `<p style="grid-column: 1 / -1; text-align: center; color: var(--text-secondary);">No extensions found for the selected filter (${filterType}).</p>`;
}
}
async function loadMarketplace() {
extensionsGrid.innerHTML = '';
for (let i = 0; i < 6; i++) {
extensionsGrid.innerHTML += `
<div class="extension-card skeleton grid-item">
<div class="skeleton-icon skeleton" style="width: 50px; height: 50px;"></div>
<div class="card-content-wrapper">
<div class="skeleton-text title-skeleton skeleton" style="width: 80%; height: 1.1em;"></div>
<div class="skeleton-text text-skeleton skeleton" style="width: 50%; height: 0.7em; margin-top: 0.25rem;"></div>
</div>
<div class="skeleton-button skeleton" style="width: 100%; height: 32px; margin-top: 0.5rem;"></div>
</div>`;
}
try {
const [availableExtensionsRaw, installedExtensionsSet] = await Promise.all([
fetch(API_URL_BASE).then(res => {
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
return res.json();
}),
getInstalledExtensions()
]);
const availableExtensionsJs = availableExtensionsRaw.filter(ext => ext.type === 'file' && ext.name.endsWith('.js'));
const detailPromises = [];
const marketplaceFileNames = new Set(availableExtensionsJs.map(ext => ext.name.toLowerCase()));
for (const ext of availableExtensionsJs) {
const downloadUrl = getRawUrl(ext.name);
const detailsPromise = getExtensionDetails(downloadUrl).then(details => ({
...ext,
...details,
fileName: ext.name,
isInstalled: installedExtensionsSet.has(ext.name.toLowerCase()),
isLocal: false,
}));
detailPromises.push(detailsPromise);
}
const extensionsWithDetails = await Promise.all(detailPromises);
installedExtensionsSet.forEach(installedName => {
if (!marketplaceFileNames.has(installedName)) {
const localExt = {
name: formatExtensionName(installedName),
fileName: installedName,
type: 'Local',
isInstalled: true,
isLocal: true,
baseUrl: 'Local Install',
};
extensionsWithDetails.push(localExt);
}
});
extensionsWithDetails.sort((a, b) => {
if (a.isInstalled !== b.isInstalled) {
return b.isInstalled - a.isInstalled;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
});
allExtensionsData = extensionsWithDetails;
if (filterSelect) {
filterSelect.addEventListener('change', (event) => {
filterAndRenderExtensions(event.target.value);
});
}
filterAndRenderExtensions('All');
} catch (error) {
console.error('Error loading the marketplace:', error);
extensionsGrid.innerHTML = `
<div style="grid-column: 1 / -1; color: #dc2626; text-align: center; padding: 2rem; background: rgba(220,38,38,0.1); border-radius: 12px; margin-top: 1rem;">
🚨 Error loading extensions.
<p>Could not connect to the extension repository or local endpoint. Detail: ${error.message}</p>
</div>
`;
allExtensionsData = [];
}
}
customModal.addEventListener('click', (e) => {
if (e.target === customModal || e.target.tagName === 'BUTTON') {
customModal.classList.add('hidden');
}
});
document.addEventListener('DOMContentLoaded', loadMarketplace);
window.addEventListener('scroll', () => {
const navbar = document.getElementById('navbar');
if (window.scrollY > 0) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
});

View File

@@ -0,0 +1,9 @@
fetch("/api/rpc", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
details: "Browsing",
state: `In App`,
mode: "idle"
})
});

View File

@@ -0,0 +1,360 @@
const ANILIST_API = 'https://graphql.anilist.co';
const CACHE_NAME = 'waifuboard-schedule-v1';
const CACHE_DURATION = 5 * 60 * 1000;
const state = {
currentDate: new Date(),
viewType: 'MONTH',
mode: 'SUB',
loading: false,
abortController: null,
refreshInterval: null
};
document.addEventListener('DOMContentLoaded', () => {
renderHeader();
fetchSchedule();
state.refreshInterval = setInterval(() => {
console.log("Auto-refreshing schedule...");
fetchSchedule(true);
}, CACHE_DURATION);
});
async function getCache(key) {
try {
const cache = await caches.open(CACHE_NAME);
const response = await cache.match(`/schedule-cache/${key}`);
if (!response) return null;
const cached = await response.json();
const age = Date.now() - cached.timestamp;
if (age < CACHE_DURATION) {
console.log(`[Cache Hit] Loaded ${key} (Age: ${Math.round(age / 1000)}s)`);
return cached.data;
}
console.log(`[Cache Stale] ${key} expired.`);
cache.delete(`/schedule-cache/${key}`);
return null;
} catch (e) {
console.error("Cache read failed", e);
return null;
}
}
async function setCache(key, data) {
try {
const cache = await caches.open(CACHE_NAME);
const payload = JSON.stringify({
timestamp: Date.now(),
data: data
});
const response = new Response(payload, {
headers: { 'Content-Type': 'application/json' }
});
await cache.put(`/schedule-cache/${key}`, response);
} catch (e) {
console.warn("Cache write failed", e);
}
}
function getCacheKey() {
if (state.viewType === 'MONTH') {
return `M_${state.currentDate.getFullYear()}_${state.currentDate.getMonth()}`;
} else {
const start = getWeekStart(state.currentDate);
return `W_${start.toISOString().split('T')[0]}`;
}
}
function navigate(delta) {
if (state.abortController) state.abortController.abort();
if (state.viewType === 'MONTH') {
state.currentDate.setMonth(state.currentDate.getMonth() + delta);
} else {
state.currentDate.setDate(state.currentDate.getDate() + (delta * 7));
}
renderHeader();
fetchSchedule();
}
function setViewType(type) {
if (state.viewType === type) return;
state.viewType = type;
document.getElementById('btnViewMonth').classList.toggle('active', type === 'MONTH');
document.getElementById('btnViewWeek').classList.toggle('active', type === 'WEEK');
if (state.abortController) state.abortController.abort();
renderHeader();
fetchSchedule();
}
function setMode(mode) {
if (state.mode === mode) return;
state.mode = mode;
document.getElementById('btnSub').classList.toggle('active', mode === 'SUB');
document.getElementById('btnDub').classList.toggle('active', mode === 'DUB');
fetchSchedule();
}
function renderHeader() {
const options = { month: 'long', year: 'numeric' };
let title = state.currentDate.toLocaleDateString('en-US', options);
if (state.viewType === 'WEEK') {
const start = getWeekStart(state.currentDate);
const end = new Date(start);
end.setDate(end.getDate() + 6);
const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
title = `Week of ${startStr} - ${endStr}`;
}
document.getElementById('monthTitle').textContent = title;
}
function getWeekStart(date) {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
return new Date(d.setDate(diff));
}
async function fetchSchedule(forceRefresh = false) {
const key = getCacheKey();
if (!forceRefresh) {
const cachedData = await getCache(key);
if (cachedData) {
renderGrid(cachedData);
updateAmbient(cachedData);
return;
}
}
if (state.abortController) state.abortController.abort();
state.abortController = new AbortController();
const signal = state.abortController.signal;
if (!forceRefresh) setLoading(true);
let startTs, endTs;
if (state.viewType === 'MONTH') {
const year = state.currentDate.getFullYear();
const month = state.currentDate.getMonth();
startTs = Math.floor(new Date(year, month, 1).getTime() / 1000);
endTs = Math.floor(new Date(year, month + 1, 0, 23, 59, 59).getTime() / 1000);
} else {
const start = getWeekStart(state.currentDate);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 7);
startTs = Math.floor(start.getTime() / 1000);
endTs = Math.floor(end.getTime() / 1000);
}
const query = `
query ($start: Int, $end: Int, $page: Int) {
Page(page: $page, perPage: 50) {
pageInfo { hasNextPage }
airingSchedules(airingAt_greater: $start, airingAt_lesser: $end, sort: TIME) {
airingAt
episode
media {
id
title { userPreferred english }
coverImage { large }
bannerImage
isAdult
countryOfOrigin
popularity
}
}
}
}
`;
let allData = [];
let page = 1;
let hasNext = true;
let retries = 0;
try {
while (hasNext && page <= 6) {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
try {
const res = await fetch(ANILIST_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
query,
variables: { start: startTs, end: endTs, page }
}),
signal: signal
});
if (res.status === 429) {
if (retries > 2) throw new Error("Rate Limited");
console.warn("429 Hit. Waiting...");
await delay(4000);
retries++;
continue;
}
const json = await res.json();
if (json.errors) throw new Error("API Error");
const data = json.data.Page;
allData = [...allData, ...data.airingSchedules];
hasNext = data.pageInfo.hasNextPage;
page++;
await delay(600);
} catch (e) {
if (e.name === 'AbortError') throw e;
console.error(e);
break;
}
}
if (!signal.aborted) {
await setCache(key, allData);
renderGrid(allData);
updateAmbient(allData);
}
} catch (e) {
if (e.name !== 'AbortError') console.error("Fetch failed:", e);
} finally {
if (!signal.aborted) {
setLoading(false);
state.abortController = null;
}
}
}
function renderGrid(data) {
const grid = document.getElementById('daysGrid');
grid.innerHTML = '';
let items = data.filter(i => !i.media.isAdult && i.media.countryOfOrigin === 'JP');
if (state.mode === 'DUB') {
items = items.filter(i => i.media.popularity > 20000);
}
if (state.viewType === 'MONTH') {
const year = state.currentDate.getFullYear();
const month = state.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
let firstDayIndex = new Date(year, month, 1).getDay() - 1;
if (firstDayIndex === -1) firstDayIndex = 6;
for (let i = 0; i < firstDayIndex; i++) {
const empty = document.createElement('div');
empty.className = 'day-cell empty';
grid.appendChild(empty);
}
for (let day = 1; day <= daysInMonth; day++) {
const dateObj = new Date(year, month, day);
renderDayCell(dateObj, items, grid);
}
} else {
const start = getWeekStart(state.currentDate);
for (let i = 0; i < 7; i++) {
const dateObj = new Date(start);
dateObj.setDate(start.getDate() + i);
renderDayCell(dateObj, items, grid);
}
}
}
function renderDayCell(dateObj, items, grid) {
const cell = document.createElement('div');
cell.className = 'day-cell';
if (state.viewType === 'WEEK') cell.style.minHeight = '300px';
const day = dateObj.getDate();
const month = dateObj.getMonth();
const year = dateObj.getFullYear();
const now = new Date();
if (day === now.getDate() && month === now.getMonth() && year === now.getFullYear()) {
cell.classList.add('today');
}
const dayEvents = items.filter(i => {
const eventDate = new Date(i.airingAt * 1000);
return eventDate.getDate() === day && eventDate.getMonth() === month && eventDate.getFullYear() === year;
});
dayEvents.sort((a, b) => b.media.popularity - a.media.popularity);
if (dayEvents.length > 0) {
const top = dayEvents[0].media;
const bg = document.createElement('div');
bg.className = 'cell-backdrop';
bg.style.backgroundImage = `url('${top.coverImage.large}')`;
cell.appendChild(bg);
}
const header = document.createElement('div');
header.className = 'day-header';
header.innerHTML = `
<span class="day-number">${day}</span>
<span class="today-label">Today</span>
`;
cell.appendChild(header);
const list = document.createElement('div');
list.className = 'events-list';
dayEvents.forEach(evt => {
const title = evt.media.title.english || evt.media.title.userPreferred;
const link = `/anime/${evt.media.id}`;
const chip = document.createElement('a');
chip.className = 'anime-chip';
chip.href = link;
chip.innerHTML = `
<span class="chip-title">${title}</span>
<span class="chip-ep">Ep ${evt.episode}</span>
`;
list.appendChild(chip);
});
cell.appendChild(list);
grid.appendChild(cell);
}
function setLoading(bool) {
state.loading = bool;
const loader = document.getElementById('loader');
if (bool) loader.classList.add('active');
else loader.classList.remove('active');
}
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function updateAmbient(data) {
if (!data || !data.length) return;
const top = data.reduce((prev, curr) => (prev.media.popularity > curr.media.popularity) ? prev : curr);
const img = top.media.bannerImage || top.media.coverImage.large;
if (img) document.getElementById('ambientBg').style.backgroundImage = `url('${img}')`;
}

View File

@@ -0,0 +1,18 @@
if (window.electronAPI?.isElectron) {
document.documentElement.classList.add("electron");
}
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.style.visibility = "visible";
if (!window.electronAPI?.isElectron) return;
document.body.classList.add("electron");
const titlebar = document.getElementById("titlebar");
if (!titlebar) return;
titlebar.style.display = "flex";
titlebar.querySelector(".min").onclick = () => window.electronAPI.win.minimize();
titlebar.querySelector(".max").onclick = () => window.electronAPI.win.maximize();
titlebar.querySelector(".close").onclick = () => window.electronAPI.win.close();
});

View File

@@ -0,0 +1,102 @@
const Gitea_OWNER = "ItsSkaiya";
const Gitea_REPO = "WaifuBoard";
const CURRENT_VERSION = "v2.0.0-rc.0";
const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000;
let currentVersionDisplay;
let latestVersionDisplay;
let updateToast;
document.addEventListener("DOMContentLoaded", () => {
currentVersionDisplay = document.getElementById("currentVersionDisplay");
latestVersionDisplay = document.getElementById("latestVersionDisplay");
updateToast = document.getElementById("updateToast");
if (currentVersionDisplay) {
currentVersionDisplay.textContent = CURRENT_VERSION;
}
checkForUpdates();
setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
});
function showToast(latestVersion) {
if (latestVersionDisplay && updateToast) {
latestVersionDisplay.textContent = latestVersion;
updateToast.classList.add("update-available");
updateToast.classList.remove("hidden");
} else {
console.error(
"Error: Cannot display toast because one or more DOM elements were not found.",
);
}
}
function hideToast() {
if (updateToast) {
updateToast.classList.add("hidden");
updateToast.classList.remove("update-available");
}
}
function isVersionOutdated(versionA, versionB) {
const vA = versionA.replace(/^v/, "").split(".").map(Number);
const vB = versionB.replace(/^v/, "").split(".").map(Number);
for (let i = 0; i < Math.max(vA.length, vB.length); i++) {
const numA = vA[i] || 0;
const numB = vB[i] || 0;
if (numA < numB) return true;
if (numA > numB) return false;
}
return false;
}
async function checkForUpdates() {
console.log(`Checking for updates for ${Gitea_OWNER}/${Gitea_REPO}...`);
const apiUrl = `https://git.waifuboard.app/api/v1/repos/${Gitea_OWNER}/${Gitea_REPO}/releases/latest`;
try {
const response = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
if (response.status === 404) {
console.info("No releases found for this repository.");
return;
}
throw new Error(
`Gitea API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
const latestVersion = data.tag_name;
if (!latestVersion) {
console.warn("Release found but no tag_name present");
return;
}
console.log(`Latest Gitea Release: ${latestVersion}`);
if (isVersionOutdated(CURRENT_VERSION, latestVersion)) {
console.warn("Update available!");
showToast(latestVersion);
} else {
console.info("Package is up to date.");
hideToast();
}
} catch (error) {
console.error("Failed to fetch Gitea release:", error);
}
}

977
docker/src/scripts/users.js Normal file
View File

@@ -0,0 +1,977 @@
const API_BASE = '/api';
let users = [];
let selectedFile = null;
let currentUserId = null;
const usersGrid = document.getElementById('usersGrid');
const btnAddUser = document.getElementById('btnAddUser');
const modalCreateUser = document.getElementById('modalCreateUser');
const closeCreateModal = document.getElementById('closeCreateModal');
const cancelCreate = document.getElementById('cancelCreate');
const createUserForm = document.getElementById('createUserForm');
const modalUserActions = document.getElementById('modalUserActions');
const closeActionsModal = document.getElementById('closeActionsModal');
const actionsModalTitle = document.getElementById('actionsModalTitle');
const modalEditUser = document.getElementById('modalEditUser');
const closeEditModal = document.getElementById('closeEditModal');
const cancelEdit = document.getElementById('cancelEdit');
const editUserForm = document.getElementById('editUserForm');
const modalAniList = document.getElementById('modalAniList');
const closeAniListModal = document.getElementById('closeAniListModal');
const aniListContent = document.getElementById('aniListContent');
const toastContainer = document.getElementById('userToastContainer');
const params = new URLSearchParams(window.location.search);
const anilistStatus = params.get("anilist");
if (anilistStatus === "success") {
showUserToast("✅ AniList connected successfully!");
}
if (anilistStatus === "error") {
showUserToast("❌ Failed to connect AniList");
}
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
attachEventListeners();
});
function showUserToast(message, type = 'info') {
if (!toastContainer) return;
const toast = document.createElement('div');
toast.className = `wb-toast ${type}`;
toast.textContent = message;
toastContainer.prepend(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove());
}, 4000);
}
function attachEventListeners() {
if (btnAddUser) btnAddUser.addEventListener('click', openCreateModal);
if (closeCreateModal) closeCreateModal.addEventListener('click', closeModal);
if (cancelCreate) cancelCreate.addEventListener('click', closeModal);
if (closeAniListModal) closeAniListModal.addEventListener('click', closeModal);
if (closeActionsModal) closeActionsModal.addEventListener('click', closeModal);
if (closeEditModal) closeEditModal.addEventListener('click', closeModal);
if (cancelEdit) cancelEdit.addEventListener('click', closeModal);
if (createUserForm) createUserForm.addEventListener('submit', handleCreateUser);
if (editUserForm) editUserForm.addEventListener('submit', handleEditUser);
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) closeModal();
});
});
}
function initAvatarUpload(uploadAreaId, fileInputId, previewId) {
const uploadArea = document.getElementById(uploadAreaId);
const fileInput = document.getElementById(fileInputId);
const preview = document.getElementById(previewId);
if (!uploadArea || !fileInput) return;
uploadArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) handleFileSelect(file, previewId);
});
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
handleFileSelect(file, previewId);
}
});
}
function handleFileSelect(file, previewId) {
if (!file.type.startsWith('image/')) {
showUserToast('Please select an image file', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
showUserToast('Image size must be less than 5MB', 'error');
return;
}
selectedFile = file;
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.getElementById(previewId);
if (preview) preview.innerHTML = `<img src="${e.target.result}" alt="Avatar preview">`;
};
reader.readAsDataURL(file);
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
}
function togglePasswordVisibility(inputId, buttonElement) {
const input = document.getElementById(inputId);
if (!input) return;
if (input.type === 'password') {
input.type = 'text';
buttonElement.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
`;
} else {
input.type = 'password';
buttonElement.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
}
}
async function loadUsers() {
try {
const res = await fetch(`${API_BASE}/users`);
if (!res.ok) throw new Error('Failed to fetch users');
const data = await res.json();
users = data.users || [];
renderUsers();
} catch (err) {
console.error('Error loading users:', err);
showEmptyState();
}
}
function renderUsers() {
if (!usersGrid) return;
if (users.length === 0) {
showEmptyState();
return;
}
usersGrid.innerHTML = '';
users.forEach(user => {
const userCard = createUserCard(user);
usersGrid.appendChild(userCard);
});
}
function createUserCard(user) {
const card = document.createElement('div');
card.className = 'user-card';
if (user.has_password) {
card.classList.add('has-password');
}
card.addEventListener('click', (e) => {
if (!e.target.closest('.user-config-btn')) {
loginUser(user.id, user.has_password);
}
});
const avatarContent = user.profile_picture_url
? `<img src="${user.profile_picture_url}" alt="${user.username}">`
: `<div class="user-avatar-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>`;
card.innerHTML = `
<div class="user-avatar">${avatarContent}</div>
<div class="user-info">
<div class="user-name">${user.username}</div>
</div>
<button class="user-config-btn" title="Manage User" data-user-id="${user.id}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1.51-1V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
`;
const configBtn = card.querySelector('.user-config-btn');
configBtn.addEventListener('click', (e) => {
e.stopPropagation();
openUserActionsModal(user.id);
});
return card;
}
function showEmptyState() {
if (!usersGrid) return;
usersGrid.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<h2 class="empty-title">No Users Yet</h2>
<p class="empty-text">Create your first profile to get started</p>
</div>
`;
}
function openCreateModal() {
modalCreateUser.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>Create New User</h2>
<button class="modal-close" onclick="closeModal()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form id="createUserFormDynamic">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required maxlength="20" placeholder="Enter your name">
</div>
<div class="form-group">
<label for="createPassword">
Password <span class="optional-label">(Optional)</span>
</label>
<div class="password-toggle-wrapper">
<input type="password" id="createPassword" name="password" placeholder="Leave empty for no password">
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('createPassword', this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
<div class="form-group">
<label>Profile Picture</label>
<div class="avatar-upload-area" id="avatarUploadArea">
<div class="avatar-preview" id="avatarPreview">
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<p class="avatar-upload-text">Click to upload or drag and drop</p>
<p class="avatar-upload-hint">PNG, JPG up to 5MB</p>
</div>
<input type="file" id="avatarInput" accept="image/*">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn-primary">Create User</button>
</div>
</form>
</div>
`;
modalCreateUser.classList.add('active');
initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview');
document.getElementById('createUserFormDynamic').addEventListener('submit', handleCreateUser);
document.getElementById('username').focus();
selectedFile = null;
}
function closeModal() {
modalCreateUser.classList.remove('active');
modalAniList.classList.remove('active');
modalUserActions.classList.remove('active');
modalEditUser.classList.remove('active');
selectedFile = null;
}
async function handleCreateUser(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('createPassword').value.trim();
if (!username) {
showUserToast('Please enter a username', 'error');
return;
}
const submitBtn = e.target.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
try {
let profilePictureUrl = null;
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
const body = { username };
if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl;
if (password) body.password = password;
const res = await fetch(`${API_BASE}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error creating user');
}
closeModal();
await loadUsers();
showUserToast(`User ${username} created successfully!`, 'success');
} catch (err) {
console.error(err);
showUserToast(err.message || 'Error creating user', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create User';
}
}
function openUserActionsModal(userId) {
currentUserId = userId;
const user = users.find(u => u.id === userId);
if (!user) return;
modalAniList.classList.remove('active');
modalEditUser.classList.remove('active');
actionsModalTitle.textContent = `Manage ${user.username}`;
const content = document.getElementById('actionsModalContent');
if (!content) return;
content.innerHTML = `
<div class="manage-actions-modal">
<button class="btn-action edit" onclick="openEditModal(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
Edit Profile
</button>
<button class="btn-action password" onclick="openPasswordModal(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
${user.has_password ? 'Change Password' : 'Add Password'}
</button>
<button class="btn-action anilist" onclick="openAniListModal(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
AniList Integration
</button>
<button class="btn-action delete" onclick="handleDeleteConfirmation(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path>
<line x1="18" y1="9" x2="12" y2="15"></line>
<line x1="12" y1="9" x2="18" y2="15"></line>
</svg>
Delete Profile
</button>
<button class="btn-action cancel" onclick="closeModal()">
Cancel
</button>
</div>
`;
modalUserActions.classList.add('active');
}
window.openEditModal = function(userId) {
currentUserId = userId;
modalUserActions.classList.remove('active');
const user = users.find(u => u.id === userId);
if (!user) return;
modalEditUser.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>Edit Profile</h2>
<button class="modal-close" onclick="closeModal()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form id="editUserFormDynamic">
<div class="form-group">
<label for="editUsername">Username</label>
<input type="text" id="editUsername" name="username" required maxlength="20" value="${user.username}">
</div>
<div class="form-group">
<label>Profile Picture</label>
<div class="avatar-upload-area" id="editAvatarUploadArea">
<div class="avatar-preview" id="editAvatarPreview">
${user.profile_picture_url
? `<img src="${user.profile_picture_url}" alt="Avatar">`
: `<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>`
}
</div>
<p class="avatar-upload-text">Click to upload or drag and drop</p>
<p class="avatar-upload-hint">PNG, JPG up to 5MB</p>
</div>
<input type="file" id="editAvatarInput" accept="image/*">
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn-primary">Save Changes</button>
</div>
</form>
</div>
`;
modalEditUser.classList.add('active');
initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview');
selectedFile = null;
document.getElementById('editUserFormDynamic').addEventListener('submit', handleEditUser);
};
async function handleEditUser(e) {
e.preventDefault();
const user = users.find(u => u.id === currentUserId);
if (!user) return;
const username = document.getElementById('editUsername').value.trim();
if (!username) {
showUserToast('Please enter a username', 'error');
return;
}
const submitBtn = e.target.querySelector('.btn-primary');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
try {
let profilePictureUrl;
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
const updates = { username };
if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl;
const res = await fetch(`${API_BASE}/users/${currentUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error updating user');
}
closeModal();
await loadUsers();
showUserToast('Profile updated successfully!', 'success');
} catch (err) {
console.error(err);
showUserToast(err.message || 'Error updating user', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Save Changes';
}
}
window.openPasswordModal = function(userId) {
currentUserId = userId;
modalUserActions.classList.remove('active');
const user = users.find(u => u.id === userId);
if (!user) return;
modalAniList.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>${user.has_password ? 'Change Password' : 'Add Password'}</h2>
<button class="modal-close" onclick="closeModal()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="password-modal-content">
${user.has_password ? `
<div class="password-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
<span>This profile is currently password protected</span>
</div>
` : ''}
<form id="passwordForm">
${user.has_password ? `
<div class="form-group">
<label for="currentPassword">Current Password</label>
<div class="password-toggle-wrapper">
<input type="password" id="currentPassword" required placeholder="Enter current password">
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('currentPassword', this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
` : ''}
<div class="form-group">
<label for="newPassword">New Password ${user.has_password ? '' : '<span class="optional-label">(Optional)</span>'}</label>
<div class="password-toggle-wrapper">
<input type="password" id="newPassword" ${user.has_password ? '' : ''} placeholder="Enter new password">
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('newPassword', this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
${user.has_password ? `
<button type="button" class="btn-disconnect" onclick="handleRemovePassword()">
Remove Password
</button>
` : ''}
<button type="submit" class="btn-primary">
${user.has_password ? 'Update' : 'Set'} Password
</button>
</div>
</form>
</div>
</div>
`;
modalAniList.classList.add('active');
document.getElementById('passwordForm').addEventListener('submit', handlePasswordSubmit);
};
async function handlePasswordSubmit(e) {
e.preventDefault();
const user = users.find(u => u.id === currentUserId);
if (!user) return;
const currentPassword = user.has_password ? document.getElementById('currentPassword').value : null;
const newPassword = document.getElementById('newPassword').value;
if (!newPassword && !user.has_password) {
showUserToast('Please enter a password', 'error');
return;
}
const submitBtn = e.target.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Updating...';
try {
const body = { newPassword: newPassword || null };
if (currentPassword) body.currentPassword = currentPassword;
const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error updating password');
}
closeModal();
await loadUsers();
showUserToast('Password updated successfully!', 'success');
} catch (err) {
console.error(err);
showUserToast(err.message || 'Error updating password', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = user.has_password ? 'Update Password' : 'Set Password';
}
}
window.handleRemovePassword = async function() {
if (!confirm('Are you sure you want to remove the password protection from this profile?')) return;
try {
const currentPassword = document.getElementById('currentPassword').value;
if (!currentPassword) {
showUserToast('Please enter your current password', 'error');
return;
}
const res = await fetch(`${API_BASE}/users/${currentUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword: null })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error removing password');
}
closeModal();
await loadUsers();
showUserToast('Password removed successfully!', 'success');
} catch (err) {
console.error(err);
showUserToast(err.message || 'Error removing password', 'error');
}
};
window.handleDeleteConfirmation = function(userId) {
const user = users.find(u => u.id === userId);
if (!user) return;
closeModal();
showConfirmationModal(
'Confirm Deletion',
`Are you absolutely sure you want to delete profile ${user.username}? This action cannot be undone.`,
`handleConfirmedDeleteUser(${userId})`
);
};
window.handleConfirmedDeleteUser = async function(userId) {
closeModal();
showUserToast('Deleting user...', 'info');
try {
const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error deleting user');
}
await loadUsers();
showUserToast('User deleted successfully!', 'success');
} catch (err) {
console.error(err);
showUserToast('Error deleting user', 'error');
}
};
function showConfirmationModal(title, message, confirmAction) {
closeModal();
modalAniList.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>${title}</h2>
<button class="modal-close" onclick="closeModal()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div style="text-align: center; padding: 1rem;">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" style="opacity: 0.8; margin: 0 auto 1rem; display: block;">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<p style="color: var(--color-text-secondary); margin-bottom: 2rem; font-size: 1rem;">
${message}
</p>
<div style="display: flex; gap: 1rem;">
<button class="btn-secondary" style="flex: 1;" onclick="closeModal()">
Cancel
</button>
<button class="btn-disconnect" style="flex: 1; background: #ef4444; color: white;" onclick="window.${confirmAction}">
Confirm Delete
</button>
</div>
</div>
</div>
`;
modalAniList.classList.add('active');
}
function openAniListModal(userId) {
currentUserId = userId;
modalUserActions.classList.remove('active');
modalEditUser.classList.remove('active');
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
modalAniList.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<h2>AniList Integration</h2>
<button class="modal-close" onclick="closeModal()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div id="aniListContent">
<div style="text-align: center; padding: 2rem;">Loading integration status...</div>
</div>
</div>
`;
modalAniList.classList.add('active');
getIntegrationStatus(userId).then(integration => {
const content = document.getElementById('aniListContent');
content.innerHTML = `
<div class="anilist-status">
${integration.connected ? `
<div class="anilist-connected">
<div class="anilist-icon">
<img src="https://anilist.co/img/icons/icon.svg" alt="AniList" style="width:40px; height:40px;">
</div>
<div class="anilist-info">
<h3>Connected to AniList</h3>
<p>User ID: ${integration.anilistUserId}</p>
<p style="font-size: 0.75rem;">Expires: ${new Date(integration.expiresAt).toLocaleDateString()}</p>
</div>
</div>
<button class="btn-disconnect" onclick="handleDisconnectAniList()">
Disconnect AniList
</button>
` : `
<div style="text-align: center; padding: 1rem;">
<h3 style="margin-bottom: 0.5rem;">Connect with AniList</h3>
<p style="color: var(--color-text-secondary); margin-bottom: 1.5rem;">
Sync your anime list by logging in with AniList.
</p>
<div style="display:flex; justify-content:center;">
<button class="btn-connect" onclick="redirectToAniListLogin()">
Login with AniList
</button>
</div>
<p style="font-size:0.85rem; margin-top:1rem; color:var(--color-text-secondary)">
You will be redirected and then returned here.
</p>
</div>
`}
</div>
`;
}).catch(err => {
console.error(err);
const content = document.getElementById('aniListContent');
content.innerHTML = `<div style="text-align:center;padding:1rem;color:var(--color-text-secondary)">Error loading integration status.</div>`;
});
}
async function redirectToAniListLogin() {
try {
const res = await fetch(`/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: currentUserId })
});
if (!res.ok) throw new Error('Login failed before AniList redirect');
const data = await res.json();
localStorage.setItem('token', data.token);
const clientId = 32898;
const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
const state = encodeURIComponent(currentUserId);
window.location.href =
`https://anilist.co/api/v2/oauth/authorize` +
`?client_id=${clientId}` +
`&response_type=code` +
`&redirect_uri=${redirectUri}` +
`&state=${state}`;
} catch (err) {
console.error(err);
showUserToast('Error starting AniList login', 'error');
}
}
async function getIntegrationStatus(userId) {
try {
const res = await fetch(`${API_BASE}/users/${userId}/integration`);
if (!res.ok) {
return { connected: false };
}
return await res.json();
} catch (err) {
console.error('getIntegrationStatus error', err);
return { connected: false };
}
}
window.handleDisconnectAniList = async function() {
if (!confirm('Are you sure you want to disconnect AniList?')) return;
try {
const res = await fetch(`${API_BASE}/users/${currentUserId}/integration`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error disconnecting AniList');
}
showUserToast('AniList disconnected successfully', 'success');
openAniListModal(currentUserId);
} catch (err) {
console.error(err);
showUserToast('Error disconnecting AniList', 'error');
}
};
async function loginUser(userId, hasPassword) {
if (hasPassword) {
// Mostrar modal de contraseña
modalAniList.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>Enter Password</h2>
<button class="modal-close" onclick="closeModal()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form id="loginPasswordForm">
<div class="form-group">
<label for="loginPassword">Password</label>
<div class="password-toggle-wrapper">
<input type="password" id="loginPassword" required placeholder="Enter your password" autofocus>
<button type="button" class="password-toggle-btn" onclick="togglePasswordVisibility('loginPassword', this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn-primary">Login</button>
</div>
</form>
</div>
`;
modalAniList.classList.add('active');
document.getElementById('loginPasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('loginPassword').value;
await performLogin(userId, password);
});
} else {
await performLogin(userId);
}
}
async function performLogin(userId, password = null) {
try {
const body = { userId };
if (password) body.password = password;
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Login failed');
}
const data = await res.json();
localStorage.setItem('token', data.token);
window.location.href = '/anime';
} catch (err) {
console.error('Login error', err);
showUserToast(err.message || 'Login failed', 'error');
}
}
window.openAniListModal = openAniListModal;
window.redirectToAniListLogin = redirectToAniListLogin;

View File

@@ -0,0 +1,26 @@
const AuthUtils = {
getToken() {
return localStorage.getItem('token');
},
getAuthHeaders() {
const token = this.getToken();
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
},
getSimpleAuthHeaders() {
const token = this.getToken();
return {
'Authorization': `Bearer ${token}`
};
},
isAuthenticated() {
return !!this.getToken();
}
};
window.AuthUtils = AuthUtils;

View File

@@ -0,0 +1,86 @@
const ContinueWatchingManager = {
API_BASE: '/api',
async load(containerId, status = 'watching', entryType = 'ANIME') {
if (!AuthUtils.isAuthenticated()) return;
const container = document.getElementById(containerId);
if (!container) return;
try {
const res = await fetch(`${this.API_BASE}/list/filter?status=${status}&entry_type=${entryType}`, {
headers: AuthUtils.getAuthHeaders()
});
if (!res.ok) return;
const data = await res.json();
const list = data.results || [];
this.render(containerId, list, entryType);
} catch (err) {
console.error(`Continue ${entryType === 'ANIME' ? 'Watching' : 'Reading'} Error:`, err);
}
},
render(containerId, list, entryType = 'ANIME') {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
if (list.length === 0) {
const label = entryType === 'ANIME' ? 'watching anime' : 'reading manga';
container.innerHTML = `<div style="padding:1rem; color:#888">No ${label}</div>`;
return;
}
list.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
list.forEach(item => {
const card = this.createCard(item, entryType);
container.appendChild(card);
});
},
createCard(item, entryType) {
const el = document.createElement('div');
el.className = 'card';
const nextProgress = (item.progress || 0) + 1;
let url;
if (entryType === 'ANIME') {
url = item.source === 'anilist'
? `/watch/${item.entry_id}/${nextProgress}`
: `/watch/${item.entry_id}/${nextProgress}?${item.source}`;
} else {
url = item.source === 'anilist'
? `/book/${item.entry_id}?chapter=${nextProgress}`
: `/read/${item.source}/${nextProgress}/${item.entry_id}?source=${item.source}`;
}
el.onclick = () => window.location.href = url;
const progressText = item.total_episodes || item.total_chapters
? `${item.progress || 0}/${item.total_episodes || item.total_chapters}`
: `${item.progress || 0}`;
const unitLabel = entryType === 'ANIME' ? 'Ep' : 'Ch';
el.innerHTML = `
<div class="card-img-wrap">
<img src="${item.poster}" loading="lazy" alt="${item.title}">
</div>
<div class="card-content">
<h3>${item.title}</h3>
<p>${unitLabel} ${progressText} - ${item.source}</p>
</div>
`;
return el;
}
};
window.ContinueWatchingManager = ContinueWatchingManager;

View File

@@ -0,0 +1,226 @@
const ListModalManager = {
API_BASE: '/api',
currentData: null,
isInList: false,
currentEntry: null,
STATUS_MAP: {
CURRENT: 'CURRENT',
COMPLETED: 'COMPLETED',
PLANNING: 'PLANNING',
PAUSED: 'PAUSED',
DROPPED: 'DROPPED',
REPEATING: 'REPEATING'
},
getEntryType(data) {
if (!data) return 'ANIME';
if (data.entry_type) return data.entry_type.toUpperCase();
return 'ANIME';
},
async checkIfInList(entryId, source = 'anilist', entryType) {
if (!AuthUtils.isAuthenticated()) return false;
const url = `${this.API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`;
try {
const response = await fetch(url, {
headers: AuthUtils.getSimpleAuthHeaders()
});
if (response.ok) {
const data = await response.json();
this.isInList = data.found && !!data.entry;
this.currentEntry = data.entry || null;
} else {
this.isInList = false;
this.currentEntry = null;
}
return this.isInList;
} catch (error) {
console.error('Error checking list entry:', error);
return false;
}
},
updateButton(buttonSelector = '.hero-buttons .btn-blur') {
const btn = document.querySelector(buttonSelector);
if (!btn) return;
if (this.isInList) {
btn.innerHTML = `
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
In Your ${this.currentData?.format ? 'Library' : 'List'}
`;
btn.style.background = 'rgba(34, 197, 94, 0.2)';
btn.style.color = '#22c55e';
btn.style.borderColor = 'rgba(34, 197, 94, 0.3)';
} else {
btn.innerHTML = `+ Add to ${this.currentData?.format ? 'Library' : 'List'}`;
btn.style.background = null;
btn.style.color = null;
btn.style.borderColor = null;
}
},
open(data, source = 'anilist') {
if (!AuthUtils.isAuthenticated()) {
NotificationUtils.error('Please log in to manage your list.');
return;
}
this.currentData = data;
const entryType = this.getEntryType(data);
const totalUnits = data.episodes || data.chapters || data.volumes || 999;
const modalTitle = document.getElementById('modal-title');
const deleteBtn = document.getElementById('modal-delete-btn');
const progressLabel = document.querySelector('label[for="entry-progress"]') ||
document.getElementById('progress-label');
if (this.isInList && this.currentEntry) {
document.getElementById('entry-status').value = this.currentEntry.status || 'PLANNING';
document.getElementById('entry-progress').value = this.currentEntry.progress || 0;
document.getElementById('entry-score').value = this.currentEntry.score || '';
document.getElementById('entry-start-date').value = this.currentEntry.start_date?.split('T')[0] || '';
document.getElementById('entry-end-date').value = this.currentEntry.end_date?.split('T')[0] || '';
document.getElementById('entry-repeat-count').value = this.currentEntry.repeat_count || 0;
document.getElementById('entry-notes').value = this.currentEntry.notes || '';
document.getElementById('entry-is-private').checked = this.currentEntry.is_private === true || this.currentEntry.is_private === 1;
modalTitle.textContent = `Edit ${entryType === 'ANIME' ? 'List' : 'Library'} Entry`;
deleteBtn.style.display = 'block';
} else {
document.getElementById('entry-status').value = 'PLANNING';
document.getElementById('entry-progress').value = 0;
document.getElementById('entry-score').value = '';
document.getElementById('entry-start-date').value = '';
document.getElementById('entry-end-date').value = '';
document.getElementById('entry-repeat-count').value = 0;
document.getElementById('entry-notes').value = '';
document.getElementById('entry-is-private').checked = false;
modalTitle.textContent = `Add to ${entryType === 'ANIME' ? 'List' : 'Library'}`;
deleteBtn.style.display = 'none';
}
const statusSelect = document.getElementById('entry-status');
[...statusSelect.options].forEach(opt => {
if (opt.value === 'CURRENT') {
opt.textContent = entryType === 'ANIME' ? 'Watching' : 'Reading';
}
});
if (progressLabel) {
if (entryType === 'ANIME') {
progressLabel.textContent = 'Episodes Watched';
} else if (entryType === 'MANGA') {
progressLabel.textContent = 'Chapters Read';
} else {
progressLabel.textContent = 'Volumes/Parts Read';
}
}
document.getElementById('entry-progress').max = totalUnits;
document.getElementById('add-list-modal').classList.add('active');
},
close() {
document.getElementById('add-list-modal').classList.remove('active');
},
async save(entryId, source = 'anilist') {
const uiStatus = document.getElementById('entry-status').value;
const status = this.STATUS_MAP[uiStatus] || uiStatus;
const progress = parseInt(document.getElementById('entry-progress').value) || 0;
const scoreValue = document.getElementById('entry-score').value;
const score = scoreValue ? parseFloat(scoreValue) : null;
const start_date = document.getElementById('entry-start-date').value || null;
const end_date = document.getElementById('entry-end-date').value || null;
const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0;
const notes = document.getElementById('entry-notes').value || null;
const is_private = document.getElementById('entry-is-private').checked;
const entryType = this.getEntryType(this.currentData);
try {
const response = await fetch(`${this.API_BASE}/list/entry`, {
method: 'POST',
headers: AuthUtils.getAuthHeaders(),
body: JSON.stringify({
entry_id: entryId,
source,
entry_type: entryType,
status,
progress,
score,
start_date,
end_date,
repeat_count,
notes,
is_private
})
});
if (!response.ok) throw new Error('Failed to save entry');
const data = await response.json();
this.isInList = true;
this.currentEntry = data.entry;
this.updateButton();
this.close();
NotificationUtils.success(this.isInList ? 'Updated successfully!' : 'Added to your list!');
} catch (error) {
console.error('Error saving to list:', error);
NotificationUtils.error('Failed to save. Please try again.');
}
},
async delete(entryId, source = 'anilist') {
if (!confirm(`Remove this ${this.getEntryType(this.currentData).toLowerCase()} from your list?`)) {
return;
}
const entryType = this.getEntryType(this.currentData);
try {
const response = await fetch(
`${this.API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`,
{
method: 'DELETE',
headers: AuthUtils.getSimpleAuthHeaders()
}
);
if (!response.ok) throw new Error('Failed to delete entry');
this.isInList = false;
this.currentEntry = null;
this.updateButton();
this.close();
NotificationUtils.success('Removed from your list');
} catch (error) {
console.error('Error deleting from list:', error);
NotificationUtils.error('Failed to remove. Please try again.');
}
}
};
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('add-list-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target.id === 'add-list-modal') {
ListModalManager.close();
}
});
}
});
window.ListModalManager = ListModalManager;

View File

@@ -0,0 +1,192 @@
const MediaMetadataUtils = {
getTitle(data) {
if (!data) return "Unknown Title";
if (data.title) {
if (typeof data.title === 'string') return data.title;
return data.title.english || data.title.romaji || data.title.native || "Unknown Title";
}
return data.name || "Unknown Title";
},
getDescription(data) {
const rawDesc = data.description || data.summary || "No description available.";
const tmp = document.createElement("DIV");
tmp.innerHTML = rawDesc;
return tmp.textContent || tmp.innerText || rawDesc;
},
getPosterUrl(data, isExtension = false) {
if (isExtension) {
return data.image || '';
}
if (data.coverImage) {
return data.coverImage.extraLarge || data.coverImage.large || data.coverImage.medium || '';
}
return data.image || '';
},
getBannerUrl(data, isExtension = false) {
if (isExtension) {
return data.image || '';
}
return data.bannerImage || this.getPosterUrl(data, isExtension);
},
getScore(data, isExtension = false) {
if (isExtension) {
return data.score ? Math.round(data.score * 10) : '?';
}
return data.averageScore || '?';
},
getYear(data, isExtension = false) {
if (isExtension) {
return data.year || data.published || '????';
}
if (data.seasonYear) return data.seasonYear;
if (data.startDate?.year) return data.startDate.year;
return '????';
},
getGenres(data, maxGenres = 3) {
if (!data.genres || !Array.isArray(data.genres)) return '';
return data.genres.slice(0, maxGenres).join(' • ');
},
getSeason(data, isExtension = false) {
if (isExtension) {
return data.season || 'Unknown';
}
if (data.season && data.seasonYear) {
return `${data.season} ${data.seasonYear}`;
}
if (data.startDate?.year && data.startDate?.month) {
const months = ['', 'Winter', 'Winter', 'Spring', 'Spring', 'Spring',
'Summer', 'Summer', 'Summer', 'Fall', 'Fall', 'Fall', 'Winter'];
const season = months[data.startDate.month] || '';
return season ? `${season} ${data.startDate.year}` : `${data.startDate.year}`;
}
return 'Unknown';
},
getStudio(data, isExtension = false) {
if (isExtension) {
return data.studio || "Unknown";
}
if (data.studios?.nodes?.[0]?.name) {
return data.studios.nodes[0].name;
}
if (data.studios?.edges?.[0]?.node?.name) {
return data.studios.edges[0].node.name;
}
return 'Unknown Studio';
},
getCharacters(data, isExtension = false, maxChars = 5) {
let characters = [];
if (isExtension) {
characters = data.characters || [];
} else {
if (data.characters?.nodes?.length > 0) {
characters = data.characters.nodes;
} else if (data.characters?.edges?.length > 0) {
characters = data.characters.edges
.filter(edge => edge?.node?.name?.full)
.map(edge => edge.node);
}
}
return characters.slice(0, maxChars).map(char => ({
name: char?.name?.full || char?.name || "Unknown",
image: char?.image?.large || char?.image?.medium || null
}));
},
getTotalEpisodes(data, isExtension = false) {
if (isExtension) {
return data.episodes || 1;
}
if (data.nextAiringEpisode?.episode) {
return data.nextAiringEpisode.episode - 1;
}
return data.episodes || 12;
},
truncateDescription(text, maxSentences = 4) {
const tmp = document.createElement("DIV");
tmp.innerHTML = text;
const cleanText = tmp.textContent || tmp.innerText || "";
const sentences = cleanText.match(/[^\.!\?]+[\.!\?]+/g) || [cleanText];
if (sentences.length > maxSentences) {
return {
short: sentences.slice(0, maxSentences).join(' ') + '...',
full: text,
isTruncated: true
};
}
return {
short: text,
full: text,
isTruncated: false
};
},
formatBookData(data, isExtension = false) {
return {
title: this.getTitle(data),
description: this.getDescription(data),
score: this.getScore(data, isExtension),
year: this.getYear(data, isExtension),
status: data.status || 'Unknown',
format: data.format || (isExtension ? 'LN' : 'MANGA'),
chapters: data.chapters || '?',
volumes: data.volumes || '?',
poster: this.getPosterUrl(data, isExtension),
banner: this.getBannerUrl(data, isExtension),
genres: this.getGenres(data)
};
},
formatAnimeData(data, isExtension = false) {
return {
title: this.getTitle(data),
description: this.getDescription(data),
score: this.getScore(data, isExtension),
year: this.getYear(data, isExtension),
season: this.getSeason(data, isExtension),
status: data.status || 'Unknown',
format: data.format || 'TV',
episodes: this.getTotalEpisodes(data, isExtension),
poster: this.getPosterUrl(data, isExtension),
banner: this.getBannerUrl(data, isExtension),
genres: this.getGenres(data),
studio: this.getStudio(data, isExtension),
characters: this.getCharacters(data, isExtension),
trailer: data.trailer || null
};
}
};
window.MediaMetadataUtils = MediaMetadataUtils;

View File

@@ -0,0 +1,52 @@
const NotificationUtils = {
show(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 100px;
right: 20px;
background: ${type === 'success' ? '#22c55e' : type === 'error' ? '#ef4444' : '#8b5cf6'};
color: white;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
z-index: 9999;
font-weight: 600;
animation: slideInRight 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOutRight 0.3s ease';
setTimeout(() => notification.remove(), 300);
}, 3000);
},
success(message) {
this.show(message, 'success');
},
error(message) {
this.show(message, 'error');
},
info(message) {
this.show(message, 'info');
}
};
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(400px); opacity: 0; }
}
`;
document.head.appendChild(style);
window.NotificationUtils = NotificationUtils;

View File

@@ -0,0 +1,91 @@
const PaginationManager = {
currentPage: 1,
itemsPerPage: 12,
totalItems: 0,
onPageChange: null,
init(itemsPerPage = 12, onPageChange = null) {
this.itemsPerPage = itemsPerPage;
this.onPageChange = onPageChange;
this.currentPage = 1;
},
setTotalItems(total) {
this.totalItems = total;
},
getTotalPages() {
return Math.ceil(this.totalItems / this.itemsPerPage);
},
getCurrentPageItems(items) {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return items.slice(start, end);
},
getPageRange() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, this.totalItems);
return { start, end };
},
nextPage() {
if (this.currentPage < this.getTotalPages()) {
this.currentPage++;
if (this.onPageChange) this.onPageChange();
return true;
}
return false;
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
if (this.onPageChange) this.onPageChange();
return true;
}
return false;
},
goToPage(page) {
const totalPages = this.getTotalPages();
if (page >= 1 && page <= totalPages) {
this.currentPage = page;
if (this.onPageChange) this.onPageChange();
return true;
}
return false;
},
reset() {
this.currentPage = 1;
},
renderControls(containerId, pageInfoId, prevBtnId, nextBtnId) {
const container = document.getElementById(containerId);
const pageInfo = document.getElementById(pageInfoId);
const prevBtn = document.getElementById(prevBtnId);
const nextBtn = document.getElementById(nextBtnId);
if (!container || !pageInfo || !prevBtn || !nextBtn) return;
const totalPages = this.getTotalPages();
if (totalPages <= 1) {
container.style.display = 'none';
return;
}
container.style.display = 'flex';
pageInfo.innerText = `Page ${this.currentPage} of ${totalPages}`;
prevBtn.disabled = this.currentPage === 1;
nextBtn.disabled = this.currentPage >= totalPages;
prevBtn.onclick = () => this.prevPage();
nextBtn.onclick = () => this.nextPage();
}
};
window.PaginationManager = PaginationManager;

View File

@@ -0,0 +1,176 @@
const SearchManager = {
availableExtensions: [],
searchTimeout: null,
init(inputSelector, resultsSelector, type = 'anime') {
const searchInput = document.querySelector(inputSelector);
const searchResults = document.querySelector(resultsSelector);
if (!searchInput || !searchResults) {
console.error('Search elements not found');
return;
}
this.loadExtensions(type);
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
clearTimeout(this.searchTimeout);
if (query.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
searchInput.style.borderRadius = '99px';
return;
}
this.searchTimeout = setTimeout(() => {
this.search(query, type, searchResults);
}, 300);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrapper')) {
searchResults.classList.remove('active');
searchInput.style.borderRadius = '99px';
}
});
},
async loadExtensions(type) {
try {
const endpoint = type === 'book' ? '/api/extensions/book' : '/api/extensions/anime';
const res = await fetch(endpoint);
const data = await res.json();
this.availableExtensions = data.extensions || [];
console.log(`${type} extensions loaded:`, this.availableExtensions);
} catch (err) {
console.error('Error loading extensions:', err);
}
},
async search(query, type, resultsContainer) {
try {
let apiUrl, extensionName = null, finalQuery = query;
const parts = query.split(':');
if (parts.length >= 2) {
const potentialExtension = parts[0].trim().toLowerCase();
const foundExtension = this.availableExtensions.find(
ext => ext.toLowerCase() === potentialExtension
);
if (foundExtension) {
extensionName = foundExtension;
finalQuery = parts.slice(1).join(':').trim();
if (finalQuery.length === 0) {
this.renderResults([], resultsContainer, type);
return;
}
}
}
if (extensionName) {
const endpoint = type === 'book' ? 'books' : '';
apiUrl = `/api/search/${endpoint ? endpoint + '/' : ''}${extensionName}?q=${encodeURIComponent(finalQuery)}`;
} else {
const endpoint = type === 'book' ? '/api/search/books' : '/api/search';
apiUrl = `${endpoint}?q=${encodeURIComponent(query)}`;
}
const res = await fetch(apiUrl);
const data = await res.json();
const results = (data.results || []).map(item => ({
...item,
isExtensionResult: !!extensionName,
extensionName
}));
this.renderResults(results, resultsContainer, type);
} catch (err) {
console.error("Search Error:", err);
this.renderResults([], resultsContainer, type);
}
},
renderResults(results, container, type) {
container.innerHTML = '';
if (!results || results.length === 0) {
container.innerHTML = '<div style="padding:1rem; color:#888; text-align:center">No results found</div>';
} else {
results.forEach(item => {
const resultElement = this.createResultElement(item, type);
container.appendChild(resultElement);
});
}
container.classList.add('active');
const searchInput = container.previousElementSibling || document.querySelector('.search-input');
if (searchInput) {
searchInput.style.borderRadius = '12px 12px 0 0';
}
},
createResultElement(item, type) {
const element = document.createElement('a');
element.className = 'search-item';
if (type === 'book') {
const title = item.title?.english || item.title?.romaji || "Unknown";
const img = item.coverImage?.medium || item.coverImage?.large || '';
const rating = Number.isInteger(item.averageScore) ? `${item.averageScore}%` : item.averageScore || 'N/A';
const year = item.seasonYear || item.startDate?.year || '????';
const format = item.format || 'MANGA';
element.href = item.isExtensionResult
? `/book/${item.extensionName}/${item.id}`
: `/book/${item.id}`;
element.innerHTML = `
<img src="${img}" class="search-poster" alt="${title}">
<div class="search-info">
<div class="search-title">${title}</div>
<div class="search-meta">
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
</div>
</div>
`;
} else {
const title = item.title?.english || item.title?.romaji || "Unknown Title";
const img = item.coverImage?.medium || item.coverImage?.large || '';
const rating = item.averageScore ? `${item.averageScore}%` : 'N/A';
const year = item.seasonYear || '';
const format = item.format || 'TV';
element.href = item.isExtensionResult
? `/anime/${item.extensionName}/${item.id}`
: `/anime/${item.id}`;
element.innerHTML = `
<img src="${img}" class="search-poster" alt="${title}">
<div class="search-info">
<div class="search-title">${title}</div>
<div class="search-meta">
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
</div>
</div>
`;
}
return element;
},
getTitle(item) {
return item.title?.english || item.title?.romaji || "Unknown Title";
}
};
window.SearchManager = SearchManager;

View File

@@ -0,0 +1,51 @@
const URLUtils = {
parseEntityPath(basePath = 'anime') {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
if (parts[0] !== basePath) {
return null;
}
if (parts.length === 3) {
return {
extensionName: parts[1],
entityId: parts[2],
slug: parts[2]
};
} else if (parts.length === 2) {
return {
extensionName: null,
entityId: parts[1],
slug: null
};
}
return null;
},
buildWatchUrl(animeId, episode, extensionName = null) {
const base = `/watch/${animeId}/${episode}`;
return extensionName ? `${base}?${extensionName}` : base;
},
buildReadUrl(bookId, chapterId, provider, extensionName = null) {
const c = encodeURIComponent(chapterId);
const p = encodeURIComponent(provider);
const extension = extensionName ? `?source=${extensionName}` : "?source=anilist";
return `/read/${p}/${c}/${bookId}${extension}`;
},
getQueryParams() {
return new URLSearchParams(window.location.search);
},
getQueryParam(key) {
return this.getQueryParams().get(key);
}
};
window.URLUtils = URLUtils;

View File

@@ -0,0 +1,111 @@
const YouTubePlayerUtils = {
player: null,
isAPIReady: false,
pendingVideoId: null,
init(containerId = 'player') {
if (this.isAPIReady) return;
const tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
window.onYouTubeIframeAPIReady = () => {
this.isAPIReady = true;
this.createPlayer(containerId);
};
},
createPlayer(containerId, videoId = null) {
if (!this.isAPIReady) {
this.pendingVideoId = videoId;
return;
}
const config = {
height: '100%',
width: '100%',
playerVars: {
autoplay: 1,
controls: 0,
mute: 1,
loop: 1,
showinfo: 0,
modestbranding: 1,
disablekb: 1
},
events: {
onReady: (event) => {
event.target.mute();
if (this.pendingVideoId) {
this.loadVideo(this.pendingVideoId);
this.pendingVideoId = null;
} else {
event.target.playVideo();
}
}
}
};
if (videoId) {
config.videoId = videoId;
config.playerVars.playlist = videoId;
}
this.player = new YT.Player(containerId, config);
},
loadVideo(videoId) {
if (!this.player || !this.player.loadVideoById) {
this.pendingVideoId = videoId;
return;
}
this.player.loadVideoById(videoId);
this.player.mute();
},
playTrailer(trailerData, containerId = 'player', fallbackImage = null) {
if (!trailerData || trailerData.site !== 'youtube' || !trailerData.id) {
if (fallbackImage) {
this.showFallbackImage(containerId, fallbackImage);
}
return false;
}
if (!this.isAPIReady) {
this.init(containerId);
this.pendingVideoId = trailerData.id;
} else if (this.player) {
this.loadVideo(trailerData.id);
} else {
this.createPlayer(containerId, trailerData.id);
}
return true;
},
showFallbackImage(containerId, imageUrl) {
const container = document.querySelector(`#${containerId}`)?.parentElement;
if (!container) return;
container.innerHTML = `<img src="${imageUrl}" style="width:100%; height:100%; object-fit:cover;">`;
},
stop() {
if (this.player && this.player.stopVideo) {
this.player.stopVideo();
}
},
destroy() {
if (this.player && this.player.destroy) {
this.player.destroy();
this.player = null;
}
}
};
window.YouTubePlayerUtils = YouTubePlayerUtils;

View File

@@ -0,0 +1,125 @@
const sqlite3 = require('sqlite3').verbose();
const os = require("os");
const path = require("path");
const fs = require("fs");
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB} = require('./schemas');
const databases = new Map();
const DEFAULT_PATHS = {
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db")
};
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
if (databases.has(name)) {
return databases.get(name);
}
const finalPath = dbPath || DEFAULT_PATHS[name] || DEFAULT_PATHS.anilist;
if (name === "favorites") {
ensureFavoritesDB(finalPath)
.catch(err => console.error("Error creando favorites:", err));
}
if (name === "cache") {
const dir = path.dirname(finalPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
if (name === "userdata") {
ensureUserDataDB(finalPath)
.catch(err => console.error("Error creando userdata:", err));
}
const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
const db = new sqlite3.Database(finalPath, mode, (err) => {
if (err) {
console.error(`Database Error (${name}):`, err.message);
} else {
console.log(`Connected to ${name} database at ${finalPath}`);
}
});
databases.set(name, db);
if (name === "anilist") {
ensureAnilistSchema(db)
.then(() => ensureExtensionsTable(db))
.catch(err => console.error("Error creating anilist schema:", err));
}
if (name === "cache") {
ensureCacheTable(db)
.catch(err => console.error("Error creating cache table:", err));
}
return db;
}
function getDatabase(name = 'anilist') {
if (!databases.has(name)) {
const readOnly = (name === 'anilist');
return initDatabase(name, null, readOnly);
}
return databases.get(name);
}
function queryOne(sql, params = [], dbName = 'anilist') {
return new Promise((resolve, reject) => {
getDatabase(dbName).get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function queryAll(sql, params = [], dbName = 'anilist') {
return new Promise((resolve, reject) => {
getDatabase(dbName).all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function run(sql, params = [], dbName = 'anilist') {
return new Promise((resolve, reject) => {
getDatabase(dbName).run(sql, params, function(err) {
if (err) reject(err);
else resolve({ changes: this.changes, lastID: this.lastID });
});
});
}
function closeDatabase(name = null) {
if (name) {
const db = databases.get(name);
if (db) {
db.close();
databases.delete(name);
console.log(`Closed ${name} database`);
}
} else {
for (const [dbName, db] of databases) {
db.close();
console.log(`Closed ${dbName} database`);
}
databases.clear();
}
}
module.exports = {
initDatabase,
getDatabase,
queryOne,
queryAll,
run,
closeDatabase
};

View File

@@ -0,0 +1,209 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const cheerio = require("cheerio");
const { queryAll, run } = require('./database');
const { scrape } = require("./headless");
const extensions = new Map();
async function loadExtensions() {
const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
if (!fs.existsSync(extensionsDir)) {
console.log("📁 Extensions directory not found, creating...");
fs.mkdirSync(extensionsDir, { recursive: true });
}
try {
const files = await fs.promises.readdir(extensionsDir);
for (const file of files) {
if (file.endsWith('.js')) {
await loadExtension(file);
}
}
console.log(`✅ Loaded ${extensions.size} extensions`);
try {
const loaded = Array.from(extensions.keys());
const rows = await queryAll("SELECT DISTINCT ext_name FROM extension");
for (const row of rows) {
if (!loaded.includes(row.ext_name)) {
console.log(`🧹 Cleaning cached metadata for removed extension: ${row.ext_name}`);
await run("DELETE FROM extension WHERE ext_name = ?", [row.ext_name]);
}
}
} catch (err) {
console.error("❌ Error cleaning extension cache:", err);
}
} catch (err) {
console.error("❌ Extension Scan Error:", err);
}
}
async function loadExtension(fileName) {
const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
const filePath = path.join(extensionsDir, fileName);
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ Extension not found: ${fileName}`);
return;
}
try {
delete require.cache[require.resolve(filePath)];
const ExtensionClass = require(filePath);
const instance = typeof ExtensionClass === 'function'
? new ExtensionClass()
: (ExtensionClass.default ? new ExtensionClass.default() : null);
if (!instance) {
console.warn(`⚠️ Invalid extension: ${fileName}`);
return;
}
if (!["anime-board", "book-board", "image-board"].includes(instance.type)) {
console.warn(`⚠️ Invalid extension (${instance.type}): ${fileName}`);
return;
}
const name = instance.constructor.name;
instance.scrape = scrape;
instance.cheerio = cheerio;
extensions.set(name, instance);
console.log(`📦 Installed & loaded: ${name}`);
return name;
} catch (err) {
console.warn(`⚠️ Error loading ${fileName}: ${err.message}`);
}
}
const https = require('https');
async function saveExtensionFile(fileName, downloadUrl) {
const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
const filePath = path.join(extensionsDir, fileName);
const fullUrl = downloadUrl;
if (!fs.existsSync(extensionsDir)) {
fs.mkdirSync(extensionsDir, { recursive: true });
}
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(filePath);
https.get(fullUrl, async (response) => {
if (response.statusCode !== 200) {
return reject(new Error(`Download failed: ${response.statusCode}`));
}
response.pipe(file);
file.on('finish', async () => {
file.close(async () => {
try {
await loadExtension(fileName);
resolve();
} catch (err) {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
}
reject(new Error(`Load failed, file rolled back: ${err.message}`));
}
});
});
}).on('error', async (err) => {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
}
reject(err);
});
});
}
async function deleteExtensionFile(fileName) {
const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
const filePath = path.join(extensionsDir, fileName);
const extName = fileName.replace(".js", "");
for (const key of extensions.keys()) {
if (key.toLowerCase() === extName) {
extensions.delete(key);
console.log(`🗑️ Removed from memory: ${key}`);
break;
}
}
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
console.log(`🗑️ Deleted file: ${fileName}`);
}
}
function getExtension(name) {
return extensions.get(name);
}
function getAllExtensions() {
return extensions;
}
function getExtensionsList() {
return Array.from(extensions.keys());
}
function getAnimeExtensionsMap() {
const animeExts = new Map();
for (const [name, ext] of extensions) {
if (ext.type === 'anime-board') {
animeExts.set(name, ext);
}
}
return animeExts;
}
function getBookExtensionsMap() {
const bookExts = new Map();
for (const [name, ext] of extensions) {
if (ext.type === 'book-board' || ext.type === 'manga-board') {
bookExts.set(name, ext);
}
}
return bookExts;
}
function getGalleryExtensionsMap() {
const galleryExts = new Map();
for (const [name, ext] of extensions) {
if (ext.type === 'image-board') {
galleryExts.set(name, ext);
}
}
return galleryExts;
}
module.exports = {
loadExtensions,
getExtension,
getAllExtensions,
getExtensionsList,
getAnimeExtensionsMap,
getBookExtensionsMap,
getGalleryExtensionsMap,
saveExtensionFile,
deleteExtensionFile
};

View File

@@ -0,0 +1,117 @@
const { chromium } = require("playwright-chromium");
let browser;
let context;
const BLOCK_LIST = [
"google-analytics", "doubleclick", "facebook", "twitter",
"adsystem", "analytics", "tracker", "pixel", "quantserve", "newrelic"
];
async function initHeadless() {
if (browser) return;
browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--disable-background-networking",
"--disable-sync",
"--disable-translate",
"--mute-audio",
"--no-first-run",
"--no-zygote",
]
});
context = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
});
}
async function turboScroll(page) {
await page.evaluate(() => {
return new Promise((resolve) => {
let last = 0;
let same = 0;
const timer = setInterval(() => {
const h = document.body.scrollHeight;
window.scrollTo(0, h);
if (h === last) {
same++;
if (same >= 5) {
clearInterval(timer);
resolve();
}
} else {
same = 0;
last = h;
}
}, 20);
});
});
}
async function scrape(url, handler, options = {}) {
const {
waitUntil = "domcontentloaded",
waitSelector = null,
timeout = 10000,
scrollToBottom = false,
renderWaitTime = 0,
loadImages = true
} = options;
if (!browser) await initHeadless();
const page = await context.newPage();
let collectedRequests = [];
await page.route("**/*", (route) => {
const req = route.request();
const rUrl = req.url().toLowerCase();
const type = req.resourceType();
collectedRequests.push({
url: req.url(),
method: req.method(),
resourceType: type
});
if (type === "font" || type === "media" || type === "manifest")
return route.abort();
if (BLOCK_LIST.some(k => rUrl.includes(k)))
return route.abort();
if (!loadImages && (
type === "image" || rUrl.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
)) return route.abort();
route.continue();
});
await page.goto(url, { waitUntil, timeout });
if (waitSelector) {
try {
await page.waitForSelector(waitSelector, { timeout });
} catch {}
}
if (scrollToBottom) {
await turboScroll(page);
}
if (renderWaitTime > 0) {
await new Promise(r => setTimeout(r, renderWaitTime));
}
const result = await handler(page);
await page.close();
return { result, requests: collectedRequests };
}
async function closeScraper() {
if (context) await context.close();
if (browser) await browser.close();
context = null;
browser = null;
}
module.exports = {
initHeadless,
scrape,
closeScraper
};

View File

@@ -0,0 +1,62 @@
const { queryOne, run } = require('./database');
async function getCachedExtension(extName, id) {
return queryOne(
"SELECT metadata FROM extension WHERE ext_name = ? AND id = ?",
[extName, id]
);
}
async function cacheExtension(extName, id, title, metadata) {
return run(
`
INSERT INTO extension (ext_name, id, title, metadata, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(ext_name, id)
DO UPDATE SET
title = excluded.title,
metadata = excluded.metadata,
updated_at = ?
`,
[extName, id, title, JSON.stringify(metadata), Date.now(), Date.now()]
);
}
async function getExtensionTitle(extName, id) {
const sql = "SELECT title FROM extension WHERE ext_name = ? AND id = ?";
const row = await queryOne(sql, [extName, id], 'anilist');
return row ? row.title : null;
}
async function deleteExtension(extName) {
return run(
"DELETE FROM extension WHERE ext_name = ?",
[extName]
);
}
async function getCache(key) {
return queryOne("SELECT result, created_at, ttl_ms FROM cache WHERE key = ?", [key], "cache");
}
async function setCache(key, result, ttl_ms) {
return run(
`
INSERT INTO cache (key, result, created_at, ttl_ms)
VALUES (?, ?, ?, ?)
ON CONFLICT(key)
DO UPDATE SET result = excluded.result, created_at = excluded.created_at, ttl_ms = excluded.ttl_ms
`,
[key, JSON.stringify(result), Date.now(), ttl_ms],
"cache"
);
}
module.exports = {
getCachedExtension,
cacheExtension,
getExtensionTitle,
deleteExtension,
getCache,
setCache
};

View File

@@ -0,0 +1,234 @@
const sqlite3 = require('sqlite3').verbose();
const path = require("path");
const fs = require("fs");
async function ensureUserDataDB(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new sqlite3.Database(
dbPath,
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE
);
return new Promise((resolve, reject) => {
const schema = `
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
profile_picture_url TEXT,
email TEXT UNIQUE,
password_hash TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS UserIntegration (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
platform TEXT NOT NULL DEFAULT 'AniList',
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
token_type TEXT NOT NULL,
anilist_user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ListEntry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
entry_id INTEGER NOT NULL,
source TEXT NOT NULL,
entry_type TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
score INTEGER,
start_date DATE,
end_date DATE,
repeat_count INTEGER NOT NULL DEFAULT 0,
notes TEXT,
is_private BOOLEAN NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, entry_id),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
`;
db.exec(schema, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
async function ensureAnilistSchema(db) {
return new Promise((resolve, reject) => {
const schema = `
CREATE TABLE IF NOT EXISTS anime (
id INTEGER PRIMARY KEY,
title TEXT,
updatedAt INTEGER,
cache_created_at INTEGER DEFAULT 0,
cache_ttl_ms INTEGER DEFAULT 0,
full_data JSON
);
CREATE TABLE IF NOT EXISTS trending (
rank INTEGER PRIMARY KEY,
id INTEGER,
full_data JSON,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS top_airing (
rank INTEGER PRIMARY KEY,
id INTEGER,
full_data JSON,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY,
title TEXT,
updatedAt INTEGER,
full_data JSON
);
CREATE TABLE IF NOT EXISTS trending_books (
rank INTEGER PRIMARY KEY,
id INTEGER,
full_data JSON,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS popular_books (
rank INTEGER PRIMARY KEY,
id INTEGER,
full_data JSON,
updated_at INTEGER NOT NULL DEFAULT 0
);
`;
db.exec(schema, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
async function ensureExtensionsTable(db) {
return new Promise((resolve, reject) => {
db.exec(`
CREATE TABLE IF NOT EXISTS extension (
ext_name TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL,
metadata TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(ext_name, id)
);
`, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
async function ensureCacheTable(db) {
return new Promise((resolve, reject) => {
db.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
result TEXT NOT NULL,
created_at INTEGER NOT NULL,
ttl_ms INTEGER NOT NULL
);
`, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
function ensureFavoritesDB(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const exists = fs.existsSync(dbPath);
const db = new sqlite3.Database(
dbPath,
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE
);
return new Promise((resolve, reject) => {
if (!exists) {
const schema = `
CREATE TABLE IF NOT EXISTS favorites (
id TEXT NOT NULL,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
image_url TEXT NOT NULL,
thumbnail_url TEXT NOT NULL DEFAULT "",
tags TEXT NOT NULL DEFAULT "",
headers TEXT NOT NULL DEFAULT "",
provider TEXT NOT NULL DEFAULT "",
PRIMARY KEY (id, user_id)
);
`;
db.exec(schema, (err) => {
if (err) reject(err);
else resolve(true);
});
return;
}
db.all(`PRAGMA table_info(favorites)`, (err, cols) => {
if (err) return reject(err);
const hasHeaders = cols.some(c => c.name === "headers");
const hasProvider = cols.some(c => c.name === "provider");
const hasUserId = cols.some(c => c.name === "user_id");
const queries = [];
if (!hasHeaders) {
queries.push(`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`);
}
if (!hasProvider) {
queries.push(`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`);
}
if (!hasUserId) {
queries.push(`ALTER TABLE favorites ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`);
}
if (queries.length === 0) {
return resolve(false);
}
db.exec(queries.join(";"), (err) => {
if (err) reject(err);
else resolve(true);
});
});
});
}
module.exports = {
ensureUserDataDB,
ensureAnilistSchema,
ensureExtensionsTable,
ensureCacheTable,
ensureFavoritesDB
};

View File

@@ -0,0 +1,82 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import * as fs from 'fs';
import * as path from 'path';
async function viewsRoutes(fastify: FastifyInstance) {
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html'));
reply.type('text/html').send(stream);
});
fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html'));
reply.type('text/html').send(stream);
});
fastify.get('/my-list', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'list.html'));
reply.type('text/html').send(stream);
});
fastify.get('/books', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'books.html'));
reply.type('text/html').send(stream);
});
fastify.get('/schedule', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'schedule.html'));
reply.type('text/html').send(stream);
});
fastify.get('/gallery', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html'));
reply.type('text/html').send(stream);
});
fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'marketplace.html'));
reply.type('text/html').send(stream);
});
fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html'));
reply.type('text/html').send(stream);
});
fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html'));
reply.type('text/html').send(stream);
});
fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html'));
reply.type('text/html').send(stream);
});
fastify.get('/anime/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html'));
reply.type('text/html').send(stream);
});
fastify.get('/watch/:id/:episode', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html'));
reply.type('text/html').send(stream);
});
fastify.get('/book/:id', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html'));
reply.type('text/html').send(stream);
});
fastify.get('/book/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html'));
reply.type('text/html').send(stream);
});
fastify.get('/read/:provider/:chapter/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html'));
reply.type('text/html').send(stream);
});
}
export default viewsRoutes;