From 0da24cb183771e557c70a5fc4583aa28f98ce90f Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Wed, 16 Sep 2026 02:20:43 +0800 Subject: [PATCH] feat: add download progress and deduplication --- index.html | 4 +-- main.go | 99 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index cce92fe..6c3e205 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@ const $=id=>document.getElementById(id);async function api(u,o){let r=await fetch('/api'+u,o);if(!r.ok)throw new Error(await r.text());return r.status===204?null:r.json()};function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))} async function search(){let k=$('keyword').value.trim();if(!k)return;try{let x=await api('/search?keyword='+encodeURIComponent(k)+'&source='+$('source').value);let songs=x.data?.songs||[];$('resultsCard').style.display='block';$('results').innerHTML=songs.length?songs.map((s,i)=>`
${esc(s.title)}
${esc(s.artist||'')} · ${esc(s.album||'')}
`).join(''):'没有结果'}catch(e){alert('搜索失败:'+e.message)}} async function queueSong(s){s.quality=$('defaultQuality').value||'standard';try{await api('/tasks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(s)});load()}catch(e){alert('加入失败:'+e.message)}} -async function load(){try{let x=await api('/tasks');$('count').textContent='· '+x.data.length+' 个任务';$('tasks').innerHTML=x.data.length?x.data.map(t=>`
${esc(t.title)} ${esc(t.artist||'')}
${esc(t.source)} · ${esc(t.quality)} · ${t.status==='done'?'已完成':t.status==='error'?esc(t.error):esc(t.status)}
${esc(t.status)}
`).join(''):'暂无任务'}catch(e){$('tasks').textContent=e.message}}async function retry(id){await api('/tasks/'+id+'/retry',{method:'POST'});load()}function toggleSettings(){$('settings').classList.toggle('open');if($('settings').classList.contains('open'))loadSettings()} -async function loadSettings(){let s=await api('/settings');$('amApi').value=s.amApi;$('downloadDir').value=s.downloadDir;$('davUrl').value=s.webdavUrl;$('davUser').value=s.webdavUser;$('davPass').value=s.webdavPass;$('davPath').value=s.webdavPath;$('defaultQuality').value=s.quality;$('quality').value=s.quality}async function saveSettings(){let b={amApi:$('amApi').value,downloadDir:$('downloadDir').value,webdavUrl:$('davUrl').value,webdavUser:$('davUser').value,webdavPass:$('davPass').value,webdavPath:$('davPath').value,quality:$('defaultQuality').value};await api('/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('已保存')}async function testDav(){try{let b={url:$('davUrl').value,user:$('davUser').value,pass:$('davPass').value,path:$('davPath').value};await api('/webdav/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('WebDAV 连接成功')}catch(e){alert('WebDAV 连接失败:'+e.message)}}load();setInterval(load,3000); +async function load(){try{let x=await api('/tasks');$('count').textContent='· '+x.data.length+' 个任务';$('tasks').innerHTML=x.data.length?x.data.map(t=>{let p=t.progress>0?` · ${t.progress.toFixed(0)}%`:'';let action=t.status==='error'?``:'';return `
${esc(t.title)} ${esc(t.artist||'')}
${esc(t.source)} · ${esc(t.quality)} · ${t.status==='done'?'已完成':t.status==='error'?esc(t.error):esc(t.status)}${p}
${action||`${esc(t.status)}`}
`}).join(''):'暂无任务'}catch(e){$('tasks').textContent=e.message}}async function retry(id){await api('/tasks/'+id+'/retry',{method:'POST'});load()}function toggleSettings(){$('settings').classList.toggle('open');if($('settings').classList.contains('open'))loadSettings()} +async function loadSettings(){let s=await api('/settings');$('amApi').value=s.amApi;$('downloadDir').value=s.downloadDir;$('davUrl').value=s.webdavUrl;$('davUser').value=s.webdavUser;$('davPass').value=s.webdavPass;$('davPath').value=s.webdavPath;$('defaultQuality').value=s.quality}async function saveSettings(){let b={amApi:$('amApi').value,downloadDir:$('downloadDir').value,webdavUrl:$('davUrl').value,webdavUser:$('davUser').value,webdavPass:$('davPass').value,webdavPath:$('davPath').value,quality:$('defaultQuality').value};await api('/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('已保存')}async function testDav(){try{let b={url:$('davUrl').value,user:$('davUser').value,pass:$('davPass').value,path:$('davPath').value};await api('/webdav/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('WebDAV 连接成功')}catch(e){alert('WebDAV 连接失败:'+e.message)}}load();setInterval(load,3000); \ No newline at end of file diff --git a/main.go b/main.go index 8ac0bd5..b92afbd 100644 --- a/main.go +++ b/main.go @@ -22,21 +22,24 @@ import ( ) type Task struct { - ID uint `gorm:"primaryKey" json:"id"` - Source string `json:"source"` - RID string `json:"rid"` - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album"` - Pic string `json:"pic"` - Quality string `json:"quality"` - Format string `json:"format"` - Status string `json:"status"` - Error string `json:"error,omitempty"` - LocalPath string `json:"localPath,omitempty"` - RemotePath string `json:"remotePath,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + Source string `json:"source"` + RID string `json:"rid"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + Pic string `json:"pic"` + Quality string `json:"quality"` + Format string `json:"format"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + LocalPath string `json:"localPath,omitempty"` + RemotePath string `json:"remotePath,omitempty"` + TotalBytes int64 `json:"totalBytes"` + DownloadedBytes int64 `json:"downloadedBytes"` + Progress float64 `json:"progress"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type Setting struct { @@ -146,6 +149,11 @@ func (a *App) addTask(c *gin.Context) { if in.Quality == "" { in.Quality = a.cfg.DefaultQuality } + var existing Task + if a.db.Where("source = ? AND rid = ? AND quality = ? AND status IN ?", in.Source, in.RID, in.Quality, []string{"pending", "resolving", "downloading", "uploading", "done"}).First(&existing).Error == nil { + c.JSON(200, existing) + return + } t := Task{Source: in.Source, RID: in.RID, Title: in.Title, Artist: in.Artist, Album: in.Album, Pic: in.Pic, Quality: in.Quality, Status: "pending"} if err := a.db.Create(&t).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) @@ -205,7 +213,7 @@ func (a *App) download(id uint) { if a.db.First(&t, id).Error != nil { return } - a.db.Model(&t).Updates(map[string]any{"status": "downloading", "error": ""}) + a.db.Model(&t).Updates(map[string]any{"status": "resolving", "error": "", "progress": 0}) info, err := a.resolve(t) if err != nil { a.fail(&t, err) @@ -241,12 +249,46 @@ func (a *App) download(id uint) { a.fail(&t, fmt.Errorf("media http %d", resp.StatusCode)) return } - f, err := os.Create(local) + t.Status = "downloading" + t.TotalBytes = resp.ContentLength + t.DownloadedBytes = 0 + t.Progress = 0 + a.db.Save(&t) + tmp := local + ".part" + f, err := os.Create(tmp) if err != nil { a.fail(&t, err) return } - _, err = io.Copy(f, resp.Body) + var n int64 + buf := make([]byte, 256*1024) + lastReport := int64(0) + for { + nr, er := resp.Body.Read(buf) + if nr > 0 { + wn, ew := f.Write(buf[:nr]) + n += int64(wn) + if ew != nil { + err = ew + break + } + if n-lastReport >= 1024*1024 { + progress := 0.0 + if resp.ContentLength > 0 { + progress = float64(n) * 100 / float64(resp.ContentLength) + } + a.db.Model(&t).Updates(map[string]any{"downloaded_bytes": n, "total_bytes": resp.ContentLength, "progress": progress}) + lastReport = n + } + } + if er == io.EOF { + break + } + if er != nil { + err = er + break + } + } cerr := f.Close() if err != nil { a.fail(&t, err) @@ -256,6 +298,15 @@ func (a *App) download(id uint) { a.fail(&t, cerr) return } + if err = os.Rename(tmp, local); err != nil { + a.fail(&t, err) + return + } + t.DownloadedBytes = n + if t.TotalBytes > 0 { + t.Progress = 100 + } + a.db.Save(&t) a.upload(&t) } @@ -294,12 +345,21 @@ func (a *App) upload(t *Task) { a.db.Model(t).Updates(map[string]any{"status": "uploading"}) remote := strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + safe(t.Artist) + "/" + safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format) t.RemotePath = remote + remoteDir := filepath.ToSlash(filepath.Dir(remote)) client := gowebdav.NewClient(a.cfg.WebDAVURL, a.cfg.WebDAVUser, a.cfg.WebDAVPass) client.SetTimeout(10 * time.Minute) - if err := client.MkdirAll(strings.TrimSuffix(remote, "/"), 0755); err != nil { + if err := client.MkdirAll(remoteDir, 0755); err != nil { a.fail(t, err) return } + if fi, err := os.Stat(t.LocalPath); err == nil { + if ri, err := client.Stat(remote); err == nil && ri.Size() == fi.Size() { + t.Status = "done" + t.Progress = 100 + a.db.Save(t) + return + } + } f, err := os.Open(t.LocalPath) if err != nil { a.fail(t, err) @@ -311,6 +371,7 @@ func (a *App) upload(t *Task) { return } t.Status = "done" + t.Progress = 100 a.db.Save(t) } func (a *App) fail(t *Task, err error) { t.Status = "error"; t.Error = err.Error(); a.db.Save(t) }