1.5.1版本,体验性修复、打包fpk、docker镜像、windows包
This commit is contained in:
1 parent
813407de30
commit
a6e45e0cb1
17 files changed
+3451
-15
No files matched your search
@@ -5,6 +5,7 @@
|
|||||||
.nitro
|
.nitro
|
||||||
.cache
|
.cache
|
||||||
dist
|
dist
|
||||||
|
dist-windows
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ const presetLabel = computed(() => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 转码已耗时(轮询每秒刷新一次) */
|
||||||
|
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`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -239,6 +248,14 @@ const presetLabel = computed(() => {
|
|||||||
>
|
>
|
||||||
{{ presetLabel }}
|
{{ presetLabel }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-if="elapsedLabel"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ t('library.transcodeElapsed') }} {{ elapsedLabel }}
|
||||||
|
</UBadge>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface FileItem {
|
|||||||
transcodeProgress?: number
|
transcodeProgress?: number
|
||||||
/** 本视频使用的转码档位 */
|
/** 本视频使用的转码档位 */
|
||||||
transcodePreset?: string
|
transcodePreset?: string
|
||||||
|
/** 转码开始时间(ISO) */
|
||||||
|
transcodeStartedAt?: string
|
||||||
/** 转码失败原因 */
|
/** 转码失败原因 */
|
||||||
transcodeError?: string
|
transcodeError?: string
|
||||||
/** HLS 播放列表地址(转码完成后有值) */
|
/** HLS 播放列表地址(转码完成后有值) */
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
const { app, BrowserWindow, dialog, Menu, shell } = require('electron')
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const net = require('node:net')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
let serverProcess = null
|
||||||
|
let mainWindow = null
|
||||||
|
let serverUrl = ''
|
||||||
|
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setAppUserModelId('com.yeomai.pichost')
|
||||||
|
app.setName('PicHost')
|
||||||
|
|
||||||
|
function runtimeRoot() {
|
||||||
|
return app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'runtime')
|
||||||
|
: path.join(__dirname, '..', 'dist-windows', 'staging', 'runtime')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataDirectory() {
|
||||||
|
const base = process.env.LOCALAPPDATA || app.getPath('userData')
|
||||||
|
return path.join(base, 'PicHost', 'data')
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once('error', reject)
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = server.address()
|
||||||
|
server.close(error => error ? reject(error) : resolve(port))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForHealth(url, process) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (process.exitCode !== null) {
|
||||||
|
clearInterval(timer)
|
||||||
|
reject(new Error(`PicHost 服务已退出,代码 ${process.exitCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/api/health`, {
|
||||||
|
signal: AbortSignal.timeout(1000)
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
clearInterval(timer)
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略轮询中的瞬时错误,继续等待
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - startedAt > 30000) {
|
||||||
|
clearInterval(timer)
|
||||||
|
reject(new Error('PicHost 服务启动超时'))
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer() {
|
||||||
|
if (!serverProcess || serverProcess.exitCode !== null) return
|
||||||
|
|
||||||
|
spawn('taskkill', ['/pid', String(serverProcess.pid), '/T', '/F'], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: 'ignore'
|
||||||
|
})
|
||||||
|
serverProcess = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
const root = runtimeRoot()
|
||||||
|
const appRoot = path.join(root, 'app')
|
||||||
|
const nodeExe = path.join(root, 'node', 'node.exe')
|
||||||
|
const ffmpegDir = path.join(root, 'ffmpeg')
|
||||||
|
const dataDir = dataDirectory()
|
||||||
|
const port = await findFreePort()
|
||||||
|
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
const log = fs.createWriteStream(path.join(dataDir, 'desktop-server.log'), {
|
||||||
|
flags: 'a'
|
||||||
|
})
|
||||||
|
|
||||||
|
serverUrl = `http://127.0.0.1:${port}`
|
||||||
|
serverProcess = spawn(nodeExe, [path.join(appRoot, '.output', 'server', 'index.mjs')], {
|
||||||
|
cwd: appRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATA_DIR: dataDir,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
NITRO_PORT: String(port),
|
||||||
|
PORT: String(port),
|
||||||
|
NITRO_HOST: '127.0.0.1',
|
||||||
|
HOST: '127.0.0.1',
|
||||||
|
PATH: `${ffmpegDir};${process.env.PATH || ''}`,
|
||||||
|
PIC_HOST_DESKTOP: '1'
|
||||||
|
},
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
|
||||||
|
serverProcess.stdout.pipe(log, { end: false })
|
||||||
|
serverProcess.stderr.pipe(log, { end: false })
|
||||||
|
serverProcess.once('close', () => log.end())
|
||||||
|
|
||||||
|
await waitForHealth(serverUrl, serverProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 920,
|
||||||
|
minWidth: 980,
|
||||||
|
minHeight: 680,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#111827',
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => mainWindow.show())
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith(serverUrl)) return { action: 'allow' }
|
||||||
|
shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (!url.startsWith(serverUrl)) {
|
||||||
|
event.preventDefault()
|
||||||
|
shell.openExternal(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.loadURL(serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (!mainWindow) return
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
mainWindow.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', stopServer)
|
||||||
|
app.on('window-all-closed', () => app.quit())
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
Menu.setApplicationMenu(null)
|
||||||
|
try {
|
||||||
|
await startServer()
|
||||||
|
createWindow()
|
||||||
|
} catch (error) {
|
||||||
|
dialog.showErrorBox('PicHost 启动失败', String(error?.message || error))
|
||||||
|
stopServer()
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
copyFile,
|
||||||
|
cp,
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs/promises'
|
||||||
|
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 outputRoot = path.join(root, 'dist-windows')
|
||||||
|
const stageRoot = path.join(outputRoot, 'staging')
|
||||||
|
const runtimeRoot = path.join(stageRoot, 'runtime')
|
||||||
|
const appRoot = path.join(runtimeRoot, 'app')
|
||||||
|
const nodeDir = path.join(runtimeRoot, 'node')
|
||||||
|
const ffmpegDir = path.join(runtimeRoot, 'ffmpeg')
|
||||||
|
const buildDir = path.join(stageRoot, 'build')
|
||||||
|
const cacheDir = path.join(outputRoot, 'cache')
|
||||||
|
const nodeVersion = '22.23.3'
|
||||||
|
|
||||||
|
function run(command, args, cwd = root) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
|
shell: process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command)
|
||||||
|
})
|
||||||
|
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 download(file, url) {
|
||||||
|
if (await exists(file)) return
|
||||||
|
await mkdir(path.dirname(file), { recursive: true })
|
||||||
|
console.log(`下载 ${url}`)
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error(`下载失败:${response.status} ${url}`)
|
||||||
|
await writeFile(file, Buffer.from(await response.arrayBuffer()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareNode() {
|
||||||
|
const cacheFile = path.join(cacheDir, `node-v${nodeVersion}-win-x64.exe`)
|
||||||
|
const nodeExe = path.join(nodeDir, 'node.exe')
|
||||||
|
await download(
|
||||||
|
cacheFile,
|
||||||
|
`https://nodejs.org/dist/v${nodeVersion}/win-x64/node.exe`
|
||||||
|
)
|
||||||
|
|
||||||
|
const sums = await (await fetch(
|
||||||
|
`https://nodejs.org/dist/v${nodeVersion}/SHASUMS256.txt`
|
||||||
|
)).text()
|
||||||
|
const expected = sums
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.trim().split(/\s+/))
|
||||||
|
.find(parts => parts[1] === 'win-x64/node.exe')?.[0]
|
||||||
|
const actual = createHash('sha256')
|
||||||
|
.update(await readFile(cacheFile))
|
||||||
|
.digest('hex')
|
||||||
|
if (!expected || actual !== expected) {
|
||||||
|
throw new Error('内置 Node.js 校验失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(nodeDir, { recursive: true })
|
||||||
|
await copyFile(cacheFile, nodeExe)
|
||||||
|
run(nodeExe, ['--version'])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFfmpegDir() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.FFMPEG_DIR,
|
||||||
|
'C:\\ffmpeg-8.0.1-essentials_build\\bin'
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (
|
||||||
|
await exists(path.join(candidate, 'ffmpeg.exe'))
|
||||||
|
&& await exists(path.join(candidate, 'ffprobe.exe'))
|
||||||
|
) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const located = spawnSync('where.exe', ['ffmpeg.exe'], { encoding: 'utf8' })
|
||||||
|
const first = located.stdout?.trim().split(/\r?\n/)[0]
|
||||||
|
if (first && await exists(path.join(path.dirname(first), 'ffprobe.exe'))) {
|
||||||
|
return path.dirname(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('未找到 ffmpeg.exe 和 ffprobe.exe,请设置 FFMPEG_DIR')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
run('npm.cmd', ['run', 'build'])
|
||||||
|
await rm(stageRoot, { recursive: true, force: true })
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(appRoot, { recursive: true }),
|
||||||
|
mkdir(buildDir, { recursive: true })
|
||||||
|
])
|
||||||
|
|
||||||
|
await cp(path.join(root, '.output'), path.join(appRoot, '.output'), {
|
||||||
|
recursive: true,
|
||||||
|
dereference: true
|
||||||
|
})
|
||||||
|
await copyFile(path.join(root, 'package.json'), path.join(appRoot, 'package.json'))
|
||||||
|
await copyFile(
|
||||||
|
path.join(root, 'package-lock.json'),
|
||||||
|
path.join(appRoot, 'package-lock.json')
|
||||||
|
)
|
||||||
|
await cp(path.join(root, 'server', 'cli'), path.join(appRoot, 'server', 'cli'), {
|
||||||
|
recursive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('安装 Windows 运行时生产依赖')
|
||||||
|
run(
|
||||||
|
'npm.cmd',
|
||||||
|
['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'],
|
||||||
|
appRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
await prepareNode()
|
||||||
|
|
||||||
|
const sourceFfmpeg = await findFfmpegDir()
|
||||||
|
await mkdir(ffmpegDir, { recursive: true })
|
||||||
|
await copyFile(
|
||||||
|
path.join(sourceFfmpeg, 'ffmpeg.exe'),
|
||||||
|
path.join(ffmpegDir, 'ffmpeg.exe')
|
||||||
|
)
|
||||||
|
await copyFile(
|
||||||
|
path.join(sourceFfmpeg, 'ffprobe.exe'),
|
||||||
|
path.join(ffmpegDir, 'ffprobe.exe')
|
||||||
|
)
|
||||||
|
run(path.join(ffmpegDir, 'ffmpeg.exe'), ['-version'])
|
||||||
|
|
||||||
|
await sharp(path.join(root, 'app', 'assets', 'image', 'logo-light.png'))
|
||||||
|
.resize(512, 512, {
|
||||||
|
fit: 'contain',
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(path.join(buildDir, 'icon.png'))
|
||||||
|
|
||||||
|
console.log(`Windows 运行时已准备到 ${runtimeRoot}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
appId: com.yeomai.pichost
|
||||||
|
productName: PicHost
|
||||||
|
asar: true
|
||||||
|
npmRebuild: false
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: dist-windows/release
|
||||||
|
buildResources: dist-windows/staging/build
|
||||||
|
|
||||||
|
files:
|
||||||
|
- desktop/**/*
|
||||||
|
- package.json
|
||||||
|
- "!node_modules/**/*"
|
||||||
|
|
||||||
|
extraResources:
|
||||||
|
- from: dist-windows/staging/runtime
|
||||||
|
to: runtime
|
||||||
|
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- target: nsis
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
icon: icon.png
|
||||||
|
executableName: PicHost
|
||||||
|
artifactName: PicHost-Setup-${version}-${arch}.${ext}
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
perMachine: false
|
||||||
|
allowElevation: true
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
createDesktopShortcut: true
|
||||||
|
createStartMenuShortcut: true
|
||||||
|
shortcutName: PicHost
|
||||||
|
uninstallDisplayName: PicHost
|
||||||
|
runAfterFinish: true
|
||||||
@@ -1220,6 +1220,7 @@
|
|||||||
"statsVsLastMonth": "vs last month {value}",
|
"statsVsLastMonth": "vs last month {value}",
|
||||||
"transcodePending": "Queued",
|
"transcodePending": "Queued",
|
||||||
"transcoding": "Transcoding",
|
"transcoding": "Transcoding",
|
||||||
|
"transcodeElapsed": "Elapsed",
|
||||||
"transcodeFailed": "Transcode failed",
|
"transcodeFailed": "Transcode failed",
|
||||||
"searchPlaceholder": "Search file name",
|
"searchPlaceholder": "Search file name",
|
||||||
"searchEmpty": "Nothing matched",
|
"searchEmpty": "Nothing matched",
|
||||||
|
|||||||
@@ -1223,6 +1223,7 @@
|
|||||||
"statsVsLastMonth": "较上月 {value}",
|
"statsVsLastMonth": "较上月 {value}",
|
||||||
"transcodePending": "等待转码中",
|
"transcodePending": "等待转码中",
|
||||||
"transcoding": "转码中",
|
"transcoding": "转码中",
|
||||||
|
"transcodeElapsed": "已耗时",
|
||||||
"transcodeFailed": "转码失败",
|
"transcodeFailed": "转码失败",
|
||||||
"searchPlaceholder": "搜索文件名",
|
"searchPlaceholder": "搜索文件名",
|
||||||
"searchEmpty": "没有匹配的内容",
|
"searchEmpty": "没有匹配的内容",
|
||||||
|
|||||||
Generated
+2981
-2
File diff suppressed because it is too large.
Load diff
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pichost",
|
"name": "pichost",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
|
||||||
"homepage": "https://github.com/O96u/PicHost#readme",
|
"homepage": "https://github.com/O96u/PicHost#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "desktop/main.cjs",
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
@@ -42,7 +43,10 @@
|
|||||||
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
"docs:prepare": "node docs-site/copy-screenshots.mjs",
|
||||||
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
|
||||||
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
"docs:build": "npm run docs:prepare && vitepress build docs-site",
|
||||||
"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:dev": "npm run desktop:prepare && electron .",
|
||||||
|
"desktop:build": "npm run desktop:prepare && electron-builder --win nsis --x64"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.876.0",
|
"@aws-sdk/client-s3": "^3.876.0",
|
||||||
@@ -62,6 +66,8 @@
|
|||||||
"@nuxt/eslint": "^1.16.0",
|
"@nuxt/eslint": "^1.16.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/tar": "^6.1.13",
|
"@types/tar": "^6.1.13",
|
||||||
|
"electron": "^44.9.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
"mermaid": "^11.17.2",
|
"mermaid": "^11.17.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ export async function handleFileList(
|
|||||||
transcodeProgress: stored.transcodeProgress ?? 0,
|
transcodeProgress: stored.transcodeProgress ?? 0,
|
||||||
...(stored.transcodeError ? { transcodeError: stored.transcodeError } : {}),
|
...(stored.transcodeError ? { transcodeError: stored.transcodeError } : {}),
|
||||||
...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}),
|
...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}),
|
||||||
|
...(stored.transcodeStartedAt
|
||||||
|
? { transcodeStartedAt: stored.transcodeStartedAt }
|
||||||
|
: {}),
|
||||||
...(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) }
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
const currentVersion = resolveRuntimeAppVersion(
|
const currentVersion = resolveRuntimeAppVersion(
|
||||||
String(config.appVersion ?? '')
|
String(config.appVersion ?? '')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 桌面版保持完全离线,不主动请求远端版本信息。
|
||||||
|
if (process.env.PIC_HOST_DESKTOP === '1') {
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
releaseUrl: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const force = query.refresh === '1' || query.refresh === 'true'
|
const force = query.refresh === '1' || query.refresh === 'true'
|
||||||
const latest = await fetchLatestGitHubRelease(force)
|
const latest = await fetchLatestGitHubRelease(force)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ImageIndexRow {
|
|||||||
source_deleted: number
|
source_deleted: number
|
||||||
transcode_preset: string | null
|
transcode_preset: string | null
|
||||||
transcode_error: string | null
|
transcode_error: string | null
|
||||||
|
transcode_started_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyToFilePath(key: string): string {
|
function keyToFilePath(key: string): string {
|
||||||
@@ -80,6 +81,7 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
|
|||||||
sourceDeleted: Number(row.source_deleted ?? 0) === 1,
|
sourceDeleted: Number(row.source_deleted ?? 0) === 1,
|
||||||
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,
|
||||||
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +236,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_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_error
|
||||||
FROM images
|
FROM images
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
`).get(key) as ImageIndexRow | undefined
|
`).get(key) as ImageIndexRow | undefined
|
||||||
@@ -427,7 +429,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_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_error
|
||||||
FROM images
|
FROM images
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
`).all() as unknown as ImageIndexRow[]
|
`).all() as unknown as ImageIndexRow[]
|
||||||
@@ -439,7 +441,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_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_error
|
||||||
FROM images
|
FROM images
|
||||||
WHERE key > ?
|
WHERE key > ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -450,7 +452,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_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_error
|
||||||
FROM images
|
FROM images
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -460,7 +462,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_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_error
|
||||||
FROM images
|
FROM images
|
||||||
WHERE backend_id = ?
|
WHERE backend_id = ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -485,6 +487,7 @@ export function updateImageTranscode(
|
|||||||
size?: number
|
size?: number
|
||||||
preset?: string | null
|
preset?: string | null
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
startedAt?: string | null
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
@@ -496,7 +499,8 @@ export function updateImageTranscode(
|
|||||||
source_deleted = COALESCE(?, source_deleted),
|
source_deleted = COALESCE(?, source_deleted),
|
||||||
size = COALESCE(?, size),
|
size = COALESCE(?, size),
|
||||||
transcode_preset = COALESCE(?, transcode_preset),
|
transcode_preset = COALESCE(?, transcode_preset),
|
||||||
transcode_error = ?
|
transcode_error = ?,
|
||||||
|
transcode_started_at = COALESCE(?, transcode_started_at)
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
`).run(
|
`).run(
|
||||||
input.status,
|
input.status,
|
||||||
@@ -506,6 +510,7 @@ export function updateImageTranscode(
|
|||||||
input.size ?? null,
|
input.size ?? null,
|
||||||
input.preset ?? null,
|
input.preset ?? null,
|
||||||
input.error ?? null,
|
input.error ?? null,
|
||||||
|
input.startedAt ?? null,
|
||||||
key
|
key
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -703,7 +708,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_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_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
|
||||||
@@ -749,7 +754,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_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_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
|
||||||
@@ -883,7 +888,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_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_error
|
||||||
FROM images
|
FROM images
|
||||||
WHERE media_type = 'image'
|
WHERE media_type = 'image'
|
||||||
ORDER BY uploaded_at ASC
|
ORDER BY uploaded_at ASC
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const row: ImageIndexRow = {
|
|||||||
thumb_key: 'videos/2026/10/example.webp',
|
thumb_key: 'videos/2026/10/example.webp',
|
||||||
source_deleted: 0,
|
source_deleted: 0,
|
||||||
transcode_preset: 'veryfast',
|
transcode_preset: 'veryfast',
|
||||||
transcode_error: null
|
transcode_error: null,
|
||||||
|
transcode_started_at: null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBackend() {
|
async function createBackend() {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const STORAGE_SCHEMA_THUMB_KEY = 'storage_schema_thumb'
|
|||||||
const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted'
|
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'
|
||||||
|
|
||||||
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
|
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
|
||||||
function migrateImagesMediaType(database: DatabaseSync): void {
|
function migrateImagesMediaType(database: DatabaseSync): void {
|
||||||
@@ -254,6 +255,27 @@ function migrateImagesTranscodeError(database: DatabaseSync): void {
|
|||||||
`).run(STORAGE_SCHEMA_TRANSCODE_ERROR_KEY, new Date().toISOString())
|
`).run(STORAGE_SCHEMA_TRANSCODE_ERROR_KEY, new Date().toISOString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** images 表新增「转码开始时间」列 */
|
||||||
|
function migrateImagesTranscodeStarted(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_TRANSCODE_STARTED_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_started_at')) {
|
||||||
|
database.exec('ALTER TABLE images ADD COLUMN transcode_started_at TEXT')
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_TRANSCODE_STARTED_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 (
|
||||||
@@ -286,6 +308,7 @@ function migrateStorageTables(database: DatabaseSync): void {
|
|||||||
source_deleted INTEGER NOT NULL DEFAULT 0,
|
source_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
transcode_preset TEXT,
|
transcode_preset TEXT,
|
||||||
transcode_error TEXT,
|
transcode_error TEXT,
|
||||||
|
transcode_started_at TEXT,
|
||||||
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);
|
||||||
@@ -308,6 +331,7 @@ function migrateStorageTables(database: DatabaseSync): void {
|
|||||||
migrateImagesSourceDeleted(database)
|
migrateImagesSourceDeleted(database)
|
||||||
migrateImagesTranscodePreset(database)
|
migrateImagesTranscodePreset(database)
|
||||||
migrateImagesTranscodeError(database)
|
migrateImagesTranscodeError(database)
|
||||||
|
migrateImagesTranscodeStarted(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);
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface StoredImage extends StoredImageMeta {
|
|||||||
transcodeProgress?: number
|
transcodeProgress?: number
|
||||||
/** 本视频使用的转码档位 */
|
/** 本视频使用的转码档位 */
|
||||||
transcodePreset?: string | null
|
transcodePreset?: string | null
|
||||||
|
/** 转码开始时间(ISO) */
|
||||||
|
transcodeStartedAt?: string | null
|
||||||
/** 转码失败原因 */
|
/** 转码失败原因 */
|
||||||
transcodeError?: string | null
|
transcodeError?: string | null
|
||||||
/** 视频封面缩略图 key */
|
/** 视频封面缩略图 key */
|
||||||
|
|||||||
@@ -705,7 +705,10 @@ let activeCount = 0
|
|||||||
|
|
||||||
async function processOne(job: QueuedTranscode): Promise<void> {
|
async function processOne(job: QueuedTranscode): Promise<void> {
|
||||||
const { videoKey } = job
|
const { videoKey } = job
|
||||||
updateImageTranscode(videoKey, { status: 'running' })
|
updateImageTranscode(videoKey, {
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
|
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey, job)
|
||||||
|
|||||||
Reference in new issue
Block a user