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

+228 -23
View File
@@ -3,7 +3,16 @@ import { dirname, join } from 'node:path'
import { getDb, getUploadSourcesForKeys } from './db'
import { getDataDir } from './data-dir'
import { contentTypeFromKey } from './content-type'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import {
DEFAULT_FOLDER,
FILE_FOLDER,
VIDEO_FOLDER,
isVideoSidecarKey,
mediaTypeForKey,
toCanonicalImageKey,
validateImageKey,
validateStorageKey
} from './image-key'
import { buildTagFilterSql, type TagFilterInput } from './tags'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import {
@@ -11,7 +20,13 @@ import {
ensureStorageSchema,
LOCAL_BACKEND_ID
} from './storage-backends'
import type { PaginatedResult, StoredImage, StoredImageMeta } from './storage/types'
import type {
MediaType,
PaginatedResult,
StoredImage,
StoredImageMeta,
TranscodeStatus
} from './storage/types'
const META_SUFFIX = '.meta.json'
@@ -24,6 +39,12 @@ export interface ImageIndexRow {
content_type: string
size: number
uploaded_at: string
hls_key: string | null
transcode_status: string
transcode_progress: number
thumb_key: string | null
source_deleted: number
transcode_preset: string | null
}
function keyToFilePath(key: string): string {
@@ -49,6 +70,12 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
contentType: row.content_type,
size: row.size,
userId: row.user_id,
hlsKey: row.hls_key ?? null,
transcodeStatus: (row.transcode_status ?? 'none') as TranscodeStatus,
transcodeProgress: Number(row.transcode_progress ?? 0),
thumbKey: row.thumb_key ?? null,
sourceDeleted: Number(row.source_deleted ?? 0) === 1,
transcodePreset: row.transcode_preset ?? null,
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
}
}
@@ -70,13 +97,14 @@ export function insertImageIndex(input: {
contentType: string
size: number
uploadedAt: string
mediaType?: MediaType
}): void {
ensureStorageSchema()
const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER
getDb().prepare(`
INSERT INTO images
(key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, media_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
backend_id = excluded.backend_id,
user_id = excluded.user_id,
@@ -84,7 +112,8 @@ export function insertImageIndex(input: {
original_name = excluded.original_name,
content_type = excluded.content_type,
size = excluded.size,
uploaded_at = excluded.uploaded_at
uploaded_at = excluded.uploaded_at,
media_type = excluded.media_type
`).run(
input.key,
input.backendId,
@@ -93,7 +122,8 @@ export function insertImageIndex(input: {
input.originalName,
input.contentType,
input.size,
input.uploadedAt
input.uploadedAt,
input.mediaType ?? 'image'
)
}
@@ -180,11 +210,13 @@ export async function reconcileLegacyImageKeys(): Promise<{
export async function purgeOrphanImageIndexRows(): Promise<number> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key, backend_id FROM images
`).all() as Array<{ key: string, backend_id: string }>
SELECT key, backend_id, source_deleted FROM images
`).all() as Array<{ key: string, backend_id: string, source_deleted: number }>
let removed = 0
for (const row of rows) {
// 转码成功后源文件是主动删除的,索引要保留(播放走 HLS)
if (Number(row.source_deleted ?? 0) === 1) continue
if (row.backend_id !== LOCAL_BACKEND_ID) continue
if (await fileExistsAtKey(row.key)) continue
const canonical = toCanonicalImageKey(row.key)
@@ -198,7 +230,7 @@ export async function purgeOrphanImageIndexRows(): Promise<number> {
export function getImageIndexRow(key: string): ImageIndexRow | null {
ensureStorageSchema()
const row = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
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
FROM images
WHERE key = ?
`).get(key) as ImageIndexRow | undefined
@@ -217,7 +249,7 @@ export function findImageKeyByDatePath(datePath: string): string | null {
ensureStorageSchema()
const suffix = `/${datePath}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
SELECT key FROM images WHERE media_type = 'image' AND key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
@@ -240,7 +272,7 @@ export function findImageKeyByFilename(filename: string): string | null {
ensureStorageSchema()
const suffix = `/${filename}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
SELECT key FROM images WHERE media_type = 'image' AND key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
@@ -274,7 +306,7 @@ async function collectImageKeysRecursive(
} else if (entry.isFile()) {
if (name.endsWith(META_SUFFIX)) continue
const key = `${keyPrefix}/${name}`
if (validateImageKey(key)) keys.push(key)
if (validateStorageKey(key)) keys.push(key)
}
}
} catch {
@@ -284,17 +316,25 @@ async function collectImageKeysRecursive(
return keys
}
async function listLocalImageKeys(): Promise<string[]> {
const imagesRoot = join(getDataDir(), DEFAULT_FOLDER)
return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER)
/** 扫描本地磁盘的 images/、files/ 与 videos/ 目录 */
async function listLocalStorageKeys(): Promise<string[]> {
const keys: string[] = []
for (const root of [DEFAULT_FOLDER, FILE_FOLDER, VIDEO_FOLDER]) {
const found = await collectImageKeysRecursive(join(getDataDir(), root), root)
keys.push(...found)
}
return keys
}
export async function migrateLocalImagesToIndex(): Promise<number> {
ensureDefaultBackends()
const keys = await listLocalImageKeys()
const keys = await listLocalStorageKeys()
let inserted = 0
for (const key of keys) {
// HLS 播放列表与分片属于视频的附属产物,不进索引
if (isVideoSidecarKey(key)) continue
const existing = getImageIndexRow(key)
if (existing) continue
@@ -320,7 +360,8 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
contentType,
size: stat.size,
uploadedAt
uploadedAt,
mediaType: mediaTypeForKey(key) as MediaType
})
inserted++
}
@@ -382,7 +423,7 @@ export function getImageTotals(): { count: number, bytes: number } {
export function listAllImageIndexRows(): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
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
FROM images
ORDER BY key ASC
`).all() as unknown as ImageIndexRow[]
@@ -391,7 +432,7 @@ export function listAllImageIndexRows(): ImageIndexRow[] {
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
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
FROM images
WHERE backend_id = ?
ORDER BY key ASC
@@ -405,15 +446,157 @@ export function updateImageBackendId(key: string, backendId: string): void {
`).run(backendId, key)
}
/** 更新 HLS 转码状态;hlsKey / progress 传 null 时保留原值 */
export function updateImageTranscode(
key: string,
input: {
status: TranscodeStatus
hlsKey?: string | null
progress?: number
sourceDeleted?: boolean
size?: number
preset?: string | null
}
): void {
ensureStorageSchema()
getDb().prepare(`
UPDATE images
SET transcode_status = ?,
hls_key = COALESCE(?, hls_key),
transcode_progress = COALESCE(?, transcode_progress),
source_deleted = COALESCE(?, source_deleted),
size = COALESCE(?, size),
transcode_preset = COALESCE(?, transcode_preset)
WHERE key = ?
`).run(
input.status,
input.hlsKey ?? null,
input.progress ?? null,
input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0),
input.size ?? null,
input.preset ?? null,
key
)
}
/** 启动时恢复被中断的转码任务 */
export function listPendingTranscodeKeys(): string[] {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key FROM images
WHERE media_type = 'video' AND transcode_status IN ('pending', 'running')
ORDER BY uploaded_at ASC
`).all() as Array<{ key: string }>
return rows.map(row => row.key)
}
/** 转码已完成但仍保留源文件的视频(用于补删历史遗留) */
export function listTranscodedSourceRows(): Array<{ key: string, hlsKey: string }> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key, hls_key FROM images
WHERE media_type = 'video'
AND transcode_status = 'done'
AND source_deleted = 0
AND hls_key IS NOT NULL
`).all() as Array<{ key: string, hls_key: string }>
return rows.map(row => ({ key: row.key, hlsKey: row.hls_key }))
}
/** 记录视频封面缩略图 key */
export function updateImageThumb(key: string, thumbKey: string): void {
ensureStorageSchema()
getDb().prepare('UPDATE images SET thumb_key = ? WHERE key = ?').run(thumbKey, key)
}
export interface MediaStats {
uploadToday: number
uploadYesterday: number
uploadMonth: number
uploadLastMonth: number
uploadTotal: number
storedCount: number
uploadBytesTotal: number
bySource: { web: number, api: number }
}
/** 按媒体类型统计(图片 / 视频 / 文件各自独立) */
export function getMediaStats(
mediaType: MediaType,
userFilter?: number | 'admin'
): MediaStats {
ensureStorageSchema()
const params: Array<string | number> = [mediaType]
const userSql = buildUserFilterSql(userFilter, params)
// 转码中的视频还没入库,与列表口径保持一致:统计不计入
const transcodeSql = mediaType === 'video'
? ` AND transcode_status IN ('none', 'done', 'failed')`
: ''
const rows = getDb().prepare(`
SELECT size, uploaded_at FROM images
WHERE media_type = ?${transcodeSql}${userSql}
`).all(...params) as Array<{ size: number, uploaded_at: string }>
const startOfDay = startOfDayIsoInShanghai()
const startOfYesterday = startOfYesterdayIsoInShanghai()
const startOfMonth = startOfMonthIsoInShanghai()
const startOfLastMonth = startOfLastMonthIsoInShanghai()
let bytes = 0
let today = 0
let yesterday = 0
let month = 0
let lastMonth = 0
for (const row of rows) {
bytes += row.size
if (row.uploaded_at >= startOfDay) today++
if (row.uploaded_at >= startOfYesterday && row.uploaded_at < startOfDay) yesterday++
if (row.uploaded_at >= startOfMonth) month++
if (row.uploaded_at >= startOfLastMonth && row.uploaded_at < startOfMonth) lastMonth++
}
// 来源分布来自上传日志,按目录前缀区分类型
const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/'
const sourceRows = getDb().prepare(`
SELECT source, COUNT(*) AS count FROM activity_logs
WHERE action = 'upload' AND key LIKE ?
GROUP BY source
`).all(`${prefix}%`) as Array<{ source: string, count: number }>
const bySource = { web: 0, api: 0 }
for (const row of sourceRows) {
if (row.source === 'web') bySource.web = row.count
else if (row.source === 'api') bySource.api = row.count
}
return {
uploadToday: today,
uploadYesterday: yesterday,
uploadMonth: month,
uploadLastMonth: lastMonth,
uploadTotal: rows.length,
storedCount: rows.length,
uploadBytesTotal: bytes,
bySource
}
}
export async function countImages(
userFilter?: number | 'admin',
backendId?: string,
tagFilter?: TagFilterInput
tagFilter?: TagFilterInput,
mediaType: MediaType | 'all' = 'image'
): Promise<number> {
ensureStorageSchema()
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
if (mediaType !== 'all') {
clauses.push('media_type = ?')
params.push(mediaType)
}
const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params)
const tagSql = buildTagFilterSql(tagFilter, params)
@@ -466,16 +649,27 @@ export async function listImages(options: {
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
mediaType?: MediaType
/** 只返回这些转码状态的条目(视频库用来隐藏转码中的视频) */
transcodeStatuses?: TranscodeStatus[]
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
clauses.push('media_type = ?')
params.push(options.mediaType ?? 'image')
if (options.transcodeStatuses?.length) {
clauses.push(`transcode_status IN (${options.transcodeStatuses.map(() => '?').join(', ')})`)
params.push(...options.transcodeStatuses)
}
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
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
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
@@ -501,17 +695,27 @@ export async function searchImages(options: {
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
mediaType?: MediaType
transcodeStatuses?: TranscodeStatus[]
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const needle = options.query.trim().toLowerCase()
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
clauses.push('media_type = ?')
params.push(options.mediaType ?? 'image')
if (options.transcodeStatuses?.length) {
clauses.push(`transcode_status IN (${options.transcodeStatuses.map(() => '?').join(', ')})`)
params.push(...options.transcodeStatuses)
}
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
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
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
@@ -645,8 +849,9 @@ export async function getUserScopedStorageStats(
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
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
FROM images
WHERE media_type = 'image'
ORDER BY uploaded_at ASC
`).all() as unknown as ImageIndexRow[]