feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务
由 163-music-dl CLI 重构为 Web 服务: - 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg) - 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider - QQ 扫码登录与 musickey 自动续期 - SQLite 持久化的下载队列,SSE 实时进度 - 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码 - Vite 8 + Vue 3 前端,浅色 / 深色 - Docker 部署配置(飞牛 NAS) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { QualityKey, TrackSummary } from '../lib/api.ts';
|
||||
import { bestQuality, formatDuration, isGold, QUALITY_BADGE, STAGE_LABEL } from '../lib/format.ts';
|
||||
import { isPlaying, player, playOnline } from '../lib/player.ts';
|
||||
import { activeByTrack, defaultQualityFor, download, isDownloaded, progress, trackKey } from '../lib/store.ts';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import Icon from './Icon.vue';
|
||||
import QualityPicker from './QualityPicker.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tracks: TrackSummary[];
|
||||
group?: string;
|
||||
showCover?: boolean;
|
||||
showAlbum?: boolean;
|
||||
numbering?: 'index' | 'track';
|
||||
hideToolbar?: boolean;
|
||||
}>(),
|
||||
{ showCover: true, showAlbum: true, numbering: 'index' },
|
||||
);
|
||||
|
||||
const source = computed(() => props.tracks[0]?.source ?? 'netease');
|
||||
const quality = ref<QualityKey>(defaultQualityFor(source.value));
|
||||
watch(source, (s) => (quality.value = defaultQualityFor(s)));
|
||||
|
||||
const selected = ref(new Set<string>());
|
||||
let anchor = -1;
|
||||
watch(
|
||||
() => props.tracks,
|
||||
() => {
|
||||
selected.value = new Set();
|
||||
anchor = -1;
|
||||
},
|
||||
);
|
||||
|
||||
const allSelected = computed(() => props.tracks.length > 0 && selected.value.size === props.tracks.length);
|
||||
const pendingCount = computed(() => props.tracks.filter((t) => !isDownloaded(t)).length);
|
||||
|
||||
function toggleRow(index: number, e: MouseEvent) {
|
||||
const next = new Set(selected.value);
|
||||
const key = props.tracks[index]!.id;
|
||||
if (e.shiftKey && anchor >= 0) {
|
||||
const [a, b] = [Math.min(anchor, index), Math.max(anchor, index)];
|
||||
const value = !next.has(key);
|
||||
for (let i = a; i <= b; i++) value ? next.add(props.tracks[i]!.id) : next.delete(props.tracks[i]!.id);
|
||||
} else {
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
}
|
||||
anchor = index;
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
selected.value = allSelected.value ? new Set() : new Set(props.tracks.map((t) => t.id));
|
||||
}
|
||||
|
||||
function selectPending() {
|
||||
selected.value = new Set(props.tracks.filter((t) => !isDownloaded(t)).map((t) => t.id));
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
const items = props.tracks.filter((t) => selected.value.has(t.id));
|
||||
await download(items, quality.value, props.group);
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
function downloadAll() {
|
||||
download(props.tracks, quality.value, props.group);
|
||||
}
|
||||
|
||||
function rowState(t: TrackSummary) {
|
||||
const task = activeByTrack.value.get(trackKey(t.source, t.id));
|
||||
if (task) {
|
||||
if (task.status === 'running') {
|
||||
const p = progress.get(task.id);
|
||||
if (p && p.stage === 'download' && p.total) return { kind: 'running', text: `${Math.round((p.received / p.total) * 100)}%`, pct: p.received / p.total };
|
||||
return { kind: 'running', text: STAGE_LABEL[task.stage ?? 'resolve'] ?? '处理中', pct: 0 };
|
||||
}
|
||||
return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 };
|
||||
}
|
||||
if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 };
|
||||
return null;
|
||||
}
|
||||
|
||||
function number(t: TrackSummary, i: number) {
|
||||
return String(props.numbering === 'track' && t.trackNo ? t.trackNo : i + 1).padStart(2, '0');
|
||||
}
|
||||
|
||||
const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tracklist" :class="{ 'no-cover': !showCover }">
|
||||
<div v-if="!hideToolbar" class="toolbar">
|
||||
<button type="button" class="check" :class="{ on: allSelected, part: selected.size && !allSelected }" @click="toggleAll" title="全选">
|
||||
<Icon v-if="allSelected" name="check" :size="13" />
|
||||
</button>
|
||||
<span class="sel muted">
|
||||
<template v-if="selected.size">已选 <b class="mono">{{ selected.size }}</b> 首</template>
|
||||
<template v-else>
|
||||
共 <b class="mono">{{ tracks.length }}</b> 首
|
||||
<button v-if="pendingCount && pendingCount < tracks.length" class="link" type="button" @click="selectPending">
|
||||
· 选择未下载的 {{ pendingCount }} 首
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
<div class="spacer" />
|
||||
<QualityPicker v-model="quality" :source="source" />
|
||||
<button v-if="selected.size" class="btn primary" type="button" @click="downloadSelected">
|
||||
<Icon name="download" :size="16" /> 下载所选
|
||||
</button>
|
||||
<button v-else class="btn" type="button" :disabled="!tracks.length" @click="downloadAll">
|
||||
<Icon name="download" :size="16" /> 全部下载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol class="rows">
|
||||
<li
|
||||
v-for="(t, i) in tracks"
|
||||
:key="`${t.source}-${t.id}-${i}`"
|
||||
class="row"
|
||||
:class="[t.source, { selected: selected.has(t.id), current: player.current?.key === `${t.source}:${t.id}` }]"
|
||||
@dblclick="playOnline(t)"
|
||||
>
|
||||
<button type="button" class="check" :class="{ on: selected.has(t.id) }" @click="toggleRow(i, $event)" :aria-label="`选择 ${t.name}`">
|
||||
<Icon v-if="selected.has(t.id)" name="check" :size="13" />
|
||||
</button>
|
||||
<span class="num">
|
||||
<span class="mono faint idx">{{ number(t, i) }}</span>
|
||||
<button type="button" class="play" @click="playOnline(t)" :aria-label="`试听 ${t.name}`">
|
||||
<Icon :name="isPlaying(`${t.source}:${t.id}`) ? 'pause' : 'play'" :size="16" />
|
||||
</button>
|
||||
</span>
|
||||
<CoverArt v-if="showCover" :src="t.cover" :size="40" />
|
||||
<div class="title">
|
||||
<div class="name">
|
||||
{{ t.name }}
|
||||
<span v-if="t.vip" class="vip" title="VIP 歌曲">VIP</span>
|
||||
</div>
|
||||
<div class="sub muted">
|
||||
<template v-if="t.artistRefs?.length">
|
||||
<template v-for="(a, ai) in t.artistRefs" :key="a.id">
|
||||
<span v-if="ai" class="faint"> / </span>
|
||||
<RouterLink :to="`/artist/${t.source}/${a.id}`" class="ln">{{ a.name }}</RouterLink>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>{{ t.artists }}</template>
|
||||
<template v-if="showAlbum && t.album">
|
||||
<span class="faint"> · </span>
|
||||
<RouterLink v-if="t.albumId" :to="`/album/${t.source}/${t.albumId}`" class="ln faint">{{ t.album }}</RouterLink>
|
||||
<span v-else class="faint">{{ t.album }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badges">
|
||||
<span v-if="best(t)" class="badge" :class="{ gold: isGold(best(t)) }">{{ QUALITY_BADGE[best(t)!] }}</span>
|
||||
</span>
|
||||
<span class="state">
|
||||
<template v-if="rowState(t)">
|
||||
<span class="st" :class="rowState(t)!.kind">
|
||||
<Icon v-if="rowState(t)!.kind === 'done'" name="check" :size="14" />
|
||||
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.text }}
|
||||
</span>
|
||||
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
|
||||
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
|
||||
<Icon name="download" :size="17" />
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 10px 12px 12px;
|
||||
background: color-mix(in oklab, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1.5px solid var(--ink);
|
||||
}
|
||||
|
||||
.sel {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sel b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1.5px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--accent-ink);
|
||||
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.check:hover {
|
||||
border-color: var(--ink-3);
|
||||
}
|
||||
|
||||
.check.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.check.part {
|
||||
background: linear-gradient(var(--accent), var(--accent)) center / 8px 2px no-repeat;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 32px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 10px 9px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 13px;
|
||||
bottom: 13px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--netease);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row.qq::before {
|
||||
background: var(--qq);
|
||||
}
|
||||
|
||||
.row:hover::before,
|
||||
.row.selected::before,
|
||||
.row.current::before {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
border-bottom-color: var(--line-strong);
|
||||
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.no-cover .row {
|
||||
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 32px;
|
||||
}
|
||||
|
||||
.num {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.idx {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
color: var(--bg);
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.row:hover .play,
|
||||
.row.current .play,
|
||||
.play:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.row.current .play {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row.current .name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vip {
|
||||
margin-left: 6px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font: 600 9px/14px var(--font-mono);
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--qq);
|
||||
border: 1px solid color-mix(in oklab, var(--qq) 50%, transparent);
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.row.netease .vip {
|
||||
color: var(--netease);
|
||||
border-color: color-mix(in oklab, var(--netease) 50%, transparent);
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ln:hover {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.st {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.st.done {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.st.running {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 72px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 300ms linear;
|
||||
}
|
||||
|
||||
.dur {
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dl {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.dl:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: 18px auto minmax(0, 1fr) auto 32px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.no-cover .row {
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto 32px;
|
||||
}
|
||||
|
||||
.num,
|
||||
.badges,
|
||||
.dur {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.state .bar {
|
||||
width: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user