up
This commit is contained in:
+89
-16
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user