diff --git a/.dockerignore b/.dockerignore
index 9c8c7f6..9302046 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,11 @@ node_modules
.github
data
dist
+dist-fpk
+dist-windows
+dist-docker
+packaging
+scripts
.env
*.md
._*
diff --git a/.gitignore b/.gitignore
index 416e15c..5784c74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,9 +38,9 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/
-# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
-scripts/
+# 打包输出目录
dist-fpk/
+dist-docker/
# VitePress docs site
docs-site/.vitepress/cache
diff --git a/app/components/MediaSessionCard.vue b/app/components/MediaSessionCard.vue
index e03d636..78a163a 100644
--- a/app/components/MediaSessionCard.vue
+++ b/app/components/MediaSessionCard.vue
@@ -99,13 +99,30 @@ const presetLabel = computed(() => {
}
})
-/** 转码已耗时(轮询每秒刷新一次) */
+function formatDuration(seconds: number): string {
+ const days = Math.floor(seconds / 86_400)
+ const hours = Math.floor((seconds % 86_400) / 3_600)
+ const minutes = Math.floor((seconds % 3_600) / 60)
+ const secs = seconds % 60
+
+ if (days > 0) return `${days} d ${hours} h ${minutes} min ${secs} s`
+ if (hours > 0) return `${hours} h ${minutes} min ${secs} s`
+ if (minutes > 0) return `${minutes} min ${secs} s`
+ return `${secs} s`
+}
+
+/** 转码进行中:已耗时(轮询每秒刷新一次) */
const elapsedLabel = computed(() => {
if (!running.value || !props.item.transcodeStartedAt) return null
const started = new Date(props.item.transcodeStartedAt).getTime()
if (!Number.isFinite(started)) return null
- const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000))
- return `${Math.floor(seconds / 60)} min ${seconds % 60} s`
+ return formatDuration(Math.max(0, Math.floor((Date.now() - started) / 1000)))
+})
+
+/** 转码结束(成功或失败):固定显示总耗时 */
+const totalLabel = computed(() => {
+ if (running.value || typeof props.item.transcodeDurationMs !== 'number') return null
+ return formatDuration(Math.floor(props.item.transcodeDurationMs / 1000))
})
@@ -237,7 +254,6 @@ const elapsedLabel = computed(() => {
{
{{ t('library.transcodeElapsed') }} {{ elapsedLabel }}
+
+ {{ t('library.transcodeTotal') }} {{ totalLabel }}
+
diff --git a/app/types/file.ts b/app/types/file.ts
index e49d48f..96672bf 100644
--- a/app/types/file.ts
+++ b/app/types/file.ts
@@ -16,6 +16,8 @@ export interface FileItem {
transcodePreset?: string
/** 转码开始时间(ISO) */
transcodeStartedAt?: string
+ /** 转码总耗时(毫秒) */
+ transcodeDurationMs?: number
/** 转码失败原因 */
transcodeError?: string
/** HLS 播放列表地址(转码完成后有值) */
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index f870343..aaef90d 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -1221,6 +1221,7 @@
"transcodePending": "Queued",
"transcoding": "Transcoding",
"transcodeElapsed": "Elapsed",
+ "transcodeTotal": "Total",
"transcodeFailed": "Transcode failed",
"searchPlaceholder": "Search file name",
"searchEmpty": "Nothing matched",
diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json
index bf33f11..b328a1d 100644
--- a/i18n/locales/zh-CN.json
+++ b/i18n/locales/zh-CN.json
@@ -1224,6 +1224,7 @@
"transcodePending": "等待转码中",
"transcoding": "转码中",
"transcodeElapsed": "已耗时",
+ "transcodeTotal": "总耗时",
"transcodeFailed": "转码失败",
"searchPlaceholder": "搜索文件名",
"searchEmpty": "没有匹配的内容",
diff --git a/package.json b/package.json
index 4e758bf..8232377 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,11 @@
"docs:preview": "npm run docs:prepare && vitepress preview docs-site",
"desktop:prepare": "node desktop/prepare-runtime.mjs",
"desktop:dev": "npm run desktop:prepare && electron .",
- "desktop:build": "npm run desktop:prepare && electron-builder --win nsis --x64"
+ "desktop:build": "npm run desktop:prepare && electron-builder --win nsis --x64",
+ "pack:docker": "node scripts/build-docker.mjs",
+ "pack:fpk": "node scripts/build-fpk.mjs",
+ "pack:windows": "npm run desktop:build",
+ "pack:all": "npm run pack:docker && npm run pack:fpk && npm run pack:windows"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.876.0",
diff --git a/packaging/README.md b/packaging/README.md
new file mode 100644
index 0000000..ad6dc58
--- /dev/null
+++ b/packaging/README.md
@@ -0,0 +1,49 @@
+# PicHost 打包
+
+版本号统一从根目录 `package.json` 的 `version` 读取。
+
+## 环境要求
+
+- Node.js 22 或更高版本
+- npm 11
+- Docker(构建 Docker 镜像和飞牛运行镜像)
+- Windows 构建需要 `ffmpeg.exe` 与 `ffprobe.exe`,可通过 `FFMPEG_DIR` 指定目录
+
+## 构建命令
+
+```bash
+npm ci
+npm run pack:docker
+npm run pack:fpk
+npm run pack:windows
+```
+
+在 Windows 上可以一次构建全部三类产物:
+
+```bash
+npm run pack:all
+```
+
+输出位置:
+
+- Docker 镜像:`dist-docker/*.tar`
+- 飞牛应用:`dist-fpk/*.fpk`
+- Windows 安装程序:`dist-windows/*.exe`
+
+`dist-docker`、`dist-fpk` 和 `dist-windows` 是生成目录,不提交到 Git。可复用的源文件位于 `packaging/fnos`、`desktop` 和 `scripts`。
+
+## 飞牛安装顺序
+
+1. 在飞牛 Docker 中手动导入 `dist-docker/*.tar`。
+2. 确认镜像列表中出现 `yeomaiyelove/pichost:`。
+3. 安装 `dist-fpk/*.fpk`。
+
+FPK 使用 `pull_policy: never`,不会联网拉取 PicHost 镜像。
+
+## 可覆盖参数
+
+- `PICHOST_IMAGE_REPOSITORY`:Docker 镜像仓库名,默认 `yeomaiyelove/pichost`
+- `DOCKER_PLATFORM`:Docker 构建平台,默认 `linux/amd64`
+- `PICHOST_IMAGE`:FPK 使用的完整镜像标签
+- `FPK_PLATFORM`:FPK 平台标识,默认 `x86`
+- `FFMPEG_DIR`:Windows 构建使用的 ffmpeg 目录
diff --git a/packaging/fnos/app/config/privilege b/packaging/fnos/app/config/privilege
new file mode 100644
index 0000000..88c190d
--- /dev/null
+++ b/packaging/fnos/app/config/privilege
@@ -0,0 +1,7 @@
+{
+ "defaults": {
+ "run-as": "package"
+ },
+ "username": "docker-pichost",
+ "groupname": "docker-pichost"
+}
diff --git a/packaging/fnos/app/config/resource b/packaging/fnos/app/config/resource
new file mode 100644
index 0000000..0b2f81d
--- /dev/null
+++ b/packaging/fnos/app/config/resource
@@ -0,0 +1,22 @@
+{
+ "docker-project": {
+ "projects": [
+ {
+ "name": "pichost",
+ "path": "docker"
+ }
+ ]
+ },
+ "data-share": {
+ "shares": [
+ {
+ "name": "pichost/data",
+ "permission": {
+ "rw": [
+ "docker-pichost"
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/packaging/fnos/app/docker/docker-compose.yaml b/packaging/fnos/app/docker/docker-compose.yaml
new file mode 100644
index 0000000..5d68ec3
--- /dev/null
+++ b/packaging/fnos/app/docker/docker-compose.yaml
@@ -0,0 +1,12 @@
+services:
+ pichost:
+ image: yeomaiyelove/pichost:1.5.1
+ pull_policy: never
+ container_name: pichost
+ restart: unless-stopped
+ ports:
+ - "${wizard_port}:6892"
+ volumes:
+ - "/${TRIM_APPDEST_VOL}/@appshare/pichost/data:/data"
+ - "${TRIM_APPDEST}/docker/start.sh:/fnos-start.sh:ro"
+ entrypoint: ["/bin/sh", "/fnos-start.sh"]
diff --git a/packaging/fnos/app/docker/start.sh b/packaging/fnos/app/docker/start.sh
new file mode 100644
index 0000000..6bd5fad
--- /dev/null
+++ b/packaging/fnos/app/docker/start.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+# 飞牛 compose 入口:安装向导写入配置后,启动官方镜像前执行初始化或备份恢复。
+if [ -f /data/.fnos-restore.pending ]; then
+ ARCHIVE="$(tr -d '\r\n' < /data/.fnos-restore.pending)"
+ if [ -n "${ARCHIVE}" ] && [ -f "${ARCHIVE}" ]; then
+ if node /app/server/cli/backup-restore.mjs "${ARCHIVE}" --overwrite; then
+ rm -f /data/.fnos-restore.pending /data/.fnos-restore.failed
+ else
+ printf 'restore failed: %s\n' "${ARCHIVE}" > /data/.fnos-restore.failed
+ rm -f /data/.fnos-restore.pending
+ fi
+ fi
+elif [ -f /data/fnos-bootstrap.mjs ]; then
+ node /data/fnos-bootstrap.mjs || true
+fi
+exec /docker-entrypoint.sh
diff --git a/packaging/fnos/app/ui/config b/packaging/fnos/app/ui/config
new file mode 100644
index 0000000..8f1ee14
--- /dev/null
+++ b/packaging/fnos/app/ui/config
@@ -0,0 +1,13 @@
+{
+ ".url": {
+ "pichost.main": {
+ "title": "PicHost",
+ "icon": "images/icon_{0}.png",
+ "type": "url",
+ "protocol": "http",
+ "port": "${wizard_port}",
+ "url": "/",
+ "allUsers": true
+ }
+ }
+}
diff --git a/packaging/fnos/cmd/config_callback b/packaging/fnos/cmd/config_callback
new file mode 100644
index 0000000..06bd986
--- /dev/null
+++ b/packaging/fnos/cmd/config_callback
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 0
diff --git a/packaging/fnos/cmd/config_init b/packaging/fnos/cmd/config_init
new file mode 100644
index 0000000..06bd986
--- /dev/null
+++ b/packaging/fnos/cmd/config_init
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 0
diff --git a/packaging/fnos/cmd/fnos-bootstrap.mjs b/packaging/fnos/cmd/fnos-bootstrap.mjs
new file mode 100644
index 0000000..cf834a0
--- /dev/null
+++ b/packaging/fnos/cmd/fnos-bootstrap.mjs
@@ -0,0 +1,148 @@
+import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'
+import { join } from 'node:path'
+import { randomBytes, scryptSync } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+
+const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
+const MIN_PASSWORD_LENGTH = 8
+const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
+
+const dataDir = process.env.DATA_DIR || '/data'
+const configPath = join(dataDir, '.fnos-setup.json')
+const donePath = join(dataDir, '.fnos-setup.done')
+
+function log(message) {
+ console.log(`[PicHost fnos-bootstrap] ${message}`)
+}
+
+function fail(message) {
+ console.error(`[PicHost fnos-bootstrap] ${message}`)
+ process.exit(1)
+}
+
+function hashPassword(password) {
+ const salt = randomBytes(16).toString('hex')
+ const derived = scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 })
+ return `scrypt$16384$8$1$${salt}$${derived.toString('hex')}`
+}
+
+// 与 server/utils/db.ts 初始建表一致;完整迁移由应用启动后的 getDb() 负责。
+function initDb(database) {
+ database.exec(`
+ PRAGMA journal_mode = WAL;
+ CREATE TABLE IF NOT EXISTS activity_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ action TEXT NOT NULL,
+ key TEXT NOT NULL,
+ original_name TEXT NOT NULL,
+ size INTEGER NOT NULL DEFAULT 0,
+ content_type TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
+ ON activity_logs(created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
+ ON activity_logs(action, created_at DESC);
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
+ created_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ token_hash TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+ `)
+
+ const columns = database.prepare('PRAGMA table_info(users)').all()
+ if (!columns.some(column => column.name === 'api_token')) {
+ database.exec('ALTER TABLE users ADD COLUMN api_token TEXT')
+ database.exec(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token ON users(api_token) WHERE api_token IS NOT NULL'
+ )
+ }
+ if (!columns.some(column => column.name === 'auto_delete_days')) {
+ database.exec('ALTER TABLE users ADD COLUMN auto_delete_days INTEGER NOT NULL DEFAULT 0')
+ }
+ const userColumns = database.prepare('PRAGMA table_info(users)').all()
+ if (!userColumns.some(column => column.name === 'auto_delete_enabled_at')) {
+ database.exec('ALTER TABLE users ADD COLUMN auto_delete_enabled_at TEXT')
+ }
+}
+
+function countUsers(database) {
+ const row = database.prepare('SELECT COUNT(*) AS count FROM users').get()
+ return Number(row?.count ?? 0)
+}
+
+function setSetting(database, key, value) {
+ const updatedAt = new Date().toISOString()
+ database.prepare(`
+ INSERT INTO settings (key, value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = excluded.updated_at
+ `).run(key, value, updatedAt)
+}
+
+mkdirSync(dataDir, { recursive: true })
+const dbPath = join(dataDir, 'pichost.db')
+const db = new DatabaseSync(dbPath)
+initDb(db)
+
+if (countUsers(db) > 0) {
+ if (existsSync(configPath)) {
+ renameSync(configPath, donePath)
+ }
+ log('already initialized')
+ process.exit(0)
+}
+
+if (!existsSync(configPath)) {
+ log('no setup file, waiting for install wizard')
+ process.exit(0)
+}
+
+let setup
+try {
+ setup = JSON.parse(readFileSync(configPath, 'utf8'))
+} catch {
+ fail('invalid .fnos-setup.json')
+}
+
+const username = String(setup.username ?? '').trim()
+const password = String(setup.password ?? '')
+const allowRegistration = Boolean(setup.allowRegistration)
+
+if (!USERNAME_PATTERN.test(username)) {
+ fail('invalid admin username in setup file')
+}
+if (password.length < MIN_PASSWORD_LENGTH) {
+ fail('invalid admin password in setup file')
+}
+
+const createdAt = new Date().toISOString()
+db.prepare(`
+ INSERT INTO users (username, password_hash, role, created_at)
+ VALUES (?, ?, 'admin', ?)
+`).run(username, hashPassword(password), createdAt)
+
+setSetting(db, SETTINGS_ALLOW_REGISTRATION, allowRegistration ? 'true' : 'false')
+db.close()
+
+renameSync(configPath, donePath)
+log(`admin "${username}" created`)
diff --git a/packaging/fnos/cmd/install_callback b/packaging/fnos/cmd/install_callback
new file mode 100644
index 0000000..dc063ac
--- /dev/null
+++ b/packaging/fnos/cmd/install_callback
@@ -0,0 +1,96 @@
+#!/bin/bash
+# 飞牛在此脚本阶段显示约 80%。只写文件并立刻返回,不要等待容器。
+DATA_DIR="/${TRIM_APPDEST_VOL}/@appshare/pichost/data"
+SETUP_FILE="${DATA_DIR}/.fnos-setup.json"
+RESTORE_PENDING="${DATA_DIR}/.fnos-restore.pending"
+BOOTSTRAP_SRC="$(cd "$(dirname "$0")" && pwd)/fnos-bootstrap.mjs"
+
+SETUP_MODE="${wizard_setup_mode:-init}"
+USERNAME="${wizard_admin_username:-admin}"
+PASSWORD="${wizard_admin_password:-}"
+ALLOW_REGISTRATION="${wizard_allow_registration:-false}"
+BACKUP_PATH="${wizard_backup_path:-}"
+PORT="${wizard_port:-6892}"
+
+log_fail() {
+ echo "$1" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
+}
+
+case "${PORT}" in
+ ''|*[!0-9]*)
+ log_fail "服务端口必须是数字。"
+ exit 1
+ ;;
+esac
+if [ "${PORT}" -lt 1024 ] || [ "${PORT}" -gt 65535 ]; then
+ log_fail "服务端口需在 1024–65535 之间。"
+ exit 1
+fi
+
+mkdir -p "${DATA_DIR}"
+
+rm -f "${SETUP_FILE}" "${RESTORE_PENDING}" "${DATA_DIR}/.fnos-restore.failed" 2>/dev/null || true
+
+case "${SETUP_MODE}" in
+ restore)
+ BACKUP_PATH="$(printf '%s' "${BACKUP_PATH}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ if [ -z "${BACKUP_PATH}" ]; then
+ log_fail "请填写备份文件的 NAS 绝对路径。"
+ exit 1
+ fi
+ if [ ! -f "${BACKUP_PATH}" ]; then
+ log_fail "找不到备份文件:${BACKUP_PATH}"
+ exit 1
+ fi
+ case "${BACKUP_PATH}" in
+ *.phost.tar.gz|*.tar.gz) ;;
+ *)
+ log_fail "备份文件须为 .phost.tar.gz 或 .tar.gz 格式。"
+ exit 1
+ ;;
+ esac
+
+ mkdir -p "${DATA_DIR}/backups"
+ RESTORE_ARCHIVE="${DATA_DIR}/backups/fnos-setup-restore.phost.tar.gz"
+ cp "${BACKUP_PATH}" "${RESTORE_ARCHIVE}"
+ printf '/data/backups/fnos-setup-restore.phost.tar.gz\n' > "${RESTORE_PENDING}"
+ rm -f "${DATA_DIR}/fnos-bootstrap.mjs" 2>/dev/null || true
+ ;;
+ init|*)
+ if [ -z "${PASSWORD}" ]; then
+ log_fail "请设置管理员密码。"
+ exit 1
+ fi
+ case "${USERNAME}" in
+ '')
+ log_fail "请设置管理员用户名。"
+ exit 1
+ ;;
+ esac
+
+ if command -v python3 >/dev/null 2>&1; then
+ USERNAME="${USERNAME}" PASSWORD="${PASSWORD}" ALLOW_REGISTRATION="${ALLOW_REGISTRATION}" python3 - <<'PY' > "${SETUP_FILE}"
+import json, os
+print(json.dumps({
+ "username": os.environ["USERNAME"],
+ "password": os.environ["PASSWORD"],
+ "allowRegistration": os.environ.get("ALLOW_REGISTRATION", "false").lower() == "true",
+}))
+PY
+ else
+ case "${ALLOW_REGISTRATION}" in
+ true|TRUE|1|yes|on) ALLOW_JSON=true ;;
+ *) ALLOW_JSON=false ;;
+ esac
+ printf '{"username":"%s","password":"%s","allowRegistration":%s}\n' \
+ "${USERNAME}" "${PASSWORD}" "${ALLOW_JSON}" > "${SETUP_FILE}"
+ fi
+
+ if [ -f "${BOOTSTRAP_SRC}" ]; then
+ cp "${BOOTSTRAP_SRC}" "${DATA_DIR}/fnos-bootstrap.mjs"
+ fi
+ ;;
+esac
+
+docker restart -t 2 pichost >/dev/null 2>&1 || true
+exit 0
diff --git a/packaging/fnos/cmd/install_init b/packaging/fnos/cmd/install_init
new file mode 100644
index 0000000..bb1d275
--- /dev/null
+++ b/packaging/fnos/cmd/install_init
@@ -0,0 +1,4 @@
+#!/bin/bash
+DATA_DIR="/${TRIM_APPDEST_VOL}/@appshare/pichost/data"
+mkdir -p "${DATA_DIR}" 2>/dev/null || true
+exit 0
diff --git a/packaging/fnos/cmd/main b/packaging/fnos/cmd/main
new file mode 100644
index 0000000..6011d16
--- /dev/null
+++ b/packaging/fnos/cmd/main
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+CONTAINER_NAME="pichost"
+
+is_running() {
+ docker inspect "$CONTAINER_NAME" 2>/dev/null | grep -q '"Status": "running"'
+}
+
+case "$1" in
+ start)
+ exit 0
+ ;;
+ stop)
+ exit 0
+ ;;
+ status)
+ if is_running; then
+ exit 0
+ fi
+ exit 3
+ ;;
+ *)
+ echo "Unknown command: $1" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
+ exit 1
+ ;;
+esac
diff --git a/packaging/fnos/cmd/uninstall_callback b/packaging/fnos/cmd/uninstall_callback
new file mode 100644
index 0000000..94730af
--- /dev/null
+++ b/packaging/fnos/cmd/uninstall_callback
@@ -0,0 +1,17 @@
+#!/bin/bash
+ACTION="${wizard_data_action:-keep}"
+
+if [ "$ACTION" = "delete" ]; then
+ DATA_DIR="/${TRIM_APPDEST_VOL}/@appshare/pichost/data"
+ if [ -d "${DATA_DIR}" ]; then
+ rm -rf "${DATA_DIR:?}"/*
+ fi
+ if [ -n "${TRIM_PKGVAR:-}" ] && [ -d "$TRIM_PKGVAR" ]; then
+ rm -rf "${TRIM_PKGVAR:?}"/*
+ fi
+ if [ -n "${TRIM_PKGETC:-}" ] && [ -d "$TRIM_PKGETC" ]; then
+ rm -rf "${TRIM_PKGETC:?}"/*
+ fi
+fi
+
+exit 0
diff --git a/packaging/fnos/cmd/uninstall_init b/packaging/fnos/cmd/uninstall_init
new file mode 100644
index 0000000..06bd986
--- /dev/null
+++ b/packaging/fnos/cmd/uninstall_init
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 0
diff --git a/packaging/fnos/cmd/upgrade_callback b/packaging/fnos/cmd/upgrade_callback
new file mode 100644
index 0000000..1a877c6
--- /dev/null
+++ b/packaging/fnos/cmd/upgrade_callback
@@ -0,0 +1,45 @@
+#!/bin/bash
+# 升级 fpk 后:使用飞牛中已导入的本地镜像重建容器(pichost/data 数据目录不变)。
+COMPOSE_DIR="${TRIM_APPDEST}/docker"
+COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yaml"
+CONTAINER_NAME="pichost"
+
+log_fail() {
+ echo "$1" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}"
+}
+
+if [ ! -f "${COMPOSE_FILE}" ]; then
+ log_fail "找不到 docker-compose.yaml"
+ exit 1
+fi
+
+IMAGE="$(sed -n 's/^[[:space:]]*image:[[:space:]]*//p' "${COMPOSE_FILE}" | head -1 | tr -d ' \r')"
+if [ -z "${IMAGE}" ]; then
+ log_fail "无法从 compose 解析镜像名"
+ exit 1
+fi
+
+# 升级向导不含 port 字段,尽量沿用旧容器端口
+if [ -z "${wizard_port:-}" ] && docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
+ wizard_port="$(docker inspect "${CONTAINER_NAME}" --format '{{with index .HostConfig.PortBindings "6892/tcp"}}{{with index . 0}}{{.HostPort}}{{end}}{{end}}' 2>/dev/null || true)"
+fi
+export wizard_port="${wizard_port:-6892}"
+
+if ! docker compose version >/dev/null 2>&1; then
+ if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
+ docker restart -t 10 "${CONTAINER_NAME}" >/dev/null 2>&1 || {
+ log_fail "docker compose 不可用,且容器重启失败"
+ exit 1
+ }
+ exit 0
+ fi
+ log_fail "docker compose 不可用,且未找到 ${CONTAINER_NAME} 容器"
+ exit 1
+fi
+
+if ! (cd "${COMPOSE_DIR}" && docker compose up -d --force-recreate); then
+ log_fail "重建容器失败,请检查 Docker 日志(镜像:${IMAGE})"
+ exit 1
+fi
+
+exit 0
diff --git a/packaging/fnos/cmd/upgrade_init b/packaging/fnos/cmd/upgrade_init
new file mode 100644
index 0000000..06bd986
--- /dev/null
+++ b/packaging/fnos/cmd/upgrade_init
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 0
diff --git a/packaging/fnos/config/privilege b/packaging/fnos/config/privilege
new file mode 100644
index 0000000..88c190d
--- /dev/null
+++ b/packaging/fnos/config/privilege
@@ -0,0 +1,7 @@
+{
+ "defaults": {
+ "run-as": "package"
+ },
+ "username": "docker-pichost",
+ "groupname": "docker-pichost"
+}
diff --git a/packaging/fnos/config/resource b/packaging/fnos/config/resource
new file mode 100644
index 0000000..0b2f81d
--- /dev/null
+++ b/packaging/fnos/config/resource
@@ -0,0 +1,22 @@
+{
+ "docker-project": {
+ "projects": [
+ {
+ "name": "pichost",
+ "path": "docker"
+ }
+ ]
+ },
+ "data-share": {
+ "shares": [
+ {
+ "name": "pichost/data",
+ "permission": {
+ "rw": [
+ "docker-pichost"
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/packaging/fnos/manifest b/packaging/fnos/manifest
new file mode 100644
index 0000000..5542ee7
--- /dev/null
+++ b/packaging/fnos/manifest
@@ -0,0 +1,18 @@
+appname = pichost
+version = 1.5.1
+display_name = PicHost
+desc = 自托管图床:多用户与用户管理、彩色标签、密码重置、本地/对象存储、整站备份与跨后端同步、双域名分离、WebP 压缩、API/Twikoo,安装向导创建管理员
+source = thirdparty
+platform = x86
+maintainer = Muxui
+maintainer_url = https://muxui.com/posts/pichost-cloudflare-r2-image-host
+distributor = Muxui
+distributor_url = https://github.com/O96u/PicHost
+os_min_version = 0.9.27
+service_port = 6892
+checkport = true
+ctl_stop = true
+desktop_uidir = ui
+desktop_applaunchname = pichost.main
+install_type =
+checksum = 57f241c05731c1c9f84467f68d25f0c1
diff --git a/packaging/fnos/wizard/install b/packaging/fnos/wizard/install
new file mode 100644
index 0000000..cce6106
--- /dev/null
+++ b/packaging/fnos/wizard/install
@@ -0,0 +1,123 @@
+[
+ {
+ "stepTitle": "安装方式",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "安装前请先在飞牛 Docker 中导入 yeomaiyelove/pichost:1.5.1 镜像。PicHost 使用本地镜像,不会自动联网拉取。与 Web 首次设置一致,可选 创建管理员 或 从备份恢复(.phost.tar.gz)。图片与数据库保存在存储空间 pichost/data 共享目录。"
+ },
+ {
+ "type": "radio",
+ "field": "wizard_setup_mode",
+ "label": "安装方式",
+ "initValue": "init",
+ "options": [
+ {
+ "label": "创建管理员(新装)",
+ "value": "init"
+ },
+ {
+ "label": "从备份恢复(换机 / 迁移)",
+ "value": "restore"
+ }
+ ],
+ "rules": [
+ {
+ "required": true,
+ "message": "请选择安装方式"
+ }
+ ]
+ },
+ {
+ "type": "text",
+ "field": "wizard_port",
+ "label": "服务端口",
+ "initValue": "6892",
+ "helpText": "PicHost 在 NAS 上对外访问的端口,默认 6892。请避免与系统或其他应用冲突。",
+ "rules": [
+ {
+ "required": true,
+ "message": "请输入端口号"
+ },
+ {
+ "pattern": "^[0-9]+$",
+ "message": "端口号必须是数字"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "stepTitle": "创建管理员",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "仅在选择 创建管理员 时需要填写。安装完成后点击桌面图标即可登录,无需再访问 /setup。"
+ },
+ {
+ "type": "text",
+ "field": "wizard_admin_username",
+ "label": "管理员用户名",
+ "initValue": "admin",
+ "rules": [
+ {
+ "pattern": "^[a-zA-Z0-9_]{3,32}$",
+ "message": "用户名为 3–32 位字母、数字或下划线"
+ }
+ ]
+ },
+ {
+ "type": "password",
+ "field": "wizard_admin_password",
+ "label": "管理员密码",
+ "initValue": "",
+ "rules": [
+ {
+ "min": 8,
+ "message": "密码至少 8 位"
+ }
+ ]
+ },
+ {
+ "type": "switch",
+ "field": "wizard_allow_registration",
+ "label": "允许用户注册",
+ "initValue": "false",
+ "helpText": "开启后,访客可在登录页自行注册普通用户账号;关闭则仅管理员可创建用户。"
+ }
+ ]
+ },
+ {
+ "stepTitle": "从备份恢复",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "仅在选择 从备份恢复 时需要填写。请先把 .phost.tar.gz 备份包放到 NAS 上可访问的路径(如共享文件夹),再填写绝对路径。恢复完成后请使用备份包内原有的管理员账号登录;对象存储密钥需在恢复后重新填写。"
+ },
+ {
+ "type": "text",
+ "field": "wizard_backup_path",
+ "label": "备份文件路径",
+ "initValue": "",
+ "helpText": "示例:/vol1/1000/backups/pichost.phost.tar.gz(路径因 NAS 存储池而异,请按实际填写)"
+ }
+ ]
+ },
+ {
+ "stepTitle": "访问与反向代理(可选)",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "安装后可通过 http://<NAS IP>:<服务端口> 或桌面图标进入后台。若使用公网域名,建议在飞牛 Lucky 中配置反向代理:前端填域名,后端填 http://<NAS 内网 IP>:<服务端口>(如 http://192.168.8.3:6892)。"
+ },
+ {
+ "type": "tips",
+ "helpText": "若希望管理后台与图片直链使用不同域名,安装完成后在 设置 → 域名与链接 中启用「后台与图片域名分离」,并在 Lucky 为两个域名各建一条反向代理,后端地址相同即可。详见 双域名分离文档。"
+ },
+ {
+ "type": "tips",
+ "helpText": "日常整站备份/跨后端同步在 存储 → 备份与迁移;也可通过 docker exec pichost backup-export 等 CLI 操作。详见 存储文档。"
+ }
+ ]
+ }
+]
diff --git a/packaging/fnos/wizard/uninstall b/packaging/fnos/wizard/uninstall
new file mode 100644
index 0000000..79768c7
--- /dev/null
+++ b/packaging/fnos/wizard/uninstall
@@ -0,0 +1,33 @@
+[
+ {
+ "stepTitle": "卸载 PicHost",
+ "items": [
+ {
+ "type": "tips",
+ "helpText": "卸载将停止 PicHost 服务。可选择是否删除图片与数据库。"
+ },
+ {
+ "type": "radio",
+ "field": "wizard_data_action",
+ "label": "数据",
+ "initValue": "keep",
+ "options": [
+ {
+ "label": "保留数据(可重装后继续使用)",
+ "value": "keep"
+ },
+ {
+ "label": "删除所有数据",
+ "value": "delete"
+ }
+ ],
+ "rules": [
+ {
+ "required": true,
+ "message": "请选择是否删除数据"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/scripts/build-docker.mjs b/scripts/build-docker.mjs
new file mode 100644
index 0000000..5501520
--- /dev/null
+++ b/scripts/build-docker.mjs
@@ -0,0 +1,42 @@
+import { createHash } from 'node:crypto'
+import { createReadStream } from 'node:fs'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { spawnSync } from 'node:child_process'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
+const repository = process.env.PICHOST_IMAGE_REPOSITORY || 'yeomaiyelove/pichost'
+const image = `${repository}:${pkg.version}`
+const platform = process.env.DOCKER_PLATFORM || 'linux/amd64'
+const outputDir = path.join(root, 'dist-docker')
+const outputName = `${repository.replaceAll('/', '-')}-${pkg.version}-amd64.tar`
+const outputFile = path.join(outputDir, outputName)
+
+function run(command, args, cwd = root) {
+ const result = spawnSync(command, args, {
+ cwd,
+ stdio: 'inherit',
+ windowsHide: true
+ })
+ if (result.error) throw result.error
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(' ')} 失败,退出代码 ${result.status}`)
+ }
+}
+
+async function sha256(file) {
+ const hash = createHash('sha256')
+ for await (const chunk of createReadStream(file)) hash.update(chunk)
+ return hash.digest('hex')
+}
+
+run('docker', ['build', '--platform', platform, '-t', image, '.'])
+await mkdir(outputDir, { recursive: true })
+run('docker', ['save', '-o', outputFile, image])
+
+const digest = await sha256(outputFile)
+await writeFile(`${outputFile}.sha256`, `${digest} ${outputName}\n`)
+console.log(`Docker 镜像包:${outputFile}`)
+console.log(`SHA256:${digest}`)
diff --git a/scripts/build-fpk.mjs b/scripts/build-fpk.mjs
new file mode 100644
index 0000000..71829dc
--- /dev/null
+++ b/scripts/build-fpk.mjs
@@ -0,0 +1,130 @@
+import { createHash } from 'node:crypto'
+import {
+ access,
+ chmod,
+ cp,
+ mkdir,
+ readFile,
+ readdir,
+ rename,
+ rm,
+ writeFile
+} from 'node:fs/promises'
+import { spawnSync } from 'node:child_process'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import sharp from 'sharp'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
+const outputRoot = path.join(root, 'dist-fpk')
+const stage = path.join(outputRoot, 'staging')
+const source = path.join(root, 'packaging', 'fnos')
+const cache = path.join(outputRoot, 'cache')
+const image = process.env.PICHOST_IMAGE
+ || `yeomaiyelove/pichost:${pkg.version}`
+const platform = process.env.FPK_PLATFORM || 'x86'
+const fnpackVersion = '1.2.3'
+
+function run(command, args, cwd = root) {
+ const result = spawnSync(command, args, {
+ cwd,
+ stdio: 'inherit',
+ windowsHide: true
+ })
+ if (result.error) throw result.error
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(' ')} 失败,退出代码 ${result.status}`)
+ }
+}
+
+async function exists(file) {
+ try {
+ await access(file)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function getFnpack() {
+ const systems = {
+ win32: 'windows',
+ linux: 'linux',
+ darwin: 'darwin'
+ }
+ const architectures = {
+ x64: 'amd64',
+ arm64: 'arm64'
+ }
+ const system = systems[process.platform]
+ const architecture = architectures[process.arch]
+ if (!system || !architecture) {
+ throw new Error(`不支持自动下载 fnpack:${process.platform}/${process.arch}`)
+ }
+
+ const executable = path.join(
+ cache,
+ `fnpack-${fnpackVersion}-${system}-${architecture}${process.platform === 'win32' ? '.exe' : ''}`
+ )
+ if (!await exists(executable)) {
+ await mkdir(cache, { recursive: true })
+ const url = `https://static2.fnnas.com/fnpack/fnpack-${fnpackVersion}-${system}-${architecture}`
+ console.log(`下载 fnpack:${url}`)
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`fnpack 下载失败:${response.status}`)
+ await writeFile(executable, Buffer.from(await response.arrayBuffer()))
+ if (process.platform !== 'win32') await chmod(executable, 0o755)
+ }
+ return executable
+}
+
+async function replace(file, replacer) {
+ const original = await readFile(file, 'utf8')
+ await writeFile(file, replacer(original))
+}
+
+await rm(stage, { recursive: true, force: true })
+await cp(source, stage, { recursive: true })
+
+const cmdDir = path.join(stage, 'cmd')
+for (const file of await readdir(cmdDir)) {
+ await chmod(path.join(cmdDir, file), 0o755)
+}
+await chmod(path.join(stage, 'app', 'docker', 'start.sh'), 0o755)
+
+await replace(path.join(stage, 'manifest'), text => text
+ .replace(/^version\s*=.*$/m, `version = ${pkg.version}`)
+ .replace(/^platform\s*=.*$/m, `platform = ${platform}`))
+await replace(path.join(stage, 'app', 'docker', 'docker-compose.yaml'), text => text
+ .replace(/image:\s*\S+/, `image: ${image}`))
+await replace(path.join(stage, 'wizard', 'install'), text => text
+ .replaceAll(/yeomaiyelove\/pichost:[0-9]+\.[0-9]+\.[0-9]+/g, image))
+
+const logo = path.join(root, 'app', 'assets', 'image', 'logo-light.png')
+const resize = size => sharp(logo)
+ .resize(size, size, {
+ fit: 'contain',
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
+ })
+ .png()
+
+await mkdir(path.join(stage, 'app', 'ui', 'images'), { recursive: true })
+await resize(64).toFile(path.join(stage, 'ICON.PNG'))
+await resize(256).toFile(path.join(stage, 'ICON_256.PNG'))
+await resize(64).toFile(path.join(stage, 'app', 'ui', 'images', 'icon_64.png'))
+await resize(256).toFile(path.join(stage, 'app', 'ui', 'images', 'icon_256.png'))
+
+const fnpack = await getFnpack()
+run(fnpack, ['build', '--directory', stage], outputRoot)
+
+const output = path.join(outputRoot, `pichost-${pkg.version}.fpk`)
+await rm(output, { force: true })
+await rename(path.join(outputRoot, 'pichost.fpk'), output)
+
+const hash = createHash('sha256')
+hash.update(await readFile(output))
+const digest = hash.digest('hex')
+await writeFile(`${output}.sha256`, `${digest} ${path.basename(output)}\n`)
+console.log(`FPK:${output}`)
+console.log(`SHA256:${digest}`)
diff --git a/server/api/files/index.get.ts b/server/api/files/index.get.ts
index c0431fa..42efad6 100644
--- a/server/api/files/index.get.ts
+++ b/server/api/files/index.get.ts
@@ -120,6 +120,9 @@ export async function handleFileList(
...(stored.transcodeStartedAt
? { transcodeStartedAt: stored.transcodeStartedAt }
: {}),
+ ...(typeof stored.transcodeDurationMs === 'number'
+ ? { transcodeDurationMs: stored.transcodeDurationMs }
+ : {}),
...(stored.sourceDeleted ? { sourceDeleted: true } : {}),
...(stored.thumbKey
? { thumbUrl: resolveImageUrl(event, stored.thumbKey, stored.backendId) }
diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts
index cc90018..454a0f1 100644
--- a/server/utils/image-index.ts
+++ b/server/utils/image-index.ts
@@ -48,6 +48,7 @@ export interface ImageIndexRow {
transcode_preset: string | null
transcode_error: string | null
transcode_started_at: string | null
+ transcode_duration_ms: number | null
}
function keyToFilePath(key: string): string {
@@ -82,6 +83,7 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
transcodePreset: row.transcode_preset ?? null,
transcodeError: row.transcode_error ?? null,
transcodeStartedAt: row.transcode_started_at ?? null,
+ transcodeDurationMs: row.transcode_duration_ms ?? null,
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
}
}
@@ -236,7 +238,7 @@ export async function purgeOrphanImageIndexRows(): Promise {
export function getImageIndexRow(key: string): ImageIndexRow | null {
ensureStorageSchema()
const row = getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE key = ?
`).get(key) as ImageIndexRow | undefined
@@ -429,7 +431,7 @@ export function getImageTotals(): { count: number, bytes: number } {
export function listAllImageIndexRows(): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
ORDER BY key ASC
`).all() as unknown as ImageIndexRow[]
@@ -441,7 +443,7 @@ export function listImageIndexRowsAfter(
): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE key > ?
ORDER BY key ASC
@@ -452,7 +454,7 @@ export function listImageIndexRowsAfter(
export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE user_id = ?
ORDER BY key ASC
@@ -462,7 +464,7 @@ export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] {
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
ensureStorageSchema()
return getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE backend_id = ?
ORDER BY key ASC
@@ -488,6 +490,7 @@ export function updateImageTranscode(
preset?: string | null
error?: string | null
startedAt?: string | null
+ durationMs?: number | null
}
): void {
ensureStorageSchema()
@@ -501,6 +504,7 @@ export function updateImageTranscode(
transcode_preset = COALESCE(?, transcode_preset),
transcode_error = ?,
transcode_started_at = COALESCE(?, transcode_started_at)
+ , transcode_duration_ms = COALESCE(?, transcode_duration_ms)
WHERE key = ?
`).run(
input.status,
@@ -511,6 +515,7 @@ export function updateImageTranscode(
input.preset ?? null,
input.error ?? null,
input.startedAt ?? null,
+ input.durationMs ?? null,
key
)
}
@@ -708,7 +713,7 @@ export async function listImages(options: {
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
@@ -754,7 +759,7 @@ export async function searchImages(options: {
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
@@ -888,7 +893,7 @@ export async function getUserScopedStorageStats(
export async function listImageKeysForAutoDelete(): Promise {
ensureStorageSchema()
const rows = getDb().prepare(`
- SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_error
+ SELECT key, backend_id, user_id, folder, media_type, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset, transcode_started_at, transcode_duration_ms, transcode_error
FROM images
WHERE media_type = 'image'
ORDER BY uploaded_at ASC
diff --git a/server/utils/media-assets.test.ts b/server/utils/media-assets.test.ts
index ccdf52a..19f1350 100644
--- a/server/utils/media-assets.test.ts
+++ b/server/utils/media-assets.test.ts
@@ -20,7 +20,8 @@ const row: ImageIndexRow = {
source_deleted: 0,
transcode_preset: 'veryfast',
transcode_error: null,
- transcode_started_at: null
+ transcode_started_at: null,
+ transcode_duration_ms: null
}
async function createBackend() {
diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts
index d8a03ce..9c313d7 100644
--- a/server/utils/storage-backends.ts
+++ b/server/utils/storage-backends.ts
@@ -96,6 +96,7 @@ const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted'
const STORAGE_SCHEMA_TRANSCODE_PRESET_KEY = 'storage_schema_transcode_preset'
const STORAGE_SCHEMA_TRANSCODE_ERROR_KEY = 'storage_schema_transcode_error'
const STORAGE_SCHEMA_TRANSCODE_STARTED_KEY = 'storage_schema_transcode_started'
+const STORAGE_SCHEMA_TRANSCODE_DURATION_KEY = 'storage_schema_transcode_duration'
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
function migrateImagesMediaType(database: DatabaseSync): void {
@@ -276,6 +277,27 @@ function migrateImagesTranscodeStarted(database: DatabaseSync): void {
`).run(STORAGE_SCHEMA_TRANSCODE_STARTED_KEY, new Date().toISOString())
}
+/** images 表新增「转码总耗时」列 */
+function migrateImagesTranscodeDuration(database: DatabaseSync): void {
+ const migrated = database.prepare(`
+ SELECT value FROM settings WHERE key = ?
+ `).get(STORAGE_SCHEMA_TRANSCODE_DURATION_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_duration_ms')) {
+ database.exec('ALTER TABLE images ADD COLUMN transcode_duration_ms INTEGER')
+ }
+
+ database.prepare(`
+ INSERT OR REPLACE INTO settings (key, value, updated_at)
+ VALUES (?, '1', ?)
+ `).run(STORAGE_SCHEMA_TRANSCODE_DURATION_KEY, new Date().toISOString())
+}
+
function migrateStorageTables(database: DatabaseSync): void {
database.exec(`
CREATE TABLE IF NOT EXISTS storage_backends (
@@ -309,6 +331,7 @@ function migrateStorageTables(database: DatabaseSync): void {
transcode_preset TEXT,
transcode_error TEXT,
transcode_started_at TEXT,
+ transcode_duration_ms INTEGER,
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
);
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
@@ -332,6 +355,7 @@ function migrateStorageTables(database: DatabaseSync): void {
migrateImagesTranscodePreset(database)
migrateImagesTranscodeError(database)
migrateImagesTranscodeStarted(database)
+ migrateImagesTranscodeDuration(database)
database.exec(`
CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type);
`)
diff --git a/server/utils/storage/types.ts b/server/utils/storage/types.ts
index 7a95f40..cb35eff 100644
--- a/server/utils/storage/types.ts
+++ b/server/utils/storage/types.ts
@@ -27,6 +27,8 @@ export interface StoredImage extends StoredImageMeta {
transcodePreset?: string | null
/** 转码开始时间(ISO) */
transcodeStartedAt?: string | null
+ /** 转码总耗时(毫秒),结束后固定 */
+ transcodeDurationMs?: number | null
/** 转码失败原因 */
transcodeError?: string | null
/** 视频封面缩略图 key */
diff --git a/server/utils/transcode.ts b/server/utils/transcode.ts
index c89a48e..67feba6 100644
--- a/server/utils/transcode.ts
+++ b/server/utils/transcode.ts
@@ -705,11 +705,14 @@ let activeCount = 0
async function processOne(job: QueuedTranscode): Promise {
const { videoKey } = job
+ const startedAtIso = new Date().toISOString()
updateImageTranscode(videoKey, {
status: 'running',
- startedAt: new Date().toISOString()
+ startedAt: startedAtIso
})
+ const elapsedMs = () => Math.max(0, Date.now() - new Date(startedAtIso).getTime())
+
try {
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
@@ -722,13 +725,19 @@ async function processOne(job: QueuedTranscode): Promise {
hlsKey: playlistKey,
progress: 100,
sourceDeleted: true,
- size: totalBytes
+ size: totalBytes,
+ // 结束后清空计时起点,前端不再显示耗时
+ startedAt: '',
+ durationMs: elapsedMs()
})
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
} catch (error) {
updateImageTranscode(videoKey, {
status: 'failed',
- error: summarizeTranscodeFailure(error)
+ error: summarizeTranscodeFailure(error),
+ // 失败同样停止计时
+ startedAt: '',
+ durationMs: elapsedMs()
})
logException('transcode failed', error, { key: videoKey })
}