feat: 新增视频床与文件床,视频支持 HLS 转码
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
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:
1 parent
05cc3895f1
commit
89ea2fb25d
61 files changed
+6637
-154
No files matched your search
+228
-23
@@ -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[]
|
||||
|
||||
|
||||
Reference in new issue
Block a user