diff --git a/server/src/core/artistImage.ts b/server/src/core/artistImage.ts new file mode 100644 index 0000000..6d646d5 --- /dev/null +++ b/server/src/core/artistImage.ts @@ -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 { + const url = `${baseUrl}/api/search/get?s=${encodeURIComponent(name)}&type=100&limit=10`; + const data = await fetchJson(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 { + 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(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 { + return (await fromNetease(name, neteaseBaseUrl)) ?? (await fromQQ(name)); +} diff --git a/server/src/core/pipeline.ts b/server/src/core/pipeline.ts index 3b0d0d6..3339213 100644 --- a/server/src/core/pipeline.ts +++ b/server/src/core/pipeline.ts @@ -23,6 +23,17 @@ export interface DownloadOptions { 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 { + 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 interface Reporter { @@ -151,6 +162,15 @@ async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadO 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) { reporter?.stage?.('tag', '写入标签'); try { diff --git a/server/src/core/providers/netease/index.ts b/server/src/core/providers/netease/index.ts index b8c444a..b104637 100644 --- a/server/src/core/providers/netease/index.ts +++ b/server/src/core/providers/netease/index.ts @@ -15,6 +15,7 @@ import { type TrackMeta, type TrackSummary, } from '../../types.ts'; +import { neteaseArtistAvatar } from '../../artistImage.ts'; import { formatPersonNames, joinNames, TtlCache } from '../../util.ts'; export interface NeteaseConfig { @@ -66,6 +67,7 @@ interface NeteaseAlbumRaw { size: number; artists: Array<{ id: number; name: string }>; albumArtist: string; + primaryArtistImage?: string; isCompilation: boolean; picUrl?: string; songs: any[]; @@ -191,6 +193,8 @@ export class NeteaseProvider implements MusicProvider { size: album.size || 0, artists, albumArtist, + // album.artist 带头像,artists[] 里没有 + primaryArtistImage: album.artist?.name === artists[0]?.name ? neteaseArtistAvatar(album.artist) : undefined, isCompilation: COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')), picUrl: album.picUrl || '', songs: data.songs || album.songs || [], @@ -239,6 +243,7 @@ export class NeteaseProvider implements MusicProvider { if (album) { meta.albumArtist = album.albumArtist || undefined; meta.albumArtistPrimary = album.artists[0]?.name; + meta.albumArtistImage = album.primaryArtistImage; meta.compilation = album.isCompilation || undefined; meta.trackTotal = album.size || undefined; meta.cover = album.picUrl || meta.cover; diff --git a/server/src/core/providers/qq/index.ts b/server/src/core/providers/qq/index.ts index 41775e9..7a7ea58 100644 --- a/server/src/core/providers/qq/index.ts +++ b/server/src/core/providers/qq/index.ts @@ -14,6 +14,7 @@ import { type TrackMeta, type TrackSummary, } from '../../types.ts'; +import { qqSingerAvatar } from '../../artistImage.ts'; import { joinNames, TtlCache } from '../../util.ts'; import { QQClient, type QQCredentials } from './client.ts'; @@ -275,6 +276,7 @@ export class QQProvider implements MusicProvider { const albumArtist = joinNames(album.singers); meta.albumArtist = albumArtist || undefined; 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.trackTotal = album.total || undefined; meta.genre ||= album.genre; diff --git a/server/src/core/types.ts b/server/src/core/types.ts index 29f17c6..f82aff5 100644 --- a/server/src/core/types.ts +++ b/server/src/core/types.ts @@ -46,6 +46,7 @@ export interface TrackMeta { disc?: string; albumArtist?: string; // 全部专辑艺人,"A / B" albumArtistPrimary?: string; // 第一位专辑艺人,用作目录名 + albumArtistImage?: string; // 第一位专辑艺人的头像,写入歌手目录的 artist.jpg compilation?: boolean; year?: number; date?: string; // YYYY-MM-DD diff --git a/server/src/library.ts b/server/src/library.ts index 633cbcc..18e2ebd 100644 --- a/server/src/library.ts +++ b/server/src/library.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import { PassThrough } from 'node:stream'; import yazl from 'yazl'; 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 { fetchBuffer } from './core/net/http.ts'; import { getProvider } from './core/providers/index.ts'; @@ -42,9 +43,29 @@ export interface LibraryEntry { hasLyric?: boolean; /** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */ 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 { + 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) { const dir = resolveInLibrary(rel); @@ -59,8 +80,16 @@ export async function listDir(rel: string | undefined) { const s = await fs.promises.stat(abs).catch(() => null); if (!s) continue; if (s.isDirectory()) { - const image = DIR_IMAGES.find((img) => fs.existsSync(path.join(abs, img))); - entries.push({ name, path: toRel(abs), type: 'dir', size: 0, mtime: s.mtimeMs, cover: image ? toRel(path.join(abs, image)) : undefined }); + const image = dirImage(abs); + 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 { const ext = path.extname(name).toLowerCase(); const isAudio = AUDIO_EXTS.has(ext); @@ -265,3 +294,76 @@ export async function startRetag(rel: string, source: Source, onlyMissingLyric: })(); 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 { + 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 { + 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 { + 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; +} diff --git a/server/src/routes/api.ts b/server/src/routes/api.ts index aa77fe1..f6187bb 100644 --- a/server/src/routes/api.ts +++ b/server/src/routes/api.ts @@ -14,11 +14,14 @@ import { allHistory, downloadedIds, historyStats, listHistory } from '../db.ts'; import { bus } from '../events.ts'; import { AUDIO_EXTS, + artistsMissingImage, + currentArtistImageJob, currentRetagJob, emptyTrash, listDir, moveToTrash, resolveInLibrary, + startArtistImages, startRetag, trashInfo, writeM3u, @@ -260,6 +263,7 @@ api.get('/events', (ctx) => { account: (d) => send('account', d), history: () => send('history', {}), retag: (d) => send('retag', d), + 'artist-images': (d) => send('artist-images', d), }; for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn); 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 ?? '')) }; }); +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) => { ctx.body = libraryIndexInfo(); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3eddaaf..597a088 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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('/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), diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts index 4da068e..fb40f00 100644 --- a/web/src/lib/store.ts +++ b/web/src/lib/store.ts @@ -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()); export const progress = reactive(new Map()); export const summary = ref({ paused: false, concurrency: 2, counts: {} }); export const retagJob = ref(null); +export const artistImageJob = ref(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); } diff --git a/web/src/views/LibraryView.vue b/web/src/views/LibraryView.vue index 2987bd8..ee95382 100644 --- a/web/src/views/LibraryView.vue +++ b/web/src/views/LibraryView.vue @@ -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('netease'); const retagMissingOnly = ref(true); const trash = ref<{ count: number; size: number } | null>(null); +const missingArtists = ref([]); 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) ?? []); @@ -163,6 +188,27 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok) +
+
+ + + + + {{ artistImageJob.done }}/{{ artistImageJob.total }} + +
+ +

从网易云 / QQ 音乐按歌手名精确匹配,保存为文件夹内的 artist.jpg(Navidrome 也会使用)。

+
+
+
+ 上次补全:成功 {{ artistImageJob.results.length - artistJobFailures.length }} 位,未找到 {{ artistJobFailures.length }} 位 +
    +
  • {{ r.artist }} —— {{ r.error }}
  • +
+
+
+
@@ -188,7 +234,10 @@ const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok)