This commit is contained in:
2026-04-02 15:16:34 +08:00
commit 4facf82956
58 changed files with 6630 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
package task
import (
"bufio"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"bilidown/bilibili"
"bilidown/common"
"bilidown/util"
)
// TaskInitOption 创建任务时需要从 POST 请求获取的参数
type TaskInitOption struct {
Bvid string `json:"bvid"`
Cid int `json:"cid"`
Format common.MediaFormat `json:"format"`
Title string `json:"title"`
Owner string `json:"owner"`
Cover string `json:"cover"`
Status TaskStatus `json:"status"`
Folder string `json:"folder"`
Audio string `json:"audio"`
Video string `json:"video"`
Duration int `json:"duration"`
DownloadType string `json:"downloadType"`
}
// TaskInDB 任务数据库中的数据
type TaskInDB struct {
TaskInitOption
ID int64 `json:"id"`
CreateAt time.Time `json:"createAt"`
}
func (task *TaskInDB) FilePath() string {
ext := ".mp4"
if task.DownloadType == "audio" {
ext = ".m4a"
}
return filepath.Join(task.Folder,
fmt.Sprintf("%s %s%s", task.Title,
strings.Replace(base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(task.ID, 10))), "=", "", -1),
ext,
),
)
}
// done | waiting | running | error
type TaskStatus string
type Task struct {
TaskInDB
AudioProgress float64 `json:"audioProgress"`
VideoProgress float64 `json:"videoProgress"`
MergeProgress float64 `json:"mergeProgress"`
}
var GlobalTaskList = []*Task{}
var GlobalTaskMux = &sync.Mutex{}
var GlobalDownloadSem = util.NewSemaphore(3)
var GlobalMergeSem = util.NewSemaphore(3)
func (task *Task) Create(db *sql.DB) error {
util.SqliteLock.Lock()
result, err := db.Exec(`INSERT INTO "task" ("bvid", "cid", "format", "title", "owner", "cover", "status", "folder", "duration", "download_type")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.Bvid,
task.Cid,
task.Format,
task.Title,
task.Owner,
task.Cover,
task.Status,
task.Folder,
task.Duration,
task.DownloadType,
)
util.SqliteLock.Unlock()
if err != nil {
return err
}
task.ID, err = result.LastInsertId()
task.CreateAt = time.Now()
return err
}
// Create 创建任务,并将任务加入全局任务列表
func (task *Task) Start() {
if task.DownloadType == "" {
task.DownloadType = "merge"
}
GlobalTaskMux.Lock()
GlobalTaskList = append(GlobalTaskList, task)
GlobalTaskMux.Unlock()
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("bilibili.GetSessdata: %v", err))
return
}
client := &bilibili.BiliClient{SESSDATA: sessdata}
GlobalDownloadSem.Acquire()
task.UpdateStatus(db, "running")
if task.DownloadType == "audio" {
// 仅音频模式:只下载音频,重命名音频文件为输出文件
err = DownloadMedia(client, task.Audio, task, "audio")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
GlobalDownloadSem.Release()
outputPath := task.TaskInDB.FilePath()
audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio")
err = os.Rename(audioPath, outputPath)
if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("os.Rename: %v", err))
return
}
// 添加元数据
if err := task.addMetadata(outputPath); err != nil {
log.Printf("添加元数据失败 (任务ID: %d): %v", task.ID, err)
}
task.UpdateStatus(db, "done")
return
} else if task.DownloadType == "video" {
// 仅视频模式:只下载视频,重命名视频文件为输出文件
err = DownloadMedia(client, task.Video, task, "video")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
GlobalDownloadSem.Release()
outputPath := task.TaskInDB.FilePath()
videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video")
err = os.Rename(videoPath, outputPath)
if err != nil {
task.UpdateStatus(db, "error", fmt.Errorf("os.Rename: %v", err))
return
}
// 添加元数据
if err := task.addMetadata(outputPath); err != nil {
log.Printf("添加元数据失败 (任务ID: %d): %v", task.ID, err)
}
task.UpdateStatus(db, "done")
return
} else {
// 合并模式:下载音频和视频,然后合并
err = DownloadMedia(client, task.Audio, task, "audio")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
err = DownloadMedia(client, task.Video, task, "video")
if err != nil {
GlobalDownloadSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("DownloadMedia: %v", err))
return
}
GlobalDownloadSem.Release()
outputPath := task.TaskInDB.FilePath()
videoPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".video")
audioPath := filepath.Join(task.Folder, strconv.FormatInt(task.ID, 10)+".audio")
GlobalMergeSem.Acquire()
err = task.MergeMedia(outputPath, videoPath, audioPath)
if err != nil {
GlobalMergeSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("task.MergeMedia: %v", err))
return
}
err = os.Remove(videoPath)
if err != nil {
GlobalMergeSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("os.Remove: %v", err))
return
}
err = os.Remove(audioPath)
if err != nil {
GlobalMergeSem.Release()
task.UpdateStatus(db, "error", fmt.Errorf("os.Remove: %v", err))
return
}
GlobalMergeSem.Release()
// 添加元数据
if err := task.addMetadata(outputPath); err != nil {
log.Printf("添加元数据失败 (任务ID: %d): %v", task.ID, err)
}
task.UpdateStatus(db, "done")
}
}
// 合并音视频
func (task *Task) MergeMedia(outputPath string, inputPaths ...string) error {
inputs := []string{}
for _, path := range inputPaths {
inputs = append(inputs, "-i", path)
}
ffmpegPath, err := util.GetFFmpegPath()
if err != nil {
return err
}
cmd := exec.Command(ffmpegPath, append(inputs, "-c:v", "copy", "-c:a", "copy", "-progress", "pipe:1", "-strict", "-2", outputPath)...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(stdout)
progress := newProgressBar(int64(task.Duration))
outTimeRegex := regexp.MustCompile(`out_time_ms=(\d+)`) // 毫秒
for scanner.Scan() {
line := scanner.Text()
match := outTimeRegex.FindStringSubmatch(line)
if len(match) == 2 {
outTime, err := strconv.ParseInt(match[1], 10, 64)
if err != nil {
return err
}
progress.current = outTime / 1000000
task.MergeProgress = progress.percent()
}
}
if err := scanner.Err(); err != nil {
return err
}
if err := cmd.Wait(); err != nil {
return err
}
task.MergeProgress = 1
return nil
}
func GetVideoURL(medias []bilibili.Media, format common.MediaFormat) (string, error) {
for _, code := range []int{12, 7, 13} {
for _, item := range medias {
if item.ID == format && item.Codecid == code {
return item.BaseURL, nil
}
}
}
return "", errors.New("未找到对应视频分辨率格式")
}
func GetAudioURL(dash *bilibili.Dash) string {
if dash.Flac != nil {
return dash.Flac.Audio.BaseURL
}
var maxAudioID common.MediaFormat
var audioURL string
for _, item := range dash.Audio {
if item.ID > maxAudioID {
maxAudioID = item.ID
audioURL = item.BaseURL
}
}
return audioURL
}
func (task *Task) UpdateStatus(db *sql.DB, status TaskStatus, errs ...error) error {
util.SqliteLock.Lock()
_, err := db.Exec(`UPDATE "task" SET "status" = ? WHERE "id" = ?`, status, task.ID)
util.SqliteLock.Unlock()
if err != nil {
return nil
}
for _, err := range errs {
if err != nil {
err = util.CreateLog(db, fmt.Sprintf("Task-%d-Error: %v", task.ID, err))
if err != nil {
log.Fatalln("CreateLog:", err)
}
}
}
task.Status = status
return err
}
func DownloadMedia(client *bilibili.BiliClient, _url string, task *Task, mediaType string) error {
var resp *http.Response
var err error
for i := 0; i < 5; i++ {
resp, err = client.SimpleGET(_url, nil)
if err == nil {
break
}
}
if err != nil {
return err
}
filename := strconv.FormatInt(task.ID, 10) + "." + mediaType
filepath := filepath.Join(task.Folder, filename)
progress := newProgressBar(resp.ContentLength)
file, err := os.Create(filepath)
if err != nil {
return err
}
defer file.Close()
reader := io.TeeReader(resp.Body, file)
buf := make([]byte, 1024)
for {
n, err := reader.Read(buf)
if err != nil && err != io.EOF {
return err
}
if n == 0 {
break
}
progress.add(n)
GlobalTaskMux.Lock()
if mediaType == "video" {
task.VideoProgress = progress.percent()
} else {
task.AudioProgress = progress.percent()
}
GlobalTaskMux.Unlock()
}
return nil
}
type progressBar struct {
total int64
current int64
}
func (p *progressBar) add(n int) {
p.current += int64(n)
}
func (p *progressBar) percent() float64 {
return float64(p.current) / float64(p.total)
}
func newProgressBar(total int64) *progressBar {
return &progressBar{
total: total,
}
}
func GetTaskList(db *sql.DB, page int, pageSize int) ([]TaskInDB, error) {
tasks := []TaskInDB{}
util.SqliteLock.Lock()
rows, err := db.Query(`SELECT
"id", "bvid", "cid", "format", "title",
"owner", "cover", "status", "folder", "duration", "download_type", "create_at"
FROM "task" ORDER BY "id" DESC LIMIT ?, ?`,
page*pageSize, pageSize,
)
util.SqliteLock.Unlock()
if err != nil {
return nil, err
}
createAt := ""
for rows.Next() {
task := TaskInDB{}
err = rows.Scan(
&task.ID,
&task.Bvid,
&task.Cid,
&task.Format,
&task.Title,
&task.Owner,
&task.Cover,
&task.Status,
&task.Folder,
&task.Duration,
&task.DownloadType,
&createAt,
)
if err != nil {
return nil, err
}
task.CreateAt, err = time.Parse("2006-01-02 15:04:05", createAt)
if err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return tasks, nil
}
func DeleteTask(db *sql.DB, taskID int) error {
util.SqliteLock.Lock()
_, err := db.Exec(`DELETE FROM "task" WHERE "id" = ?`, taskID)
util.SqliteLock.Unlock()
return err
}
func GetTask(db *sql.DB, taskID int) (*TaskInDB, error) {
task := TaskInDB{}
createAt := ""
util.SqliteLock.Lock()
err := db.QueryRow(`SELECT
"id", "bvid", "cid", "format", "title",
"owner", "cover", "status", "folder", "duration", "download_type", "create_at"
FROM "task" WHERE "id" = ?`,
taskID,
).Scan(
&task.ID,
&task.Bvid,
&task.Cid,
&task.Format,
&task.Title,
&task.Owner,
&task.Cover,
&task.Status,
&task.Folder,
&task.Duration,
&task.DownloadType,
&createAt,
)
util.SqliteLock.Unlock()
if err != nil {
return nil, err
}
task.CreateAt, err = time.Parse("2006-01-02 15:04:05", createAt)
if err != nil {
return nil, err
}
return &task, nil
}
// addMetadata 使用 ffmpeg 给输出文件添加元数据(description 和 artist
func (task *Task) addMetadata(filePath string) error {
ffmpegPath, err := util.GetFFmpegPath()
if err != nil {
return err
}
desc := task.Bvid
if desc == "" {
desc = ""
}
author := task.Owner
// 临时文件加上 .mp4 扩展名
tempPath := filePath + ".tmp.mp4"
// 使用双引号包裹文件路径,避免特殊字符
cmd := exec.Command(ffmpegPath,
"-i", filePath,
"-metadata", "description="+desc,
"-metadata", "artist="+author,
"-codec", "copy",
"-y",
tempPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg添加元数据失败: %v, 输出: %s", err, string(output))
}
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("删除原文件失败: %v", err)
}
if err := os.Rename(tempPath, filePath); err != nil {
return fmt.Errorf("重命名临时文件失败: %v", err)
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package task_test
import (
"bufio"
"fmt"
"os/exec"
"testing"
)
func TestFFMPEG(t *testing.T) {
cmd := exec.Command("ffmpeg", "-i", `E:\bilidown\27.video`, "-i", `E:\bilidown\27.audio`, "-c:v", "copy", "-c:a", "copy", "-progress", "pipe:1", `E:\bilidown\27.mp4`)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
fmt.Println(">>>", scanner.Text())
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatal(err)
}
t.Log("done")
}
func TestNum(t *testing.T) {
t.Log(int64(100999) / 1000)
}