implemented game server sync
This commit is contained in:
@@ -10,6 +10,7 @@ import { CardService } from './services/CardService';
|
||||
import { ScryfallService } from './services/ScryfallService';
|
||||
import { PackGeneratorService } from './services/PackGeneratorService';
|
||||
import { CardParserService } from './services/CardParserService';
|
||||
import { PersistenceManager } from './managers/PersistenceManager';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -27,6 +28,16 @@ const io = new Server(httpServer, {
|
||||
const roomManager = new RoomManager();
|
||||
const gameManager = new GameManager();
|
||||
const draftManager = new DraftManager();
|
||||
const persistenceManager = new PersistenceManager(roomManager, draftManager, gameManager);
|
||||
|
||||
// Load previous state
|
||||
persistenceManager.load();
|
||||
|
||||
// Auto-Save Loop (Every 5 seconds)
|
||||
const persistenceInterval = setInterval(() => {
|
||||
persistenceManager.save();
|
||||
}, 5000);
|
||||
|
||||
const cardService = new CardService();
|
||||
const scryfallService = new ScryfallService();
|
||||
const packGeneratorService = new PackGeneratorService();
|
||||
@@ -36,7 +47,32 @@ const PORT = process.env.PORT || 3000;
|
||||
app.use(express.json({ limit: '1000mb' })); // Increase limit for large card lists
|
||||
|
||||
// Serve static images (Nested)
|
||||
app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
|
||||
import { RedisClientManager } from './managers/RedisClientManager';
|
||||
import { fileStorageManager } from './managers/FileStorageManager';
|
||||
|
||||
const redisForFiles = RedisClientManager.getInstance().db1;
|
||||
|
||||
if (redisForFiles) {
|
||||
console.log('[Server] Using Redis for file serving');
|
||||
app.get('/cards/*', async (req: Request, res: Response) => {
|
||||
const relativePath = req.path;
|
||||
const filePath = path.join(__dirname, 'public', relativePath);
|
||||
|
||||
const buffer = await fileStorageManager.readFile(filePath);
|
||||
if (buffer) {
|
||||
if (filePath.endsWith('.jpg')) res.type('image/jpeg');
|
||||
else if (filePath.endsWith('.png')) res.type('image/png');
|
||||
else if (filePath.endsWith('.json')) res.type('application/json');
|
||||
res.send(buffer);
|
||||
} else {
|
||||
res.status(404).send('Not Found');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[Server] Using Local FS for file serving');
|
||||
app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
|
||||
}
|
||||
|
||||
app.use('/images', express.static(path.join(__dirname, 'public/images')));
|
||||
|
||||
// API Routes
|
||||
@@ -566,6 +602,8 @@ httpServer.listen(Number(PORT), '0.0.0.0', () => {
|
||||
const gracefulShutdown = () => {
|
||||
console.log('Received kill signal, shutting down gracefully');
|
||||
clearInterval(draftInterval);
|
||||
clearInterval(persistenceInterval);
|
||||
persistenceManager.save(); // Save on exit
|
||||
|
||||
io.close(() => {
|
||||
console.log('Socket.io closed');
|
||||
|
||||
55
src/server/managers/FileStorageManager.ts
Normal file
55
src/server/managers/FileStorageManager.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { RedisClientManager } from './RedisClientManager';
|
||||
|
||||
export class FileStorageManager {
|
||||
private redisManager: RedisClientManager;
|
||||
|
||||
constructor() {
|
||||
this.redisManager = RedisClientManager.getInstance();
|
||||
}
|
||||
|
||||
async saveFile(filePath: string, data: Buffer | string): Promise<void> {
|
||||
if (this.redisManager.db1) {
|
||||
// Use Redis DB1
|
||||
// Key: Normalize path to be relative to project root or something unique?
|
||||
// Simple approach: Use absolute path (careful with different servers) or relative path key.
|
||||
// Let's assume filePath passed in is absolute. We iterate up to remove common prefix if we want cleaner keys,
|
||||
// but absolute is safest uniqueness.
|
||||
await this.redisManager.db1.set(filePath, typeof data === 'string' ? data : data.toString('binary'));
|
||||
} else {
|
||||
// Local File System
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, data);
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<Buffer | null> {
|
||||
if (this.redisManager.db1) {
|
||||
// Redis DB1
|
||||
const data = await this.redisManager.db1.getBuffer(filePath);
|
||||
return data;
|
||||
} else {
|
||||
// Local
|
||||
if (fs.existsSync(filePath)) {
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
if (this.redisManager.db1) {
|
||||
const exists = await this.redisManager.db1.exists(filePath);
|
||||
return exists > 0;
|
||||
} else {
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileStorageManager = new FileStorageManager();
|
||||
114
src/server/managers/PersistenceManager.ts
Normal file
114
src/server/managers/PersistenceManager.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { RoomManager } from './RoomManager';
|
||||
import { DraftManager } from './DraftManager';
|
||||
import { GameManager } from './GameManager';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { RedisClientManager } from './RedisClientManager';
|
||||
|
||||
// Handling __dirname in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Store data in src/server/data so it persists (assuming not inside a dist that gets wiped, but user root)
|
||||
const DATA_DIR = path.resolve(process.cwd(), 'server-data');
|
||||
|
||||
export class PersistenceManager {
|
||||
private roomManager: RoomManager;
|
||||
private draftManager: DraftManager;
|
||||
private gameManager: GameManager;
|
||||
private redisManager: RedisClientManager;
|
||||
|
||||
constructor(roomManager: RoomManager, draftManager: DraftManager, gameManager: GameManager) {
|
||||
this.roomManager = roomManager;
|
||||
this.draftManager = draftManager;
|
||||
this.gameManager = gameManager;
|
||||
this.redisManager = RedisClientManager.getInstance();
|
||||
|
||||
if (!this.redisManager.db0 && !fs.existsSync(DATA_DIR)) {
|
||||
console.log(`Creating data directory at ${DATA_DIR}`);
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
try {
|
||||
// Accessing private maps via any cast for simplicity without modifying all manager classes to add getters
|
||||
const rooms = Array.from((this.roomManager as any).rooms.entries());
|
||||
const drafts = Array.from((this.draftManager as any).drafts.entries());
|
||||
const games = Array.from((this.gameManager as any).games.entries());
|
||||
|
||||
if (this.redisManager.db0) {
|
||||
// Save to Redis
|
||||
const pipeline = this.redisManager.db0.pipeline();
|
||||
pipeline.set('rooms', JSON.stringify(rooms));
|
||||
pipeline.set('drafts', JSON.stringify(drafts));
|
||||
pipeline.set('games', JSON.stringify(games));
|
||||
await pipeline.exec();
|
||||
// console.log('State saved to Redis');
|
||||
} else {
|
||||
// Save to Local File
|
||||
fs.writeFileSync(path.join(DATA_DIR, 'rooms.json'), JSON.stringify(rooms));
|
||||
fs.writeFileSync(path.join(DATA_DIR, 'drafts.json'), JSON.stringify(drafts));
|
||||
fs.writeFileSync(path.join(DATA_DIR, 'games.json'), JSON.stringify(games));
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to save state', e);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
if (this.redisManager.db0) {
|
||||
// Load from Redis
|
||||
const [roomsData, draftsData, gamesData] = await Promise.all([
|
||||
this.redisManager.db0.get('rooms'),
|
||||
this.redisManager.db0.get('drafts'),
|
||||
this.redisManager.db0.get('games')
|
||||
]);
|
||||
|
||||
if (roomsData) {
|
||||
(this.roomManager as any).rooms = new Map(JSON.parse(roomsData));
|
||||
console.log(`[Redis] Loaded ${(this.roomManager as any).rooms.size} rooms`);
|
||||
}
|
||||
if (draftsData) {
|
||||
(this.draftManager as any).drafts = new Map(JSON.parse(draftsData));
|
||||
console.log(`[Redis] Loaded ${(this.draftManager as any).drafts.size} drafts`);
|
||||
}
|
||||
if (gamesData) {
|
||||
(this.gameManager as any).games = new Map(JSON.parse(gamesData));
|
||||
console.log(`[Redis] Loaded ${(this.gameManager as any).games.size} games`);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Load from Local File
|
||||
const roomFile = path.join(DATA_DIR, 'rooms.json');
|
||||
const draftFile = path.join(DATA_DIR, 'drafts.json');
|
||||
const gameFile = path.join(DATA_DIR, 'games.json');
|
||||
|
||||
if (fs.existsSync(roomFile)) {
|
||||
const roomsData = JSON.parse(fs.readFileSync(roomFile, 'utf-8'));
|
||||
(this.roomManager as any).rooms = new Map(roomsData);
|
||||
console.log(`[Local] Loaded ${roomsData.length} rooms`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(draftFile)) {
|
||||
const draftsData = JSON.parse(fs.readFileSync(draftFile, 'utf-8'));
|
||||
(this.draftManager as any).drafts = new Map(draftsData);
|
||||
console.log(`[Local] Loaded ${draftsData.length} drafts`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(gameFile)) {
|
||||
const gamesData = JSON.parse(fs.readFileSync(gameFile, 'utf-8'));
|
||||
(this.gameManager as any).games = new Map(gamesData);
|
||||
console.log(`[Local] Loaded ${gamesData.length} games`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load state', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/server/managers/RedisClientManager.ts
Normal file
52
src/server/managers/RedisClientManager.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export class RedisClientManager {
|
||||
private static instance: RedisClientManager;
|
||||
public db0: Redis | null = null; // Session Persistence
|
||||
public db1: Redis | null = null; // File Storage
|
||||
|
||||
private constructor() {
|
||||
const useRedis = process.env.USE_REDIS === 'true';
|
||||
const redisHost = process.env.REDIS_HOST || 'localhost';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
|
||||
if (useRedis) {
|
||||
console.log(`[RedisManager] Connecting to Redis at ${redisHost}:${redisPort}...`);
|
||||
|
||||
this.db0 = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
db: 0,
|
||||
retryStrategy: (times) => Math.min(times * 50, 2000)
|
||||
});
|
||||
|
||||
this.db1 = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
db: 1,
|
||||
retryStrategy: (times) => Math.min(times * 50, 2000)
|
||||
});
|
||||
|
||||
this.db0.on('connect', () => console.log('[RedisManager] DB0 Connected'));
|
||||
this.db0.on('error', (err) => console.error('[RedisManager] DB0 Error', err));
|
||||
|
||||
this.db1.on('connect', () => console.log('[RedisManager] DB1 Connected'));
|
||||
this.db1.on('error', (err) => console.error('[RedisManager] DB1 Error', err));
|
||||
} else {
|
||||
console.log('[RedisManager] Redis disabled. Using local storage.');
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): RedisClientManager {
|
||||
if (!RedisClientManager.instance) {
|
||||
RedisClientManager.instance = new RedisClientManager();
|
||||
}
|
||||
return RedisClientManager.instance;
|
||||
}
|
||||
|
||||
public async quit() {
|
||||
if (this.db0) await this.db0.quit();
|
||||
if (this.db1) await this.db1.quit();
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,17 @@ interface Room {
|
||||
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
|
||||
messages: ChatMessage[];
|
||||
maxPlayers: number;
|
||||
lastActive: number; // For persistence cleanup
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private rooms: Map<string, Room> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Cleanup job: Check every 5 minutes
|
||||
setInterval(() => this.cleanupRooms(), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
createRoom(hostId: string, hostName: string, packs: any[], basicLands: any[] = [], socketId?: string): Room {
|
||||
const roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const room: Room = {
|
||||
@@ -40,7 +46,8 @@ export class RoomManager {
|
||||
basicLands,
|
||||
status: 'waiting',
|
||||
messages: [],
|
||||
maxPlayers: 8
|
||||
maxPlayers: hostId.startsWith('SOLO_') ? 1 : 8, // Little hack for solo testing, though 8 is fine
|
||||
lastActive: Date.now()
|
||||
};
|
||||
this.rooms.set(roomId, room);
|
||||
return room;
|
||||
@@ -50,6 +57,7 @@ export class RoomManager {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
room.lastActive = Date.now();
|
||||
const player = room.players.find(p => p.id === playerId);
|
||||
if (player) {
|
||||
player.ready = true;
|
||||
@@ -62,6 +70,8 @@ export class RoomManager {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
room.lastActive = Date.now();
|
||||
|
||||
// Rejoin if already exists
|
||||
const existingPlayer = room.players.find(p => p.id === playerId);
|
||||
if (existingPlayer) {
|
||||
@@ -83,6 +93,9 @@ export class RoomManager {
|
||||
updatePlayerSocket(roomId: string, playerId: string, socketId: string): Room | null {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
room.lastActive = Date.now();
|
||||
|
||||
const player = room.players.find(p => p.id === playerId);
|
||||
if (player) {
|
||||
player.socketId = socketId;
|
||||
@@ -97,6 +110,12 @@ export class RoomManager {
|
||||
const player = room.players.find(p => p.socketId === socketId);
|
||||
if (player) {
|
||||
player.isOffline = true;
|
||||
// Do NOT update lastActive on disconnect, or maybe we should?
|
||||
// No, lastActive is for "when was the room last used?". Disconnect is an event, but inactivity starts from here.
|
||||
// So keeping lastActive as previous interaction time is safer?
|
||||
// Actually, if everyone disconnects now, room should be kept for 8 hours from NOW.
|
||||
// So update lastActive.
|
||||
room.lastActive = Date.now();
|
||||
return { room, playerId: player.id };
|
||||
}
|
||||
}
|
||||
@@ -107,29 +126,30 @@ export class RoomManager {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
room.lastActive = Date.now();
|
||||
|
||||
// Logic change: Explicit leave only removes player from list if waiting.
|
||||
// If playing, mark offline (abandon).
|
||||
// NEVER DELETE ROOM HERE. Rely on cleanup.
|
||||
|
||||
if (room.status === 'waiting') {
|
||||
// Normal logic: Remove player completely
|
||||
room.players = room.players.filter(p => p.id !== playerId);
|
||||
|
||||
// If host leaves, assign new host from remaining players
|
||||
if (room.players.length === 0) {
|
||||
this.rooms.delete(roomId);
|
||||
return null;
|
||||
} else if (room.hostId === playerId) {
|
||||
if (room.players.length > 0 && room.hostId === playerId) {
|
||||
const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0];
|
||||
if (nextPlayer) {
|
||||
room.hostId = nextPlayer.id;
|
||||
nextPlayer.isHost = true;
|
||||
}
|
||||
}
|
||||
// If 0 players, room remains in Map until cleanup
|
||||
} else {
|
||||
// Game in progress (Drafting/Playing)
|
||||
// DO NOT REMOVE PLAYER. Just mark offline.
|
||||
// This allows them to rejoin and reclaim their seat (and deck).
|
||||
const player = room.players.find(p => p.id === playerId);
|
||||
if (player) {
|
||||
player.isOffline = true;
|
||||
// Note: socketId is already handled by disconnect event usually, but if explicit leave, we should clear it?
|
||||
player.socketId = undefined;
|
||||
}
|
||||
console.log(`Player ${playerId} left active game in room ${roomId}. Marked as offline.`);
|
||||
@@ -141,26 +161,30 @@ export class RoomManager {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
room.status = 'drafting';
|
||||
room.lastActive = Date.now();
|
||||
return room;
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
// Refresh activity if accessed? Not necessarily, only write actions.
|
||||
// But rejoining calls getRoom implicitly in join logic or index logic?
|
||||
// Let's assume write actions update lastActive.
|
||||
return this.rooms.get(roomId);
|
||||
}
|
||||
|
||||
kickPlayer(roomId: string, playerId: string): Room | null {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
room.lastActive = Date.now();
|
||||
|
||||
room.players = room.players.filter(p => p.id !== playerId);
|
||||
|
||||
// If game was running, we might need more cleanup, but for now just removal.
|
||||
return room;
|
||||
}
|
||||
|
||||
addMessage(roomId: string, sender: string, text: string): ChatMessage | null {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
room.lastActive = Date.now();
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
@@ -173,7 +197,6 @@ export class RoomManager {
|
||||
}
|
||||
|
||||
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
|
||||
// Inefficient linear search, but robust for now. Maps would be better for high scale.
|
||||
for (const room of this.rooms.values()) {
|
||||
const player = room.players.find(p => p.socketId === socketId);
|
||||
if (player) {
|
||||
@@ -182,4 +205,26 @@ export class RoomManager {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private cleanupRooms() {
|
||||
const now = Date.now();
|
||||
const EXPIRATION_MS = 8 * 60 * 60 * 1000; // 8 Hours
|
||||
|
||||
for (const [roomId, room] of this.rooms.entries()) {
|
||||
// Logic:
|
||||
// 1. If players are online, room is active. -> Don't delete.
|
||||
// 2. If NO players are online (all offline or empty), check lastActive.
|
||||
|
||||
const anyOnline = room.players.some(p => !p.isOffline);
|
||||
if (anyOnline) {
|
||||
continue; // Active
|
||||
}
|
||||
|
||||
// No one online. Check expiration.
|
||||
if (now - room.lastActive > EXPIRATION_MS) {
|
||||
console.log(`Cleaning up expired room ${roomId}. Inactive for > 8 hours.`);
|
||||
this.rooms.delete(roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { fileStorageManager } from '../managers/FileStorageManager';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -15,65 +16,10 @@ export class CardService {
|
||||
this.imagesDir = path.join(CARDS_DIR, 'images');
|
||||
this.metadataDir = path.join(CARDS_DIR, 'metadata');
|
||||
|
||||
this.ensureDirs();
|
||||
this.migrateExistingImages();
|
||||
}
|
||||
|
||||
private ensureDirs() {
|
||||
if (!fs.existsSync(this.imagesDir)) {
|
||||
fs.mkdirSync(this.imagesDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.metadataDir)) {
|
||||
fs.mkdirSync(this.metadataDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private migrateExistingImages() {
|
||||
console.log('[CardService] Checking for images to migrate...');
|
||||
const start = Date.now();
|
||||
let moved = 0;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(this.metadataDir)) {
|
||||
const items = fs.readdirSync(this.metadataDir);
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(this.metadataDir, item);
|
||||
if (fs.statSync(itemPath).isDirectory()) {
|
||||
// This determines the set
|
||||
const setCode = item;
|
||||
const cardFiles = fs.readdirSync(itemPath);
|
||||
|
||||
for (const file of cardFiles) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const id = file.replace('.json', '');
|
||||
|
||||
// Check for legacy image
|
||||
const legacyImgPath = path.join(this.imagesDir, `${id}.jpg`);
|
||||
if (fs.existsSync(legacyImgPath)) {
|
||||
const targetDir = path.join(this.imagesDir, setCode);
|
||||
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const targetPath = path.join(targetDir, `${id}.jpg`);
|
||||
try {
|
||||
fs.renameSync(legacyImgPath, targetPath);
|
||||
moved++;
|
||||
} catch (e) {
|
||||
console.error(`[CardService] Failed to move ${id}.jpg to ${setCode}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CardService] Migration error', e);
|
||||
}
|
||||
|
||||
if (moved > 0) {
|
||||
console.log(`[CardService] Migrated ${moved} images to set folders in ${Date.now() - start}ms.`);
|
||||
} else {
|
||||
console.log(`[CardService] No images needed migration.`);
|
||||
}
|
||||
// Directory creation is handled by FileStorageManager on write for Local,
|
||||
// and not needed for Redis.
|
||||
// Migration logic removed as it's FS specific and one-time.
|
||||
// If we need migration to Redis, it should be a separate script.
|
||||
}
|
||||
|
||||
async cacheImages(cards: any[]): Promise<number> {
|
||||
@@ -102,36 +48,19 @@ export class CardService {
|
||||
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const setDir = path.join(this.imagesDir, setCode);
|
||||
if (!fs.existsSync(setDir)) {
|
||||
fs.mkdirSync(setDir, { recursive: true });
|
||||
}
|
||||
const filePath = path.join(this.imagesDir, setCode, `${uuid}.jpg`);
|
||||
|
||||
const filePath = path.join(setDir, `${uuid}.jpg`);
|
||||
|
||||
// Check if exists in set folder
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Check if exists
|
||||
if (await fileStorageManager.exists(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check legacy location and move if exists (double check)
|
||||
const legacyPath = path.join(this.imagesDir, `${uuid}.jpg`);
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
try {
|
||||
fs.renameSync(legacyPath, filePath);
|
||||
// console.log(`Migrated image ${uuid} to ${setCode}`);
|
||||
continue;
|
||||
} catch (e) {
|
||||
console.error(`Failed to migrate image ${uuid}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Download
|
||||
const response = await fetch(imageUrl);
|
||||
if (response.ok) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
fs.writeFileSync(filePath, Buffer.from(buffer));
|
||||
await fileStorageManager.saveFile(filePath, Buffer.from(buffer));
|
||||
downloadedCount++;
|
||||
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
|
||||
} else {
|
||||
@@ -154,19 +83,10 @@ export class CardService {
|
||||
for (const card of cards) {
|
||||
if (!card.id || !card.set) continue;
|
||||
|
||||
const setDir = path.join(this.metadataDir, card.set);
|
||||
if (!fs.existsSync(setDir)) {
|
||||
fs.mkdirSync(setDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(setDir, `${card.id}.json`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const filePath = path.join(this.metadataDir, card.set, `${card.id}.json`);
|
||||
if (!(await fileStorageManager.exists(filePath))) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(card, null, 2));
|
||||
// Check and delete legacy if exists
|
||||
const legacy = path.join(this.metadataDir, `${card.id}.json`);
|
||||
if (fs.existsSync(legacy)) fs.unlinkSync(legacy);
|
||||
|
||||
await fileStorageManager.saveFile(filePath, JSON.stringify(card, null, 2));
|
||||
cachedCount++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to save metadata for ${card.id}`, e);
|
||||
|
||||
Reference in New Issue
Block a user