Compare commits

..

5 commits

Author SHA1 Message Date
ld3zian
6d783ad0d0 update bgs 2025-03-11 22:02:23 -07:00
ld3zian
e48ea08c05 format backgrounds and add new ones 2025-03-11 21:54:56 -07:00
ld3zian
7577ff4f83 vercel better work 2025-03-11 14:43:17 -07:00
ld3zian
5874ae5d9b fix flaw 2025-03-11 14:30:10 -07:00
ld3zian
0fd7ea433d implement new sstem 2025-03-11 14:23:51 -07:00
6 changed files with 410 additions and 65 deletions

BIN
public/cynthia1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

BIN
public/darling.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
public/granny.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

BIN
public/numi_lily.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View file

@ -33,7 +33,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://danbooru.donmai.us/posts/6900673", source: "https://danbooru.donmai.us/posts/6900673",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://twitter.com/ayanagi0319", link: "https://twitter.com/ayanagi0319",
}, },
], ],
@ -45,7 +45,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://danbooru.donmai.us/posts/8743208", source: "https://danbooru.donmai.us/posts/8743208",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/kamepan44231", link: "https://x.com/kamepan44231",
}, },
], ],
@ -57,7 +57,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://danbooru.donmai.us/posts/8074444", source: "https://danbooru.donmai.us/posts/8074444",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/Ten_0123_/", link: "https://x.com/Ten_0123_/",
}, },
], ],
@ -69,8 +69,8 @@ export const backgrounds: BackgroundImage[] = [
source: "https://www.pixiv.net/en/artworks/124222053", source: "https://www.pixiv.net/en/artworks/124222053",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://twitter.com/greatodoggo", link: "https://x.com/greatodoggo",
}, },
], ],
}, },
@ -81,7 +81,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://safebooru.org/index.php?page=post&s=view&id=5418793", source: "https://safebooru.org/index.php?page=post&s=view&id=5418793",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/soaphisoap", link: "https://x.com/soaphisoap",
}, },
], ],
@ -93,7 +93,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://safebooru.org/index.php?page=post&s=view&id=5531630", source: "https://safebooru.org/index.php?page=post&s=view&id=5531630",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/charlieinoni", link: "https://x.com/charlieinoni",
}, },
], ],
@ -106,11 +106,59 @@ export const backgrounds: BackgroundImage[] = [
"https://i.pximg.net/img-original/img/2025/03/03/15/13/24/127822472_p0.png", "https://i.pximg.net/img-original/img/2025/03/03/15/13/24/127822472_p0.png",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/", link: "https://x.com/",
}, },
], ],
}, },
{
url: "/cynthia1.webp",
title: "Cynthia (Pokemon)",
artist: "???",
source: "https://safebooru.org/index.php?page=post&s=view&id=5547740",
socials: [
{
name: "Artist's Twitter",
link: "https://x.com/",
},
],
},
{
url: "/darling.webp",
title: "Zero Two",
artist: "AWZ",
source: "https://www.pixiv.net/en/artworks/123110068",
socials: [
{
name: "Pixiv Profile",
link: "https://www.pixiv.net/en/users/67355371",
},
],
},
{
url: "/granny.webp",
title: "Turbo Granny (Dandadan)",
artist: "MajimartBCN",
source: "https://twitter.com/MajimartBCN/status/1864741369383694841",
socials: [
{
name: "Artist's Twitter",
link: "https://x.com/MajimartBCN",
},
],
},
{
url: "/numi_lily.webp",
title: "Nihmune & Shylily",
artist: "Gryever_AW",
source: "https://x.com/gryever_aw/status/1890772686084686118",
socials: [
{
name: "Artist's Twitter",
link: "https://x.com/Gryever_AW",
},
],
},
// Add more backgrounds here! // Add more backgrounds here!
// { // {
// url: 'your-image-url', // url: 'your-image-url',

View file

@ -7,6 +7,10 @@ export const CHAPTER_CACHE_TTL = 60 * 60; // 1 hour
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
const IS_VERCEL = process.env.VERCEL || false; const IS_VERCEL = process.env.VERCEL || false;
const HEALTH_CHECK_INTERVAL = 60 * 1000; // Check primary health every minute
const REPLICA_PROMOTION_THRESHOLD = 5; // After 5 failed primary health checks, promote a replica to primary
const SYNC_BUFFER_SIZE = 100; // Maximum number of operations to buffer before syncing
const SYNC_INTERVAL = 10 * 1000; // Sync replicas every 10 seconds
// Redis URLs from environment variables // Redis URLs from environment variables
const PRIMARY_REDIS_URL = const PRIMARY_REDIS_URL =
@ -18,13 +22,35 @@ const REPLICA_URLS = [
import.meta.env.REPLICA_REDIS_URL_1 || import.meta.env.REPLICA_REDIS_URL_1 ||
process.env.REPLICA_REDIS_URL_1 || process.env.REPLICA_REDIS_URL_1 ||
import.meta.env.REPLICA_REDIS_URL, import.meta.env.REPLICA_REDIS_URL,
import.meta.env.REPLICA_REDIS_URL_2 || process.env.REPLICA_REDIS_URL_2, import.meta.env.REPLICA_REDIS_URL_2 || process.env.REPLICA_REPLICA_URL_2 || process.env.REPLICA_REDIS_URL_2,
import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3, import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
// Singleton clients // Log available Redis instances
console.log(`Primary Redis URL: ${PRIMARY_REDIS_URL ? 'Available' : 'Not configured'}`);
console.log(`Replica URLs available: ${REPLICA_URLS.length}`);
// Singleton clients with health status tracking
let primaryClient: Redis | null = null; let primaryClient: Redis | null = null;
let primaryHealthy: boolean = true;
let primaryFailedChecks = 0;
let replicaClients: Map<string, Redis> = new Map(); let replicaClients: Map<string, Redis> = new Map();
let activeReplicaUrl: string | null = null;
// Track which replica was used when primary was down
let lastUsedReplica: string | null = null;
// Operation buffer for sync
interface RedisOperation {
key: string;
value: string;
ttl?: number;
type: 'set' | 'del';
timestamp: number;
}
// Buffer to store operations that need to be synced
const operationBuffer: RedisOperation[] = [];
// Redis instance naming for logging // Redis instance naming for logging
const redisInstanceNames = new Map<string, string>(); const redisInstanceNames = new Map<string, string>();
@ -57,6 +83,7 @@ async function createRedisClient(url: string): Promise<Redis> {
throw new Error("Redis URL not provided"); throw new Error("Redis URL not provided");
} }
console.log(`Creating Redis client for ${getRedisName(url)}: ${url.slice(0, 20)}...`);
const redisName = getRedisName(url); const redisName = getRedisName(url);
const client = new Redis(url, { const client = new Redis(url, {
@ -82,14 +109,66 @@ async function createRedisClient(url: string): Promise<Redis> {
// Setup event listeners // Setup event listeners
client client
.on("ready", () => console.log(`${redisName} ready`)) .on("ready", () => {
.on("error", (err) => console.error(`${redisName} error:`, err)) console.log(`${redisName} ready`);
// If primary recovers and we had used a replica, initiate sync from replica to primary
if (url === PRIMARY_REDIS_URL && lastUsedReplica) {
syncFromReplicaToPrimary(lastUsedReplica).catch(err =>
console.error(`Failed to sync from replica to primary: ${err}`));
}
})
.on("error", (err) => {
console.error(`${redisName} error:`, err);
if (url === PRIMARY_REDIS_URL) {
primaryHealthy = false;
}
})
.on("reconnecting", () => console.log(`${redisName} reconnecting`)) .on("reconnecting", () => console.log(`${redisName} reconnecting`))
.on("end", () => console.log(`${redisName} closed`)); .on("end", () => {
console.log(`${redisName} closed`);
if (url === PRIMARY_REDIS_URL) {
primaryHealthy = false;
}
});
return client; return client;
} }
/**
* Initialize all replicas to establish connections early
*/
async function initializeAllClients(): Promise<void> {
try {
// Initialize primary
if (PRIMARY_REDIS_URL && (!primaryClient || primaryClient.status !== "ready")) {
primaryClient = await createRedisClient(PRIMARY_REDIS_URL);
primaryHealthy = await checkConnection(primaryClient, "Primary");
console.log(`Primary Redis initialized, healthy: ${primaryHealthy}`);
}
// Initialize all replicas
for (const url of REPLICA_URLS) {
if (!replicaClients.has(url)) {
try {
const client = await createRedisClient(url);
const isHealthy = await checkConnection(client, getRedisName(url));
if (isHealthy) {
replicaClients.set(url, client);
console.log(`Replica ${getRedisName(url)} initialized successfully`);
}
} catch (error) {
console.error(`Failed to initialize replica ${getRedisName(url)}:`, error);
}
}
}
console.log(`Initialized ${replicaClients.size} replica connections`);
} catch (error) {
console.error("Error during client initialization:", error);
}
}
/** /**
* Check if a Redis client is connected and responsive * Check if a Redis client is connected and responsive
*/ */
@ -110,69 +189,257 @@ async function checkConnection(client: Redis, name: string): Promise<boolean> {
/** /**
* Get an active Redis client from the pool * Get an active Redis client from the pool
* Prioritizes primary, falls back to replicas
*/ */
export async function getRedisClient(): Promise<Redis> { export async function getRedisClient(): Promise<Redis> {
// Try primary client // Try primary client first if it's considered healthy
if (!primaryClient || primaryClient.status !== "ready") { if (primaryHealthy && (primaryClient === null || primaryClient.status !== "ready")) {
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!); try {
} primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
const isPrimaryConnected = await checkConnection(primaryClient, "Primary");
if (await checkConnection(primaryClient, "Primary")) { primaryHealthy = isPrimaryConnected;
if (isPrimaryConnected) {
primaryFailedChecks = 0;
console.log("Initialized primary Redis connection");
return primaryClient;
}
} catch (err) {
console.error("Failed to initialize primary Redis:", err);
primaryHealthy = false;
primaryFailedChecks++;
}
} else if (primaryHealthy && primaryClient && primaryClient.status === "ready") {
return primaryClient; return primaryClient;
} }
// Try replicas // Primary is unhealthy, try to use the active replica if we have one
for (const url of REPLICA_URLS) { if (activeReplicaUrl && replicaClients.has(activeReplicaUrl)) {
let client = replicaClients.get(url); const activeReplica = replicaClients.get(activeReplicaUrl)!;
if (!client || client.status !== "ready") { if (activeReplica.status === "ready" && await checkConnection(activeReplica, getRedisName(activeReplicaUrl))) {
client = await createRedisClient(url); console.log(`Using active replica: ${getRedisName(activeReplicaUrl)}`);
replicaClients.set(url, client); lastUsedReplica = activeReplicaUrl; // Track that we used this replica
} return activeReplica;
if (await checkConnection(client, getRedisName(url))) {
return client;
} }
} }
// Fallback: Force new primary connection // No active replica, try replicas in order
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);
}
if (await checkConnection(client, getRedisName(url))) {
console.log(`Switching to replica: ${getRedisName(url)}`);
activeReplicaUrl = url;
lastUsedReplica = url; // Track that we used this replica
// If primary has been down for a while, promote this replica to be the new primary
if (primaryFailedChecks >= REPLICA_PROMOTION_THRESHOLD) {
console.log(`Promoting ${getRedisName(url)} to primary after ${primaryFailedChecks} failed checks`);
primaryClient = client;
primaryHealthy = true;
primaryFailedChecks = 0;
}
return client;
}
} catch (err) {
console.error(`Failed to connect to replica ${getRedisName(url)}:`, err);
}
}
// If all else fails, try one more time with primary
console.warn("All Redis connections failed, making last attempt with primary");
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!); primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
return primaryClient; return primaryClient;
} }
/** /**
* Write data to all available Redis instances * Get all available replicas that are not the active client
*/ */
async function writeToAllRedis( async function getAvailableReplicas(activeClientUrl: string): Promise<Redis[]> {
operation: (redis: Redis) => Promise<any> const availableReplicas: Redis[] = [];
): Promise<void> {
const clients = [
primaryClient,
...Array.from(replicaClients.values()),
].filter(Boolean) as Redis[];
const errors: Error[] = []; // Initialize clients if not already done
await initializeAllClients();
for (const client of clients) {
try { // Check each replica
if (client.status === "ready") { for (const [url, client] of replicaClients.entries()) {
await operation(client); if (url !== activeClientUrl && client.status === "ready") {
try {
const isHealthy = await checkConnection(client, getRedisName(url));
if (isHealthy) {
availableReplicas.push(client);
}
} catch (error) {
console.error(`Error checking replica ${getRedisName(url)}:`, error);
} }
} catch (err) {
errors.push(err as Error);
console.error(
`Write failed for ${getRedisName(client.options.host || "")}:`,
err
);
} }
} }
console.log(`Found ${availableReplicas.length} available replicas for replication`);
return availableReplicas;
}
if (errors.length === clients.length && clients.length > 0) { /**
throw new Error("All Redis writes failed"); * Immediately replicate a Redis operation to all available replicas
*/
async function replicateOperation(
activeClientUrl: string,
key: string,
value: string,
ttl?: number
): Promise<void> {
try {
// Get available replicas that are not the active client
const replicas = await getAvailableReplicas(activeClientUrl);
if (replicas.length === 0) {
console.log('No available replicas for replication');
return;
}
console.log(`Replicating operation for key ${key} to ${replicas.length} replicas`);
// Replicate to each available replica
const replicationPromises = replicas.map(async (replica) => {
try {
if (ttl) {
await replica.set(key, value, 'EX', ttl);
} else {
await replica.set(key, value);
}
return true;
} catch (error) {
console.error(`Replication failed for key ${key}:`, error);
return false;
}
});
const results = await Promise.allSettled(replicationPromises);
const successful = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
console.log(`Successfully replicated to ${successful}/${replicas.length} replicas`);
} catch (error) {
console.error('Error during replication:', error);
} }
} }
/** /**
* Store a feed mapping in Redis * Synchronize data from a replica to the primary
* Used when primary recovers after being down
*/
async function syncFromReplicaToPrimary(replicaUrl: string): Promise<void> {
if (!primaryClient || primaryClient.status !== "ready" || !replicaClients.has(replicaUrl)) {
console.log("Cannot sync: primary or replica not available");
return;
}
const replicaClient = replicaClients.get(replicaUrl)!;
console.log(`Starting sync from ${getRedisName(replicaUrl)} to Primary...`);
try {
// Get all feed keys using scan to prevent blocking
const feedKeys: string[] = [];
let cursor = '0';
do {
const [nextCursor, keys] = await replicaClient.scan(
cursor,
'MATCH',
'[0-9a-f]*', // Match feed IDs (hex format)
'COUNT',
'100'
);
cursor = nextCursor;
feedKeys.push(...keys);
} while (cursor !== '0');
// Also try scanning for any key pattern that might be a feed ID
cursor = '0';
const additionalKeys: string[] = [];
do {
const [nextCursor, keys] = await replicaClient.scan(
cursor,
'MATCH',
'feed:*', // Try another pattern
'COUNT',
'100'
);
cursor = nextCursor;
additionalKeys.push(...keys);
} while (cursor !== '0');
feedKeys.push(...additionalKeys);
// Get chapter cache keys
const chapterKeys: string[] = [];
cursor = '0';
do {
const [nextCursor, keys] = await replicaClient.scan(
cursor,
'MATCH',
'chapters:*',
'COUNT',
'100'
);
cursor = nextCursor;
chapterKeys.push(...keys);
} while (cursor !== '0');
console.log(`Found ${feedKeys.length} feed keys and ${chapterKeys.length} chapter keys to sync`);
// Sync feed mappings
if (feedKeys.length > 0) {
const feedPipeline = primaryClient.pipeline();
for (const key of feedKeys) {
const value = await replicaClient.get(key);
const ttl = await replicaClient.ttl(key);
if (value && ttl > 0) {
feedPipeline.set(key, value, 'EX', ttl);
}
}
await feedPipeline.exec();
console.log(`Synced ${feedKeys.length} feed mappings from replica to primary`);
}
// Sync chapter cache
if (chapterKeys.length > 0) {
const chapterPipeline = primaryClient.pipeline();
for (const key of chapterKeys) {
const value = await replicaClient.get(key);
const ttl = await replicaClient.ttl(key);
if (value && ttl > 0) {
chapterPipeline.set(key, value, 'EX', ttl);
}
}
await chapterPipeline.exec();
console.log(`Synced ${chapterKeys.length} chapter caches from replica to primary`);
}
lastUsedReplica = null; // Reset after successful sync
console.log("Replica to primary sync completed successfully");
} catch (error) {
console.error("Error during replica to primary sync:", error);
}
}
/**
* Store a feed mapping in Redis with immediate replication
*/ */
export async function storeFeedMapping( export async function storeFeedMapping(
feedId: string, feedId: string,
@ -185,31 +452,54 @@ export async function storeFeedMapping(
created_at: new Date().toISOString() created_at: new Date().toISOString()
}; };
await writeToAllRedis(async (redis) => const stringValue = JSON.stringify(mapping);
redis.set(feedId, JSON.stringify(mapping), "EX", DEFAULT_CACHE_TTL)
); try {
// First write to primary/active client
const redis = await getRedisClient(); // This already handles failover
const activeUrl = redis.options.host || '';
await redis.set(feedId, stringValue, "EX", DEFAULT_CACHE_TTL);
console.log(`Successfully stored mapping for feed ${feedId}`);
// Immediately replicate to other instances
await replicateOperation(activeUrl, feedId, stringValue, DEFAULT_CACHE_TTL);
} catch (error) {
console.error(`Error storing feed mapping for ${feedId}:`, error);
throw error;
}
} }
/** /**
* Retrieve a feed mapping from Redis * Retrieve a feed mapping from Redis with automatic failover
*/ */
export async function getFeedMapping( export async function getFeedMapping(
feedId: string feedId: string
): Promise<FeedMapping | null> { ): Promise<FeedMapping | null> {
try { try {
const redis = await getRedisClient(); const redis = await getRedisClient(); // This already handles failover
const mapping = await redis.get(feedId); const mapping = await redis.get(feedId);
if (!mapping) return null; if (!mapping) return null;
// Reset expiration on access // Reset expiration on access
const activeUrl = redis.options.host || '';
await redis.expire(feedId, DEFAULT_CACHE_TTL); await redis.expire(feedId, DEFAULT_CACHE_TTL);
const data = JSON.parse(mapping); // Also reset TTL on other replicas
return { replicateOperation(activeUrl, feedId, mapping, DEFAULT_CACHE_TTL)
slugs: data.slugs, .catch(err => console.error(`Failed to reset TTL on replicas for ${feedId}:`, err));
lang: data.lang
}; try {
const data = JSON.parse(mapping);
return {
slugs: data.slugs,
lang: data.lang
};
} catch (e) {
console.error(`Error parsing feed mapping for ${feedId}:`, e);
return null;
}
} catch (error) { } catch (error) {
console.error(`Error retrieving feed mapping for ${feedId}:`, error); console.error(`Error retrieving feed mapping for ${feedId}:`, error);
return null; return null;
@ -227,7 +517,7 @@ export async function warmChapterCache(
): Promise<void> { ): Promise<void> {
try { try {
console.log(`Warming chapter cache for ${slugs.length} comics in ${lang}`); console.log(`Warming chapter cache for ${slugs.length} comics in ${lang}`);
const redis = await getRedisClient(); const redis = await getRedisClient(); // This already handles failover
// Validate slugs // Validate slugs
const validSlugs = slugs.filter(slug => const validSlugs = slugs.filter(slug =>
@ -281,5 +571,12 @@ export async function warmChapterCache(
} }
} }
// Initialize connections lazily // Initialize connections eagerly to establish connections as soon as possible
if (!import.meta.env.SSR) {
initializeAllClients()
.then(() => console.log("Redis clients initialized"))
.catch(err => console.error("Failed to initialize Redis clients:", err));
}
// Initialize connections lazily as backup
console.log("Redis will connect on first use"); console.log("Redis will connect on first use");