Files

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;