feat: add task cancellation support

- Add CancelTask function to cancel running/waiting tasks
- Add /api/cancelTask endpoint
- Add cancel button in task list for running/waiting tasks
- Check cancellation status during download and merge operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 16:40:27 +08:00
parent 1b23d813a8
commit 81df437b2c
5 changed files with 106 additions and 10 deletions
+1
View File
@@ -28,6 +28,7 @@ func API() *http.ServeMux {
router.HandleFunc("/quit", quit) router.HandleFunc("/quit", quit)
router.HandleFunc("/getPopularVideos", getPopularVideos) router.HandleFunc("/getPopularVideos", getPopularVideos)
router.HandleFunc("/deleteTask", deleteTask) router.HandleFunc("/deleteTask", deleteTask)
router.HandleFunc("/cancelTask", cancelTask)
router.HandleFunc("/getRedirectedLocation", getRedirectedLocation) router.HandleFunc("/getRedirectedLocation", getRedirectedLocation)
router.HandleFunc("/downloadVideo", downloadVideo) router.HandleFunc("/downloadVideo", downloadVideo)
router.HandleFunc("/getSeasonsArchivesListFirstBvid", getSeasonsArchivesListFirstBvid) router.HandleFunc("/getSeasonsArchivesListFirstBvid", getSeasonsArchivesListFirstBvid)
+14
View File
@@ -170,3 +170,17 @@ func deleteTask(w http.ResponseWriter, r *http.Request) {
} }
util.Res{Success: true, Message: "删除成功"}.Write(w) util.Res{Success: true, Message: "删除成功"}.Write(w)
} }
func cancelTask(w http.ResponseWriter, r *http.Request) {
taskIDStr := r.FormValue("id")
taskID, err := strconv.ParseInt(taskIDStr, 10, 64)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
if task.CancelTask(taskID) {
util.Res{Success: true, Message: "任务已取消"}.Write(w)
} else {
util.Res{Success: false, Message: "任务不存在或已完成"}.Write(w)
}
}
+60 -9
View File
@@ -59,7 +59,7 @@ func (task *TaskInDB) FilePath() string {
) )
} }
// done | waiting | running | error // done | waiting | running | error | cancelled
type TaskStatus string type TaskStatus string
type Task struct { type Task struct {
@@ -67,6 +67,7 @@ type Task struct {
AudioProgress float64 `json:"audioProgress"` AudioProgress float64 `json:"audioProgress"`
VideoProgress float64 `json:"videoProgress"` VideoProgress float64 `json:"videoProgress"`
MergeProgress float64 `json:"mergeProgress"` MergeProgress float64 `json:"mergeProgress"`
Cancelled bool `json:"cancelled"`
} }
var GlobalTaskList = []*Task{} var GlobalTaskList = []*Task{}
@@ -74,6 +75,20 @@ var GlobalTaskMux = &sync.Mutex{}
var GlobalDownloadSem = util.NewSemaphore(3) var GlobalDownloadSem = util.NewSemaphore(3)
var GlobalMergeSem = util.NewSemaphore(3) var GlobalMergeSem = util.NewSemaphore(3)
// CancelTask 取消指定任务
func CancelTask(taskID int64) bool {
GlobalTaskMux.Lock()
defer GlobalTaskMux.Unlock()
for _, task := range GlobalTaskList {
if task.ID == taskID && (task.Status == "waiting" || task.Status == "running") {
task.Cancelled = true
task.Status = "error"
return true
}
}
return false
}
func (task *Task) Create(db *sql.DB) error { func (task *Task) Create(db *sql.DB) error {
util.SqliteLock.Lock() util.SqliteLock.Lock()
result, err := db.Exec(`INSERT INTO "task" ("bvid", "cid", "format", "title", "owner", "cover", "status", "folder", "duration", "download_type") result, err := db.Exec(`INSERT INTO "task" ("bvid", "cid", "format", "title", "owner", "cover", "status", "folder", "duration", "download_type")
@@ -109,6 +124,13 @@ func (task *Task) Start() {
GlobalTaskMux.Unlock() GlobalTaskMux.Unlock()
db := util.MustGetDB() db := util.MustGetDB()
defer db.Close() defer db.Close()
// 检查是否已取消
if task.Cancelled {
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
return
}
sessdata, err := bilibili.GetSessdata(db) sessdata, err := bilibili.GetSessdata(db)
if err != nil { if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("bilibili.GetSessdata: %v", err)) task.UpdateStatus(db, "error", fmt.Errorf("bilibili.GetSessdata: %v", err))
@@ -124,14 +146,26 @@ func (task *Task) Start() {
client := &bilibili.BiliClient{SESSDATA: sessdata} client := &bilibili.BiliClient{SESSDATA: sessdata}
GlobalDownloadSem.Acquire() GlobalDownloadSem.Acquire()
// 再次检查是否已取消
if task.Cancelled {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
return
}
task.UpdateStatus(db, "running") task.UpdateStatus(db, "running")
if task.DownloadType == "audio" { if task.DownloadType == "audio" {
// 仅音频模式:只下载音频,重命名音频文件为输出文件 // 仅音频模式:只下载音频,重命名音频文件为输出文件
err = DownloadMedia(client, task.Audio, task, "audio") err = DownloadMedia(client, task.Audio, task, "audio")
if err != nil { if err != nil || task.Cancelled {
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err)) if task.Cancelled {
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
} else {
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
}
return return
} }
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
@@ -151,9 +185,13 @@ func (task *Task) Start() {
} else if task.DownloadType == "video" { } else if task.DownloadType == "video" {
// 仅视频模式:只下载视频,重命名视频文件为输出文件 // 仅视频模式:只下载视频,重命名视频文件为输出文件
err = DownloadMedia(client, task.Video, task, "video") err = DownloadMedia(client, task.Video, task, "video")
if err != nil { if err != nil || task.Cancelled {
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err)) if task.Cancelled {
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
} else {
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
}
return return
} }
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
@@ -173,19 +211,32 @@ func (task *Task) Start() {
} else { } else {
// 合并模式:下载音频和视频,然后合并 // 合并模式:下载音频和视频,然后合并
err = DownloadMedia(client, task.Audio, task, "audio") err = DownloadMedia(client, task.Audio, task, "audio")
if err != nil { if err != nil || task.Cancelled {
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err)) if task.Cancelled {
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
} else {
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
}
return return
} }
err = DownloadMedia(client, task.Video, task, "video") err = DownloadMedia(client, task.Video, task, "video")
if err != nil { if err != nil || task.Cancelled {
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err)) if task.Cancelled {
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
} else {
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
}
return return
} }
GlobalDownloadSem.Release() GlobalDownloadSem.Release()
if task.Cancelled {
task.UpdateStatus(db, "error", fmt.Errorf("任务已取消"))
return
}
outputPath := task.TaskInDB.FilePath() outputPath := task.TaskInDB.FilePath()
videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video") videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video")
audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio") audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio")
+5
View File
@@ -72,3 +72,8 @@ export const deleteTask = async (id: number) => {
const res = await fetch(`/api/deleteTask?id=${id}`).then(res => res.json()) as ResJSON const res = await fetch(`/api/deleteTask?id=${id}`).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message) if (!res.success) throw new Error(res.message)
} }
export const cancelTask = async (id: number) => {
const res = await fetch(`/api/cancelTask?id=${id}`).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
}
+26 -1
View File
@@ -1,7 +1,7 @@
import van, { State } from 'vanjs-core' import van, { State } from 'vanjs-core'
import { Route, goto, now } from 'vanjs-router' import { Route, goto, now } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN, GLOBAL_HIDE_PAGE, ResJSON, VanComponent } from '../mixin' import { checkLogin, GLOBAL_HAS_LOGIN, GLOBAL_HIDE_PAGE, ResJSON, VanComponent } from '../mixin'
import { deleteTask, getActiveTask, getTaskList, showFile } from './data' import { deleteTask, getActiveTask, getTaskList, showFile, cancelTask } from './data'
import { TaskInDB, TaskStatus } from '../work/type' import { TaskInDB, TaskStatus } from '../work/type'
import { LoadingBox } from '../view' import { LoadingBox } from '../view'
import { PlayerModalComp } from './playerModal' import { PlayerModalComp } from './playerModal'
@@ -156,6 +156,24 @@ export class TaskRoute implements VanComponent {
_that.DeleteSVG() _that.DeleteSVG()
) )
), ),
div({
class: 'me-4',
hidden: task.statusState.val != 'waiting' && task.statusState.val != 'running'
},
div({
class: 'hover-btn text-danger', title: '取消任务',
onclick() {
if (!confirm('确定要取消该任务吗?')) return
cancelTask(task.id).then(() => {
task.statusState.val = 'error'
}).catch(error => {
alert(error.message)
})
}
},
_that.CancelSVG()
)
),
) )
}) })
) )
@@ -226,6 +244,13 @@ export class TaskRoute implements VanComponent {
) )
} }
CancelSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-x-circle", viewBox: "0 0 16 16" },
path({ "d": "M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" }),
path({ "d": "M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708" }),
)
}
FolderSVG() { FolderSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-folder2", viewBox: "0 0 16 16" }, return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-folder2", viewBox: "0 0 16 16" },
path({ "d": "M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5zM2.5 3a.5.5 0 0 0-.5.5V6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3zM14 7H2v5.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5z" }), path({ "d": "M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5zM2.5 3a.5.5 0 0 0-.5.5V6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3zM14 7H2v5.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5z" }),