This commit is contained in:
2026-04-02 15:16:34 +08:00
commit 4facf82956
58 changed files with 6630 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
[build]
exclude_dir = ["download", "bin"]
include_ext = ["go"]
+71
View File
@@ -0,0 +1,71 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- id: bilidown_windows
env:
- CGO_ENABLED=1
- >-
{{- if eq .Arch "amd64" }}CC=x86_64-w64-mingw32-gcc{{ end }}
{{- if eq .Arch "386" }}CC=i686-w64-mingw32-gcc{{ end }}
goos:
- windows
ldflags:
- -s -w -H windowsgui
- id: bilidown_darwin
env:
- CGO_ENABLED=1
- CC=o64-clang
goos:
- darwin
ldflags:
- -s -w
- id: bilidown_linux
env:
- CGO_ENABLED=1
- >-
{{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{ end }}
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
files:
- static/**
- >-
{{- if eq .Os "windows" }}bin/ffmpeg.exe{{- end }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
+146
View File
@@ -0,0 +1,146 @@
package bilibili_test
import (
"encoding/json"
"fmt"
"testing"
"time"
"bilidown/bilibili"
"bilidown/util"
"github.com/skip2/go-qrcode"
_ "modernc.org/sqlite"
)
const TEST_SESSDATA = ""
const TEST_BVID = "BV1KX4y1V7sA" // 普通合集
const TEST_CID = 305542578 // 普通分集
const TEST_BVID_HDR = "BV1rp4y1e745" // HDR 合集
const TEST_CID_HDR = 244954665 // HDR 分集
const TEST_EPID = 835909 // 剧集
const TEST_SSID = 48744 // 番剧
func TestBiliClient(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
check, err := client.CheckLogin()
if err != nil {
t.Error(err)
}
if check {
fmt.Println("SESSDATA 有效")
} else {
fmt.Println("SESSDATA 无效")
}
}
func TestNewQRInfo(t *testing.T) {
client := bilibili.BiliClient{}
info, err := client.NewQRInfo()
if err != nil {
t.Error(err)
}
fmt.Printf("二维码信息:%+v", info)
}
func TestGetQRStatus(t *testing.T) {
client := bilibili.BiliClient{}
qrInfo, err := client.NewQRInfo()
if err != nil {
t.Error(err)
}
qr, err := qrcode.New(qrInfo.URL, qrcode.Low)
if err != nil {
t.Error(err)
}
fmt.Println(qr.ToSmallString(false))
for {
qrStatus, sessdata, err := client.GetQRStatus(qrInfo.QrcodeKey)
fmt.Println(qrStatus.Message)
if err != nil {
t.Error(err)
}
if qrStatus.Code == bilibili.QR_SUCCESS {
fmt.Println("登录成功")
fmt.Println(sessdata)
break
} else if qrStatus.Code == bilibili.QR_EXPIRES {
break
}
time.Sleep(time.Second)
}
}
func TestGetBVInfo(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
videoInfo, err := client.GetVideoInfo(TEST_BVID)
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", videoInfo)
}
func TestGetSeasonInfo(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
seasonInfo, err := client.GetSeasonInfo(TEST_EPID, TEST_SSID)
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", seasonInfo)
}
func TestGetPlayInfo(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
playInfo, err := client.GetPlayInfo(TEST_BVID_HDR, TEST_CID_HDR)
if err != nil {
t.Error(err)
}
a, _ := json.Marshal(playInfo.AcceptQuality)
b, _ := json.Marshal(playInfo.AcceptDescription)
c, _ := json.Marshal(playInfo.SupportFormats)
fmt.Println(string(a))
fmt.Println(string(b))
fmt.Println(string(c))
}
func TestSaveSessdata(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
err := bilibili.SaveSessdata(db, TEST_SESSDATA)
if err != nil {
t.Error(err)
} else {
fmt.Println("SESSDATA 保存成功")
}
sessdata, err := bilibili.GetSessdata(db)
if err != nil {
t.Error(err)
}
if sessdata != TEST_SESSDATA {
t.Error("SESSDATA 读写不一致")
} else {
fmt.Println("SESSDATA 读取成功")
}
}
func TestGetSessdata(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil {
t.Error(err)
}
fmt.Println(sessdata)
}
func TestGetPopularVideos(t *testing.T) {
client := bilibili.BiliClient{SESSDATA: TEST_SESSDATA}
videos, err := client.GetPopularVideos()
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", videos)
}
+177
View File
@@ -0,0 +1,177 @@
package bilibili
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/url"
"regexp"
"bilidown/util"
)
type BiliClient struct {
SESSDATA string
}
// SimpleGET 简单的 GET 请求
func (client *BiliClient) SimpleGET(_url string, params map[string]string) (*http.Response, error) {
values := url.Values{}
for k, v := range params {
values.Set(k, v)
}
_client := http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(nil),
},
}
request, err := http.NewRequest("GET", _url+"?"+values.Encode(), nil)
if err != nil {
return nil, err
}
request.Header = client.MakeHeader()
return _client.Do(request)
}
// MakeHeader 生成请求头
func (client *BiliClient) MakeHeader() http.Header {
header := http.Header{}
header.Set("Cookie", "SESSDATA="+client.SESSDATA)
header.Set("User-Agent", "Mozilla/5.0")
header.Set("Referer", "https://www.bilibili.com")
return header
}
// CheckLogin 检查是否已经登录
func (client *BiliClient) CheckLogin() (bool, error) {
response, err := client.SimpleGET("https://api.bilibili.com/x/space/myinfo", nil)
if err != nil {
return false, err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return false, err
}
if body.Code != 0 {
return false, errors.New(body.Message)
}
return body.Success(), nil
}
// NewQRInfo 获取登录二维码信息
func (client *BiliClient) NewQRInfo() (*QRInfo, error) {
response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/generate", nil)
if err != nil {
return nil, err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
qrInfo := QRInfo{}
err = json.Unmarshal(body.Data, &qrInfo)
if err != nil {
return nil, err
}
return &qrInfo, nil
}
func (client *BiliClient) getWbiKeyRemote() (wbiKey string, err error) {
if client.SESSDATA == "" {
return "", errors.New("SESSDATA 不能为空")
}
response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/nav", nil)
if err != nil {
return "", err
}
defer response.Body.Close()
body := BaseResV2{}
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
return "", err
}
var data struct {
WbiImg struct {
ImgURL string `json:"img_url"`
SubURL string `json:"sub_url"`
} `json:"wbi_img"`
}
if err = json.Unmarshal(body.Data, &data); err != nil {
return "", err
}
match := regexp.MustCompile(`/bfs/wbi/([a-z0-9]+)\.`)
imgKey := match.FindStringSubmatch(data.WbiImg.ImgURL)[1]
subKey := match.FindStringSubmatch(data.WbiImg.SubURL)[1]
if imgKey == "" || subKey == "" {
return "", errors.New("regexp.MustCompile(`/bfs/wbi/([a-z0-9])\\.`)")
}
return imgKey + subKey, nil
}
// GetQRStatus 获取二维码状态
func (client *BiliClient) GetQRStatus(qrKey string) (qrStatus *QRStatus, sessdata string, err error) {
params := map[string]string{
"qrcode_key": qrKey,
}
response, err := client.SimpleGET("https://passport.bilibili.com/x/passport-login/web/qrcode/poll", params)
if err != nil {
return nil, "", err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, "", err
}
if body.Code != 0 {
return nil, "", errors.New(body.Message)
}
qrStatus = &QRStatus{}
err = json.Unmarshal(body.Data, &qrStatus)
if err != nil {
return nil, "", err
}
if qrStatus.Code != 0 {
return qrStatus, "", nil
}
sessdata, err = GetCookieValue(response.Cookies(), "SESSDATA")
if err != nil {
return nil, "", err
}
return qrStatus, sessdata, nil
}
// GetCookieValue 获取指定 Name 的 Cookie 值
func GetCookieValue(cookies []*http.Cookie, name string) (string, error) {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie.Value, nil
}
}
return "", errors.New("cookie with name " + name + " not found")
}
// SaveSessdata 保存 SESSDATA
func SaveSessdata(db *sql.DB, sessdata string) error {
util.SqliteLock.Lock()
_, err := db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ("sessdata", ?)`, sessdata)
util.SqliteLock.Unlock()
return err
}
// GetSessdata 获取 SESSDATA
func GetSessdata(db *sql.DB) (string, error) {
util.SqliteLock.Lock()
row := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = "sessdata"`)
util.SqliteLock.Unlock()
var sessdata string
err := row.Scan(&sessdata)
return sessdata, err
}
+253
View File
@@ -0,0 +1,253 @@
package bilibili
import (
"encoding/json"
"bilidown/common"
)
// BaseRes 来自 Bilibili 的接口响应,Message 字段为 msg
type BaseRes struct {
Code int `json:"code"`
Message string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// BaseRes2 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msg
type BaseResV2 struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// BaseResV3 来自 Bilibili 的接口响应,Message 字段为 message 而不是 msgData 字段为 result
type BaseResV3 struct {
Code int `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
func (b *BaseRes) Success() bool {
return b.Code == 0
}
func (b *BaseResV2) Success() bool {
return b.Code == 0
}
func (b *BaseResV3) Success() bool {
return b.Code == 0
}
type QRInfo struct {
URL string `json:"url"`
QrcodeKey string `json:"qrcode_key"`
}
type QRStatus struct {
URL string `json:"string"`
RefreshToken string `json:"refresh_token"`
Code int `json:"code"`
Message string `json:"message"`
}
const (
QR_NO_SCAN = 86101 // 未扫码
QR_NO_CLICK = 86090 // 已扫码未确认
QR_EXPIRES = 86038 // 已过期
QR_SUCCESS = 0 // 已确认登录
)
// Dimension 视频分辨率
type Dimension struct {
Width int `json:"width"`
Height int `json:"height"`
Rotate int `json:"rotate"`
}
// StaffItem 视频人员
type StaffItem struct {
Mid int `json:"mid"`
Title string `json:"title"`
Name string `json:"name"`
Face string `json:"face"`
}
// Page 分集信息
type Page struct {
// 配合 Bvid 用于获取播放地址
Cid int `json:"cid"`
// 当前分集在合集中的序号,从 1 开始
Page int `json:"page"`
// 分集标题
Part string `json:"part"`
// 分集时长
Duration int `json:"duration"`
// 分集分辨率
Dimension `json:"dimension"`
}
// 通过 BVID 获取的视频信息
type VideoInfo struct {
Bvid string `json:"bvid"`
Aid int `json:"aid"`
Pic string `json:"pic"`
Title string `json:"title"`
Pubdate int `json:"pubdate"`
Desc string `json:"desc"`
Owner struct {
Mid int `json:"mid"`
Name string `json:"name"`
Face string `json:"face"`
} `json:"owner"`
Dimension `json:"dimension"`
Staff []StaffItem `json:"staff"`
Pages []Page `json:"pages"`
Duration int `json:"duration"`
Stat struct {
Aid int `json:"aid"`
View int `json:"view"` // 播放数量
Danmaku int `json:"danmaku"` // 弹幕数量
Reply int `json:"reply"` // 评论数量
Favorite int `json:"favorite"` // 收藏数量
Coin int `json:"coin"` // 投币数量
Share int `json:"share"` // 转发数量
NowRank int `json:"now_rank"` // 当前排名
HisRank int `json:"his_rank"` // 历史最高排名
Like int `json:"like"` // 点赞数量
Dislike int `json:"dislike"` // 不喜欢
} `json:"stat"`
UgcSeason struct {
Sections []struct {
Title string `json:"title"`
Episodes []struct {
Title string `json:"title"`
Pages []Page `json:"pages"`
Bvid string `json:"bvid"`
} `json:"episodes"`
} `json:"sections"`
Title string `json:"title"`
} `json:"ugc_season"`
}
// EpisodeInBV 通过 BV 获取的视频信息中的合集信息
type EpisodeInBV struct {
Aid int `json:"aid"`
Bvid string `json:"bvid"`
Cid int `json:"cid"`
Pic string `json:"pic"` // 封面
Dimension `json:"dimension"` // 分辨率
Duration int `json:"duration"` // 时长
EPID int `json:"ep_id"`
LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】
PubTime int `json:"pub_time"` // 发布时间
Title string `json:"title"` // 分集简略标题,比如【1】
}
// Episode 剧集分集信息
type Episode struct {
Aid int `json:"aid"`
Bvid string `json:"bvid"`
Cid int `json:"cid"`
Cover string `json:"cover"` // 封面
Dimension `json:"dimension"` // 分辨率
Duration int `json:"duration"` // 时长
EPID int `json:"ep_id"`
LongTitle string `json:"long_title"` // 分集完整标题,比如【法外狂徒张三现身!】
PubTime int `json:"pub_time"` // 发布时间
Title string `json:"title"` // 分集简略标题,比如【1】
}
// 通过 EPID 获取的视频信息
type SeasonInfo struct {
Actors string `json:"actors"` // 演员名单
Areas []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"areas"` // 地区列表
Cover string `json:"cover"` // 封面
Evaluate string `json:"evaluate"` // 简介
Publish struct {
IsFinish int `json:"is_finish"` // 是否完结
PubTime string `json:"pub_time"` // 发布时间
} `json:"publish"`
SeasonID int `json:"season_id"` // 剧集编号
SeasonTitle string `json:"season_title"` // 剧集标题
Stat struct {
Coins int `json:"coins"` // 投币数量
Danmakus int `json:"danmakus"` // 弹幕数量
Favorite int `json:"favorite"` // 收藏数量
Favorites int `json:"favorites"` // 追剧数量
Likes int `json:"likes"` // 点赞数量
Reply int `json:"reply"` // 评论数量
Share int `json:"share"` // 分享数量
Views int `json:"views"` // 播放数量
} `json:"stat"`
Styles []string `json:"styles"` // 剧集内容类型,例如 [ "短剧", "奇幻", "搞笑" ]
Title string `json:"title"` // 剧集标题
Total int `json:"total"` // 总集数
Episodes []Episode `json:"episodes"` // 分集信息列表
NewEp struct {
Desc string `json:"desc"` // 更新状态文本
IsNew int `json:"is_new"` // 是否是连载,0 为完结,1 为连载
} `json:"new_ep"`
Section []struct {
Title string `json:"title"`
Episodes []Episode `json:"episodes"`
} `json:"section"`
}
type PlayInfo struct {
AcceptDescription []string `json:"accept_description"`
AcceptQuality []common.MediaFormat `json:"accept_quality"`
SupportFormats []struct {
Quality common.MediaFormat `json:"quality"`
Format string `json:"format"`
NewDescription string `json:"new_description"`
Codecs []string `json:"codecs"`
} `json:"support_formats"`
Dash *Dash `json:"dash"`
}
type Dash struct {
// 视频时长(秒)
Duration int `json:"duration"`
Video []Media `json:"video"`
Audio []Media `json:"audio"`
Flac *struct {
Audio Media `json:"audio"`
} `json:"flac"`
}
type Media struct {
ID common.MediaFormat `json:"id"`
BaseURL string `json:"baseUrl"`
BackupURL []string `json:"backupUrl"`
Bandwidth int `json:"bandwidth"`
MimeType string `json:"mimeType"`
Codecs string `json:"codecs"`
Width int `json:"width"`
Height int `json:"height"`
FrameRate string `json:"frameRate"`
Codecid int `json:"codecid"`
}
type FavList []struct {
Title string `json:"title"`
Cover string `json:"cover"`
Intro string `json:"intro"`
Duration int `json:"duration"`
Upper struct {
Mid int `json:"mid"`
Name string `json:"name"`
Face string `json:"face"`
} `json:"upper"`
PubTime int `json:"pubtime"`
Bvid string `json:"bvid"`
Ugc struct {
FirstCid int `json:"first_cid"`
} `json:"ugc"`
}
+228
View File
@@ -0,0 +1,228 @@
package bilibili
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
)
// GetBVInfo 根据 BVID 获取视频信息
func (client *BiliClient) GetVideoInfo(bvid string) (*VideoInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
params := map[string]string{"bvid": bvid}
response, err := client.SimpleGET("https://api.bilibili.com/x/web-interface/wbi/view", params)
if err != nil {
return nil, err
}
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
bvInfo := VideoInfo{}
err = json.Unmarshal(body.Data, &bvInfo)
if err != nil {
return nil, err
}
return &bvInfo, nil
}
// GetSeasonInfo 根据 EPID 或 SSID 获取剧集信息
func (client *BiliClient) GetSeasonInfo(epid int, ssid int) (*SeasonInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
params := map[string]string{
"ep_id": strconv.Itoa(epid),
"season_id": strconv.Itoa(ssid),
}
response, err := client.SimpleGET("https://api.bilibili.com/pgc/view/web/season", params)
if err != nil {
return nil, err
}
body := BaseResV3{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
seasonInfo := SeasonInfo{}
err = json.Unmarshal(body.Result, &seasonInfo)
if err != nil {
return nil, err
}
return &seasonInfo, nil
}
// GetPlayInfo 根据 BVID 和 CID 获取视频播放信息
func (client *BiliClient) GetPlayInfo(bvid string, cid int) (*PlayInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
params := map[string]string{
"bvid": bvid,
"cid": strconv.Itoa(cid),
"fnval": "4048",
"fnver": "0",
"fourk": "1",
}
response, err := client.SimpleGET("https://api.bilibili.com/x/player/playurl", params)
if err != nil {
return nil, err
}
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
playInfo := PlayInfo{}
err = json.Unmarshal(body.Data, &playInfo)
if err != nil {
return nil, err
}
return &playInfo, nil
}
func (client *BiliClient) GetPopularVideos() ([]VideoInfo, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
urls := []string{
"https://api.bilibili.com/x/web-interface/popular",
"https://api.bilibili.com/x/web-interface/popular/precious",
"https://api.bilibili.com/x/web-interface/ranking/v2",
}
response, err := client.SimpleGET(urls[rand.Intn(len(urls))], nil)
if err != nil {
return nil, err
}
defer response.Body.Close()
body := BaseResV2{}
err = json.NewDecoder(response.Body).Decode(&body)
if err != nil {
return nil, err
}
if body.Code != 0 {
return nil, errors.New(body.Message)
}
data := struct {
List []VideoInfo `json:"list"`
}{}
err = json.Unmarshal(body.Data, &data)
if err != nil {
return nil, err
}
return data.List, nil
}
// GetSeasonsArchivesList 获取合集中的第一个视频的 BVID
func (client *BiliClient) GetSeasonsArchivesListFirstBvid(mid int, seasonId int) (string, error) {
url := "https://api.bilibili.com/x/polymer/web-space/seasons_archives_list"
params := map[string]string{
"mid": strconv.Itoa(mid),
"season_id": strconv.Itoa(seasonId),
"page_num": "1",
"page_size": "1",
}
response, err := client.SimpleGET(url, params)
if err != nil {
return "", err
}
defer response.Body.Close()
body := BaseResV2{}
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
return "", err
}
if body.Code != 0 {
return "", errors.New(body.Message)
}
var data struct {
Archives []struct {
Bvid string `json:"bvid"`
} `json:"archives"`
}
if err = json.Unmarshal(body.Data, &data); err != nil {
return "", nil
}
if len(data.Archives) == 0 {
return "", errors.New("视频列表为空")
}
return data.Archives[0].Bvid, nil
}
func (client *BiliClient) GetFavlist(mediaId int) (*FavList, error) {
if client.SESSDATA == "" {
return nil, errors.New("SESSDATA 不能为空")
}
page := 0
retry := 0
allFavList := FavList{}
for {
favList, hasMore, err := client.GetFavlistByPage(mediaId, page, 40)
if err != nil {
fmt.Println(err.Error())
if retry == 5 || strings.HasPrefix(err.Error(), "body.Code not 0") {
return nil, err
}
retry++
continue
}
allFavList = append(allFavList, *favList...)
if !hasMore {
break
}
page++
}
return &allFavList, nil
}
func (client *BiliClient) GetFavlistByPage(mediaId int, page int, pageSize int) (favlist *FavList, hasMore bool, err error) {
if client.SESSDATA == "" {
return nil, false, errors.New("SESSDATA 不能为空")
}
response, err := client.SimpleGET("https://api.bilibili.com/x/v3/fav/resource/list", map[string]string{
"media_id": strconv.Itoa(mediaId),
"pn": strconv.Itoa(page + 1),
"ps": strconv.Itoa(pageSize),
"order": "mtime",
"type": "0",
"tid": "0",
"platform": "web",
})
if err != nil {
return nil, false, err
}
defer response.Body.Close()
body := BaseResV2{}
if err = json.NewDecoder(response.Body).Decode(&body); err != nil {
return nil, false, err
}
if body.Code != 0 {
return nil, false, fmt.Errorf("body.Code not 0, %s", body.Message)
}
data := struct {
Medias FavList `json:"medias"`
HasMore bool `json:"has_more"`
}{}
if err = json.Unmarshal(body.Data, &data); err != nil {
return nil, false, err
}
return &data.Medias, data.HasMore, nil
}
+87
View File
@@ -0,0 +1,87 @@
package bilibili
import (
"bilidown/util"
"database/sql"
"net/url"
"strconv"
"strings"
"time"
)
var MIXIN_KEY_ENC_TAB = []int{
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
}
// GetWebKey 获取最新可用的 WbiKey,自动刷新缓存
func (client *BiliClient) getWbiKey(db *sql.DB) (wbiKey string, err error) {
wbiKey, err = getWbiKeyFromDB(db)
if err != nil {
return "", err
}
if wbiKey == "" {
// 更新数据库中的 WebKey
wbiKey, err = client.getWbiKeyRemote()
if err != nil {
return "", err
}
if err = saveWbiKey(db, wbiKey); err != nil {
return "", err
}
}
return wbiKey, nil
}
func (client *BiliClient) GetMixinKey(db *sql.DB) (string, error) {
wbiKey, err := client.getWbiKey(db)
if err != nil {
return "", err
}
var result string
for _, index := range MIXIN_KEY_ENC_TAB {
result += string(wbiKey[index])
}
return result[:32], nil
}
func WbiSign(params map[string]string, mixinKey string) url.Values {
values := url.Values{}
for key, value := range params {
values.Set(key, value)
}
wts := strconv.FormatInt(time.Now().Unix(), 10)
values.Set("wts", wts)
encodeStr := strings.ReplaceAll(values.Encode(), "+", "%20") + mixinKey
w_rid := util.MD5Hash(encodeStr)
values.Set("w_rid", w_rid)
return values
}
// GetWbiKey 从数据库中获取 wbiKey,如果数据库中不存在记录或记录过期,则返回空字符串但不返回错误
func getWbiKeyFromDB(db *sql.DB) (wbiKey string, err error) {
fields, err := util.GetFields(db, "wbi_key", "wbi_key_update_at")
if err != nil {
return "", err
}
// 获取上次更新时间的时间戳,单位是秒
updateAt, err := strconv.ParseInt(fields["wbi_key_update_at"], 10, 64)
if err != nil {
return "", nil
}
// 注意 key_update_at 单位是秒,判断上次刷新时间是否超过 1 天
if time.Now().Unix()-updateAt > 24*60*60 {
return "", nil
}
return fields["wbi_key"], nil
}
// SaveWbiKey 保存 imgKey 和 subKey 到数据库
func saveWbiKey(db *sql.DB, wbiKey string) error {
return util.SaveFields(db, [][2]string{
{"wbi_key", wbiKey},
{"wbi_key_update_at", strconv.FormatInt(time.Now().Unix(), 10)},
})
}
+71
View File
@@ -0,0 +1,71 @@
package bilibili
import (
"bilidown/util"
"fmt"
"net/url"
"strings"
"testing"
"time"
)
func TestGetWbiKey(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
sessdata, err := GetSessdata(db)
if err != nil {
t.Error(err)
}
client := BiliClient{SESSDATA: sessdata}
mixinKey, err := client.GetMixinKey(db)
if err != nil {
t.Error(err)
}
fmt.Println(mixinKey)
}
func TestTime(t *testing.T) {
fmt.Println(time.Now().Unix())
}
func TestURLEncode(t *testing.T) {
str := url.Values{
"foo": {"one one four"},
"bar": {"五一四"},
"baz": {"1919810"},
}.Encode()
str = strings.ReplaceAll(str, "+", "%20")
fmt.Println(str)
}
func TestWbiSign(t *testing.T) {
db := util.MustGetDB("../data.db")
defer db.Close()
sessdata, err := GetSessdata(db)
if err != nil {
t.Error(err)
}
client := BiliClient{SESSDATA: sessdata}
mixinKey, err := client.GetMixinKey(db)
if err != nil {
t.Error(err)
}
newParams := WbiSign(map[string]string{
"dm_cover_img_str": "QU5HTEUgKEludGVsLCBJbnRlbChSKSBJcmlzKFIpIFhlIEdyYXBoaWNzICgweDAwMDA5QTQ5KSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChJbnRlbC",
"dm_img_inter": `{"ds":[{"t":10,"c":"YmUtcGFnZXItaXRlbSBiZS1wYWdlci1pdGVtLWFjdGl2ZQ","p":[330,110,110],"s":[179,179,358]}],"wh":[5762,6704,10],"of":[318,636,318]}`,
"dm_img_list": `[{"x":1847,"y":-1616,"z":0,"timestamp":2486463,"k":70,"type":0},{"x":1972,"y":-1005,"z":24,"timestamp":2486563,"k":120,"type":0},{"x":2564,"y":-638,"z":72,"timestamp":2486665,"k":107,"type":0},{"x":4200,"y":-753,"z":199,"timestamp":2486765,"k":91,"type":0},{"x":4922,"y":-73,"z":426,"timestamp":2486865,"k":106,"type":0},{"x":4656,"y":-512,"z":35,"timestamp":2486965,"k":94,"type":0},{"x":4942,"y":-590,"z":79,"timestamp":2487066,"k":126,"type":0},{"x":5200,"y":-414,"z":123,"timestamp":2487167,"k":60,"type":0},{"x":4916,"y":-820,"z":228,"timestamp":2563922,"k":111,"type":0},{"x":5004,"y":-50,"z":570,"timestamp":2564022,"k":110,"type":0},{"x":4784,"y":330,"z":666,"timestamp":2564122,"k":98,"type":0},{"x":5029,"y":767,"z":956,"timestamp":2564223,"k":115,"type":0},{"x":5327,"y":1400,"z":1307,"timestamp":2564324,"k":107,"type":0},{"x":4598,"y":671,"z":578,"timestamp":2564424,"k":125,"type":0},{"x":5228,"y":1283,"z":1193,"timestamp":2564525,"k":83,"type":0},{"x":5210,"y":932,"z":978,"timestamp":2564625,"k":66,"type":0},{"x":5034,"y":195,"z":484,"timestamp":2564725,"k":112,"type":0},{"x":6496,"y":1467,"z":1849,"timestamp":2564825,"k":83,"type":0},{"x":6592,"y":1387,"z":1852,"timestamp":2564929,"k":122,"type":0},{"x":6001,"y":728,"z":1235,"timestamp":2565030,"k":95,"type":0},{"x":6666,"y":1393,"z":1900,"timestamp":2627309,"k":121,"type":1},{"x":5486,"y":5603,"z":2283,"timestamp":2627842,"k":102,"type":0},{"x":5269,"y":4186,"z":1733,"timestamp":2627947,"k":117,"type":0},{"x":4439,"y":3174,"z":897,"timestamp":2628056,"k":117,"type":0},{"x":5231,"y":3959,"z":1687,"timestamp":2628268,"k":79,"type":0},{"x":6197,"y":4786,"z":2587,"timestamp":2628368,"k":96,"type":0},{"x":4667,"y":3298,"z":724,"timestamp":2628470,"k":91,"type":0},{"x":4658,"y":3436,"z":205,"timestamp":2629433,"k":88,"type":0},{"x":5380,"y":3443,"z":1117,"timestamp":2630390,"k":121,"type":0},{"x":5559,"y":2678,"z":1920,"timestamp":2630492,"k":116,"type":0},{"x":5517,"y":2465,"z":2023,"timestamp":2630634,"k":86,"type":0},{"x":6584,"y":3533,"z":3087,"timestamp":2630782,"k":96,"type":0},{"x":6183,"y":3242,"z":2839,"timestamp":2630892,"k":103,"type":0},{"x":6277,"y":580,"z":1449,"timestamp":2663911,"k":68,"type":0},{"x":7556,"y":3077,"z":3053,"timestamp":2664013,"k":114,"type":0},{"x":5321,"y":1642,"z":1201,"timestamp":2664114,"k":104,"type":0},{"x":5839,"y":2261,"z":1761,"timestamp":2664215,"k":72,"type":0},{"x":6303,"y":2726,"z":2222,"timestamp":2664317,"k":93,"type":0},{"x":7553,"y":4255,"z":3532,"timestamp":2664420,"k":104,"type":0},{"x":7677,"y":4381,"z":3650,"timestamp":2664521,"k":119,"type":0},{"x":8184,"y":4762,"z":4098,"timestamp":2664622,"k":122,"type":0},{"x":4555,"y":740,"z":406,"timestamp":2664722,"k":118,"type":0},{"x":4305,"y":241,"z":75,"timestamp":2664822,"k":105,"type":0},{"x":6373,"y":2218,"z":2117,"timestamp":2664934,"k":110,"type":0},{"x":6079,"y":1757,"z":1680,"timestamp":2665036,"k":90,"type":0},{"x":8158,"y":3462,"z":3524,"timestamp":2665136,"k":100,"type":0},{"x":9312,"y":4205,"z":4531,"timestamp":2665236,"k":124,"type":0},{"x":5306,"y":89,"z":487,"timestamp":2665337,"k":89,"type":0},{"x":9721,"y":4399,"z":4941,"timestamp":2665437,"k":100,"type":0},{"x":9058,"y":3727,"z":4282,"timestamp":2665540,"k":107,"type":0}]`,
"dm_img_str": "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ",
"keyword": "",
"mid": "596866446",
"order": "pubdate",
"order_avoided": "true",
"platform": "web",
"pn": "3",
"ps": "30",
"tid": "0",
"w_webid": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzcG1faWQiOiIwLjAiLCJidXZpZCI6IjBGRTlGRDMxLTZGQTQtRjU2RC1GQjEwLUI4OTg2ODUyRUZDNzUwMDUyaW5mb2MiLCJ1c2VyX2FnZW50IjoiTW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzEzMS4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xMzEuMC4wLjAiLCJidXZpZF9mcCI6IjY3YzZkNzg2NTgyYzQ2OGYwNjRiZTE5MTk2OWU4ZWE4IiwiYmlsaV90aWNrZXQiOiI4ODA0MDgzYmYzZGQ1NGNkNzI4OWZjZDE0MmIwN2U4ZCIsImNyZWF0ZWRfYXQiOjE3MzI0NzE3MTIsInR0bCI6ODY0MDAsInVybCI6Ii81OTY4NjY0NDYvdmlkZW8_dGlkPTBcdTAwMjZwbj0yXHUwMDI2a2V5d29yZD1cdTAwMjZvcmRlcj1wdWJkYXRlIiwicmVzdWx0Ijoibm9ybWFsIiwiaXNzIjoiZ2FpYSIsImlhdCI6MTczMjQ3MTcxMn0.pTVyhdRcB2VFcXqeYoi3TjnlEFLAXMpNNJK-dW9Gq3vqBL4YldGgZBuGBXXF7Ldwg_vW6TQg6pQCQ7vz357ws2Z9g2-kLIuNmR3j8oMg2zAXAND1q5oJNw0jNnevhLlB8_vOcip0eIJSHRjqbPbNShKLOcSnSfLaiI64EHwjRFAOEPYVw3evLXKB4TFnxSRi1WDSI684TfNrXp0_2yTJvuPheHQmQC1NcUP_P9tqTuRiDy3YfkuR8PlRcQxmKHVs-byObL5WEPqMMQa8b8zidRtzEkbGV7ra8gTgv1HwgpSDi_Y1VNsX2WtNcBPTSwURWc7zREf-RJqruzgcf1ErpA",
"web_location": "1550101",
}, mixinKey)
fmt.Println(newParams.Encode())
}
+14
View File
@@ -0,0 +1,14 @@
package common
import (
"crypto/rand"
"fmt"
)
func RandomString(length int) string {
randomBytes := make([]byte, length)
rand.Read(randomBytes)
return fmt.Sprintf("%x", randomBytes)[:length]
}
type MediaFormat int
+41
View File
@@ -0,0 +1,41 @@
module bilidown
go 1.23.0
require (
github.com/getlantern/systray v1.2.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
modernc.org/sqlite v1.33.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
github.com/getlantern/errors v1.0.4 // indirect
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 // indirect
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc // indirect
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 // indirect
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/sys v0.26.0 // indirect
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+164
View File
@@ -0,0 +1,164 @@
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 h1:oEZYEpZo28Wdx+5FZo4aU7JFXu0WG/4wJWese5reQSA=
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201/go.mod h1:Y9WZUHEb+mpra02CbQ/QczLUe6f0Dezxaw5DCJlJQGo=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.1/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/errors v1.0.4 h1:i2iR1M9GKj4WuingpNqJ+XQEw6i6dnAgKAmLj6ZB3X0=
github.com/getlantern/errors v1.0.4/go.mod h1:/Foq8jtSDGP8GOXzAjeslsC4Ar/3kB+UiQH+WyV4pzY=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65 h1:NlQedYmPI3pRAXJb+hLVVDGqfvvXGRPV8vp7XOjKAZ0=
github.com/getlantern/golog v0.0.0-20230503153817-8e72de7e0a65/go.mod h1:+ZU1h+iOVqWReBpky6d5Y2WL0sF2Llxu+QcxJFs2+OU=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc h1:sue+aeVx7JF5v36H1HfvcGFImLpSD5goj8d+MitovDU=
github.com/getlantern/hex v0.0.0-20220104173244-ad7e4b9194dc/go.mod h1:D9RWpXy/EFPYxiKUURo2TB8UBosbqkiLhttRrZYtvqM=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770 h1:cSrD9ryDfTV2yaur9Qk3rHYD414j3Q1rl7+L0AylxrE=
github.com/getlantern/hidden v0.0.0-20220104173330-f221c5a24770/go.mod h1:GOQsoDnEHl6ZmNIL+5uVo+JWRFWozMEp18Izcb++H+A=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/otel v1.9.0/go.mod h1:np4EoPGzoPs3O67xUVNoPPcmSvsfOxNlNA4F4AC+0Eo=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.9.0/go.mod h1:2737Q0MuG8q1uILYm2YYVkAyLtOofiTNGg6VODnOiPo=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+226
View File
@@ -0,0 +1,226 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"runtime"
"time"
"bilidown/router"
"bilidown/util"
"github.com/getlantern/systray"
_ "modernc.org/sqlite"
)
const (
HTTP_PORT = 8098 // 限定 HTTP 服务器端口
HTTP_HOST = "" // 限定 HTTP 服务器主机
VERSION = "v2.1.1" // 软件版本号,将影响托盘标题显示
)
var urlLocal = fmt.Sprintf("http://127.0.0.1:%d", HTTP_PORT)
var urlLocalUnix = fmt.Sprintf("%s?___%d", urlLocal, time.Now().UnixMilli())
func main() {
checkFFmpeg()
// 启动托盘程序
systray.Run(onReady, nil)
}
func onReady() {
// 设置托盘图标
setIcon()
// 设置托盘标题
setTitle()
// 设置托盘菜单
setMenuItem()
// 初始化数据表
mustInitTables()
// 配置和启动 HTTP 服务器
mustRunServer()
// 调用默认浏览器访问端口
time.Sleep(time.Millisecond * 1000)
openBrowser(urlLocalUnix)
// 保持运行
select {}
}
// checkFFmpeg 检测 ffmpeg 的安装情况,如果未安装则打印提示信息。
func checkFFmpeg() {
if _, err := util.GetFFmpegPath(); err != nil {
fmt.Println("🚨 FFmpeg is missing. Install it from https://www.ffmpeg.org/download.html or place it in ./bin, then restart the application.")
select {}
}
}
// 配置和启动 HTTP 服务器
func mustRunServer() {
// 前端打包文件
http.Handle("/", http.FileServer(http.Dir("static")))
// 后端接口服务
http.Handle("/api/", http.StripPrefix("/api", router.API()))
// 启动 HTTP 服务器
go func() {
err := http.ListenAndServe(fmt.Sprintf("%s:%d", HTTP_HOST, HTTP_PORT), nil)
if err != nil {
log.Fatal("http.ListenAndServe:", err)
}
}()
}
// openBrowser 调用系统默认浏览器打开指定 URL
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
default:
log.Printf("openBrowser: %v.", errors.New("unsupported operating system"))
}
if err := cmd.Start(); err != nil {
log.Printf("openBrowser: %v.", err)
}
fmt.Printf("Opened in default browser: %s.\n", url)
}
// setIcon 设置托盘图标
func setIcon() {
var path string
if runtime.GOOS == "windows" {
path = "static/favicon.ico"
} else {
path = "static/favicon-32x32.png"
}
systray.SetIcon(mustReadFile(path))
}
// mustReadFile 返回文件字节内容
func mustReadFile(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
log.Fatalln("os.ReadFile:", err)
}
return data
}
// setTitle 设置托盘标题和工具提示
func setTitle() {
title := "Bilidown"
tooltip := fmt.Sprintf("%s 视频解析器 %s (port:%d)", title, VERSION, HTTP_PORT)
// only available on Mac and Windows.
systray.SetTooltip(tooltip)
}
// setMenuItem 设置托盘菜单
func setMenuItem() {
openBrowserItemText := fmt.Sprintf("打开主界面 (port:%d)", HTTP_PORT)
openBrowserItem := systray.AddMenuItem(openBrowserItemText, openBrowserItemText)
go func() {
for {
<-openBrowserItem.ClickedCh
openBrowser(urlLocalUnix)
}
}()
aboutItemText := "Github 项目主页"
aboutItem := systray.AddMenuItem(aboutItemText, aboutItemText)
go func() {
for {
<-aboutItem.ClickedCh
openBrowser("https://github.com/iuroc/bilidown")
}
}()
exitItemText := "退出应用"
exitItem := systray.AddMenuItem(exitItemText, exitItemText)
go func() {
<-exitItem.ClickedCh
log.Printf("Bilidown has exited.")
systray.Quit()
}()
}
// mustInitTables 初始化数据表
func mustInitTables() {
db := util.MustGetDB()
defer db.Close()
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "field" (
"name" TEXT PRIMARY KEY NOT NULL,
"value" TEXT
)`); err != nil {
log.Fatalln("create table field:", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "log" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"content" TEXT NOT NULL,
"create_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
)`); err != nil {
log.Fatalln("create table log:", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS "task" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"bvid" text NOT NULL,
"cid" integer NOT NULL,
"format" integer NOT NULL,
"title" text NOT NULL,
"owner" text NOT NULL,
"cover" text NOT NULL,
"status" text NOT NULL,
"folder" text NOT NULL,
"duration" integer NOT NULL,
"download_type" text NOT NULL DEFAULT 'merge',
"create_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
)`); err != nil {
log.Fatalln("create table task:", err)
}
if _, err := util.GetCurrentFolder(db); err != nil {
log.Fatalln("util.GetCurrentFolder:", err)
}
if err := initHistoryTask(db); err != nil {
log.Fatalln("initHistoryTask:", err)
}
// 添加可能缺失的列(用于数据库迁移)
if err := addMissingColumns(db); err != nil {
log.Fatalln("addMissingColumns:", err)
}
}
// addMissingColumns 添加可能缺失的列(用于数据库迁移)
func addMissingColumns(db *sql.DB) error {
// 检查download_type列是否存在,如果不存在则添加
// SQLite没有直接的方法检查列是否存在,我们尝试添加列并忽略错误
// 使用事务确保操作原子性
util.SqliteLock.Lock()
_, _ = db.Exec(`ALTER TABLE "task" ADD COLUMN "download_type" TEXT DEFAULT 'merge'`)
// 将现有记录中的NULL值更新为默认值'merge'
_, _ = db.Exec(`UPDATE "task" SET "download_type" = 'merge' WHERE "download_type" IS NULL`)
util.SqliteLock.Unlock()
// 忽略错误,因为列可能已经存在
// SQLite错误码为1表示列已存在
return nil
}
// initHistoryTask 将上一次程序运行时未完成的任务进度全部变为 error
func initHistoryTask(db *sql.DB) error {
util.SqliteLock.Lock()
_, err := db.Exec(`UPDATE "task" SET "status" = 'error' WHERE "status" IN ('waiting', 'running')`)
util.SqliteLock.Unlock()
return err
}
+102
View File
@@ -0,0 +1,102 @@
package router
import (
"encoding/base64"
"net/http"
"bilidown/bilibili"
"bilidown/util"
"github.com/skip2/go-qrcode"
)
func getQRInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
client := bilibili.BiliClient{}
qrInfo, err := client.NewQRInfo()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
imageData, err := qrcode.Encode(qrInfo.URL, qrcode.Medium, 256)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
base64Str := base64.StdEncoding.EncodeToString(imageData)
util.Res{
Success: true,
Message: "获取成功",
Data: struct {
Key string `json:"key"`
Image string `json:"image"`
}{
Key: qrInfo.QrcodeKey,
Image: "data:image/png;base64," + base64Str,
}}.Write(w)
}
func checkLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
util.Res{Success: false, Message: "未登录"}.Write(w)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
check, err := client.CheckLogin()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
if check {
util.Res{Success: true, Message: "登录成功"}.Write(w)
} else {
util.Res{Success: false, Message: "登录失败"}.Write(w)
}
}
// getQRStatus 获取二维码状态
func getQRStatus(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
key := r.FormValue("key")
if key == "" {
util.Res{Success: false, Message: "key 不能为空"}.Write(w)
return
}
client := bilibili.BiliClient{}
qrStatus, sessdata, err := client.GetQRStatus(key)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
if qrStatus.Code != bilibili.QR_SUCCESS {
util.Res{Success: false, Message: qrStatus.Message}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
err = bilibili.SaveSessdata(db, sessdata)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "登录成功"}.Write(w)
}
func logout(w http.ResponseWriter, r *http.Request) {
db := util.MustGetDB()
defer db.Close()
err := bilibili.SaveSessdata(db, "")
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "退出成功"}.Write(w)
}
+120
View File
@@ -0,0 +1,120 @@
package router
import (
"encoding/json"
"fmt"
"net/http"
"os"
"bilidown/util"
"bilidown/util/res_error"
)
func API() *http.ServeMux {
router := http.NewServeMux()
router.HandleFunc("/getVideoInfo", getVideoInfo)
router.HandleFunc("/getSeasonInfo", getSeasonInfo)
router.HandleFunc("/getQRInfo", getQRInfo)
router.HandleFunc("/getQRStatus", getQRStatus)
router.HandleFunc("/checkLogin", checkLogin)
router.HandleFunc("/getPlayInfo", getPlayInfo)
router.HandleFunc("/createTask", createTask)
router.HandleFunc("/getActiveTask", getActiveTask)
router.HandleFunc("/getTaskList", getTaskList)
router.HandleFunc("/showFile", showFile)
router.HandleFunc("/getFields", getFields)
router.HandleFunc("/saveFields", saveFields)
router.HandleFunc("/logout", logout)
router.HandleFunc("/quit", quit)
router.HandleFunc("/getPopularVideos", getPopularVideos)
router.HandleFunc("/deleteTask", deleteTask)
router.HandleFunc("/getRedirectedLocation", getRedirectedLocation)
router.HandleFunc("/downloadVideo", downloadVideo)
router.HandleFunc("/getSeasonsArchivesListFirstBvid", getSeasonsArchivesListFirstBvid)
router.HandleFunc("/getFavList", getFavList)
return router
}
func getRedirectedLocation(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
res_error.Send(w, res_error.ParamError)
return
}
url := r.FormValue("url")
if !util.IsValidURL(url) {
res_error.Send(w, res_error.URLFormatError)
return
}
if location, err := util.GetRedirectedLocation(url); err != nil {
res_error.Send(w, res_error.NoLocationError)
return
} else {
util.Res{Success: true, Message: "获取成功", Data: location}.Write(w)
return
}
}
func quit(w http.ResponseWriter, r *http.Request) {
util.Res{Success: true, Message: "退出成功"}.Write(w)
go func() {
os.Exit(0)
}()
}
func getFields(w http.ResponseWriter, r *http.Request) {
db := util.MustGetDB()
defer db.Close()
fields, err := util.GetFields(db, util.FieldUtil{}.AllowSelect()...)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Data: fields}.Write(w)
}
func saveFields(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
util.Res{Success: false, Message: "不支持的请求方法"}.Write(w)
return
}
defer r.Body.Close()
var body [][2]string
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
fu := util.FieldUtil{}
for _, d := range body {
if !fu.IsAllowUpdate(d[0]) {
util.Res{Success: false, Message: fmt.Sprintf("字段 %s 不允许修改", d[0])}.Write(w)
return
}
if d[0] == "download_folder" {
if _, err := os.Stat(d[1]); os.IsNotExist(err) {
if err := os.MkdirAll(d[1], os.ModePerm); err != nil {
util.Res{Success: false, Message: fmt.Sprintf("目录创建失败:%s", d[1])}.Write(w)
return
}
} else if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("路径设置失败:%v", err)}.Write(w)
return
}
}
}
err = util.SaveFields(db, body)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "保存成功"}.Write(w)
}
+167
View File
@@ -0,0 +1,167 @@
package router
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"bilidown/task"
"bilidown/util"
)
func createTask(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if r.Method != http.MethodPost {
util.Res{Success: false, Message: "不支持的请求方法"}.Write(w)
return
}
var body []task.TaskInDB
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
for _, item := range body {
if !util.CheckBvidFormat(item.Bvid) {
util.Res{Success: false, Message: "bvid 格式错误"}.Write(w)
return
}
if item.Cover == "" || item.Title == "" || item.Owner == "" {
util.Res{Success: false, Message: "参数错误"}.Write(w)
}
if !util.IsValidURL(item.Cover) {
util.Res{Success: false, Message: "封面链接格式错误"}.Write(w)
return
}
if !util.IsValidURL(item.Audio) {
util.Res{Success: false, Message: "音频链接格式错误"}.Write(w)
return
}
if !util.IsValidURL(item.Video) {
util.Res{Success: false, Message: "视频链接格式错误"}.Write(w)
return
}
if !util.IsValidFormatCode(item.Format) {
util.Res{Success: false, Message: "清晰度代码错误"}.Write(w)
return
}
item.Folder, err = util.GetCurrentFolder(db)
item.Status = "waiting"
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("util.GetCurrentFolder: %v.", err)}.Write(w)
return
}
_task := task.Task{TaskInDB: item}
_task.Title = util.FilterFileName(_task.Title)
err = _task.Create(db)
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("_task.Create: %v.", err)}.Write(w)
return
}
go _task.Start()
}
util.Res{Success: true, Message: "创建成功"}.Write(w)
}
func getActiveTask(w http.ResponseWriter, r *http.Request) {
util.Res{Success: true, Data: task.GlobalTaskList}.Write(w)
}
func getTaskList(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
page, err := strconv.Atoi(r.FormValue("page"))
if err != nil {
page = 0
}
pageSize, err := strconv.Atoi(r.FormValue("pageSize"))
if err != nil {
pageSize = 360
}
tasks, err := task.GetTaskList(db, page, pageSize)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: tasks}.Write(w)
}
// showFile 调用 Explorer 查看文件位置
func showFile(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
filePath := r.FormValue("filePath")
var cmd *exec.Cmd
// 根据操作系统选择命令
switch runtime.GOOS {
case "windows":
// Windows 使用 explorer
cmd = exec.Command("explorer", "/select,", filePath)
case "darwin":
// macOS 使用 open
cmd = exec.Command("open", "-R", filePath)
case "linux":
// Linux 使用 xdg-open
cmd = exec.Command("xdg-open", filePath)
default:
util.Res{Success: false, Message: "不支持的操作系统"}.Write(w)
return
}
err := cmd.Start()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "操作成功"}.Write(w)
}
func deleteTask(w http.ResponseWriter, r *http.Request) {
taskIDStr := r.FormValue("id")
taskID, err := strconv.Atoi(taskIDStr)
if err != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
_task, err := task.GetTask(db, taskID)
if err == sql.ErrNoRows {
util.Res{Success: true, Message: "数据库中没有该条记录,所以本次操作被忽略,可以算作成功。"}.Write(w)
return
}
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("task.GetTask: %v", err)}.Write(w)
return
}
filePath := _task.FilePath()
err = os.Remove(filePath)
if err != nil && !os.IsNotExist(err) {
util.Res{Success: false, Message: fmt.Sprintf("文件删除失败 os.Remove: %v", err)}.Write(w)
return
}
err = task.DeleteTask(db, taskID)
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("task.DeleteTask: %v", err)}.Write(w)
return
}
util.Res{Success: true, Message: "删除成功"}.Write(w)
}
+182
View File
@@ -0,0 +1,182 @@
package router
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"bilidown/bilibili"
"bilidown/util"
"bilidown/util/res_error"
)
// getVideoInfo 通过 BV 号获取视频信息
func getVideoInfo(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
res_error.Send(w, res_error.ParamError)
return
}
bvid := r.FormValue("bvid")
if !util.CheckBvidFormat(bvid) {
res_error.Send(w, res_error.BvidFormatError)
return
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
videoInfo, err := client.GetVideoInfo(bvid)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: videoInfo}.Write(w)
}
// getSeasonInfo 通过 EP 号或 SS 号获取视频信息
func getSeasonInfo(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
var epid int
epid, err := strconv.Atoi(r.FormValue("epid"))
if r.FormValue("epid") != "" && err != nil {
util.Res{Success: false, Message: "epid 格式错误"}.Write(w)
return
}
var ssid int
if epid == 0 {
ssid, err = strconv.Atoi(r.FormValue("ssid"))
if r.FormValue("ssid") != "" && err != nil {
util.Res{Success: false, Message: "ssid 格式错误"}.Write(w)
return
}
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
seasonInfo, err := client.GetSeasonInfo(epid, ssid)
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: seasonInfo}.Write(w)
}
// getPlayInfo 通过 BVID 和 CID 获取视频播放信息
func getPlayInfo(w http.ResponseWriter, r *http.Request) {
if r.ParseForm() != nil {
util.Res{Success: false, Message: "参数错误"}.Write(w)
return
}
bvid := r.FormValue("bvid")
if !util.CheckBvidFormat(bvid) {
util.Res{Success: false, Message: "bvid 格式错误"}.Write(w)
return
}
cid, err := strconv.Atoi(r.FormValue("cid"))
if err != nil {
util.Res{Success: false, Message: "cid 格式错误"}.Write(w)
return
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
playInfo, err := client.GetPlayInfo(bvid, cid)
if err != nil {
util.Res{Success: false, Message: fmt.Sprintf("client.GetPlayInfo: %v", err)}.Write(w)
return
}
util.Res{Success: true, Message: "获取成功", Data: playInfo}.Write(w)
}
func getPopularVideos(w http.ResponseWriter, r *http.Request) {
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
videos, err := client.GetPopularVideos()
if err != nil {
util.Res{Success: false, Message: err.Error()}.Write(w)
return
}
bvidList := make([]string, 0)
for _, v := range videos {
bvidList = append(bvidList, v.Bvid)
}
util.Res{Success: true, Message: "获取成功", Data: bvidList}.Write(w)
}
var downloadVideo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
safePath := filepath.Clean(path)
safePath = strings.ReplaceAll(safePath, "\\", "/")
http.ServeFile(w, r, safePath)
})
var getSeasonsArchivesListFirstBvid = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var mid int
var seasonId int
var err error
if mid, err = strconv.Atoi(r.URL.Query().Get("mid")); err != nil {
res_error.Send(w, res_error.MidFormatError)
return
}
if seasonId, err = strconv.Atoi(r.URL.Query().Get("seasonId")); err != nil {
res_error.Send(w, res_error.SeasonIdFormatError)
return
}
client := bilibili.BiliClient{}
bvid, err := client.GetSeasonsArchivesListFirstBvid(mid, seasonId)
if err != nil {
res_error.Send(w, fmt.Sprintf("client.GetSeasonsArchivesList: %v", err))
return
}
util.Res{Success: true, Message: "获取成功", Data: bvid}.Write(w)
})
var getFavList = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mediaId, err := strconv.Atoi(r.URL.Query().Get("mediaId"))
if err != nil {
res_error.Send(w, res_error.ParamError)
return
}
db := util.MustGetDB()
defer db.Close()
sessdata, err := bilibili.GetSessdata(db)
if err != nil || sessdata == "" {
res_error.Send(w, res_error.NotLogin)
return
}
client := bilibili.BiliClient{SESSDATA: sessdata}
favList, err := client.GetFavlist(mediaId)
if err != nil {
res_error.Send(w, err.Error())
return
}
util.Res{Success: true, Message: "获取成功", Data: favList}.Write(w)
})
+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)
}
+151
View File
@@ -0,0 +1,151 @@
package util
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"sync"
)
func CreateLog(db *sql.DB, content string) error {
SqliteLock.Lock()
_, err := db.Exec(`INSERT INTO "log" ("content") VALUES (?)`, content)
SqliteLock.Unlock()
return err
}
func GetFields(db *sql.DB, names ...string) (map[string]string, error) {
if len(names) == 0 {
return nil, nil
}
placeholders := make([]string, len(names))
for i := 0; i < len(names); i++ {
placeholders[i] = "?"
}
query := fmt.Sprintf(`SELECT "name", "value" FROM "field" WHERE "name" IN (%s)`, strings.Join(placeholders, ","))
values := make([]interface{}, len(names))
for i := 0; i < len(names); i++ {
values[i] = names[i]
}
SqliteLock.Lock()
row, err := db.Query(query, values...)
SqliteLock.Unlock()
if err != nil {
return nil, err
}
defer row.Close()
var name, value string
fields := make(map[string]string)
for row.Next() {
if err := row.Scan(&name, &value); err != nil {
return nil, err
}
fields[name] = value
}
return fields, nil
}
func SaveFields(db *sql.DB, data [][2]string) error {
if len(data) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
stmt, err := tx.Prepare(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES (?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, d := range data {
SqliteLock.Lock()
_, err = stmt.Exec(d[0], d[1])
SqliteLock.Unlock()
if err != nil {
return err
}
}
return nil
}
// GetCurrentFolder 获取数据库中的下载保存路径,如果不存在则将默认路径保存到数据库
func GetCurrentFolder(db *sql.DB) (string, error) {
var folder string
SqliteLock.Lock()
err := db.QueryRow(`SELECT "value" FROM "field" WHERE "name" = 'download_folder'`).Scan(&folder)
SqliteLock.Unlock()
if err != nil && err == sql.ErrNoRows {
folder, err = GetDefaultDownloadFolder()
if err != nil {
return "", err
}
err = os.MkdirAll(folder, os.ModePerm)
if err != nil {
return "", err
}
err = SaveDownloadFolder(db, folder)
if err != nil {
return "", err
}
return folder, nil
}
err = os.MkdirAll(folder, os.ModePerm)
if err != nil {
return "", err
}
return folder, nil
}
// SaveDownloadFolder 保存下载路径,不存在则自动创建
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()
_, err = db.Exec(`INSERT OR REPLACE INTO "field" ("name", "value") VALUES ('download_folder', ?)`, downloadFolder)
SqliteLock.Unlock()
return err
}
var SqliteLock sync.Mutex
func MustGetDB(path ...string) *sql.DB {
pathStr := ""
if len(path) == 0 {
pathStr = "./data.db"
} else if len(path) > 1 {
log.Fatalln(errors.New("len(path) <= 1"))
} else {
pathStr = path[0]
}
db, err := sql.Open("sqlite", pathStr)
if err != nil {
log.Fatalln("sql.Open:", err)
}
return db
}
+36
View File
@@ -0,0 +1,36 @@
package util
type FieldUtil struct{}
func (f FieldUtil) AllowSelect() []string {
return []string{
"download_folder",
}
}
func (f FieldUtil) AllowUpdate() []string {
return []string{
"download_folder",
}
}
func (f FieldUtil) IsAllow(allFields []string, names ...string) bool {
allowedFields := make(map[string]struct{})
for _, field := range allFields {
allowedFields[field] = struct{}{}
}
for _, name := range names {
if _, exists := allowedFields[name]; !exists {
return false
}
}
return true
}
func (f FieldUtil) IsAllowSelect(names ...string) bool {
return f.IsAllow(f.AllowSelect(), names...)
}
func (f FieldUtil) IsAllowUpdate(names ...string) bool {
return f.IsAllow(f.AllowUpdate(), names...)
}
+26
View File
@@ -0,0 +1,26 @@
package res_error
import (
"net/http"
"bilidown/util"
)
// Send 发送异常响应
func Send(w http.ResponseWriter, message string) {
util.Res{Message: message, Success: false}.Write(w)
}
const (
BvidFormatError = "错误的 Bvid 格式"
URLFormatError = "错误的 URL 格式"
MidFormatError = "错误的 Mid 格式"
SeasonIdFormatError = "错误的 SeasonId 格式"
ParamError = "参数错误"
MethodNotAllowError = "不允许的请求方式"
NoLocationError = "无重定向目标地址"
FileNotFountError = "文件不存在"
FileTypeNotAllowError = "不允许的文件类型"
SystemError = "系统错误"
NotLogin = "未登录"
)
+23
View File
@@ -0,0 +1,23 @@
package util
import (
"encoding/json"
"net/http"
)
// 统一的 JSON 响应结构
type Res struct {
Success bool `json:"success"`
Message string `json:"message"`
Data any `json:"data"`
}
// 发送响应
func (r Res) Write(w http.ResponseWriter) {
bs, err := json.Marshal(r)
if err != nil {
w.Write([]byte(`{"success":false,"message":"系统错误","data":null}`))
}
w.Header().Set("Content-Type", "application/json")
w.Write(bs)
}
+28
View File
@@ -0,0 +1,28 @@
package util
import "sync"
type Semaphore struct {
ch chan struct{}
wg sync.WaitGroup
}
func NewSemaphore(concurrency int) *Semaphore {
return &Semaphore{
ch: make(chan struct{}, concurrency),
}
}
func (s *Semaphore) Acquire() {
s.ch <- struct{}{}
s.wg.Add(1)
}
func (s *Semaphore) Release() {
<-s.ch
s.wg.Done()
}
func (s *Semaphore) Wait() {
s.wg.Wait()
}
+92
View File
@@ -0,0 +1,92 @@
package util
import (
"crypto/md5"
"encoding/hex"
"errors"
"net/http"
"net/url"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"bilidown/common"
)
func CheckBvidFormat(bvid string) bool {
return regexp.MustCompile("^BV1[a-zA-Z0-9]+").MatchString(bvid)
}
// GetDefaultDownloadFolder 获取默认下载路径
func GetDefaultDownloadFolder() (string, error) {
return filepath.Abs("./download")
}
func IsNumber(str string) bool {
_, err := strconv.Atoi(str)
return err == nil
}
// IsValidURL 判断字符串是否为合法的URL
func IsValidURL(u string) bool {
_, err := url.ParseRequestURI(u)
return err == nil
}
// IsValidFormatCode 判断格式码是否合法
func IsValidFormatCode(format common.MediaFormat) bool {
allowed := []common.MediaFormat{6, 16, 32, 64, 74, 80, 112, 116, 120, 125, 126, 127}
for _, v := range allowed {
if v == format {
return true
}
}
return false
}
// FilterFileName 过滤字符串中的特殊字符,使其允许作为文件名。
func FilterFileName(fileName string) string {
return regexp.MustCompile(`[\\/:*?"<>|\n]`).ReplaceAllString(fileName, "")
}
// GetFFmpegPath 获取可用的 FFmpeg 执行路径。
func GetFFmpegPath() (string, error) {
if err := exec.Command("ffmpeg", "-version").Run(); err == nil {
return "ffmpeg", nil
}
if err := exec.Command("bin/ffmpeg", "-version").Run(); err == nil {
return "bin/ffmpeg", nil
}
return "", errors.New("ffmpeg not found")
}
// GetRedirectedLocation 获取响应头中的 Location,不会自动跟随重定向。
func GetRedirectedLocation(url string) (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
request, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return "", err
}
response, err := client.Do(request)
if err != nil {
return "", err
}
if locationURL, err := response.Location(); err != nil {
return "", err
} else {
return locationURL.String(), nil
}
}
func MD5Hash(str string) string {
hasher := md5.New()
hasher.Write([]byte(str))
hash := hasher.Sum(nil)
hashString := hex.EncodeToString(hash)
return hashString
}
+29
View File
@@ -0,0 +1,29 @@
package util_test
import (
"fmt"
"os"
"testing"
"bilidown/common"
"bilidown/util"
)
func TestRandomString(t *testing.T) {
for i := 4; i < 10; i++ {
for j := 0; j < 3; j++ {
str := common.RandomString(i)
t.Log(str)
}
t.Log("\n")
}
}
func TestGetRedirectedLocation(t *testing.T) {
os.Setenv("https_proxy", "http://192.168.1.5:9000")
if location, err := util.GetRedirectedLocation("https://b23.tv/Ga6sbzT"); err != nil {
t.Error(err)
} else {
fmt.Println(location)
}
}