feat: 新增视频床与文件床,视频支持 HLS 转码
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

文件床
- 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立
- 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg
- 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载

视频床
- 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB
- 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行
- 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例)
- 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩
- 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接
- 上传时自动抽帧生成封面,视频与封面各有独立链接
- 删除视频时中断转码并清理切片、封面与源文件

媒体库
- 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布
- 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆
- 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件)

修复
- 清理无主的切片、封面与开发期遗留的 ffmpeg 进程
- 源文件按设计删除后,索引不再被启动孤儿清理误删
- 视频统计口径与列表一致,转码中的视频不计入统计
- 转码失败时回收已写入的部分切片,避免残留
- 设置页停用检查更新(与上游版本号已不对应)

其他
- Docker 镜像安装 ffmpeg
- git remote 指向内网仓库
This commit is contained in:
Yeomai committed 2026-10-17 03:14:57 +08:00
1 parent 05cc3895f1
commit 89ea2fb25d
61 files changed
+6637 -154

No files matched your search

+128
View File
@@ -1,3 +1,4 @@
import { VIDEO_EXTENSIONS } from './constants'
import type { AllowedMimeType } from './constants'
const EXT_MAP: Record<AllowedMimeType, string> = {
@@ -10,11 +11,16 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
}
export const DEFAULT_FOLDER = 'images'
export const FILE_FOLDER = 'files'
export const VIDEO_FOLDER = 'videos'
export type StorageLayout = 'date' | 'flat'
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
/** 文件床文件名:字母数字开头,允许 - _ .,最长 64(含扩展名) */
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
function generateRandomId(length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const bytes = new Uint8Array(length)
@@ -69,6 +75,128 @@ export function validateImageKey(key: string): boolean {
return true
}
/** 扩展名清洗:只保留 1–10 位字母数字 */
export function sanitizeFileExtension(raw: string): string {
const ext = raw.trim().toLowerCase().replace(/^\.+/, '')
return /^[a-z0-9]{1,10}$/.test(ext) ? ext : ''
}
/** 从原始文件名取安全扩展名;无扩展名时返回空串 */
export function extensionFromFilename(filename: string): string {
const base = filename.split(/[/\\]/).pop() ?? ''
const dot = base.lastIndexOf('.')
if (dot <= 0 || dot === base.length - 1) return ''
return sanitizeFileExtension(base.slice(dot + 1))
}
export function generateFileKey(
extension: string,
date = new Date(),
layout: StorageLayout = 'date'
): string {
const id = generateRandomId(12)
const ext = sanitizeFileExtension(extension)
const suffix = ext ? `.${ext}` : ''
if (layout === 'flat') {
return `${FILE_FOLDER}/${id}${suffix}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${FILE_FOLDER}/${year}/${month}/${id}${suffix}`
}
/** 文件床 key(files/…);图片 key 走 validateImageKey */
export function validateFileKey(key: string): boolean {
if (!key || typeof key !== 'string') return false
if (key.startsWith('/')) return false
if (key.includes('..')) return false
if (key.includes('//')) return false
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
if (parts[0] !== FILE_FOLDER) return false
const filename = parts[parts.length - 1]!
if (!FILE_NAME_PATTERN.test(filename)) return false
for (let i = 1; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return false
}
return true
}
/** 图片或文件床的存储 key */
export function validateStorageKey(key: string): boolean {
return validateImageKey(key) || validateFileKey(key) || validateVideoKey(key)
}
export function mediaTypeForKey(key: string): 'image' | 'video' | 'file' {
if (key.startsWith(`${FILE_FOLDER}/`)) return 'file'
if (key.startsWith(`${VIDEO_FOLDER}/`)) return 'video'
return 'image'
}
/** videos/ 下除视频本体外的附属资源:播放列表、分片、封面缩略图 */
const VIDEO_SIDECAR_EXTENSIONS = ['m3u8', 'ts', 'webp', 'jpg', 'jpeg', 'png']
function isVideoKeyExtension(extension: string): boolean {
if (!extension) return false
const value = extension.toLowerCase()
return (VIDEO_EXTENSIONS as readonly string[]).includes(value)
|| VIDEO_SIDECAR_EXTENSIONS.includes(value)
}
/** 附属资源不进索引,也不出现在列表里 */
export function isVideoSidecarKey(key: string): boolean {
return validateVideoKey(key)
&& VIDEO_SIDECAR_EXTENSIONS.includes(extensionFromFilename(key))
}
export function isVideoExtension(extension: string): boolean {
if (!extension) return false
return (VIDEO_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
}
export function generateVideoKey(
extension: string,
date = new Date(),
layout: StorageLayout = 'date'
): string {
const id = generateRandomId(12)
const ext = sanitizeFileExtension(extension) || 'mp4'
if (layout === 'flat') {
return `${VIDEO_FOLDER}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${VIDEO_FOLDER}/${year}/${month}/${id}.${ext}`
}
/** 视频床 key(videos/…),扩展名必须在视频白名单内 */
export function validateVideoKey(key: string): boolean {
if (!key || typeof key !== 'string') return false
if (key.startsWith('/')) return false
if (key.includes('..')) return false
if (key.includes('//')) return false
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
if (parts[0] !== VIDEO_FOLDER) return false
const filename = parts[parts.length - 1]!
if (!FILE_NAME_PATTERN.test(filename)) return false
if (!isVideoKeyExtension(extensionFromFilename(filename))) return false
for (let i = 1; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return false
}
return true
}
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */
export function toCanonicalImageKey(key: string): string | null {
if (validateImageKey(key)) return key