feat: 曲库索引 —— 曲库里已有的同名歌曲在列表中标记「曲库」

不是通过声匣下载的文件(其他工具 / 手动放入)以前一律显示未下载。
现在启动时扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」匹配,
下载完成增量更新,每 30 分钟或删除 / 补全标签后重建;设置页可手动重建。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 22:53:04 +08:00
parent d5152080bb
commit 85b2d36b3d
8 changed files with 181 additions and 10 deletions
+20 -6
View File
@@ -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);
</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 class="st" :class="rowState(t)!.kind" :title="rowState(t)!.title ?? rowState(t)!.text">
<Icon v-if="rowState(t)!.kind === 'done' || rowState(t)!.kind === 'library'" name="check" :size="14" />
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.kind === 'library' ? '曲库' : rowState(t)!.text }}
</span>
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
</template>
@@ -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);
}
+5
View File
@@ -20,6 +20,8 @@ export interface TrackSummary {
qualities?: QualityKey[];
vip?: boolean;
downloaded?: boolean;
/** 曲库中同名文件(相对路径),不是通过本服务下载的 */
inLibrary?: string;
}
export interface Paged<T> {
@@ -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),
+21 -1
View File
@@ -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(() => {});
});
</script>
<template>
@@ -229,6 +240,11 @@ onMounted(() => refreshAccounts());
{{ opt.label }}
</label>
</div>
<p v-if="libIndex" class="faint small index">
曲库索引:{{ libIndex.files }} 个音频文件<template v-if="libIndex.builtAt">,{{ relTime(libIndex.builtAt) }}更新</template>
—— 列表中「曲库」标记来自这里,每 30 分钟自动刷新
<button class="link" type="button" :disabled="libIndex.building" @click="reindex">{{ libIndex.building ? '重建中…' : '立即重建' }}</button>
</p>
<p v-if="ffmpeg === false" class="warn small">未检测到 ffmpeg:FLAC 等非 MP3 文件无法写入标签。请安装 ffmpeg 并加入 PATH。</p>
</section>
@@ -515,6 +531,10 @@ h3 {
outline-offset: 2px;
}
.index {
margin: 16px 0 0;
}
.warn {
color: var(--gold);
margin: 14px 0 0;