mirror of
https://github.com/H4zeyaf/Breakallery.git
synced 2026-10-17 19:00:46 +02:00
369 lines
13 KiB
JavaScript
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;
|