feat: 歌手头像 —— 下载时写入 artist.jpg、曲库批量补全、无头像时用专辑封面拼图

- 按「歌手/专辑」下载时,歌手目录没有图片则保存歌手头像(合辑不写)
- 曲库页列出缺少头像的歌手文件夹,一键从网易云 / QQ 音乐按名字精确匹配补全
- 网易云头像缩到 600×600,避免原图数 MB
- 仍无头像的文件夹显示前 4 张专辑封面拼图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 23:15:31 +08:00
parent 7a3de9afba
commit 4a8fe455f3
11 changed files with 302 additions and 10 deletions
+47
View File
@@ -0,0 +1,47 @@
// 歌手头像:按名字在网易云精确匹配,找不到再用 QQ 音乐 smartbox(免登录)。
// 结果用于写入曲库歌手文件夹的 artist.jpg(Navidrome 也会读取)。
import { fetchJson, UA } from './net/http.ts';
/** 网易云的默认头像(没有上传头像的歌手都是这张) */
const NETEASE_DEFAULT_AVATAR = '18686200114669622';
function normName(s: string): string {
return s.normalize('NFKC').toLowerCase().replace(/[\s'"’‘“”`·・.]/g, '');
}
/** 网易云歌手对象 → 头像地址(优先正方形的 img1v1Url) */
export function neteaseArtistAvatar(a: { img1v1Url?: string; picUrl?: string } | null | undefined): string | undefined {
if (!a) return undefined;
for (const url of [a.img1v1Url, a.picUrl]) {
// 原图可能有好几 MB,统一缩到 600×600
if (url && !url.includes(NETEASE_DEFAULT_AVATAR)) return `${url.replace(/^http:/, 'https:').split('?')[0]}?param=600y600`;
}
return undefined;
}
export function qqSingerAvatar(mid: string | undefined): string | undefined {
return mid ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${mid}.jpg` : undefined;
}
async function fromNetease(name: string, baseUrl: string): Promise<string | undefined> {
const url = `${baseUrl}/api/search/get?s=${encodeURIComponent(name)}&type=100&limit=10`;
const data = await fetchJson<any>(url, { headers: { Referer: baseUrl, Cookie: 'os=pc' }, timeoutMs: 8000 }).catch(() => null);
const want = normName(name);
const artist = (data?.result?.artists || []).find(
(a: any) => normName(a.name || '') === want || (a.alias || []).some((x: string) => normName(x) === want) || (a.trans && normName(a.trans) === want),
);
return neteaseArtistAvatar(artist);
}
async function fromQQ(name: string): Promise<string | undefined> {
const url = `https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?key=${encodeURIComponent(name)}&format=json&outCharset=utf-8`;
const data = await fetchJson<any>(url, { headers: { Referer: 'https://y.qq.com/', 'User-Agent': UA }, timeoutMs: 8000 }).catch(() => null);
const want = normName(name);
const singer = (data?.data?.singer?.itemlist || []).find((s: any) => normName(s.name || '') === want);
return qqSingerAvatar(singer?.mid);
}
/** 按歌手名查头像;名字必须精确一致,避免存错(如「寻找陈奕迅」) */
export async function findArtistAvatar(name: string, neteaseBaseUrl: string): Promise<string | undefined> {
return (await fromNetease(name, neteaseBaseUrl)) ?? (await fromQQ(name));
}
+20
View File
@@ -23,6 +23,17 @@ export interface DownloadOptions {
const TAG_OVERHEAD_MAX = 8 * 1024 * 1024; const TAG_OVERHEAD_MAX = 8 * 1024 * 1024;
/** 文件夹封面 / 歌手头像的常见文件名(曲库页按此顺序取图) */
export const FOLDER_IMAGES = ['artist.jpg', 'folder.jpg', 'cover.jpg', 'cover.png', 'artist.png'];
export async function hasFolderImage(dir: string): Promise<boolean> {
for (const name of FOLDER_IMAGES) {
const stat = await fs.promises.stat(path.join(dir, name)).catch(() => null);
if (stat?.size) return true;
}
return false;
}
export type Stage ='resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag'; export type Stage ='resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag';
export interface Reporter { export interface Reporter {
@@ -151,6 +162,15 @@ async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadO
if (!stat?.size) await fs.promises.writeFile(coverPath, cover); if (!stat?.size) await fs.promises.writeFile(coverPath, cover);
} }
// 歌手目录还没有头像时写入 artist.jpg(合辑「群星」不写)
if (wantAlbumCover && meta?.albumArtistImage && !meta.compilation) {
const artistDir = path.dirname(target.dir);
if (!(await hasFolderImage(artistDir))) {
const avatar = await fetchBuffer(meta.albumArtistImage, { signal }).catch(() => null);
if (avatar?.length) await fs.promises.writeFile(path.join(artistDir, 'artist.jpg'), avatar);
}
}
if (opts.embedTags) { if (opts.embedTags) {
reporter?.stage?.('tag', '写入标签'); reporter?.stage?.('tag', '写入标签');
try { try {
@@ -15,6 +15,7 @@ import {
type TrackMeta, type TrackMeta,
type TrackSummary, type TrackSummary,
} from '../../types.ts'; } from '../../types.ts';
import { neteaseArtistAvatar } from '../../artistImage.ts';
import { formatPersonNames, joinNames, TtlCache } from '../../util.ts'; import { formatPersonNames, joinNames, TtlCache } from '../../util.ts';
export interface NeteaseConfig { export interface NeteaseConfig {
@@ -66,6 +67,7 @@ interface NeteaseAlbumRaw {
size: number; size: number;
artists: Array<{ id: number; name: string }>; artists: Array<{ id: number; name: string }>;
albumArtist: string; albumArtist: string;
primaryArtistImage?: string;
isCompilation: boolean; isCompilation: boolean;
picUrl?: string; picUrl?: string;
songs: any[]; songs: any[];
@@ -191,6 +193,8 @@ export class NeteaseProvider implements MusicProvider {
size: album.size || 0, size: album.size || 0,
artists, artists,
albumArtist, albumArtist,
// album.artist 带头像,artists[] 里没有
primaryArtistImage: album.artist?.name === artists[0]?.name ? neteaseArtistAvatar(album.artist) : undefined,
isCompilation: COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')), isCompilation: COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')),
picUrl: album.picUrl || '', picUrl: album.picUrl || '',
songs: data.songs || album.songs || [], songs: data.songs || album.songs || [],
@@ -239,6 +243,7 @@ export class NeteaseProvider implements MusicProvider {
if (album) { if (album) {
meta.albumArtist = album.albumArtist || undefined; meta.albumArtist = album.albumArtist || undefined;
meta.albumArtistPrimary = album.artists[0]?.name; meta.albumArtistPrimary = album.artists[0]?.name;
meta.albumArtistImage = album.primaryArtistImage;
meta.compilation = album.isCompilation || undefined; meta.compilation = album.isCompilation || undefined;
meta.trackTotal = album.size || undefined; meta.trackTotal = album.size || undefined;
meta.cover = album.picUrl || meta.cover; meta.cover = album.picUrl || meta.cover;
+2
View File
@@ -14,6 +14,7 @@ import {
type TrackMeta, type TrackMeta,
type TrackSummary, type TrackSummary,
} from '../../types.ts'; } from '../../types.ts';
import { qqSingerAvatar } from '../../artistImage.ts';
import { joinNames, TtlCache } from '../../util.ts'; import { joinNames, TtlCache } from '../../util.ts';
import { QQClient, type QQCredentials } from './client.ts'; import { QQClient, type QQCredentials } from './client.ts';
@@ -275,6 +276,7 @@ export class QQProvider implements MusicProvider {
const albumArtist = joinNames(album.singers); const albumArtist = joinNames(album.singers);
meta.albumArtist = albumArtist || undefined; meta.albumArtist = albumArtist || undefined;
meta.albumArtistPrimary = album.singers[0]?.name; meta.albumArtistPrimary = album.singers[0]?.name;
meta.albumArtistImage = qqSingerAvatar(album.singers[0]?.mid);
meta.compilation = COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')) || undefined; meta.compilation = COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')) || undefined;
meta.trackTotal = album.total || undefined; meta.trackTotal = album.total || undefined;
meta.genre ||= album.genre; meta.genre ||= album.genre;
+1
View File
@@ -46,6 +46,7 @@ export interface TrackMeta {
disc?: string; disc?: string;
albumArtist?: string; // 全部专辑艺人,"A / B" albumArtist?: string; // 全部专辑艺人,"A / B"
albumArtistPrimary?: string; // 第一位专辑艺人,用作目录名 albumArtistPrimary?: string; // 第一位专辑艺人,用作目录名
albumArtistImage?: string; // 第一位专辑艺人的头像,写入歌手目录的 artist.jpg
compilation?: boolean; compilation?: boolean;
year?: number; year?: number;
date?: string; // YYYY-MM-DD date?: string; // YYYY-MM-DD
+106 -4
View File
@@ -4,7 +4,8 @@ import path from 'node:path';
import { PassThrough } from 'node:stream'; import { PassThrough } from 'node:stream';
import yazl from 'yazl'; import yazl from 'yazl';
import { config } from './config.ts'; import { config } from './config.ts';
import { buildTagMeta } from './core/pipeline.ts'; import { findArtistAvatar } from './core/artistImage.ts';
import { buildTagMeta, hasFolderImage } from './core/pipeline.ts';
import { mergeLrc } from './core/lyrics.ts'; import { mergeLrc } from './core/lyrics.ts';
import { fetchBuffer } from './core/net/http.ts'; import { fetchBuffer } from './core/net/http.ts';
import { getProvider } from './core/providers/index.ts'; import { getProvider } from './core/providers/index.ts';
@@ -42,9 +43,29 @@ export interface LibraryEntry {
hasLyric?: boolean; hasLyric?: boolean;
/** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */ /** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */
cover?: string; cover?: string;
/** 没有文件夹图片时,取前几个子文件夹(专辑)的封面拼图 */
covers?: string[];
} }
const DIR_IMAGES = ['cover.jpg', 'folder.jpg', 'cover.png', 'artist.jpg']; const DIR_IMAGES = ['cover.jpg', 'folder.jpg', 'cover.png', 'artist.jpg', 'artist.png'];
function dirImage(abs: string): string | undefined {
const image = DIR_IMAGES.find((img) => fs.existsSync(path.join(abs, img)));
return image ? path.join(abs, image) : undefined;
}
/** 子文件夹(专辑)封面,最多 4 张 */
async function childCovers(abs: string): Promise<string[] | undefined> {
const out: string[] = [];
const children = await fs.promises.readdir(abs, { withFileTypes: true }).catch(() => []);
for (const c of children.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }))) {
if (!c.isDirectory() || c.name.startsWith('.')) continue;
const img = dirImage(path.join(abs, c.name));
if (img) out.push(toRel(img));
if (out.length >= 4) break;
}
return out.length ? out : undefined;
}
export async function listDir(rel: string | undefined) { export async function listDir(rel: string | undefined) {
const dir = resolveInLibrary(rel); const dir = resolveInLibrary(rel);
@@ -59,8 +80,16 @@ export async function listDir(rel: string | undefined) {
const s = await fs.promises.stat(abs).catch(() => null); const s = await fs.promises.stat(abs).catch(() => null);
if (!s) continue; if (!s) continue;
if (s.isDirectory()) { if (s.isDirectory()) {
const image = DIR_IMAGES.find((img) => fs.existsSync(path.join(abs, img))); const image = dirImage(abs);
entries.push({ name, path: toRel(abs), type: 'dir', size: 0, mtime: s.mtimeMs, cover: image ? toRel(path.join(abs, image)) : undefined }); entries.push({
name,
path: toRel(abs),
type: 'dir',
size: 0,
mtime: s.mtimeMs,
cover: image ? toRel(image) : undefined,
covers: image ? undefined : await childCovers(abs),
});
} else { } else {
const ext = path.extname(name).toLowerCase(); const ext = path.extname(name).toLowerCase();
const isAudio = AUDIO_EXTS.has(ext); const isAudio = AUDIO_EXTS.has(ext);
@@ -265,3 +294,76 @@ export async function startRetag(rel: string, source: Source, onlyMissingLyric:
})(); })();
return current; return current;
} }
// ---------- 补全歌手头像 ----------
export interface ArtistImageJob {
id: number;
total: number;
done: number;
current?: string;
results: Array<{ artist: string; ok: boolean; error?: string }>;
finished: boolean;
}
const COMPILATION_DIRS = ['群星', '华语群星', '欧美群星', '日韩群星', 'variousartists', 'va'];
let artistJob: ArtistImageJob | null = null;
export function currentArtistImageJob(): ArtistImageJob | null {
return artistJob;
}
async function containsAudio(abs: string, depth = 0): Promise<boolean> {
if (depth > 3) return false;
for (const e of await fs.promises.readdir(abs, { withFileTypes: true }).catch(() => [])) {
if (e.name.startsWith('.')) continue;
if (e.isFile() && AUDIO_EXTS.has(path.extname(e.name).toLowerCase())) return true;
if (e.isDirectory() && (await containsAudio(path.join(abs, e.name), depth + 1))) return true;
}
return false;
}
/** 音乐目录第一层(歌手文件夹)中缺少头像、且确实有音频的文件夹 */
export async function artistsMissingImage(): Promise<string[]> {
const out: string[] = [];
for (const e of await fs.promises.readdir(config.musicDir, { withFileTypes: true })) {
if (!e.isDirectory() || e.name.startsWith('.') || e.name.startsWith('@')) continue;
if (COMPILATION_DIRS.includes(e.name.toLowerCase().replace(/[.\s]/g, ''))) continue;
const abs = path.join(config.musicDir, e.name);
if (await hasFolderImage(abs)) continue;
if (await containsAudio(abs)) out.push(e.name);
}
return out.sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
export async function startArtistImages(): Promise<ArtistImageJob> {
if (artistJob && !artistJob.finished) throw httpError(409, '已有头像补全任务在进行');
const names = await artistsMissingImage();
const current: ArtistImageJob = { id: ++jobSeq, total: names.length, done: 0, results: [], finished: false };
artistJob = current;
bus.emit('artist-images', current);
void (async () => {
for (const name of names) {
current.current = name;
bus.emit('artist-images', current);
try {
const url = await findArtistAvatar(name, config.netease.baseUrl);
if (!url) throw new Error('网易云 / QQ 音乐都没有同名歌手');
const image = await fetchBuffer(url);
if (image.length < 1024) throw new Error('头像图片无效');
await fs.promises.writeFile(path.join(config.musicDir, name, 'artist.jpg'), image);
current.results.push({ artist: name, ok: true });
} catch (e) {
current.results.push({ artist: name, ok: false, error: errorMessage(e) });
}
current.done++;
bus.emit('artist-images', current);
await sleep(300);
}
current.finished = true;
current.current = undefined;
bus.emit('artist-images', current);
})();
return current;
}
+12
View File
@@ -14,11 +14,14 @@ import { allHistory, downloadedIds, historyStats, listHistory } from '../db.ts';
import { bus } from '../events.ts'; import { bus } from '../events.ts';
import { import {
AUDIO_EXTS, AUDIO_EXTS,
artistsMissingImage,
currentArtistImageJob,
currentRetagJob, currentRetagJob,
emptyTrash, emptyTrash,
listDir, listDir,
moveToTrash, moveToTrash,
resolveInLibrary, resolveInLibrary,
startArtistImages,
startRetag, startRetag,
trashInfo, trashInfo,
writeM3u, writeM3u,
@@ -260,6 +263,7 @@ api.get('/events', (ctx) => {
account: (d) => send('account', d), account: (d) => send('account', d),
history: () => send('history', {}), history: () => send('history', {}),
retag: (d) => send('retag', d), retag: (d) => send('retag', d),
'artist-images': (d) => send('artist-images', d),
}; };
for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn); for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn);
send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() }); send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() });
@@ -345,6 +349,14 @@ 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/artist-images', async (ctx) => {
ctx.body = { job: currentArtistImageJob(), missing: await artistsMissingImage() };
});
api.post('/library/artist-images', async (ctx) => {
ctx.body = await startArtistImages();
});
api.get('/library/index', (ctx) => { api.get('/library/index', (ctx) => {
ctx.body = libraryIndexInfo(); ctx.body = libraryIndexInfo();
}); });
+12
View File
@@ -215,6 +215,7 @@ export interface LibraryEntry {
mtime: number; mtime: number;
hasLyric?: boolean; hasLyric?: boolean;
cover?: string; cover?: string;
covers?: string[];
} }
export interface LibraryListing { export interface LibraryListing {
@@ -234,6 +235,15 @@ export interface RetagJob {
finished: boolean; finished: boolean;
} }
export interface ArtistImageJob {
id: number;
total: number;
done: number;
current?: string;
results: Array<{ artist: string; ok: boolean; error?: string }>;
finished: boolean;
}
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
constructor(message: string, status: number) { constructor(message: string, status: number) {
@@ -320,6 +330,8 @@ export const api = {
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'), libraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index'),
rebuildLibraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index', json('POST')), rebuildLibraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index', json('POST')),
artistImages: () => request<{ job: ArtistImageJob | null; missing: string[] }>('/library/artist-images'),
startArtistImages: () => request<ArtistImageJob>('/library/artist-images', 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),
+3
View File
@@ -7,6 +7,7 @@ import {
type QQStatus, type QQStatus,
type QualityKey, type QualityKey,
type QueueSummary, type QueueSummary,
type ArtistImageJob,
type RetagJob, type RetagJob,
type Settings, type Settings,
type Source, type Source,
@@ -26,6 +27,7 @@ export const tasks = reactive(new Map<number, Task>());
export const progress = reactive(new Map<number, TaskProgress>()); export const progress = reactive(new Map<number, TaskProgress>());
export const summary = ref<QueueSummary>({ paused: false, concurrency: 2, counts: {} }); export const summary = ref<QueueSummary>({ paused: false, concurrency: 2, counts: {} });
export const retagJob = ref<RetagJob | null>(null); export const retagJob = ref<RetagJob | null>(null);
export const artistImageJob = ref<ArtistImageJob | null>(null);
/** 每次有新的下载历史时递增,供页面刷新 */ /** 每次有新的下载历史时递增,供页面刷新 */
export const historyTick = ref(0); export const historyTick = ref(0);
/** 本次会话内完成下载的曲目 */ /** 本次会话内完成下载的曲目 */
@@ -111,6 +113,7 @@ function connectEvents() {
on('account', () => refreshAccounts(true)); on('account', () => refreshAccounts(true));
on('history', () => historyTick.value++); on('history', () => historyTick.value++);
on('retag', (j: RetagJob) => (retagJob.value = j)); on('retag', (j: RetagJob) => (retagJob.value = j));
on('artist-images', (j: ArtistImageJob) => (artistImageJob.value = j));
source.onerror = () => (connected.value = false); source.onerror = () => (connected.value = false);
} }
+93 -5
View File
@@ -5,7 +5,7 @@ import Icon from '../components/Icon.vue';
import { api, type LibraryEntry, type LibraryListing, type Source } from '../lib/api.ts'; import { api, type LibraryEntry, type LibraryListing, type Source } from '../lib/api.ts';
import { formatSize } from '../lib/format.ts'; import { formatSize } from '../lib/format.ts';
import { isPlaying, play } from '../lib/player.ts'; import { isPlaying, play } from '../lib/player.ts';
import { historyTick, retagJob, toast, toastError } from '../lib/store.ts'; import { artistImageJob, historyTick, retagJob, toast, toastError } from '../lib/store.ts';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -17,6 +17,7 @@ const retagOpen = ref(false);
const retagSource = ref<Source>('netease'); const retagSource = ref<Source>('netease');
const retagMissingOnly = ref(true); const retagMissingOnly = ref(true);
const trash = ref<{ count: number; size: number } | null>(null); const trash = ref<{ count: number; size: number } | null>(null);
const missingArtists = ref<string[]>([]);
const path = computed(() => String(route.query.path ?? '')); const path = computed(() => String(route.query.path ?? ''));
const crumbs = computed(() => { const crumbs = computed(() => {
@@ -41,15 +42,24 @@ async function load() {
} finally { } finally {
loading.value = false; loading.value = false;
} }
if (!path.value) api.trashInfo().then((t) => (trash.value = t)).catch(() => {}); if (!path.value) {
api.trashInfo().then((t) => (trash.value = t)).catch(() => {});
api
.artistImages()
.then((r) => {
missingArtists.value = r.missing;
artistImageJob.value ??= r.job;
})
.catch(() => {});
}
} }
onMounted(load); onMounted(load);
watch(path, load); watch(path, load);
watch(historyTick, () => !loading.value && load()); watch(historyTick, () => !loading.value && load());
watch( watch(
() => retagJob.value?.finished, () => [retagJob.value?.finished, artistImageJob.value?.finished],
(finished) => finished && load(), () => load(),
); );
function go(p: string) { function go(p: string) {
@@ -109,6 +119,21 @@ async function emptyTrash() {
trash.value = { count: 0, size: 0 }; trash.value = { count: 0, size: 0 };
} }
async function fillArtistImages() {
try {
artistImageJob.value = await api.startArtistImages();
if (!artistImageJob.value.total) toast('所有歌手文件夹都已有头像');
} catch (e) {
toastError(e);
}
}
const artistJobFailures = computed(() => artistImageJob.value?.results.filter((r) => !r.ok) ?? []);
/** 带目录修改时间做缓存参数,写入新头像后能立即刷新 */
const img = (p: string, v: number) => `${api.libraryFileUrl(p)}&v=${Math.round(v)}`;
const artistJobPct = computed(() => (artistImageJob.value?.total ? (artistImageJob.value.done / artistImageJob.value.total) * 100 : 0));
const jobPct = computed(() => (retagJob.value?.total ? (retagJob.value.done / retagJob.value.total) * 100 : 0)); const jobPct = computed(() => (retagJob.value?.total ? (retagJob.value.done / retagJob.value.total) * 100 : 0));
const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok) ?? []); const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok) ?? []);
</script> </script>
@@ -163,6 +188,27 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
</div> </div>
</Transition> </Transition>
<div v-if="!path && (missingArtists.length || (artistImageJob && !artistImageJob.finished))" class="job artists">
<div class="job-head">
<span>
<template v-if="artistImageJob && !artistImageJob.finished"><b>正在补全歌手头像</b><span class="faint"> · {{ artistImageJob.current }}</span></template>
<template v-else><b>{{ missingArtists.length }} 位歌手缺少头像</b><span class="faint"> · {{ missingArtists.slice(0, 6).join('、') }}{{ missingArtists.length > 6 ? ' 等' : '' }}</span></template>
</span>
<span v-if="artistImageJob && !artistImageJob.finished" class="mono faint">{{ artistImageJob.done }}/{{ artistImageJob.total }}</span>
<button v-else class="btn sm" type="button" @click="fillArtistImages"><Icon name="user" :size="14" /> 补全歌手头像</button>
</div>
<span v-if="artistImageJob && !artistImageJob.finished" class="bar"><i :style="{ width: `${artistJobPct}%` }" /></span>
<p class="faint small">从网易云 / QQ 音乐按歌手名精确匹配,保存为文件夹内的 artist.jpg(Navidrome 也会使用)。</p>
</div>
<div v-if="!path && artistImageJob?.finished && artistJobFailures.length" class="job artists done">
<details class="small">
<summary class="faint">上次补全:成功 {{ artistImageJob.results.length - artistJobFailures.length }} 位,未找到 {{ artistJobFailures.length }} 位</summary>
<ul>
<li v-for="r in artistJobFailures" :key="r.artist" class="bad">{{ r.artist }} —— {{ r.error }}</li>
</ul>
</details>
</div>
<div v-if="retagJob && (!retagJob.finished || retagJob.results.length)" class="job"> <div v-if="retagJob && (!retagJob.finished || retagJob.results.length)" class="job">
<div class="job-head"> <div class="job-head">
<span> <span>
@@ -188,7 +234,10 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
<div v-if="dirs.length" class="grid"> <div v-if="dirs.length" class="grid">
<button v-for="d in dirs" :key="d.path" type="button" class="tile" @click="go(d.path)"> <button v-for="d in dirs" :key="d.path" type="button" class="tile" @click="go(d.path)">
<span class="art"> <span class="art">
<img v-if="d.cover" :src="api.libraryFileUrl(d.cover)" alt="" loading="lazy" /> <img v-if="d.cover" :src="img(d.cover, d.mtime)" alt="" loading="lazy" />
<span v-else-if="d.covers?.length" class="mosaic" :class="`n${Math.min(d.covers.length, 4)}`">
<img v-for="c in d.covers.slice(0, 4)" :key="c" :src="api.libraryFileUrl(c)" alt="" loading="lazy" @error="($event.target as HTMLImageElement).style.visibility = 'hidden'" />
</span>
<span v-else class="glyph"><Icon name="folder" :size="30" /></span> <span v-else class="glyph"><Icon name="folder" :size="30" /></span>
</span> </span>
<span class="tile-name">{{ d.name }}</span> <span class="tile-name">{{ d.name }}</span>
@@ -404,6 +453,45 @@ details li.bad {
display: block; display: block;
} }
.mosaic {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2px;
background: var(--surface);
}
.mosaic img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mosaic.n1 {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.mosaic.n2 img,
.mosaic.n3 img:first-child {
grid-row: span 2;
}
.job.artists {
border-left-color: var(--qq);
}
.job.done {
border-left-color: var(--line-strong);
padding: 10px 18px;
}
.job p {
margin: 0;
}
.glyph { .glyph {
position: absolute; position: absolute;
inset: 0; inset: 0;
+1 -1
View File
@@ -233,7 +233,7 @@ onMounted(() => {
{ key: 'lyric', label: '保存 .lrc 歌词' }, { key: 'lyric', label: '保存 .lrc 歌词' },
{ key: 'translatedLyric', label: '额外保存合并翻译歌词' }, { key: 'translatedLyric', label: '额外保存合并翻译歌词' },
{ key: 'embedTags', label: '写入封面与标签' }, { key: 'embedTags', label: '写入封面与标签' },
{ key: 'albumCover', label: '专辑目录保存 cover.jpg' }, { key: 'albumCover', label: '保存专辑封面与歌手头像' },
] as const" :key="opt.key" class="toggle"> ] as const" :key="opt.key" class="toggle">
<input type="checkbox" :checked="settings[opt.key]" @change="save({ [opt.key]: ($event.target as HTMLInputElement).checked })" /> <input type="checkbox" :checked="settings[opt.key]" @change="save({ [opt.key]: ($event.target as HTMLInputElement).checked })" />
<span class="sw" /> <span class="sw" />