mirror of
https://github.com/H4zeyaf/Breakallery.git
synced 2026-10-17 12:00:46 +02:00
445 lines
14 KiB
JavaScript
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,
|
|
};
|