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,89 @@
|
||||
import type { QualityKey, Source } from './api.ts';
|
||||
|
||||
export const SOURCE_LABEL: Record<Source, string> = { netease: '网易云', qq: 'QQ 音乐' };
|
||||
|
||||
export const QUALITY_BADGE: Record<QualityKey, string> = {
|
||||
standard: '128K',
|
||||
high: '320K',
|
||||
lossless: 'FLAC',
|
||||
hires: 'HI-RES',
|
||||
master: 'MASTER',
|
||||
spatial: 'SPATIAL',
|
||||
effect: 'EFFECT',
|
||||
};
|
||||
|
||||
export const QUALITY_LABEL: Record<QualityKey, string> = {
|
||||
standard: '标准',
|
||||
high: '极高',
|
||||
lossless: '无损',
|
||||
hires: 'Hi-Res',
|
||||
master: '超清母带',
|
||||
spatial: '空间音频',
|
||||
effect: '高清臻品',
|
||||
};
|
||||
|
||||
const QUALITY_RANK: QualityKey[] = ['effect', 'spatial', 'master', 'hires', 'lossless', 'high', 'standard'];
|
||||
|
||||
export function isGold(q: string | null | undefined): boolean {
|
||||
return q === 'hires' || q === 'master' || q === 'spatial' || q === 'effect';
|
||||
}
|
||||
|
||||
/** 该曲目声明的最佳音质(用于徽章) */
|
||||
export function bestQuality(qs: QualityKey[] | undefined): QualityKey | undefined {
|
||||
if (!qs?.length) return undefined;
|
||||
return QUALITY_RANK.find((q) => qs.includes(q));
|
||||
}
|
||||
|
||||
export function formatDuration(ms?: number | null): string {
|
||||
if (!ms) return '--:--';
|
||||
const s = Math.round(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatSeconds(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number | null): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`;
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
export function formatSpeed(bps: number): string {
|
||||
return bps >= 1048576 ? `${(bps / 1048576).toFixed(1)} MB/s` : `${Math.round(bps / 1024)} KB/s`;
|
||||
}
|
||||
|
||||
export function formatDate(ts?: number | string | null): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return Number.isNaN(d.getTime()) ? '' : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const dtf = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
export function formatDateTime(ts: string): string {
|
||||
return dtf.format(new Date(ts));
|
||||
}
|
||||
|
||||
export function coverThumb(url: string | undefined | null, size = 120): string | undefined {
|
||||
if (!url) return undefined;
|
||||
const https = url.replace(/^http:/, 'https:');
|
||||
if (/music\.126\.net/.test(https)) return `${https.split('?')[0]}?param=${size}y${size}`;
|
||||
if (/y\.gtimg\.cn\/music\/photo_new\/T00\dR\d+x\d+/.test(https)) {
|
||||
const s = size <= 150 ? 150 : size <= 300 ? 300 : 800;
|
||||
return https.replace(/R\d+x\d+/, `R${s}x${s}`);
|
||||
}
|
||||
return https;
|
||||
}
|
||||
|
||||
export const STAGE_LABEL: Record<string, string> = {
|
||||
resolve: '解析音源',
|
||||
metadata: '获取信息',
|
||||
download: '下载中',
|
||||
lyric: '保存歌词',
|
||||
cover: '获取封面',
|
||||
tag: '写入标签',
|
||||
};
|
||||
Reference in New Issue
Block a user