feat: detect actual audio format and resolve quality mismatch

This commit is contained in:
OpenClaw Agent
2026-09-16 05:34:47 +08:00
parent f376348b05
commit 83224fe5b5
6 changed files with 260 additions and 26 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
data
downloads
*.pre-*
*.backup-*
*.log
+2 -2
View File
@@ -2,8 +2,8 @@ FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY main.go index.html ./
RUN CGO_ENABLED=0 go build -o /out/am-downloader ./main.go
COPY . .
RUN CGO_ENABLED=0 go build -o /out/am-downloader .
FROM alpine:3.22
WORKDIR /app
COPY --from=build /out/am-downloader /app/am-downloader
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func normalizedFormat(format string) string {
f := strings.ToLower(strings.TrimPrefix(format, "."))
switch f {
case "m4a", "mp4", "mmp4":
return "m4a"
case "mflac", "flac":
return "flac"
case "mp3":
return "mp3"
case "ogg", "opus":
return "ogg"
case "wav":
return "wav"
case "aac":
return "aac"
default:
return f
}
}
func actualQuality(requested, resolved, format string) string {
f := normalizedFormat(format)
if f == "flac" {
if resolved != "" {
return resolved
}
if requested == "lossless" || requested == "hires" {
return requested
}
}
if f == "mp3" {
if resolved == "standard" || resolved == "exhigh" || resolved == "sq" {
return resolved
}
}
return ""
}
func renameToActualFormat(local string, format string) (string, error) {
ext := normalizedFormat(format)
if ext == "" {
return local, nil
}
target := strings.TrimSuffix(local, filepath.Ext(local)) + "." + ext
if target == local {
return local, nil
}
if _, err := os.Stat(target); err == nil {
if err := os.Remove(local); err != nil {
return "", err
}
return target, nil
}
if err := os.Rename(local, target); err != nil {
return "", fmt.Errorf("rename actual format: %w", err)
}
return target, nil
}
+39 -13
View File
@@ -1,13 +1,39 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AM Downloader</title><style>
*{box-sizing:border-box}body{margin:0;background:#0b1220;color:#e5edf7;font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select{font:inherit}.wrap{max-width:1080px;margin:auto;padding:28px 18px 60px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.brand{font-size:25px;font-weight:800}.brand b{color:#10b981}.sub{color:#7f8da3;margin-top:5px}.card{background:#111b2c;border:1px solid #22304a;border-radius:18px;padding:18px;margin-bottom:16px}.row{display:flex;gap:8px}.row>*{flex:1}.input{width:100%;padding:11px 12px;border-radius:11px;border:1px solid #2a3852;background:#0c1525;color:#fff;outline:none}.btn{border:0;border-radius:11px;padding:11px 15px;background:#10b981;color:#06251b;font-weight:700;cursor:pointer}.btn.alt{background:#1d2a40;color:#d9e3ef}.title{font-size:16px;font-weight:750;margin-bottom:12px}.muted{color:#7f8da3;font-size:12px}.results{display:grid;gap:6px}.result{display:grid;grid-template-columns:46px 1fr auto;gap:12px;align-items:center;padding:9px;border-radius:12px}.result:hover{background:#162238}.cover{width:46px;height:46px;border-radius:9px;object-fit:cover;background:#1b2638}.name{font-weight:650}.meta{font-size:12px;color:#7f8da3;margin-top:4px}.status{font-size:12px;padding:5px 8px;border-radius:8px;background:#1c2a40}.done{color:#34d399}.error{color:#fb7185}.settings{display:none}.settings.open{display:block}label{display:block;color:#9aa8bb;font-size:12px;margin:10px 0 6px}@media(max-width:720px){.row{flex-wrap:wrap}.row>*{flex-basis:100%}.top{align-items:flex-start}.result{grid-template-columns:40px 1fr auto}.cover{width:40px;height:40px}}
</style></head><body><div class="wrap"><div class="top"><div><div class="brand">AM <b>Downloader</b></div><div class="sub">独立下载队列 · WebDAV NAS 同步</div></div><button class="btn alt" onclick="toggleSettings()">设置</button></div>
<div class="card"><div class="title">搜索音乐</div><div class="row"><input id="keyword" class="input" placeholder="歌曲名、歌手或专辑"><select id="source" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn" onclick="search()">搜索</button></div></div>
<div id="resultsCard" class="card" style="display:none"><div class="title">搜索结果</div><div id="results" class="results"></div></div>
<div id="settings" class="card settings"><div class="title">下载与 WebDAV</div><label>AM API</label><input id="amApi" class="input"><label>本地下载目录</label><input id="downloadDir" class="input"><label>WebDAV 地址</label><input id="davUrl" class="input" placeholder="https://nas.example.com/dav/music"><div class="row"><div><label>用户名</label><input id="davUser" class="input"></div><div><label>密码</label><input id="davPass" type="password" class="input"></div></div><label>远程音乐目录</label><input id="davPath" class="input" placeholder="Music"><label>默认下载品质</label><select id="defaultQuality" class="input"><option value="standard">标准</option><option value="exhigh">高品质</option><option value="lossless">无损</option><option value="hires">Hi-Res</option><option value="sky">Sky</option><option value="jyeffect">高清音效</option><option value="jymaster">母带</option><option value="atmos">Atmos</option><option value="atmos_plus">Atmos+</option><option value="master">Master</option></select><div class="row" style="margin-top:14px"><button class="btn" onclick="saveSettings()">保存设置</button><button class="btn alt" onclick="testDav()">测试 WebDAV</button></div></div>
<div class="card"><div class="title">下载队列 <span id="count" class="muted"></span></div><div id="tasks" class="muted">加载中…</div></div></div><script>
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 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=>{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}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>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>AM Downloader</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#0b1220;color:#e7edf7;font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:1100px;margin:auto;padding:20px 16px 80px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.brand{font-size:24px;font-weight:850}.brand b{color:#10b981}.sub,.muted{color:#8492a7}.sub{margin-top:4px;font-size:12px}.nav{display:flex;gap:6px;background:#101a2b;border:1px solid #22304a;border-radius:14px;padding:5px}.nav button{border:0;background:transparent;color:#9aa8bb;padding:9px 13px;border-radius:10px;cursor:pointer;font-weight:650}.nav button.active{background:#1c2b42;color:#fff}.badge{display:inline-flex;min-width:19px;height:19px;align-items:center;justify-content:center;border-radius:10px;background:#10b981;color:#06251b;font-size:11px;margin-left:5px}.view{display:none}.view.active{display:block}.hero{padding:44px 8px 34px;text-align:center}.hero h1{font-size:34px;margin:0 0 10px}.hero p{color:#8492a7;margin:0 0 24px}.searchbar{display:flex;max-width:720px;margin:auto;gap:8px}.input{width:100%;padding:12px 13px;border-radius:11px;border:1px solid #2a3852;background:#0c1525;color:#fff;outline:none}.input:focus{border-color:#10b981}.btn{border:0;border-radius:11px;padding:11px 16px;background:#10b981;color:#06251b;font-weight:750;cursor:pointer;white-space:nowrap}.btn.alt{background:#1b2940;color:#dbe5f2}.btn:disabled{opacity:.5;cursor:not-allowed}.card{background:#111b2c;border:1px solid #22304a;border-radius:17px;padding:18px;margin-bottom:15px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:13px}.title{font-size:17px;font-weight:780}.toolbar{display:flex;gap:8px;align-items:center}.result{display:grid;grid-template-columns:50px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px;border-radius:12px}.result:hover{background:#162238}.cover{width:50px;height:50px;border-radius:10px;object-fit:cover;background:#1b2638}.name{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{font-size:12px;color:#8492a7;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.results{display:grid;gap:3px}.empty{text-align:center;padding:42px 12px;color:#8492a7}.loadmore{width:100%;margin-top:12px}.task-tabs{display:flex;gap:6px;overflow:auto;margin-bottom:14px}.task-tabs button{border:1px solid #283750;background:#0e1829;color:#aab6c7;border-radius:10px;padding:8px 12px;white-space:nowrap}.task-tabs button.active{background:#1c2b42;color:#fff}.status{font-size:12px;padding:5px 8px;border-radius:8px;background:#1c2a40}.done{color:#34d399}.error{color:#fb7185}.progress{height:5px;background:#1b2940;border-radius:4px;margin-top:8px;overflow:hidden}.progress i{display:block;height:100%;background:#10b981}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.setting-card h3{margin:0 0 5px;font-size:15px}.setting-card p{margin:0 0 15px;color:#8492a7;font-size:12px}.field{margin-top:12px}.field label{display:block;color:#9aa8bb;font-size:12px;margin-bottom:6px}.row{display:flex;gap:9px}.row>*{min-width:0;flex:1}.notice{padding:10px 12px;background:#0d1728;border:1px solid #23314a;border-radius:10px;color:#9eacc0;font-size:12px;margin-top:12px}.recent{margin-top:4px}.mobile-nav{display:none}.quality-select{max-width:132px}.file-info{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.chip{display:inline-flex;align-items:center;padding:3px 7px;border-radius:7px;background:#17243a;color:#b9c6d8;font-size:11px}.chip.actual{color:#d9fbe9;background:#123126}.chip.warn{color:#fde7b0;background:#352b16}
@media(max-width:720px){.wrap{padding:14px 12px 132px}.top{align-items:flex-start}.top .nav{display:none}.hero{padding:35px 4px 25px}.hero h1{font-size:28px}.searchbar{flex-wrap:wrap}.searchbar .input{flex-basis:100%}.searchbar select,.searchbar button{flex:1}.settings-grid{grid-template-columns:1fr}.result{grid-template-columns:42px minmax(0,1fr);gap:10px;padding:10px 6px}.cover{width:42px;height:42px}.result .toolbar{grid-column:2;display:flex;width:100%;gap:7px;margin-top:4px}.result .quality-select{max-width:none;min-width:0;flex:1}.result .toolbar .btn{padding:9px 14px;min-width:76px}.mobile-nav{position:fixed;left:10px;right:10px;bottom:max(10px,env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(4,1fr);gap:4px;padding:5px;background:#111b2c;border:1px solid #2a3852;border-radius:16px;z-index:10}.mobile-nav button{border:0;background:transparent;color:#8492a7;padding:9px 4px;border-radius:11px;font-size:12px}.mobile-nav button.active{background:#1c2b42;color:#fff}.row{flex-wrap:wrap}.row>*{flex-basis:100%}}
</style>
</head>
<body><div class="wrap">
<header class="top"><div><div class="brand">AM <b>Downloader</b></div><div class="sub">搜索 · 下载 · WebDAV 同步</div></div><nav class="nav" id="topNav"><button data-view="home">首页</button><button data-view="search">搜索</button><button data-view="queue">下载<span id="navBadge" class="badge">0</span></button><button data-view="settings">设置</button></nav></header>
<main>
<section id="home" class="view"><div class="hero"><h1>找到你想听的音乐</h1><p>搜索歌曲、歌手或专辑,加入下载队列即可。</p><form id="homeSearch" class="searchbar"><input id="homeKeyword" class="input" placeholder="输入歌曲名、歌手或专辑" autocomplete="off"><select id="homeSource" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn">开始搜索</button></form></div><div class="card"><div class="section-head"><div class="title">最近下载</div><button class="btn alt" data-go="queue">查看全部</button></div><div id="recentTasks"></div></div></section>
<section id="search" class="view"><div class="card"><div class="section-head"><div><div class="title">搜索音乐</div><div class="muted">支持歌曲名、歌手、专辑</div></div></div><form id="searchForm" class="row"><input id="keyword" class="input" placeholder="输入关键词" autocomplete="off"><select id="source" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn">搜索</button></form></div><div id="resultsCard" class="card" style="display:none"><div class="section-head"><div><div class="title">搜索结果</div><div id="resultHint" class="muted"></div></div></div><div id="results" class="results"></div><button id="loadMore" class="btn alt loadmore" style="display:none">加载更多</button></div></section>
<section id="queue" class="view"><div class="card"><div class="section-head"><div><div class="title">下载队列</div><div class="muted">任务会自动处理,完成后可在本地下载目录找到文件。</div></div></div><div id="taskTabs" class="task-tabs"><button class="active" data-filter="all">全部</button><button data-filter="active">进行中</button><button data-filter="done">已完成</button><button data-filter="error">失败</button></div><div id="tasks"></div></div></section>
<section id="settings" class="view"><div class="settings-grid"><div class="card setting-card"><h3>音乐 API</h3><p>用于搜索和解析音乐资源,与 Downloader Web 服务分离。</p><div class="field"><label>API 地址</label><input id="amApi" class="input" placeholder="http://127.0.0.1:9530"></div><div class="notice">当前服务架构:Downloader Web 使用 9540,音乐 API 使用 9530。</div></div><div class="card setting-card"><h3>下载</h3><p>设置文件保存位置与默认音质。</p><div class="field"><label>本地下载目录</label><input id="downloadDir" class="input"></div><div class="field"><label>默认下载品质</label><select id="defaultQuality" class="input"><option value="standard">标准</option><option value="exhigh">高品质</option><option value="lossless">无损</option><option value="hires">Hi-Res</option><option value="sky">Sky</option><option value="jyeffect">高清音效</option><option value="jymaster">母带</option><option value="atmos">Atmos</option><option value="atmos_plus">Atmos+</option><option value="master">Master</option></select></div></div><div class="card setting-card"><h3>WebDAV</h3><p>可选的 NAS/远程音乐库同步配置。</p><div class="field"><label>WebDAV 地址</label><input id="davUrl" class="input" placeholder="https://nas.example.com/dav/music"></div><div class="row"><div class="field"><label>用户名</label><input id="davUser" class="input"></div><div class="field"><label>密码</label><input id="davPass" type="password" class="input"></div></div><div class="field"><label>远程音乐目录</label><input id="davPath" class="input" placeholder="Music"></div><div class="row" style="margin-top:15px"><button id="testDav" type="button" class="btn alt">测试连接</button><button id="saveSettings" type="button" class="btn">保存设置</button></div></div></div></section>
</main><nav class="mobile-nav" id="mobileNav"><button data-view="home">⌂<br>首页</button><button data-view="search">⌕<br>搜索</button><button data-view="queue">♫ <span id="mobileBadge">0</span><br>下载</button><button data-view="settings">⚙<br>设置</button></nav></div>
<script>
const $=id=>document.getElementById(id);let searchPage=1,searchKeyword='',searchSource='kw',searchLoading=false,hasMore=false,taskFilter='all';
async function api(u,o){const 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
function go(view){document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view===view));if(view==='settings')loadSettings();if(view==='queue')loadTasks();if(view==='home')loadRecent();history.replaceState(null,'','#'+view)}
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.view)));document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.go)));
function qualityRank(q){return {standard:1,exhigh:2,sq:3,lossless:4,hires:5,jyeffect:6,sky:7,jymaster:8,atmos:9,atmos_plus:10,master:11}[q]||0}
function qualityName(q){return {standard:'标准',exhigh:'高品质',sq:'SQ',lossless:'无损',hires:'Hi-Res',jyeffect:'高清音效',sky:'Sky',jymaster:'母带',atmos:'Atmos',atmos_plus:'Atmos+',master:'Master'}[q]||q}
function pickQuality(s){const qs=Array.isArray(s.qualities)?s.qualities.map(x=>typeof x==='string'?{level:x}:x).filter(x=>x.level):[];const def=$('defaultQuality').value||'standard';if(qs.some(x=>x.level===def))return def;return qs.slice().sort((a,b)=>qualityRank(b.level)-qualityRank(a.level))[0]?.level||def}
async function runSearch(reset=true){const k=$('keyword').value.trim();if(!k)return;if(reset){searchPage=1;searchKeyword=k;searchSource=$('source').value;$('results').innerHTML=''}if(searchLoading)return;searchLoading=true;$('resultsCard').style.display='block';$('resultHint').textContent=reset?'搜索中…':'加载中…';$('loadMore').disabled=true;try{const x=await api('/search?keyword='+encodeURIComponent(searchKeyword)+'&source='+encodeURIComponent(searchSource)+'&page='+searchPage+'&limit=20');const songs=x.data?.songs||[];const seen=new Set([...$('results').querySelectorAll('[data-key]')].map(x=>x.dataset.key));songs.forEach(s=>{const key=String(s.rid||s.id||s.title+'|'+s.artist);if(seen.has(key))return;const el=document.createElement('div');el.className='result';el.dataset.key=key;const qs=Array.isArray(s.qualities)?s.qualities.map(x=>typeof x==='string'?{level:x}:x).filter(x=>x.level):[];const selected=pickQuality(s);const options=qs.length?qs.slice().sort((a,b)=>qualityRank(a.level)-qualityRank(b.level)).map(q=>'<option value="'+esc(q.level)+'" '+(q.level===selected?'selected':'')+'>'+esc(qualityName(q.level))+(q.format?' · '+esc(String(q.format).toUpperCase()):'')+'</option>').join(''):'';el.innerHTML='<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><div class="toolbar">'+(qs.length?'<select class="input quality-select">'+options+'</select>':'')+'<button class="btn">下载</button></div>';el.querySelector('button').addEventListener('click',()=>{const q=el.querySelector('.quality-select');queueSong(s,q?q.value:selected)});$('results').appendChild(el)});hasMore=songs.length>=20;$('resultHint').textContent='第 '+searchPage+' 页 · 已显示 '+$('results').children.length+' 首';$('loadMore').style.display=hasMore?'block':'none'}catch(e){$('resultHint').textContent='搜索失败:'+e.message}finally{searchLoading=false;$('loadMore').disabled=false}}
$('searchForm').addEventListener('submit',e=>{e.preventDefault();runSearch(true)});$('loadMore').addEventListener('click',()=>{searchPage++;runSearch(false)});$('homeSearch').addEventListener('submit',e=>{e.preventDefault();$('keyword').value=$('homeKeyword').value;$('source').value=$('homeSource').value;go('search');runSearch(true)});
async function queueSong(s,quality){s.quality=quality||pickQuality(s);try{await api('/tasks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(s)});go('queue');await loadTasks()}catch(e){alert('加入失败:'+e.message)}}
function formatSize(n){n=Number(n)||0;if(n<1024)return n+' B';const units=['KB','MB','GB','TB'];let v=n/1024;for(let i=0;i<units.length;i++){if(v<1024||i===units.length-1)return v.toFixed(v>=100?0:v>=10?1:2)+' '+units[i];v/=1024}return n+' B'}
function taskHtml(t){let pct=Math.max(0,Math.min(100,Number(t.progress)||0));let status=t.status==='done'?'已完成':t.status==='error'?'失败':t.status==='pending'?'等待中':t.status==='resolving'?'解析中':t.status==='downloading'?'下载中':t.status==='uploading'?'上传中':esc(t.status);let actual=(t.format||'').toUpperCase();let requested=qualityName(t.quality||'');let actualQ=t.actualQuality?qualityName(t.actualQuality):'';let apiQ=t.resolvedQuality?qualityName(t.resolvedQuality):'';let action=t.status==='error'?'<button class="btn alt retry">重试</button>':'';let highLossless=t.quality==='lossless'||t.quality==='hires';let mismatch=(actualQ&&actualQ!==requested)||(highLossless&&actual!=='FLAC');let info=t.status==='done'?`<div class="file-info"><span class="chip actual">格式 ${esc(actual||'未知')}</span><span class="chip">大小 ${esc(formatSize(t.totalBytes||t.downloadedBytes))}</span>${actualQ&&!mismatch?`<span class="chip actual">实际 ${esc(actualQ)}</span>`:''}${mismatch?`<span class="chip warn">请求 ${esc(requested)} · 实际 ${esc(actualQ||actual||'未知')}${apiQ&&apiQ!==actualQ?' · API '+esc(apiQ):''}</span>`:''}</div>`:'';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(requested)} · ${status}${t.status==='error'?' · '+esc(t.error||''):''}</div>${info}${t.status==='downloading'||t.status==='resolving'?`<div class="progress"><i style="width:${pct}%"></i></div>`:''}</div>${action}</div>`}
async function loadTasks(){try{const x=await api('/tasks');const all=x.data||[];const active=all.filter(t=>['pending','resolving','downloading'].includes(t.status));$('navBadge').textContent=active.length;$('mobileBadge').textContent=active.length;let arr=taskFilter==='active'?active:taskFilter==='done'?all.filter(t=>t.status==='done'):taskFilter==='error'?all.filter(t=>t.status==='error'):all;$('tasks').innerHTML=arr.length?arr.map(taskHtml).join(''):'<div class="empty">暂无任务</div>';$('tasks').querySelectorAll('.retry').forEach((b,i)=>b.addEventListener('click',async()=>{const errs=arr.filter(t=>t.status==='error');await api('/tasks/'+errs[i].id+'/retry',{method:'POST'});loadTasks()}))}catch(e){$('tasks').innerHTML='<div class="empty">加载失败:'+esc(e.message)+'</div>'}}
async function loadRecent(){try{const x=await api('/tasks');const a=(x.data||[]).slice(0,5);$('recentTasks').innerHTML=a.length?a.map(taskHtml).join(''):'<div class="empty">还没有下载记录</div>'}catch(e){$('recentTasks').innerHTML='<div class="empty">加载失败</div>'}}
document.querySelectorAll('#taskTabs button').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#taskTabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');taskFilter=b.dataset.filter;loadTasks()}));
async function loadSettings(){try{const 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||'standard'}catch(e){alert('设置加载失败:'+e.message)}}
$('saveSettings').addEventListener('click',async()=>{const b={amApi:$('amApi').value,downloadDir:$('downloadDir').value,webdavUrl:$('davUrl').value,webdavUser:$('davUser').value,webdavPass:$('davPass').value,webdavPath:$('davPath').value,quality:$('defaultQuality').value};try{await api('/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('设置已保存')}catch(e){alert('保存失败:'+e.message)}});
$('testDav').addEventListener('click',async()=>{try{const 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)}});
go(location.hash.slice(1)||'home');loadRecent();setInterval(()=>{loadTasks();if(document.getElementById('home').classList.contains('active'))loadRecent()},3000);
</script></body></html>
+87 -11
View File
@@ -23,13 +23,15 @@ import (
type Task struct {
ID uint `gorm:"primaryKey" json:"id"`
Source string `json:"source"`
RID string `json:"rid"`
Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Pic string `json:"pic"`
Quality string `json:"quality"`
Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"`
ResolvedQuality string `json:"resolvedQuality,omitempty"`
ActualQuality string `json:"actualQuality,omitempty"`
Format string `json:"format"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
@@ -70,6 +72,7 @@ type App struct {
type playInfo struct {
URL string `json:"url"`
Level string `json:"level"`
Format string `json:"format"`
Headers map[string]string `json:"headers"`
}
@@ -146,19 +149,31 @@ func (a *App) addTask(c *gin.Context) {
c.JSON(400, gin.H{"error": "source/rid/title required"})
return
}
if in.Source == "" {
in.Source = "kw"
}
if in.Quality == "" {
in.Quality = a.cfg.DefaultQuality
}
var existing Task
err := a.db.Where("source = ? AND rid = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error
err := a.db.Where("source = ? AND r_id = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error
if err == nil {
if existing.Status != "error" {
c.JSON(200, existing)
return
}
a.db.Model(&existing).Updates(map[string]any{"title": in.Title, "artist": in.Artist, "album": in.Album, "pic": in.Pic, "status": "pending", "error": ""})
a.queue <- existing.ID
c.JSON(202, 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 {
// A concurrent request may have inserted the same task; return that task.
if a.db.Where("source = ? AND r_id = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error == nil {
c.JSON(200, existing)
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
@@ -201,7 +216,9 @@ func (a *App) retryTask(c *gin.Context) {
c.Status(404)
return
}
a.db.Model(&t).Updates(map[string]any{"status": "pending", "error": ""})
a.db.Model(&t).Updates(map[string]any{"status": "pending", "error": "", "progress": 0, "downloaded_bytes": 0})
t.Status = "pending"
t.Error = ""
a.queue <- t.ID
c.JSON(202, t)
}
@@ -216,24 +233,60 @@ func (a *App) download(id uint) {
if a.db.First(&t, id).Error != nil {
return
}
// A task may be queued twice; only pending tasks are eligible to start.
if t.Status != "pending" {
return
}
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)
return
}
ext := info.Format
priorLocal := t.LocalPath
ext := normalizedFormat(info.Format)
if ext == "" {
ext = normalizedFormat(filepath.Ext(info.URL))
}
if ext == "" {
ext = "mp3"
}
filename := safe(t.Artist) + " - " + safe(t.Title) + "." + safe(ext)
filename := safe(t.Artist) + " - " + safe(t.Title) + "." + ext
local := filepath.Join(a.cfg.DownloadDir, filename)
t.Format = ext
t.ResolvedQuality = info.Level
t.ActualQuality = ""
t.LocalPath = local
if _, err = os.Stat(local); err == nil {
if priorLocal != "" {
if fi, statErr := os.Stat(priorLocal); statErr == nil && fi.Size() > 0 {
local = priorLocal
t.LocalPath = local
}
}
if fi, statErr := os.Stat(local); statErr == nil && fi.Size() > 0 {
actual, detectErr := detectAudioFormat(local)
if detectErr == nil {
actual = normalizedFormat(actual)
if actual != t.Format {
newLocal, renameErr := renameToActualFormat(local, actual)
if renameErr != nil {
a.fail(&t, renameErr)
return
}
local = newLocal
t.LocalPath = local
}
t.Format = actual
t.ActualQuality = actualQuality(t.Quality, t.ResolvedQuality, actual)
}
t.TotalBytes = fi.Size()
t.DownloadedBytes = fi.Size()
t.Progress = 100
a.db.Save(&t)
a.upload(&t)
return
}
a.db.Model(&t).Updates(map[string]any{"status": "downloading", "format": ext, "local_path": local})
req, err := http.NewRequest("GET", info.URL, nil)
if err != nil {
a.fail(&t, err)
@@ -301,10 +354,26 @@ func (a *App) download(id uint) {
a.fail(&t, cerr)
return
}
if err = os.Rename(tmp, local); err != nil {
a.fail(&t, err)
if t.TotalBytes > 0 && n != t.TotalBytes {
a.fail(&t, fmt.Errorf("incomplete download: got %d of %d bytes", n, t.TotalBytes))
return
}
actual, detectErr := detectAudioFormat(tmp)
if detectErr != nil {
a.fail(&t, fmt.Errorf("detect audio format: %w", detectErr))
_ = os.Remove(tmp)
return
}
actual = normalizedFormat(actual)
local = strings.TrimSuffix(local, filepath.Ext(local)) + "." + actual
if err = os.Rename(tmp, local); err != nil {
a.fail(&t, err)
_ = os.Remove(tmp)
return
}
t.Format = actual
t.LocalPath = local
t.ActualQuality = actualQuality(t.Quality, t.ResolvedQuality, actual)
t.DownloadedBytes = n
if t.TotalBytes > 0 {
t.Progress = 100
@@ -377,7 +446,14 @@ func (a *App) upload(t *Task) {
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) }
func (a *App) fail(t *Task, err error) {
if t.LocalPath != "" {
_ = os.Remove(t.LocalPath + ".part")
}
t.Status = "error"
t.Error = err.Error()
a.db.Save(t)
}
func (a *App) getSettings(c *gin.Context) {
c.JSON(200, gin.H{"amApi": a.cfg.AMAPI, "downloadDir": a.cfg.DownloadDir, "webdavUrl": a.cfg.WebDAVURL, "webdavUser": a.cfg.WebDAVUser, "webdavPass": a.cfg.WebDAVPass, "webdavPath": a.cfg.WebDAVPath, "quality": a.cfg.DefaultQuality})
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"encoding/binary"
"errors"
"io"
"os"
)
func detectAudioFormat(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
b := make([]byte, 64*1024)
n, err := f.Read(b)
if err != nil && err != io.EOF {
return "", err
}
b = b[:n]
if len(b) >= 4 && string(b[:4]) == "fLaC" {
return "flac", nil
}
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WAVE" {
return "wav", nil
}
if len(b) >= 12 && string(b[:4]) == "FORM" && (string(b[8:12]) == "AIFF" || string(b[8:12]) == "AIFC") {
return "aiff", nil
}
if len(b) >= 4 && string(b[:4]) == "OggS" {
return "ogg", nil
}
if len(b) >= 12 && string(b[4:8]) == "ftyp" {
return "m4a", nil
}
start := 0
if len(b) >= 10 && string(b[:3]) == "ID3" {
size := int(b[6]&0x7f)<<21 | int(b[7]&0x7f)<<14 | int(b[8]&0x7f)<<7 | int(b[9]&0x7f)
start = 10 + size
if start > len(b) {
start = 10
}
}
for i := start; i+4 <= len(b) && i < start+4096; i++ {
if b[i] == 0xff && b[i+1]&0xe0 == 0xe0 {
h := binary.BigEndian.Uint32(b[i : i+4])
layer := (h >> 17) & 3
if layer == 1 {
return "mp3", nil
}
if b[i+1]&0x06 == 0x06 {
return "aac", nil
}
}
}
return "", errors.New("unknown audio format")
}