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