mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 20:04:00 -07:00
init proj
This commit is contained in:
parent
aa0c03b86b
commit
fd14c1a80c
48 changed files with 10041 additions and 1 deletions
392
src/lib/api.ts
Normal file
392
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import type { Comic, Genre, ComicType } from './types';
|
||||
import { getRedisClient } from './db';
|
||||
|
||||
const languageMap = {
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"es": "Spanish",
|
||||
"it": "Italian",
|
||||
"pl": "Polish",
|
||||
"tr": "Turkish",
|
||||
"ja": "Japanese",
|
||||
"zh": "Chinese",
|
||||
"sv": "Swedish",
|
||||
"ar": "Arabic",
|
||||
"de": "German",
|
||||
"ko": "Korean"
|
||||
} as const;
|
||||
|
||||
const languages = new Set(Object.keys(languageMap));
|
||||
const contentTypes = new Set(["sfw", "nsfw"]);
|
||||
const comicTypes = new Set(['manga', 'manhwa', 'manhua', 'all']);
|
||||
const urlBase = "https://api.comick.fun/chapter";
|
||||
|
||||
let genreCache: Genre[] = [];
|
||||
|
||||
const RETRY_COUNT = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
async function fetchWithRetry(url: string, options: RequestInit = {}, retries = RETRY_COUNT): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ComicReader/1.0)',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
|
||||
return fetchWithRetry(url, options, retries - 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGenres(): Promise<Genre[]> {
|
||||
if (genreCache.length > 0) return genreCache;
|
||||
|
||||
try {
|
||||
const response = await fetchWithRetry('https://api.comick.fun/genre');
|
||||
genreCache = await response.json();
|
||||
return genreCache;
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getGenreNames(genreIds: number[], genres: Genre[]): string[] {
|
||||
return genreIds
|
||||
.map(id => genres.find(g => g.id === id)?.name ?? '')
|
||||
.filter(name => name !== '');
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public endpoint?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchComics(
|
||||
language: string = 'en',
|
||||
contentType: string = 'sfw',
|
||||
comicType: ComicType = 'all',
|
||||
page: number = 1
|
||||
): Promise<Comic[]> {
|
||||
const apiUrl = `${urlBase}?lang=${language}&page=${page}&order=new&accept_erotic_content=${contentType === 'nsfw'}${comicType === 'all' ? '' : `&type=${comicType}`}`;
|
||||
|
||||
try {
|
||||
console.log('Fetching comics from:', apiUrl);
|
||||
|
||||
const response = await fetchWithRetry(apiUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new APIError('Invalid API response format', undefined, apiUrl);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) throw error;
|
||||
throw new APIError(
|
||||
'Failed to fetch comics',
|
||||
error instanceof Response ? error.status : undefined,
|
||||
apiUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidLanguage(lang: string): boolean {
|
||||
return languages.has(lang);
|
||||
}
|
||||
|
||||
export function isValidContentType(type: string): boolean {
|
||||
return contentTypes.has(type);
|
||||
}
|
||||
|
||||
export function isValidComicType(type: string): boolean {
|
||||
return comicTypes.has(type);
|
||||
}
|
||||
|
||||
export function getLanguageName(code: string): string {
|
||||
return languageMap[code as keyof typeof languageMap] || code.toUpperCase();
|
||||
}
|
||||
|
||||
export const availableLanguages = Array.from(languages);
|
||||
export const availableContentTypes = Array.from(contentTypes);
|
||||
export const availableComicTypes = Array.from(comicTypes);
|
||||
export { languageMap };
|
||||
|
||||
interface ComicDetail {
|
||||
comic: {
|
||||
hid: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChapterDetail {
|
||||
id: string;
|
||||
chap: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
md_comics: {
|
||||
title: string;
|
||||
slug: string;
|
||||
md_covers?: {
|
||||
b2key: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ChapterParams {
|
||||
limit?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export async function getComicBySlug(slug: string): Promise<ComicDetail | null> {
|
||||
try {
|
||||
const response = await fetchWithRetry(`https://api.comick.fun/comic/${slug}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.comic?.hid) {
|
||||
console.error(`Invalid comic data for slug ${slug}:`, data);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching comic ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChaptersByHid(
|
||||
hid: string,
|
||||
slug: string,
|
||||
params: ChapterParams = { limit: 5, lang: 'en' }
|
||||
): Promise<ChapterDetail[]> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
limit: params.limit?.toString() || '5',
|
||||
lang: params.lang || 'en',
|
||||
ordering: '-created_at'
|
||||
});
|
||||
|
||||
// Fetch comic details including cover
|
||||
const comicResponse = await fetchWithRetry(
|
||||
`https://api.comick.fun/comic/${hid}`
|
||||
);
|
||||
const comicData = await comicResponse.json();
|
||||
const comicTitle = comicData?.comic?.title || 'Unknown Comic';
|
||||
const covers = comicData?.comic?.md_covers || [];
|
||||
|
||||
const response = await fetchWithRetry(
|
||||
`https://api.comick.fun/comic/${hid}/chapters?${queryParams}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.chapters || !Array.isArray(data.chapters)) {
|
||||
console.error(`Invalid chapters data for hid ${hid}:`, data);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Processing chapters for comic:', comicTitle);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const chapters: ChapterDetail[] = [];
|
||||
const rawChapters = data.chapters || [];
|
||||
|
||||
for (const chap of rawChapters) {
|
||||
try {
|
||||
// Add created_at check
|
||||
if (!chap.id || !chap.chap || !chap.created_at) {
|
||||
console.warn(`Skipping chapter with missing fields in ${hid}:`, chap);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate both dates
|
||||
const isValidCreated = !isNaN(Date.parse(chap.created_at));
|
||||
const isValidUpdated = !isNaN(Date.parse(chap.updated_at));
|
||||
|
||||
if (!isValidCreated || !isValidUpdated) {
|
||||
console.warn(`Invalid dates for chapter ${chap.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
id: chap.id,
|
||||
chap: chap.chap.toString(),
|
||||
title: chap.title || `Chapter ${chap.chap}`,
|
||||
created_at: new Date(chap.created_at).toISOString(), // Use created_at
|
||||
md_comics: {
|
||||
title: comicTitle,
|
||||
slug: slug,
|
||||
md_covers: covers
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Error processing chapter ${chap.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Successfully processed ${chapters.length} chapters for ${comicTitle}`);
|
||||
return chapters;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching chapters for hid ${hid}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Add deduplication function
|
||||
function deduplicateChapters(chapters: ChapterDetail[]): ChapterDetail[] {
|
||||
const seen = new Set<string>();
|
||||
return chapters.filter(chapter => {
|
||||
const key = `${chapter.md_comics.slug}-${chapter.chap}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChaptersForSlugs(
|
||||
slugs: string[],
|
||||
lang: string = 'en'
|
||||
): Promise<ChapterDetail[]> {
|
||||
console.log(`Fetching chapters for ${slugs.length} comics`);
|
||||
const redis = await getRedisClient();
|
||||
const CACHE_TTL = 60 * 60;
|
||||
|
||||
const uniqueSlugs = [...new Set(slugs)];
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
// Add cache key validation
|
||||
uniqueSlugs.forEach(slug => {
|
||||
if (!slug.match(/^[a-z0-9-]+$/)) {
|
||||
console.error(`Invalid slug format: ${slug}`);
|
||||
}
|
||||
const cacheKey = `chapters:${slug}:${lang}`;
|
||||
pipeline.get(cacheKey);
|
||||
});
|
||||
|
||||
const [cacheResults, slugsToFetch] = await Promise.all([
|
||||
pipeline.exec(),
|
||||
validateSlugs(uniqueSlugs) // Add this new function
|
||||
]);
|
||||
|
||||
const cachedChapters: ChapterDetail[] = [];
|
||||
cacheResults.forEach(([err, result], index) => {
|
||||
if (!err && result) {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
if (Array.isArray(parsed)) {
|
||||
cachedChapters.push(...parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Cache parse error for ${uniqueSlugs[index]}:`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add progress tracking
|
||||
const cachedSlugs = new Set(cachedChapters.map(c => c.md_comics.slug));
|
||||
console.log(`Cache hits: ${cachedChapters.length} chapters from ${cachedSlugs.size} comics`);
|
||||
console.log(`Fetching ${slugsToFetch.length} comics from API`);
|
||||
|
||||
const fetchPromises = slugsToFetch.map(async (slug) => {
|
||||
const timer = Date.now();
|
||||
try {
|
||||
const comicDetail = await getComicBySlug(slug);
|
||||
|
||||
if (!comicDetail?.comic?.hid) {
|
||||
console.error(`❌ No HID found for ${slug}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const chapters = await getChaptersByHid(comicDetail.comic.hid, slug, {
|
||||
limit: 5,
|
||||
lang: lang
|
||||
});
|
||||
|
||||
console.log(`✅ ${slug} (${comicDetail.comic.hid}) returned ${chapters.length} chapters in ${Date.now() - timer}ms`);
|
||||
return chapters;
|
||||
} catch (error) {
|
||||
console.error(`🚨 Error processing ${slug}:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const liveChapters = (await Promise.all(fetchPromises)).flat();
|
||||
|
||||
// Cache new results in batch
|
||||
const cachePipeline = redis.pipeline();
|
||||
const chaptersBySlug: Record<string, ChapterDetail[]> = {};
|
||||
|
||||
liveChapters.forEach(chapter => {
|
||||
const slug = chapter.md_comics.slug;
|
||||
if (!chaptersBySlug[slug]) {
|
||||
chaptersBySlug[slug] = [];
|
||||
}
|
||||
chaptersBySlug[slug].push(chapter);
|
||||
});
|
||||
|
||||
Object.entries(chaptersBySlug).forEach(([slug, chaps]) => {
|
||||
const cacheKey = `chapters:${slug}:${lang}`;
|
||||
cachePipeline.set(
|
||||
cacheKey,
|
||||
JSON.stringify(chaps),
|
||||
'EX',
|
||||
CACHE_TTL
|
||||
);
|
||||
});
|
||||
|
||||
await cachePipeline.exec();
|
||||
|
||||
// Combine and sort all chapters
|
||||
const allChapters = [...cachedChapters, ...liveChapters];
|
||||
const deduplicatedChapters = deduplicateChapters(allChapters);
|
||||
return deduplicatedChapters.sort((a, b) =>
|
||||
parseFloat(b.chap) - parseFloat(a.chap) ||
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// Add export to the validation function
|
||||
export async function validateSlugs(slugs: string[]): Promise<string[]> {
|
||||
const validSlugs: string[] = [];
|
||||
const invalidSlugs: string[] = [];
|
||||
|
||||
await Promise.all(slugs.map(async (slug) => {
|
||||
try {
|
||||
const response = await fetchWithRetry(`https://api.comick.fun/comic/${slug}`, {}, 1);
|
||||
if (response.ok) {
|
||||
validSlugs.push(slug);
|
||||
} else {
|
||||
invalidSlugs.push(slug);
|
||||
}
|
||||
} catch {
|
||||
invalidSlugs.push(slug);
|
||||
}
|
||||
}));
|
||||
|
||||
if (invalidSlugs.length > 0) {
|
||||
console.error(`Invalid/Unavailable slugs: ${invalidSlugs.join(', ')}`);
|
||||
}
|
||||
|
||||
return validSlugs;
|
||||
}
|
||||
458
src/lib/db.ts
Normal file
458
src/lib/db.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import Redis from 'ioredis';
|
||||
import type { FeedMapping } from './types';
|
||||
import { getChaptersForSlugs } from './api';
|
||||
|
||||
// Extended Error interface for network errors
|
||||
interface RedisNetworkError extends Error {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
// Primary Redis configuration
|
||||
const PRIMARY_REDIS_URL = import.meta.env.PRIMARY_REDIS_URL || process.env.PRIMARY_REDIS_URL || process.env.REDIS_URL;
|
||||
|
||||
// Multiple replicas support
|
||||
const REPLICA_URLS = [
|
||||
import.meta.env.REPLICA_REDIS_URL_1 || process.env.REPLICA_REDIS_URL_1 || import.meta.env.REPLICA_REDIS_URL || process.env.REPLICA_REDIS_URL,
|
||||
import.meta.env.REPLICA_REDIS_URL_2 || process.env.REPLICA_REDIS_URL_2,
|
||||
import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// Create a mapping of URLs to friendly names for logging
|
||||
const redisInstanceNames = new Map<string, string>();
|
||||
|
||||
// Initialize friendly names for each Redis instance
|
||||
function initializeRedisNames() {
|
||||
redisInstanceNames.set(PRIMARY_REDIS_URL!, 'Primary');
|
||||
|
||||
REPLICA_URLS.forEach((url, index) => {
|
||||
redisInstanceNames.set(url, `Replica ${index + 1}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Call this immediately
|
||||
initializeRedisNames();
|
||||
|
||||
// Helper function to get a safe name for a Redis instance
|
||||
function getRedisName(url: string): string {
|
||||
return redisInstanceNames.get(url) || 'Unknown Redis instance';
|
||||
}
|
||||
|
||||
// Client instances
|
||||
let primaryClient: Redis | null = null;
|
||||
let replicaClients: Map<string, Redis> = new Map();
|
||||
|
||||
const DEFAULT_CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_DELAY_MS = 100;
|
||||
const IS_VERCEL = process.env.VERCEL || false;
|
||||
const VERCEL_CONNECTION_TIMEOUT = 15000; // 15 seconds for Vercel
|
||||
const LOCAL_CONNECTION_TIMEOUT = 5000; // 5 seconds for local
|
||||
|
||||
// Improved connection handling with exponential backoff
|
||||
async function createRedisClient(url: string): Promise<Redis> {
|
||||
if (!url) throw new Error('Redis URL is not set');
|
||||
|
||||
const redisName = getRedisName(url);
|
||||
|
||||
// Parse URL to extract authentication info
|
||||
const options = {
|
||||
retryStrategy: (times) => {
|
||||
if (times >= MAX_RETRIES) return null;
|
||||
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, times), 5000);
|
||||
console.log(`${redisName} retry attempt ${times}, next try in ${delay}ms`);
|
||||
return delay + Math.random() * 200; // Add jitter
|
||||
},
|
||||
maxRetriesPerRequest: null, // Allow retries for queued commands
|
||||
enableOfflineQueue: true, // Enable command queuing
|
||||
enableReadyCheck: true,
|
||||
connectTimeout: 15000, // Increase connection timeout to 15 seconds for Vercel's serverless environment
|
||||
disconnectTimeout: 10000, // Increase disconnect timeout to 10 seconds
|
||||
keepAlive: 10000, // Send keepalive every 10 seconds
|
||||
commandTimeout: 10000, // Command timeout
|
||||
retryMaxDelay: 5000, // Maximum delay between retries
|
||||
reconnectOnError: (err) => {
|
||||
const targetErrors = ['READONLY', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'];
|
||||
return targetErrors.some(e => err.message.includes(e));
|
||||
}
|
||||
};
|
||||
|
||||
return new Redis(url, options);
|
||||
}
|
||||
|
||||
// Get the best available Redis client
|
||||
export async function getRedisClient(): Promise<Redis> {
|
||||
// Special handling for Vercel environment
|
||||
if (IS_VERCEL) {
|
||||
console.log('Running in Vercel environment - using optimized Redis connection strategy');
|
||||
}
|
||||
|
||||
// Try primary first
|
||||
if (primaryClient?.status === 'ready') {
|
||||
try {
|
||||
// Add timeout to ping
|
||||
const pingPromise = primaryClient.ping();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Ping timeout')), 3000)
|
||||
);
|
||||
|
||||
await Promise.race([pingPromise, timeoutPromise]);
|
||||
return primaryClient;
|
||||
} catch (error) {
|
||||
console.warn('Primary Redis connection failed, will try replicas:', error);
|
||||
// Fall through to try replicas
|
||||
}
|
||||
}
|
||||
|
||||
// Try replicas in sequence
|
||||
for (const [url, client] of replicaClients.entries()) {
|
||||
if (client.status === 'ready') {
|
||||
try {
|
||||
// Add timeout to ping
|
||||
const pingPromise = client.ping();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Ping timeout')), 3000)
|
||||
);
|
||||
|
||||
await Promise.race([pingPromise, timeoutPromise]);
|
||||
console.log(`Using ${getRedisName(url)}`);
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.warn(`${getRedisName(url)} connection failed:`, error);
|
||||
// Continue to next replica
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect to primary
|
||||
try {
|
||||
console.log('Initializing new primary Redis connection...');
|
||||
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
|
||||
|
||||
primaryClient
|
||||
.on('ready', () => console.log('Primary Redis connection ready'))
|
||||
.on('error', (err: RedisNetworkError) => {
|
||||
// Log more details for connection errors
|
||||
if (err.code === 'ETIMEDOUT') {
|
||||
console.error('Primary Redis connection timeout. This is common in serverless environments like Vercel. Check your network connectivity and Redis server availability.');
|
||||
} else if (err.code === 'ECONNREFUSED') {
|
||||
console.error('Primary Redis connection refused. Ensure the Redis server is running and the URL is correct.');
|
||||
} else if (err.code === 'ENOTFOUND') {
|
||||
console.error('Primary Redis host not found. Check your PRIMARY_REDIS_URL environment variable.');
|
||||
} else {
|
||||
console.error('Primary Redis error:', err);
|
||||
}
|
||||
})
|
||||
.on('reconnecting', () => console.log('Primary Redis reconnecting'))
|
||||
.on('end', () => console.log('Primary Redis connection closed'));
|
||||
|
||||
return primaryClient;
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to primary Redis:', error);
|
||||
|
||||
// Special handling for Vercel ETIMEDOUT errors
|
||||
if (IS_VERCEL && error instanceof Error && error.message.includes('ETIMEDOUT')) {
|
||||
console.log('Encountered ETIMEDOUT in Vercel environment. Retrying with longer timeout...');
|
||||
try {
|
||||
// Try one more time with increased timeout
|
||||
const tempClient = new Redis(PRIMARY_REDIS_URL!, {
|
||||
connectTimeout: 20000, // Increased timeout for last-ditch effort
|
||||
retryStrategy: () => null // No retries for this attempt
|
||||
});
|
||||
|
||||
// Wait for ready or error
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
tempClient.disconnect();
|
||||
reject(new Error('Final connection attempt timed out'));
|
||||
}, 20000);
|
||||
|
||||
tempClient.once('ready', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
tempClient.once('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// If we get here, connection was successful
|
||||
primaryClient = tempClient;
|
||||
return tempClient;
|
||||
} catch (finalError) {
|
||||
console.error('Final Redis connection attempt failed:', finalError);
|
||||
}
|
||||
}
|
||||
|
||||
// Try connecting to any replica that might be available
|
||||
for (const url of REPLICA_URLS) {
|
||||
try {
|
||||
const redisName = getRedisName(url);
|
||||
console.log(`Trying to connect to ${redisName}...`);
|
||||
const client = await createRedisClient(url);
|
||||
|
||||
client
|
||||
.on('ready', () => console.log(`${redisName} connection ready`))
|
||||
.on('error', (err: RedisNetworkError) => {
|
||||
// Log more details for connection errors
|
||||
if (err.code === 'ETIMEDOUT') {
|
||||
console.error(`${redisName} connection timeout. This is common in serverless environments like Vercel. Check your network connectivity and Redis server availability.`);
|
||||
} else if (err.code === 'ECONNREFUSED') {
|
||||
console.error(`${redisName} connection refused. Ensure the Redis server is running and the URL is correct.`);
|
||||
} else if (err.code === 'ENOTFOUND') {
|
||||
console.error(`${redisName} host not found. Check your Redis URL environment variables.`);
|
||||
} else {
|
||||
console.error(`${redisName} error:`, err);
|
||||
}
|
||||
})
|
||||
.on('reconnecting', () => console.log(`${redisName} reconnecting`))
|
||||
.on('end', () => console.log(`${redisName} connection closed`));
|
||||
|
||||
replicaClients.set(url, client);
|
||||
return client;
|
||||
} catch (replicaError) {
|
||||
console.error(`Failed to connect to ${getRedisName(url)}:`, replicaError);
|
||||
// Continue to next replica
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('All Redis connections failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Write to primary and all replicas for redundancy
|
||||
export async function writeToAllRedis(operation: (redis: Redis) => Promise<any>): Promise<void> {
|
||||
const errors = [];
|
||||
let successCount = 0;
|
||||
|
||||
// Try primary
|
||||
try {
|
||||
if (!primaryClient || primaryClient.status !== 'ready') {
|
||||
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
|
||||
}
|
||||
await operation(primaryClient);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to write to Primary Redis:', error);
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
// Try all replicas
|
||||
for (const url of REPLICA_URLS) {
|
||||
try {
|
||||
let client = replicaClients.get(url);
|
||||
if (!client || client.status !== 'ready') {
|
||||
client = await createRedisClient(url);
|
||||
replicaClients.set(url, client);
|
||||
}
|
||||
await operation(client);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to write to ${getRedisName(url)}:`, error);
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
// If all writes failed, throw an error
|
||||
if (successCount === 0) {
|
||||
throw new Error('Failed to write to any Redis instance');
|
||||
}
|
||||
|
||||
// Log warning if some writes failed
|
||||
if (errors.length > 0) {
|
||||
console.warn(`${errors.length} Redis writes failed out of ${errors.length + successCount} attempts`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all connections on startup
|
||||
(async () => {
|
||||
try {
|
||||
// Initialize primary
|
||||
if (PRIMARY_REDIS_URL) {
|
||||
primaryClient = await createRedisClient(PRIMARY_REDIS_URL);
|
||||
await primaryClient.ping();
|
||||
console.log('Primary Redis pre-connected successfully');
|
||||
}
|
||||
|
||||
// Initialize all replicas
|
||||
for (const url of REPLICA_URLS) {
|
||||
try {
|
||||
const client = await createRedisClient(url);
|
||||
await client.ping();
|
||||
replicaClients.set(url, client);
|
||||
console.log(`${getRedisName(url)} pre-connected successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to pre-connect to ${getRedisName(url)}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Redis setup complete: 1 primary and ${replicaClients.size} replicas connected`);
|
||||
} catch (error) {
|
||||
console.error('Redis pre-connection failed:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
// Update existing functions to use the new methods
|
||||
|
||||
export async function storeFeedMappings(mappings: Array<{ feedId: string, slugs: string[], lang: string }>): Promise<void> {
|
||||
await writeToAllRedis(async (redis) => {
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
mappings.forEach(({ feedId, slugs, lang }) => {
|
||||
const mapping = {
|
||||
slugs,
|
||||
lang,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
pipeline.set(feedId, JSON.stringify(mapping), 'EX', DEFAULT_CACHE_TTL);
|
||||
});
|
||||
|
||||
await pipeline.exec();
|
||||
});
|
||||
}
|
||||
|
||||
export async function warmChapterCache(slugs: string[], lang: string): Promise<void> {
|
||||
const redis = await getRedisClient();
|
||||
const needsCache = await checkCacheStatus(slugs, lang);
|
||||
|
||||
if (needsCache.length > 0) {
|
||||
console.log(`Pre-warming cache for ${needsCache.length} comics`);
|
||||
|
||||
await writeToAllRedis(async (redis) => {
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
await Promise.all(needsCache.map(async slug => {
|
||||
try {
|
||||
const chapters = await getChaptersForSlugs([slug], lang);
|
||||
const cacheKey = `chapters:${slug}:${lang}`;
|
||||
pipeline.set(cacheKey, JSON.stringify(chapters), 'EX', 60 * 60);
|
||||
} catch (error) {
|
||||
console.error(`Failed to warm cache for ${slug}:`, error);
|
||||
}
|
||||
}));
|
||||
|
||||
await pipeline.exec();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeFeedMapping(feedId: string, slugs: string[], lang: string): Promise<void> {
|
||||
const mapping = {
|
||||
slugs,
|
||||
lang,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await writeToAllRedis(async (redis) => {
|
||||
await redis.set(
|
||||
feedId,
|
||||
JSON.stringify(mapping),
|
||||
'EX',
|
||||
DEFAULT_CACHE_TTL
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error storing feed mapping:', error);
|
||||
throw new Error('Failed to store feed mapping');
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCacheStatus(slugs: string[], lang: string): Promise<string[]> {
|
||||
const redis = await getRedisClient();
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
slugs.forEach(slug => {
|
||||
const cacheKey = `chapters:${slug}:${lang}`;
|
||||
pipeline.exists(cacheKey);
|
||||
});
|
||||
|
||||
const results = await pipeline.exec();
|
||||
return slugs.filter((_, index) => results![index][1] === 0);
|
||||
}
|
||||
|
||||
export async function getFeedMapping(feedId: string): Promise<FeedMapping | null> {
|
||||
let foundMapping: string | null = null;
|
||||
let sourceRedisName: string = "";
|
||||
|
||||
// First try primary
|
||||
try {
|
||||
// Check primary database if available
|
||||
if (primaryClient?.status === 'ready') {
|
||||
try {
|
||||
await primaryClient.ping();
|
||||
const mapping = await primaryClient.get(feedId);
|
||||
if (mapping) {
|
||||
await primaryClient.expire(feedId, DEFAULT_CACHE_TTL);
|
||||
foundMapping = mapping;
|
||||
sourceRedisName = "Primary";
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Primary Redis connection failed while retrieving mapping:', error);
|
||||
// Fall through to try replicas
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in primary, check replicas
|
||||
if (!foundMapping) {
|
||||
for (const [url, client] of replicaClients.entries()) {
|
||||
if (client.status === 'ready') {
|
||||
try {
|
||||
await client.ping();
|
||||
console.log(`Trying to get key from ${getRedisName(url)}`);
|
||||
const mapping = await client.get(feedId);
|
||||
if (mapping) {
|
||||
await client.expire(feedId, DEFAULT_CACHE_TTL);
|
||||
foundMapping = mapping;
|
||||
sourceRedisName = getRedisName(url);
|
||||
break; // Exit the loop once we find the mapping
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${getRedisName(url)} connection failed while retrieving mapping:`, error);
|
||||
// Continue to next replica
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the mapping in any database, re-synchronize to all other databases
|
||||
if (foundMapping) {
|
||||
console.log(`Found mapping in ${sourceRedisName}. Re-synchronizing to all Redis instances...`);
|
||||
|
||||
// Parse data for returning
|
||||
const data = JSON.parse(foundMapping);
|
||||
const result = {
|
||||
slugs: data.slugs,
|
||||
lang: data.lang
|
||||
};
|
||||
|
||||
// Fire-and-forget re-sync to all databases
|
||||
(async () => {
|
||||
try {
|
||||
await writeToAllRedis(async (redis) => {
|
||||
await redis.set(
|
||||
feedId,
|
||||
foundMapping!,
|
||||
'EX',
|
||||
DEFAULT_CACHE_TTL
|
||||
);
|
||||
});
|
||||
console.log(`Successfully re-synchronized mapping for ${feedId} to all Redis instances`);
|
||||
} catch (syncError) {
|
||||
console.error('Error during Redis re-synchronization:', syncError);
|
||||
}
|
||||
})();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we get here, the key wasn't found in any database
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error retrieving feed mapping:', error);
|
||||
throw new Error('Failed to retrieve feed mapping');
|
||||
}
|
||||
}
|
||||
|
||||
// No need for manual cleanup as we're using TTL (expire)
|
||||
53
src/lib/types.ts
Normal file
53
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export interface Comic {
|
||||
id: string;
|
||||
chap: string;
|
||||
updated_at: string;
|
||||
md_comics: {
|
||||
title: string;
|
||||
slug: string;
|
||||
genres: number[];
|
||||
md_covers: ComicCover[];
|
||||
content_rating: 'safe' | 'suggestive' | 'erotica';
|
||||
comic_type: ComicType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FeedMapping {
|
||||
slugs: string[];
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export type ComicType = 'manga' | 'manhwa' | 'manhua' | 'all';
|
||||
|
||||
export interface ComicCover {
|
||||
b2key: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'success';
|
||||
dismissible?: boolean;
|
||||
link?: {
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChapterDetail {
|
||||
id: string;
|
||||
chap: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
md_comics: {
|
||||
title: string;
|
||||
slug: string;
|
||||
md_covers?: ComicCover[];
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue