feat: add download progress and deduplication
This commit is contained in:
+2
-2
@@ -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]))}
|
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)=>`<div class="result"><img class="cover" src="${esc(s.pic||'')}" onerror="this.style.display='none'"><div><div class="name">${esc(s.title)}</div><div class="meta">${esc(s.artist||'')} · ${esc(s.album||'')}</div></div><button class="btn" onclick='queueSong(${JSON.stringify(s)})'>下载</button></div>`).join(''):'没有结果'}catch(e){alert('搜索失败:'+e.message)}}
|
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)=>`<div class="result"><img class="cover" src="${esc(s.pic||'')}" onerror="this.style.display='none'"><div><div class="name">${esc(s.title)}</div><div class="meta">${esc(s.artist||'')} · ${esc(s.album||'')}</div></div><button class="btn" onclick='queueSong(${JSON.stringify(s)})'>下载</button></div>`).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 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=>`<div class="result"><img class="cover" src="${esc(t.pic||'')}" onerror="this.style.display='none'"><div><div class="name">${esc(t.title)} <span class="muted">${esc(t.artist||'')}</span></div><div class="meta">${esc(t.source)} · ${esc(t.quality)} · ${t.status==='done'?'已完成':t.status==='error'?esc(t.error):esc(t.status)}</div></div><span class="status ${t.status==='done'?'done':t.status==='error'?'error':''}">${esc(t.status)}</span></div>`).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 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'?`<button class="btn alt" onclick="retry(${t.id})">重试</button>`:'';return `<div class="result"><img class="cover" src="${esc(t.pic||'')}" onerror="this.style.display='none'"><div><div class="name">${esc(t.title)} <span class="muted">${esc(t.artist||'')}</span></div><div class="meta">${esc(t.source)} · ${esc(t.quality)} · ${t.status==='done'?'已完成':t.status==='error'?esc(t.error):esc(t.status)}${p}</div></div>${action||`<span class="status ${t.status==='done'?'done':t.status==='error'?'error':''}">${esc(t.status)}</span>`}</div>`}).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 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);
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
@@ -22,21 +22,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
RID string `json:"rid"`
|
RID string `json:"rid"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
Album string `json:"album"`
|
Album string `json:"album"`
|
||||||
Pic string `json:"pic"`
|
Pic string `json:"pic"`
|
||||||
Quality string `json:"quality"`
|
Quality string `json:"quality"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
LocalPath string `json:"localPath,omitempty"`
|
LocalPath string `json:"localPath,omitempty"`
|
||||||
RemotePath string `json:"remotePath,omitempty"`
|
RemotePath string `json:"remotePath,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
DownloadedBytes int64 `json:"downloadedBytes"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
@@ -146,6 +149,11 @@ func (a *App) addTask(c *gin.Context) {
|
|||||||
if in.Quality == "" {
|
if in.Quality == "" {
|
||||||
in.Quality = a.cfg.DefaultQuality
|
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"}
|
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 {
|
if err := a.db.Create(&t).Error; err != nil {
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
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 {
|
if a.db.First(&t, id).Error != nil {
|
||||||
return
|
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)
|
info, err := a.resolve(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.fail(&t, err)
|
a.fail(&t, err)
|
||||||
@@ -241,12 +249,46 @@ func (a *App) download(id uint) {
|
|||||||
a.fail(&t, fmt.Errorf("media http %d", resp.StatusCode))
|
a.fail(&t, fmt.Errorf("media http %d", resp.StatusCode))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
a.fail(&t, err)
|
a.fail(&t, err)
|
||||||
return
|
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()
|
cerr := f.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.fail(&t, err)
|
a.fail(&t, err)
|
||||||
@@ -256,6 +298,15 @@ func (a *App) download(id uint) {
|
|||||||
a.fail(&t, cerr)
|
a.fail(&t, cerr)
|
||||||
return
|
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)
|
a.upload(&t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,12 +345,21 @@ func (a *App) upload(t *Task) {
|
|||||||
a.db.Model(t).Updates(map[string]any{"status": "uploading"})
|
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)
|
remote := strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + safe(t.Artist) + "/" + safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format)
|
||||||
t.RemotePath = remote
|
t.RemotePath = remote
|
||||||
|
remoteDir := filepath.ToSlash(filepath.Dir(remote))
|
||||||
client := gowebdav.NewClient(a.cfg.WebDAVURL, a.cfg.WebDAVUser, a.cfg.WebDAVPass)
|
client := gowebdav.NewClient(a.cfg.WebDAVURL, a.cfg.WebDAVUser, a.cfg.WebDAVPass)
|
||||||
client.SetTimeout(10 * time.Minute)
|
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)
|
a.fail(t, err)
|
||||||
return
|
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)
|
f, err := os.Open(t.LocalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.fail(t, err)
|
a.fail(t, err)
|
||||||
@@ -311,6 +371,7 @@ func (a *App) upload(t *Task) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Status = "done"
|
t.Status = "done"
|
||||||
|
t.Progress = 100
|
||||||
a.db.Save(t)
|
a.db.Save(t)
|
||||||
}
|
}
|
||||||
func (a *App) fail(t *Task, err error) { t.Status = "error"; t.Error = err.Error(); a.db.Save(t) }
|
func (a *App) fail(t *Task, err error) { t.Status = "error"; t.Error = err.Error(); a.db.Save(t) }
|
||||||
|
|||||||
Reference in New Issue
Block a user