打包构建
This commit is contained in:
1 parent
a6e45e0cb1
commit
ee8772c1b7
36 files changed
+940
-21
No files matched your search
@@ -5,6 +5,11 @@ node_modules
|
|||||||
.github
|
.github
|
||||||
data
|
data
|
||||||
dist
|
dist
|
||||||
|
dist-fpk
|
||||||
|
dist-windows
|
||||||
|
dist-docker
|
||||||
|
packaging
|
||||||
|
scripts
|
||||||
.env
|
.env
|
||||||
*.md
|
*.md
|
||||||
._*
|
._*
|
||||||
|
|||||||
+2
-2
@@ -38,9 +38,9 @@ data
|
|||||||
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
|
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
|
||||||
git-hooks/
|
git-hooks/
|
||||||
|
|
||||||
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
|
# 打包输出目录
|
||||||
scripts/
|
|
||||||
dist-fpk/
|
dist-fpk/
|
||||||
|
dist-docker/
|
||||||
|
|
||||||
# VitePress docs site
|
# VitePress docs site
|
||||||
docs-site/.vitepress/cache
|
docs-site/.vitepress/cache
|
||||||
|
|||||||
@@ -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(() => {
|
const elapsedLabel = computed(() => {
|
||||||
if (!running.value || !props.item.transcodeStartedAt) return null
|
if (!running.value || !props.item.transcodeStartedAt) return null
|
||||||
const started = new Date(props.item.transcodeStartedAt).getTime()
|
const started = new Date(props.item.transcodeStartedAt).getTime()
|
||||||
if (!Number.isFinite(started)) return null
|
if (!Number.isFinite(started)) return null
|
||||||
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000))
|
return formatDuration(Math.max(0, Math.floor((Date.now() - started) / 1000)))
|
||||||
return `${Math.floor(seconds / 60)} min ${seconds % 60} s`
|
})
|
||||||
|
|
||||||
|
/** 转码结束(成功或失败):固定显示总耗时 */
|
||||||
|
const totalLabel = computed(() => {
|
||||||
|
if (running.value || typeof props.item.transcodeDurationMs !== 'number') return null
|
||||||
|
return formatDuration(Math.floor(props.item.transcodeDurationMs / 1000))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -237,7 +254,6 @@ const elapsedLabel = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-else
|
|
||||||
class="flex flex-wrap items-center gap-1.5"
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<UBadge
|
<UBadge
|
||||||
@@ -250,12 +266,20 @@ const elapsedLabel = computed(() => {
|
|||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="elapsedLabel"
|
v-if="elapsedLabel"
|
||||||
color="primary"
|
color="success"
|
||||||
variant="subtle"
|
variant="solid"
|
||||||
size="xs"
|
size="xs"
|
||||||
>
|
>
|
||||||
{{ t('library.transcodeElapsed') }} {{ elapsedLabel }}
|
{{ t('library.transcodeElapsed') }} {{ elapsedLabel }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="totalLabel"
|
||||||
|
color="success"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ t('library.transcodeTotal') }} {{ totalLabel }}
|
||||||
|
</UBadge>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface FileItem {
|
|||||||
transcodePreset?: string
|
transcodePreset?: string
|
||||||
/** 转码开始时间(ISO) */
|
/** 转码开始时间(ISO) */
|
||||||
transcodeStartedAt?: string
|
transcodeStartedAt?: string
|
||||||
|
/** 转码总耗时(毫秒) */
|
||||||
|
transcodeDurationMs?: number
|
||||||
/** 转码失败原因 */
|
/** 转码失败原因 */
|
||||||
transcodeError?: string
|
transcodeError?: string
|
||||||
/** HLS 播放列表地址(转码完成后有值) */
|
/** HLS 播放列表地址(转码完成后有值) */
|
||||||
|
|||||||
@@ -1221,6 +1221,7 @@
|
|||||||
"transcodePending": "Queued",
|
"transcodePending": "Queued",
|
||||||
"transcoding": "Transcoding",
|
"transcoding": "Transcoding",
|
||||||
"transcodeElapsed": "Elapsed",
|
"transcodeElapsed": "Elapsed",
|
||||||
|
"transcodeTotal": "Total",
|
||||||
"transcodeFailed": "Transcode failed",
|
"transcodeFailed": "Transcode failed",
|
||||||
"searchPlaceholder": "Search file name",
|
"searchPlaceholder": "Search file name",
|
||||||
"searchEmpty": "Nothing matched",
|
"searchEmpty": "Nothing matched",
|
||||||
|
|||||||
@@ -1224,6 +1224,7 @@
|
|||||||
"transcodePending": "等待转码中",
|
"transcodePending": "等待转码中",
|
||||||
"transcoding": "转码中",
|
"transcoding": "转码中",
|
||||||
"transcodeElapsed": "已耗时",
|
"transcodeElapsed": "已耗时",
|
||||||
|
"transcodeTotal": "总耗时",
|
||||||
"transcodeFailed": "转码失败",
|
"transcodeFailed": "转码失败",
|
||||||
"searchPlaceholder": "搜索文件名",
|
"searchPlaceholder": "搜索文件名",
|
||||||
"searchEmpty": "没有匹配的内容",
|
"searchEmpty": "没有匹配的内容",
|
||||||
|
|||||||
+5
-1
@@ -46,7 +46,11 @@
|
|||||||
"docs:preview": "npm run docs:prepare && vitepress preview docs-site",
|
"docs:preview": "npm run docs:prepare && vitepress preview docs-site",
|
||||||
"desktop:prepare": "node desktop/prepare-runtime.mjs",
|
"desktop:prepare": "node desktop/prepare-runtime.mjs",
|
||||||
"desktop:dev": "npm run desktop:prepare && electron .",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.876.0",
|
"@aws-sdk/client-s3": "^3.876.0",
|
||||||
|
|||||||
@@ -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:<version>`。
|
||||||
|
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 目录
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"run-as": "package"
|
||||||
|
},
|
||||||
|
"username": "docker-pichost",
|
||||||
|
"groupname": "docker-pichost"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"docker-project": {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "pichost",
|
||||||
|
"path": "docker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data-share": {
|
||||||
|
"shares": [
|
||||||
|
{
|
||||||
|
"name": "pichost/data",
|
||||||
|
"permission": {
|
||||||
|
"rw": [
|
||||||
|
"docker-pichost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
".url": {
|
||||||
|
"pichost.main": {
|
||||||
|
"title": "PicHost",
|
||||||
|
"icon": "images/icon_{0}.png",
|
||||||
|
"type": "url",
|
||||||
|
"protocol": "http",
|
||||||
|
"port": "${wizard_port}",
|
||||||
|
"url": "/",
|
||||||
|
"allUsers": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
@@ -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`)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
DATA_DIR="/${TRIM_APPDEST_VOL}/@appshare/pichost/data"
|
||||||
|
mkdir -p "${DATA_DIR}" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"defaults": {
|
||||||
|
"run-as": "package"
|
||||||
|
},
|
||||||
|
"username": "docker-pichost",
|
||||||
|
"groupname": "docker-pichost"
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"docker-project": {
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "pichost",
|
||||||
|
"path": "docker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data-share": {
|
||||||
|
"shares": [
|
||||||
|
{
|
||||||
|
"name": "pichost/data",
|
||||||
|
"permission": {
|
||||||
|
"rw": [
|
||||||
|
"docker-pichost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"stepTitle": "安装方式",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "tips",
|
||||||
|
"helpText": "安装前请先在飞牛 Docker 中导入 <strong>yeomaiyelove/pichost:1.5.1</strong> 镜像。PicHost 使用本地镜像,不会自动联网拉取。与 Web 首次设置一致,可选 <strong>创建管理员</strong> 或 <strong>从备份恢复</strong>(<code>.phost.tar.gz</code>)。图片与数据库保存在存储空间 <strong>pichost/data</strong> 共享目录。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "仅在选择 <strong>创建管理员</strong> 时需要填写。安装完成后点击桌面图标即可登录,无需再访问 /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": "仅在选择 <strong>从备份恢复</strong> 时需要填写。请先把 <code>.phost.tar.gz</code> 备份包放到 NAS 上可访问的路径(如共享文件夹),再填写<strong>绝对路径</strong>。恢复完成后请使用<strong>备份包内原有的管理员账号</strong>登录;对象存储密钥需在恢复后重新填写。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"field": "wizard_backup_path",
|
||||||
|
"label": "备份文件路径",
|
||||||
|
"initValue": "",
|
||||||
|
"helpText": "示例:<code>/vol1/1000/backups/pichost.phost.tar.gz</code>(路径因 NAS 存储池而异,请按实际填写)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepTitle": "访问与反向代理(可选)",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "tips",
|
||||||
|
"helpText": "安装后可通过 <strong>http://<NAS IP>:<服务端口></strong> 或桌面图标进入后台。若使用公网域名,建议在飞牛 <strong>Lucky</strong> 中配置反向代理:前端填域名,后端填 <strong>http://<NAS 内网 IP>:<服务端口></strong>(如 <code>http://192.168.8.3:6892</code>)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tips",
|
||||||
|
"helpText": "若希望<strong>管理后台与图片直链使用不同域名</strong>,安装完成后在 <strong>设置 → 域名与链接</strong> 中启用「后台与图片域名分离」,并在 Lucky 为两个域名各建一条反向代理,<strong>后端地址相同</strong>即可。详见 <a href=\"https://o96u.github.io/PicHost/guide/domain-separation.html\" target=\"_blank\">双域名分离文档</a>。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tips",
|
||||||
|
"helpText": "日常整站备份/跨后端同步在 <strong>存储 → 备份与迁移</strong>;也可通过 <code>docker exec pichost backup-export</code> 等 CLI 操作。详见 <a href=\"https://o96u.github.io/PicHost/guide/storage.html\" target=\"_blank\">存储文档</a>。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": "请选择是否删除数据"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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}`)
|
||||||
@@ -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}`)
|
||||||
@@ -120,6 +120,9 @@ export async function handleFileList(
|
|||||||
...(stored.transcodeStartedAt
|
...(stored.transcodeStartedAt
|
||||||
? { transcodeStartedAt: stored.transcodeStartedAt }
|
? { transcodeStartedAt: stored.transcodeStartedAt }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof stored.transcodeDurationMs === 'number'
|
||||||
|
? { transcodeDurationMs: stored.transcodeDurationMs }
|
||||||
|
: {}),
|
||||||
...(stored.sourceDeleted ? { sourceDeleted: true } : {}),
|
...(stored.sourceDeleted ? { sourceDeleted: true } : {}),
|
||||||
...(stored.thumbKey
|
...(stored.thumbKey
|
||||||
? { thumbUrl: resolveImageUrl(event, stored.thumbKey, stored.backendId) }
|
? { thumbUrl: resolveImageUrl(event, stored.thumbKey, stored.backendId) }
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface ImageIndexRow {
|
|||||||
transcode_preset: string | null
|
transcode_preset: string | null
|
||||||
transcode_error: string | null
|
transcode_error: string | null
|
||||||
transcode_started_at: string | null
|
transcode_started_at: string | null
|
||||||
|
transcode_duration_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyToFilePath(key: string): string {
|
function keyToFilePath(key: string): string {
|
||||||
@@ -82,6 +83,7 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
|
|||||||
transcodePreset: row.transcode_preset ?? null,
|
transcodePreset: row.transcode_preset ?? null,
|
||||||
transcodeError: row.transcode_error ?? null,
|
transcodeError: row.transcode_error ?? null,
|
||||||
transcodeStartedAt: row.transcode_started_at ?? null,
|
transcodeStartedAt: row.transcode_started_at ?? null,
|
||||||
|
transcodeDurationMs: row.transcode_duration_ms ?? null,
|
||||||
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +238,7 @@ export async function purgeOrphanImageIndexRows(): Promise<number> {
|
|||||||
export function getImageIndexRow(key: string): ImageIndexRow | null {
|
export function getImageIndexRow(key: string): ImageIndexRow | null {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const row = getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
`).get(key) as ImageIndexRow | undefined
|
`).get(key) as ImageIndexRow | undefined
|
||||||
@@ -429,7 +431,7 @@ export function getImageTotals(): { count: number, bytes: number } {
|
|||||||
export function listAllImageIndexRows(): ImageIndexRow[] {
|
export function listAllImageIndexRows(): ImageIndexRow[] {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
return getDb().prepare(`
|
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
|
FROM images
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
`).all() as unknown as ImageIndexRow[]
|
`).all() as unknown as ImageIndexRow[]
|
||||||
@@ -441,7 +443,7 @@ export function listImageIndexRowsAfter(
|
|||||||
): ImageIndexRow[] {
|
): ImageIndexRow[] {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
return getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE key > ?
|
WHERE key > ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -452,7 +454,7 @@ export function listImageIndexRowsAfter(
|
|||||||
export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] {
|
export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
return getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -462,7 +464,7 @@ export function listImageIndexRowsForUser(userId: number): ImageIndexRow[] {
|
|||||||
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
|
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
return getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE backend_id = ?
|
WHERE backend_id = ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -488,6 +490,7 @@ export function updateImageTranscode(
|
|||||||
preset?: string | null
|
preset?: string | null
|
||||||
error?: string | null
|
error?: string | null
|
||||||
startedAt?: string | null
|
startedAt?: string | null
|
||||||
|
durationMs?: number | null
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
@@ -501,6 +504,7 @@ export function updateImageTranscode(
|
|||||||
transcode_preset = COALESCE(?, transcode_preset),
|
transcode_preset = COALESCE(?, transcode_preset),
|
||||||
transcode_error = ?,
|
transcode_error = ?,
|
||||||
transcode_started_at = COALESCE(?, transcode_started_at)
|
transcode_started_at = COALESCE(?, transcode_started_at)
|
||||||
|
, transcode_duration_ms = COALESCE(?, transcode_duration_ms)
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
`).run(
|
`).run(
|
||||||
input.status,
|
input.status,
|
||||||
@@ -511,6 +515,7 @@ export function updateImageTranscode(
|
|||||||
input.preset ?? null,
|
input.preset ?? null,
|
||||||
input.error ?? null,
|
input.error ?? null,
|
||||||
input.startedAt ?? null,
|
input.startedAt ?? null,
|
||||||
|
input.durationMs ?? null,
|
||||||
key
|
key
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -708,7 +713,7 @@ export async function listImages(options: {
|
|||||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||||
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
||||||
const rows = getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||||
ORDER BY uploaded_at DESC, key DESC
|
ORDER BY uploaded_at DESC, key DESC
|
||||||
@@ -754,7 +759,7 @@ export async function searchImages(options: {
|
|||||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||||
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
||||||
const rows = getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||||
ORDER BY uploaded_at DESC, key DESC
|
ORDER BY uploaded_at DESC, key DESC
|
||||||
@@ -888,7 +893,7 @@ export async function getUserScopedStorageStats(
|
|||||||
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
|
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const rows = getDb().prepare(`
|
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
|
FROM images
|
||||||
WHERE media_type = 'image'
|
WHERE media_type = 'image'
|
||||||
ORDER BY uploaded_at ASC
|
ORDER BY uploaded_at ASC
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const row: ImageIndexRow = {
|
|||||||
source_deleted: 0,
|
source_deleted: 0,
|
||||||
transcode_preset: 'veryfast',
|
transcode_preset: 'veryfast',
|
||||||
transcode_error: null,
|
transcode_error: null,
|
||||||
transcode_started_at: null
|
transcode_started_at: null,
|
||||||
|
transcode_duration_ms: null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBackend() {
|
async function createBackend() {
|
||||||
|
|||||||
@@ -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_PRESET_KEY = 'storage_schema_transcode_preset'
|
||||||
const STORAGE_SCHEMA_TRANSCODE_ERROR_KEY = 'storage_schema_transcode_error'
|
const STORAGE_SCHEMA_TRANSCODE_ERROR_KEY = 'storage_schema_transcode_error'
|
||||||
const STORAGE_SCHEMA_TRANSCODE_STARTED_KEY = 'storage_schema_transcode_started'
|
const STORAGE_SCHEMA_TRANSCODE_STARTED_KEY = 'storage_schema_transcode_started'
|
||||||
|
const STORAGE_SCHEMA_TRANSCODE_DURATION_KEY = 'storage_schema_transcode_duration'
|
||||||
|
|
||||||
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
|
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
|
||||||
function migrateImagesMediaType(database: DatabaseSync): void {
|
function migrateImagesMediaType(database: DatabaseSync): void {
|
||||||
@@ -276,6 +277,27 @@ function migrateImagesTranscodeStarted(database: DatabaseSync): void {
|
|||||||
`).run(STORAGE_SCHEMA_TRANSCODE_STARTED_KEY, new Date().toISOString())
|
`).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 {
|
function migrateStorageTables(database: DatabaseSync): void {
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS storage_backends (
|
CREATE TABLE IF NOT EXISTS storage_backends (
|
||||||
@@ -309,6 +331,7 @@ function migrateStorageTables(database: DatabaseSync): void {
|
|||||||
transcode_preset TEXT,
|
transcode_preset TEXT,
|
||||||
transcode_error TEXT,
|
transcode_error TEXT,
|
||||||
transcode_started_at TEXT,
|
transcode_started_at TEXT,
|
||||||
|
transcode_duration_ms INTEGER,
|
||||||
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
|
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
|
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)
|
migrateImagesTranscodePreset(database)
|
||||||
migrateImagesTranscodeError(database)
|
migrateImagesTranscodeError(database)
|
||||||
migrateImagesTranscodeStarted(database)
|
migrateImagesTranscodeStarted(database)
|
||||||
|
migrateImagesTranscodeDuration(database)
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type);
|
CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type);
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface StoredImage extends StoredImageMeta {
|
|||||||
transcodePreset?: string | null
|
transcodePreset?: string | null
|
||||||
/** 转码开始时间(ISO) */
|
/** 转码开始时间(ISO) */
|
||||||
transcodeStartedAt?: string | null
|
transcodeStartedAt?: string | null
|
||||||
|
/** 转码总耗时(毫秒),结束后固定 */
|
||||||
|
transcodeDurationMs?: number | null
|
||||||
/** 转码失败原因 */
|
/** 转码失败原因 */
|
||||||
transcodeError?: string | null
|
transcodeError?: string | null
|
||||||
/** 视频封面缩略图 key */
|
/** 视频封面缩略图 key */
|
||||||
|
|||||||
@@ -705,11 +705,14 @@ let activeCount = 0
|
|||||||
|
|
||||||
async function processOne(job: QueuedTranscode): Promise<void> {
|
async function processOne(job: QueuedTranscode): Promise<void> {
|
||||||
const { videoKey } = job
|
const { videoKey } = job
|
||||||
|
const startedAtIso = new Date().toISOString()
|
||||||
updateImageTranscode(videoKey, {
|
updateImageTranscode(videoKey, {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
startedAt: new Date().toISOString()
|
startedAt: startedAtIso
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const elapsedMs = () => Math.max(0, Date.now() - new Date(startedAtIso).getTime())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
|
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
|
||||||
|
|
||||||
@@ -722,13 +725,19 @@ async function processOne(job: QueuedTranscode): Promise<void> {
|
|||||||
hlsKey: playlistKey,
|
hlsKey: playlistKey,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
sourceDeleted: true,
|
sourceDeleted: true,
|
||||||
size: totalBytes
|
size: totalBytes,
|
||||||
|
// 结束后清空计时起点,前端不再显示耗时
|
||||||
|
startedAt: '',
|
||||||
|
durationMs: elapsedMs()
|
||||||
})
|
})
|
||||||
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
|
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateImageTranscode(videoKey, {
|
updateImageTranscode(videoKey, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: summarizeTranscodeFailure(error)
|
error: summarizeTranscodeFailure(error),
|
||||||
|
// 失败同样停止计时
|
||||||
|
startedAt: '',
|
||||||
|
durationMs: elapsedMs()
|
||||||
})
|
})
|
||||||
logException('transcode failed', error, { key: videoKey })
|
logException('transcode failed', error, { key: videoKey })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user