This commit is contained in:
2026-10-18 14:15:43 +07:00
parent 0cd736f682
commit 913b237cb8
14 changed files with 1042 additions and 100 deletions
+89 -16
View File
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { lstat, mkdir, open, readFile, readdir, rename, rm, statfs } from 'node:fs/promises'
import { extname, join, resolve } from 'node:path'
import { dirname, extname, join, resolve } from 'node:path'
import { setTimeout as wait } from 'node:timers/promises'
import { parseScreenPackage, type ScreenPackage } from './screen-package.js'
@@ -48,6 +48,10 @@ export interface DownloadProgress {
totalFiles: number
totalBytes: number | null
}
export interface VerificationProgress {
completed: number
total: number | null
}
interface Dependencies {
fetch?: typeof fetch
freeBytes?: () => Promise<bigint>
@@ -89,6 +93,7 @@ export class PackageStore {
private readonly repaired = new Set<string>()
private readonly checked = new Map<string, string>()
private readonly loading = new Map<string, Promise<void>>()
private readonly assetErrors = new Map<string, string>()
constructor(
root: string,
private readonly dependencies: Dependencies = {},
@@ -110,6 +115,21 @@ export class PackageStore {
}
return path
}
async folders(key: string) {
checkHex(key)
const pkg = this.list().find((pkg) => pkg.key === key)
if (!pkg) throw new Error('Игра не найдена')
const folders = new Set<string>()
for (const asset of pkg.package.assets) {
try {
folders.add(dirname((await this.localFile(key, asset.id)).path))
} catch {
/* Отсутствующий файл не должен вести пользователя в пустую папку. */
}
}
if (!folders.size) throw new Error('Материалы игры ещё не загружены')
return [...folders]
}
private async exclusive<T>(action: () => Promise<T>): Promise<T> {
if (this.busy) throw new Error('Хранилище пакетов занято')
this.busy = true
@@ -152,13 +172,17 @@ export class PackageStore {
}
}
}
async initialize(): Promise<void> {
async initialize(onProgress: (progress: VerificationProgress) => void = () => {}): Promise<void> {
onProgress({ completed: 0, total: null })
await this.exclusive(async () => {
await this.path()
await mkdir(this.root, { recursive: true })
await this.path()
this.ready.clear()
this.descriptions.clear()
this.checked.clear()
this.repaired.clear()
this.assetErrors.clear()
for (const key of await readdir(this.root)) {
if (!hex.test(key)) continue
try {
@@ -194,6 +218,16 @@ export class PackageStore {
}
}
}
const packages = this.list()
const total = packages.reduce((count, pkg) => count + pkg.package.assets.length, 0)
let completed = 0
onProgress({ completed, total })
for (const pkg of packages) {
for (const asset of pkg.package.assets) {
await this.checkAsset(pkg.key, asset.id)
onProgress({ completed: ++completed, total })
}
}
})
}
private async load(key: string): Promise<LocalPackage> {
@@ -219,6 +253,7 @@ export class PackageStore {
if (packageKey(pkg.server, pkg.game.id) !== key || raw.files.length !== pkg.assets.length)
throw new Error('Неполный пакет')
const files: StoredFile[] = []
const signatures = new Map<string, string>()
for (let i = 0; i < pkg.assets.length; i++) {
const file = raw.files[i] as StoredFile
if (
@@ -231,13 +266,19 @@ export class PackageStore {
)
throw new Error('Повреждён список файлов')
const path = await this.path(key, `v-${version}`, 'assets', file.id)
if (!(await lstat(path)).isFile()) throw new Error('Ресурс не является файлом')
const info = await lstat(path)
if (!info.isFile()) throw new Error('Ресурс не является файлом')
const actual = await checksum(path)
if (actual.size !== file.size || actual.sha256 !== file.sha256)
throw new Error('Повреждён файл пакета')
files.push({ id: file.id, ...actual, mime: file.mime })
signatures.set(
`${key}/${file.id}`,
`${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`,
)
}
if (versionOf(pkg, files) !== version) throw new Error('Повреждена версия пакета')
for (const [token, signature] of signatures) this.checked.set(token, signature)
return { formatVersion: 1, key, version, package: pkg, files }
}
list(): LocalPackage[] {
@@ -265,6 +306,12 @@ export class PackageStore {
await rm(dir, { recursive: true, force: true })
this.ready.delete(key)
this.descriptions.delete(key)
for (const token of this.checked.keys())
if (token.startsWith(`${key}/`)) this.checked.delete(token)
for (const token of this.repaired)
if (token.startsWith(`${key}/`)) this.repaired.delete(token)
for (const token of this.assetErrors.keys())
if (token.startsWith(`${key}/`)) this.assetErrors.delete(token)
})
}
private descriptionPackage(pkg: ScreenPackage): LocalPackage {
@@ -348,6 +395,37 @@ export class PackageStore {
?.files.some((f) => f.id === assetId && !this.repaired.has(`${key}/${assetId}`))
)
}
assetStatus(key: string, assetId: string) {
const token = `${key}/${assetId}`
const error = this.assetErrors.get(token) ?? ''
const status = this.loading.has(token)
? 'loading'
: this.canRead(key, assetId)
? 'ready'
: error
? 'error'
: 'missing'
return { status, error } as const
}
async checkAsset(key: string, assetId: string): Promise<boolean> {
const token = `${key}/${assetId}`
try {
const file = await this.localFile(key, assetId)
const stat = await lstat(file.path)
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
if (this.checked.get(token) === signature) return true
const actual = await checksum(file.path)
if (actual.size === file.size && actual.sha256 === file.sha256) {
this.checked.set(token, signature)
return true
}
} catch {
/* Отсутствующие и повреждённые файлы доступны для повторной загрузки. */
}
this.checked.delete(token)
this.repaired.add(token)
return false
}
async ensureAsset(
pkg: ScreenPackage,
assetId: string,
@@ -361,21 +439,10 @@ export class PackageStore {
const token = `${key}/${assetId}`
const existing = this.loading.get(token)
if (existing) return existing
this.assetErrors.delete(token)
const job = (async () => {
signal.throwIfAborted()
try {
const file = await this.localFile(key, assetId)
const stat = await lstat(file.path)
const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}`
if (this.checked.get(token) === signature) return
const actual = await checksum(file.path)
if (actual.size === file.size && actual.sha256 === file.sha256) {
this.checked.set(token, signature)
return
}
} catch {
/* Отсутствующий или повреждённый файл скачивается заново. */
}
if (await this.checkAsset(key, assetId)) return
signal.throwIfAborted()
await this.remembering.get(key)
if (!this.descriptions.has(key)) await this.remember(pkg)
@@ -409,6 +476,8 @@ export class PackageStore {
await rename(metadata, await this.path(key, 'cache', `${assetId}.json`))
// Восстановленный кеш заменяет повреждённый ресурс старого пакета.
this.repaired.add(token)
const info = await lstat(await this.path(key, 'cache', assetId))
this.checked.set(token, `${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`)
} finally {
await rm(path, { force: true })
await rm(metadata, { force: true })
@@ -417,6 +486,10 @@ export class PackageStore {
this.loading.set(token, job)
try {
await job
} catch (error) {
if (!signal.aborted)
this.assetErrors.set(token, error instanceof Error ? error.message : 'Файл недоступен')
throw error
} finally {
if (this.loading.get(token) === job) this.loading.delete(token)
}