feat: add FFmpeg status check and shutdown button

- FFmpeg check no longer blocks server startup
- Add /api/checkFFmpeg endpoint to query FFmpeg availability
- Add /api/shutdown endpoint to gracefully stop the server
- Add FFmpeg status indicator in web header
- Add shutdown button in web interface
- Move static files output to cmd/bilidown/static for embedding
- Update .gitignore to exclude cmd/bilidown/static

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 15:27:55 +08:00
parent dd8eaef97d
commit eb662a8b66
7 changed files with 69 additions and 37 deletions
+2 -1
View File
@@ -19,4 +19,5 @@ Thumbs.db
backup/ backup/
*.db *.db
download/ download/
bin/ bin/
cmd/bilidown/static
+24 -10
View File
@@ -15,7 +15,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
//go:embed all:build/static //go:embed all:static
var staticFiles embed.FS var staticFiles embed.FS
const ( const (
@@ -25,34 +25,48 @@ const (
) )
var urlLocal = fmt.Sprintf("http://127.0.0.1:%d", HTTP_PORT) var urlLocal = fmt.Sprintf("http://127.0.0.1:%d", HTTP_PORT)
var ffmpegAvailable bool
func main() { func main() {
checkFFmpeg() ffmpegAvailable = checkFFmpeg()
mustInitTables() mustInitTables()
mustRunServer() mustRunServer()
fmt.Printf("Bilidown %s server running at %s\n", VERSION, urlLocal) fmt.Printf("Bilidown %s server running at %s\n", VERSION, urlLocal)
if !ffmpegAvailable {
fmt.Println("Warning: FFmpeg is not installed. Video download will not work.")
}
select {} // 保持运行 select {} // 保持运行
} }
// checkFFmpeg 检测 ffmpeg 的安装情况 // checkFFmpeg 检测 ffmpeg 的安装情况,返回是否可用
func checkFFmpeg() { func checkFFmpeg() bool {
if _, err := util.GetFFmpegPath(); err != nil { _, err := util.GetFFmpegPath()
fmt.Println("FFmpeg is missing. Install it from https://www.ffmpeg.org/download.html, then restart.") return err == nil
os.Exit(1)
}
} }
// mustRunServer 配置和启动 HTTP 服务器 // mustRunServer 配置和启动 HTTP 服务器
func mustRunServer() { func mustRunServer() {
// 从嵌入的文件系统中获取静态文件 // 从嵌入的文件系统中获取静态文件
staticFS, err := fs.Sub(staticFiles, "build/static") staticFS, err := fs.Sub(staticFiles, "static")
if err != nil { if err != nil {
log.Fatal("Failed to load static files:", err) log.Fatal("Failed to load static files:", err)
} }
// 前端静态文件 // 前端静态文件
http.Handle("/", http.FileServer(http.FS(staticFS))) http.Handle("/", http.FileServer(http.FS(staticFS)))
// 后端 API 接口 // 后端 API 接口
http.Handle("/api/", http.StripPrefix("/api", router.API())) apiRouter := router.API()
// 添加 FFmpeg 状态检查接口
http.HandleFunc("/api/checkFFmpeg", func(w http.ResponseWriter, r *http.Request) {
util.Res{Success: true, Data: map[string]bool{"available": ffmpegAvailable}}.Write(w)
})
// 添加关闭服务接口
http.HandleFunc("/api/shutdown", func(w http.ResponseWriter, r *http.Request) {
util.Res{Success: true, Message: "Shutting down..."}.Write(w)
go func() {
os.Exit(0)
}()
})
http.Handle("/api/", http.StripPrefix("/api", apiRouter))
// 启动 HTTP 服务器 // 启动 HTTP 服务器
go func() { go func() {
err := http.ListenAndServe(fmt.Sprintf("%s:%d", HTTP_HOST, HTTP_PORT), nil) err := http.ListenAndServe(fmt.Sprintf("%s:%d", HTTP_HOST, HTTP_PORT), nil)
+3 -22
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"strings" "strings"
"sync" "sync"
) )
@@ -97,37 +96,19 @@ func GetCurrentFolder(db *sql.DB) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
err = os.MkdirAll(folder, os.ModePerm)
if err != nil {
return "", err
}
err = SaveDownloadFolder(db, folder) err = SaveDownloadFolder(db, folder)
if err != nil { if err != nil {
return "", err return "", err
} }
return folder, nil return folder, nil
} }
err = os.MkdirAll(folder, os.ModePerm) return folder, err
if err != nil {
return "", err
}
return folder, nil
} }
// SaveDownloadFolder 保存下载路径,不存在则自动创建 // SaveDownloadFolder 保存下载路径(不自动创建目录)
func SaveDownloadFolder(db *sql.DB, downloadFolder string) error { func SaveDownloadFolder(db *sql.DB, downloadFolder string) error {
_, err := os.Stat(downloadFolder)
if err != nil {
if os.IsNotExist(err) {
err = os.MkdirAll(downloadFolder, os.ModePerm)
if err != nil {
return err
}
}
return err
}
SqliteLock.Lock() SqliteLock.Lock()
_, err = db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ('download_folder', ?)`, downloadFolder) _, err := db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ('download_folder', ?)`, downloadFolder)
SqliteLock.Unlock() SqliteLock.Unlock()
return err return err
} }
+29 -2
View File
@@ -1,12 +1,29 @@
import van from 'vanjs-core' import van from 'vanjs-core'
import { now } from 'vanjs-router' import { now } from 'vanjs-router'
import { GLOBAL_HAS_LOGIN } from './mixin' import { GLOBAL_HAS_LOGIN, getApi } from './mixin'
const { a, div } = van.tags const { a, div, button, span } = van.tags
export const FFmpegAvailable = van.state<boolean | null>(null)
export const checkFFmpeg = async () => {
try {
const res = await getApi('/checkFFmpeg')
FFmpegAvailable.val = res.data?.available ?? false
} catch {
FFmpegAvailable.val = false
}
}
export default () => { export default () => {
const classStr = (name: string) => van.derive(() => `text-nowrap nav-link ${now.val.split('/')[0] == name ? 'active' : ''}`) const classStr = (name: string) => van.derive(() => `text-nowrap nav-link ${now.val.split('/')[0] == name ? 'active' : ''}`)
const shutdown = async () => {
if (confirm('确定要关闭服务吗?')) {
await getApi('/shutdown')
}
}
return div({ class: 'hstack gap-4' }, return div({ class: 'hstack gap-4' },
div({ class: 'fs-4 fw-bold text-nowrap' }, 'Bilidown'), div({ class: 'fs-4 fw-bold text-nowrap' }, 'Bilidown'),
div({ class: 'nav nav-underline flex-nowrap overflow-auto' }, div({ class: 'nav nav-underline flex-nowrap overflow-auto' },
@@ -22,6 +39,16 @@ export default () => {
div({ class: 'nav-item', hidden: GLOBAL_HAS_LOGIN }, div({ class: 'nav-item', hidden: GLOBAL_HAS_LOGIN },
a({ class: classStr('login'), href: '#/login' }, '扫码登录') a({ class: classStr('login'), href: '#/login' }, '扫码登录')
), ),
),
div({ class: 'ms-auto hstack gap-2' },
// FFmpeg 状态指示器
div({ class: 'hstack gap-1' },
span({ class: () => `badge ${FFmpegAvailable.val === true ? 'bg-success' : FFmpegAvailable.val === false ? 'bg-danger' : 'bg-secondary'}` },
() => FFmpegAvailable.val === true ? 'FFmpeg ✓' : FFmpegAvailable.val === false ? 'FFmpeg ✗' : '检查中...'
)
),
// 关闭按钮
button({ class: 'btn btn-outline-danger btn-sm', onclick: shutdown }, '关闭服务')
) )
) )
} }
+4 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import van from 'vanjs-core' import van from 'vanjs-core'
import Header from './header' import Header, { checkFFmpeg } from './header'
import Work from './work' import Work from './work'
import Task from './task' import Task from './task'
import Login from './login' import Login from './login'
@@ -16,6 +16,9 @@ const { div } = van.tags
redirect('home', 'work') redirect('home', 'work')
// 启动时检查 FFmpeg 状态
checkFFmpeg()
van.add(document.body, van.add(document.body,
div({ class: 'container py-4 vstack gap-4', hidden: GLOBAL_HIDE_PAGE }, div({ class: 'container py-4 vstack gap-4', hidden: GLOBAL_HIDE_PAGE },
Header(), Header(),
+6
View File
@@ -66,4 +66,10 @@ export const formatSeconds = (seconds: number) => {
if (secs > 0) str += `${secs}` if (secs > 0) str += `${secs}`
return str return str
}
/** API 请求封装 */
export const getApi = async <T = unknown>(path: string, options?: RequestInit): Promise<ResJSON<T>> => {
const res = await fetch('/api' + path, options)
return res.json() as Promise<ResJSON<T>>
} }
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
host: '0.0.0.0' host: '0.0.0.0'
}, },
build: { build: {
outDir: '../build/static', outDir: '../cmd/bilidown/static',
emptyOutDir: true, emptyOutDir: true,
}, },
css: { css: {