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
+2689 -311

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>
+6 -15
View File
@@ -17,6 +17,7 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences(props.kind) const { copyFormat } = useUploadPreferences(props.kind)
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
@@ -102,22 +103,11 @@ const presetLabel = computed(() => {
<template> <template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm"> <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"> <div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<img <VideoThumbnail
v-if="kind === 'video' && item.thumbUrl" v-if="kind === 'video'"
:src="thumbLink" :src="thumbLink"
:alt="item.originalName" :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 <div
v-else v-else
class="flex size-full items-center justify-center" class="flex size-full items-center justify-center"
@@ -174,9 +164,10 @@ const presetLabel = computed(() => {
color="error" color="error"
variant="subtle" variant="subtle"
size="xs" size="xs"
class="shrink-0" class="max-w-48 shrink-0 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
> >
{{ t('library.transcodeFailed') }} {{ transcodeFailureLabel(item.transcodeError) }}
</UBadge> </UBadge>
<UBadge <UBadge
v-else-if="item.hlsUrl" v-else-if="item.hlsUrl"
+15 -21
View File
@@ -16,11 +16,13 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'play': [] 'play': []
'delete': [] 'delete': []
'download': []
'update:selected': [value: boolean] 'update:selected': [value: boolean]
}>() }>()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
/** 转码完成后按 HLS 播放列表展示 */ /** 转码完成后按 HLS 播放列表展示 */
const displayName = computed(() => { const displayName = computed(() => {
@@ -37,34 +39,15 @@ const uploadedLabel = computed(() => {
return props.item.uploadedAt return props.item.uploadedAt
} }
}) })
const thumbFailed = ref(false)
watch(() => props.item.thumbUrl, () => {
thumbFailed.value = false
})
</script> </script>
<template> <template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated"> <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"> <div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
<img <VideoThumbnail
v-if="item.thumbUrl && !thumbFailed"
:src="thumbLink" :src="thumbLink"
:alt="displayName" :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 <button
type="button" type="button"
@@ -108,8 +91,10 @@ watch(() => props.item.thumbUrl, () => {
color="error" color="error"
variant="solid" variant="solid"
size="xs" size="xs"
class="max-w-48 truncate"
:title="transcodeFailureLabel(item.transcodeError)"
> >
{{ t('library.transcodeFailed') }} {{ transcodeFailureLabel(item.transcodeError) }}
</UBadge> </UBadge>
</div> </div>
</div> </div>
@@ -162,6 +147,15 @@ watch(() => props.item.thumbUrl, () => {
rel="noopener" rel="noopener"
:aria-label="t('files.download')" :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 <UButton
icon="i-lucide-trash-2" icon="i-lucide-trash-2"
variant="ghost" variant="ghost"
+30 -11
View File
@@ -7,6 +7,7 @@ const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast() const toast = useToast()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags() const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const transcodeFailureLabel = useTranscodeFailureLabel()
const items = ref<FileItem[]>([]) const items = ref<FileItem[]>([])
const total = ref(0) const total = ref(0)
@@ -27,6 +28,8 @@ const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false) const deleting = ref(false)
const previewOpen = ref(false) const previewOpen = ref(false)
const previewItem = ref<FileItem | null>(null) const previewItem = ref<FileItem | null>(null)
const downloadOpen = ref(false)
const downloadItem = ref<FileItem | null>(null)
const currentBackend = ref('all') const currentBackend = ref('all')
const currentSource = ref('all') const currentSource = ref('all')
@@ -66,7 +69,7 @@ function videoLink(item: FileItem): string {
} }
function thumbLink(item: FileItem): string { function thumbLink(item: FileItem): string {
return withToken(item.thumbUrl) return withToken(item.thumbUrl) || '/video-placeholder.svg'
} }
function downloadUrl(item: FileItem): string { function downloadUrl(item: FileItem): string {
@@ -264,6 +267,13 @@ function openPreview(item: FileItem) {
previewOpen.value = true previewOpen.value = true
} }
function requestDownload(item: FileItem | null) {
if (!item?.hlsUrl) return
previewOpen.value = false
downloadItem.value = item
downloadOpen.value = true
}
onMounted(() => { onMounted(() => {
void loadBackendOptions() void loadBackendOptions()
}) })
@@ -514,6 +524,7 @@ defineExpose({ refresh })
:selected="selectedKeys.has(item.key)" :selected="selectedKeys.has(item.key)"
@update:selected="toggleSelected(item.key, $event)" @update:selected="toggleSelected(item.key, $event)"
@play="openPreview(item)" @play="openPreview(item)"
@download="requestDownload(item)"
@delete="requestDelete(item)" @delete="requestDelete(item)"
/> />
</div> </div>
@@ -577,17 +588,9 @@ defineExpose({ refresh })
:aria-label="t('library.play')" :aria-label="t('library.play')"
@click="openPreview(item)" @click="openPreview(item)"
> >
<img <VideoThumbnail
v-if="item.thumbUrl"
:src="thumbLink(item)" :src="thumbLink(item)"
:alt="displayName(item)" :alt="displayName(item)"
loading="lazy"
class="size-full object-cover"
>
<UIcon
v-else
name="i-lucide-film"
class="size-5 text-dimmed"
/> />
</button> </button>
<p <p
@@ -611,8 +614,9 @@ defineExpose({ refresh })
variant="subtle" variant="subtle"
size="xs" size="xs"
class="shrink-0" class="shrink-0"
:title="transcodeFailureLabel(item.transcodeError)"
> >
{{ t('library.transcodeFailed') }} {{ transcodeFailureLabel(item.transcodeError) }}
</UBadge> </UBadge>
</div> </div>
</td> </td>
@@ -695,6 +699,15 @@ defineExpose({ refresh })
rel="noopener" rel="noopener"
:aria-label="t('files.download')" :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 <UButton
size="xs" size="xs"
variant="ghost" variant="ghost"
@@ -746,7 +759,13 @@ defineExpose({ refresh })
:thumb-link="previewItem ? thumbLink(previewItem) : ''" :thumb-link="previewItem ? thumbLink(previewItem) : ''"
:download-link="previewItem ? downloadUrl(previewItem) : ''" :download-link="previewItem ? downloadUrl(previewItem) : ''"
:deleting="deleting" :deleting="deleting"
@download="requestDownload(previewItem)"
@delete="previewItem && requestDelete(previewItem)" @delete="previewItem && requestDelete(previewItem)"
/> />
<HlsDownloadModal
v-model:open="downloadOpen"
:item="downloadItem"
/>
</div> </div>
</template> </template>
+12 -1
View File
@@ -20,11 +20,13 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:open': [value: boolean] 'update:open': [value: boolean]
'delete': [] 'delete': []
'download': []
}>() }>()
const toast = useToast() const toast = useToast()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const transcodeFailureLabel = useTranscodeFailureLabel()
const { copyFormat } = useUploadPreferences('video') const { copyFormat } = useUploadPreferences('video')
const videoEl = ref<HTMLVideoElement | null>(null) const videoEl = ref<HTMLVideoElement | null>(null)
@@ -80,7 +82,7 @@ const transcodeLabel = computed(() => {
case 'done': case 'done':
return t('library.video.transcodeDone') return t('library.video.transcodeDone')
case 'failed': case 'failed':
return t('library.transcodeFailed') return transcodeFailureLabel(item.transcodeError)
default: default:
return t('library.video.transcodeNone') return t('library.video.transcodeNone')
} }
@@ -498,6 +500,15 @@ function downloadOriginal() {
size="sm" size="sm"
@click="downloadOriginal" @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 <UButton
:label="t('image.previewCopyLink')" :label="t('image.previewCopyLink')"
icon="i-lucide-link" 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 (!isDebugParam(param)) continue
if (param.type === 'file') { if (param.type === 'file') {
files[param.name] = null files[param.name] = null
} else if (param.name === 'kind') {
values[param.name] = props.endpoint.category === 'video' ? 'video' : 'file'
} else if (param.name === 'keys') { } 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) { } else if (param.name === 'token' && props.token) {
values[param.name] = props.token values[param.name] = props.token
} else { } else {
@@ -84,6 +90,12 @@ function fileLabel(name: string) {
return fileValues.value[name]?.name ?? t('api.debug.noFileSelected') 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) { function triggerFilePick(name: string) {
fileInputRefs.value[name]?.click() fileInputRefs.value[name]?.click()
} }
@@ -101,8 +113,8 @@ function isParamFilled(param: ApiDocParam) {
if (!value) return false if (!value) return false
if (param.location === 'body' && param.name === 'keys') { if (param.location === 'body' && param.name === 'keys') {
try { try {
const parsed = JSON.parse(value) as { keys?: unknown } const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed.keys) && parsed.keys.length > 0 return Array.isArray(parsed) && parsed.length > 0
} catch { } catch {
return false return false
} }
@@ -159,7 +171,14 @@ async function sendRequest() {
} else if (debugBodyParams.value.some(p => p.location === 'body')) { } else if (debugBodyParams.value.some(p => p.location === 'body')) {
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
try { 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 { } catch {
toast.add({ title: t('api.debug.invalidJson'), color: 'error' }) toast.add({ title: t('api.debug.invalidJson'), color: 'error' })
sending.value = false sending.value = false
@@ -297,7 +316,7 @@ const responseScrollable = computed(() => responseBody.value.split('\n').length
<input <input
:ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }" :ref="(el) => { fileInputRefs[param.name] = el as HTMLInputElement }"
type="file" type="file"
accept="image/*" :accept="fileAccept()"
class="hidden" class="hidden"
@change="onFileChange(param.name, $event)" @change="onFileChange(param.name, $event)"
> >
+45 -5
View File
@@ -20,9 +20,18 @@ const docsUrl = computed(() =>
const categories: { key: ApiDocCategory, labelKey: string }[] = [ const categories: { key: ApiDocCategory, labelKey: string }[] = [
{ key: 'image', labelKey: 'api.categories.image' }, { 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: 'extension', labelKey: 'api.categories.extension' },
{ key: 'system', labelKey: 'api.categories.system' } { 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 filteredEndpoints = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
@@ -38,6 +47,17 @@ function endpointsForCategory(category: ApiDocCategory) {
return filteredEndpoints.value.filter(endpoint => endpoint.category === category) 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) { function onSelect(id: string) {
emit('select', id) emit('select', id)
} }
@@ -59,6 +79,15 @@ onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown) window.addEventListener('keydown', onGlobalKeydown)
onUnmounted(() => window.removeEventListener('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> </script>
<template> <template>
@@ -89,13 +118,24 @@ onMounted(() => {
:key="category.key" :key="category.key"
class="mb-4" class="mb-4"
> >
<p <button
v-if="endpointsForCategory(category.key).length" 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 <li
v-for="endpoint in endpointsForCategory(category.key)" v-for="endpoint in endpointsForCategory(category.key)"
:key="endpoint.id" :key="endpoint.id"
+13 -2
View File
@@ -53,6 +53,12 @@ function progressPercent(job: BackupJobItem): number {
return Math.min(100, Math.round((job.progress / job.total) * 100)) 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 { function jobMessage(job: BackupJobItem): string {
if (job.status === 'failed') { if (job.status === 'failed') {
return job.error || job.message || t('storage.backup.jobFailedUnknown') return job.error || job.message || t('storage.backup.jobFailedUnknown')
@@ -174,7 +180,7 @@ function formatTime(value: string) {
class="text-xs text-dimmed" class="text-xs text-dimmed"
>—</span> >—</span>
</td> </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="flex items-center gap-2">
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted"> <div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div <div
@@ -183,11 +189,16 @@ function formatTime(value: string) {
:style="{ width: `${progressPercent(job)}%` }" :style="{ width: `${progressPercent(job)}%` }"
/> />
</div> </div>
<span class="shrink-0 text-xs tabular-nums text-muted"> <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-if="job.total">{{ job.progress }}/{{ job.total }}</template>
<template v-else-if="job.status === 'done'">100%</template> <template v-else-if="job.status === 'done'">100%</template>
<template v-else>—</template> <template v-else>—</template>
</span> </span>
<span class="text-[10px] text-dimmed">
{{ progressUnit(job) }}
</span>
</span>
</div> </div>
</td> </td>
<td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5"> <td class="px-4 py-3.5 whitespace-nowrap text-xs text-muted sm:px-5">
+630 -1
View File
@@ -1,5 +1,5 @@
export type ApiDocMethod = 'GET' | 'POST' | 'DELETE' 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 type ApiParamLocation = 'form-data' | 'query' | 'header' | 'body'
export interface ApiDocParam { export interface ApiDocParam {
@@ -251,6 +251,635 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
"success": true, "success": true,
"deleted": ["images/2026/08/a.webp"], "deleted": ["images/2026/08/a.webp"],
"failed": [] "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 { function thumbLink(item: FileItem): string {
const base = item.thumbUrl const base = item.thumbUrl
if (!base) return '' if (!base) return '/video-placeholder.svg'
if (accessMode.value !== 'token' || !apiToken.value) return base if (accessMode.value !== 'token' || !apiToken.value) return base
const separator = base.includes('?') ? '&' : '?' const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}` 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 deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('') const deleteConfirmName = ref('')
const deleting = ref(false) 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) const backupPanelRef = ref<{ applySyncDraft: (draft: { fromBackendId: string, toBackendId: string }) => void } | null>(null)
@@ -389,11 +400,36 @@ watch(isAuthenticated, async (authed) => {
<StorageBackupPanel <StorageBackupPanel
v-if="!storageData.envOverride" v-if="!storageData.envOverride"
:key="backupPanelKey"
ref="backupPanelRef" ref="backupPanelRef"
:backends="storageData.backends" :backends="storageData.backends"
/> />
<StorageSupportedBackends /> <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> </template>
</section> </section>
</AppShell> </AppShell>
@@ -447,5 +483,10 @@ watch(isAuthenticated, async (authed) => {
</div> </div>
</template> </template>
</UModal> </UModal>
<ClearStorageModal
v-model:open="clearStorageOpen"
@cleared="handleStorageCleared"
/>
</div> </div>
</template> </template>
+2
View File
@@ -14,6 +14,8 @@ export interface FileItem {
transcodeProgress?: number transcodeProgress?: number
/** 本视频使用的转码档位 */ /** 本视频使用的转码档位 */
transcodePreset?: string transcodePreset?: string
/** 转码失败原因 */
transcodeError?: string
/** HLS 播放列表地址(转码完成后有值) */ /** HLS 播放列表地址(转码完成后有值) */
hlsUrl?: string hlsUrl?: string
/** HLS 播放列表在存储中的 key */ /** HLS 播放列表在存储中的 key */
+3
View File
@@ -8,7 +8,10 @@ export type ErrorCode
| 'UNSUPPORTED_FILE_TYPE' | 'UNSUPPORTED_FILE_TYPE'
| 'INVALID_FILE_SIGNATURE' | 'INVALID_FILE_SIGNATURE'
| 'INVALID_IMAGE_KEY' | 'INVALID_IMAGE_KEY'
| 'INVALID_VIDEO_KEY'
| 'IMAGE_NOT_FOUND' | 'IMAGE_NOT_FOUND'
| 'HLS_NOT_FOUND'
| 'HLS_REMUX_FAILED'
| 'UPLOAD_FAILED' | 'UPLOAD_FAILED'
| 'DELETE_FAILED' | 'DELETE_FAILED'
+1
View File
@@ -6,6 +6,7 @@
- Node.js **22** - Node.js **22**
- npm **11**(与 `package.json` 中 `packageManager` 一致) - npm **11**(与 `package.json` 中 `packageManager` 一致)
- FFmpeg(视频缩略图与 HLS 转码;未安装时视频仍可上传,但使用占位封面)
## 启动 ## 启动
+130 -23
View File
@@ -8,6 +8,7 @@
"loadingSession": "Verifying session…", "loadingSession": "Verifying session…",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"continue": "Continue",
"copy": "Copy", "copy": "Copy",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
@@ -546,7 +547,20 @@
"storageId": "Storage ID", "storageId": "Storage ID",
"storageType": "Storage type", "storageType": "Storage type",
"badgeLocal": "Local", "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", "supportedTitle": "Supported backends",
"supportedSubtitle": "PicHost supports multiple storage backends for different scenarios", "supportedSubtitle": "PicHost supports multiple storage backends for different scenarios",
"supportedLocalDesc": "Ideal for small deployments and personal use", "supportedLocalDesc": "Ideal for small deployments and personal use",
@@ -554,7 +568,7 @@
"supportedS3Title": "S3 compatible", "supportedS3Title": "S3 compatible",
"supportedS3Desc": "Storage services compatible with the Amazon S3 API", "supportedS3Desc": "Storage services compatible with the Amazon S3 API",
"supportedTelegramDesc": "Store images in a Telegram channel or group; good for light unlimited storage", "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", "statTotal": "Total",
"statUsed": "Used", "statUsed": "Used",
"statFree": "Free", "statFree": "Free",
@@ -585,28 +599,28 @@
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving", "manageStorageHint": "Configure R2 / S3 / COS backends and URL serving",
"backup": { "backup": {
"sectionTitle": "Backup & migration", "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", "siteTitle": "Site backup & restore",
"siteSubtitle": "For migration, disaster recovery, and full-site 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", "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", "exportAction": "Start backup",
"exportStarted": "Backup job started", "exportStarted": "Backup job started",
"exportFailed": "Failed to start backup", "exportFailed": "Failed to start backup",
"exportPathHint": "Exports are saved under data/backups/", "exportPathHint": "Exports are saved under data/backups/",
"exportIncludesTitle": "Package includes", "exportIncludesTitle": "Package includes",
"exportIncludesDb": "Database snapshot (pichost.db)", "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", "exportExcludesSecrets": "Excludes plain-text secret.json for cloud storage",
"exportConcurrencyHint": "Only one backup or migration job can run at a time", "exportConcurrencyHint": "Only one backup or migration job can run at a time",
"estimateLoading": "Estimating size…", "estimateLoading": "Estimating size…",
"estimateImageCountLabel": "Images", "estimateImageCountLabel": "Files",
"estimatePackageSizeLabel": "Est. package", "estimatePackageSizeLabel": "Est. package",
"estimateDatabaseSnapshotLabel": "Database snapshot", "estimateDatabaseSnapshotLabel": "Database snapshot",
"estimateStoragePathLabel": "Save location", "estimateStoragePathLabel": "Save location",
"estimateStoragePathValue": "data/backups/", "estimateStoragePathValue": "data/backups/",
"estimateImages": "{count} images · {size}", "estimateImages": "{count} files · {size}",
"estimateDatabase": "Database ~{size}", "estimateDatabase": "Database ~{size}",
"estimateTotal": "Total ~{size}", "estimateTotal": "Total ~{size}",
"restoreTitle": "Restore from backup", "restoreTitle": "Restore from backup",
@@ -623,30 +637,30 @@
"restoreConfirm": "Confirm restore", "restoreConfirm": "Confirm restore",
"restoreConfirmTitle": "Confirm site restore", "restoreConfirmTitle": "Confirm site restore",
"restoreConfirmSource": "Source: {name}", "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", "restoreSelectRequired": "Select or upload a backup file",
"restoreStarted": "Restore job started", "restoreStarted": "Restore job started",
"restoreFailed": "Restore failed", "restoreFailed": "Restore failed",
"conflictMode": "Conflict policy", "conflictMode": "Conflict policy",
"conflictSkip": "Skip existing images", "conflictSkip": "Skip existing files",
"conflictOverwrite": "Overwrite existing images and database", "conflictOverwrite": "Overwrite existing files and database",
"syncTitle": "Cross-backend sync", "syncTitle": "Cross-backend sync",
"syncMigrateTitle": "Cross-backend migration", "syncMigrateTitle": "Cross-backend migration",
"syncMigrateSubtitle": "Migrate images between configured backends", "syncMigrateSubtitle": "Migrate files between configured backends",
"syncSubtitle": "Copy images between configured backends and update the index", "syncSubtitle": "Copy files between configured backends and update the index",
"syncFrom": "Source backend", "syncFrom": "Source backend",
"syncTo": "Target backend", "syncTo": "Target backend",
"syncPendingImages": "Images to migrate", "syncPendingImages": "Files to migrate",
"syncPendingSize": "Est. size", "syncPendingSize": "Est. size",
"syncIncludesTitle": "What sync does", "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", "syncIncludesIndex": "Update storage_backend_id in the index",
"syncIncludesConfig": "Site settings and other backends are unchanged", "syncIncludesConfig": "Site settings and other backends are unchanged",
"syncDryRun": "Dry run only", "syncDryRun": "Dry run only",
"syncSetDefaultAfter": "Set target as default after migration", "syncSetDefaultAfter": "Set target as default after migration",
"syncStartMigrate": "Start migration", "syncStartMigrate": "Start migration",
"syncPreviewOnly": "Preview", "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", "syncDeleteSource": "Delete source files after sync",
"syncSetDefault": "Set target as default after sync", "syncSetDefault": "Set target as default after sync",
"syncPreview": "Preview & start", "syncPreview": "Preview & start",
@@ -658,8 +672,8 @@
"syncStarted": "Sync job started", "syncStarted": "Sync job started",
"syncFailed": "Sync failed", "syncFailed": "Sync failed",
"previewFailed": "Preview failed", "previewFailed": "Preview failed",
"previewImages": "Images", "previewImages": "Files",
"previewNew": "New images", "previewNew": "New files",
"previewConflicts": "Conflicts", "previewConflicts": "Conflicts",
"previewCreated": "Backup created", "previewCreated": "Backup created",
"previewSize": "Data size", "previewSize": "Data size",
@@ -672,6 +686,8 @@
"colType": "Type", "colType": "Type",
"colStatus": "Status", "colStatus": "Status",
"colProgress": "Progress", "colProgress": "Progress",
"progressUnitMedia": "media records",
"progressUnitObjects": "storage objects",
"colMessage": "Message", "colMessage": "Message",
"colCreated": "Created", "colCreated": "Created",
"colActions": "Actions", "colActions": "Actions",
@@ -691,8 +707,8 @@
"jobRunning": "Another job is already running. Try again later.", "jobRunning": "Another job is already running. Try again later.",
"migrateIn": "Migrate in", "migrateIn": "Migrate in",
"migrateOut": "Migrate out", "migrateOut": "Migrate out",
"migrateInHint": "Copy images from the default backend to this one", "migrateInHint": "Copy files from the default backend to this one",
"migrateOutHint": "Move images out of this backend", "migrateOutHint": "Move files out of this backend",
"syncDraftApplied": "Sync form prefilled — review and start when ready", "syncDraftApplied": "Sync form prefilled — review and start when ready",
"syncNeedTwoBackends": "At least two storage backends are required to sync", "syncNeedTwoBackends": "At least two storage backends are required to sync",
"selectBackend": "Select backend", "selectBackend": "Select backend",
@@ -869,7 +885,7 @@
"envOverrideTitle": "Token overridden by environment", "envOverrideTitle": "Token overridden by environment",
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.", "envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
"personalTitle": "Personal API token", "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", "notConfiguredTitle": "No token configured",
"notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.", "notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.",
"regenerate": "Regenerate", "regenerate": "Regenerate",
@@ -911,6 +927,8 @@
"responseExample": "Response example", "responseExample": "Response example",
"categories": { "categories": {
"image": "Images", "image": "Images",
"video": "Videos",
"file": "Files",
"extension": "Plugins", "extension": "Plugins",
"system": "System" "system": "System"
}, },
@@ -935,6 +953,47 @@
"batchDelete": { "batchDelete": {
"keys": "Array of image keys to delete" "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": { "twikoo": {
"image": "Image file", "image": "Image file",
"token": "API token" "token": "API token"
@@ -955,9 +1014,9 @@
"sendFailed": "Request failed", "sendFailed": "Request failed",
"networkError": "Network error or unreachable", "networkError": "Network error or unreachable",
"notesTitle": "Notes", "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", "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": { "docs": {
"upload": { "upload": {
@@ -980,6 +1039,46 @@
"title": "Batch delete", "title": "Batch delete",
"desc": "Delete multiple images; request body is a keys array." "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": { "twikoo": {
"title": "Twikoo-compatible upload", "title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token." "desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
@@ -1058,6 +1157,14 @@
"previewKey": "Video path", "previewKey": "Video path",
"transcodeNone": "Not transcoded", "transcodeNone": "Not transcoded",
"transcodeDone": "Done (m3u8)", "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", "uploadTitle": "Drop videos here to upload",
"uploadHint": "or click to choose video files, up to 10 at a time", "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", "uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
+131 -24
View File
@@ -8,6 +8,7 @@
"loadingSession": "正在验证登录状态…", "loadingSession": "正在验证登录状态…",
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"continue": "继续",
"copy": "复制", "copy": "复制",
"delete": "删除", "delete": "删除",
"edit": "编辑", "edit": "编辑",
@@ -546,7 +547,20 @@
"storageId": "存储标识", "storageId": "存储标识",
"storageType": "存储类型", "storageType": "存储类型",
"badgeLocal": "本地", "badgeLocal": "本地",
"defaultStorageTip": "默认存储提示:新上传的图片将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。", "defaultStorageTip": "新上传的所有图片、视频、文件将默认保存到「{name}」。可点击其他后端的「设为默认」进行修改。",
"clearStorage": "清空存储",
"clearStorageTitle": "清空我的存储",
"clearStorageDescription": "永久删除当前账号上传的所有图片、视频、文件和缓存,不影响其他用户的数据。",
"clearStorageWarning": "高危风险:此操作会清空存储中的所有文件和缓存,无法撤销!",
"clearStorageScope": "只会清除当前登录用户的数据和文件。其他用户的数据、账户、存储配置和全站备份不会被删除。",
"clearStorageFinalTitle": "最终确认",
"clearStorageInstruction": "请输入“我已确认”以继续。操作开始后无法取消或撤销。",
"clearStoragePlaceholder": "我已确认",
"clearStoragePhrase": "我已确认",
"clearStorageConfirm": "永久清空",
"clearStorageSuccess": "已清空 {files} 个文件和 {cache} 个临时缓存,存储空间已释放",
"clearStoragePartial": "{failed}/{total} 个文件清理失败,请重试",
"clearStorageFailed": "清空存储失败,请稍后重试",
"supportedTitle": "支持的存储后端", "supportedTitle": "支持的存储后端",
"supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求", "supportedSubtitle": "PicHost 支持多种存储后端,满足不同场景需求",
"supportedLocalDesc": "适合小型部署和个人使用", "supportedLocalDesc": "适合小型部署和个人使用",
@@ -554,7 +568,7 @@
"supportedS3Title": "S3 兼容", "supportedS3Title": "S3 兼容",
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务", "supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
"supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景", "supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景",
"footerTip": "默认存储后端将自动接收所有新上传的图片", "footerTip": "默认存储后端将自动接收所有新上传的图片、视频和文件",
"statTotal": "总量", "statTotal": "总量",
"statUsed": "已用", "statUsed": "已用",
"statFree": "剩余", "statFree": "剩余",
@@ -585,28 +599,28 @@
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略", "manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略",
"backup": { "backup": {
"sectionTitle": "备份与迁移", "sectionTitle": "备份与迁移",
"sectionSubtitle": "整站备份恢复与跨后端图片迁移", "sectionSubtitle": "整站备份恢复与跨后端文件迁移",
"siteTitle": "站点备份与恢复", "siteTitle": "站点备份与恢复",
"siteSubtitle": "用于换机、灾备与整站恢复", "siteSubtitle": "用于换机、灾备与整站恢复",
"siteFooterHint": "备份包含数据库与图片,不包含 secret_json 明文。", "siteFooterHint": "备份包含数据库与全部文件,不包含 secret_json 明文。",
"exportTitle": "创建站点备份", "exportTitle": "创建站点备份",
"exportSubtitle": "打包数据库与全部图片为 .phost.tar.gz", "exportSubtitle": "打包数据库与全部文件为 .phost.tar.gz",
"exportAction": "开始备份", "exportAction": "开始备份",
"exportStarted": "备份任务已开始", "exportStarted": "备份任务已开始",
"exportFailed": "创建备份失败", "exportFailed": "创建备份失败",
"exportPathHint": "导出文件保存在 data/backups/", "exportPathHint": "导出文件保存在 data/backups/",
"exportIncludesTitle": "备份包含", "exportIncludesTitle": "备份包含",
"exportIncludesDb": "数据库快照(pichost.db)", "exportIncludesDb": "数据库快照(pichost.db)",
"exportIncludesImages": "全部图片文件({count} 张)", "exportIncludesImages": "全部图片、视频和文件({count} 个)",
"exportExcludesSecrets": "不含云存储 secret.json 明文", "exportExcludesSecrets": "不含云存储 secret.json 明文",
"exportConcurrencyHint": "同时仅可运行一个备份或迁移任务", "exportConcurrencyHint": "同时仅可运行一个备份或迁移任务",
"estimateLoading": "正在估算体积…", "estimateLoading": "正在估算体积…",
"estimateImageCountLabel": "图片总数", "estimateImageCountLabel": "文件总数",
"estimatePackageSizeLabel": "预计备份包", "estimatePackageSizeLabel": "预计备份包",
"estimateDatabaseSnapshotLabel": "数据库快照", "estimateDatabaseSnapshotLabel": "数据库快照",
"estimateStoragePathLabel": "保存位置", "estimateStoragePathLabel": "保存位置",
"estimateStoragePathValue": "data/backups/", "estimateStoragePathValue": "data/backups/",
"estimateImages": "图片 {count} 张 · {size}", "estimateImages": "文件 {count} 个 · {size}",
"estimateDatabase": "数据库约 {size}", "estimateDatabase": "数据库约 {size}",
"estimateTotal": "合计约 {size}", "estimateTotal": "合计约 {size}",
"restoreTitle": "从备份恢复", "restoreTitle": "从备份恢复",
@@ -623,44 +637,44 @@
"restoreConfirm": "确认恢复", "restoreConfirm": "确认恢复",
"restoreConfirmTitle": "确认恢复站点", "restoreConfirmTitle": "确认恢复站点",
"restoreConfirmSource": "来源:{name}", "restoreConfirmSource": "来源:{name}",
"restoreOverwriteWarning": "覆盖模式将替换冲突图片与数据库,请确认已做好当前站点备份", "restoreOverwriteWarning": "覆盖模式将替换冲突文件与数据库,请确认已做好当前站点备份",
"restoreSelectRequired": "请选择或上传备份文件", "restoreSelectRequired": "请选择或上传备份文件",
"restoreStarted": "恢复任务已开始", "restoreStarted": "恢复任务已开始",
"restoreFailed": "恢复失败", "restoreFailed": "恢复失败",
"conflictMode": "冲突策略", "conflictMode": "冲突策略",
"conflictSkip": "跳过已存在图片", "conflictSkip": "跳过已存在文件",
"conflictOverwrite": "覆盖已存在图片与数据库", "conflictOverwrite": "覆盖已存在文件与数据库",
"syncTitle": "跨后端同步", "syncTitle": "跨后端同步",
"syncMigrateTitle": "跨后端迁移", "syncMigrateTitle": "跨后端迁移",
"syncMigrateSubtitle": "在已配置后端之间迁移图片", "syncMigrateSubtitle": "在已配置后端之间迁移文件",
"syncSubtitle": "在已配置的后端之间拷贝图片并更新索引", "syncSubtitle": "在已配置的后端之间拷贝文件并更新索引",
"syncFrom": "源后端", "syncFrom": "源后端",
"syncTo": "目标后端", "syncTo": "目标后端",
"syncPendingImages": "待迁移图片", "syncPendingImages": "待迁移文件",
"syncPendingSize": "预计体积", "syncPendingSize": "预计体积",
"syncIncludesTitle": "同步说明", "syncIncludesTitle": "同步说明",
"syncIncludesCopy": "拷贝源端图片文件到目标后端", "syncIncludesCopy": "拷贝源端图片、视频和文件到目标后端",
"syncIncludesIndex": "更新索引中的 storage_backend_id", "syncIncludesIndex": "更新索引中的 storage_backend_id",
"syncIncludesConfig": "不修改站点配置与其它后端数据", "syncIncludesConfig": "不修改站点配置与其它后端数据",
"syncDryRun": "仅预览 Dry Run", "syncDryRun": "仅预览 Dry Run",
"syncSetDefaultAfter": "迁移后设为默认后端", "syncSetDefaultAfter": "迁移后设为默认后端",
"syncStartMigrate": "开始迁移", "syncStartMigrate": "开始迁移",
"syncPreviewOnly": "预览", "syncPreviewOnly": "预览",
"syncFooterHint": "仅迁移图片文件与索引,不影响站点配置。", "syncFooterHint": "仅迁移文件与索引,不影响站点配置。",
"syncDeleteSource": "同步后删除源端文件", "syncDeleteSource": "同步后删除源端文件",
"syncSetDefault": "同步后将目标设为默认", "syncSetDefault": "同步后将目标设为默认",
"syncPreview": "预检并开始", "syncPreview": "预检并开始",
"syncConfirm": "开始同步", "syncConfirm": "开始同步",
"syncConfirmTitle": "确认跨后端同步", "syncConfirmTitle": "确认跨后端同步",
"syncDeleteWarning": "将删除源后端上的图片文件,此操作不可撤销", "syncDeleteWarning": "将删除源后端上的文件,此操作不可撤销",
"syncSelectRequired": "请选择源与目标后端", "syncSelectRequired": "请选择源与目标后端",
"syncSameBackend": "源与目标不能相同", "syncSameBackend": "源与目标不能相同",
"syncStarted": "同步任务已开始", "syncStarted": "同步任务已开始",
"syncFailed": "同步失败", "syncFailed": "同步失败",
"previewFailed": "预检失败", "previewFailed": "预检失败",
"previewImages": "图片数量", "previewImages": "文件数量",
"previewNew": "新增图片", "previewNew": "新增文件",
"previewConflicts": "冲突图片", "previewConflicts": "冲突文件",
"previewCreated": "备份时间", "previewCreated": "备份时间",
"previewSize": "数据量", "previewSize": "数据量",
"jobsTitle": "最近任务", "jobsTitle": "最近任务",
@@ -672,6 +686,8 @@
"colType": "类型", "colType": "类型",
"colStatus": "状态", "colStatus": "状态",
"colProgress": "进度", "colProgress": "进度",
"progressUnitMedia": "媒体记录",
"progressUnitObjects": "存储对象",
"colMessage": "说明", "colMessage": "说明",
"colCreated": "创建时间", "colCreated": "创建时间",
"colActions": "操作", "colActions": "操作",
@@ -691,8 +707,8 @@
"jobRunning": "已有任务正在运行,请稍后再试", "jobRunning": "已有任务正在运行,请稍后再试",
"migrateIn": "迁入", "migrateIn": "迁入",
"migrateOut": "迁出", "migrateOut": "迁出",
"migrateInHint": "从默认后端拷贝图片到此后端", "migrateInHint": "从默认后端拷贝文件到此后端",
"migrateOutHint": "将图片从此后端迁出", "migrateOutHint": "将文件从此后端迁出",
"syncDraftApplied": "已填入同步表单,请确认后开始", "syncDraftApplied": "已填入同步表单,请确认后开始",
"syncNeedTwoBackends": "至少需要两个存储后端才能同步", "syncNeedTwoBackends": "至少需要两个存储后端才能同步",
"selectBackend": "选择后端", "selectBackend": "选择后端",
@@ -869,7 +885,7 @@
"envOverrideTitle": "Token 由环境变量覆盖", "envOverrideTitle": "Token 由环境变量覆盖",
"envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。", "envOverrideDesc": "修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。",
"personalTitle": "个人 API Token", "personalTitle": "个人 API Token",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。", "personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片、视频或文件归属您的账号。",
"notConfiguredTitle": "尚未配置 Token", "notConfiguredTitle": "尚未配置 Token",
"notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。", "notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。",
"regenerate": "重新生成", "regenerate": "重新生成",
@@ -911,6 +927,8 @@
"responseExample": "响应示例", "responseExample": "响应示例",
"categories": { "categories": {
"image": "图片接口", "image": "图片接口",
"video": "视频接口",
"file": "文件接口",
"extension": "插件接口", "extension": "插件接口",
"system": "系统通用" "system": "系统通用"
}, },
@@ -938,6 +956,47 @@
"batchDelete": { "batchDelete": {
"keys": "要删除的图片 key 数组" "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": { "twikoo": {
"image": "上传图片", "image": "上传图片",
"token": "API Token" "token": "API Token"
@@ -960,7 +1019,7 @@
"notesTitle": "响应说明", "notesTitle": "响应说明",
"noteSuccess": "200 表示请求成功,返回 JSON 数据", "noteSuccess": "200 表示请求成功,返回 JSON 数据",
"noteError": "4xx / 5xx 表示请求失败,查看 error 字段", "noteError": "4xx / 5xx 表示请求失败,查看 error 字段",
"noteLimit": "上传限制:单次最多 10 张,单张最大 10 MB" "noteLimit": "上传限制:单次最多 10 个;视频单文件最大 500 MB,文件单文件最大 100 MB"
}, },
"docs": { "docs": {
"upload": { "upload": {
@@ -983,6 +1042,46 @@
"title": "批量删除", "title": "批量删除",
"desc": "一次删除多张图片,请求体为 keys 数组。" "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": { "twikoo": {
"title": "twikoo 兼容上传", "title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。" "desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
@@ -1061,6 +1160,14 @@
"previewKey": "视频路径", "previewKey": "视频路径",
"transcodeNone": "未转码", "transcodeNone": "未转码",
"transcodeDone": "已完成(m3u8)", "transcodeDone": "已完成(m3u8)",
"transcodeErrorFfmpegMissing": "无 FFmpeg",
"transcodeErrorPermissionDenied": "无文件权限",
"transcodeErrorUnknown": "原因未知",
"downloadMp4": "下载 MP4",
"downloadMp4Title": "下载 MP4 视频",
"downloadMp4Confirm": "将会合并视频切片到 MP4,是否继续?",
"downloadMp4Working": "正在合并视频切片,请稍候…",
"downloadMp4Failed": "视频合并失败,请稍后重试",
"uploadTitle": "拖拽视频到此处上传", "uploadTitle": "拖拽视频到此处上传",
"uploadHint": "或点击选择视频文件,单次最多 10 个", "uploadHint": "或点击选择视频文件,单次最多 10 个",
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB", "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 { requireAdminAuth } from '../../../utils/access'
import { getBackupExportEstimate } from '../../../utils/backup/stats' import { getBackupExportEstimate } from '../../../utils/backup/stats'
import { getBackupsDir, isSafeBackupFilename } from '../../../utils/backup/paths' 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 { promises as fs } from 'node:fs'
import { join } from 'node:path'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const estimate = await getBackupExportEstimate() 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 () => { const packages = await (async () => {
try { try {
@@ -15,6 +26,7 @@ export default defineEventHandler(async (event) => {
const items = await Promise.all( const items = await Promise.all(
entries entries
.filter(entry => entry.isFile() && isSafeBackupFilename(entry.name)) .filter(entry => entry.isFile() && isSafeBackupFilename(entry.name))
.filter(entry => allowedArchives.has(entry.name))
.map(async (entry) => { .map(async (entry) => {
const full = join(getBackupsDir(), entry.name) const full = join(getBackupsDir(), entry.name)
const fileStat = await fs.stat(full) 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' import { startFullExportJob } from '../../../utils/backup/runner'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const appVersion = String(config.appVersion ?? '0.0.0') const appVersion = String(config.appVersion ?? '0.0.0')
const estimate = await getBackupExportEstimate() const estimate = await getBackupExportEstimate()
try { try {
const job = startFullExportJob(appVersion) const job = startFullExportJob(appVersion, user.id)
return { return {
job: serializeBackupJob(job), job: serializeBackupJob(job),
estimate estimate
+2 -2
View File
@@ -4,13 +4,13 @@ import { serializeBackupJob } from '../../../../utils/backup/api'
import { getBackupJob } from '../../../../utils/backup/jobs' import { getBackupJob } from '../../../../utils/backup/jobs'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) { if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
} }
const job = getBackupJob(id) const job = getBackupJob(id, user.id)
if (!job) { if (!job) {
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
} }
@@ -5,16 +5,19 @@ import { getBackupJob } from '../../../../../utils/backup/jobs'
import { cancelBackupJob } from '../../../../../utils/backup/runner' import { cancelBackupJob } from '../../../../../utils/backup/runner'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) { if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
} }
if (!getBackupJob(id, user.id)) {
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
}
if (!cancelBackupJob(id)) { if (!cancelBackupJob(id)) {
createApiError(event, 'INVALID_REQUEST', '只能取消进行中的任务', 400) createApiError(event, 'INVALID_REQUEST', '只能取消进行中的任务', 400)
} }
const job = getBackupJob(id) const job = getBackupJob(id, user.id)
return { job: job ? serializeBackupJob(job) : null } return { job: job ? serializeBackupJob(job) : null }
}) })
@@ -6,13 +6,13 @@ import { getBackupJob } from '../../../../../utils/backup/jobs'
import { BACKUP_ARCHIVE_EXT } from '../../../../../utils/backup/paths' import { BACKUP_ARCHIVE_EXT } from '../../../../../utils/backup/paths'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) { if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) 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) { if (!job || job.type !== 'full_export' || job.status !== 'done' || !job.outputPath) {
createApiError(event, 'INVALID_REQUEST', '可下载的备份不存在', 404) createApiError(event, 'INVALID_REQUEST', '可下载的备份不存在', 404)
} }
@@ -5,13 +5,13 @@ import { serializeBackupJob } from '../../../../../utils/backup/api'
import { getBackupJob } from '../../../../../utils/backup/jobs' import { getBackupJob } from '../../../../../utils/backup/jobs'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) { if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400) createApiError(event, 'INVALID_REQUEST', '缺少任务 ID', 400)
} }
const job = getBackupJob(id) const job = getBackupJob(id, user.id)
if (!job || job.type !== 'backend_sync') { if (!job || job.type !== 'backend_sync') {
createApiError(event, 'INVALID_REQUEST', '任务不存在', 404) createApiError(event, 'INVALID_REQUEST', '任务不存在', 404)
} }
@@ -23,6 +23,6 @@ export default defineEventHandler(async (event) => {
createApiError(event, 'INVALID_REQUEST', message, 400) createApiError(event, 'INVALID_REQUEST', message, 400)
} }
const updated = getBackupJob(id) const updated = getBackupJob(id, user.id)
return { job: updated ? serializeBackupJob(updated) : null } return { job: updated ? serializeBackupJob(updated) : null }
}) })
+4 -4
View File
@@ -9,21 +9,21 @@ import {
import { isBackupRunnerBusy } from '../../../../utils/backup/runner' import { isBackupRunnerBusy } from '../../../../utils/backup/runner'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
if (getRunningBackupJob() && !isBackupRunnerBusy()) { if (getRunningBackupJob() && !isBackupRunnerBusy()) {
recoverInterruptedBackupJobs() recoverInterruptedBackupJobs()
} }
const query = getQuery(event) const query = getQuery(event)
const pageSize = Math.min(50, Math.max(1, Number(query.pageSize) || 10)) 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 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 totalPages = Math.max(1, Math.ceil(total / pageSize))
const page = total > 0 ? Math.min(pageNum, totalPages) : 1 const page = total > 0 ? Math.min(pageNum, totalPages) : 1
const slice = page === pageNum const slice = page === pageNum
? jobs ? jobs
: listBackupJobsPage(page, pageSize).jobs : listBackupJobsPage(page, pageSize, user.id).jobs
const activeJob = getActiveBackupJob() const activeJob = getActiveBackupJob(user.id)
return { return {
jobs: slice.map(serializeBackupJob), 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 { serializeBackupJob } from '../../../utils/backup/api'
import { previewFullRestore } from '../../../utils/backup/full-restore' import { previewFullRestore } from '../../../utils/backup/full-restore'
import { startFullRestoreJob } from '../../../utils/backup/runner' import { startFullRestoreJob } from '../../../utils/backup/runner'
import { listAllBackupJobsForUser } from '../../../utils/backup/jobs'
import { import {
BACKUP_ARCHIVE_EXT, BACKUP_ARCHIVE_EXT,
getBackupsDir, getBackupsDir,
getBackupStagingDir, getBackupStagingDir,
isSafeBackupFilename isSafeBackupFilename
} from '../../../utils/backup/paths' } from '../../../utils/backup/paths'
import { join } from 'node:path' import { basename, join } from 'node:path'
import { promises as fs } from 'node:fs' import { promises as fs } from 'node:fs'
interface RestoreBody { interface RestoreBody {
@@ -19,7 +20,7 @@ interface RestoreBody {
dryRun?: boolean dryRun?: boolean
} }
async function resolveRestoreInput(event: H3Event): Promise<{ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{
archivePath: string archivePath: string
body: RestoreBody body: RestoreBody
cleanup?: () => Promise<void> cleanup?: () => Promise<void>
@@ -71,6 +72,19 @@ async function resolveRestoreInput(event: H3Event): Promise<{
if (!body?.archiveName || !isSafeBackupFilename(body.archiveName)) { if (!body?.archiveName || !isSafeBackupFilename(body.archiveName)) {
createApiError(event, 'INVALID_REQUEST', '请选择有效的备份文件', 400) 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) const archivePath = join(getBackupsDir(), body.archiveName)
try { try {
await fs.access(archivePath) await fs.access(archivePath)
@@ -81,9 +95,9 @@ async function resolveRestoreInput(event: H3Event): Promise<{
} }
export default defineEventHandler(async (event) => { 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' const conflictMode = body.conflictMode === 'skip' ? 'skip' : 'overwrite'
@@ -97,7 +111,7 @@ export default defineEventHandler(async (event) => {
} }
try { try {
const job = startFullRestoreJob(archivePath, conflictMode) const job = startFullRestoreJob(archivePath, conflictMode, user.id)
return { job: serializeBackupJob(job) } return { job: serializeBackupJob(job) }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') { if (error instanceof Error && error.message === 'BACKUP_JOB_RUNNING') {
+2 -2
View File
@@ -13,7 +13,7 @@ interface SyncBody {
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireAdminAuth(event)
const body = await readBody<SyncBody>(event) const body = await readBody<SyncBody>(event)
const fromBackendId = body?.fromBackendId?.trim() ?? '' const fromBackendId = body?.fromBackendId?.trim() ?? ''
@@ -36,7 +36,7 @@ export default defineEventHandler(async (event) => {
return { preview } return { preview }
} }
const job = startBackendSyncJob(options) const job = startBackendSyncJob(options, user.id)
return { job: serializeBackupJob(job) } return { job: serializeBackupJob(job) }
} catch (error) { } catch (error) {
if (error instanceof 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 { FileListResponse } from '~/types/file'
import type { MediaType, TranscodeStatus } from '../../utils/storage/types' import type { MediaType, TranscodeStatus } from '../../utils/storage/types'
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access' import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
@@ -21,7 +22,10 @@ import {
} from '../../utils/image-query' } from '../../utils/image-query'
import { listImages, searchImages } from '../../utils/storage' import { listImages, searchImages } from '../../utils/storage'
export default defineEventHandler(async (event) => { export async function handleFileList(
event: H3Event,
forcedKind?: MediaType
): Promise<FileListResponse> {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
const query = getQuery(event) const query = getQuery(event)
@@ -51,7 +55,7 @@ export default defineEventHandler(async (event) => {
} }
// 文件床按类型分工:视频库看 media_type=video,文件库看 media_type=file // 文件床按类型分工:视频库看 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') { if (kindRaw && kindRaw !== 'file' && kindRaw !== 'video') {
createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400) createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400)
} }
@@ -111,6 +115,7 @@ export default defineEventHandler(async (event) => {
...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {}), ...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {}),
transcodeStatus: stored.transcodeStatus ?? 'none', transcodeStatus: stored.transcodeStatus ?? 'none',
transcodeProgress: stored.transcodeProgress ?? 0, transcodeProgress: stored.transcodeProgress ?? 0,
...(stored.transcodeError ? { transcodeError: stored.transcodeError } : {}),
...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}), ...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}),
...(stored.sourceDeleted ? { sourceDeleted: true } : {}), ...(stored.sourceDeleted ? { sourceDeleted: true } : {}),
...(stored.thumbKey ...(stored.thumbKey
@@ -138,4 +143,6 @@ export default defineEventHandler(async (event) => {
} }
return response 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 { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
import { processSingleVideoUpload } from '../../utils/file-bed' 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' import { getFileBedAccessMode } from '../../utils/env'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -37,6 +42,36 @@ export default defineEventHandler(async (event) => {
) )
: false : 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( const fileParts = formData.filter(
part => (part.name === 'file' || part.name === 'files' || part.name === 'video') part => (part.name === 'file' || part.name === 'files' || part.name === 'video')
&& part.data?.length && part.data?.length
@@ -82,7 +117,10 @@ export default defineEventHandler(async (event) => {
if (wantTranscode) { if (wantTranscode) {
for (const item of items) { 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 { join } from 'node:path'
import { loadServerModule } from './backup-cli-loader.mjs' import { loadServerModule } from './backup-cli-loader.mjs'
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') const {
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') ensureBackupSchema,
const { runFullExportJob } = loadServerModule('utils/backup/full-export.ts') createBackupJob,
getBackupJob,
runFullExportJob
} = loadServerModule('cli/backup-runtime.ts')
function readAppVersion() { function readAppVersion() {
try { try {
+8 -5
View File
@@ -11,10 +11,13 @@ import { access } from 'node:fs/promises'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { loadServerModule } from './backup-cli-loader.mjs' import { loadServerModule } from './backup-cli-loader.mjs'
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') const {
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') ensureBackupSchema,
const { previewFullRestore } = loadServerModule('utils/backup/full-restore.ts') createBackupJob,
const { runFullRestoreJob } = loadServerModule('utils/backup/full-restore.ts') getBackupJob,
previewFullRestore,
runFullRestoreJob
} = loadServerModule('cli/backup-runtime.ts')
function usage() { function usage() {
console.error('Usage: backup-restore <archive-path> [--overwrite]') console.error('Usage: backup-restore <archive-path> [--overwrite]')
@@ -41,7 +44,7 @@ async function main() {
ensureBackupSchema() ensureBackupSchema()
const preview = await previewFullRestore(archivePath) 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({ const job = createBackupJob({
type: 'full_restore', 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: * Usage:
* node server/cli/storage-sync.mjs --from <id> --to <id> [--delete-source] [--set-default] [--dry-run] * 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' import { loadServerModule } from './backup-cli-loader.mjs'
const { ensureBackupSchema } = loadServerModule('utils/backup/schema.ts') const {
const { createBackupJob, getBackupJob } = loadServerModule('utils/backup/jobs.ts') ensureBackupSchema,
const { previewBackendSync, runBackendSyncJob } = loadServerModule('utils/backup/backend-sync.ts') createBackupJob,
getBackupJob,
previewBackendSync,
runBackendSyncJob
} = loadServerModule('cli/backup-runtime.ts')
function usage() { function usage() {
console.error('Usage: storage-sync --from <backend-id> --to <backend-id> [--delete-source] [--set-default] [--dry-run]') 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) { if (dryRun) {
const preview = previewBackendSync(options) 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}`) console.log(`[PicHost] From ${preview.fromBackendId} → ${preview.toBackendId}`)
return 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 { try {
const entries = await fs.readdir(tmpdir(), { withFileTypes: true }) const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
for (const entry of entries) { 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 }) await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true })
.catch(() => {}) .catch(() => {})
} }
+51 -40
View File
@@ -1,18 +1,22 @@
import { promises as fs } from 'node:fs' import { promises as fs } from 'node:fs'
import { join } from 'node:path' import { basename, join } from 'node:path'
import { import {
createBackendInstance, createBackendInstance,
getStorageBackendRow, getStorageBackendRow,
setDefaultBackend setDefaultBackend
} from '../storage-backends' } from '../storage-backends'
import { import {
getImageIndexAsStored, getImageIndexRow,
listImageIndexRowsByBackend, listImageIndexRowsByBackend,
updateImageBackendId updateImageBackendId
} from '../image-index' } 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 { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs'
import { getBackupStagingDir } from './paths' import { getBackupStagingDir } from './paths'
import { collectMediaAssetKeys } from '../media-assets'
export interface BackendSyncOptions { export interface BackendSyncOptions {
fromBackendId: string fromBackendId: string
@@ -63,6 +67,44 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return Buffer.concat(chunks) 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( export async function runBackendSyncJob(
jobId: string, jobId: string,
options: BackendSyncOptions options: BackendSyncOptions
@@ -88,32 +130,13 @@ export async function runBackendSyncJob(
updateBackupJob(jobId, { updateBackupJob(jobId, {
total: rows.length, total: rows.length,
progress: 0, progress: 0,
message: '正在同步图片…' message: '正在同步文件…'
}) })
let processed = 0 let processed = 0
for (const row of rows) { for (const row of rows) {
try { try {
const stream = await createImageStream(row.key) await syncMediaRow(row, fromBackend, toBackend, Boolean(options.deleteSource))
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)
}
} catch (error) { } catch (error) {
failedKeys.push(row.key) failedKeys.push(row.key)
await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {}) await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {})
@@ -136,7 +159,7 @@ export async function runBackendSyncJob(
} }
if (failedKeys.length > 0) { if (failedKeys.length > 0) {
markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`) markJobFailed(jobId, `${failedKeys.length} 个文件同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
updateBackupJob(jobId, { updateBackupJob(jobId, {
payload: { failedKeys, failedLogPath, preview } payload: { failedKeys, failedLogPath, preview }
}) })
@@ -178,22 +201,10 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
const stillFailed: string[] = [] const stillFailed: string[] = []
for (const key of keys) { for (const key of keys) {
const row = getImageIndexAsStored(key) const row = getImageIndexRow(key)
if (!row) continue if (!row) continue
try { try {
const stream = await createImageStream(key) await syncMediaRow(row, fromBackend, toBackend, deleteSource)
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)
}
} catch { } catch {
stillFailed.push(key) stillFailed.push(key)
} }
@@ -205,7 +216,7 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
stillFailed.map(key => `${key}\tretry failed\n`).join(''), stillFailed.map(key => `${key}\tretry failed\n`).join(''),
'utf8' 'utf8'
) )
throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`) throw new Error(`仍有 ${stillFailed.length} 个文件同步失败`)
} }
await fs.rm(failedLogPath, { force: true }).catch(() => {}) 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 { 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 { pipeline } from 'node:stream/promises'
import * as tar from 'tar' import * as tar from 'tar'
import { getImageTotals, listAllImageIndexRows } from '../image-index' import { getImageTotals, listAllImageIndexRows } from '../image-index'
@@ -9,7 +9,7 @@ import {
} from '../storage-backends' } from '../storage-backends'
import { parseS3Config } from '../storage/s3' import { parseS3Config } from '../storage/s3'
import { parseTelegramConfig } from '../storage/telegram' import { parseTelegramConfig } from '../storage/telegram'
import { createImageStream } from '../storage' import { getBackendForKey } from '../storage/resolver'
import { copyDatabaseSnapshot } from './db-snapshot' import { copyDatabaseSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, updateBackupJob } from './jobs' import { markJobDone, markJobFailed, updateBackupJob } from './jobs'
import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest' import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest'
@@ -19,6 +19,7 @@ import {
getBackupsDir getBackupsDir
} from './paths' } from './paths'
import type { StorageBackendRow } from '../storage/types' import type { StorageBackendRow } from '../storage/types'
import { collectMediaAssetKeys } from '../media-assets'
function formatArchiveName(jobId: string): string { function formatArchiveName(jobId: string): string {
return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}` return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}`
@@ -48,36 +49,15 @@ async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string
await pipeline(stream, createWriteStream(filePath)) await pipeline(stream, createWriteStream(filePath))
} }
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> { async function exportMediaAssets(
const chunks: Buffer[] = [] row: ImageIndexRow,
for await (const chunk of stream) { stagingRoot: string
chunks.push(Buffer.from(chunk as Uint8Array)) ): Promise<void> {
} const backend = await getBackendForKey(row.key)
return Buffer.concat(chunks).toString('utf8') const keys = await collectMediaAssetKeys(row, backend)
}
/**
* 源文件已按设计删除(转码完成)时,导出 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)}`)
}
}
for (const key of keys) { for (const key of keys) {
await writeStreamToFile( await writeStreamToFile(
await createImageStream(key), await backend.createStream(key),
join(stagingRoot, 'blobs', key) join(stagingRoot, 'blobs', key)
) )
} }
@@ -138,17 +118,10 @@ export async function runFullExportJob(jobId: string, appVersion: string): Promi
for (const row of rows) { for (const row of rows) {
updateBackupJob(jobId, { updateBackupJob(jobId, {
progress: processed, progress: processed,
message: `正在导出图片 ${processed}/${totals.count + 1}…` message: `正在导出文件 ${processed}/${totals.count + 1}…`
}) })
if (Number(row.source_deleted ?? 0) === 1) { await exportMediaAssets(row, stagingRoot)
// 转码完成的视频只有 HLS 产物与封面
await exportVideoSidecars(row, stagingRoot)
} else {
const blobPath = join(stagingRoot, 'blobs', row.key)
const stream = await createImageStream(row.key)
await writeStreamToFile(stream, blobPath)
}
processed += 1 processed += 1
updateBackupJob(jobId, { progress: processed }) updateBackupJob(jobId, { progress: processed })
} }
+11 -8
View File
@@ -4,6 +4,7 @@ import { pipeline } from 'node:stream/promises'
import * as tar from 'tar' import * as tar from 'tar'
import { getImageIndexRow } from '../image-index' import { getImageIndexRow } from '../image-index'
import { getActiveBackend } from '../storage/resolver' import { getActiveBackend } from '../storage/resolver'
import { contentTypeFromKey } from '../content-type'
import { replaceDatabaseFromSnapshot } from './db-snapshot' import { replaceDatabaseFromSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs' import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs'
import { import {
@@ -60,10 +61,11 @@ export async function previewFullRestore(archivePath: string): Promise<BackupRes
blobFiles = [] blobFiles = []
} }
const backend = await getActiveBackend()
let conflictingImages = 0 let conflictingImages = 0
for (const file of blobFiles) { for (const file of blobFiles) {
const rel = relative(blobsRoot, file).replace(/\\/g, '/') const rel = relative(blobsRoot, file).replace(/\\/g, '/')
if (getImageIndexRow(rel)) { if (await backend.head(rel)) {
conflictingImages += 1 conflictingImages += 1
} }
} }
@@ -104,7 +106,7 @@ export async function runFullRestoreJob(
restoreBackupJobsAfterReplace(preservedJobs) restoreBackupJobsAfterReplace(preservedJobs)
updateBackupJob(jobId, { updateBackupJob(jobId, {
status: 'running', status: 'running',
message: '正在恢复图片…', message: '正在恢复文件…',
progress: 0, progress: 0,
total: 0 total: 0
}) })
@@ -112,7 +114,7 @@ export async function runFullRestoreJob(
const blobsRoot = join(stagingRoot, 'blobs') const blobsRoot = join(stagingRoot, 'blobs')
const blobFiles = await walkFiles(blobsRoot) const blobFiles = await walkFiles(blobsRoot)
const total = blobFiles.length + 1 const total = blobFiles.length + 1
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复图片…' }) updateBackupJob(jobId, { total, progress: 1, message: '正在恢复文件…' })
const backend = await getActiveBackend() const backend = await getActiveBackend()
let processed = 1 let processed = 1
@@ -120,8 +122,9 @@ export async function runFullRestoreJob(
for (const file of blobFiles) { for (const file of blobFiles) {
const key = relative(blobsRoot, file).replace(/\\/g, '/') const key = relative(blobsRoot, file).replace(/\\/g, '/')
const indexed = getImageIndexRow(key) const indexed = getImageIndexRow(key)
const exists = Boolean(await backend.head(key))
if (!indexed && options.conflictMode === 'skip') { if (exists && options.conflictMode === 'skip') {
processed += 1 processed += 1
continue continue
} }
@@ -138,19 +141,19 @@ export async function runFullRestoreJob(
: { : {
originalName: key.split('/').pop() ?? key, originalName: key.split('/').pop() ?? key,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
contentType: 'application/octet-stream', contentType: contentTypeFromKey(key),
size: bytes.length, size: bytes.length,
userId: null userId: null
} }
if (options.conflictMode === 'overwrite' || !indexed) { if (options.conflictMode === 'overwrite' || !exists) {
await backend.put(key, bytes, meta) await backend.put(key, bytes, meta)
} }
processed += 1 processed += 1
updateBackupJob(jobId, { updateBackupJob(jobId, {
progress: processed, progress: processed,
message: `正在恢复图片 ${processed}/${total}…` message: `正在恢复文件 ${processed}/${total}…`
}) })
} }
@@ -159,7 +162,7 @@ export async function runFullRestoreJob(
markJobDone(jobId, { markJobDone(jobId, {
message: '恢复完成,请重新登录并在存储页检查对象存储密钥', message: '恢复完成,请重新登录并在存储页检查对象存储密钥',
payload: { payload: {
restoredImages: blobFiles.length, restoredFiles: blobFiles.length,
manifest manifest
} }
}) })
+58 -25
View File
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { getDb } from '../db' import { getDb, getPrimaryAdminUserId } from '../db'
import { ensureBackupSchema } from './schema' import { ensureBackupSchema } from './schema'
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync' export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync'
@@ -7,6 +7,7 @@ export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
export interface BackupJob { export interface BackupJob {
id: string id: string
userId: number | null
type: BackupJobType type: BackupJobType
status: BackupJobStatus status: BackupJobStatus
progress: number progress: number
@@ -21,6 +22,7 @@ export interface BackupJob {
interface BackupJobRow { interface BackupJobRow {
id: string id: string
user_id: number | null
type: BackupJobType type: BackupJobType
status: BackupJobStatus status: BackupJobStatus
progress: number progress: number
@@ -43,6 +45,7 @@ function rowToJob(row: BackupJobRow): BackupJob {
})() })()
return { return {
id: row.id, id: row.id,
userId: row.user_id ?? null,
type: row.type, type: row.type,
status: row.status, status: row.status,
progress: row.progress, progress: row.progress,
@@ -62,33 +65,34 @@ function generateJobId(): string {
return `${stamp}-${suffix}` return `${stamp}-${suffix}`
} }
export function getRunningBackupJob(): BackupJob | null { export function getRunningBackupJob(userId?: number): BackupJob | null {
ensureBackupSchema() ensureBackupSchema()
const row = getDb().prepare(` 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 output_path, error, created_at, finished_at
FROM backup_jobs FROM backup_jobs
WHERE status = 'running' WHERE status = 'running'${userId === undefined ? '' : ' AND user_id = ?'}
LIMIT 1 LIMIT 1
`).get() as BackupJobRow | undefined `).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null return row ? rowToJob(row) : null
} }
export function getActiveBackupJob(): BackupJob | null { export function getActiveBackupJob(userId?: number): BackupJob | null {
ensureBackupSchema() ensureBackupSchema()
const row = getDb().prepare(` 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 output_path, error, created_at, finished_at
FROM backup_jobs FROM backup_jobs
WHERE status IN ('pending', 'running') WHERE status IN ('pending', 'running')${userId === undefined ? '' : ' AND user_id = ?'}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
`).get() as BackupJobRow | undefined `).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null return row ? rowToJob(row) : null
} }
export function createBackupJob(input: { export function createBackupJob(input: {
type: BackupJobType type: BackupJobType
userId?: number | null
total?: number total?: number
message?: string message?: string
payload?: Record<string, unknown> payload?: Record<string, unknown>
@@ -97,13 +101,15 @@ export function createBackupJob(input: {
const id = generateJobId() const id = generateJobId()
const now = new Date().toISOString() const now = new Date().toISOString()
const payloadJson = JSON.stringify(input.payload ?? {}) const payloadJson = JSON.stringify(input.payload ?? {})
const userId = input.userId === undefined ? getPrimaryAdminUserId() : input.userId
getDb().prepare(` getDb().prepare(`
INSERT INTO backup_jobs INSERT INTO backup_jobs
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL) VALUES (?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
`).run( `).run(
id, id,
userId,
input.type, input.type,
input.total ?? 0, input.total ?? 0,
input.message ?? '', input.message ?? '',
@@ -114,43 +120,69 @@ export function createBackupJob(input: {
return getBackupJob(id)! return getBackupJob(id)!
} }
export function getBackupJob(id: string): BackupJob | null { export function getBackupJob(id: string, userId?: number): BackupJob | null {
ensureBackupSchema() ensureBackupSchema()
const row = getDb().prepare(` 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 output_path, error, created_at, finished_at
FROM backup_jobs FROM backup_jobs
WHERE id = ? WHERE id = ?${userId === undefined ? '' : ' AND user_id = ?'}
`).get(id) as BackupJobRow | undefined `).get(...(userId === undefined ? [id] : [id, userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null return row ? rowToJob(row) : null
} }
export function listBackupJobs(limit = 20): BackupJob[] { export function listBackupJobs(limit = 20, userId?: number): BackupJob[] {
return listBackupJobsPage(1, limit).jobs return listBackupJobsPage(1, limit, userId).jobs
} }
export function countBackupJobs(): number { export function countBackupJobs(userId?: number): number {
ensureBackupSchema() 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 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() ensureBackupSchema()
const limit = Math.min(50, Math.max(1, pageSize)) const limit = Math.min(50, Math.max(1, pageSize))
const safePage = Math.max(1, page) const safePage = Math.max(1, page)
const offset = (safePage - 1) * limit const offset = (safePage - 1) * limit
const total = countBackupJobs() const total = countBackupJobs(userId)
const rows = getDb().prepare(` 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 output_path, error, created_at, finished_at
FROM backup_jobs FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? OFFSET ? 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 } 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. */ /** Capture live task history before a full restore swaps the database file. */
export function snapshotBackupJobs(limit = 50): BackupJob[] { export function snapshotBackupJobs(limit = 50): BackupJob[] {
return listBackupJobs(limit) return listBackupJobs(limit)
@@ -165,12 +197,13 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void {
db.prepare('DELETE FROM backup_jobs').run() db.prepare('DELETE FROM backup_jobs').run()
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO backup_jobs INSERT INTO backup_jobs
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at) (id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`) `)
for (const job of preserved) { for (const job of preserved) {
insert.run( insert.run(
job.id, job.id,
job.userId,
job.type, job.type,
job.status, job.status,
job.progress, 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() claimJobSlot()
const job = createBackupJob({ const job = createBackupJob({
type: 'full_export', type: 'full_export',
userId,
message: '等待开始…' message: '等待开始…'
}) })
scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion)) scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion))
@@ -82,11 +83,13 @@ export function startFullExportJob(appVersion: string): BackupJob {
export function startFullRestoreJob( export function startFullRestoreJob(
archivePath: string, archivePath: string,
conflictMode: 'skip' | 'overwrite' conflictMode: 'skip' | 'overwrite',
userId?: number | null
): BackupJob { ): BackupJob {
claimJobSlot() claimJobSlot()
const job = createBackupJob({ const job = createBackupJob({
type: 'full_restore', type: 'full_restore',
userId,
message: '等待开始…', message: '等待开始…',
payload: { archivePath, conflictMode } payload: { archivePath, conflictMode }
}) })
@@ -100,10 +103,11 @@ export function startBackendSyncJob(options: {
deleteSource?: boolean deleteSource?: boolean
setDefault?: boolean setDefault?: boolean
dryRun?: boolean dryRun?: boolean
}): BackupJob { }, userId?: number | null): BackupJob {
claimJobSlot() claimJobSlot()
const job = createBackupJob({ const job = createBackupJob({
type: 'backend_sync', type: 'backend_sync',
userId,
message: options.dryRun ? '预检中…' : '等待开始…', message: options.dryRun ? '预检中…' : '等待开始…',
payload: options payload: options
}) })
+18
View File
@@ -4,6 +4,7 @@ export function ensureBackupSchema(): void {
getDb().exec(` getDb().exec(`
CREATE TABLE IF NOT EXISTS backup_jobs ( CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER,
type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')), type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')),
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')), status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')),
progress INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
@@ -18,4 +19,21 @@ export function ensureBackupSchema(): void {
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at
ON backup_jobs(created_at DESC); 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', 'flv',
'wmv', 'wmv',
'mpg', 'mpg',
'mpeg', 'mpeg'
'ts',
'm3u8'
] as const ] as const
/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */ /** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */
+7
View File
@@ -131,6 +131,11 @@ describe('isVideoExtension', () => {
expect(isVideoExtension('mkv')).toBe(true) 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', () => { it('rejects non-video extensions', () => {
expect(isVideoExtension('png')).toBe(false) expect(isVideoExtension('png')).toBe(false)
expect(isVideoExtension('pdf')).toBe(false) expect(isVideoExtension('pdf')).toBe(false)
@@ -153,6 +158,8 @@ describe('validateVideoKey', () => {
it('is covered by validateStorageKey', () => { it('is covered by validateStorageKey', () => {
expect(validateStorageKey('videos/2026/10/a.mp4')).toBe(true) 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 thumb_key: string | null
source_deleted: number source_deleted: number
transcode_preset: string | null transcode_preset: string | null
transcode_error: string | null
} }
function keyToFilePath(key: string): string { function keyToFilePath(key: string): string {
@@ -76,6 +77,7 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
thumbKey: row.thumb_key ?? null, thumbKey: row.thumb_key ?? null,
sourceDeleted: Number(row.source_deleted ?? 0) === 1, sourceDeleted: Number(row.source_deleted ?? 0) === 1,
transcodePreset: row.transcode_preset ?? null, transcodePreset: row.transcode_preset ?? null,
transcodeError: row.transcode_error ?? null,
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now() mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
} }
} }
@@ -230,7 +232,7 @@ export async function purgeOrphanImageIndexRows(): Promise<number> {
export function getImageIndexRow(key: string): ImageIndexRow | null { export function getImageIndexRow(key: string): ImageIndexRow | null {
ensureStorageSchema() ensureStorageSchema()
const row = getDb().prepare(` 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 FROM images
WHERE key = ? WHERE key = ?
`).get(key) as ImageIndexRow | undefined `).get(key) as ImageIndexRow | undefined
@@ -423,16 +425,26 @@ export function getImageTotals(): { count: number, bytes: number } {
export function listAllImageIndexRows(): ImageIndexRow[] { export function listAllImageIndexRows(): ImageIndexRow[] {
ensureStorageSchema() ensureStorageSchema()
return getDb().prepare(` 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 FROM images
ORDER BY key ASC ORDER BY key ASC
`).all() as unknown as ImageIndexRow[] `).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[] { export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
ensureStorageSchema() ensureStorageSchema()
return getDb().prepare(` 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 FROM images
WHERE backend_id = ? WHERE backend_id = ?
ORDER BY key ASC ORDER BY key ASC
@@ -456,6 +468,7 @@ export function updateImageTranscode(
sourceDeleted?: boolean sourceDeleted?: boolean
size?: number size?: number
preset?: string | null preset?: string | null
error?: string | null
} }
): void { ): void {
ensureStorageSchema() ensureStorageSchema()
@@ -466,7 +479,8 @@ export function updateImageTranscode(
transcode_progress = COALESCE(?, transcode_progress), transcode_progress = COALESCE(?, transcode_progress),
source_deleted = COALESCE(?, source_deleted), source_deleted = COALESCE(?, source_deleted),
size = COALESCE(?, size), size = COALESCE(?, size),
transcode_preset = COALESCE(?, transcode_preset) transcode_preset = COALESCE(?, transcode_preset),
transcode_error = ?
WHERE key = ? WHERE key = ?
`).run( `).run(
input.status, input.status,
@@ -475,19 +489,23 @@ export function updateImageTranscode(
input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0), input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0),
input.size ?? null, input.size ?? null,
input.preset ?? null, input.preset ?? null,
input.error ?? null,
key key
) )
} }
/** 启动时恢复被中断的转码任务 */ /** 启动时恢复被中断的转码任务 */
export function listPendingTranscodeKeys(): string[] { export function listPendingTranscodeRows(): Array<{
key: string
preset: string | null
}> {
ensureStorageSchema() ensureStorageSchema()
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT key FROM images SELECT key, transcode_preset FROM images
WHERE media_type = 'video' AND transcode_status IN ('pending', 'running') WHERE media_type = 'video' AND transcode_status IN ('pending', 'running')
ORDER BY uploaded_at ASC ORDER BY uploaded_at ASC
`).all() as Array<{ key: string }> `).all() as Array<{ key: string, transcode_preset: string | null }>
return rows.map(row => row.key) 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 backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params) const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(` 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 FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql} WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC ORDER BY uploaded_at DESC, key DESC
@@ -715,7 +733,7 @@ export async function searchImages(options: {
const backendSql = buildBackendFilterSql(options.backendId, params) const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params) const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(` 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 FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql} WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC ORDER BY uploaded_at DESC, key DESC
@@ -849,7 +867,7 @@ export async function getUserScopedStorageStats(
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> { export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
ensureStorageSchema() ensureStorageSchema()
const rows = getDb().prepare(` 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 FROM images
WHERE media_type = 'image' WHERE media_type = 'image'
ORDER BY uploaded_at ASC 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_THUMB_KEY = 'storage_schema_thumb'
const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted' const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted'
const STORAGE_SCHEMA_TRANSCODE_PRESET_KEY = 'storage_schema_transcode_preset' const STORAGE_SCHEMA_TRANSCODE_PRESET_KEY = 'storage_schema_transcode_preset'
const STORAGE_SCHEMA_TRANSCODE_ERROR_KEY = 'storage_schema_transcode_error'
/** images 表新增 media_type 列(区分图床图片与文件床文件) */ /** images 表新增 media_type 列(区分图床图片与文件床文件) */
function migrateImagesMediaType(database: DatabaseSync): void { function migrateImagesMediaType(database: DatabaseSync): void {
@@ -232,6 +233,27 @@ function migrateImagesTranscodePreset(database: DatabaseSync): void {
`).run(STORAGE_SCHEMA_TRANSCODE_PRESET_KEY, new Date().toISOString()) `).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 { function migrateStorageTables(database: DatabaseSync): void {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS storage_backends ( CREATE TABLE IF NOT EXISTS storage_backends (
@@ -263,6 +285,7 @@ function migrateStorageTables(database: DatabaseSync): void {
thumb_key TEXT, thumb_key TEXT,
source_deleted INTEGER NOT NULL DEFAULT 0, source_deleted INTEGER NOT NULL DEFAULT 0,
transcode_preset TEXT, transcode_preset TEXT,
transcode_error TEXT,
FOREIGN KEY (backend_id) REFERENCES storage_backends(id) FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
); );
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC); 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) migrateImagesThumb(database)
migrateImagesSourceDeleted(database) migrateImagesSourceDeleted(database)
migrateImagesTranscodePreset(database) migrateImagesTranscodePreset(database)
migrateImagesTranscodeError(database)
database.exec(` database.exec(`
CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type); 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 { export function buildObjectKey(prefix: string | undefined, key: string): string {
const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? '' const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? ''
return trimmed ? `${trimmed}/${key}` : key return trimmed ? `${trimmed}/${key}` : key
@@ -125,7 +134,8 @@ export class S3StorageBackend implements StorageBackend {
Body: bytes, Body: bytes,
ContentType: meta.contentType, ContentType: meta.contentType,
Metadata: { Metadata: {
originalName: meta.originalName, // S3 metadata headers must be ASCII-safe.
originalName: encodeURIComponent(meta.originalName),
uploadedAt: meta.uploadedAt, uploadedAt: meta.uploadedAt,
userId: meta.userId != null ? String(meta.userId) : '' userId: meta.userId != null ? String(meta.userId) : ''
} }
@@ -144,7 +154,7 @@ export class S3StorageBackend implements StorageBackend {
const uploadedAt = response.Metadata?.uploadedat const uploadedAt = response.Metadata?.uploadedat
?? response.LastModified?.toISOString() ?? response.LastModified?.toISOString()
?? new Date().toISOString() ?? new Date().toISOString()
const originalName = response.Metadata?.originalname const originalName = decodeMetadata(response.Metadata?.originalname)
?? key.split('/').pop() ?? key.split('/').pop()
?? 'image' ?? 'image'
const userIdRaw = response.Metadata?.userid const userIdRaw = response.Metadata?.userid
+2
View File
@@ -25,6 +25,8 @@ export interface StoredImage extends StoredImageMeta {
transcodeProgress?: number transcodeProgress?: number
/** 本视频使用的转码档位 */ /** 本视频使用的转码档位 */
transcodePreset?: string | null transcodePreset?: string | null
/** 转码失败原因 */
transcodeError?: string | null
/** 视频封面缩略图 key */ /** 视频封面缩略图 key */
thumbKey?: string | null 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 { describe, expect, it } from 'vitest'
import { import {
cleanupExpiredHlsDownloads,
DEFAULT_TRANSCODE_CONCURRENCY, DEFAULT_TRANSCODE_CONCURRENCY,
DEFAULT_TRANSCODE_PRESET, DEFAULT_TRANSCODE_PRESET,
DEFAULT_TRANSCODE_THREADS, DEFAULT_TRANSCODE_THREADS,
getTranscodeConcurrency, getTranscodeConcurrency,
getTranscodeSettings, getTranscodeSettings,
hlsSegmentNames,
hlsPlaylistKey, hlsPlaylistKey,
markHlsDownloadForCleanup,
parseTranscodePreset, parseTranscodePreset,
setTranscodeSettings, setTranscodeSettings,
summarizeTranscodeFailure,
thumbKeyFor thumbKeyFor
} from './transcode' } from './transcode'
import { isVideoSidecarKey, mediaTypeForKey } from './image-key' 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', () => { describe('mediaTypeForKey', () => {
it('maps folders to media types', () => { it('maps folders to media types', () => {
expect(mediaTypeForKey('images/2026/10/a.webp')).toBe('image') 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', () => { describe('transcode settings', () => {
it('defaults to one concurrent job with one thread', () => { it('defaults to one concurrent job with one thread', () => {
expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1) expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1)
+185 -28
View File
@@ -5,10 +5,11 @@ import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { basename, join } from 'node:path' import { basename, join } from 'node:path'
import { pipeline } from 'node:stream/promises' import { pipeline } from 'node:stream/promises'
import sharp from 'sharp'
import { getDataDir } from './data-dir' import { getDataDir } from './data-dir'
import { import {
listAllImageIndexRows, listAllImageIndexRows,
listPendingTranscodeKeys, listPendingTranscodeRows,
listTranscodedSourceRows, listTranscodedSourceRows,
updateImageTranscode updateImageTranscode
} from './image-index' } from './image-index'
@@ -37,6 +38,9 @@ import {
/** HLS 分片时长(秒) */ /** HLS 分片时长(秒) */
const HLS_SEGMENT_TIME = 6 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_CONCURRENCY = 1
export const DEFAULT_TRANSCODE_THREADS = 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 MIN_TRANSCODE_BITRATE_PERCENT = 10
export const MAX_TRANSCODE_BITRATE_PERCENT = 100 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 { export function getTranscodeBitratePercent(): number {
return parseBoundedInt( return parseBoundedInt(
getSetting(SETTINGS_TRANSCODE_BITRATE), getSetting(SETTINGS_TRANSCODE_BITRATE),
@@ -84,6 +103,21 @@ function parseBoundedInt(
return value 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 { export interface TranscodeSettings {
concurrency: number concurrency: number
threads: number threads: number
@@ -162,6 +196,19 @@ export function thumbKeyFor(videoKey: string): string {
return `${stripExtension(videoKey)}.webp` 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> { function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', [ const child = spawn('ffmpeg', [
@@ -172,8 +219,7 @@ function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Pr
'-i', inputPath, '-i', inputPath,
'-frames:v', '1', '-frames:v', '1',
'-vf', 'scale=640:-2', '-vf', 'scale=640:-2',
'-c:v', 'libwebp', '-c:v', 'png',
'-quality', '80',
outputPath outputPath
], { windowsHide: true }) ], { windowsHide: true })
@@ -203,6 +249,7 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`) const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`)
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`) const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
const framePath = join(workDir, 'thumb.png')
const outputPath = join(workDir, 'thumb.webp') const outputPath = join(workDir, 'thumb.webp')
await fs.mkdir(workDir, { recursive: true }) await fs.mkdir(workDir, { recursive: true })
@@ -211,11 +258,12 @@ export async function generateVideoThumbnail(videoKey: string): Promise<string |
await streamToFile(await createImageStream(videoKey), inputPath) await streamToFile(await createImageStream(videoKey), inputPath)
try { try {
await runThumbFfmpeg(inputPath, outputPath, 1) await runThumbFfmpeg(inputPath, framePath, 1)
} catch { } 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 bytes = await fs.readFile(outputPath)
const thumbKey = thumbKeyFor(videoKey) const thumbKey = thumbKeyFor(videoKey)
const backend = await getBackendForKey(videoKey) const backend = await getBackendForKey(videoKey)
@@ -318,9 +366,10 @@ function runFfmpeg(
outputDir: string, outputDir: string,
sourceBitrate: number, sourceBitrate: number,
sourceAudioBitrate: number, sourceAudioBitrate: number,
options: TranscodeJobOptions,
onProgressUs: (outTimeUs: number) => void onProgressUs: (outTimeUs: number) => void
): Promise<void> { ): Promise<void> {
const { preset, bitratePercent } = getTranscodeSettings() const { preset, bitratePercent } = options
// 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k) // 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k)
const audioBitrate = sourceAudioBitrate > 0 const audioBitrate = sourceAudioBitrate > 0
@@ -419,12 +468,96 @@ async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
return Buffer.concat(chunks).toString('utf8') 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 与产物总大小 */ /** 把视频转成 HLS 并写入存储,返回播放列表 key 与产物总大小 */
export async function transcodeVideoToHls( export async function transcodeVideoToHls(
videoKey: string videoKey: string,
jobOptions?: Partial<TranscodeJobOptions>
): Promise<{ playlistKey: string, totalBytes: number }> { ): Promise<{ playlistKey: string, totalBytes: number }> {
const stored = await headImage(videoKey) const stored = await headImage(videoKey)
if (!stored) throw new Error('视频不存在') if (!stored) throw new Error('视频不存在')
const options = resolveTranscodeJobOptions(jobOptions)
const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`) const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`)
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`) const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
@@ -445,7 +578,14 @@ export async function transcodeVideoToHls(
const sourceAudioBitrate = await probeAudioBitrate(inputPath) const sourceAudioBitrate = await probeAudioBitrate(inputPath)
let lastProgressAt = 0 let lastProgressAt = 0
await runFfmpeg(videoKey, inputPath, outputDir, sourceBitrate, sourceAudioBitrate, (outTimeUs) => { await runFfmpeg(
videoKey,
inputPath,
outputDir,
sourceBitrate,
sourceAudioBitrate,
options,
(outTimeUs) => {
if (!durationSec) return if (!durationSec) return
const now = Date.now() const now = Date.now()
if (now - lastProgressAt < 1000) return if (now - lastProgressAt < 1000) return
@@ -455,7 +595,8 @@ export async function transcodeVideoToHls(
status: 'running', status: 'running',
progress: Math.min(99, Math.max(1, percent)) progress: Math.min(99, Math.max(1, percent))
}) })
}) }
)
const uploadedAt = new Date().toISOString() const uploadedAt = new Date().toISOString()
@@ -503,10 +644,8 @@ export async function removeHlsArtifacts(videoKey: string): Promise<void> {
const dirKey = stripExtension(videoKey) const dirKey = stripExtension(videoKey)
const text = await streamToString(await createImageStream(playlistKey)) const text = await streamToString(await createImageStream(playlistKey))
for (const line of text.split('\n')) { for (const name of hlsSegmentNames(text)) {
const name = line.trim() const segmentKey = `${dirKey}/${name}`
if (!name || name.startsWith('#')) continue
const segmentKey = `${dirKey}/${basename(name)}`
if (!validateStorageKey(segmentKey)) continue if (!validateStorageKey(segmentKey)) continue
await deleteImage(segmentKey).catch(() => {}) 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>() const queued = new Set<string>()
let activeCount = 0 let activeCount = 0
async function processOne(videoKey: string): Promise<void> { async function processOne(job: QueuedTranscode): Promise<void> {
const { videoKey } = job
updateImageTranscode(videoKey, { status: 'running' }) updateImageTranscode(videoKey, { status: 'running' })
try { try {
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey) const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
// 转码成功后源文件不再需要,直接删除以节省空间 // 转码成功后源文件不再需要,直接删除以节省空间
const backend = await getBackendForKey(videoKey) const backend = await getBackendForKey(videoKey)
@@ -543,7 +687,10 @@ async function processOne(videoKey: string): Promise<void> {
}) })
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey }) logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
} catch (error) { } catch (error) {
updateImageTranscode(videoKey, { status: 'failed' }) updateImageTranscode(videoKey, {
status: 'failed',
error: summarizeTranscodeFailure(error)
})
logException('transcode failed', error, { key: videoKey }) logException('transcode failed', error, { key: videoKey })
} }
} }
@@ -551,10 +698,11 @@ async function processOne(videoKey: string): Promise<void> {
/** 按设置的并发数消费队列(默认 1,即串行) */ /** 按设置的并发数消费队列(默认 1,即串行) */
function drain(): void { function drain(): void {
while (queue.length && activeCount < getTranscodeConcurrency()) { while (queue.length && activeCount < getTranscodeConcurrency()) {
const key = queue.shift()! const job = queue.shift()!
const key = job.videoKey
activeCount += 1 activeCount += 1
void processOne(key).finally(() => { void processOne(job).finally(() => {
activeCount -= 1 activeCount -= 1
queued.delete(key) queued.delete(key)
drain() 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 if (!validateVideoKey(videoKey) || queued.has(videoKey)) return
const job = resolveTranscodeJobOptions(options)
queued.add(videoKey) queued.add(videoKey)
queue.push(videoKey) queue.push({ videoKey, ...job })
// 记录本视频使用的档位,便于上传页展示 // 记录本视频使用的档位,便于上传页展示
const preset = getTranscodeSettings().preset
updateImageTranscode(videoKey, { updateImageTranscode(videoKey, {
status: 'pending', status: 'pending',
// 压缩档带上百分比,例如 compress:80 // 压缩档带上百分比,例如 compress:80
preset: preset === 'compress' preset: job.preset === 'compress'
? `compress:${getTranscodeBitratePercent()}` ? `compress:${job.bitratePercent}`
: preset : job.preset
}) })
drain() drain()
} }
/** 取消排队中或正在转码的任务(删除视频时调用) */ /** 取消排队中或正在转码的任务(删除视频时调用) */
export function cancelTranscode(videoKey: string): void { 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) if (index >= 0) queue.splice(index, 1)
queued.delete(videoKey) queued.delete(videoKey)
@@ -616,8 +767,14 @@ export async function cleanupTranscodedSources(): Promise<number> {
/** 启动时把上次中断的任务重新排队 */ /** 启动时把上次中断的任务重新排队 */
export function resumePendingTranscodes(): void { export function resumePendingTranscodes(): void {
for (const key of listPendingTranscodeKeys()) { for (const row of listPendingTranscodeRows()) {
enqueueTranscode(key) 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)
}
}