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
+12
View File
@@ -215,6 +215,7 @@ export interface LibraryEntry {
mtime: number;
hasLyric?: boolean;
cover?: string;
covers?: string[];
}
export interface LibraryListing {
@@ -234,6 +235,15 @@ export interface RetagJob {
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 {
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 })),
libraryIndex: () => request<{ files: number; builtAt: string | null; building: boolean }>('/library/index'),
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'),
emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job),
+3
View File
@@ -7,6 +7,7 @@ import {
type QQStatus,
type QualityKey,
type QueueSummary,
type ArtistImageJob,
type RetagJob,
type Settings,
type Source,
@@ -26,6 +27,7 @@ export const tasks = reactive(new Map<number, Task>());
export const progress = reactive(new Map<number, TaskProgress>());
export const summary = ref<QueueSummary>({ paused: false, concurrency: 2, counts: {} });
export const retagJob = ref<RetagJob | null>(null);
export const artistImageJob = ref<ArtistImageJob | null>(null);
/** 每次有新的下载历史时递增,供页面刷新 */
export const historyTick = ref(0);
/** 本次会话内完成下载的曲目 */
@@ -111,6 +113,7 @@ function connectEvents() {
on('account', () => refreshAccounts(true));
on('history', () => historyTick.value++);
on('retag', (j: RetagJob) => (retagJob.value = j));
on('artist-images', (j: ArtistImageJob) => (artistImageJob.value = j));
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 { formatSize } from '../lib/format.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 router = useRouter();
@@ -17,6 +17,7 @@ const retagOpen = ref(false);
const retagSource = ref<Source>('netease');
const retagMissingOnly = ref(true);
const trash = ref<{ count: number; size: number } | null>(null);
const missingArtists = ref<string[]>([]);
const path = computed(() => String(route.query.path ?? ''));
const crumbs = computed(() => {
@@ -41,15 +42,24 @@ async function load() {
} finally {
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);
watch(path, load);
watch(historyTick, () => !loading.value && load());
watch(
() => retagJob.value?.finished,
(finished) => finished && load(),
() => [retagJob.value?.finished, artistImageJob.value?.finished],
() => load(),
);
function go(p: string) {
@@ -109,6 +119,21 @@ async function emptyTrash() {
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 jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok) ?? []);
</script>
@@ -163,6 +188,27 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
</div>
</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 class="job-head">
<span>
@@ -188,7 +234,10 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)
<div v-if="dirs.length" class="grid">
<button v-for="d in dirs" :key="d.path" type="button" class="tile" @click="go(d.path)">
<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>
<span class="tile-name">{{ d.name }}</span>
@@ -404,6 +453,45 @@ details li.bad {
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 {
position: absolute;
inset: 0;
+1 -1
View File
@@ -233,7 +233,7 @@ onMounted(() => {
{ key: 'lyric', label: '保存 .lrc 歌词' },
{ key: 'translatedLyric', label: '额外保存合并翻译歌词' },
{ key: 'embedTags', label: '写入封面与标签' },
{ key: 'albumCover', label: '专辑目录保存 cover.jpg' },
{ key: 'albumCover', label: '保存专辑封面与歌手头像' },
] as const" :key="opt.key" class="toggle">
<input type="checkbox" :checked="settings[opt.key]" @change="save({ [opt.key]: ($event.target as HTMLInputElement).checked })" />
<span class="sw" />