Files
electric-cherry/src/routes/game-files/+page.svelte
T
2026-10-10 00:19:48 -04:00

884 lines
36 KiB
Svelte

<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { gameTitle } from '$ui/server-labels.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { beforeNavigate } from '$app/navigation';
import type { GameLibrary, GameLibraryVersion } from '$lib/game-files.js';
import { formatGameBytes } from '$lib/game-files.js';
import type { Host } from '$lib/hosts.js';
import { selectGameFolder, type SelectedGameFolder } from '$ui/game-folder.js';
import { pollVisible } from '$ui/poll.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import GameFileHosts from '$ui/components/GameFileHosts.svelte';
type OverviewLibrary = Omit<GameLibrary, 'versions'> & {
versions: Omit<GameLibraryVersion, 'files'>[];
};
interface UploadFile {
path: string;
size: number;
lastModified: number;
receivedBytes: number;
complete: boolean;
}
interface UploadStatus {
status: 'pending' | 'verifying' | 'published' | 'failed';
chunkSize: number;
files: UploadFile[];
error?: string | null;
version: { id: string; state: string };
}
type Phase = 'idle' | 'preparing' | 'uploading' | 'paused' | 'verifying' | 'published';
let libraries = $state<OverviewLibrary[]>([]);
let hosts = $state<Host[]>([]);
let loaded = $state(false);
let error = $state('');
let notice = $state('');
let busy = $state(false);
let showUpload = $state(false);
let game = $state<'t5' | 't6'>('t5');
let targets = $state<Record<string, string>>({});
let folder = $state.raw<SelectedGameFolder | null>(null);
let uploadStatus = $state.raw<UploadStatus | null>(null);
let uploadId = $state('');
let uploadBusy = $state(false);
let uploadError = $state('');
let phase = $state<Phase>('idle');
let currentPath = $state('');
let pauseRequested = $state(false);
let acceptedBytes = $state(0);
let sendingBytes = $state(0);
let completedFiles = $state(0);
let folderInput = $state<HTMLInputElement>();
const activeRequests = new Map<XMLHttpRequest, number>();
let disposed = false;
let failedTransfer = false;
const updateSending = () => {
sendingBytes = [...activeRequests.values()].reduce((a, b) => a + b, 0);
};
const abortUploads = () => {
for (const request of activeRequests.keys()) request.abort();
};
const canManage = $derived($page.data.user?.role === 'admin');
const onlineHosts = $derived(
hosts.filter((host) => host.connected && host.credentialActive && host.mode !== 'disabled')
);
const totalBytes = $derived(
folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0
);
const totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0);
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading'));
const hostName = (id: string | null) =>
id ? (hosts.find((host) => host.id === id)?.name ?? id) : 'Browser upload';
const gameName = (game: 't5' | 't6') => (game === 't5' ? 'Black Ops I' : 'Black Ops II');
async function responseBody(response: Response) {
let body;
try {
body = await response.json();
} catch {
throw new Error(
'The controller returned an unexpected response. Sign in again if your session expired, then resume the upload.'
);
}
if (!response.ok)
throw new Error(body.error ?? 'The controller could not complete this request.');
return body;
}
async function load() {
try {
const body = await responseBody(await fetch('/api/game-files'));
libraries = body.libraries;
hosts = body.hosts;
loaded = true;
} catch (cause) {
error = (cause as Error).message;
}
}
async function download(versionId: string) {
busy = true;
error = '';
notice = '';
try {
await responseBody(
await fetch(`/api/game-files/${versionId}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostId: targets[versionId] })
})
);
notice = 'Game files queued for download. Progress will update below.';
await load();
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
function resetUpload() {
folder = null;
uploadStatus = null;
uploadId = '';
phase = 'idle';
uploadError = '';
currentPath = '';
acceptedBytes = 0;
sendingBytes = 0;
completedFiles = 0;
pauseRequested = false;
if (folderInput) folderInput.value = '';
}
async function newUpload(library: OverviewLibrary) {
resetUpload();
showUpload = true;
game = library.game;
await tick();
document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
}
function chooseFolder(event: Event) {
uploadError = '';
folder = null;
const input = event.currentTarget as HTMLInputElement;
if (!input.files?.length) return;
try {
const selected = selectGameFolder(Array.from(input.files));
if (uploadStatus) checkSelection(selected, uploadStatus);
folder = selected;
} catch (cause) {
uploadError = (cause as Error).message;
input.value = '';
}
}
function checkSelection(selected: SelectedGameFolder, status: UploadStatus) {
const originals = new Map(status.files.map((file) => [file.path, file]));
if (
selected.files.length !== originals.size ||
selected.files.some(({ path, file }) => {
const original = originals.get(path);
return (
!original ||
original.size !== file.size ||
original.lastModified !== file.lastModified
);
})
)
throw new Error(
'This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Start a new upload for changed game files.'
);
}
function applyStatus(status: UploadStatus) {
uploadStatus = status;
acceptedBytes = status.files.reduce((total, file) => total + file.receivedBytes, 0);
completedFiles = status.files.filter((file) => file.complete).length;
sendingBytes = 0;
}
async function getStatus(): Promise<UploadStatus> {
const status = (await responseBody(
await fetch(`/api/game-files/${uploadId}/upload`)
)) as UploadStatus;
applyStatus(status);
return status;
}
async function followVerification(initial: UploadStatus) {
let status = initial;
phase = 'verifying';
currentPath = '';
while (!disposed && status.status === 'verifying') {
await new Promise((resolve) => setTimeout(resolve, 1500));
if (disposed) return;
status = await getStatus();
}
if (disposed) return;
if (status.status !== 'published')
throw new Error(
status.error || 'Verification did not finish. Retry this upload to continue.'
);
phase = 'published';
folder = null;
notice = `${gameTitle(game, true)} game files are ready. Active hosts will download and verify the latest files automatically.`;
if (folderInput) folderInput.value = '';
await load();
}
async function resumeUpload(
library: OverviewLibrary,
item: OverviewLibrary['versions'][number]
) {
resetUpload();
showUpload = true;
uploadId = item.id;
game = library.game;
uploadBusy = true;
try {
const status = await getStatus();
if (status.status === 'verifying' || status.status === 'published')
await followVerification(status);
else {
phase = 'paused';
uploadError = status.error ?? '';
}
} catch (cause) {
uploadError = (cause as Error).message;
phase = 'paused';
} finally {
uploadBusy = false;
}
await tick();
document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
}
function pauseUpload() {
pauseRequested = true;
abortUploads();
}
async function sendChunk(
path: string,
file: File,
offset: number,
length: number
): Promise<number> {
const chunk = file.slice(offset, offset + length);
const hash = await crypto.subtle.digest('SHA-256', await chunk.arrayBuffer());
if (pauseRequested || disposed || failedTransfer) throw new Error('Upload paused.');
const sha256 = [...new Uint8Array(hash)]
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
activeRequests.set(request, 0);
request.open(
'PUT',
`/api/game-files/${uploadId}/upload/files/${path.split('/').map(encodeURIComponent).join('/')}`
);
request.setRequestHeader('Content-Type', 'application/octet-stream');
request.setRequestHeader('X-Upload-Offset', String(offset));
request.setRequestHeader('X-Upload-Length', String(length));
request.setRequestHeader('X-Content-SHA256', sha256);
request.timeout = 300_000;
request.upload.onprogress = (event) => {
activeRequests.set(request, Math.min(length, event.loaded));
updateSending();
};
request.onload = () => {
activeRequests.delete(request);
updateSending();
try {
const body = JSON.parse(request.responseText);
if (request.status < 200 || request.status >= 300)
throw new Error(body.error ?? `Upload failed (${request.status}).`);
if (body.receivedBytes !== offset + length)
throw new Error(
'The upload position changed. Resume to check the saved progress.'
);
resolve(body.receivedBytes);
} catch (cause) {
reject(cause);
}
};
request.onerror = () => {
activeRequests.delete(request);
updateSending();
reject(
new Error(
'The connection was interrupted. Resume to continue from the last saved chunk.'
)
);
};
request.ontimeout = () => {
activeRequests.delete(request);
updateSending();
reject(
new Error('This chunk timed out. Resume to check saved progress and retry.')
);
};
request.onabort = () => {
activeRequests.delete(request);
updateSending();
reject(new Error('Upload paused.'));
};
request.send(chunk);
});
}
async function startUpload(event: SubmitEvent) {
event.preventDefault();
if (uploadBusy || !folder) return;
if (!globalThis.crypto?.subtle) {
uploadError =
'Folder uploads require a secure connection. Open this controller over HTTPS or localhost.';
return;
}
uploadBusy = true;
pauseRequested = false;
failedTransfer = false;
uploadError = '';
notice = '';
phase = 'preparing';
try {
if (!uploadId) {
const result = await responseBody(
await fetch('/api/game-files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
game,
files: folder.files.map(({ path, file }) => ({
path,
size: file.size,
lastModified: file.lastModified
}))
})
})
);
uploadId = result.version.id;
}
const status = await getStatus();
if (status.status === 'verifying' || status.status === 'published') {
await followVerification(status);
return;
}
checkSelection(folder, status);
const localFiles = new Map(folder.files.map((item) => [item.path, item.file]));
const chunkSize = Math.min(8_388_608, Math.max(1, status.chunkSize));
phase = 'uploading';
let nextFile = 0;
let failure: unknown;
const transfer = async () => {
while (!pauseRequested && !disposed && !failure) {
const remote = status.files[nextFile++];
if (!remote) return;
if (remote.complete) continue;
currentPath = remote.path;
const file = localFiles.get(remote.path)!;
try {
do {
if (pauseRequested || disposed || failure) return;
const length = Math.min(chunkSize, remote.size - remote.receivedBytes);
remote.receivedBytes = await sendChunk(
remote.path,
file,
remote.receivedBytes,
length
);
acceptedBytes += length;
if (remote.receivedBytes === remote.size) {
remote.complete = true;
completedFiles++;
}
} while (!remote.complete);
} catch (cause) {
failure ??= cause;
failedTransfer = true;
abortUploads();
}
}
};
await Promise.all(Array.from({ length: Math.min(3, status.files.length) }, transfer));
if (failure) throw failure;
if (pauseRequested || disposed) {
phase = 'paused';
return;
}
phase = 'verifying';
currentPath = '';
const verification = (await responseBody(
await fetch(`/api/game-files/${uploadId}/upload/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
)) as UploadStatus;
applyStatus(verification);
await followVerification(verification);
} catch (cause) {
if (!disposed) {
uploadError = pauseRequested ? '' : (cause as Error).message;
phase = 'paused';
}
} finally {
uploadBusy = false;
sendingBytes = 0;
if (!disposed) void load();
}
}
beforeNavigate((navigation) => {
if (!transferring) return;
if (
navigation.type === 'leave' ||
!window.confirm(
'This upload is still transferring files. Leave this page? You can resume later by selecting the original unchanged folder.'
)
)
navigation.cancel();
});
onMount(() => {
const stop = pollVisible(load, 5000);
return () => {
disposed = true;
pauseRequested = true;
abortUploads();
stop();
};
});
</script>
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Game Files" subtitle="Verified game files and host readiness." />
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if !canManage}<p class="muted">
Administrators upload game files. Active hosts prepare verified copies automatically.
</p>{/if}
{#if canManage && showUpload}
<section class="card" id="game-upload">
<div class="heading">
<h2>
{phase === 'published'
? `${gameTitle(game, true)} files uploaded`
: uploadId
? `Continue ${gameTitle(game, true)} upload`
: `Upload ${gameTitle(game, true)} game folder`}
</h2>
<Button
size="sm"
variant="ghost"
disabled={uploadBusy}
onclick={() => (showUpload = false)}>Close</Button
>
</div>
<p>
Select your complete {gameName(game)} game folder, including its installed DLC. Once verified,
this upload becomes the latest files and hosts download it automatically. Running servers
keep using their existing files.
</p>
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}>
{#if phase !== 'verifying' && phase !== 'published'}
<label class="folder-picker"
>{uploadId ? 'Select the original unchanged folder' : 'Game folder'}<input
bind:this={folderInput}
type="file"
webkitdirectory
multiple
onchange={chooseFolder}
disabled={uploadBusy}
aria-describedby="folder-help"
/></label
>
<p class="muted" id="folder-help">
{uploadId
? 'To resume, file names, sizes, and modification dates must match the original upload.'
: 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}
</p>
{/if}
{#if folder}<div class="folder-summary">
<strong>{folder.name}</strong><span
>{folder.files.length.toLocaleString()} files · {formatGameBytes(
folder.size
)}</span
>
<details>
<summary>Preview folder paths</summary>
<ul class="path-list">
{#each folder.files.slice(0, 8) as file}<li>{file.path}</li>{/each}
</ul>
{#if folder.files.length > 8}<p class="muted">
And {(folder.files.length - 8).toLocaleString()} more files.
</p>{/if}
</details>
</div>{/if}
{#if phase !== 'idle'}<div class="upload-progress" aria-live="polite">
<div class="heading">
<strong
>{phase === 'preparing'
? 'Preparing upload…'
: phase === 'uploading'
? 'Uploading game files'
: phase === 'verifying'
? 'Verifying files…'
: phase === 'published'
? 'Ready · host downloads are automatic'
: 'Upload paused'}</strong
><span
>{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files</span
>
</div>
<progress
max={totalBytes || Math.max(1, totalFiles)}
value={totalBytes ? progressBytes : completedFiles}
aria-label="Game folder upload progress"
></progress>
<p>
{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or
transferring
</p>
{#if currentPath}<p class="current-file">{currentPath}</p>{/if}
{#if phase === 'verifying'}<p class="muted">
The controller checks every file before making this upload available
to hosts. Verification continues if you leave this page.
</p>{:else if phase === 'paused'}<p class="muted">
Saved chunks are kept on the controller. Resume this upload to check
the saved progress and retry.
</p>{/if}
</div>{/if}
<div class="upload-actions">
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}
>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button
>
{:else if phase !== 'verifying' && phase !== 'published'}<Button
type="submit"
variant="primary"
disabled={uploadBusy || !folder}
>{uploadId
? 'Resume upload'
: `Upload ${gameTitle(game, true)} files`}</Button
>{/if}
{#if phase === 'idle' || phase === 'uploading'}<span class="muted"
>Keep this page open while files transfer. Interrupted uploads can be
resumed below.</span
>{/if}
</div>
</form>
</section>
{/if}
{#if !loaded}<p role="status">Loading game files…</p>{/if}
{#each libraries as library (library.game)}
{@const latest = library.versions.find((item) => item.id === library.latestVersionId)}
{@const unfinished = library.versions.filter((item) => item.state !== 'ready')}
{@const history = library.versions.filter(
(item) => item.state === 'ready' && item.id !== library.latestVersionId
)}
<section class="card">
<div class="heading">
<div>
<h2>{gameName(library.game)}</h2>
{#if latest}<p class="muted">
{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()}
files
</p>{/if}
</div>
{#if canManage}<Button
size="sm"
disabled={uploadBusy}
onclick={() => newUpload(library)}
>{latest
? `Update ${gameTitle(library.game, true)} files`
: `Upload ${gameTitle(library.game, true)} files`}</Button
>{/if}
</div>
{#if latest}
<div class="heading">
<span class="muted">Latest verified upload</span><Badge
variant={latest.caches.some((cache) => cache.status === 'failed')
? 'danger'
: latest.caches.length &&
latest.caches.every((cache) => cache.status === 'ready')
? 'online'
: 'neutral'}
>{latest.caches.length
? `${latest.caches.filter((cache) => cache.status === 'ready').length} / ${latest.caches.length} host copies ready`
: 'No hosts'}</Badge
>
</div>
<GameFileHosts caches={latest.caches} {hosts} latest />
{:else}
<p class="muted">
Upload the complete {gameName(library.game)} game folder once. Hosts will download
and verify it automatically as part of setup.
</p>
{/if}
{#if unfinished.length}
<div class="uploads">
<h3>Unfinished uploads</h3>
{#each unfinished as item (item.id)}
{@const browserUpload = !item.sourceHostId}
<article class="upload-entry">
<div class="heading">
<p>
Started <RelativeTime value={item.createdAt} /> · {formatGameBytes(
item.sizeBytes
)} · {item.fileCount.toLocaleString()} files
</p>
<Badge variant={item.state === 'failed' ? 'danger' : 'info'}
>{item.state === 'failed'
? 'Failed'
: browserUpload
? item.id === uploadId && phase === 'verifying'
? 'Verifying'
: 'Uploading'
: 'Importing'}</Badge
>
</div>
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
{#if browserUpload}
<p class="muted">
Resume with the original unchanged folder. The latest files stay
available while this upload completes.
</p>
{#if canManage}<div>
<Button
size="sm"
disabled={uploadBusy}
onclick={() => resumeUpload(library, item)}
>Resume upload</Button
>
</div>{/if}
{:else if item.state === 'importing'}<p class="muted">
The existing host import continues independently. It is
reconciled when the host reconnects.
</p>{/if}
</article>
{/each}
</div>
{/if}
{#if latest || history.length}<details class="library-details">
<summary>Details</summary>
<div class="detail-content">
{#if latest}<p class="muted">
Uploaded <RelativeTime
value={latest.publishedAt ?? latest.createdAt}
/>
</p>
{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
{#if history.length}
<details class="history">
<summary>Previous uploads ({history.length})</summary>
<p class="muted">
Old host copies are removed once no instances use them.
Controller files are removed after all host copies are gone;
upload history is kept.
</p>
{#each history as item (item.id)}
<article class="upload-entry">
<h3>
Uploaded <RelativeTime
value={item.publishedAt ?? item.createdAt}
/>
</h3>
{#if item.filesRemoved}<p class="muted">
Files removed from controller · upload history
retained
</p>{:else if item.filesRemoving}<p class="muted">
Removing unused controller files · upload history
retained
</p>{/if}
<p>
{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()}
files
</p>
{@render uploadDetails(item)}
<details>
<summary>Host copies</summary><GameFileHosts
caches={item.caches}
{hosts}
/>{@render downloadControls(item)}
</details>
</article>
{/each}
</details>
{/if}
</div>
</details>{/if}
</section>
{/each}
</div>
{#snippet uploadDetails(item: OverviewLibrary['versions'][number])}
<details>
<summary>Upload details</summary>
<dl>
<dt>Upload ID</dt>
<dd>{item.id}</dd>
<dt>Manifest SHA256</dt>
<dd>{item.manifestSha256 ?? 'Available after verification'}</dd>
<dt>Uploaded from</dt>
<dd>
{!item.sourceHostId
? 'Game folder in browser'
: `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}
</dd>
</dl>
<a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer"
>View complete file manifest</a
>
</details>
{/snippet}
{#snippet downloadControls(item: OverviewLibrary['versions'][number])}
{#if canManage && !item.filesRemoved && !item.filesRemoving}<details>
<summary>Advanced: download or verify these files</summary>
<p class="muted">
Retry a download or prepare a previous upload on a host. The latest files are
downloaded automatically.
</p>
<div class="download">
<Field label="Host">
<Select bind:value={targets[item.id]}
><option value="">Select host</option>{#each onlineHosts as host}<option
value={host.id}
>{host.name} · {formatGameBytes(
Number(host.inventory.storageAvailable ?? 0)
)} free</option
>{/each}</Select
></Field
><Button
size="sm"
disabled={busy || !targets[item.id]}
onclick={() => download(item.id)}>Download / verify</Button
>
</div>
</details>{/if}
{/snippet}
<style>
.page,
.card,
.uploads,
.upload-entry,
form {
display: grid;
gap: 16px;
}
.detail-content {
display: grid;
gap: 16px;
padding-top: 16px;
}
.card {
padding: 24px;
min-width: 0;
border: 1px solid var(--border-subtle);
background: var(--surface);
border-radius: var(--radius-md);
}
.uploads,
.upload-entry {
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
.history > p,
.history > .upload-entry {
margin-top: 16px;
}
.heading,
.download,
.upload-actions {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.download,
.upload-actions {
justify-content: flex-start;
align-items: end;
}
label {
display: grid;
gap: 6px;
font-size: var(--text-sm);
}
input {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 10px;
background: var(--surface);
color: inherit;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
}
input:disabled {
opacity: 0.7;
}
input[type='file'] {
padding: 16px;
cursor: pointer;
}
.folder-summary,
.upload-progress {
padding: 20px;
display: grid;
gap: 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
min-width: 0;
}
.folder-summary > span,
.upload-progress span,
.upload-actions > span {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.path-list {
padding-left: 20px;
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.current-file {
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
progress {
width: 100%;
accent-color: var(--accent-primary);
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-md);
}
h3 {
font-size: var(--text-sm);
}
p,
details {
font-size: var(--text-sm);
line-height: 1.6;
}
.muted {
color: var(--text-secondary);
}
a {
color: var(--accent-primary-hover);
}
summary {
cursor: pointer;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.card,
.folder-summary,
.upload-progress {
padding: 16px;
}
dl {
grid-template-columns: 1fr;
}
.heading > div {
min-width: 0;
}
}
</style>