diff --git a/server/src/library.ts b/server/src/library.ts index a800595..633cbcc 100644 --- a/server/src/library.ts +++ b/server/src/library.ts @@ -12,6 +12,7 @@ import { embedTags } from './core/tagging.ts'; import type { Source, TrackSummary } from './core/types.ts'; import { errorMessage, sleep } from './core/util.ts'; import { bus } from './events.ts'; +import { rebuildLibraryIndex } from './libraryIndex.ts'; export const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']); const TRASH_DIR = path.join(config.dataDir, '.trash'); @@ -139,6 +140,7 @@ export async function moveToTrash(rel: string): Promise { await fs.promises.cp(target, dest, { recursive: true }); await fs.promises.rm(target, { recursive: true, force: true }); } + void rebuildLibraryIndex(); return toRel(target); } @@ -257,6 +259,7 @@ export async function startRetag(rel: string, source: Source, onlyMissingLyric: await sleep(400); } current.finished = true; + void rebuildLibraryIndex(); current.current = undefined; bus.emit('retag', current); })(); diff --git a/server/src/libraryIndex.ts b/server/src/libraryIndex.ts new file mode 100644 index 0000000..57bf314 --- /dev/null +++ b/server/src/libraryIndex.ts @@ -0,0 +1,108 @@ +// 曲库索引:扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」判断某首歌是否已在曲库中, +// 用于给不是通过本服务下载的文件也打上标记。 +import fs from 'node:fs'; +import path from 'node:path'; +import { config } from './config.ts'; +import { sanitize, sanitizePathSegment } from './core/util.ts'; + +const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']); +const REBUILD_INTERVAL = 30 * 60 * 1000; + +interface Entry { + artists: Set; + album: string; + file: string; // 相对音乐目录 +} + +/** 比较用的规范化:全角转半角、小写、去掉空白与引号类符号 */ +export function norm(s: string): string { + return s + .normalize('NFKC') + .toLowerCase() + .replace(/[\s'"’‘“”`·・]/g, ''); +} + +let index = new Map(); +let building: Promise | null = null; +let builtAt = 0; +let fileCount = 0; + +function addFile(map: Map, rel: string) { + const parts = rel.split('/'); + const fileName = parts.at(-1)!; + const base = fileName.slice(0, fileName.length - path.extname(fileName).length); + const artists = new Set(); + let title = base.replace(/^\d+[.\s_-]+\s*/, ''); + + // 「歌手 - 歌名」(旧版扁平格式) + const dash = base.match(/^(.+?)\s+-\s+(.+)$/); + if (dash && !/^\d+[.\s]/.test(base)) { + title = dash[2]!; + for (const a of dash[1]!.split(/\s*[/_&,,、]\s*/)) if (a) artists.add(norm(a)); + } + // 「歌手/专辑/NN. 歌名」:第一层目录是歌手(专辑艺人) + if (parts.length >= 2) artists.add(norm(parts[0]!)); + const album = parts.length >= 3 ? norm(parts.at(-2)!) : ''; + + const key = norm(title); + if (!key) return; + const list = map.get(key) ?? []; + list.push({ artists, album, file: rel }); + map.set(key, list); +} + +async function walk(dir: string, rel: string, map: Map) { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const e of entries) { + if (e.name.startsWith('.') || e.name.startsWith('@')) continue; + const childRel = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) await walk(path.join(dir, e.name), childRel, map); + else if (AUDIO_EXTS.has(path.extname(e.name).toLowerCase())) { + addFile(map, childRel); + fileCount++; + } + } +} + +export function rebuildLibraryIndex(): Promise { + building ??= (async () => { + const started = Date.now(); + const map = new Map(); + fileCount = 0; + await walk(config.musicDir, '', map); + index = map; + builtAt = Date.now(); + console.log(`📚 曲库索引:${fileCount} 个音频文件,用时 ${Date.now() - started} ms`); + })().finally(() => (building = null)); + return building; +} + +/** 下载完成后增量加入,不必等下次重建 */ +export function addToLibraryIndex(relFile: string) { + addFile(index, relFile.split(path.sep).join('/')); +} + +export function startLibraryIndex() { + void rebuildLibraryIndex(); + setInterval(() => void rebuildLibraryIndex(), REBUILD_INTERVAL).unref(); +} + +export function libraryIndexInfo() { + return { files: fileCount, builtAt: builtAt ? new Date(builtAt).toISOString() : null, building: !!building }; +} + +/** + * 在曲库中查找同名歌曲:歌名一致,且歌手文件夹与任一歌手一致或专辑文件夹与专辑一致。 + * 返回匹配到的文件(相对路径),找不到返回 undefined。 + */ +export function findInLibrary(track: { name: string; artists: string; album?: string }): string | undefined { + // 下载时文件名经过 sanitize,这里同样处理,"A/B" 才能对上 "A_B" + const candidates = index.get(norm(sanitize(track.name))); + if (!candidates) return undefined; + const artists = track.artists + .split(/\s*\/\s*/) + .filter(Boolean) + .map((a) => norm(sanitizePathSegment(a, a))); + const album = track.album ? norm(sanitizePathSegment(track.album, track.album)) : ''; + return candidates.find((c) => artists.some((a) => c.artists.has(a)) || (album && c.album === album))?.file; +} diff --git a/server/src/main.ts b/server/src/main.ts index 6823741..63878d0 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -4,6 +4,7 @@ import { hasFFmpeg } from './core/tagging.ts'; import { importQQCookieFile, startQQKeeper } from './accounts.ts'; import { qq } from './core/providers/index.ts'; import { importLegacyHistory } from './db.ts'; +import { startLibraryIndex } from './libraryIndex.ts'; import { queue } from './queue.ts'; const imported = importLegacyHistory(config.legacyHistoryFile); @@ -16,5 +17,6 @@ createApp().listen(config.port, process.env.HOST || '0.0.0.0', async () => { console.log(` 数据目录: ${config.dataDir}`); queue.pump(); startQQKeeper(qq); + startLibraryIndex(); console.log(` ffmpeg : ${(await hasFFmpeg()) ? '✓' : '✗(FLAC 标签写入不可用)'}`); }); diff --git a/server/src/queue.ts b/server/src/queue.ts index e62bd52..4029caa 100644 --- a/server/src/queue.ts +++ b/server/src/queue.ts @@ -6,6 +6,7 @@ import { isQualityKey, type QualityKey } from './core/quality.ts'; import { FatalError, type Source } from './core/types.ts'; import { errorMessage, sleep } from './core/util.ts'; import { addHistory, db, getSetting, setSetting } from './db.ts'; +import { addToLibraryIndex } from './libraryIndex.ts'; import { bus } from './events.ts'; import { getSettings } from './settings.ts'; @@ -291,6 +292,7 @@ class DownloadQueue { size: result.size, file: result.relFile, }); + addToLibraryIndex(result.relFile); bus.emit('history'); } catch (e) { const reason = controller.signal.aborted ? (controller.signal.reason as AbortReason) : null; diff --git a/server/src/routes/api.ts b/server/src/routes/api.ts index c556fba..aa77fe1 100644 --- a/server/src/routes/api.ts +++ b/server/src/routes/api.ts @@ -24,6 +24,7 @@ import { writeM3u, zipStream, } from '../library.ts'; +import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.ts'; import { queue, type EnqueueItem } from '../queue.ts'; import { getSettings, updateSettings } from '../settings.ts'; import { proxyStream } from '../stream.ts'; @@ -44,9 +45,16 @@ function body(ctx: Context): Record { } /** 给列表中的歌曲标记是否已下载过 */ +/** 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary) */ function markDownloaded(source: string, detail: T) { const owned = downloadedIds(source, detail.tracks.map((t) => t.id)); - return { ...detail, tracks: detail.tracks.map((t) => ({ ...t, downloaded: owned.has(t.id) })) }; + return { + ...detail, + tracks: detail.tracks.map((t) => { + const downloaded = owned.has(t.id); + return { ...t, downloaded, inLibrary: downloaded ? undefined : findInLibrary(t) }; + }), + }; } // ---------- 基础 ---------- @@ -144,8 +152,8 @@ api.get('/resolve-link', async (ctx) => { api.get('/:source/track/:id', async (ctx) => { const provider = getProvider(ctx.params.source!); const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲'); - const owned = downloadedIds(provider.source, [meta.id]); - ctx.body = { ...meta, downloaded: owned.has(meta.id) }; + const downloaded = downloadedIds(provider.source, [meta.id]).has(meta.id); + ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : findInLibrary(meta) }; }); api.get('/:source/album/:id', async (ctx) => { @@ -337,6 +345,15 @@ api.post('/library/trash', async (ctx) => { ctx.body = { moved: await moveToTrash(String(body(ctx).path ?? '')) }; }); +api.get('/library/index', (ctx) => { + ctx.body = libraryIndexInfo(); +}); + +api.post('/library/index', async (ctx) => { + await rebuildLibraryIndex(); + ctx.body = libraryIndexInfo(); +}); + api.get('/library/trash', async (ctx) => { ctx.body = await trashInfo(); }); diff --git a/web/src/components/TrackList.vue b/web/src/components/TrackList.vue index ddb73c8..69ca392 100644 --- a/web/src/components/TrackList.vue +++ b/web/src/components/TrackList.vue @@ -35,7 +35,8 @@ watch( ); const allSelected = computed(() => props.tracks.length > 0 && selected.value.size === props.tracks.length); -const pendingCount = computed(() => props.tracks.filter((t) => !isDownloaded(t)).length); +const owned = (t: TrackSummary) => isDownloaded(t) || !!t.inLibrary; +const pendingCount = computed(() => props.tracks.filter((t) => !owned(t)).length); function toggleRow(index: number, e: MouseEvent) { const next = new Set(selected.value); @@ -56,7 +57,7 @@ function toggleAll() { } function selectPending() { - selected.value = new Set(props.tracks.filter((t) => !isDownloaded(t)).map((t) => t.id)); + selected.value = new Set(props.tracks.filter((t) => !owned(t)).map((t) => t.id)); } async function downloadSelected() { @@ -69,7 +70,14 @@ function downloadAll() { download(props.tracks, quality.value, props.group); } -function rowState(t: TrackSummary) { +interface RowState { + kind: string; + text: string; + pct: number; + title?: string; +} + +function rowState(t: TrackSummary): RowState | null { const task = activeByTrack.value.get(trackKey(t.source, t.id)); if (task) { if (task.status === 'running') { @@ -80,6 +88,7 @@ function rowState(t: TrackSummary) { return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 }; } if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 }; + if (t.inLibrary) return { kind: 'library', text: '曲库中有', pct: 1, title: `曲库中有同名歌曲:${t.inLibrary}` }; return null; } @@ -158,9 +167,9 @@ const best = (t: TrackSummary) => bestQuality(t.qualities); @@ -392,6 +401,11 @@ const best = (t: TrackSummary) => bestQuality(t.qualities); font-family: var(--font-mono); } +.st.library { + color: var(--ink-3); + cursor: help; +} + .st.done { color: var(--ok); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ef92bed..3eddaaf 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -20,6 +20,8 @@ export interface TrackSummary { qualities?: QualityKey[]; vip?: boolean; downloaded?: boolean; + /** 曲库中同名文件(相对路径),不是通过本服务下载的 */ + inLibrary?: string; } export interface Paged { @@ -84,6 +86,7 @@ export interface TrackMeta { genre?: string; publisher?: string; downloaded?: boolean; + inLibrary?: string; } export interface QualityOption { @@ -315,6 +318,8 @@ export const api = { libraryZipUrl: (path: string) => `/api/library/zip?${qs({ path })}`, writeM3u: (path: string) => request<{ file: string }>('/library/m3u', json('POST', { path })), trash: (path: string) => request<{ moved: string }>('/library/trash', json('POST', { path })), + libraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index'), + rebuildLibraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index', json('POST')), trashInfo: () => request<{ count: number; size: number }>('/library/trash'), emptyTrash: () => request('/library/trash', { method: 'DELETE' }), retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job), diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 863f201..75cb76f 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -108,7 +108,18 @@ const themes: Array<{ key: ThemeMode; label: string }> = [ { key: 'dark', label: '墨 · 深色' }, ]; -onMounted(() => refreshAccounts()); +// ---- 曲库索引 ---- +const libIndex = ref<{ files: number; builtAt: string | null; building: boolean } | null>(null); +async function reindex() { + libIndex.value = { ...(libIndex.value ?? { files: 0, builtAt: null }), building: true }; + libIndex.value = await api.rebuildLibraryIndex().catch((e) => (toastError(e), null)); + if (libIndex.value) toast(`曲库索引已重建:${libIndex.value.files} 个文件`, 'ok'); +} + +onMounted(() => { + refreshAccounts(); + api.libraryIndex().then((i) => (libIndex.value = i)).catch(() => {}); +});