feat: 完善媒体管理与备份迁移流程

- 视频上传生成缩略图并提供占位封面和失败原因展示
- 支持 HLS 按需重新封装 MP4,并限制临时文件生命周期与访问权限
- 完善视频、文件 API 文档、搜索、批量删除和单次转码质量参数
- 修复备份恢复与跨后端迁移遗漏缩略图、HLS 清单和分片的问题
- 将备份任务、归档和下载权限按用户隔离
- 增加仅清理当前用户数据的高危存储清空流程和二次文字确认
- 修复 S3 中文文件名元数据及 CLI 数据库连接问题
- 补充备份恢复、用户隔离、媒体资源和转码相关测试
This commit is contained in:
tianmo committed 2026-10-17 13:46:02 +08:00
1 parent 9ebab8270c
commit a64be50b73
60 files changed
+2703 -325

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'cleared': []
}>()
const { t } = useI18n()
const toast = useToast()
const step = ref<'warning' | 'confirm'>('warning')
const confirmation = ref('')
const clearing = ref(false)
const canConfirm = computed(() =>
!clearing.value && confirmation.value.trim() === t('storage.clearStoragePhrase')
)
watch(() => props.open, (open) => {
if (open) {
step.value = 'warning'
confirmation.value = ''
clearing.value = false
}
})
function close() {
if (clearing.value) return
emit('update:open', false)
}
function goToConfirm() {
step.value = 'confirm'
}
function clearBrowserCache() {
const keys = [
'pichost.upload-preferences',
'pichost.upload-preferences.video',
'pichost.upload-preferences.file',
'pichost.recent-tag-ids',
'pichost.library-view.image',
'pichost.library-view.video',
'pichost.library-view.file'
]
for (const key of keys) localStorage.removeItem(key)
}
async function confirmClear() {
if (!canConfirm.value) return
clearing.value = true
try {
const result = await $fetch<{
deletedMedia: number
deletedObjects: number
clearedCacheDirs: number
failed: Array<{ key: string }>
}>('/api/user/storage/purge', {
method: 'POST',
credentials: 'include',
body: { confirmation: confirmation.value.trim() }
})
clearBrowserCache()
emit('cleared')
if (result.failed.length) {
toast.add({
title: t('storage.clearStoragePartial', {
failed: result.failed.length,
total: result.deletedMedia + result.failed.length
}),
color: 'warning'
})
} else {
toast.add({
title: t('storage.clearStorageSuccess', {
files: result.deletedObjects,
cache: result.clearedCacheDirs
}),
color: 'success'
})
}
emit('update:open', false)
} catch (error: unknown) {
const message = error && typeof error === 'object' && 'data' in error
? (error as { data?: { error?: { message?: string } } }).data?.error?.message
: undefined
toast.add({
title: message || t('storage.clearStorageFailed'),
color: 'error'
})
} finally {
clearing.value = false
}
}
</script>
<template>
<UModal
:open="open"
:title="step === 'warning' ? t('storage.clearStorageTitle') : t('storage.clearStorageFinalTitle')"
@update:open="emit('update:open', $event)"
>
<template #body>
<template v-if="step === 'warning'">
<UAlert
color="error"
variant="subtle"
icon="i-lucide-octagon-alert"
:description="t('storage.clearStorageWarning')"
/>
<p class="mt-3 text-sm text-muted">
{{ t('storage.clearStorageScope') }}
</p>
</template>
<template v-else>
<p class="text-sm leading-6 text-muted">
{{ t('storage.clearStorageInstruction') }}
</p>
<UInput
v-model="confirmation"
class="mt-3 w-full"
:placeholder="t('storage.clearStoragePlaceholder')"
autofocus
/>
</template>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="clearing"
@click="close"
/>
<UButton
v-if="step === 'warning'"
:label="t('common.continue')"
color="error"
@click="goToConfirm"
/>
<UButton
v-else
:label="t('storage.clearStorageConfirm')"
color="error"
:loading="clearing"
:disabled="!canConfirm"
@click="confirmClear"
/>
</div>
</template>
</UModal>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { FileItem } from '~/types/file'
const props = defineProps<{
open: boolean
item: FileItem | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const downloading = ref(false)
watch(() => props.open, (open) => {
if (!open) downloading.value = false
})
async function confirmDownload() {
const item = props.item
if (!item?.hlsKey || downloading.value) return
downloading.value = true
try {
const response = await fetch('/api/files/hls-mp4', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: item.key })
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as {
error?: { message?: string }
} | null
throw new Error(payload?.error?.message || t('library.video.downloadMp4Failed'))
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const dot = item.originalName.lastIndexOf('.')
const filename = `${dot > 0 ? item.originalName.slice(0, dot) : item.originalName}.mp4`
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
emit('update:open', false)
} catch (error) {
toast.add({
title: error instanceof Error ? error.message : t('library.video.downloadMp4Failed'),
color: 'error'
})
} finally {
downloading.value = false
}
}
</script>
<template>
<UModal
:open="open && !!item"
:title="t('library.video.downloadMp4Title')"
@update:open="emit('update:open', $event)"
>
<template #body>
<p class="text-sm leading-6 text-muted">
{{ t('library.video.downloadMp4Confirm') }}
</p>
<p
v-if="downloading"
class="mt-3 text-sm text-primary"
>
{{ t('library.video.downloadMp4Working') }}
</p>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="downloading"
@click="emit('update:open', false)"
/>
<UButton
:label="t('common.continue')"
color="primary"
:loading="downloading"
@click="confirmDownload"
/>
</div>
</template>
</UModal>
</template>
+7 -16
View File
@@ -17,6 +17,7 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences(props.kind)
const copyFormatItems = computed(() => [
@@ -102,22 +103,11 @@ const presetLabel = computed(() => {
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<img
v-if="kind === 'video' && item.thumbUrl"
<VideoThumbnail
v-if="kind === 'video'"
:src="thumbLink"
:alt="item.originalName"
loading="lazy"
class="size-full object-cover"
>
<div
v-else-if="kind === 'video'"
class="flex size-full items-center justify-center"
>
<UIcon
name="i-lucide-film"
class="size-10 text-dimmed"
/>
</div>
/>
<div
v-else
class="flex size-full items-center justify-center"
@@ -174,9 +164,10 @@ const presetLabel = computed(() => {
color="error"
variant="subtle"
size="xs"
class="shrink-0"
class="max-w-48 shrink-0 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ t('library.transcodeFailed') }}
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
<UBadge
v-else-if="item.hlsUrl"
+16 -22
View File
@@ -16,11 +16,13 @@ const props = defineProps<{
const emit = defineEmits<{
'play': []
'delete': []
'download': []
'update:selected': [value: boolean]
}>()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
/** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => {
@@ -37,34 +39,15 @@ const uploadedLabel = computed(() => {
return props.item.uploadedAt
}
})
const thumbFailed = ref(false)
watch(() => props.item.thumbUrl, () => {
thumbFailed.value = false
})
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<img
v-if="item.thumbUrl && !thumbFailed"
<VideoThumbnail
:src="thumbLink"
:alt="displayName"
loading="lazy"
class="size-full object-cover object-center"
@error="thumbFailed = true"
>
<div
v-else
class="flex size-full items-center justify-center"
>
<UIcon
name="i-lucide-film"
class="size-10 text-dimmed"
/>
</div>
/>
<button
type="button"
@@ -108,8 +91,10 @@ watch(() => props.item.thumbUrl, () => {
color="error"
variant="solid"
size="xs"
class="max-w-48 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ t('library.transcodeFailed') }}
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
</div>
</div>
@@ -162,6 +147,15 @@ watch(() => props.item.thumbUrl, () => {
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
v-else-if="item.hlsUrl"
icon="i-lucide-download"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('library.video.downloadMp4')"
@click="emit('download')"
/>
<UButton
icon="i-lucide-trash-2"
variant="ghost"
+30 -11
View File
@@ -7,6 +7,7 @@ const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const transcodeFailureLabel = useTranscodeFailureLabel()
const items = ref<FileItem[]>([])
const total = ref(0)
@@ -27,6 +28,8 @@ const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false)
const previewOpen = ref(false)
const previewItem = ref<FileItem | null>(null)
const downloadOpen = ref(false)
const downloadItem = ref<FileItem | null>(null)
const currentBackend = ref('all')
const currentSource = ref('all')
@@ -66,7 +69,7 @@ function videoLink(item: FileItem): string {
}
function thumbLink(item: FileItem): string {
return withToken(item.thumbUrl)
return withToken(item.thumbUrl) || '/video-placeholder.svg'
}
function downloadUrl(item: FileItem): string {
@@ -264,6 +267,13 @@ function openPreview(item: FileItem) {
previewOpen.value = true
}
function requestDownload(item: FileItem | null) {
if (!item?.hlsUrl) return
previewOpen.value = false
downloadItem.value = item
downloadOpen.value = true
}
onMounted(() => {
void loadBackendOptions()
})
@@ -514,6 +524,7 @@ defineExpose({ refresh })
:selected="selectedKeys.has(item.key)"
@update:selected="toggleSelected(item.key, $event)"
@play="openPreview(item)"
@download="requestDownload(item)"
@delete="requestDelete(item)"
/>
</div>
@@ -577,17 +588,9 @@ defineExpose({ refresh })
:aria-label="t('library.play')"
@click="openPreview(item)"
>
<img
v-if="item.thumbUrl"
<VideoThumbnail
:src="thumbLink(item)"
:alt="displayName(item)"
loading="lazy"
class="size-full object-cover"
>
<UIcon
v-else
name="i-lucide-film"
class="size-5 text-dimmed"
/>
</button>
<p
@@ -611,8 +614,9 @@ defineExpose({ refresh })
variant="subtle"
size="xs"
class="shrink-0"
:title="transcodeFailureLabel(item.transcodeError)"
>
{{ t('library.transcodeFailed') }}
{{ transcodeFailureLabel(item.transcodeError) }}
</UBadge>
</div>
</td>
@@ -695,6 +699,15 @@ defineExpose({ refresh })
rel="noopener"
:aria-label="t('files.download')"
/>
<UButton
v-else-if="item.hlsUrl"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-download"
:aria-label="t('library.video.downloadMp4')"
@click="requestDownload(item)"
/>
<UButton
size="xs"
variant="ghost"
@@ -746,7 +759,13 @@ defineExpose({ refresh })
:thumb-link="previewItem ? thumbLink(previewItem) : ''"
:download-link="previewItem ? downloadUrl(previewItem) : ''"
:deleting="deleting"
@download="requestDownload(previewItem)"
@delete="previewItem && requestDelete(previewItem)"
/>
<HlsDownloadModal
v-model:open="downloadOpen"
:item="downloadItem"
/>
</div>
</template>
+12 -1
View File
@@ -20,11 +20,13 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
'update:open': [value: boolean]
'delete': []
'download': []
}>()
const toast = useToast()
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences('video')
const videoEl = ref<HTMLVideoElement | null>(null)
@@ -80,7 +82,7 @@ const transcodeLabel = computed(() => {
case 'done':
return t('library.video.transcodeDone')
case 'failed':
return t('library.transcodeFailed')
return transcodeFailureLabel(item.transcodeError)
default:
return t('library.video.transcodeNone')
}
@@ -498,6 +500,15 @@ function downloadOriginal() {
size="sm"
@click="downloadOriginal"
/>
<UButton
v-else-if="item.hlsUrl"
:label="t('library.video.downloadMp4')"
icon="i-lucide-download"
color="neutral"
variant="outline"
size="sm"
@click="emit('download')"
/>
<UButton
:label="t('image.previewCopyLink')"
icon="i-lucide-link"
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = defineProps<{
src?: string
alt: string
}>()
const failed = ref(false)
watch(() => props.src, () => {
failed.value = false
})
</script>
<template>
<img
v-if="src && !failed"
:src="src"
:alt="alt"
loading="lazy"
class="size-full object-cover object-center"
@error="failed = true"
>
<img
v-else
src="/video-placeholder.svg"
:alt="alt"
loading="lazy"
class="size-full object-cover object-center"
>
</template>
+24 -5
View File
@@ -65,8 +65,14 @@ function resetForm() {
if (!isDebugParam(param)) continue
if (param.type === 'file') {
files[param.name] = null
} else if (param.name === 'kind') {
values[param.name] = props.endpoint.category === 'video' ? 'video' : 'file'
} else if (param.name === 'keys') {
values[param.name] = '["images/2026/08/example.webp"]'
values[param.name] = props.endpoint.category === 'video'
? '["videos/2026/10/example.mp4"]'
: props.endpoint.category === 'file'
? '["files/2026/10/example.pdf"]'
: '["images/2026/08/example.webp"]'
} else if (param.name === 'token' && props.token) {
values[param.name] = props.token
} else {
@@ -84,6 +90,12 @@ function fileLabel(name: string) {
return fileValues.value[name]?.name ?? t('api.debug.noFileSelected')
}
function fileAccept() {
if (props.endpoint.category === 'video') return 'video/*'
if (props.endpoint.category === 'file') return undefined
return 'image/*'
}
function triggerFilePick(name: string) {
fileInputRefs.value[name]?.click()
}
@@ -101,8 +113,8 @@ function isParamFilled(param: ApiDocParam) {
if (!value) return false
if (param.location === 'body' && param.name === 'keys') {
try {
const parsed = JSON.parse(value) as { keys?: unknown }
return Array.isArray(parsed.keys) && parsed.keys.length > 0
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed) && parsed.length > 0
} catch {
return false
}
@@ -159,7 +171,14 @@ async function sendRequest() {
} else if (debugBodyParams.value.some(p => p.location === 'body')) {
headers['Content-Type'] = 'application/json'
try {
body = JSON.stringify(JSON.parse(paramValues.value.keys || '{}'))
const payload: Record<string, unknown> = {}
for (const param of debugBodyParams.value) {
if (param.location !== 'body') continue
const value = paramValues.value[param.name]?.trim()
if (!value) continue
payload[param.name] = param.name === 'keys' ? JSON.parse(value) : value
}
body = JSON.stringify(payload)
} catch {
toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
sending.value = false
@@ -297,7 +316,7 @@ const responseScrollable = computed(() => responseBody.value.split('\n').length
<input
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
type="file"
accept="image/*"
:accept="fileAccept()"
class="hidden"
@change="onFileChange(param.name, $event)"
>
+45 -5
View File
@@ -20,9 +20,18 @@ const docsUrl = computed(() =>
const categories: { key: ApiDocCategory, labelKey: string }[] = [
{ key: 'image', labelKey: 'api.categories.image' },
{ key: 'video', labelKey: 'api.categories.video' },
{ key: 'file', labelKey: 'api.categories.file' },
{ key: 'extension', labelKey: 'api.categories.extension' },
{ key: 'system', labelKey: 'api.categories.system' }
]
const openCategories = ref<Set<ApiDocCategory>>(
new Set(
categories
.filter(category => category.key !== 'video' && category.key !== 'file')
.map(category => category.key)
)
)
const filteredEndpoints = computed(() => {
const q = search.value.trim().toLowerCase()
@@ -38,6 +47,17 @@ function endpointsForCategory(category: ApiDocCategory) {
return filteredEndpoints.value.filter(endpoint => endpoint.category === category)
}
function isCategoryOpen(category: ApiDocCategory) {
return openCategories.value.has(category)
}
function toggleCategory(category: ApiDocCategory) {
const next = new Set(openCategories.value)
if (next.has(category)) next.delete(category)
else next.add(category)
openCategories.value = next
}
function onSelect(id: string) {
emit('select', id)
}
@@ -59,6 +79,15 @@ onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown)
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown))
})
watch(search, (value) => {
if (!value.trim()) return
const next = new Set(openCategories.value)
for (const category of categories) {
if (endpointsForCategory(category.key).length) next.add(category.key)
}
openCategories.value = next
})
</script>
<template>
@@ -89,13 +118,24 @@ onMounted(() => {
:key="category.key"
class="mb-4"
>
<p
<button
v-if="endpointsForCategory(category.key).length"
class="mb-1.5 px-2 text-xs font-medium text-muted"
type="button"
class="mb-1.5 flex w-full items-center justify-between rounded-md px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-muted/40 hover:text-highlighted"
:aria-expanded="isCategoryOpen(category.key)"
@click="toggleCategory(category.key)"
>
<span>{{ t(category.labelKey) }}</span>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 transition-transform"
:class="isCategoryOpen(category.key) ? '' : '-rotate-90'"
/>
</button>
<ul
v-show="isCategoryOpen(category.key)"
class="space-y-0.5"
>
{{ t(category.labelKey) }}
</p>
<ul class="space-y-0.5">
<li
v-for="endpoint in endpointsForCategory(category.key)"
:key="endpoint.id"
+16 -5
View File
@@ -53,6 +53,12 @@ function progressPercent(job: BackupJobItem): number {
return Math.min(100, Math.round((job.progress / job.total) * 100))
}
function progressUnit(job: BackupJobItem): string {
return job.type === 'full_restore'
? t('storage.backup.progressUnitObjects')
: t('storage.backup.progressUnitMedia')
}
function jobMessage(job: BackupJobItem): string {
if (job.status === 'failed') {
return job.error || job.message || t('storage.backup.jobFailedUnknown')
@@ -174,7 +180,7 @@ function formatTime(value: string) {
class="text-xs text-dimmed"
>—</span>
</td>
<td class="min-w-32 px-4 py-3.5 sm:px-5">
<td class="min-w-40 px-4 py-3.5 sm:px-5">
<div class="flex items-center gap-2">
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
@@ -183,10 +189,15 @@ function formatTime(value: string) {
:style="{ width: `${progressPercent(job)}%` }"
/>
</div>
<span class="shrink-0 text-xs tabular-nums text-muted">
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
<template v-else-if="job.status === 'done'">100%</template>
<template v-else>—</template>
<span class="flex shrink-0 flex-col text-right">
<span class="text-xs tabular-nums text-muted">
<template v-if="job.total">{{ job.progress }}/{{ job.total }}</template>
<template v-else-if="job.status === 'done'">100%</template>
<template v-else>—</template>
</span>
<span class="text-[10px] text-dimmed">
{{ progressUnit(job) }}
</span>
</span>
</div>
</td>
+630 -1
View File
@@ -1,5 +1,5 @@
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE'
export type ApiDocCategory = 'image' | 'extension' | 'system'
export type ApiDocCategory = 'image' | 'video' | 'file' | 'extension' | 'system'
export type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
export interface ApiDocParam {
@@ -251,6 +251,635 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
"success": true,
"deleted": ["images/2026/08/a.webp"],
"failed": []
}`
},
{
id: 'videoUpload',
category: 'video',
method: 'POST',
path: '/api/videos/upload',
titleKey: 'api.docs.videoUpload.title',
descKey: 'api.docs.videoUpload.desc',
requiresAuth: true,
params: [
{
name: 'video',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.videoUpload.video'
},
{
name: 'file',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.files'
},
{
name: 'transcode',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcode'
},
{
name: 'transcodePreset',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcodePreset'
},
{
name: 'transcodeBitratePercent',
type: 'number',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.transcodeBitratePercent'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.videoUpload.token'
}
],
curl: `curl -X POST "${baseUrl}/api/videos/upload" \\
${auth} \\
-F "video=@./demo.mp4" \\
-F "transcode=1" \\
-F "transcodePreset=compress" \\
-F "transcodeBitratePercent=80"`,
responseExample: `{
"success": true,
"items": [
{
"key": "videos/2026/10/abc123.mp4",
"url": "https://pic.example.com/videos/2026/10/abc123.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 10485760,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[demo.mp4](https://pic.example.com/videos/2026/10/abc123.mp4)",
"thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp"
}
],
"errors": [],
"accessMode": "public"
}`
},
{
id: 'videoList',
category: 'video',
method: 'GET',
path: '/api/files',
titleKey: 'api.docs.videoList.title',
descKey: 'api.docs.videoList.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindVideo'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'q',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.q'
},
{
name: 'processing',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.processing'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files?kind=video&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "videos/2026/10/abc123.mp4",
"url": "https://pic.example.com/videos/2026/10/abc123.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 3580000,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"transcodeStatus": "done",
"transcodeProgress": 100,
"transcodePreset": "veryfast",
"sourceDeleted": true,
"thumbUrl": "https://pic.example.com/videos/2026/10/abc123.webp",
"hlsUrl": "https://pic.example.com/videos/2026/10/abc123/index.m3u8",
"hlsKey": "videos/2026/10/abc123/index.m3u8"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'videoDelete',
category: 'video',
method: 'DELETE',
path: '/api/files',
titleKey: 'api.docs.videoDelete.title',
descKey: 'api.docs.videoDelete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.videoDelete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/files?key=videos/2026/10/abc123.mp4" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'videoSearch',
category: 'video',
method: 'GET',
path: '/api/videos/search',
titleKey: 'api.docs.videoSearch.title',
descKey: 'api.docs.videoSearch.desc',
requiresAuth: true,
params: [
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaSearch.q'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
},
{
name: 'processing',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.processing'
}
],
curl: `curl "${baseUrl}/api/videos/search?q=demo&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "videos/2026/10/demo.mp4",
"originalName": "demo.mp4",
"contentType": "video/mp4",
"size": 10485760,
"transcodeStatus": "done",
"thumbUrl": "https://pic.example.com/videos/2026/10/demo.webp",
"hlsUrl": "https://pic.example.com/videos/2026/10/demo/index.m3u8"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'videoBatchDelete',
category: 'video',
method: 'POST',
path: '/api/files/batch-delete',
titleKey: 'api.docs.videoBatchDelete.title',
descKey: 'api.docs.videoBatchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.videoBatchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/files/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["videos/2026/10/a.mp4","videos/2026/10/b.mp4"]}'`,
responseExample: `{
"success": true,
"deleted": [
"videos/2026/10/a.mp4",
"videos/2026/10/b.mp4"
],
"failed": []
}`
},
{
id: 'fileUpload',
category: 'file',
method: 'POST',
path: '/api/files/upload',
titleKey: 'api.docs.fileUpload.title',
descKey: 'api.docs.fileUpload.desc',
requiresAuth: true,
params: [
{
name: 'file',
type: 'file',
location: 'form-data',
required: true,
descriptionKey: 'api.params.fileUpload.file'
},
{
name: 'files',
type: 'file',
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.files'
},
{
name: 'token',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.fileUpload.token'
}
],
curl: `curl -X POST "${baseUrl}/api/files/upload" \\
${auth} \\
-F "file=@./report.pdf"`,
responseExample: `{
"success": true,
"items": [
{
"key": "files/2026/10/abc123.pdf",
"url": "https://pic.example.com/files/2026/10/abc123.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)"
}
],
"errors": [],
"accessMode": "public"
}`
},
{
id: 'fileList',
category: 'file',
method: 'GET',
path: '/api/files',
titleKey: 'api.docs.fileList.title',
descKey: 'api.docs.fileList.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindFile'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'q',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.q'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files?kind=file&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "files/2026/10/abc123.pdf",
"url": "https://pic.example.com/files/2026/10/abc123.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/abc123.pdf)"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
id: 'fileDelete',
category: 'file',
method: 'DELETE',
path: '/api/files',
titleKey: 'api.docs.fileDelete.title',
descKey: 'api.docs.fileDelete.desc',
requiresAuth: true,
params: [
{
name: 'key',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.fileDelete.key'
}
],
curl: `curl -X DELETE "${baseUrl}/api/files?key=files/2026/10/abc123.pdf" \\
${auth}`,
responseExample: `{
"success": true
}`
},
{
id: 'fileBatchDelete',
category: 'file',
method: 'POST',
path: '/api/files/batch-delete',
titleKey: 'api.docs.fileBatchDelete.title',
descKey: 'api.docs.fileBatchDelete.desc',
requiresAuth: true,
params: [
{
name: 'keys',
type: 'string[]',
location: 'body',
required: true,
descriptionKey: 'api.params.fileBatchDelete.keys'
}
],
curl: `curl -X POST "${baseUrl}/api/files/batch-delete" \\
${auth} \\
-H "Content-Type: application/json" \\
-d '{"keys":["files/2026/10/a.pdf","files/2026/10/b.zip"]}'`,
responseExample: `{
"success": true,
"deleted": [
"files/2026/10/a.pdf",
"files/2026/10/b.zip"
],
"failed": []
}`
},
{
id: 'fileSearch',
category: 'file',
method: 'GET',
path: '/api/files/search',
titleKey: 'api.docs.fileSearch.title',
descKey: 'api.docs.fileSearch.desc',
requiresAuth: true,
params: [
{
name: 'kind',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaList.kindFile'
},
{
name: 'q',
type: 'string',
location: 'query',
required: true,
descriptionKey: 'api.params.mediaSearch.q'
},
{
name: 'page',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.page'
},
{
name: 'limit',
type: 'number',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.limit'
},
{
name: 'backendId',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.backendId'
},
{
name: 'uploadSource',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.uploadSource'
},
{
name: 'tagIds',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagIds'
},
{
name: 'tagMode',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.tagMode'
},
{
name: 'untagged',
type: 'string',
location: 'query',
required: false,
descriptionKey: 'api.params.mediaList.untagged'
}
],
curl: `curl "${baseUrl}/api/files/search?kind=file&q=report&page=1&limit=20" \\
${auth}`,
responseExample: `{
"items": [
{
"key": "files/2026/10/report.pdf",
"url": "https://pic.example.com/files/2026/10/report.pdf",
"originalName": "report.pdf",
"contentType": "application/pdf",
"size": 5242880,
"uploadedAt": "2026-10-17T03:00:00.000Z",
"markdown": "[report.pdf](https://pic.example.com/files/2026/10/report.pdf)"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
"accessMode": "public"
}`
},
{
+1 -1
View File
@@ -89,7 +89,7 @@ export function useMediaUpload(kind: UploadMediaKind) {
/** 封面缩略图地址(同样按访问模式拼接) */
function thumbLink(item: FileItem): string {
const base = item.thumbUrl
if (!base) return ''
if (!base) return '/video-placeholder.svg'
if (accessMode.value !== 'token' || !apiToken.value) return base
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
@@ -0,0 +1,12 @@
export function useTranscodeFailureLabel() {
const { t } = useI18n()
return (error?: string) => {
const reason = error === 'ffmpeg-missing'
? t('library.video.transcodeErrorFfmpegMissing')
: error === 'permission-denied'
? t('library.video.transcodeErrorPermissionDenied')
: t('library.video.transcodeErrorUnknown')
return `${t('library.transcodeFailed')} · ${reason}`
}
}
+41
View File
@@ -20,6 +20,17 @@ const deleteOpen = ref(false)
const deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
const clearStorageOpen = ref(false)
const backupPanelKey = ref(0)
function openClearStorage() {
clearStorageOpen.value = true
}
function handleStorageCleared() {
backupPanelKey.value += 1
void loadStorage()
}
const backupPanelRef = ref<{ applySyncDraft: (draft: { fromBackendId: string, toBackendId: string }) => void } | null>(null)
@@ -389,11 +400,36 @@ watch(isAuthenticated, async (authed) => {
<StorageBackupPanel
v-if="!storageData.envOverride"
:key="backupPanelKey"
ref="backupPanelRef"
:backends="storageData.backends"
/>
<StorageSupportedBackends />
<section class="rounded-2xl border border-error/30 bg-error/5 p-5 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-base font-semibold text-error">
<UIcon
name="i-lucide-trash-2"
class="size-5"
/>
{{ t('storage.clearStorageTitle') }}
</h2>
<p class="mt-1 text-sm leading-relaxed text-muted">
{{ t('storage.clearStorageDescription') }}
</p>
</div>
<UButton
:label="t('storage.clearStorage')"
icon="i-lucide-trash-2"
color="error"
class="shrink-0"
@click="openClearStorage"
/>
</div>
</section>
</template>
</section>
</AppShell>
@@ -447,5 +483,10 @@ watch(isAuthenticated, async (authed) => {
</div>
</template>
</UModal>
<ClearStorageModal
v-model:open="clearStorageOpen"
@cleared="handleStorageCleared"
/>
</div>
</template>
+2
View File
@@ -14,6 +14,8 @@ export interface FileItem {
transcodeProgress?: number
/** 本视频使用的转码档位 */
transcodePreset?: string
/** 转码失败原因 */
transcodeError?: string
/** HLS 播放列表地址(转码完成后有值) */
hlsUrl?: string
/** HLS 播放列表在存储中的 key */
+3
View File
@@ -8,7 +8,10 @@ export type ErrorCode
| 'UNSUPPORTED_FILE_TYPE'
| 'INVALID_FILE_SIGNATURE'
| 'INVALID_IMAGE_KEY'
| 'INVALID_VIDEO_KEY'
| 'IMAGE_NOT_FOUND'
| 'HLS_NOT_FOUND'
| 'HLS_REMUX_FAILED'
| 'UPLOAD_FAILED'
| 'DELETE_FAILED'
+1
View File
@@ -6,6 +6,7 @@
- Node.js **22**
- npm **11**(与 `package.json` 中 `packageManager` 一致)
- FFmpeg(视频缩略图与 HLS 转码;未安装时视频仍可上传,但使用占位封面)
## 启动
+130 -23
View File
@@ -8,6 +8,7 @@
"loadingSession": "Verifying session…",
"save": "Save",
"cancel": "Cancel",
"continue": "Continue",
"copy": "Copy",
"delete": "Delete",
"edit": "Edit",
@@ -546,7 +547,20 @@
"storageId": "Storage ID",
"storageType": "Storage type",
"badgeLocal": "Local",
"defaultStorageTip": "Default storage: new uploads are saved to “{name}”. Use “Set as default” on another backend to change this.",
"defaultStorageTip": "All new images, videos, and files are saved to “{name}” by default. Use “Set as default” on another backend to change this.",
"clearStorage": "Clear storage",
"clearStorageTitle": "Clear my storage",
"clearStorageDescription": "Permanently delete every image, video, file, and cache uploaded by the current account without affecting other users.",
"clearStorageWarning": "High-risk operation: this will clear all files and caches in storage and cannot be undone!",
"clearStorageScope": "Only data and files owned by the current signed-in user are removed. Other users, account settings, storage configuration, and site backups are not affected.",
"clearStorageFinalTitle": "Final confirmation",
"clearStorageInstruction": "Type “I confirm” to continue. The operation cannot be cancelled or undone once started.",
"clearStoragePlaceholder": "I confirm",
"clearStoragePhrase": "I confirm",
"clearStorageConfirm": "Permanently clear",
"clearStorageSuccess": "Cleared {files} files and {cache} temporary caches; storage space has been released",
"clearStoragePartial": "{failed}/{total} files failed to clear; please retry",
"clearStorageFailed": "Failed to clear storage; please try again",
"supportedTitle": "Supported backends",
"supportedSubtitle": "PicHost supports multiple storage backends for different scenarios",
"supportedLocalDesc": "Ideal for small deployments and personal use",
@@ -554,7 +568,7 @@
"supportedS3Title": "S3 compatible",
"supportedS3Desc": "Storage services compatible with the Amazon S3 API",
"supportedTelegramDesc": "Store images in a Telegram channel or group; good for light unlimited storage",
"footerTip": "The default backend receives all new uploads",
"footerTip": "The default backend receives all new images, videos, and files",
"statTotal": "Total",
"statUsed": "Used",
"statFree": "Free",
@@ -585,28 +599,28 @@
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving",
"backup": {
"sectionTitle": "Backup & migration",
"sectionSubtitle": "Full-site backup, restore, and cross-backend image migration",
"sectionSubtitle": "Full-site backup, restore, and cross-backend file migration",
"siteTitle": "Site backup & restore",
"siteSubtitle": "For migration, disaster recovery, and full-site restore",
"siteFooterHint": "Backups include the database and images, not plain-text secret_json.",
"siteFooterHint": "Backups include the database and all files, not plain-text secret_json.",
"exportTitle": "Create site backup",
"exportSubtitle": "Package the database and all images as .phost.tar.gz",
"exportSubtitle": "Package the database and all files as .phost.tar.gz",
"exportAction": "Start backup",
"exportStarted": "Backup job started",
"exportFailed": "Failed to start backup",
"exportPathHint": "Exports are saved under data/backups/",
"exportIncludesTitle": "Package includes",
"exportIncludesDb": "Database snapshot (pichost.db)",
"exportIncludesImages": "All image files ({count})",
"exportIncludesImages": "All images, videos, and files ({count})",
"exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage",
"exportConcurrencyHint": "Only one backup or migration job can run at a time",
"estimateLoading": "Estimating size…",
"estimateImageCountLabel": "Images",
"estimateImageCountLabel": "Files",
"estimatePackageSizeLabel": "Est. package",
"estimateDatabaseSnapshotLabel": "Database snapshot",
"estimateStoragePathLabel": "Save location",
"estimateStoragePathValue": "data/backups/",
"estimateImages": "{count} images · {size}",
"estimateImages": "{count} files · {size}",
"estimateDatabase": "Database ~{size}",
"estimateTotal": "Total ~{size}",
"restoreTitle": "Restore from backup",
@@ -623,30 +637,30 @@
"restoreConfirm": "Confirm restore",
"restoreConfirmTitle": "Confirm site restore",
"restoreConfirmSource": "Source: {name}",
"restoreOverwriteWarning": "Overwrite mode replaces conflicting images and the database. Ensure you have a current backup.",
"restoreOverwriteWarning": "Overwrite mode replaces conflicting files and the database. Ensure you have a current backup.",
"restoreSelectRequired": "Select or upload a backup file",
"restoreStarted": "Restore job started",
"restoreFailed": "Restore failed",
"conflictMode": "Conflict policy",
"conflictSkip": "Skip existing images",
"conflictOverwrite": "Overwrite existing images and database",
"conflictSkip": "Skip existing files",
"conflictOverwrite": "Overwrite existing files and database",
"syncTitle": "Cross-backend sync",
"syncMigrateTitle": "Cross-backend migration",
"syncMigrateSubtitle": "Migrate images between configured backends",
"syncSubtitle": "Copy images between configured backends and update the index",
"syncMigrateSubtitle": "Migrate files between configured backends",
"syncSubtitle": "Copy files between configured backends and update the index",
"syncFrom": "Source backend",
"syncTo": "Target backend",
"syncPendingImages": "Images to migrate",
"syncPendingImages": "Files to migrate",
"syncPendingSize": "Est. size",
"syncIncludesTitle": "What sync does",
"syncIncludesCopy": "Copy image files from source to target backend",
"syncIncludesCopy": "Copy images, videos, and files from source to target backend",
"syncIncludesIndex": "Update storage_backend_id in the index",
"syncIncludesConfig": "Site settings and other backends are unchanged",
"syncDryRun": "Dry run only",
"syncSetDefaultAfter": "Set target as default after migration",
"syncStartMigrate": "Start migration",
"syncPreviewOnly": "Preview",
"syncFooterHint": "Only image files and index are migrated; site settings are unchanged.",
"syncFooterHint": "Only files and index are migrated; site settings are unchanged.",
"syncDeleteSource": "Delete source files after sync",
"syncSetDefault": "Set target as default after sync",
"syncPreview": "Preview & start",
@@ -658,8 +672,8 @@
"syncStarted": "Sync job started",
"syncFailed": "Sync failed",
"previewFailed": "Preview failed",
"previewImages": "Images",
"previewNew": "New images",
"previewImages": "Files",
"previewNew": "New files",
"previewConflicts": "Conflicts",
"previewCreated": "Backup created",
"previewSize": "Data size",
@@ -672,6 +686,8 @@
"colType": "Type",
"colStatus": "Status",
"colProgress": "Progress",
"progressUnitMedia": "media records",
"progressUnitObjects": "storage objects",
"colMessage": "Message",
"colCreated": "Created",
"colActions": "Actions",
@@ -691,8 +707,8 @@
"jobRunning": "Another job is already running. Try again later.",
"migrateIn": "Migrate in",
"migrateOut": "Migrate out",
"migrateInHint": "Copy images from the default backend to this one",
"migrateOutHint": "Move images out of this backend",
"migrateInHint": "Copy files from the default backend to this one",
"migrateOutHint": "Move files out of this backend",
"syncDraftApplied": "Sync form prefilled — review and start when ready",
"syncNeedTwoBackends": "At least two storage backends are required to sync",
"selectBackend": "Select backend",
@@ -869,7 +885,7 @@
"envOverrideTitle": "Token overridden by environment",
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
"personalTitle": "Personal API token",
"personalDesc": "For your scripts only; uploads belong to your account.",
"personalDesc": "For your scripts only; uploaded images, videos and files belong to your account.",
"notConfiguredTitle": "No token configured",
"notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.",
"regenerate": "Regenerate",
@@ -911,6 +927,8 @@
"responseExample": "Response example",
"categories": {
"image": "Images",
"video": "Videos",
"file": "Files",
"extension": "Plugins",
"system": "System"
},
@@ -935,6 +953,47 @@
"batchDelete": {
"keys": "Array of image keys to delete"
},
"videoUpload": {
"video": "Primary video field; max 500 MB per file",
"file": "Compatibility field, can replace video",
"files": "Compatibility field for multiple videos",
"transcode": "Set to 1 to transcode to HLS asynchronously; otherwise keep the source and generate a thumbnail",
"transcodePreset": "Optional preset: copy, veryfast, medium, slow, or compress (target bitrate)",
"transcodeBitratePercent": "Used by compress only; target bitrate as 10–100% of the source. Uses the current server setting when omitted",
"token": "Optional; the API token can also be sent with the Auth-Token header"
},
"fileUpload": {
"file": "Primary file field; max 100 MB per file. HTML / HTM / SVG are blocked",
"files": "Compatibility field for multiple files",
"token": "Optional; the API token can also be sent with the Auth-Token header"
},
"mediaList": {
"kindVideo": "Set to video to list videos",
"kindFile": "Set to file to list files",
"page": "Page number, starts at 1",
"limit": "Page size, default 20, max 100",
"q": "Search by filename or path",
"processing": "Video only; set to 1 to return queued or running transcodes",
"uploadSource": "Upload source: web or api",
"tagIds": "Filter by comma-separated tag IDs",
"tagMode": "Use or or and for multiple tags; default is or",
"untagged": "Set to 1 to return only untagged items"
},
"mediaSearch": {
"q": "Search keyword matched against filename or storage path; required"
},
"videoDelete": {
"key": "Video storage path; also removes its thumbnail and HLS artifacts"
},
"videoBatchDelete": {
"keys": "Array of video keys; up to 100 per request. Thumbnails and HLS artifacts are also removed"
},
"fileDelete": {
"key": "File storage path, e.g. files/2026/10/xxx.pdf"
},
"fileBatchDelete": {
"keys": "Array of file keys; up to 100 per request"
},
"twikoo": {
"image": "Image file",
"token": "API token"
@@ -955,9 +1014,9 @@
"sendFailed": "Request failed",
"networkError": "Network error or unreachable",
"notesTitle": "Notes",
"noteSuccess": "200 means success with JSON body",
"noteSuccess": "200 means success with a JSON body",
"noteError": "4xx / 5xx means failure; check error field",
"noteLimit": "Upload limit: up to 10 files, 10 MB each"
"noteLimit": "Upload limit: up to 10 files; videos up to 500 MB each, files up to 100 MB each"
},
"docs": {
"upload": {
@@ -980,6 +1039,46 @@
"title": "Batch delete",
"desc": "Delete multiple images; request body is a keys array."
},
"videoUpload": {
"title": "Upload videos",
"desc": "Upload MP4 / WebM / MOV / MKV and more, up to 10 files and 500 MB each. A thumbnail is generated independently; transcode=1 starts asynchronous HLS transcoding."
},
"videoList": {
"title": "List videos",
"desc": "Use kind=video to list videos, including untranscoded, completed and failed items. processing=1 returns only queued or running jobs."
},
"videoDelete": {
"title": "Delete video",
"desc": "Delete a video by key, including its thumbnail and generated HLS playlist and segments."
},
"videoSearch": {
"title": "Search videos",
"desc": "Search by filename or storage path with pagination, backend, upload source and tag filters; q is required."
},
"videoBatchDelete": {
"title": "Batch delete videos",
"desc": "Delete up to 100 videos at once, including each video's thumbnail, HLS playlist and segments."
},
"fileUpload": {
"title": "Upload files",
"desc": "Upload any file, up to 10 files and 100 MB each. HTML, HTM and SVG are blocked."
},
"fileList": {
"title": "List files",
"desc": "Use kind=file to paginate file-bed entries with search, storage, source and tag filters."
},
"fileDelete": {
"title": "Delete file",
"desc": "Delete one file by its storage key. This action cannot be undone."
},
"fileSearch": {
"title": "Search files",
"desc": "Search by filename or storage path with kind=file, pagination, backend, upload source and tag filters."
},
"fileBatchDelete": {
"title": "Batch delete files",
"desc": "Delete up to 100 files at once. The response reports deleted and failed keys separately."
},
"twikoo": {
"title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
@@ -1058,6 +1157,14 @@
"previewKey": "Video path",
"transcodeNone": "Not transcoded",
"transcodeDone": "Done (m3u8)",
"transcodeErrorFfmpegMissing": "No FFmpeg",
"transcodeErrorPermissionDenied": "File permission denied",
"transcodeErrorUnknown": "Unknown reason",
"downloadMp4": "Download MP4",
"downloadMp4Title": "Download MP4 video",
"downloadMp4Confirm": "The video segments will be merged into an MP4 file. Continue?",
"downloadMp4Working": "Merging video segments, please wait…",
"downloadMp4Failed": "Video merge failed. Please try again later.",
"uploadTitle": "Drop videos here to upload",
"uploadHint": "or click to choose video files, up to 10 at a time",
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
+131 -24
View File
@@ -8,6 +8,7 @@
"loadingSession": "正在验证登录状态…",
"save": "保存",
"cancel": "取消",
"continue": "继续",
"copy": "复制",
"delete": "删除",
"edit": "编辑",
@@ -546,7 +547,20 @@
"storageId": "存储标识",
"storageType": "存储类型",
"badgeLocal": "本地",
"defaultStorageTip": "默认存储提示:新上传的图片将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
"defaultStorageTip": "新上传的所有图片、视频、文件将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
"clearStorage": "清空存储",
"clearStorageTitle": "清空我的存储",
"clearStorageDescription": "永久删除当前账号上传的所有图片、视频、文件和缓存,不影响其他用户的数据。",
"clearStorageWarning": "高危风险:此操作会清空存储中的所有文件和缓存,无法撤销!",
"clearStorageScope": "只会清除当前登录用户的数据和文件。其他用户的数据、账户、存储配置和全站备份不会被删除。",
"clearStorageFinalTitle": "最终确认",
"clearStorageInstruction": "请输入“我已确认”以继续。操作开始后无法取消或撤销。",
"clearStoragePlaceholder": "我已确认",
"clearStoragePhrase": "我已确认",
"clearStorageConfirm": "永久清空",
"clearStorageSuccess": "已清空 {files} 个文件和 {cache} 个临时缓存,存储空间已释放",
"clearStoragePartial": "{failed}/{total} 个文件清理失败,请重试",
"clearStorageFailed": "清空存储失败,请稍后重试",
"supportedTitle": "支持的存储后端",
"supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求",
"supportedLocalDesc": "适合小型部署和个人使用",
@@ -554,7 +568,7 @@
"supportedS3Title": "S3 兼容",
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
"supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景",
"footerTip": "默认存储后端将自动接收所有新上传的图片",
"footerTip": "默认存储后端将自动接收所有新上传的图片、视频和文件",
"statTotal": "总量",
"statUsed": "已用",
"statFree": "剩余",
@@ -585,28 +599,28 @@
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略",
"backup": {
"sectionTitle": "备份与迁移",
"sectionSubtitle": "整站备份恢复与跨后端图片迁移",
"sectionSubtitle": "整站备份恢复与跨后端文件迁移",
"siteTitle": "站点备份与恢复",
"siteSubtitle": "用于换机、灾备与整站恢复",
"siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。",
"siteFooterHint": "备份包含数据库与全部文件,不包含 secret_json 明文。",
"exportTitle": "创建站点备份",
"exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz",
"exportSubtitle": "打包数据库与全部文件为 .phost.tar.gz",
"exportAction": "开始备份",
"exportStarted": "备份任务已开始",
"exportFailed": "创建备份失败",
"exportPathHint": "导出文件保存在 data/backups/",
"exportIncludesTitle": "备份包含",
"exportIncludesDb": "数据库快照(pichost.db)",
"exportIncludesImages": "全部图片文件({count} 张)",
"exportIncludesImages": "全部图片、视频和文件({count} 个)",
"exportExcludesSecrets": "不含云存储 secret.json 明文",
"exportConcurrencyHint": "同时仅可运行一个备份或迁移任务",
"estimateLoading": "正在估算体积…",
"estimateImageCountLabel": "图片总数",
"estimateImageCountLabel": "文件总数",
"estimatePackageSizeLabel": "预计备份包",
"estimateDatabaseSnapshotLabel": "数据库快照",
"estimateStoragePathLabel": "保存位置",
"estimateStoragePathValue": "data/backups/",
"estimateImages": "图片 {count} 张 · {size}",
"estimateImages": "文件 {count} 个 · {size}",
"estimateDatabase": "数据库约 {size}",
"estimateTotal": "合计约 {size}",
"restoreTitle": "从备份恢复",
@@ -623,44 +637,44 @@
"restoreConfirm": "确认恢复",
"restoreConfirmTitle": "确认恢复站点",
"restoreConfirmSource": "来源:{name}",
"restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份",
"restoreOverwriteWarning": "覆盖模式将替换冲突文件与数据库,请确认已做好当前站点备份",
"restoreSelectRequired": "请选择或上传备份文件",
"restoreStarted": "恢复任务已开始",
"restoreFailed": "恢复失败",
"conflictMode": "冲突策略",
"conflictSkip": "跳过已存在图片",
"conflictOverwrite": "覆盖已存在图片与数据库",
"conflictSkip": "跳过已存在文件",
"conflictOverwrite": "覆盖已存在文件与数据库",
"syncTitle": "跨后端同步",
"syncMigrateTitle": "跨后端迁移",
"syncMigrateSubtitle": "在已配置后端之间迁移图片",
"syncSubtitle": "在已配置的后端之间拷贝图片并更新索引",
"syncMigrateSubtitle": "在已配置后端之间迁移文件",
"syncSubtitle": "在已配置的后端之间拷贝文件并更新索引",
"syncFrom": "源后端",
"syncTo": "目标后端",
"syncPendingImages": "待迁移图片",
"syncPendingImages": "待迁移文件",
"syncPendingSize": "预计体积",
"syncIncludesTitle": "同步说明",
"syncIncludesCopy": "拷贝源端图片文件到目标后端",
"syncIncludesCopy": "拷贝源端图片、视频和文件到目标后端",
"syncIncludesIndex": "更新索引中的 storage_backend_id",
"syncIncludesConfig": "不修改站点配置与其它后端数据",
"syncDryRun": "仅预览 Dry Run",
"syncSetDefaultAfter": "迁移后设为默认后端",
"syncStartMigrate": "开始迁移",
"syncPreviewOnly": "预览",
"syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。",
"syncFooterHint": "仅迁移文件与索引,不影响站点配置。",
"syncDeleteSource": "同步后删除源端文件",
"syncSetDefault": "同步后将目标设为默认",
"syncPreview": "预检并开始",
"syncConfirm": "开始同步",
"syncConfirmTitle": "确认跨后端同步",
"syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销",
"syncDeleteWarning": "将删除源后端上的文件,此操作不可撤销",
"syncSelectRequired": "请选择源与目标后端",
"syncSameBackend": "源与目标不能相同",
"syncStarted": "同步任务已开始",
"syncFailed": "同步失败",
"previewFailed": "预检失败",
"previewImages": "图片数量",
"previewNew": "新增图片",
"previewConflicts": "冲突图片",
"previewImages": "文件数量",
"previewNew": "新增文件",
"previewConflicts": "冲突文件",
"previewCreated": "备份时间",
"previewSize": "数据量",
"jobsTitle": "最近任务",
@@ -672,6 +686,8 @@
"colType": "类型",
"colStatus": "状态",
"colProgress": "进度",
"progressUnitMedia": "媒体记录",
"progressUnitObjects": "存储对象",
"colMessage": "说明",
"colCreated": "创建时间",
"colActions": "操作",
@@ -691,8 +707,8 @@
"jobRunning": "已有任务正在运行,请稍后再试",
"migrateIn": "迁入",
"migrateOut": "迁出",
"migrateInHint": "从默认后端拷贝图片到此后端",
"migrateOutHint": "将图片从此后端迁出",
"migrateInHint": "从默认后端拷贝文件到此后端",
"migrateOutHint": "将文件从此后端迁出",
"syncDraftApplied": "已填入同步表单,请确认后开始",
"syncNeedTwoBackends": "至少需要两个存储后端才能同步",
"selectBackend": "选择后端",
@@ -869,7 +885,7 @@
"envOverrideTitle": "Token 由环境变量覆盖",
"envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。",
"personalTitle": "个人 API Token",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片、视频或文件归属您的账号。",
"notConfiguredTitle": "尚未配置 Token",
"notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。",
"regenerate": "重新生成",
@@ -911,6 +927,8 @@
"responseExample": "响应示例",
"categories": {
"image": "图片接口",
"video": "视频接口",
"file": "文件接口",
"extension": "插件接口",
"system": "系统通用"
},
@@ -938,6 +956,47 @@
"batchDelete": {
"keys": "要删除的图片 key 数组"
},
"videoUpload": {
"video": "上传视频主字段,单文件最大 500 MB",
"file": "兼容字段,可代替 video",
"files": "兼容字段,可一次上传多个视频",
"transcode": "设为 1 后异步转码为 HLS;不设置则只保留原视频并生成缩略图",
"transcodePreset": "可选转码档位:copy(直接封装)、veryfast(速度)、medium(平衡)、slow(质量)、compress(目标码率压缩)",
"transcodeBitratePercent": "仅 compress 档使用,目标码率为源码率的 10–100% 整数;省略时使用服务端当前设置",
"token": "可选,API Token 也可通过 Auth-Token 请求头传递"
},
"fileUpload": {
"file": "上传文件主字段,单文件最大 100 MB;禁止 HTML / HTM / SVG",
"files": "兼容字段,可一次上传多个文件",
"token": "可选,API Token 也可通过 Auth-Token 请求头传递"
},
"mediaList": {
"kindVideo": "固定为 video,获取视频列表",
"kindFile": "固定为 file,获取文件列表",
"page": "页码,从 1 开始",
"limit": "每页条数,默认 20,最大 100",
"q": "按文件名或路径关键词搜索",
"processing": "视频专用;设为 1 时只返回排队中或转码中的视频",
"uploadSource": "上传来源:web 或 api",
"tagIds": "按标签筛选,逗号分隔标签 ID",
"tagMode": "多标签筛选时使用 or 或 and,默认 or",
"untagged": "设为 1 时仅返回未标记内容"
},
"mediaSearch": {
"q": "搜索关键词,匹配文件名或存储路径;不能为空"
},
"videoDelete": {
"key": "视频存储路径;删除时同时清理缩略图与 HLS 文件"
},
"videoBatchDelete": {
"keys": "要删除的视频 key 数组,单次最多 100 个;同时清理缩略图和 HLS 文件"
},
"fileDelete": {
"key": "文件存储路径,如 files/2026/10/xxx.pdf"
},
"fileBatchDelete": {
"keys": "要删除的文件 key 数组,单次最多 100 个"
},
"twikoo": {
"image": "上传图片",
"token": "API Token"
@@ -960,7 +1019,7 @@
"notesTitle": "响应说明",
"noteSuccess": "200 表示请求成功,返回 JSON 数据",
"noteError": "4xx / 5xx 表示请求失败,查看 error 字段",
"noteLimit": "上传限制:单次最多 10 张,单张最大 10 MB"
"noteLimit": "上传限制:单次最多 10 个;视频单文件最大 500 MB,文件单文件最大 100 MB"
},
"docs": {
"upload": {
@@ -983,6 +1042,46 @@
"title": "批量删除",
"desc": "一次删除多张图片,请求体为 keys 数组。"
},
"videoUpload": {
"title": "上传视频",
"desc": "上传 MP4 / WebM / MOV / MKV 等视频,单次最多 10 个、单文件最大 500 MB。上传后独立生成缩略图;transcode=1 时再异步转码为 HLS。"
},
"videoList": {
"title": "获取视频列表",
"desc": "设置 kind=video 获取视频。默认包含未转码、已完成和失败条目;processing=1 仅查看排队中或转码中的任务。"
},
"videoDelete": {
"title": "删除视频",
"desc": "通过视频 key 删除视频,同时清理缩略图和已生成的 HLS 播放列表与分片。"
},
"videoSearch": {
"title": "搜索视频",
"desc": "按文件名或存储路径搜索视频,支持分页、存储后端、上传来源和标签筛选;q 必填。"
},
"videoBatchDelete": {
"title": "批量删除视频",
"desc": "一次删除最多 100 个视频;每个视频的缩略图、HLS 播放列表与分片会一并清理。"
},
"fileUpload": {
"title": "上传文件",
"desc": "上传任意文件,单次最多 10 个、单文件最大 100 MB;禁止上传 HTML、HTM 和 SVG。"
},
"fileList": {
"title": "获取文件列表",
"desc": "设置 kind=file 分页获取文件床内容,支持搜索、存储后端、来源和标签筛选。"
},
"fileDelete": {
"title": "删除文件",
"desc": "通过文件 key 删除单个文件,操作不可恢复。"
},
"fileSearch": {
"title": "搜索文件",
"desc": "按文件名或存储路径搜索文件,设置 kind=file;支持分页、存储后端、上传来源和标签筛选。"
},
"fileBatchDelete": {
"title": "批量删除文件",
"desc": "一次删除最多 100 个文件,响应会分别返回删除成功和失败的 key。"
},
"twikoo": {
"title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
@@ -1061,6 +1160,14 @@
"previewKey": "视频路径",
"transcodeNone": "未转码",
"transcodeDone": "已完成(m3u8)",
"transcodeErrorFfmpegMissing": "无 FFmpeg",
"transcodeErrorPermissionDenied": "无文件权限",
"transcodeErrorUnknown": "原因未知",
"downloadMp4": "下载 MP4",
"downloadMp4Title": "下载 MP4 视频",
"downloadMp4Confirm": "将会合并视频切片到 MP4,是否继续?",
"downloadMp4Working": "正在合并视频切片,请稍候…",
"downloadMp4Failed": "视频合并失败,请稍后重试",
"uploadTitle": "拖拽视频到此处上传",
"uploadHint": "或点击选择视频文件,单次最多 10 个",
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB",
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-label="Video">
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#111827"/>
<stop offset="1" stop-color="#1f2937"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="45%" r="60%">
<stop offset="0" stop-color="#334155" stop-opacity=".75"/>
<stop offset="1" stop-color="#334155" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="360" fill="url(#background)"/>
<rect width="640" height="360" fill="url(#glow)"/>
<rect x="22" y="22" width="596" height="316" rx="18" fill="none" stroke="#94a3b8" stroke-opacity=".18" stroke-width="2"/>
<circle cx="320" cy="180" r="52" fill="#0f172a" fill-opacity=".82" stroke="#cbd5e1" stroke-opacity=".4" stroke-width="2"/>
<path d="M307 151c0-7 8-11 14-7l34 25c5 4 5 11 0 15l-34 25c-6 4-14 0-14-7z" fill="#cbd5e1" fill-opacity=".86"/>
</svg>

After

Width:  |  Height:  |  Size: 980 B

+14 -2
View File
@@ -1,12 +1,23 @@
import { requireAdminAuth } from '../../../utils/access'
import { getBackupExportEstimate } from '../../../utils/backup/stats'
import { getBackupsDir, isSafeBackupFilename } from '../../../utils/backup/paths'
import { listAllBackupJobsForUser } from '../../../utils/backup/jobs'
import { basename, join } from 'node:path'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const estimate = await getBackupExportEstimate()
const allowedArchives = new Set(
listAllBackupJobsForUser(user.id).flatMap((job) => {
const names: string[] = []
if (job.outputPath) names.push(basename(job.outputPath))
if (typeof job.payload.archivePath === 'string') {
names.push(basename(job.payload.archivePath))
}
return names
})
)
const packages = await (async () => {
try {
@@ -15,6 +26,7 @@ export default defineEventHandler(async (event) => {
const items = await Promise.all(
entries
.filter(entry => entry.isFile() && isSafeBackupFilename(entry.name))
.filter(entry => allowedArchives.has(entry.name))
.map(async (entry) => {
const full = join(getBackupsDir(), entry.name)
const fileStat = await fs.stat(full)
+2 -2
View File
@@ -5,14 +5,14 @@ import { getBackupExportEstimate } from '../../../utils/backup/stats'
import { startFullExportJob } from '../../../utils/backup/runner'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const config = useRuntimeConfig(event)
const appVersion = String(config.appVersion ?? '0.0.0')
const estimate = await getBackupExportEstimate()
try {
const job = startFullExportJob(appVersion)
const job = startFullExportJob(appVersion, user.id)
return {
job: serializeBackupJob(job),
estimate
+2 -2
View File
@@ -4,13 +4,13 @@ import { serializeBackupJob } from '../../../../utils/backup/api'
import { getBackupJob } from '../../../../utils/backup/jobs'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
}
const job = getBackupJob(id)
const job = getBackupJob(id, user.id)
if (!job) {
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
}
@@ -5,16 +5,19 @@ import { getBackupJob } from '../../../../../utils/backup/jobs'
import { cancelBackupJob } from '../../../../../utils/backup/runner'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
}
if (!getBackupJob(id, user.id)) {
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
}
if (!cancelBackupJob(id)) {
createApiError(event, 'INVALID_REQUEST', '只能取消进行中的任务', 400)
}
const job = getBackupJob(id)
const job = getBackupJob(id, user.id)
return { job: job ? serializeBackupJob(job) : null }
})
@@ -6,13 +6,13 @@ import { getBackupJob } from '../../../../../utils/backup/jobs'
import { BACKUP_ARCHIVE_EXT } from '../../../../../utils/backup/paths'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
}
const job = getBackupJob(id)
const job = getBackupJob(id, user.id)
if (!job || job.type !== 'full_export' || job.status !== 'done' || !job.outputPath) {
createApiError(event, 'INVALID_REQUEST', '可下载的备份不存在', 404)
}
@@ -5,13 +5,13 @@ import { serializeBackupJob } from '../../../../../utils/backup/api'
import { getBackupJob } from '../../../../../utils/backup/jobs'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
}
const job = getBackupJob(id)
const job = getBackupJob(id, user.id)
if (!job || job.type !== 'backend_sync') {
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
}
@@ -23,6 +23,6 @@ export default defineEventHandler(async (event) => {
createApiError(event, 'INVALID_REQUEST', message, 400)
}
const updated = getBackupJob(id)
const updated = getBackupJob(id, user.id)
return { job: updated ? serializeBackupJob(updated) : null }
})
+4 -4
View File
@@ -9,21 +9,21 @@ import {
import { isBackupRunnerBusy } from '../../../../utils/backup/runner'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
if (getRunningBackupJob() && !isBackupRunnerBusy()) {
recoverInterruptedBackupJobs()
}
const query = getQuery(event)
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 10))
const pageNum = Math.max(1, Number(query.pageNum) || Number(query.page) || 1)
const { jobs, total } = listBackupJobsPage(pageNum, pageSize)
const { jobs, total } = listBackupJobsPage(pageNum, pageSize, user.id)
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const page = total > 0 ? Math.min(pageNum, totalPages) : 1
const slice = page === pageNum
? jobs
: listBackupJobsPage(page, pageSize).jobs
: listBackupJobsPage(page, pageSize, user.id).jobs
const activeJob = getActiveBackupJob()
const activeJob = getActiveBackupJob(user.id)
return {
jobs: slice.map(serializeBackupJob),
+19 -5
View File
@@ -4,13 +4,14 @@ import { createApiError } from '../../../utils/api-error'
import { serializeBackupJob } from '../../../utils/backup/api'
import { previewFullRestore } from '../../../utils/backup/full-restore'
import { startFullRestoreJob } from '../../../utils/backup/runner'
import { listAllBackupJobsForUser } from '../../../utils/backup/jobs'
import {
BACKUP_ARCHIVE_EXT,
getBackupsDir,
getBackupStagingDir,
isSafeBackupFilename
} from '../../../utils/backup/paths'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { promises as fs } from 'node:fs'
interface RestoreBody {
@@ -19,7 +20,7 @@ interface RestoreBody {
dryRun?: boolean
}
async function resolveRestoreInput(event: H3Event): Promise<{
async function resolveRestoreInput(event: H3Event, userId: number): Promise<{
archivePath: string
body: RestoreBody
cleanup?: () => Promise<void>
@@ -71,6 +72,19 @@ async function resolveRestoreInput(event: H3Event): Promise<{
if (!body?.archiveName || !isSafeBackupFilename(body.archiveName)) {
createApiError(event, 'INVALID_REQUEST', '请选择有效的备份文件', 400)
}
const allowedArchives = new Set(
listAllBackupJobsForUser(userId).flatMap((job) => {
const names: string[] = []
if (job.outputPath) names.push(basename(job.outputPath))
if (typeof job.payload.archivePath === 'string') {
names.push(basename(job.payload.archivePath))
}
return names
})
)
if (!allowedArchives.has(body.archiveName)) {
createApiError(event, 'INVALID_REQUEST', '备份文件不存在', 404)
}
const archivePath = join(getBackupsDir(), body.archiveName)
try {
await fs.access(archivePath)
@@ -81,9 +95,9 @@ async function resolveRestoreInput(event: H3Event): Promise<{
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const { archivePath, body, cleanup } = await resolveRestoreInput(event)
const { archivePath, body, cleanup } = await resolveRestoreInput(event, user.id)
const conflictMode = body.conflictMode === 'skip' ? 'skip' : 'overwrite'
@@ -97,7 +111,7 @@ export default defineEventHandler(async (event) => {
}
try {
const job = startFullRestoreJob(archivePath, conflictMode)
const job = startFullRestoreJob(archivePath, conflictMode, user.id)
return { job: serializeBackupJob(job) }
} catch (error) {
if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') {
+2 -2
View File
@@ -13,7 +13,7 @@ interface SyncBody {
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireAdminAuth(event)
const body = await readBody<SyncBody>(event)
const fromBackendId = body?.fromBackendId?.trim() ?? ''
@@ -36,7 +36,7 @@ export default defineEventHandler(async (event) => {
return { preview }
}
const job = startBackendSyncJob(options)
const job = startBackendSyncJob(options, user.id)
return { job: serializeBackupJob(job) }
} catch (error) {
if (error instanceof Error) {
+81
View File
@@ -0,0 +1,81 @@
import { createReadStream, promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { assertImageOwnership, requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { buildAttachmentDisposition } from '../../utils/file-bed'
import { getImageIndexRow } from '../../utils/image-index'
import { validateVideoKey } from '../../utils/image-key'
import { logException, logInfo } from '../../utils/logger'
import {
cleanupExpiredHlsDownloads,
markHlsDownloadForCleanup,
remuxHlsToMp4
} from '../../utils/transcode'
interface DownloadBody {
key?: string
}
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const body = await readBody<DownloadBody>(event).catch(() => null)
const key = body?.key
if (!key || !validateVideoKey(key)) {
createApiError(event, 'INVALID_VIDEO_KEY', '无效的视频路径', 400)
}
const row = getImageIndexRow(key)
if (!row?.hls_key) {
createApiError(event, 'HLS_NOT_FOUND', 'HLS 视频不存在或尚未转码完成', 404)
}
await assertImageOwnership(event, row.user_id)
void cleanupExpiredHlsDownloads()
const workDir = join(tmpdir(), `pichost-hls-download-${user.id}-${randomUUID()}`)
try {
await fs.mkdir(workDir, { recursive: true })
await markHlsDownloadForCleanup(workDir)
} catch (error) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
throw error
}
let outputPath: string
try {
outputPath = await remuxHlsToMp4(row.hls_key, workDir)
} catch (error) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
logException('HLS download remux failed', error, { key })
createApiError(event, 'HLS_REMUX_FAILED', '视频合并失败,请稍后重试', 500)
}
let outputSize: number
try {
const stat = await fs.stat(outputPath)
if (!stat.isFile() || stat.size === 0) {
createApiError(event, 'HLS_REMUX_FAILED', '视频合并失败,请稍后重试', 500)
}
outputSize = stat.size
await markHlsDownloadForCleanup(workDir)
} catch (error) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
throw error
}
const baseName = row.original_name.replace(/\.[^.]+$/, '') || 'video'
setResponseHeader(event, 'Content-Type', 'video/mp4')
setResponseHeader(event, 'Content-Length', outputSize)
setResponseHeader(event, 'Cache-Control', 'no-store')
setResponseHeader(
event,
'Content-Disposition',
buildAttachmentDisposition(`${baseName}.mp4`)
)
logInfo('HLS download remuxed', { key, size: outputSize })
await sendStream(event, createReadStream(outputPath))
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
})
+10 -3
View File
@@ -1,3 +1,4 @@
import type { H3Event } from 'h3'
import type { FileListResponse } from '~/types/file'
import type { MediaType, TranscodeStatus } from '../../utils/storage/types'
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
@@ -21,7 +22,10 @@ import {
} from '../../utils/image-query'
import { listImages, searchImages } from '../../utils/storage'
export default defineEventHandler(async (event) => {
export async function handleFileList(
event: H3Event,
forcedKind?: MediaType
): Promise<FileListResponse> {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
@@ -51,7 +55,7 @@ export default defineEventHandler(async (event) => {
}
// 文件床按类型分工:视频库看 media_type=video,文件库看 media_type=file
const kindRaw = typeof query.kind === 'string' ? query.kind.trim() : ''
const kindRaw = forcedKind ?? (typeof query.kind === 'string' ? query.kind.trim() : '')
if (kindRaw && kindRaw !== 'file' && kindRaw !== 'video') {
createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400)
}
@@ -111,6 +115,7 @@ export default defineEventHandler(async (event) => {
...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {}),
transcodeStatus: stored.transcodeStatus ?? 'none',
transcodeProgress: stored.transcodeProgress ?? 0,
...(stored.transcodeError ? { transcodeError: stored.transcodeError } : {}),
...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}),
...(stored.sourceDeleted ? { sourceDeleted: true } : {}),
...(stored.thumbKey
@@ -138,4 +143,6 @@ export default defineEventHandler(async (event) => {
}
return response
})
}
export default defineEventHandler(handleFileList)
+16
View File
@@ -0,0 +1,16 @@
import { handleFileList } from './index.get'
import { createApiError } from '../../utils/api-error'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const search = typeof query.q === 'string' ? query.q.trim() : ''
if (!search) {
createApiError(event, 'INVALID_REQUEST', '搜索关键词不能为空', 400)
}
const kind = typeof query.kind === 'string' ? query.kind.trim() : 'file'
if (kind !== 'file' && kind !== 'video') {
createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400)
}
return handleFileList(event)
})
+31
View File
@@ -0,0 +1,31 @@
import { requireUserAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import { logInfo } from '../../../utils/logger'
import { clearUserStorage } from '../../../utils/user-storage'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const body = await readBody<{ confirmation?: string }>(event).catch(() => null)
if (!['我已确认', 'I confirm'].includes(body?.confirmation?.trim() ?? '')) {
createApiError(event, 'INVALID_REQUEST', '请输入“我已确认”以继续', 400)
}
try {
const result = await clearUserStorage(user.id)
logInfo('user storage cleared', {
userId: user.id,
deletedMedia: result.deletedMedia,
deletedObjects: result.deletedObjects,
failed: result.failed.length
})
return result
} catch (error) {
if (error instanceof Error && error.message === 'USER_STORAGE_CLEAR_IN_PROGRESS') {
createApiError(event, 'CONFLICT', '清空存储任务正在执行,请勿重复操作', 409)
}
if (error instanceof Error && error.message === 'USER_STORAGE_CLEAR_ACTIVE_JOB') {
createApiError(event, 'CONFLICT', '当前有备份或迁移任务正在运行,请完成后再清空存储', 409)
}
throw error
}
})
+11
View File
@@ -0,0 +1,11 @@
import { handleFileList } from '../files/index.get'
import { createApiError } from '../../utils/api-error'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const search = typeof query.q === 'string' ? query.q.trim() : ''
if (!search) {
createApiError(event, 'INVALID_REQUEST', '搜索关键词不能为空', 400)
}
return handleFileList(event, 'video')
})
+40 -2
View File
@@ -10,7 +10,12 @@ import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { processSingleVideoUpload } from '../../utils/file-bed'
import { enqueueTranscode } from '../../utils/transcode'
import {
enqueueTranscode,
MAX_TRANSCODE_BITRATE_PERCENT,
MIN_TRANSCODE_BITRATE_PERCENT,
parseTranscodePreset
} from '../../utils/transcode'
import { getFileBedAccessMode } from '../../utils/env'
export default defineEventHandler(async (event) => {
@@ -37,6 +42,36 @@ export default defineEventHandler(async (event) => {
)
: false
const presetField = formData.find(part => part.name === 'transcodePreset')
const presetRaw = presetField?.data?.length
? new TextDecoder().decode(presetField.data).trim()
: ''
const transcodePreset = presetRaw ? parseTranscodePreset(presetRaw) : undefined
if (presetRaw && !transcodePreset) {
createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400)
}
const bitrateField = formData.find(part => part.name === 'transcodeBitratePercent')
const bitrateRaw = bitrateField?.data?.length
? new TextDecoder().decode(bitrateField.data).trim()
: ''
const transcodeBitratePercent = bitrateRaw ? Number(bitrateRaw) : undefined
if (
bitrateRaw
&& (
!Number.isInteger(transcodeBitratePercent)
|| transcodeBitratePercent! < MIN_TRANSCODE_BITRATE_PERCENT
|| transcodeBitratePercent! > MAX_TRANSCODE_BITRATE_PERCENT
)
) {
createApiError(
event,
'INVALID_REQUEST',
`转码目标码率需为 ${MIN_TRANSCODE_BITRATE_PERCENT}–${MAX_TRANSCODE_BITRATE_PERCENT} 的整数`,
400
)
}
const fileParts = formData.filter(
part => (part.name === 'file' || part.name === 'files' || part.name === 'video')
&& part.data?.length
@@ -82,7 +117,10 @@ export default defineEventHandler(async (event) => {
if (wantTranscode) {
for (const item of items) {
enqueueTranscode(item.key)
enqueueTranscode(item.key, {
...(transcodePreset ? { preset: transcodePreset } : {}),
...(transcodeBitratePercent !== undefined ? { bitratePercent: transcodeBitratePercent } : {})
})
}
}
+6 -3
View File
@@ -11,9 +11,12 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { loadServerModule } from './backup-cli-loader.mjs'
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts')
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts')
const { runFullExportJob } = loadServerModule('utils/backup/full-export.ts')
const {
ensureBackupSchema,
createBackupJob,
getBackupJob,
runFullExportJob
} = loadServerModule('cli/backup-runtime.ts')
function readAppVersion() {
try {
+8 -5
View File
@@ -11,10 +11,13 @@ import { access } from 'node:fs/promises'
import { resolve } from 'node:path'
import { loadServerModule } from './backup-cli-loader.mjs'
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts')
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts')
const { previewFullRestore } = loadServerModule('utils/backup/full-restore.ts')
const { runFullRestoreJob } = loadServerModule('utils/backup/full-restore.ts')
const {
ensureBackupSchema,
createBackupJob,
getBackupJob,
previewFullRestore,
runFullRestoreJob
} = loadServerModule('cli/backup-runtime.ts')
function usage() {
console.error('Usage: backup-restore <archive-path> [--overwrite]')
@@ -41,7 +44,7 @@ async function main() {
ensureBackupSchema()
const preview = await previewFullRestore(archivePath)
console.log(`[PicHost] Preview: ${preview.imageCount} images, ${preview.newImages} new, ${preview.conflictingImages} conflicts`)
console.log(`[PicHost] Preview: ${preview.imageCount} media items, ${preview.newImages} new files, ${preview.conflictingImages} conflicting files`)
const job = createBackupJob({
type: 'full_restore',
+5
View File
@@ -0,0 +1,5 @@
export { ensureBackupSchema } from '../utils/backup/schema'
export { createBackupJob, getBackupJob } from '../utils/backup/jobs'
export { runFullExportJob } from '../utils/backup/full-export'
export { previewFullRestore, runFullRestoreJob } from '../utils/backup/full-restore'
export { previewBackendSync, runBackendSyncJob } from '../utils/backup/backend-sync'
+9 -5
View File
@@ -1,5 +1,5 @@
/**
* Sync images between storage backends.
* Sync media files between storage backends.
*
* Usage:
* node server/cli/storage-sync.mjs --from <id> --to <id> [--delete-source] [--set-default] [--dry-run]
@@ -9,9 +9,13 @@
*/
import { loadServerModule } from './backup-cli-loader.mjs'
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts')
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts')
const { previewBackendSync, runBackendSyncJob } = loadServerModule('utils/backup/backend-sync.ts')
const {
ensureBackupSchema,
createBackupJob,
getBackupJob,
previewBackendSync,
runBackendSyncJob
} = loadServerModule('cli/backup-runtime.ts')
function usage() {
console.error('Usage: storage-sync --from <backend-id> --to <backend-id> [--delete-source] [--set-default] [--dry-run]')
@@ -45,7 +49,7 @@ async function main() {
if (dryRun) {
const preview = previewBackendSync(options)
console.log(`[PicHost] Dry run: ${preview.imageCount} images, ${preview.imageBytes} bytes`)
console.log(`[PicHost] Dry run: ${preview.imageCount} media items, ${preview.imageBytes} bytes`)
console.log(`[PicHost] From ${preview.fromBackendId} → ${preview.toBackendId}`)
return
}
+11
View File
@@ -0,0 +1,11 @@
import { cleanupExpiredHlsDownloads } from '../utils/transcode'
export default defineNitroPlugin(() => {
const cleanup = () => {
void cleanupExpiredHlsDownloads()
}
cleanup()
const timer = setInterval(cleanup, 60_000)
timer.unref()
})
+5 -1
View File
@@ -31,7 +31,11 @@ export default defineNitroPlugin(() => {
try {
const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('pichost-hls-')) continue
if (
!entry.isDirectory()
|| !entry.name.startsWith('pichost-hls-')
|| entry.name.startsWith('pichost-hls-download-')
) continue
await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true })
.catch(() => {})
}
+51 -40
View File
@@ -1,18 +1,22 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import {
createBackendInstance,
getStorageBackendRow,
setDefaultBackend
} from '../storage-backends'
import {
getImageIndexAsStored,
getImageIndexRow,
listImageIndexRowsByBackend,
updateImageBackendId
} from '../image-index'
import { createImageStream } from '../storage'
import { contentTypeFromKey } from '../content-type'
import { mediaTypeForKey } from '../image-key'
import type { ImageIndexRow } from '../image-index'
import type { StorageBackend } from '../storage/types'
import { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs'
import { getBackupStagingDir } from './paths'
import { collectMediaAssetKeys } from '../media-assets'
export interface BackendSyncOptions {
fromBackendId: string
@@ -63,6 +67,44 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return Buffer.concat(chunks)
}
async function syncMediaRow(
row: ImageIndexRow,
fromBackend: StorageBackend,
toBackend: StorageBackend,
deleteSource: boolean
): Promise<void> {
const keys = await collectMediaAssetKeys(row, fromBackend)
if (!keys.length) throw new Error('没有可同步的文件对象')
for (const key of keys) {
const stored = await fromBackend.head(key)
if (!stored) throw new Error(`源文件不存在:${key}`)
const bytes = await streamToBuffer(await fromBackend.createStream(key))
const isPrimary = key === row.key
await toBackend.put(key, bytes, {
originalName: isPrimary ? row.original_name : basename(key),
uploadedAt: row.uploaded_at,
contentType: isPrimary ? row.content_type : contentTypeFromKey(key),
size: bytes.length,
userId: row.user_id,
mediaType: isPrimary ? mediaTypeForKey(key) : 'video'
})
if (!await toBackend.head(key)) {
throw new Error(`目标后端写入校验失败:${key}`)
}
}
updateImageBackendId(row.key, toBackend.id)
if (deleteSource) {
for (const key of keys) {
await fromBackend.delete(key)
}
}
}
export async function runBackendSyncJob(
jobId: string,
options: BackendSyncOptions
@@ -88,32 +130,13 @@ export async function runBackendSyncJob(
updateBackupJob(jobId, {
total: rows.length,
progress: 0,
message: '正在同步图片…'
message: '正在同步文件…'
})
let processed = 0
for (const row of rows) {
try {
const stream = await createImageStream(row.key)
const bytes = await streamToBuffer(stream)
await toBackend.put(row.key, bytes, {
originalName: row.original_name,
uploadedAt: row.uploaded_at,
contentType: row.content_type,
size: row.size,
userId: row.user_id
})
const head = await toBackend.head(row.key)
if (!head) {
throw new Error('目标后端写入校验失败')
}
updateImageBackendId(row.key, options.toBackendId)
if (options.deleteSource) {
await fromBackend.delete(row.key)
}
await syncMediaRow(row, fromBackend, toBackend, Boolean(options.deleteSource))
} catch (error) {
failedKeys.push(row.key)
await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {})
@@ -136,7 +159,7 @@ export async function runBackendSyncJob(
}
if (failedKeys.length > 0) {
markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
markJobFailed(jobId, `${failedKeys.length} 个文件同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
updateBackupJob(jobId, {
payload: { failedKeys, failedLogPath, preview }
})
@@ -178,22 +201,10 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
const stillFailed: string[] = []
for (const key of keys) {
const row = getImageIndexAsStored(key)
const row = getImageIndexRow(key)
if (!row) continue
try {
const stream = await createImageStream(key)
const bytes = await streamToBuffer(stream)
await toBackend.put(key, bytes, {
originalName: row.originalName,
uploadedAt: row.uploadedAt,
contentType: row.contentType,
size: row.size,
userId: row.userId
})
updateImageBackendId(key, toBackendId)
if (deleteSource) {
await fromBackend.delete(key)
}
await syncMediaRow(row, fromBackend, toBackend, deleteSource)
} catch {
stillFailed.push(key)
}
@@ -205,7 +216,7 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
stillFailed.map(key => `${key}\tretry failed\n`).join(''),
'utf8'
)
throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`)
throw new Error(`仍有 ${stillFailed.length} 个文件同步失败`)
}
await fs.rm(failedLogPath, { force: true }).catch(() => {})
+106
View File
@@ -0,0 +1,106 @@
import * as tar from 'tar'
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 { runFullRestoreJob } from './full-restore'
async function archiveEntries(path: string): Promise<string[]> {
const entries: string[] = []
await tar.t({
file: path,
onentry: (entry) => {
if (entry.type === 'File') entries.push(entry.path.replace(/^\.\//, ''))
}
})
return entries
}
describe('runFullExportJob', () => {
it('includes video source, thumbnail and HLS artifacts', async () => {
ensureDefaultBackends()
const key = 'videos/2026/10/backup-test.mp4'
const thumbKey = 'videos/2026/10/backup-test.webp'
const hlsKey = 'videos/2026/10/backup-test/index.m3u8'
const segmentKey = 'videos/2026/10/backup-test/seg000.ts'
const meta = {
originalName: 'backup-test.mp4',
uploadedAt: '2026-10-17T00: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
})
const job = createBackupJob({ type: 'full_export', message: 'test' })
await runFullExportJob(job.id, 'test')
const done = getBackupJob(job.id)
expect(done?.status).toBe('done')
const entries = await archiveEntries(done!.outputPath!)
expect(entries).toEqual(expect.arrayContaining([
'blobs/videos/2026/10/backup-test.mp4',
'blobs/videos/2026/10/backup-test.webp',
'blobs/videos/2026/10/backup-test/index.m3u8',
'blobs/videos/2026/10/backup-test/seg000.ts'
]))
})
it('restores missing sidecar files in skip mode', async () => {
ensureDefaultBackends()
const key = 'videos/2026/10/restore-test.mp4'
const thumbKey = 'videos/2026/10/restore-test.webp'
const hlsKey = 'videos/2026/10/restore-test/index.m3u8'
const segmentKey = 'videos/2026/10/restore-test/seg000.ts'
const meta = {
originalName: 'restore-test.mp4',
uploadedAt: '2026-10-17T00: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 })
const exportJob = createBackupJob({ type: 'full_export', message: 'test' })
await runFullExportJob(exportJob.id, 'test')
const archivePath = getBackupJob(exportJob.id)!.outputPath!
await backend.delete(segmentKey)
expect(await backend.head(segmentKey)).toBeNull()
const restoreJob = createBackupJob({ type: 'full_restore', message: 'test' })
await runFullRestoreJob(restoreJob.id, archivePath, { conflictMode: 'skip' })
expect(await backend.head(segmentKey)).not.toBeNull()
})
})
+12 -39
View File
@@ -1,5 +1,5 @@
import { createWriteStream, promises as fs } from 'node:fs'
import { basename, join } from 'node:path'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import * as tar from 'tar'
import { getImageTotals, listAllImageIndexRows } from '../image-index'
@@ -9,7 +9,7 @@ import {
} from '../storage-backends'
import { parseS3Config } from '../storage/s3'
import { parseTelegramConfig } from '../storage/telegram'
import { createImageStream } from '../storage'
import { getBackendForKey } from '../storage/resolver'
import { copyDatabaseSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, updateBackupJob } from './jobs'
import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest'
@@ -19,6 +19,7 @@ import {
getBackupsDir
} from './paths'
import type { StorageBackendRow } from '../storage/types'
import { collectMediaAssetKeys } from '../media-assets'
function formatArchiveName(jobId: string): string {
return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}`
@@ -48,36 +49,15 @@ async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string
await pipeline(stream, createWriteStream(filePath))
}
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Uint8Array))
}
return Buffer.concat(chunks).toString('utf8')
}
/**
* 源文件已按设计删除(转码完成)时,导出 HLS 播放列表、分片与封面。
* 恢复端会遍历 blobs 目录原样还原,因此这里只需把附属文件写进去。
*/
async function exportVideoSidecars(row: ImageIndexRow, stagingRoot: string): Promise<void> {
const keys: string[] = []
if (row.thumb_key) keys.push(row.thumb_key)
if (row.hls_key) {
keys.push(row.hls_key)
const dir = row.hls_key.slice(0, row.hls_key.lastIndexOf('/'))
const text = await streamToString(await createImageStream(row.hls_key))
for (const line of text.split('\n')) {
const name = line.trim()
if (!name || name.startsWith('#')) continue
keys.push(`${dir}/${basename(name)}`)
}
}
async function exportMediaAssets(
row: ImageIndexRow,
stagingRoot: string
): Promise<void> {
const backend = await getBackendForKey(row.key)
const keys = await collectMediaAssetKeys(row, backend)
for (const key of keys) {
await writeStreamToFile(
await createImageStream(key),
await backend.createStream(key),
join(stagingRoot, 'blobs', key)
)
}
@@ -138,17 +118,10 @@ export async function runFullExportJob(jobId: string, appVersion: string): Promi
for (const row of rows) {
updateBackupJob(jobId, {
progress: processed,
message: `正在导出图片 ${processed}/${totals.count + 1}…`
message: `正在导出文件 ${processed}/${totals.count + 1}…`
})
if (Number(row.source_deleted ?? 0) === 1) {
// 转码完成的视频只有 HLS 产物与封面
await exportVideoSidecars(row, stagingRoot)
} else {
const blobPath = join(stagingRoot, 'blobs', row.key)
const stream = await createImageStream(row.key)
await writeStreamToFile(stream, blobPath)
}
await exportMediaAssets(row, stagingRoot)
processed += 1
updateBackupJob(jobId, { progress: processed })
}
+11 -8
View File
@@ -4,6 +4,7 @@ import { pipeline } from 'node:stream/promises'
import * as tar from 'tar'
import { getImageIndexRow } from '../image-index'
import { getActiveBackend } from '../storage/resolver'
import { contentTypeFromKey } from '../content-type'
import { replaceDatabaseFromSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs'
import {
@@ -60,10 +61,11 @@ export async function previewFullRestore(archivePath: string): Promise<BackupRes
blobFiles = []
}
const backend = await getActiveBackend()
let conflictingImages = 0
for (const file of blobFiles) {
const rel = relative(blobsRoot, file).replace(/\\/g, '/')
if (getImageIndexRow(rel)) {
if (await backend.head(rel)) {
conflictingImages += 1
}
}
@@ -104,7 +106,7 @@ export async function runFullRestoreJob(
restoreBackupJobsAfterReplace(preservedJobs)
updateBackupJob(jobId, {
status: 'running',
message: '正在恢复图片…',
message: '正在恢复文件…',
progress: 0,
total: 0
})
@@ -112,7 +114,7 @@ export async function runFullRestoreJob(
const blobsRoot = join(stagingRoot, 'blobs')
const blobFiles = await walkFiles(blobsRoot)
const total = blobFiles.length + 1
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复图片…' })
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复文件…' })
const backend = await getActiveBackend()
let processed = 1
@@ -120,8 +122,9 @@ export async function runFullRestoreJob(
for (const file of blobFiles) {
const key = relative(blobsRoot, file).replace(/\\/g, '/')
const indexed = getImageIndexRow(key)
const exists = Boolean(await backend.head(key))
if (!indexed && options.conflictMode === 'skip') {
if (exists && options.conflictMode === 'skip') {
processed += 1
continue
}
@@ -138,19 +141,19 @@ export async function runFullRestoreJob(
: {
originalName: key.split('/').pop() ?? key,
uploadedAt: new Date().toISOString(),
contentType: 'application/octet-stream',
contentType: contentTypeFromKey(key),
size: bytes.length,
userId: null
}
if (options.conflictMode === 'overwrite' || !indexed) {
if (options.conflictMode === 'overwrite' || !exists) {
await backend.put(key, bytes, meta)
}
processed += 1
updateBackupJob(jobId, {
progress: processed,
message: `正在恢复图片 ${processed}/${total}…`
message: `正在恢复文件 ${processed}/${total}…`
})
}
@@ -159,7 +162,7 @@ export async function runFullRestoreJob(
markJobDone(jobId, {
message: '恢复完成,请重新登录并在存储页检查对象存储密钥',
payload: {
restoredImages: blobFiles.length,
restoredFiles: blobFiles.length,
manifest
}
})
+58 -25
View File
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto'
import { getDb } from '../db'
import { getDb, getPrimaryAdminUserId } from '../db'
import { ensureBackupSchema } from './schema'
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync'
@@ -7,6 +7,7 @@ export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
export interface BackupJob {
id: string
userId: number | null
type: BackupJobType
status: BackupJobStatus
progress: number
@@ -21,6 +22,7 @@ export interface BackupJob {
interface BackupJobRow {
id: string
user_id: number | null
type: BackupJobType
status: BackupJobStatus
progress: number
@@ -43,6 +45,7 @@ function rowToJob(row: BackupJobRow): BackupJob {
})()
return {
id: row.id,
userId: row.user_id ?? null,
type: row.type,
status: row.status,
progress: row.progress,
@@ -62,33 +65,34 @@ function generateJobId(): string {
return `${stamp}-${suffix}`
}
export function getRunningBackupJob(): BackupJob | null {
export function getRunningBackupJob(userId?: number): BackupJob | null {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE status = 'running'
WHERE status = 'running'${userId === undefined ? '' : ' AND user_id = ?'}
LIMIT 1
`).get() as BackupJobRow | undefined
`).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null
}
export function getActiveBackupJob(): BackupJob | null {
export function getActiveBackupJob(userId?: number): BackupJob | null {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE status IN ('pending', 'running')
WHERE status IN ('pending', 'running')${userId === undefined ? '' : ' AND user_id = ?'}
ORDER BY created_at DESC
LIMIT 1
`).get() as BackupJobRow | undefined
`).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null
}
export function createBackupJob(input: {
type: BackupJobType
userId?: number | null
total?: number
message?: string
payload?: Record<string, unknown>
@@ -97,13 +101,15 @@ export function createBackupJob(input: {
const id = generateJobId()
const now = new Date().toISOString()
const payloadJson = JSON.stringify(input.payload ?? {})
const userId = input.userId === undefined ? getPrimaryAdminUserId() : input.userId
getDb().prepare(`
INSERT INTO backup_jobs
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
(id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
`).run(
id,
userId,
input.type,
input.total ?? 0,
input.message ?? '',
@@ -114,43 +120,69 @@ export function createBackupJob(input: {
return getBackupJob(id)!
}
export function getBackupJob(id: string): BackupJob | null {
export function getBackupJob(id: string, userId?: number): BackupJob | null {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE id = ?
`).get(id) as BackupJobRow | undefined
WHERE id = ?${userId === undefined ? '' : ' AND user_id = ?'}
`).get(...(userId === undefined ? [id] : [id, userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null
}
export function listBackupJobs(limit = 20): BackupJob[] {
return listBackupJobsPage(1, limit).jobs
export function listBackupJobs(limit = 20, userId?: number): BackupJob[] {
return listBackupJobsPage(1, limit, userId).jobs
}
export function countBackupJobs(): number {
export function countBackupJobs(userId?: number): number {
ensureBackupSchema()
const row = getDb().prepare('SELECT COUNT(*) AS count FROM backup_jobs').get() as { count: number }
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
`).get(...(userId === undefined ? [] : [userId])) as { count: number }
return row.count
}
export function listBackupJobsPage(page = 1, pageSize = 10): { jobs: BackupJob[], total: number } {
export function listBackupJobsPage(
page = 1,
pageSize = 10,
userId?: number
): { jobs: BackupJob[], total: number } {
ensureBackupSchema()
const limit = Math.min(50, Math.max(1, pageSize))
const safePage = Math.max(1, page)
const offset = (safePage - 1) * limit
const total = countBackupJobs()
const total = countBackupJobs(userId)
const rows = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as unknown as BackupJobRow[]
`).all(...(userId === undefined ? [limit, offset] : [userId, limit, offset])) as unknown as BackupJobRow[]
return { jobs: rows.map(rowToJob), total }
}
export function listAllBackupJobsForUser(userId: number): BackupJob[] {
ensureBackupSchema()
const rows = getDb().prepare(`
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId) as unknown as BackupJobRow[]
return rows.map(rowToJob)
}
export function deleteBackupJobsForUser(userId: number): number {
ensureBackupSchema()
const result = getDb().prepare('DELETE FROM backup_jobs WHERE user_id = ?').run(userId)
return Number(result.changes ?? 0)
}
/** Capture live task history before a full restore swaps the database file. */
export function snapshotBackupJobs(limit = 50): BackupJob[] {
return listBackupJobs(limit)
@@ -165,12 +197,13 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void {
db.prepare('DELETE FROM backup_jobs').run()
const insert = db.prepare(`
INSERT INTO backup_jobs
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const job of preserved) {
insert.run(
job.id,
job.userId,
job.type,
job.status,
job.progress,
+7 -3
View File
@@ -70,10 +70,11 @@ export function scheduleBackupJob(
})
}
export function startFullExportJob(appVersion: string): BackupJob {
export function startFullExportJob(appVersion: string, userId?: number | null): BackupJob {
claimJobSlot()
const job = createBackupJob({
type: 'full_export',
userId,
message: '等待开始…'
})
scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion))
@@ -82,11 +83,13 @@ export function startFullExportJob(appVersion: string): BackupJob {
export function startFullRestoreJob(
archivePath: string,
conflictMode: 'skip' | 'overwrite'
conflictMode: 'skip' | 'overwrite',
userId?: number | null
): BackupJob {
claimJobSlot()
const job = createBackupJob({
type: 'full_restore',
userId,
message: '等待开始…',
payload: { archivePath, conflictMode }
})
@@ -100,10 +103,11 @@ export function startBackendSyncJob(options: {
deleteSource?: boolean
setDefault?: boolean
dryRun?: boolean
}): BackupJob {
}, userId?: number | null): BackupJob {
claimJobSlot()
const job = createBackupJob({
type: 'backend_sync',
userId,
message: options.dryRun ? '预检中…' : '等待开始…',
payload: options
})
+18
View File
@@ -4,6 +4,7 @@ export function ensureBackupSchema(): void {
getDb().exec(`
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
user_id INTEGER,
type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')),
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')),
progress INTEGER NOT NULL DEFAULT 0,
@@ -18,4 +19,21 @@ export function ensureBackupSchema(): void {
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at
ON backup_jobs(created_at DESC);
`)
const columns = getDb().prepare('PRAGMA table_info(backup_jobs)').all() as Array<{
name: string
}>
if (!columns.some(column => column.name === 'user_id')) {
getDb().exec('ALTER TABLE backup_jobs ADD COLUMN user_id INTEGER')
}
getDb().exec(`
CREATE INDEX IF NOT EXISTS idx_backup_jobs_user_created
ON backup_jobs(user_id, created_at DESC);
UPDATE backup_jobs
SET user_id = (
SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1
)
WHERE user_id IS NULL
AND EXISTS (SELECT 1 FROM users WHERE role = 'admin');
`)
}
+1 -3
View File
@@ -38,9 +38,7 @@ export const VIDEO_EXTENSIONS = [
'flv',
'wmv',
'mpg',
'mpeg',
'ts',
'm3u8'
'mpeg'
] as const
/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */
+7
View File
@@ -131,6 +131,11 @@ describe('isVideoExtension', () => {
expect(isVideoExtension('mkv')).toBe(true)
})
it('rejects HLS sidecars from the user upload allowlist', () => {
expect(isVideoExtension('ts')).toBe(false)
expect(isVideoExtension('m3u8')).toBe(false)
})
it('rejects non-video extensions', () => {
expect(isVideoExtension('png')).toBe(false)
expect(isVideoExtension('pdf')).toBe(false)
@@ -153,6 +158,8 @@ describe('validateVideoKey', () => {
it('is covered by validateStorageKey', () => {
expect(validateStorageKey('videos/2026/10/a.mp4')).toBe(true)
expect(validateStorageKey('videos/2026/10/a/index.m3u8')).toBe(true)
expect(validateStorageKey('videos/2026/10/a/seg000.ts')).toBe(true)
})
})
+29 -11
View File
@@ -45,6 +45,7 @@ export interface ImageIndexRow {
thumb_key: string | null
source_deleted: number
transcode_preset: string | null
transcode_error: string | null
}
function keyToFilePath(key: string): string {
@@ -76,6 +77,7 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
thumbKey: row.thumb_key ?? null,
sourceDeleted: Number(row.source_deleted ?? 0) === 1,
transcodePreset: row.transcode_preset ?? null,
transcodeError: row.transcode_error ?? null,
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
}
}
@@ -230,7 +232,7 @@ export async function purgeOrphanImageIndexRows(): Promise<number> {
export function getImageIndexRow(key: string): ImageIndexRow | null {
ensureStorageSchema()
const row = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
WHERE key = ?
`).get(key) as ImageIndexRow | undefined
@@ -423,16 +425,26 @@ export function getImageTotals(): { count: number, bytes: number } {
export function listAllImageIndexRows(): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
ORDER BY key ASC
`).all() as unknown as ImageIndexRow[]
}
export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
WHERE user_id = ?
ORDER BY key ASC
`).all(userId) as unknown as ImageIndexRow[]
}
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
WHERE backend_id = ?
ORDER BY key ASC
@@ -456,6 +468,7 @@ export function updateImageTranscode(
sourceDeleted?: boolean
size?: number
preset?: string | null
error?: string | null
}
): void {
ensureStorageSchema()
@@ -466,7 +479,8 @@ export function updateImageTranscode(
transcode_progress = COALESCE(?, transcode_progress),
source_deleted = COALESCE(?, source_deleted),
size = COALESCE(?, size),
transcode_preset = COALESCE(?, transcode_preset)
transcode_preset = COALESCE(?, transcode_preset),
transcode_error = ?
WHERE key = ?
`).run(
input.status,
@@ -475,19 +489,23 @@ export function updateImageTranscode(
input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0),
input.size ?? null,
input.preset ?? null,
input.error ?? null,
key
)
}
/** 启动时恢复被中断的转码任务 */
export function listPendingTranscodeKeys(): string[] {
export function listPendingTranscodeRows(): Array<{
key: string
preset: string | null
}> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key FROM images
SELECT key, transcode_preset FROM images
WHERE media_type = 'video' AND transcode_status IN ('pending', 'running')
ORDER BY uploaded_at ASC
`).all() as Array<{ key: string }>
return rows.map(row => row.key)
`).all() as Array<{ key: string, transcode_preset: string | null }>
return rows.map(row => ({ key: row.key, preset: row.transcode_preset }))
}
/** 转码已完成但仍保留源文件的视频(用于补删历史遗留) */
@@ -669,7 +687,7 @@ export async function listImages(options: {
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
@@ -715,7 +733,7 @@ export async function searchImages(options: {
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
@@ -849,7 +867,7 @@ export async function getUserScopedStorageStats(
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_error
FROM images
WHERE media_type = 'image'
ORDER BY uploaded_at ASC
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { LocalStorageBackend } from './storage/local'
import type { ImageIndexRow } from './image-index'
import { collectMediaAssetKeys } from './media-assets'
const row: ImageIndexRow = {
key: 'videos/2026/10/example.mp4',
backend_id: 'local',
user_id: 1,
folder: 'videos',
original_name: 'example.mp4',
content_type: 'video/mp4',
size: 100,
uploaded_at: '2026-10-17T00:00:00.000Z',
hls_key: 'videos/2026/10/example/index.m3u8',
transcode_status: 'done',
transcode_progress: 100,
thumb_key: 'videos/2026/10/example.webp',
source_deleted: 0,
transcode_preset: 'veryfast',
transcode_error: null
}
async function createBackend() {
const backend = new LocalStorageBackend()
const meta = {
originalName: 'example',
uploadedAt: row.uploaded_at,
contentType: 'application/octet-stream',
size: 0,
userId: row.user_id,
mediaType: 'video' as const
}
await backend.put(row.key, new Uint8Array([1]), meta)
await backend.put(row.thumb_key!, new Uint8Array([2]), meta)
await backend.put(
row.hls_key!,
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
meta
)
await backend.put('videos/2026/10/example/seg000.ts', new Uint8Array([3]), meta)
return backend
}
describe('collectMediaAssetKeys', () => {
it('includes source, thumbnail, playlist and HLS segments', async () => {
const backend = await createBackend()
expect(new Set(await collectMediaAssetKeys(row, backend))).toEqual(new Set([
row.key,
row.thumb_key,
row.hls_key,
'videos/2026/10/example/seg000.ts'
]))
})
it('omits the source when transcoding already removed it', async () => {
const backend = await createBackend()
expect(new Set(await collectMediaAssetKeys({
...row,
source_deleted: 1
}, backend))).toEqual(new Set([
row.thumb_key,
row.hls_key,
'videos/2026/10/example/seg000.ts'
]))
})
})
+36
View File
@@ -0,0 +1,36 @@
import type { ImageIndexRow } from './image-index'
import type { StorageBackend } from './storage/types'
import { hlsSegmentNames } from './transcode'
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Uint8Array))
}
return Buffer.concat(chunks).toString('utf8')
}
/** 一个媒体记录对应的全部存储对象,包括原文件、缩略图和 HLS 附属文件。 */
export async function collectMediaAssetKeys(
row: ImageIndexRow,
backend: Pick<StorageBackend, 'createStream'>
): Promise<string[]> {
const keys = new Set<string>()
if (Number(row.source_deleted ?? 0) !== 1) {
keys.add(row.key)
}
if (row.thumb_key) {
keys.add(row.thumb_key)
}
if (row.hls_key) {
keys.add(row.hls_key)
const dir = row.hls_key.slice(0, row.hls_key.lastIndexOf('/'))
const playlist = await streamToString(await backend.createStream(row.hls_key))
for (const name of hlsSegmentNames(playlist)) {
keys.add(`${dir}/${name}`)
}
}
return [...keys]
}
+24
View File
@@ -94,6 +94,7 @@ const STORAGE_SCHEMA_TRANSCODE_PROGRESS_KEY = 'storage_schema_transcode_progress
const STORAGE_SCHEMA_THUMB_KEY = 'storage_schema_thumb'
const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted'
const STORAGE_SCHEMA_TRANSCODE_PRESET_KEY = 'storage_schema_transcode_preset'
const STORAGE_SCHEMA_TRANSCODE_ERROR_KEY = 'storage_schema_transcode_error'
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
function migrateImagesMediaType(database: DatabaseSync): void {
@@ -232,6 +233,27 @@ function migrateImagesTranscodePreset(database: DatabaseSync): void {
`).run(STORAGE_SCHEMA_TRANSCODE_PRESET_KEY, new Date().toISOString())
}
/** images 表新增「转码失败原因」列 */
function migrateImagesTranscodeError(database: DatabaseSync): void {
const migrated = database.prepare(`
SELECT value FROM settings WHERE key = ?
`).get(STORAGE_SCHEMA_TRANSCODE_ERROR_KEY) as { value?: string } | undefined
if (migrated?.value === '1') return
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
name: string
}>
if (!columns.some(column => column.name === 'transcode_error')) {
database.exec('ALTER TABLE images ADD COLUMN transcode_error TEXT')
}
database.prepare(`
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, '1', ?)
`).run(STORAGE_SCHEMA_TRANSCODE_ERROR_KEY, new Date().toISOString())
}
function migrateStorageTables(database: DatabaseSync): void {
database.exec(`
CREATE TABLE IF NOT EXISTS storage_backends (
@@ -263,6 +285,7 @@ function migrateStorageTables(database: DatabaseSync): void {
thumb_key TEXT,
source_deleted INTEGER NOT NULL DEFAULT 0,
transcode_preset TEXT,
transcode_error TEXT,
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
);
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
@@ -284,6 +307,7 @@ function migrateStorageTables(database: DatabaseSync): void {
migrateImagesThumb(database)
migrateImagesSourceDeleted(database)
migrateImagesTranscodePreset(database)
migrateImagesTranscodeError(database)
database.exec(`
CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type);
`)
+12 -2
View File
@@ -38,6 +38,15 @@ function contentTypeFromKey(key: string): string {
}
}
function decodeMetadata(value: string | undefined): string | undefined {
if (!value) return value
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function buildObjectKey(prefix: string | undefined, key: string): string {
const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? ''
return trimmed ? `${trimmed}/${key}` : key
@@ -125,7 +134,8 @@ export class S3StorageBackend implements StorageBackend {
Body: bytes,
ContentType: meta.contentType,
Metadata: {
originalName: meta.originalName,
// S3 metadata headers must be ASCII-safe.
originalName: encodeURIComponent(meta.originalName),
uploadedAt: meta.uploadedAt,
userId: meta.userId != null ? String(meta.userId) : ''
}
@@ -144,7 +154,7 @@ export class S3StorageBackend implements StorageBackend {
const uploadedAt = response.Metadata?.uploadedat
?? response.LastModified?.toISOString()
?? new Date().toISOString()
const originalName = response.Metadata?.originalname
const originalName = decodeMetadata(response.Metadata?.originalname)
?? key.split('/').pop()
?? 'image'
const userIdRaw = response.Metadata?.userid
+2
View File
@@ -25,6 +25,8 @@ export interface StoredImage extends StoredImageMeta {
transcodeProgress?: number
/** 本视频使用的转码档位 */
transcodePreset?: string | null
/** 转码失败原因 */
transcodeError?: string | null
/** 视频封面缩略图 key */
thumbKey?: string | null
/** 转码成功后源视频是否已删除 */
+57
View File
@@ -1,13 +1,20 @@
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
cleanupExpiredHlsDownloads,
DEFAULT_TRANSCODE_CONCURRENCY,
DEFAULT_TRANSCODE_PRESET,
DEFAULT_TRANSCODE_THREADS,
getTranscodeConcurrency,
getTranscodeSettings,
hlsSegmentNames,
hlsPlaylistKey,
markHlsDownloadForCleanup,
parseTranscodePreset,
setTranscodeSettings,
summarizeTranscodeFailure,
thumbKeyFor
} from './transcode'
import { isVideoSidecarKey, mediaTypeForKey } from './image-key'
@@ -39,6 +46,44 @@ describe('isVideoSidecarKey', () => {
})
})
describe('hlsSegmentNames', () => {
it('returns unique relative segment names and ignores tags', () => {
expect(hlsSegmentNames([
'#EXTM3U',
'#EXTINF:6,',
'seg000.ts',
'#EXTINF:6,',
'seg001.ts',
'seg000.ts'
].join('\n'))).toEqual(['seg000.ts', 'seg001.ts'])
})
it('ignores absolute and traversal paths', () => {
expect(hlsSegmentNames([
'../secret.ts',
'https://example.com/video.ts',
'folder/seg.ts',
'seg.ts'
].join('\n'))).toEqual(['seg.ts'])
})
})
describe('cleanupExpiredHlsDownloads', () => {
it('keeps fresh downloads and removes files after their marker expires', async () => {
const now = Date.now()
const fresh = await fs.mkdtemp(join(tmpdir(), 'pichost-hls-download-fresh-'))
const expired = await fs.mkdtemp(join(tmpdir(), 'pichost-hls-download-expired-'))
await markHlsDownloadForCleanup(fresh, now + 60_000)
await markHlsDownloadForCleanup(expired, now - 1)
await cleanupExpiredHlsDownloads(now)
await expect(fs.access(fresh)).resolves.toBeUndefined()
await expect(fs.access(expired)).rejects.toThrow()
await fs.rm(fresh, { recursive: true, force: true })
})
})
describe('mediaTypeForKey', () => {
it('maps folders to media types', () => {
expect(mediaTypeForKey('images/2026/10/a.webp')).toBe('image')
@@ -47,6 +92,18 @@ describe('mediaTypeForKey', () => {
})
})
describe('summarizeTranscodeFailure', () => {
it('recognizes a missing FFmpeg binary', () => {
expect(summarizeTranscodeFailure(new Error('spawn ffmpeg ENOENT')))
.toBe('ffmpeg-missing')
})
it('maps other FFmpeg errors to unknown', () => {
const error = new Error(`first line\n${'invalid data '.repeat(20)}`)
expect(summarizeTranscodeFailure(error)).toBe('unknown')
})
})
describe('transcode settings', () => {
it('defaults to one concurrent job with one thread', () => {
expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1)
+194 -37
View File
@@ -5,10 +5,11 @@ import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import sharp from 'sharp'
import { getDataDir } from './data-dir'
import {
listAllImageIndexRows,
listPendingTranscodeKeys,
listPendingTranscodeRows,
listTranscodedSourceRows,
updateImageTranscode
} from './image-index'
@@ -37,6 +38,9 @@ import {
/** HLS 分片时长(秒) */
const HLS_SEGMENT_TIME = 6
const HLS_DOWNLOAD_PREFIX = 'pichost-hls-download-'
export const HLS_DOWNLOAD_RETENTION_MS = 60 * 60 * 1000
const HLS_DOWNLOAD_MARKER = '.expires'
export const DEFAULT_TRANSCODE_CONCURRENCY = 1
export const DEFAULT_TRANSCODE_THREADS = 1
@@ -56,6 +60,21 @@ export const DEFAULT_TRANSCODE_BITRATE_PERCENT = 80
export const MIN_TRANSCODE_BITRATE_PERCENT = 10
export const MAX_TRANSCODE_BITRATE_PERCENT = 100
export interface TranscodeJobOptions {
preset: TranscodePreset
bitratePercent: number
}
/** 将底层错误归纳成适合界面展示的简短原因 */
export function summarizeTranscodeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (/spawn ffmpeg ENOENT|ffmpeg.*(not found|ENOENT)|ENOENT.*ffmpeg/i.test(message)) {
return 'ffmpeg-missing'
}
if (/permission denied/i.test(message)) return 'permission-denied'
return 'unknown'
}
export function getTranscodeBitratePercent(): number {
return parseBoundedInt(
getSetting(SETTINGS_TRANSCODE_BITRATE),
@@ -84,6 +103,21 @@ function parseBoundedInt(
return value
}
function resolveTranscodeJobOptions(
options: Partial<TranscodeJobOptions> = {}
): TranscodeJobOptions {
const settings = getTranscodeSettings()
const bitratePercent = Number.isInteger(options.bitratePercent)
&& options.bitratePercent! >= MIN_TRANSCODE_BITRATE_PERCENT
&& options.bitratePercent! <= MAX_TRANSCODE_BITRATE_PERCENT
? options.bitratePercent!
: settings.bitratePercent
return {
preset: options.preset ?? settings.preset,
bitratePercent
}
}
export interface TranscodeSettings {
concurrency: number
threads: number
@@ -162,6 +196,19 @@ export function thumbKeyFor(videoKey: string): string {
return `${stripExtension(videoKey)}.webp`
}
/** HLS 播放列表中的相对分片文件名 */
export function hlsSegmentNames(playlistText: string): string[] {
const names = new Set<string>()
for (const line of playlistText.split('\n')) {
const name = line.trim()
if (!name || name.startsWith('#') || name === '.' || name === '..' || /[/\\]/.test(name)) {
continue
}
names.add(name)
}
return [...names]
}
function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', [
@@ -172,8 +219,7 @@ function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Pr
'-i', inputPath,
'-frames:v', '1',
'-vf', 'scale=640:-2',
'-c:v', 'libwebp',
'-quality', '80',
'-c:v', 'png',
outputPath
], { windowsHide: true })
@@ -203,6 +249,7 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`)
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
const framePath = join(workDir, 'thumb.png')
const outputPath = join(workDir, 'thumb.webp')
await fs.mkdir(workDir, { recursive: true })
@@ -211,11 +258,12 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
await streamToFile(await createImageStream(videoKey), inputPath)
try {
await runThumbFfmpeg(inputPath, outputPath, 1)
await runThumbFfmpeg(inputPath, framePath, 1)
} catch {
await runThumbFfmpeg(inputPath, outputPath, 0)
await runThumbFfmpeg(inputPath, framePath, 0)
}
await sharp(framePath).webp({ quality: 80 }).toFile(outputPath)
const bytes = await fs.readFile(outputPath)
const thumbKey = thumbKeyFor(videoKey)
const backend = await getBackendForKey(videoKey)
@@ -318,9 +366,10 @@ function runFfmpeg(
outputDir: string,
sourceBitrate: number,
sourceAudioBitrate: number,
options: TranscodeJobOptions,
onProgressUs: (outTimeUs: number) => void
): Promise<void> {
const { preset, bitratePercent } = getTranscodeSettings()
const { preset, bitratePercent } = options
// 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k)
const audioBitrate = sourceAudioBitrate > 0
@@ -419,12 +468,96 @@ async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
return Buffer.concat(chunks).toString('utf8')
}
function remuxPlaylistToMp4(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', [
'-hide_banner',
'-loglevel', 'error',
'-y',
'-i', inputPath,
'-c', 'copy',
'-movflags', '+faststart',
outputPath
], { windowsHide: true })
let stderr = ''
child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) {
resolve()
return
}
const detail = stderr.trim().split('\n').filter(Boolean).pop()
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
})
})
}
/** 下载 HLS 清单和分片到临时目录,不转码,重新封装为 MP4 */
export async function remuxHlsToMp4(playlistKey: string, workDir: string): Promise<string> {
if (!validateStorageKey(playlistKey) || !playlistKey.endsWith('.m3u8')) {
throw new Error('无效的 HLS 播放列表')
}
const playlistText = await streamToString(await createImageStream(playlistKey))
const segmentNames = hlsSegmentNames(playlistText)
if (!segmentNames.length) throw new Error('HLS 播放列表没有视频分片')
const dirKey = playlistKey.slice(0, playlistKey.lastIndexOf('/'))
await fs.mkdir(workDir, { recursive: true })
await fs.writeFile(join(workDir, 'index.m3u8'), playlistText)
for (const name of segmentNames) {
const segmentKey = `${dirKey}/${name}`
if (!validateStorageKey(segmentKey)) throw new Error('HLS 分片路径无效')
await streamToFile(await createImageStream(segmentKey), join(workDir, name))
}
const outputPath = join(workDir, 'video.mp4')
await remuxPlaylistToMp4(join(workDir, 'index.m3u8'), outputPath)
return outputPath
}
/** 标记 HLS 下载临时文件,满一小时后允许清理 */
export async function markHlsDownloadForCleanup(
workDir: string,
expiresAt = Date.now() + HLS_DOWNLOAD_RETENTION_MS
): Promise<void> {
await fs.writeFile(join(workDir, HLS_DOWNLOAD_MARKER), String(expiresAt))
}
/** 删除已经到达过期时间的 HLS 下载临时目录 */
export async function cleanupExpiredHlsDownloads(now = Date.now()): Promise<number> {
let removed = 0
try {
const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(HLS_DOWNLOAD_PREFIX)) continue
const path = join(tmpdir(), entry.name)
const expiresAt = Number(
await fs.readFile(join(path, HLS_DOWNLOAD_MARKER), 'utf8').catch(() => '')
)
if (!Number.isFinite(expiresAt) || expiresAt > now) continue
await fs.rm(path, { recursive: true, force: true })
removed++
}
} catch {
// 清理失败不影响新下载
}
return removed
}
/** 把视频转成 HLS 并写入存储,返回播放列表 key 与产物总大小 */
export async function transcodeVideoToHls(
videoKey: string
videoKey: string,
jobOptions?: Partial<TranscodeJobOptions>
): Promise<{ playlistKey: string, totalBytes: number }> {
const stored = await headImage(videoKey)
if (!stored) throw new Error('视频不存在')
const options = resolveTranscodeJobOptions(jobOptions)
const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`)
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
@@ -445,17 +578,25 @@ export async function transcodeVideoToHls(
const sourceAudioBitrate = await probeAudioBitrate(inputPath)
let lastProgressAt = 0
await runFfmpeg(videoKey, inputPath, outputDir, sourceBitrate, sourceAudioBitrate, (outTimeUs) => {
if (!durationSec) return
const now = Date.now()
if (now - lastProgressAt < 1000) return
lastProgressAt = now
const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100)
updateImageTranscode(videoKey, {
status: 'running',
progress: Math.min(99, Math.max(1, percent))
})
})
await runFfmpeg(
videoKey,
inputPath,
outputDir,
sourceBitrate,
sourceAudioBitrate,
options,
(outTimeUs) => {
if (!durationSec) return
const now = Date.now()
if (now - lastProgressAt < 1000) return
lastProgressAt = now
const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100)
updateImageTranscode(videoKey, {
status: 'running',
progress: Math.min(99, Math.max(1, percent))
})
}
)
const uploadedAt = new Date().toISOString()
@@ -503,10 +644,8 @@ export async function removeHlsArtifacts(videoKey: string): Promise<void> {
const dirKey = stripExtension(videoKey)
const text = await streamToString(await createImageStream(playlistKey))
for (const line of text.split('\n')) {
const name = line.trim()
if (!name || name.startsWith('#')) continue
const segmentKey = `${dirKey}/${basename(name)}`
for (const name of hlsSegmentNames(text)) {
const segmentKey = `${dirKey}/${name}`
if (!validateStorageKey(segmentKey)) continue
await deleteImage(segmentKey).catch(() => {})
}
@@ -520,15 +659,20 @@ export async function removeHlsArtifacts(videoKey: string): Promise<void> {
}
}
const queue: string[] = []
interface QueuedTranscode extends TranscodeJobOptions {
videoKey: string
}
const queue: QueuedTranscode[] = []
const queued = new Set<string>()
let activeCount = 0
async function processOne(videoKey: string): Promise<void> {
async function processOne(job: QueuedTranscode): Promise<void> {
const { videoKey } = job
updateImageTranscode(videoKey, { status: 'running' })
try {
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey)
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
// 转码成功后源文件不再需要,直接删除以节省空间
const backend = await getBackendForKey(videoKey)
@@ -543,7 +687,10 @@ async function processOne(videoKey: string): Promise<void> {
})
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
} catch (error) {
updateImageTranscode(videoKey, { status: 'failed' })
updateImageTranscode(videoKey, {
status: 'failed',
error: summarizeTranscodeFailure(error)
})
logException('transcode failed', error, { key: videoKey })
}
}
@@ -551,10 +698,11 @@ async function processOne(videoKey: string): Promise<void> {
/** 按设置的并发数消费队列(默认 1,即串行) */
function drain(): void {
while (queue.length && activeCount < getTranscodeConcurrency()) {
const key = queue.shift()!
const job = queue.shift()!
const key = job.videoKey
activeCount += 1
void processOne(key).finally(() => {
void processOne(job).finally(() => {
activeCount -= 1
queued.delete(key)
drain()
@@ -562,25 +710,28 @@ function drain(): void {
}
}
export function enqueueTranscode(videoKey: string): void {
export function enqueueTranscode(
videoKey: string,
options: Partial<TranscodeJobOptions> = {}
): void {
if (!validateVideoKey(videoKey) || queued.has(videoKey)) return
const job = resolveTranscodeJobOptions(options)
queued.add(videoKey)
queue.push(videoKey)
queue.push({ videoKey, ...job })
// 记录本视频使用的档位,便于上传页展示
const preset = getTranscodeSettings().preset
updateImageTranscode(videoKey, {
status: 'pending',
// 压缩档带上百分比,例如 compress:80
preset: preset === 'compress'
? `compress:${getTranscodeBitratePercent()}`
: preset
preset: job.preset === 'compress'
? `compress:${job.bitratePercent}`
: job.preset
})
drain()
}
/** 取消排队中或正在转码的任务(删除视频时调用) */
export function cancelTranscode(videoKey: string): void {
const index = queue.indexOf(videoKey)
const index = queue.findIndex(job => job.videoKey === videoKey)
if (index >= 0) queue.splice(index, 1)
queued.delete(videoKey)
@@ -616,8 +767,14 @@ export async function cleanupTranscodedSources(): Promise<number> {
/** 启动时把上次中断的任务重新排队 */
export function resumePendingTranscodes(): void {
for (const key of listPendingTranscodeKeys()) {
enqueueTranscode(key)
for (const row of listPendingTranscodeRows()) {
const [presetName, bitrateRaw] = (row.preset ?? '').split(':')
const preset = parseTranscodePreset(presetName)
const bitratePercent = Number(bitrateRaw)
enqueueTranscode(row.key, {
...(preset ? { preset } : {}),
...(Number.isInteger(bitratePercent) ? { bitratePercent } : {})
})
}
}
+145
View File
@@ -0,0 +1,145 @@
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
getDb,
insertActivityLog,
insertUser
} from './db'
import {
listImageIndexRowsForUser,
updateImageThumb,
updateImageTranscode
} from './image-index'
import { ensureDefaultBackends } from './storage-backends'
import { getActiveBackend } from './storage/resolver'
import { putImage } from './storage'
import { createTag } from './tags'
import { clearUserStorage } from './user-storage'
import {
createBackupJob,
getBackupJob,
markJobDone
} from './backup/jobs'
import { getBackupsDir } from './backup/paths'
describe('clearUserStorage', () => {
it('deletes only the selected user media, tags, logs and cache', async () => {
ensureDefaultBackends()
const now = new Date().toISOString()
const userA = insertUser({
username: 'clear-user-a',
passwordHash: 'hash-a',
role: 'user',
createdAt: now
})
const userB = insertUser({
username: 'clear-user-b',
passwordHash: 'hash-b',
role: 'user',
createdAt: now
})
const keyA = 'videos/2026/10/clear-a.mp4'
const thumbA = 'videos/2026/10/clear-a.webp'
const hlsA = 'videos/2026/10/clear-a/index.m3u8'
const segmentA = 'videos/2026/10/clear-a/seg000.ts'
const keyB = 'images/2026/10/clear-b.webp'
const videoMeta = {
originalName: 'clear-a.mp4',
uploadedAt: now,
contentType: 'video/mp4',
size: 1,
userId: userA,
mediaType: 'video' as const
}
await putImage(keyA, new Uint8Array([1]), videoMeta)
const backend = await getActiveBackend()
await backend.put(thumbA, new Uint8Array([2]), videoMeta)
await backend.put(
hlsA,
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
videoMeta
)
await backend.put(segmentA, new Uint8Array([3]), videoMeta)
updateImageThumb(keyA, thumbA)
updateImageTranscode(keyA, { status: 'done', hlsKey: hlsA, progress: 100 })
await putImage(keyB, new Uint8Array([4]), {
originalName: 'clear-b.webp',
uploadedAt: now,
contentType: 'image/webp',
size: 1,
userId: userB,
mediaType: 'image'
})
const tagA = createTag(userA, 'A tag')
const tagB = createTag(userB, 'B tag')
getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)').run(keyA, tagA.id)
getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)').run(keyB, tagB.id)
insertActivityLog({
action: 'upload',
key: keyA,
originalName: 'clear-a.mp4',
size: 1,
contentType: 'video/mp4',
source: 'web',
userId: userA
})
insertActivityLog({
action: 'upload',
key: keyB,
originalName: 'clear-b.webp',
size: 1,
contentType: 'image/webp',
source: 'web',
userId: userB
})
const cacheDir = await fs.mkdtemp(join(tmpdir(), `pichost-hls-download-${userA}-`))
const jobA = createBackupJob({ type: 'full_export', userId: userA })
const jobB = createBackupJob({ type: 'full_export', userId: userB })
const archiveA = join(getBackupsDir(), `clear-a-${jobA.id}.phost.tar.gz`)
const archiveB = join(getBackupsDir(), `clear-b-${jobB.id}.phost.tar.gz`)
await fs.mkdir(getBackupsDir(), { recursive: true })
await fs.writeFile(archiveA, 'a')
await fs.writeFile(archiveB, 'b')
markJobDone(jobA.id, { outputPath: archiveA })
markJobDone(jobB.id, { outputPath: archiveB })
const result = await clearUserStorage(userA)
expect(result.failed).toEqual([])
expect(result.deletedMedia).toBe(1)
expect(result.deletedObjects).toBe(4)
expect(result.deletedJobs).toBe(1)
expect(listImageIndexRowsForUser(userA)).toHaveLength(0)
expect(listImageIndexRowsForUser(userB)).toHaveLength(1)
expect(await backend.head(keyB)).not.toBeNull()
expect(await backend.head(keyA)).toBeNull()
expect(await backend.head(thumbA)).toBeNull()
expect(await backend.head(hlsA)).toBeNull()
expect(await backend.head(segmentA)).toBeNull()
await expect(fs.access(cacheDir)).rejects.toThrow()
const db = getDb()
expect(db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?').get(userA))
.toEqual({ count: 0 })
expect(db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?').get(userB))
.toEqual({ count: 1 })
expect(db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?').get(userA))
.toEqual({ count: 0 })
expect(db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?').get(userB))
.toEqual({ count: 1 })
expect(db.prepare('SELECT COUNT(*) AS count FROM image_tags WHERE image_key = ?').get(keyA))
.toEqual({ count: 0 })
expect(db.prepare('SELECT COUNT(*) AS count FROM image_tags WHERE image_key = ?').get(keyB))
.toEqual({ count: 1 })
expect(getBackupJob(jobA.id, userA)).toBeNull()
expect(getBackupJob(jobB.id, userB)).not.toBeNull()
await expect(fs.access(archiveA)).rejects.toThrow()
await expect(fs.access(archiveB)).resolves.toBeUndefined()
})
})
+173
View File
@@ -0,0 +1,173 @@
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { getDb } from './db'
import {
deleteImageIndex,
listImageIndexRowsForUser
} from './image-index'
import { getBackendForKey } from './storage/resolver'
import type { StorageBackend } from './storage/types'
import { cancelTranscode } from './transcode'
import { collectMediaAssetKeys } from './media-assets'
import {
deleteBackupJobsForUser,
getActiveBackupJob,
listAllBackupJobsForUser,
type BackupJob
} from './backup/jobs'
import {
getBackupsDir,
getBackupStagingDir,
isSafeBackupFilename
} from './backup/paths'
const activeUsers = new Set<number>()
export interface ClearUserStorageResult {
deletedMedia: number
deletedObjects: number
deletedTags: number
deletedLogs: number
clearedCacheDirs: number
deletedJobs: number
failed: Array<{ key: string, message: string }>
}
async function deleteObject(
backend: StorageBackend,
key: string
): Promise<boolean> {
if (!await backend.head(key)) return false
await backend.delete(key)
return true
}
async function clearHlsDownloadCache(userId: number): Promise<number> {
let removed = 0
const prefix = `pichost-hls-download-${userId}-`
try {
const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue
await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true })
removed++
}
} catch {
// Cache cleanup failure does not affect the media cleanup result.
}
return removed
}
async function removeBackupJobArtifacts(job: BackupJob): Promise<void> {
await fs.rm(getBackupStagingDir(job.id), { recursive: true, force: true }).catch(() => {})
const paths = [
job.outputPath,
typeof job.payload.archivePath === 'string' ? job.payload.archivePath : null
]
for (const path of paths) {
if (!path || !isSafeBackupFilename(basename(path))) continue
if (resolve(dirname(path)) !== resolve(getBackupsDir())) continue
await fs.rm(path, { force: true }).catch(() => {})
}
}
export async function clearUserStorage(userId: number): Promise<ClearUserStorageResult> {
if (activeUsers.has(userId)) {
throw new Error('USER_STORAGE_CLEAR_IN_PROGRESS')
}
activeUsers.add(userId)
const result: ClearUserStorageResult = {
deletedMedia: 0,
deletedObjects: 0,
deletedTags: 0,
deletedLogs: 0,
clearedCacheDirs: 0,
deletedJobs: 0,
failed: []
}
try {
if (getActiveBackupJob(userId)) {
throw new Error('USER_STORAGE_CLEAR_ACTIVE_JOB')
}
const backupJobs = listAllBackupJobsForUser(userId)
const rows = listImageIndexRowsForUser(userId)
for (const row of rows) {
try {
cancelTranscode(row.key)
const backend = await getBackendForKey(row.key)
let keys: string[]
try {
keys = await collectMediaAssetKeys(row, backend)
} catch {
keys = [
...(Number(row.source_deleted ?? 0) !== 1 ? [row.key] : []),
...(row.thumb_key ? [row.thumb_key] : []),
...(row.hls_key ? [row.hls_key] : [])
]
}
for (const key of new Set(keys)) {
if (await deleteObject(backend, key)) result.deletedObjects++
}
deleteImageIndex(row.key)
result.deletedMedia++
} catch (error) {
result.failed.push({
key: row.key,
message: error instanceof Error ? error.message : '删除失败'
})
}
}
const db = getDb()
db.prepare(`
DELETE FROM image_tags
WHERE image_key NOT IN (SELECT key FROM images)
`).run()
if (!result.failed.length) {
const tagCount = db.prepare('SELECT COUNT(*) AS count FROM tags WHERE user_id = ?')
.get(userId) as { count: number }
const logCount = db.prepare('SELECT COUNT(*) AS count FROM activity_logs WHERE user_id = ?')
.get(userId) as { count: number }
db.exec('BEGIN')
try {
db.prepare(`
DELETE FROM image_tags
WHERE tag_id IN (SELECT id FROM tags WHERE user_id = ?)
`).run(userId)
db.prepare('DELETE FROM tags WHERE user_id = ?').run(userId)
db.prepare('DELETE FROM activity_logs WHERE user_id = ?').run(userId)
db.prepare(`
UPDATE users
SET auto_delete_days = 0, auto_delete_enabled_at = NULL
WHERE id = ?
`).run(userId)
db.exec('COMMIT')
result.deletedTags = Number(tagCount.count)
result.deletedLogs = Number(logCount.count)
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
if (!result.failed.length) {
for (const job of backupJobs) {
await removeBackupJobArtifacts(job)
}
result.deletedJobs = deleteBackupJobsForUser(userId)
}
result.clearedCacheDirs = await clearHlsDownloadCache(userId)
return result
} finally {
activeUsers.delete(userId)
}
}