feat: 歌手头像 —— 下载时写入 artist.jpg、曲库批量补全、无头像时用专辑封面拼图
- 按「歌手/专辑」下载时,歌手目录没有图片则保存歌手头像(合辑不写) - 曲库页列出缺少头像的歌手文件夹,一键从网易云 / QQ 音乐按名字精确匹配补全 - 网易云头像缩到 600×600,避免原图数 MB - 仍无头像的文件夹显示前 4 张专辑封面拼图 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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<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 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user