Files

445 lines
14 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const path = require('path');
const os = require('os');
const fs = require('fs');
const crypto = require('crypto');
const { exec } = require('child_process');
const QRCode = require('qrcode');
const { loadConfig, saveConfig } = require('./config');
const FTPManager = require('./ftpClient');
const MediaScanner = require('./scanner');
const ThumbnailService = require('./thumbnailService');
const StreamProxy = require('./streamProxy');
const AutoSyncManager = require('./autoSync');
const { CAPTURES_DIR, THUMBNAILS_DIR, sanitizeFolderName } = require('./paths');
const app = express();
app.use(cors());
app.use(express.json());
// Load Configuration
let config = loadConfig();
// Initialize Services
const ftpManager = new FTPManager(config);
const scanner = new MediaScanner(ftpManager, config);
const thumbnailService = new ThumbnailService(ftpManager);
const streamProxy = new StreamProxy(ftpManager, config);
const autoSync = new AutoSyncManager(ftpManager, scanner, config);
// Start autoSync if enabled
autoSync.start();
// Initial probe on startup if console is reachable
setTimeout(async () => {
try {
const isOnline = await ftpManager.checkOnline(1500);
if (isOnline) {
console.log('[Startup] PS5 is online. Running initial sync...');
await scanner.scan();
} else {
console.log(`[Startup] PS5 at ${config.ps5.host}:${config.ps5.port} is currently offline. Loaded ${scanner.getItems().length} cached captures.`);
}
} catch (err) {
console.warn('[Startup] Initial scan warning:', err.message);
}
}, 800);
// Utility: Find local network IPv4 address
function getLocalIP() {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
// Look for IPv4 and non-internal (e.g. 192.168.x.x, 10.x.x.x)
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return 'localhost';
}
// ---------------- API ROUTES ----------------
// GET /api/status - Live health, PS5 info, network URL, QR code
app.get('/api/status', async (req, res) => {
const localIP = getLocalIP();
const serverPort = config.server.port || 5050;
const networkUrl = `http://${localIP}:${serverPort}`;
let qrCodeDataUrl = '';
try {
qrCodeDataUrl = await QRCode.toDataURL(networkUrl, {
margin: 1,
color: { dark: '#00d2ff', light: '#06080e' },
width: 256,
});
} catch (err) {
console.error('QR code generation error:', err);
}
const isConsoleOnline = ftpManager.isOnline();
const offlineOnly = !isConsoleOnline;
const allItems = scanner.getItems({ offlineOnly: false });
const localItems = scanner.getItems({ offlineOnly: true });
const activeItems = isConsoleOnline ? allItems : localItems;
res.json({
version: '1.3.0',
ps5: {
host: config.ps5.host,
port: config.ps5.port,
connected: isConsoleOnline,
lastScanTime: scanner.lastScanTime,
lastError: scanner.lastError,
},
server: {
port: serverPort,
localIP,
networkUrl,
qrCode: qrCodeDataUrl,
},
stats: {
totalPhotos: activeItems.filter((i) => i.type === 'photo').length,
totalVideos: activeItems.filter((i) => i.type === 'video').length,
totalGames: scanner.getGamesSummary(offlineOnly).length,
archiveTotal: allItems.length,
localTotal: localItems.length,
isOffline: !isConsoleOnline,
},
autoSync: autoSync.getStatus(),
minimizeToTray: !!config.minimizeToTray,
});
});
// POST /api/connect - Test connection to PS5 FTP
app.post('/api/connect', async (req, res) => {
const { host, port } = req.body;
const result = await ftpManager.testConnection(host || config.ps5.host, port || config.ps5.port);
res.json(result);
});
// POST /api/config - Update configuration
app.post('/api/config', (req, res) => {
const newConfig = req.body;
config = {
...config,
...newConfig,
ps5: { ...config.ps5, ...(newConfig.ps5 || {}) },
autoSync: { ...config.autoSync, ...(newConfig.autoSync || {}) },
};
saveConfig(config);
ftpManager.updateConfig(config.ps5);
scanner.config = config;
streamProxy.config = config;
autoSync.config = config;
if (config.autoSync.enabled) {
autoSync.start();
} else {
autoSync.stop();
}
res.json({ success: true, config });
});
// GET /api/media - Get list of captures with filtering
app.get('/api/media', (req, res) => {
const { type, titleId, search, sort = 'newest', offline, showAll } = req.query;
const isConsoleOnline = ftpManager.isOnline();
// When offline, default to showing only media that is fully available locally
const offlineOnly = offline === 'true' || (!isConsoleOnline && showAll !== 'true');
let items = scanner.getItems({ offlineOnly });
if (type && type !== 'all') {
items = items.filter((item) => item.type === type);
}
if (titleId && titleId !== 'all') {
items = items.filter((item) => item.titleId === titleId);
}
if (search) {
const s = search.toLowerCase();
items = items.filter(
(item) =>
item.filename.toLowerCase().includes(s) ||
(item.gameTitle && item.gameTitle.toLowerCase().includes(s)) ||
item.titleId.toLowerCase().includes(s)
);
}
if (sort === 'oldest') {
items = [...items].sort((a, b) => new Date(a.date) - new Date(b.date));
} else {
items = [...items].sort((a, b) => new Date(b.date) - new Date(a.date));
}
res.json({
items,
total: items.length,
offlineOnly,
});
});
// GET /api/games - Get list of all games
app.get('/api/games', (req, res) => {
const isConsoleOnline = ftpManager.isOnline();
const offlineOnly = req.query.offline === 'true' || (!isConsoleOnline && req.query.showAll !== 'true');
const games = scanner.getGamesSummary(offlineOnly);
res.json({ games });
});
// POST /api/scan - Trigger fresh capture scan
app.post('/api/scan', async (req, res) => {
const result = await scanner.scan();
res.json(result);
});
// GET /api/thumbnail - Serve cached or fetched thumbnail/icon
app.get('/api/thumbnail', (req, res) => {
const { path: thumbPath, fallback, id } = req.query;
if (id && id.startsWith('icon_')) {
return thumbnailService.serveCachedIcon(req, res, id);
}
return thumbnailService.serveThumbnail(req, res, thumbPath, fallback);
});
// GET /api/stream - Stream video or photo with HTTP Range support
app.get('/api/stream', (req, res) => {
const { path: remotePath } = req.query;
return streamProxy.streamMedia(req, res, remotePath);
});
// GET /api/download - Direct file download
app.get('/api/download', (req, res) => {
const { path: remotePath, filename } = req.query;
return streamProxy.downloadMedia(req, res, remotePath, filename);
});
// POST /api/save-capture - Save capture to Captures/<Game Title>/<filename> for offline viewing
app.post('/api/save-capture', async (req, res) => {
const { remotePath, gameTitle, filename, titleId, type } = req.body;
if (!remotePath || !filename) {
return res.status(400).json({ success: false, error: 'remotePath and filename are required' });
}
const gameFolder = sanitizeFolderName(gameTitle || titleId);
const targetDir = path.join(CAPTURES_DIR, gameFolder);
const targetPath = path.join(targetDir, filename);
try {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Check if already saved on disk
if (fs.existsSync(targetPath) && fs.statSync(targetPath).size > 0) {
const thumbKey = crypto.createHash('md5').update(remotePath).digest('hex') + '.thumb';
const thumbPath = path.join(THUMBNAILS_DIR, thumbKey);
if (!fs.existsSync(thumbPath) && type === 'photo') {
try { fs.copyFileSync(targetPath, thumbPath); } catch (e) {}
}
return res.json({
success: true,
alreadySaved: true,
localPath: targetPath,
gameFolder,
filename,
message: 'Already saved in Captures',
});
}
// Must be online on PS5 to download fresh file
if (!ftpManager.isOnline()) {
return res.status(503).json({
success: false,
error: 'PS5 is currently offline. Connect your console to download this capture offline.',
});
}
const tempPath = targetPath + '.part';
console.log(`[SaveCapture] Downloading ${filename} to ${targetDir}...`);
await ftpManager.downloadFile(remotePath, tempPath);
if (fs.existsSync(tempPath)) {
fs.renameSync(tempPath, targetPath);
}
// Cache thumbnail locally so it's ready for offline display
const thumbKey = crypto.createHash('md5').update(remotePath).digest('hex') + '.thumb';
const thumbPath = path.join(THUMBNAILS_DIR, thumbKey);
if (!fs.existsSync(thumbPath)) {
if (type === 'photo') {
try { fs.copyFileSync(targetPath, thumbPath); } catch (e) {}
} else if (type === 'video') {
try {
const util = require('util');
const execP = util.promisify(exec);
await execP(`ffmpeg -y -ss 00:00:01 -i "${targetPath}" -frames:v 1 -update 1 -f image2 -vcodec mjpeg "${thumbPath}"`);
} catch (e) {
console.warn('[SaveCapture] Video thumbnail extraction warning:', e.message);
}
}
}
console.log(`[SaveCapture] Successfully saved: ${targetPath}`);
return res.json({
success: true,
alreadySaved: false,
localPath: targetPath,
gameFolder,
filename,
message: 'Saved to Captures',
});
} catch (err) {
console.error('[SaveCapture] Download error:', err.message);
return res.status(500).json({ success: false, error: err.message });
}
});
// POST /api/open-captures-folder - Open Captures folder or specific game folder in Explorer
app.post('/api/open-captures-folder', (req, res) => {
const { gameTitle, filename } = req.body || {};
let target = CAPTURES_DIR;
if (gameTitle) {
const gameFolder = sanitizeFolderName(gameTitle);
const gameDir = path.join(CAPTURES_DIR, gameFolder);
if (filename) {
const filePath = path.join(gameDir, filename);
if (fs.existsSync(filePath)) {
target = filePath;
} else if (fs.existsSync(gameDir)) {
target = gameDir;
}
} else if (fs.existsSync(gameDir)) {
target = gameDir;
}
}
if (!fs.existsSync(target)) {
fs.mkdirSync(CAPTURES_DIR, { recursive: true });
target = CAPTURES_DIR;
}
if (process.platform === 'win32') {
try {
if (fs.statSync(target).isFile()) {
exec(`explorer.exe /select,"${target}"`);
} else {
exec(`explorer.exe "${target}"`);
}
} catch (e) {
exec(`explorer.exe "${CAPTURES_DIR}"`);
}
}
res.json({ success: true, opened: target });
});
// POST /api/sync-trigger - Trigger auto-sync immediately
app.post('/api/sync-trigger', async (req, res) => {
autoSync.runSync();
res.json({ success: true, message: 'Sync triggered' });
});
// GET /api/sync-status - Current auto-sync status
app.get('/api/sync-status', (req, res) => {
res.json(autoSync.getStatus());
});
// Serve frontend build if exists
const clientDist = path.join(__dirname, '../client/dist');
app.use(express.static(clientDist));
// Client fallback route for SPA (Express 5 compatible)
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
const indexPath = path.join(clientDist, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Breakallery Server</title>
<style>
body { font-family: -apple-system, sans-serif; background: #05070c; color: white; padding: 40px; text-align: center; }
h1 { font-size: 32px; color: #00d2ff; }
p { color: #8899a6; font-size: 16px; }
code { background: #111625; padding: 4px 8px; border-radius: 4px; color: #fff; }
</style>
</head>
<body>
<h1>BREAKALLERY</h1>
<p>Server is running on port ${config.server.port || 5050}.</p>
<p>Build the client UI with <code>npm run build</code> in the <code>client</code> folder, or run <code>npm run dev</code> for development.</p>
</body>
</html>
`);
}
});
});
// Start Server
const PORT = config.server.port || 5050;
function startServer() {
return new Promise((resolve, reject) => {
try {
const server = app.listen(PORT, '0.0.0.0', async () => {
const localIP = getLocalIP();
const localUrl = `http://localhost:${PORT}`;
const networkUrl = `http://${localIP}:${PORT}`;
console.log('\n======================================================');
console.log(' ▲ BREAKALLERY - PS5 CAPTURE HUB ✖');
console.log('======================================================');
console.log(` ▸ Local Address: ${localUrl}`);
console.log(` ▸ Network (Phone): ${networkUrl}`);
console.log(` ▸ PS5 Target: ${config.ps5.host}:${config.ps5.port}`);
console.log('------------------------------------------------------');
console.log(' Scan this QR code on your phone to open Breakallery:');
console.log('------------------------------------------------------\n');
try {
const qrTerminal = await QRCode.toString(networkUrl, { type: 'terminal', small: true });
console.log(qrTerminal);
} catch (err) {
// Ignore terminal QR error
}
console.log('======================================================\n');
resolve({ server, port: PORT, localIP, localUrl, networkUrl });
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.log(`[Server] Port ${PORT} already in use, assuming existing server instance.`);
resolve({ server: null, port: PORT, localIP: getLocalIP() });
} else {
reject(err);
}
});
} catch (e) {
reject(e);
}
});
}
if (require.main === module) {
startServer();
}
module.exports = {
app,
startServer,
};