mirror of
https://github.com/H4zeyaf/Breakallery.git
synced 2026-10-17 14:00:43 +02:00
296 lines
10 KiB
JavaScript
296 lines
10 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const mime = require('mime-types');
|
|
const crypto = require('crypto');
|
|
const { exec } = require('child_process');
|
|
const util = require('util');
|
|
const execPromise = util.promisify(exec);
|
|
const { VIDEOS_CACHE, THUMBNAILS_DIR, CAPTURES_DIR, sanitizeFolderName } = require('./paths');
|
|
|
|
class StreamProxy {
|
|
constructor(ftpManager, config) {
|
|
this.ftp = ftpManager;
|
|
this.config = config;
|
|
this.pendingDownloads = new Map();
|
|
}
|
|
|
|
findLocalMediaPath(remotePath) {
|
|
if (!remotePath) return null;
|
|
const filename = path.basename(remotePath);
|
|
|
|
// 1. Search in CAPTURES_DIR (check root and all game subfolders)
|
|
if (fs.existsSync(CAPTURES_DIR)) {
|
|
try {
|
|
const directRoot = path.join(CAPTURES_DIR, filename);
|
|
if (fs.existsSync(directRoot) && fs.statSync(directRoot).size > 0) {
|
|
return directRoot;
|
|
}
|
|
|
|
const entries = fs.readdirSync(CAPTURES_DIR, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const subFile = path.join(CAPTURES_DIR, entry.name, filename);
|
|
if (fs.existsSync(subFile) && fs.statSync(subFile).size > 0) {
|
|
return subFile;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// 2. Check AutoSync directory
|
|
if (this.config.autoSync && this.config.autoSync.downloadPath) {
|
|
const syncDir = this.config.autoSync.downloadPath;
|
|
if (syncDir !== CAPTURES_DIR && fs.existsSync(syncDir)) {
|
|
try {
|
|
const relative = remotePath.replace(/^\/user\/av_contents\//, '');
|
|
const localRel = path.join(syncDir, relative);
|
|
if (fs.existsSync(localRel) && fs.statSync(localRel).size > 0) {
|
|
return localRel;
|
|
}
|
|
|
|
const entries = fs.readdirSync(syncDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const subFile = path.join(syncDir, entry.name, filename);
|
|
if (fs.existsSync(subFile) && fs.statSync(subFile).size > 0) {
|
|
return subFile;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
// 3. For video, check VIDEOS_CACHE
|
|
const ext = path.extname(remotePath).toLowerCase();
|
|
if (['.mp4', '.webm', '.mov', '.mkv'].includes(ext)) {
|
|
const key = this.getVideoCacheKey(remotePath);
|
|
const cached = path.join(VIDEOS_CACHE, `${key}${ext}`);
|
|
if (fs.existsSync(cached) && fs.statSync(cached).size > 0) {
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
getVideoCacheKey(remotePath) {
|
|
return crypto.createHash('md5').update(remotePath).digest('hex');
|
|
}
|
|
|
|
async normalizeVideo(inputPath, outputPath) {
|
|
try {
|
|
// Fast remux: -c copy takes ~100ms and resets container PTS/start_time to 0.000000
|
|
// Fixing the PS5 continuous DVR session timestamp bug
|
|
await execPromise(`ffmpeg -y -fflags +genpts -i "${inputPath}" -c copy "${outputPath}"`);
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[StreamProxy] ffmpeg remux warning (falling back to raw):', e.message);
|
|
try {
|
|
fs.copyFileSync(inputPath, outputPath);
|
|
return true;
|
|
} catch (copyErr) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async ensureCachedVideo(remotePath, ext) {
|
|
const key = this.getVideoCacheKey(remotePath);
|
|
const finalPath = path.join(VIDEOS_CACHE, `${key}${ext}`);
|
|
|
|
if (fs.existsSync(finalPath) && fs.statSync(finalPath).size > 0) {
|
|
return finalPath;
|
|
}
|
|
|
|
if (this.pendingDownloads.has(key)) {
|
|
return await this.pendingDownloads.get(key);
|
|
}
|
|
|
|
const downloadPromise = (async () => {
|
|
const tempPath = path.join(VIDEOS_CACHE, `temp_${key}${ext}`);
|
|
try {
|
|
console.log(`[StreamProxy] Caching video for instant scrubbing: ${path.basename(remotePath)}...`);
|
|
await this.ftp.downloadFile(remotePath, tempPath);
|
|
|
|
// Normalize timestamps
|
|
await this.normalizeVideo(tempPath, finalPath);
|
|
|
|
try {
|
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
} catch (e) {}
|
|
|
|
console.log(`[StreamProxy] Video ready and normalized: ${path.basename(remotePath)}`);
|
|
return finalPath;
|
|
} catch (err) {
|
|
console.error('[StreamProxy] Failed to cache video:', err.message);
|
|
try {
|
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
} catch (e) {}
|
|
throw err;
|
|
} finally {
|
|
this.pendingDownloads.delete(key);
|
|
}
|
|
})();
|
|
|
|
this.pendingDownloads.set(key, downloadPromise);
|
|
return await downloadPromise;
|
|
}
|
|
|
|
async streamMedia(req, res, remotePath) {
|
|
if (!remotePath) {
|
|
return res.status(400).send('Path parameter is required');
|
|
}
|
|
|
|
const ext = path.extname(remotePath).toLowerCase();
|
|
const isVideo = ['.mp4', '.webm', '.mov', '.mkv'].includes(ext);
|
|
const mimeType = mime.lookup(remotePath) || (isVideo ? 'video/mp4' : 'application/octet-stream');
|
|
|
|
// 1. Check if available locally on disk (Captures folder, autoSync, or cache)
|
|
const localMedia = this.findLocalMediaPath(remotePath);
|
|
if (localMedia) {
|
|
return this.streamLocalFile(req, res, localMedia, mimeType);
|
|
}
|
|
|
|
// 2. For video files: ensure cached & normalized so scrubbing is instant and duration is exact
|
|
if (isVideo) {
|
|
// If console is offline and video is not cached, return 404 immediately
|
|
if (!this.ftp.isOnline()) {
|
|
return res.status(404).send('Video not cached locally for offline playback');
|
|
}
|
|
|
|
try {
|
|
const cachedPath = await this.ensureCachedVideo(remotePath, ext);
|
|
if (cachedPath && fs.existsSync(cachedPath)) {
|
|
return this.streamLocalFile(req, res, cachedPath, mimeType);
|
|
}
|
|
} catch (cacheErr) {
|
|
console.warn('[StreamProxy] Falling back to direct FTP stream for video:', cacheErr.message);
|
|
}
|
|
}
|
|
|
|
// 3. For photos: if console is offline, check thumbnail cache
|
|
if (!isVideo && !this.ftp.isOnline()) {
|
|
const key = crypto.createHash('md5').update(remotePath).digest('hex');
|
|
const thumbPath = path.join(THUMBNAILS_DIR, `${key}.thumb`);
|
|
if (fs.existsSync(thumbPath)) {
|
|
return this.streamLocalFile(req, res, thumbPath, 'image/jpeg');
|
|
}
|
|
return res.status(404).send('Photo not available offline');
|
|
}
|
|
|
|
// If console is offline and reached here, do not attempt FTP
|
|
if (!this.ftp.isOnline()) {
|
|
return res.status(404).send('Console is offline and file is not stored locally');
|
|
}
|
|
|
|
// 4. Direct FTP stream for photos or if video caching fails (only if online)
|
|
try {
|
|
const fileSize = await this.ftp.size(remotePath);
|
|
const range = req.headers.range;
|
|
|
|
if (range) {
|
|
const parts = range.replace(/bytes=/, '').split('-');
|
|
const start = parseInt(parts[0], 10);
|
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
const chunkSize = end - start + 1;
|
|
|
|
res.status(206);
|
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
res.setHeader('Content-Length', chunkSize);
|
|
res.setHeader('Content-Type', mimeType);
|
|
|
|
await this.ftp.streamTo(remotePath, res, start);
|
|
} else {
|
|
res.status(200);
|
|
res.setHeader('Content-Length', fileSize);
|
|
res.setHeader('Content-Type', mimeType);
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
|
|
await this.ftp.streamTo(remotePath, res, 0);
|
|
}
|
|
} catch (err) {
|
|
console.error('[StreamProxy] Error streaming remote file:', err.message);
|
|
if (!res.headersSent) {
|
|
res.status(500).send(`Streaming failed: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
streamLocalFile(req, res, localPath, mimeType) {
|
|
try {
|
|
const stat = fs.statSync(localPath);
|
|
const fileSize = stat.size;
|
|
const range = req.headers.range;
|
|
|
|
if (range) {
|
|
const parts = range.replace(/bytes=/, '').split('-');
|
|
const start = parseInt(parts[0], 10);
|
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
const chunkSize = end - start + 1;
|
|
|
|
res.status(206);
|
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
res.setHeader('Content-Length', chunkSize);
|
|
res.setHeader('Content-Type', mimeType);
|
|
|
|
const fileStream = fs.createReadStream(localPath, { start, end });
|
|
req.on('close', () => fileStream.destroy());
|
|
fileStream.pipe(res);
|
|
} else {
|
|
res.status(200);
|
|
res.setHeader('Content-Length', fileSize);
|
|
res.setHeader('Content-Type', mimeType);
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
|
|
const fileStream = fs.createReadStream(localPath);
|
|
req.on('close', () => fileStream.destroy());
|
|
fileStream.pipe(res);
|
|
}
|
|
} catch (err) {
|
|
console.error('[StreamProxy] Error serving local file:', err.message);
|
|
if (!res.headersSent) {
|
|
res.status(500).send('Local stream error');
|
|
}
|
|
}
|
|
}
|
|
|
|
async downloadMedia(req, res, remotePath, filename) {
|
|
if (!remotePath) {
|
|
return res.status(400).send('Path parameter is required');
|
|
}
|
|
|
|
const downloadName = filename || path.basename(remotePath);
|
|
const mimeType = mime.lookup(remotePath) || 'application/octet-stream';
|
|
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(downloadName)}"`);
|
|
res.setHeader('Content-Type', mimeType);
|
|
|
|
// 1. Check if saved locally (Captures folder, autoSync, or cache)
|
|
const localMedia = this.findLocalMediaPath(remotePath);
|
|
if (localMedia && fs.existsSync(localMedia)) {
|
|
return fs.createReadStream(localMedia).pipe(res);
|
|
}
|
|
|
|
if (!this.ftp.isOnline()) {
|
|
return res.status(404).send('Console is offline and file is not stored locally');
|
|
}
|
|
|
|
try {
|
|
const fileSize = await this.ftp.size(remotePath);
|
|
res.setHeader('Content-Length', fileSize);
|
|
await this.ftp.streamTo(remotePath, res, 0);
|
|
} catch (err) {
|
|
console.error('[StreamProxy] Download error:', err.message);
|
|
if (!res.headersSent) {
|
|
res.status(500).send(`Download failed: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = StreamProxy;
|