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,62 @@
|
||||
export function sanitize(name: string): string {
|
||||
return name.replace(/[/\\:*?"<>|]/g, '_').trim();
|
||||
}
|
||||
|
||||
export function sanitizePathSegment(name: unknown, fallback: string): string {
|
||||
const value = sanitize(String(name || '')).replace(/[. ]+$/g, '');
|
||||
return value && value !== '.' && value !== '..' ? value : fallback;
|
||||
}
|
||||
|
||||
// "A/B" "A /B" -> "A / B"
|
||||
export function formatPersonNames(value: string): string;
|
||||
export function formatPersonNames(value: string | undefined): string | undefined;
|
||||
export function formatPersonNames(value: string | undefined): string | undefined {
|
||||
return typeof value === 'string' ? value.replace(/\s*\/\s*/g, ' / ') : value;
|
||||
}
|
||||
|
||||
export function joinNames(names: Array<{ name?: string } | string | undefined> | undefined): string {
|
||||
return (names || [])
|
||||
.map((item) => (typeof item === 'string' ? item : item?.name))
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(signal.reason);
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal!.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// 简单的带过期时间的内存缓存,用于专辑详情等批量下载时反复请求的数据
|
||||
export class TtlCache<V> {
|
||||
private map = new Map<string, { value: V; expires: number }>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly max: number;
|
||||
|
||||
constructor(ttlMs: number, max = 500) {
|
||||
this.ttlMs = ttlMs;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
async getOrSet(key: string, load: () => Promise<V>): Promise<V> {
|
||||
const hit = this.map.get(key);
|
||||
if (hit && hit.expires > Date.now()) return hit.value;
|
||||
const value = await load();
|
||||
if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value!);
|
||||
this.map.set(key, { value, expires: Date.now() + this.ttlMs });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user