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 }) }