Support resumable game folder uploads and remove DLC declarations
This commit is contained in:
@@ -6,6 +6,7 @@ import { config } from '../config.js';
|
||||
import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hosts/index.js';
|
||||
import { ensureGameFileSchema } from './schema.js';
|
||||
import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js';
|
||||
import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js';
|
||||
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js';
|
||||
|
||||
function database() { const handle = db(); ensureGameFileSchema(handle); return handle; }
|
||||
@@ -17,10 +18,10 @@ const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id
|
||||
function versionFromRow(row: VersionRow): GameLibraryVersion {
|
||||
const files = JSON.parse(row.manifest) as GameFileEntry[];
|
||||
return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version,
|
||||
description: row.description, dlc: JSON.parse(row.dlc), state: row.state, sizeBytes: row.size_bytes,
|
||||
files, fileCount: files.length, manifestSha256: row.manifest_sha256, createdAt: row.created_at,
|
||||
publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id,
|
||||
sourceVolume: row.source_volume, error: row.error, operationId: row.operation_id,
|
||||
description: row.description, state: row.state, sizeBytes: row.size_bytes,
|
||||
files, fileCount: files.length || (database().prepare('SELECT COUNT(*) AS count FROM game_file_upload_entries WHERE version_id=?').get(row.id) as { count: number }).count, manifestSha256: row.manifest_sha256, createdAt: row.created_at,
|
||||
publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id || null,
|
||||
sourceVolume: row.source_volume || null, error: row.error, operationId: row.operation_id,
|
||||
caches: (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) };
|
||||
}
|
||||
export function getLibraryVersion(id: string): GameLibraryVersion | null {
|
||||
@@ -60,6 +61,112 @@ function textField(input: unknown, label: string, max: number, required = true):
|
||||
if (typeof input !== 'string' || input.length > max || (required && !input.trim())) throw new Error(`Provide a valid ${label}.`);
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
/** Register a folder on the controller; no host or Docker volume is needed. */
|
||||
export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion {
|
||||
const game = input.game;
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
|
||||
const name = textField(input.name, 'library name', 160);
|
||||
const description = textField(input.description ?? '', 'description', 4000, false);
|
||||
const version = textField(input.version, 'version label', 120);
|
||||
const files = validateUploadPlan(input.files);
|
||||
const handle = database();
|
||||
const id = randomUUID(); const now = new Date().toISOString();
|
||||
handle.transaction(() => {
|
||||
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined;
|
||||
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
|
||||
if (!library) {
|
||||
library = { id: randomUUID(), game };
|
||||
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
|
||||
}
|
||||
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
|
||||
// Keep legacy non-null columns for existing databases and published versions.
|
||||
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume)
|
||||
VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, library.id, version, description, files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id));
|
||||
const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)');
|
||||
for (const file of files) insert.run(id, file.path, file.size, file.lastModified);
|
||||
})();
|
||||
return getLibraryVersion(id)!;
|
||||
}
|
||||
|
||||
type UploadEntry = { path: string; size: number; lastModified: number };
|
||||
function requireBrowserUpload(id: string): GameLibraryVersion {
|
||||
const version = getLibraryVersion(id);
|
||||
if (!version || version.sourceHostId !== null || !database().prepare('SELECT 1 FROM game_file_upload_entries WHERE version_id=? LIMIT 1').get(id)) {
|
||||
throw Object.assign(new Error('Folder upload not found.'), { status: 404 });
|
||||
}
|
||||
return version;
|
||||
}
|
||||
function uploadEntries(id: string): UploadEntry[] {
|
||||
return database().prepare('SELECT path,size,last_modified AS lastModified FROM game_file_upload_entries WHERE version_id=? ORDER BY path').all(id) as UploadEntry[];
|
||||
}
|
||||
async function uploadStorageRoot(version: GameLibraryVersion): Promise<string> {
|
||||
// Recover publication interrupted after the atomic rename but before its DB commit.
|
||||
try { if ((await stat(version.storageLocation)).isDirectory()) return version.storageLocation; }
|
||||
catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause; }
|
||||
return stagingRoot(version.id);
|
||||
}
|
||||
export async function browserUploadStatus(id: string) {
|
||||
const version = requireBrowserUpload(id);
|
||||
const status = version.state === 'ready' ? 'published' as const : publishing.has(id) ? 'verifying' as const : version.state === 'failed' ? 'failed' as const : 'pending' as const;
|
||||
const root = await uploadStorageRoot(version);
|
||||
const entries = uploadEntries(id);
|
||||
const files = [];
|
||||
// Avoid opening thousands of files concurrently for a large folder.
|
||||
for (const file of entries) files.push({ ...file, ...(status === 'published' ? { receivedBytes: file.size, complete: true } : await getUploadFileProgress(root, file)) });
|
||||
const { files: _manifest, ...summary } = version;
|
||||
return { status, chunkSize: MAX_UPLOAD_CHUNK_BYTES, files, version: summary, error: version.error };
|
||||
}
|
||||
export async function uploadBrowserFile(id: string, path: string, chunk: { offset: number; size: number; sha256: string }, body: ReadableStream<Uint8Array> | null) {
|
||||
validateGamePath(path);
|
||||
const checkWritable = () => {
|
||||
const version = requireBrowserUpload(id);
|
||||
if (version.state === 'ready' || version.manifestSha256 || publishing.has(id)) throw Object.assign(new Error('This upload is no longer writable.'), { status: 409 });
|
||||
};
|
||||
checkWritable();
|
||||
const file = database().prepare('SELECT path,size,last_modified AS lastModified FROM game_file_upload_entries WHERE version_id=? AND path=?').get(id, path) as UploadEntry | undefined;
|
||||
if (!file) throw new Error('File is not listed in this upload.');
|
||||
const progress = await receiveUploadChunk(stagingRoot(id), file, chunk, body, checkWritable);
|
||||
database().prepare("UPDATE game_file_versions SET state='importing',error=NULL WHERE id=? AND state IN ('importing','failed')").run(id);
|
||||
return progress;
|
||||
}
|
||||
export async function publishBrowserUpload(id: string): Promise<GameLibraryVersion> {
|
||||
const version = requireBrowserUpload(id);
|
||||
if (version.state === 'ready') return version;
|
||||
const existing = publishing.get(id); if (existing) return existing;
|
||||
const task = withUploadLock(stagingRoot(id), async () => {
|
||||
const root = await uploadStorageRoot(version);
|
||||
const files = await hashUploadedFiles(root, uploadEntries(id));
|
||||
const checksum = manifestChecksum(files);
|
||||
if (version.manifestSha256 && checksum !== version.manifestSha256) throw new Error('Stored files no longer match the verified manifest.');
|
||||
database().transaction(() => {
|
||||
database().prepare("UPDATE game_file_versions SET state='importing',manifest=?,manifest_sha256=?,error=NULL WHERE id=? AND state IN ('importing','failed')").run(JSON.stringify(files), checksum, id);
|
||||
database().prepare('DELETE FROM game_file_entries WHERE version_id=?').run(id);
|
||||
const insert = database().prepare('INSERT INTO game_file_entries(version_id,path,size,sha256) VALUES(?,?,?,?)');
|
||||
for (const file of files) insert.run(id, file.path, file.size, file.sha256);
|
||||
})();
|
||||
if (root !== version.storageLocation) {
|
||||
await mkdir(dirname(version.storageLocation), { recursive: true });
|
||||
await rename(root, version.storageLocation);
|
||||
}
|
||||
database().prepare("UPDATE game_file_versions SET state='ready',published_at=?,error=NULL WHERE id=? AND state='importing'").run(new Date().toISOString(), id);
|
||||
return getLibraryVersion(id)!;
|
||||
});
|
||||
publishing.set(id, task);
|
||||
try { return await task; } finally { publishing.delete(id); }
|
||||
}
|
||||
export async function requestBrowserPublication(id: string) {
|
||||
const version = requireBrowserUpload(id);
|
||||
if (version.state !== 'ready' && !publishing.has(id)) {
|
||||
database().prepare("UPDATE game_file_versions SET state='importing',error=NULL WHERE id=? AND state IN ('importing','failed')").run(id);
|
||||
void publishBrowserUpload(id).catch(cause => {
|
||||
database().prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=? AND state='importing'")
|
||||
.run((cause instanceof Error ? cause.message : 'Folder publication failed.').slice(0, 2000), id);
|
||||
});
|
||||
}
|
||||
return browserUploadStatus(id);
|
||||
}
|
||||
|
||||
export function createLibraryImport(input: Record<string, unknown>): GameLibraryVersion {
|
||||
const game = input.game;
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
|
||||
@@ -69,9 +176,6 @@ export function createLibraryImport(input: Record<string, unknown>): GameLibrary
|
||||
const name = textField(input.name, 'library name', 160);
|
||||
const description = textField(input.description ?? '', 'description', 4000, false);
|
||||
const version = textField(input.version, 'version label', 120);
|
||||
const dlcInput = input.dlc ?? [];
|
||||
if (!Array.isArray(dlcInput) || dlcInput.length > 64) throw new Error('DLC must be a list of content names.');
|
||||
const dlc = dlcInput.map(value => textField(value, 'DLC name', 120));
|
||||
const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files';
|
||||
const handle = database();
|
||||
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined;
|
||||
@@ -84,7 +188,7 @@ export function createLibraryImport(input: Record<string, unknown>): GameLibrary
|
||||
}
|
||||
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
|
||||
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
|
||||
VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, JSON.stringify(dlc), now, join(gameFilesRoot(), game, id), hostId, sourceVolume);
|
||||
VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume);
|
||||
})();
|
||||
try {
|
||||
const operation = queueHostOperation(hostId, 'library.import', {
|
||||
|
||||
Reference in New Issue
Block a user