Files

369 lines
13 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { DatabaseSync } = require('node:sqlite');
const { CACHE_DIR, THUMBNAILS_DIR, VIDEOS_CACHE, CAPTURES_DIR, sanitizeFolderName } = require('./paths');
const LIBRARY_CACHE = path.join(CACHE_DIR, 'library.json');
const GAMES_CACHE = path.join(CACHE_DIR, 'games.json');
const DB_LOCAL_PATH = path.join(CACHE_DIR, 'av_content.db');
class MediaScanner {
constructor(ftpManager, config) {
this.ftp = ftpManager;
this.config = config;
this.isScanning = false;
this.lastScanTime = null;
this.lastError = null;
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
if (!fs.existsSync(THUMBNAILS_DIR)) fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
this.library = this.loadCache(LIBRARY_CACHE, { items: [], lastScan: null });
this.games = this.loadCache(GAMES_CACHE, {});
this.lastScanTime = this.library?.lastScan || null;
}
loadCache(filePath, defaultValue) {
try {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
} catch (err) {
console.error(`[Scanner] Failed to read ${filePath}:`, err.message);
}
return defaultValue;
}
saveCache(filePath, data) {
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.error(`[Scanner] Failed to write ${filePath}:`, err.message);
}
}
cleanGameTitle(title, titleId) {
if (!title) return titleId;
// PS5 titles in av_content.db are like "Marvel's Spider-Man 2_20230807200945" or "ASTRO's PLAYROOM_20230712164814"
const lastUnderscore = title.lastIndexOf('_');
if (lastUnderscore > 0) {
const suffix = title.substring(lastUnderscore + 1);
// If suffix is a timestamp (all digits)
if (/^\d+$/.test(suffix)) {
return title.substring(0, lastUnderscore);
}
}
return title;
}
async cacheGameIcon(titleId) {
const localIconPath = path.join(THUMBNAILS_DIR, `icon_${titleId}.png`);
if (fs.existsSync(localIconPath)) {
return `/api/thumbnail?id=icon_${titleId}`;
}
try {
const remoteIconPath = `/user/appmeta/${titleId}/icon0.png`;
await this.ftp.downloadFile(remoteIconPath, localIconPath);
return `/api/thumbnail?id=icon_${titleId}`;
} catch (e) {
// Icon might not be in /user/appmeta
return null;
}
}
async scan() {
if (this.isScanning) {
return { status: 'already_scanning', library: this.library };
}
this.isScanning = true;
this.lastError = null;
try {
// 0. Ensure PS5 is online before attempting scan
const isOnline = await this.ftp.checkOnline(2000);
if (!isOnline) {
throw new Error(`PS5 at ${this.config.ps5.host}:${this.config.ps5.port} is offline or unreachable. Ensure console is powered on with etaHEN/ftps5 active.`);
}
console.log(`[Scanner] Connecting to PS5 at ${this.config.ps5.host}:${this.config.ps5.port} to fetch database...`);
// 1. Fetch PS5's internal media database safely to a temporary file
const tempDbPath = DB_LOCAL_PATH + '.tmp';
try {
await this.ftp.downloadFile('/system_data/priv/mms/av_content.db', tempDbPath);
if (fs.existsSync(tempDbPath)) {
fs.copyFileSync(tempDbPath, DB_LOCAL_PATH);
try { fs.unlinkSync(tempDbPath); } catch {}
}
console.log('[Scanner] Successfully downloaded av_content.db from PS5.');
} catch (dbErr) {
if (fs.existsSync(tempDbPath)) {
try { fs.unlinkSync(tempDbPath); } catch {}
}
throw new Error(`Failed to download database from PS5 (${dbErr.message}). Verify etaHEN FTP server is active.`);
}
if (!fs.existsSync(DB_LOCAL_PATH)) {
throw new Error('Could not retrieve av_content.db from PS5. Verify PS5 IP and FTP server.');
}
// 2. Query captures using native node:sqlite
const db = new DatabaseSync(DB_LOCAL_PATH);
const rows = db.prepare(`
SELECT * FROM tbl_PhotoVideo
WHERE status = 1
ORDER BY created_time DESC
`).all();
console.log(`[Scanner] Discovered ${rows.length} captures in PS5 database.`);
const discoveredItems = [];
const titleIconsMap = {};
for (const row of rows) {
const isVideo = row.content_type === 2;
const ext = path.extname(row.content_path).toLowerCase();
const filename = path.basename(row.content_path);
// Remote FTP paths on PS5 need /user prefix since content_path starts with /av_contents
const remotePath = `/user${row.content_path}`;
const remoteThumb = row.icon_path ? `/user${row.icon_path}` : null;
const titleId = row.appli_title_id || 'UNKNOWN';
const gameTitle = this.cleanGameTitle(row.title, titleId);
// Pre-fetch icons asynchronously for distinct games
if (!titleIconsMap[titleId]) {
titleIconsMap[titleId] = `/api/thumbnail?id=icon_${titleId}`;
// Background attempt to download icon0.png
this.cacheGameIcon(titleId).catch(() => {});
}
discoveredItems.push({
id: String(row.mrid),
filename,
remotePath,
type: isVideo ? 'video' : 'photo',
format: ext.replace('.', '').toUpperCase() || (isVideo ? 'MP4' : 'JPG'),
titleId,
gameTitle,
gameIcon: titleIconsMap[titleId],
size: row.size || 0,
duration: row.duration || 0,
width: row.width || 0,
height: row.height || 0,
date: row.created_time ? new Date(row.created_time.replace(' ', 'T') + 'Z').toISOString() : new Date().toISOString(),
hasThumbnail: true,
thumbnailUrl: `/api/thumbnail?path=${encodeURIComponent(remoteThumb || remotePath)}&fallback=${encodeURIComponent(remotePath)}`,
streamUrl: `/api/stream?path=${encodeURIComponent(remotePath)}`,
downloadUrl: `/api/download?path=${encodeURIComponent(remotePath)}&filename=${encodeURIComponent(filename)}`,
});
}
this.library = {
items: discoveredItems,
lastScan: new Date().toISOString(),
totalPhotos: discoveredItems.filter((i) => i.type === 'photo').length,
totalVideos: discoveredItems.filter((i) => i.type === 'video').length,
};
this.saveCache(LIBRARY_CACHE, this.library);
this.lastScanTime = this.library.lastScan;
console.log(`[Scanner] Scan complete. Loaded ${discoveredItems.length} captures.`);
return { success: true, count: discoveredItems.length, library: this.library };
} catch (err) {
console.error('[Scanner] Scan failed:', err.message);
this.lastError = err.message;
return { success: false, error: err.message, library: this.library };
} finally {
this.isScanning = false;
}
}
buildLocalFilesIndex() {
const localMap = new Map();
// Index CAPTURES_DIR (including game subfolders)
if (fs.existsSync(CAPTURES_DIR)) {
try {
const entries = fs.readdirSync(CAPTURES_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subDir = path.join(CAPTURES_DIR, entry.name);
try {
const files = fs.readdirSync(subDir);
for (const f of files) {
const fullP = path.join(subDir, f);
try {
const stat = fs.statSync(fullP);
if (stat.isFile() && stat.size > 0) {
localMap.set(f.toLowerCase(), fullP);
}
} catch (e) {}
}
} catch (e) {}
} else if (entry.isFile()) {
const fullP = path.join(CAPTURES_DIR, entry.name);
try {
const stat = fs.statSync(fullP);
if (stat.isFile() && stat.size > 0) {
localMap.set(entry.name.toLowerCase(), fullP);
}
} catch (e) {}
}
}
} catch (e) {}
}
// Also index autoSync.downloadPath if defined, different, and exists
if (this.config.autoSync && this.config.autoSync.downloadPath && fs.existsSync(this.config.autoSync.downloadPath)) {
const syncDir = this.config.autoSync.downloadPath;
if (syncDir !== CAPTURES_DIR) {
try {
const entries = fs.readdirSync(syncDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subDir = path.join(syncDir, entry.name);
try {
const files = fs.readdirSync(subDir);
for (const f of files) {
const fullP = path.join(subDir, f);
try {
const stat = fs.statSync(fullP);
if (stat.isFile() && stat.size > 0) {
localMap.set(f.toLowerCase(), fullP);
}
} catch (e) {}
}
} catch (e) {}
} else if (entry.isFile()) {
const fullP = path.join(syncDir, entry.name);
try {
const stat = fs.statSync(fullP);
if (stat.isFile() && stat.size > 0) {
localMap.set(entry.name.toLowerCase(), fullP);
}
} catch (e) {}
}
}
} catch (e) {}
}
}
return localMap;
}
getLocalMediaStatus(item, localMap, thumbSet, videoSet) {
let localMediaPath = null;
// 1. Check in local files index (Captures/<Game>/<file> or Captures/<file>)
const indexed = localMap ? localMap.get(item.filename.toLowerCase()) : null;
if (indexed) {
localMediaPath = indexed;
}
// 2. For video, check cached videos in VIDEOS_CACHE
if (!localMediaPath && item.type === 'video') {
const key = crypto.createHash('md5').update(item.remotePath).digest('hex');
const ext = path.extname(item.remotePath).toLowerCase();
const videoFilename = `${key}${ext}`;
if (videoSet && videoSet.has(videoFilename)) {
const cachedPath = path.join(VIDEOS_CACHE, videoFilename);
try {
if (fs.existsSync(cachedPath) && fs.statSync(cachedPath).size > 0) {
localMediaPath = cachedPath;
}
} catch (e) {}
}
}
// Check thumbnail presence
let hasThumbnail = false;
const key1 = crypto.createHash('md5').update(item.remotePath).digest('hex') + '.thumb';
if (thumbSet && thumbSet.has(key1)) {
hasThumbnail = true;
} else if (item.thumbnailUrl) {
const m = item.thumbnailUrl.match(/path=([^&]+)/);
if (m) {
const remoteThumb = decodeURIComponent(m[1]);
const key2 = crypto.createHash('md5').update(remoteThumb).digest('hex') + '.thumb';
if (thumbSet && thumbSet.has(key2)) {
hasThumbnail = true;
}
}
}
// If it's a photo and the photo media file exists on disk, the photo can serve as its own thumbnail
if (!hasThumbnail && item.type === 'photo' && localMediaPath) {
hasThumbnail = true;
}
// CRITICAL: An item is only viewable offline if BOTH the actual media file AND the thumbnail exist!
const isFullyAvailable = Boolean(localMediaPath && hasThumbnail);
return {
isFullyAvailable,
localMediaPath,
hasThumbnail,
isSavedLocally: Boolean(localMediaPath),
};
}
getItems(options = {}) {
const rawItems = this.library.items || [];
const localMap = this.buildLocalFilesIndex();
const thumbSet = new Set(fs.existsSync(THUMBNAILS_DIR) ? fs.readdirSync(THUMBNAILS_DIR) : []);
const videoSet = new Set(fs.existsSync(VIDEOS_CACHE) ? fs.readdirSync(VIDEOS_CACHE) : []);
const enrichedItems = rawItems.map((item) => {
const status = this.getLocalMediaStatus(item, localMap, thumbSet, videoSet);
return {
...item,
isSavedLocally: status.isSavedLocally,
isFullyAvailable: status.isFullyAvailable,
localMediaPath: status.localMediaPath,
};
});
if (options.offlineOnly) {
// In offline mode: ONLY return captures that actually have their media file AND thumbnail
return enrichedItems.filter((item) => item.isFullyAvailable);
}
return enrichedItems;
}
getGamesSummary(offlineOnly = false) {
const items = this.getItems({ offlineOnly });
const gamesMap = {};
for (const item of items) {
if (!gamesMap[item.titleId]) {
gamesMap[item.titleId] = {
titleId: item.titleId,
titleName: item.gameTitle || item.titleId,
iconUrl: item.gameIcon,
photoCount: 0,
videoCount: 0,
totalSize: 0,
latestDate: item.date,
};
}
const g = gamesMap[item.titleId];
if (item.type === 'photo') g.photoCount++;
if (item.type === 'video') g.videoCount++;
g.totalSize += item.size || 0;
if (new Date(item.date) > new Date(g.latestDate)) {
g.latestDate = item.date;
}
}
return Object.values(gamesMap).sort((a, b) => (b.photoCount + b.videoCount) - (a.photoCount + a.videoCount));
}
}
module.exports = MediaScanner;