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