feat: 曲库索引 —— 曲库里已有的同名歌曲在列表中标记「曲库」
不是通过声匣下载的文件(其他工具 / 手动放入)以前一律显示未下载。 现在启动时扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」匹配, 下载完成增量更新,每 30 分钟或删除 / 补全标签后重建;设置页可手动重建。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { embedTags } from './core/tagging.ts';
|
|||||||
import type { Source, TrackSummary } from './core/types.ts';
|
import type { Source, TrackSummary } from './core/types.ts';
|
||||||
import { errorMessage, sleep } from './core/util.ts';
|
import { errorMessage, sleep } from './core/util.ts';
|
||||||
import { bus } from './events.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']);
|
export const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']);
|
||||||
const TRASH_DIR = path.join(config.dataDir, '.trash');
|
const TRASH_DIR = path.join(config.dataDir, '.trash');
|
||||||
@@ -139,6 +140,7 @@ export async function moveToTrash(rel: string): Promise<string> {
|
|||||||
await fs.promises.cp(target, dest, { recursive: true });
|
await fs.promises.cp(target, dest, { recursive: true });
|
||||||
await fs.promises.rm(target, { recursive: true, force: true });
|
await fs.promises.rm(target, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
void rebuildLibraryIndex();
|
||||||
return toRel(target);
|
return toRel(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +259,7 @@ export async function startRetag(rel: string, source: Source, onlyMissingLyric:
|
|||||||
await sleep(400);
|
await sleep(400);
|
||||||
}
|
}
|
||||||
current.finished = true;
|
current.finished = true;
|
||||||
|
void rebuildLibraryIndex();
|
||||||
current.current = undefined;
|
current.current = undefined;
|
||||||
bus.emit('retag', current);
|
bus.emit('retag', current);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
album: string;
|
||||||
|
file: string; // 相对音乐目录
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 比较用的规范化:全角转半角、小写、去掉空白与引号类符号 */
|
||||||
|
export function norm(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s'"’‘“”`·・]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = new Map<string, Entry[]>();
|
||||||
|
let building: Promise<void> | null = null;
|
||||||
|
let builtAt = 0;
|
||||||
|
let fileCount = 0;
|
||||||
|
|
||||||
|
function addFile(map: Map<string, Entry[]>, 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<string>();
|
||||||
|
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<string, Entry[]>) {
|
||||||
|
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<void> {
|
||||||
|
building ??= (async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
const map = new Map<string, Entry[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { hasFFmpeg } from './core/tagging.ts';
|
|||||||
import { importQQCookieFile, startQQKeeper } from './accounts.ts';
|
import { importQQCookieFile, startQQKeeper } from './accounts.ts';
|
||||||
import { qq } from './core/providers/index.ts';
|
import { qq } from './core/providers/index.ts';
|
||||||
import { importLegacyHistory } from './db.ts';
|
import { importLegacyHistory } from './db.ts';
|
||||||
|
import { startLibraryIndex } from './libraryIndex.ts';
|
||||||
import { queue } from './queue.ts';
|
import { queue } from './queue.ts';
|
||||||
|
|
||||||
const imported = importLegacyHistory(config.legacyHistoryFile);
|
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}`);
|
console.log(` 数据目录: ${config.dataDir}`);
|
||||||
queue.pump();
|
queue.pump();
|
||||||
startQQKeeper(qq);
|
startQQKeeper(qq);
|
||||||
|
startLibraryIndex();
|
||||||
console.log(` ffmpeg : ${(await hasFFmpeg()) ? '✓' : '✗(FLAC 标签写入不可用)'}`);
|
console.log(` ffmpeg : ${(await hasFFmpeg()) ? '✓' : '✗(FLAC 标签写入不可用)'}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isQualityKey, type QualityKey } from './core/quality.ts';
|
|||||||
import { FatalError, type Source } from './core/types.ts';
|
import { FatalError, type Source } from './core/types.ts';
|
||||||
import { errorMessage, sleep } from './core/util.ts';
|
import { errorMessage, sleep } from './core/util.ts';
|
||||||
import { addHistory, db, getSetting, setSetting } from './db.ts';
|
import { addHistory, db, getSetting, setSetting } from './db.ts';
|
||||||
|
import { addToLibraryIndex } from './libraryIndex.ts';
|
||||||
import { bus } from './events.ts';
|
import { bus } from './events.ts';
|
||||||
import { getSettings } from './settings.ts';
|
import { getSettings } from './settings.ts';
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ class DownloadQueue {
|
|||||||
size: result.size,
|
size: result.size,
|
||||||
file: result.relFile,
|
file: result.relFile,
|
||||||
});
|
});
|
||||||
|
addToLibraryIndex(result.relFile);
|
||||||
bus.emit('history');
|
bus.emit('history');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const reason = controller.signal.aborted ? (controller.signal.reason as AbortReason) : null;
|
const reason = controller.signal.aborted ? (controller.signal.reason as AbortReason) : null;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
writeM3u,
|
writeM3u,
|
||||||
zipStream,
|
zipStream,
|
||||||
} from '../library.ts';
|
} from '../library.ts';
|
||||||
|
import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.ts';
|
||||||
import { queue, type EnqueueItem } from '../queue.ts';
|
import { queue, type EnqueueItem } from '../queue.ts';
|
||||||
import { getSettings, updateSettings } from '../settings.ts';
|
import { getSettings, updateSettings } from '../settings.ts';
|
||||||
import { proxyStream } from '../stream.ts';
|
import { proxyStream } from '../stream.ts';
|
||||||
@@ -44,9 +45,16 @@ function body(ctx: Context): Record<string, any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 给列表中的歌曲标记是否已下载过 */
|
/** 给列表中的歌曲标记是否已下载过 */
|
||||||
|
/** 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary) */
|
||||||
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
|
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
|
||||||
const owned = downloadedIds(source, detail.tracks.map((t) => t.id));
|
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) => {
|
api.get('/:source/track/:id', async (ctx) => {
|
||||||
const provider = getProvider(ctx.params.source!);
|
const provider = getProvider(ctx.params.source!);
|
||||||
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
|
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
|
||||||
const owned = downloadedIds(provider.source, [meta.id]);
|
const downloaded = downloadedIds(provider.source, [meta.id]).has(meta.id);
|
||||||
ctx.body = { ...meta, downloaded: owned.has(meta.id) };
|
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : findInLibrary(meta) };
|
||||||
});
|
});
|
||||||
|
|
||||||
api.get('/:source/album/:id', async (ctx) => {
|
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 ?? '')) };
|
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) => {
|
api.get('/library/trash', async (ctx) => {
|
||||||
ctx.body = await trashInfo();
|
ctx.body = await trashInfo();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allSelected = computed(() => props.tracks.length > 0 && selected.value.size === props.tracks.length);
|
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) {
|
function toggleRow(index: number, e: MouseEvent) {
|
||||||
const next = new Set(selected.value);
|
const next = new Set(selected.value);
|
||||||
@@ -56,7 +57,7 @@ function toggleAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectPending() {
|
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() {
|
async function downloadSelected() {
|
||||||
@@ -69,7 +70,14 @@ function downloadAll() {
|
|||||||
download(props.tracks, quality.value, props.group);
|
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));
|
const task = activeByTrack.value.get(trackKey(t.source, t.id));
|
||||||
if (task) {
|
if (task) {
|
||||||
if (task.status === 'running') {
|
if (task.status === 'running') {
|
||||||
@@ -80,6 +88,7 @@ function rowState(t: TrackSummary) {
|
|||||||
return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 };
|
return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 };
|
||||||
}
|
}
|
||||||
if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 };
|
if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 };
|
||||||
|
if (t.inLibrary) return { kind: 'library', text: '曲库中有', pct: 1, title: `曲库中有同名歌曲:${t.inLibrary}` };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +167,9 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
|||||||
</span>
|
</span>
|
||||||
<span class="state">
|
<span class="state">
|
||||||
<template v-if="rowState(t)">
|
<template v-if="rowState(t)">
|
||||||
<span class="st" :class="rowState(t)!.kind">
|
<span class="st" :class="rowState(t)!.kind" :title="rowState(t)!.title ?? rowState(t)!.text">
|
||||||
<Icon v-if="rowState(t)!.kind === 'done'" name="check" :size="14" />
|
<Icon v-if="rowState(t)!.kind === 'done' || rowState(t)!.kind === 'library'" name="check" :size="14" />
|
||||||
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.text }}
|
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.kind === 'library' ? '曲库' : rowState(t)!.text }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
|
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
|
||||||
</template>
|
</template>
|
||||||
@@ -392,6 +401,11 @@ const best = (t: TrackSummary) => bestQuality(t.qualities);
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.st.library {
|
||||||
|
color: var(--ink-3);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.st.done {
|
.st.done {
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface TrackSummary {
|
|||||||
qualities?: QualityKey[];
|
qualities?: QualityKey[];
|
||||||
vip?: boolean;
|
vip?: boolean;
|
||||||
downloaded?: boolean;
|
downloaded?: boolean;
|
||||||
|
/** 曲库中同名文件(相对路径),不是通过本服务下载的 */
|
||||||
|
inLibrary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Paged<T> {
|
export interface Paged<T> {
|
||||||
@@ -84,6 +86,7 @@ export interface TrackMeta {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
publisher?: string;
|
publisher?: string;
|
||||||
downloaded?: boolean;
|
downloaded?: boolean;
|
||||||
|
inLibrary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QualityOption {
|
export interface QualityOption {
|
||||||
@@ -315,6 +318,8 @@ export const api = {
|
|||||||
libraryZipUrl: (path: string) => `/api/library/zip?${qs({ path })}`,
|
libraryZipUrl: (path: string) => `/api/library/zip?${qs({ path })}`,
|
||||||
writeM3u: (path: string) => request<{ file: string }>('/library/m3u', json('POST', { path })),
|
writeM3u: (path: string) => request<{ file: string }>('/library/m3u', json('POST', { path })),
|
||||||
trash: (path: string) => request<{ moved: string }>('/library/trash', 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'),
|
trashInfo: () => request<{ count: number; size: number }>('/library/trash'),
|
||||||
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
|
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
|
||||||
retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job),
|
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: '墨 · 深色' },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -229,6 +240,11 @@ onMounted(() => refreshAccounts());
|
|||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
<p v-if="ffmpeg === false" class="warn small">未检测到 ffmpeg:FLAC 等非 MP3 文件无法写入标签。请安装 ffmpeg 并加入 PATH。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -515,6 +531,10 @@ h3 {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.index {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.warn {
|
.warn {
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user