Files

173 lines
5.7 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const mime = require('mime-types');
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
const { THUMBNAILS_DIR, CAPTURES_DIR, VIDEOS_CACHE } = require('./paths');
class ThumbnailService {
constructor(ftpManager) {
this.ftp = ftpManager;
if (!fs.existsSync(THUMBNAILS_DIR)) {
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
}
}
getCacheKey(remotePath) {
return crypto.createHash('md5').update(remotePath).digest('hex');
}
findLocalMediaFile(remotePath, fallbackPath) {
const filenames = [
remotePath ? path.basename(remotePath) : null,
fallbackPath ? path.basename(fallbackPath) : null,
].filter(Boolean);
if (fs.existsSync(CAPTURES_DIR)) {
for (const fname of filenames) {
const rootPath = path.join(CAPTURES_DIR, fname);
if (fs.existsSync(rootPath)) return rootPath;
try {
const entries = fs.readdirSync(CAPTURES_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subPath = path.join(CAPTURES_DIR, entry.name, fname);
if (fs.existsSync(subPath)) return subPath;
}
}
} catch (e) {}
}
}
// Check video cache
for (const p of [remotePath, fallbackPath]) {
if (p) {
const key = this.getCacheKey(p);
const ext = path.extname(p).toLowerCase();
const vPath = path.join(VIDEOS_CACHE, `${key}${ext}`);
if (fs.existsSync(vPath)) return vPath;
}
}
return null;
}
async serveThumbnail(req, res, remotePath, fallbackPath) {
if (!remotePath && !fallbackPath) {
return res.status(400).send('Path parameter is required');
}
const primaryKey = this.getCacheKey(remotePath || fallbackPath);
const cachedFilePath = path.join(THUMBNAILS_DIR, `${primaryKey}.thumb`);
// 1. Check local cache (primary and fallback)
if (fs.existsSync(cachedFilePath)) {
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
return fs.createReadStream(cachedFilePath).pipe(res);
}
if (fallbackPath) {
const fallbackKey = this.getCacheKey(fallbackPath);
const fallbackFilePath = path.join(THUMBNAILS_DIR, `${fallbackKey}.thumb`);
if (fs.existsSync(fallbackFilePath)) {
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
return fs.createReadStream(fallbackFilePath).pipe(res);
}
}
// 2. Check if local media file exists on disk
const localMedia = this.findLocalMediaFile(remotePath, fallbackPath);
if (localMedia) {
const ext = path.extname(localMedia).toLowerCase();
const isPhoto = ['.jpg', '.jpeg', '.png', '.webp'].includes(ext);
if (isPhoto) {
res.setHeader('Content-Type', mime.lookup(localMedia) || 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
return fs.createReadStream(localMedia).pipe(res);
}
// Video: extract thumbnail frame
try {
await execPromise(`ffmpeg -y -ss 00:00:01 -i "${localMedia}" -frames:v 1 -update 1 -f image2 -vcodec mjpeg "${cachedFilePath}"`);
if (fs.existsSync(cachedFilePath)) {
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
return fs.createReadStream(cachedFilePath).pipe(res);
}
} catch (ffErr) {
console.warn('[Thumbnail] ffmpeg frame extraction warning:', ffErr.message);
}
}
// If console is offline and thumbnail is not in cache, return 404 immediately without hanging on FTP
if (!this.ftp.isOnline()) {
return res.status(404).send('Thumbnail not available offline');
}
// 2. Fetch from PS5 FTP (only if console is online)
try {
let buffer = null;
// Try designated thumbnail path first
if (remotePath) {
try {
buffer = await this.ftp.getBuffer(remotePath);
} catch (e) {
// Thumbnail path might not exist, proceed to fallback
}
}
// Try fallback full photo if thumbnail not present
if ((!buffer || buffer.length === 0) && fallbackPath) {
try {
buffer = await this.ftp.getBuffer(fallbackPath);
} catch (e) {
// Both failed
}
}
if (!buffer || buffer.length === 0) {
return res.status(404).send('Thumbnail not found');
}
// Cache to disk
try {
fs.writeFileSync(cachedFilePath, buffer);
} catch (err) {
console.error('[Thumbnail] Cache write error:', err.message);
}
const mimeType = mime.lookup(remotePath || fallbackPath) || 'image/jpeg';
res.setHeader('Content-Type', mimeType);
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
return res.end(buffer);
} catch (err) {
console.error('[Thumbnail] Fetch error:', err.message);
return res.status(500).send('Error retrieving thumbnail');
}
}
serveCachedIcon(req, res, iconId) {
// iconId is like 'icon_PPSA01234'
const safeName = path.basename(iconId);
const iconPath = path.join(THUMBNAILS_DIR, `${safeName}.png`);
if (fs.existsSync(iconPath)) {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
return fs.createReadStream(iconPath).pipe(res);
}
return res.status(404).send('Icon not found');
}
}
module.exports = ThumbnailService;