feat: 曲库索引 —— 曲库里已有的同名歌曲在列表中标记「曲库」
不是通过声匣下载的文件(其他工具 / 手动放入)以前一律显示未下载。 现在启动时扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」匹配, 下载完成增量更新,每 30 分钟或删除 / 补全标签后重建;设置页可手动重建。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user