备份与恢复
This commit is contained in:
1 parent
5d5fecde1b
commit
b4535d6aea
6 files changed
+252
-27
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureDefaultBackends } from '../storage-backends'
|
||||
import { getActiveBackend } from '../storage/resolver'
|
||||
import { putImage } from '../storage'
|
||||
import { updateImageThumb, updateImageTranscode } from '../image-index'
|
||||
import { createBackupJob, getBackupJob } from './jobs'
|
||||
import { runFullExportJob } from './full-export'
|
||||
import { previewFullRestore } from './full-restore'
|
||||
|
||||
describe('previewFullRestore', () => {
|
||||
it('counts media records instead of HLS sidecar files', async () => {
|
||||
ensureDefaultBackends()
|
||||
const key = 'videos/2026/10/preview-count.mp4'
|
||||
const thumbKey = 'videos/2026/10/preview-count.webp'
|
||||
const hlsKey = 'videos/2026/10/preview-count/index.m3u8'
|
||||
const segmentKey = 'videos/2026/10/preview-count/seg000.ts'
|
||||
const hlsOnlyKey = 'videos/2026/10/preview-count-hls-only.mp4'
|
||||
const hlsOnlyThumbKey = 'videos/2026/10/preview-count-hls-only.webp'
|
||||
const hlsOnlyPlaylistKey = 'videos/2026/10/preview-count-hls-only/index.m3u8'
|
||||
const hlsOnlySegmentKey = 'videos/2026/10/preview-count-hls-only/seg000.ts'
|
||||
const meta = {
|
||||
originalName: 'preview-count.mp4',
|
||||
uploadedAt: '2026-10-18T00:00:00.000Z',
|
||||
contentType: 'video/mp4',
|
||||
size: 1,
|
||||
userId: 1,
|
||||
mediaType: 'video' as const
|
||||
}
|
||||
|
||||
await putImage(key, new Uint8Array([1]), meta)
|
||||
const backend = await getActiveBackend()
|
||||
await backend.put(thumbKey, new Uint8Array([2]), meta)
|
||||
await backend.put(
|
||||
hlsKey,
|
||||
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
|
||||
meta
|
||||
)
|
||||
await backend.put(segmentKey, new Uint8Array([3]), meta)
|
||||
updateImageThumb(key, thumbKey)
|
||||
updateImageTranscode(key, { status: 'done', hlsKey, progress: 100 })
|
||||
|
||||
await putImage(hlsOnlyKey, new Uint8Array([4]), meta)
|
||||
await backend.put(hlsOnlyThumbKey, new Uint8Array([5]), meta)
|
||||
await backend.put(
|
||||
hlsOnlyPlaylistKey,
|
||||
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
|
||||
meta
|
||||
)
|
||||
await backend.put(hlsOnlySegmentKey, new Uint8Array([6]), meta)
|
||||
updateImageThumb(hlsOnlyKey, hlsOnlyThumbKey)
|
||||
updateImageTranscode(hlsOnlyKey, {
|
||||
status: 'done',
|
||||
hlsKey: hlsOnlyPlaylistKey,
|
||||
progress: 100,
|
||||
sourceDeleted: true
|
||||
})
|
||||
|
||||
const exportJob = createBackupJob({ type: 'full_export', message: 'test' })
|
||||
await runFullExportJob(exportJob.id, 'test')
|
||||
const archivePath = getBackupJob(exportJob.id)!.outputPath!
|
||||
|
||||
for (const fileKey of [
|
||||
key,
|
||||
thumbKey,
|
||||
hlsKey,
|
||||
segmentKey,
|
||||
hlsOnlyThumbKey,
|
||||
hlsOnlyPlaylistKey,
|
||||
hlsOnlySegmentKey
|
||||
]) {
|
||||
await backend.delete(fileKey)
|
||||
}
|
||||
|
||||
const preview = await previewFullRestore(archivePath)
|
||||
expect(preview.imageCount).toBe(2)
|
||||
expect(preview.newImages).toBe(2)
|
||||
expect(preview.conflictingImages).toBe(0)
|
||||
|
||||
await backend.put(
|
||||
hlsOnlyPlaylistKey,
|
||||
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
|
||||
meta
|
||||
)
|
||||
const conflictPreview = await previewFullRestore(archivePath)
|
||||
expect(conflictPreview.newImages).toBe(1)
|
||||
expect(conflictPreview.conflictingImages).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createReadStream, promises as fs } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import * as tar from 'tar'
|
||||
import { getImageIndexRow } from '../image-index'
|
||||
import { getActiveBackend } from '../storage/resolver'
|
||||
@@ -48,32 +49,53 @@ async function readManifest(stagingRoot: string): Promise<BackupManifest> {
|
||||
return manifest
|
||||
}
|
||||
|
||||
interface RestoreImageRow {
|
||||
key: string
|
||||
thumb_key: string | null
|
||||
hls_key: string | null
|
||||
source_deleted: number
|
||||
}
|
||||
|
||||
function readRestoreImageRows(stagingRoot: string): RestoreImageRow[] {
|
||||
const db = new DatabaseSync(
|
||||
join(stagingRoot, 'database', 'pichost.sqlite'),
|
||||
{ readOnly: true }
|
||||
)
|
||||
try {
|
||||
return db.prepare(`
|
||||
SELECT key, thumb_key, hls_key, source_deleted
|
||||
FROM images
|
||||
`).all() as unknown as RestoreImageRow[]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewFullRestore(archivePath: string): Promise<BackupRestorePreview> {
|
||||
const stagingRoot = getBackupStagingDir(`preview-${Date.now()}`)
|
||||
try {
|
||||
await extractArchive(archivePath, stagingRoot)
|
||||
const manifest = await readManifest(stagingRoot)
|
||||
const blobsRoot = join(stagingRoot, 'blobs')
|
||||
let blobFiles: string[] = []
|
||||
try {
|
||||
blobFiles = await walkFiles(blobsRoot)
|
||||
} catch {
|
||||
blobFiles = []
|
||||
}
|
||||
|
||||
const backend = await getActiveBackend()
|
||||
const imageRows = readRestoreImageRows(stagingRoot)
|
||||
let conflictingImages = 0
|
||||
for (const file of blobFiles) {
|
||||
const rel = relative(blobsRoot, file).replace(/\\/g, '/')
|
||||
if (await backend.head(rel)) {
|
||||
for (const row of imageRows) {
|
||||
const keys = [
|
||||
...(Number(row.source_deleted) === 1 ? [] : [row.key]),
|
||||
...(row.thumb_key ? [row.thumb_key] : []),
|
||||
...(row.hls_key ? [row.hls_key] : [])
|
||||
]
|
||||
for (const key of keys) {
|
||||
if (!await backend.head(key)) continue
|
||||
conflictingImages += 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imageCount: manifest.imageCount,
|
||||
imageCount: imageRows.length,
|
||||
imageBytes: manifest.imageBytes,
|
||||
newImages: Math.max(0, blobFiles.length - conflictingImages),
|
||||
newImages: imageRows.length - conflictingImages,
|
||||
conflictingImages,
|
||||
formatVersion: manifest.formatVersion,
|
||||
appVersion: manifest.appVersion,
|
||||
|
||||
Reference in new issue
Block a user