252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const database = require('../../src/database');
|
|
const {
|
|
cacheLocalArtwork,
|
|
discoverGameId,
|
|
scanLocalLibraries,
|
|
gameIdFrom,
|
|
} = require('../../src/services/local-library-scanner');
|
|
|
|
function normaliseLocationPath(filePath) {
|
|
return String(filePath || '').replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
}
|
|
|
|
function deriveRelativePath(filePath) {
|
|
const normalized = normaliseLocationPath(filePath);
|
|
const trimmed = normalized.replace(/^\/+/, '');
|
|
const parts = trimmed.split('/').filter(Boolean);
|
|
return parts.length > 1 ? parts.slice(1).join('/') : trimmed || 'legacy-root';
|
|
}
|
|
|
|
function withTempDatabase(fn) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-library-test-'));
|
|
database.configure({
|
|
dataDir: root,
|
|
logger: { info() {}, warn() {}, error() {} },
|
|
});
|
|
return database.initialize()
|
|
.then(() => fn())
|
|
.finally(() => {
|
|
database.close();
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
}
|
|
|
|
function seedLocalCopy({ root, gameId, copyType = 'folder', version = '1.00', size = 0 }) {
|
|
const relativePath = deriveRelativePath(path.join(root, gameId));
|
|
const trimmedRoot = normaliseLocationPath(root);
|
|
const rootPart = trimmedRoot.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
const fingerprint = `legacy:${rootPart[0] || 'unknown-device'}`;
|
|
const volumeKey = rootPart.length > 1 ? `legacy:${rootPart.slice(0, 2).join('/')}` : `legacy:${rootPart[0] || 'unknown'}`;
|
|
const content = String(gameId).toUpperCase();
|
|
|
|
database.runMutatingSqlWithParams(`
|
|
INSERT INTO titles (platform, title_id, content_id, name, sort_name, region, metadata_source)
|
|
VALUES (@platform, @gameId, @content, @name, @name, 'unknown', 'test')
|
|
`, {
|
|
platform: content.startsWith('CUSA') ? 'ps4' : 'ps5',
|
|
gameId: content,
|
|
content: `${content}-CONTENT`,
|
|
name: content,
|
|
});
|
|
|
|
const titleId = Number(database.runQuery('SELECT last_insert_rowid() AS id')[0]?.id || 0);
|
|
|
|
database.runMutatingSqlWithParams(`
|
|
INSERT INTO storage_devices (scope, stable_fingerprint, display_name, device_type, availability_state)
|
|
VALUES ('server', @fingerprint, @displayName, 'server', 'available')
|
|
`, { fingerprint, displayName: root });
|
|
const deviceId = Number(database.runQuery('SELECT last_insert_rowid() AS id')[0]?.id || 0);
|
|
|
|
database.runMutatingSqlWithParams(`
|
|
INSERT INTO storage_volumes (device_id, stable_volume_key, mount_path, can_copy, can_play, can_install, can_mount, availability_state)
|
|
VALUES (@deviceId, @volumeKey, @mountPath, 1, 1, 1, 1, 'available')
|
|
`, {
|
|
deviceId,
|
|
volumeKey,
|
|
mountPath: root,
|
|
});
|
|
const volumeId = Number(database.runQuery('SELECT last_insert_rowid() AS id')[0]?.id || 0);
|
|
|
|
database.runMutatingSqlWithParams(`
|
|
INSERT INTO local_copies (title_id, volume_id, relative_path, copy_type, base_version, version_source, size_bytes, last_seen_at, availability_state)
|
|
VALUES (@titleId, @volumeId, @relativePath, @copyType, @baseVersion, 'scan', @sizeBytes, 0, 'available')
|
|
`, {
|
|
titleId,
|
|
volumeId,
|
|
relativePath,
|
|
copyType,
|
|
baseVersion: version,
|
|
sizeBytes: size,
|
|
});
|
|
}
|
|
|
|
test('local library scan writes local_copies and records scan coverage', () => {
|
|
return withTempDatabase(async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-library-'));
|
|
fs.mkdirSync(path.join(root, 'Game.CUSA03041'));
|
|
fs.mkdirSync(path.join(root, 'Game.PS5-PPSA12345'));
|
|
fs.mkdirSync(path.join(root, 'Release-without-title-id'));
|
|
try {
|
|
const result = await scanLocalLibraries({ roots: [root], db: database });
|
|
const copies = Number(database.runQuery('SELECT COUNT(*) AS c FROM local_copies')[0]?.c || 0);
|
|
assert.deepEqual([...new Set(result.found)].sort(), ['CUSA03041', 'PPSA12345']);
|
|
assert.equal(result.total, 2);
|
|
assert.equal(result.added, 2);
|
|
assert.equal(result.skippedWithoutGameId, 1);
|
|
assert.equal(result.scan_run_id > 0, true);
|
|
assert.equal(copies, 2);
|
|
assert.equal(database.runQuery('SELECT COUNT(*) AS c FROM scan_root_coverage')[0]?.c || 0, 1);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
test('nested platform folders register separate PS5 JSON-identified backups', () => withTempDatabase(async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-nested-json-'));
|
|
try {
|
|
for (const [name, id] of [['Museum backup', 'PPSA12345'], ['Second backup', 'PPSA54321']]) {
|
|
const directory = path.join(root, 'ps5', name, 'sce_sys');
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
fs.writeFileSync(path.join(directory, 'param.json'), JSON.stringify({ titleId: id, contentId: `UP0001-${id}_00-CHIMERAFPKG00001`, contentVersion: '01.000.000', localizedParameters: { enUS: { titleName: name } } }));
|
|
}
|
|
const result = await scanLocalLibraries({ roots: [root], db: database });
|
|
assert.deepEqual(result.found.sort(), ['PPSA12345', 'PPSA54321']);
|
|
const copies = database.runQuery('SELECT relative_path FROM local_copies');
|
|
assert.equal(copies.length, 2);
|
|
assert.ok(copies.every(copy => /backup$/.test(copy.relative_path)));
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
}));
|
|
|
|
test('local library discovery reads IDs from nested archive and NFO names', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-library-nested-'));
|
|
const archiveRelease = path.join(root, 'Marvel Spider-Man Remastered');
|
|
const nfoRelease = path.join(root, 'Disney Epic Mickey Rebrushed');
|
|
fs.mkdirSync(path.join(archiveRelease, 'archives'), { recursive: true });
|
|
fs.mkdirSync(nfoRelease, { recursive: true });
|
|
fs.writeFileSync(path.join(archiveRelease, 'archives', 'release-PPSA01467.part01.rar'), 'fixture');
|
|
fs.writeFileSync(path.join(nfoRelease, 'release.nfo'), 'Title ID: PPSA15657\n');
|
|
try {
|
|
assert.equal(await discoverGameId(archiveRelease, true), 'PPSA01467');
|
|
assert.equal(await discoverGameId(nfoRelease, true), 'PPSA15657');
|
|
assert.equal(await discoverGameId(path.join(root, 'Unknown release'), false), '');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('recursive scan preserves NFO and multipart release wrappers as single copies', () => withTempDatabase(async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-wrapper-scan-'));
|
|
try {
|
|
const nfo = path.join(root, 'ps5', 'Nfo release');
|
|
const archive = path.join(root, 'ps5', 'Archive release');
|
|
fs.mkdirSync(nfo, { recursive: true });
|
|
fs.mkdirSync(path.join(archive, 'archives'), { recursive: true });
|
|
fs.writeFileSync(path.join(nfo, 'release.nfo'), 'Title ID: PPSA12345');
|
|
for (const part of ['01', '02']) fs.writeFileSync(path.join(archive, 'archives', `PPSA54321.part${part}.rar`), 'fixture');
|
|
const result = await scanLocalLibraries({ roots: [root], db: database });
|
|
assert.deepEqual(result.found.sort(), ['PPSA12345', 'PPSA54321']);
|
|
const copies = database.runQuery('SELECT relative_path FROM local_copies');
|
|
assert.equal(copies.length, 2);
|
|
assert.ok(copies.every(copy => /release$/.test(copy.relative_path)));
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
}));
|
|
|
|
test('linked subtree is not followed and incomplete coverage preserves prior copies', () => withTempDatabase(async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-partial-scan-'));
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-outside-scan-'));
|
|
try {
|
|
seedLocalCopy({ root, gameId: 'PPSA11111' });
|
|
fs.mkdirSync(path.join(root, 'PPSA22222'));
|
|
fs.mkdirSync(path.join(outside, 'PPSA33333'));
|
|
fs.symlinkSync(outside, path.join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
const result = await scanLocalLibraries({ roots: [root], db: database });
|
|
assert.deepEqual(result.found, ['PPSA22222']);
|
|
assert.equal(database.runQuery('SELECT coverage_state FROM scan_root_coverage')[0].coverage_state, 'partial');
|
|
assert.equal(database.runQuery("SELECT availability_state FROM local_copies WHERE relative_path LIKE '%PPSA11111'")[0].availability_state, 'available');
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); }
|
|
}));
|
|
|
|
test('conflicting folder and PS5 metadata identities are not combined', () => withTempDatabase(async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-identity-scan-'));
|
|
try {
|
|
const metadata = path.join(root, 'PPSA11111', 'sce_sys');
|
|
fs.mkdirSync(metadata, { recursive: true });
|
|
fs.writeFileSync(path.join(metadata, 'param.json'), JSON.stringify({ titleId: 'PPSA22222' }));
|
|
const result = await scanLocalLibraries({ roots: [root], db: database });
|
|
assert.deepEqual(result.found, []);
|
|
assert.equal(database.runQuery('SELECT coverage_state FROM scan_root_coverage')[0].coverage_state, 'partial');
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
}));
|
|
|
|
test('local library scanner detects IDs from bounded package metadata', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-library-containers-'));
|
|
const pkgRelease = path.join(root, 'Uncharted 4 release');
|
|
const zipRelease = path.join(root, 'Destroy All Humans 2 release');
|
|
const ignoredRelease = path.join(root, 'Unknown binary release');
|
|
fs.mkdirSync(pkgRelease);
|
|
fs.mkdirSync(zipRelease);
|
|
fs.mkdirSync(ignoredRelease);
|
|
const pkg = Buffer.alloc(256 * 1024);
|
|
pkg.write('EP9000-CUSA00917_00-UNCHARTED4000000', 4096, 'ascii');
|
|
fs.writeFileSync(path.join(pkgRelease, 'game.pkg'), pkg);
|
|
const zip = Buffer.alloc(512 * 1024);
|
|
zip.write('PPSA05398_00-DAH2REPROBED0000.pkg', zip.length - 8192, 'ascii');
|
|
fs.writeFileSync(path.join(zipRelease, 'release.zip'), zip);
|
|
fs.writeFileSync(path.join(ignoredRelease, 'unrelated.bin'), 'CUSA99999');
|
|
try {
|
|
assert.equal(gameIdFrom(await discoverGameId(pkgRelease, true)), 'CUSA00917');
|
|
assert.equal(gameIdFrom(await discoverGameId(zipRelease, true)), 'PPSA05398');
|
|
assert.equal(gameIdFrom(await discoverGameId(ignoredRelease, true)), '');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('cacheLocalArtwork validates and stores local cover images', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-artwork-'));
|
|
const artworkDir = path.join(root, 'sce_sys');
|
|
const cacheDir = path.join(root, 'cache');
|
|
fs.mkdirSync(artworkDir, { recursive: true });
|
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
|
fs.writeFileSync(path.join(artworkDir, 'icon0.png'), png);
|
|
try {
|
|
const result = await cacheLocalArtwork({ metadata: { artwork_dir: artworkDir }, gameId: 'CUSA03041', cacheDir });
|
|
assert.equal(result.icon, '/pork-cache/game-metadata/CUSA03041/local-icon.png');
|
|
assert.deepEqual(fs.readFileSync(path.join(cacheDir, 'game-metadata', 'CUSA03041', 'local-icon.png')), png);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('scheduled scan reuses previous size when refresh is disabled', () => {
|
|
return withTempDatabase(() => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-library-size-'));
|
|
const gamePath = path.join(root, 'CUSA03041');
|
|
fs.mkdirSync(gamePath);
|
|
fs.writeFileSync(path.join(gamePath, 'large.bin'), Buffer.alloc(256));
|
|
const expected = 12345;
|
|
seedLocalCopy({ root, gameId: 'CUSA03041', size: expected });
|
|
|
|
return scanLocalLibraries({ roots: [root], db: database }).then(() => {
|
|
const relativePath = deriveRelativePath(gamePath);
|
|
const row = database.runQuery(`
|
|
SELECT lc.size_bytes
|
|
FROM local_copies lc
|
|
JOIN titles t ON t.id = lc.title_id
|
|
WHERE t.title_id = 'CUSA03041' AND lc.relative_path = ${JSON.stringify(relativePath)}
|
|
`)[0];
|
|
assert.equal(row.size_bytes, expected);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
});
|
|
});
|