feat: 粘贴网易云 / QQ 音乐歌单分享链接导入为自建歌单

- POST /api/playlists/import:resolveLink 识别歌单 / 专辑链接,拉取全部曲目后建歌单,简介注明来源与原简介
- 歌单页新增「导入歌单」;上游只返回部分曲目时提示实际获取数
- 网易云歌单详情补上 description

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-11 11:45:38 +08:00
parent 676541457b
commit 53d523d7a0
7 changed files with 108 additions and 14 deletions
@@ -131,6 +131,7 @@ export class NeteaseProvider implements MusicProvider {
name: data.name,
cover: data.coverImgUrl,
creator: data.creator?.nickname,
description: data.description || undefined,
trackCount: data.trackCount ?? data.tracks.length,
tracks: data.tracks.map((t: any) => this.toSummary(t)),
};
+22
View File
@@ -1,5 +1,6 @@
// 自建歌单:在线曲目(source + id)与本地曲库文件(相对路径)可以混排在同一个歌单里
import fs from 'node:fs';
import { getProvider, resolveLink } from './core/providers/index.ts';
import type { Source } from './core/types.ts';
import { db } from './db.ts';
import { bus } from './events.ts';
@@ -255,3 +256,24 @@ export function reorderItems(id: number, itemIds: unknown): void {
tx(() => ordered.forEach((itemId, i) => update.run(i, itemId)));
changed(id);
}
// ---------- 从分享链接导入 ----------
const SOURCE_NAME: Record<Source, string> = { netease: '网易云音乐', qq: 'QQ 音乐' };
/** 把网易云 / QQ 音乐的歌单(或专辑)分享链接导入为新歌单;total 大于 fetched 说明上游只返回了部分曲目 */
export async function importPlaylist(url: unknown): Promise<{ playlist: PlaylistSummary; fetched: number; total: number }> {
const ref = await resolveLink(text(url, 2000));
if (!ref || (ref.kind !== 'playlist' && ref.kind !== 'album')) throw httpError(400, '请粘贴网易云或 QQ 音乐的歌单 / 专辑分享链接');
const provider = getProvider(ref.source);
const detail = ref.kind === 'playlist' ? await provider.getPlaylist(ref.id) : await provider.getAlbum(ref.id);
if (!detail?.tracks.length) throw httpError(404, ref.kind === 'playlist' ? '歌单不存在或没有歌曲' : '专辑不存在或没有歌曲');
const isList = 'trackCount' in detail;
const by = isList ? detail.creator : detail.artist;
const origin = `导入自${SOURCE_NAME[ref.source]}${isList ? '歌单' : '专辑'}${by ? ` · ${by}` : ''}`;
const description = [origin, detail.description?.trim()].filter(Boolean).join('\n\n');
const items = detail.tracks.map((t) => ({ source: t.source, id: t.id, name: t.name, artists: t.artists, album: t.album, albumId: t.albumId, cover: t.cover, duration: t.duration }));
const playlist = createPlaylist(detail.name, description, items);
const fetched = detail.tracks.length;
return { playlist, fetched, total: isList ? Math.max(detail.trackCount, fetched) : fetched };
}
+5 -1
View File
@@ -29,7 +29,7 @@ import {
zipStream,
} from '../library.ts';
import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.ts';
import { addItems, createPlaylist, deletePlaylist, getPlaylist, listPlaylists, removeItems, reorderItems, updatePlaylist } from '../playlists.ts';
import { addItems, createPlaylist, deletePlaylist, getPlaylist, importPlaylist, listPlaylists, removeItems, reorderItems, updatePlaylist } from '../playlists.ts';
import { queue, type EnqueueItem } from '../queue.ts';
import { getSettings, updateSettings } from '../settings.ts';
import { proxyStream } from '../stream.ts';
@@ -301,6 +301,10 @@ api.post('/playlists', (ctx) => {
ctx.body = createPlaylist(name, description, items);
});
api.post('/playlists/import', async (ctx) => {
ctx.body = await importPlaylist(body(ctx).url);
});
api.get('/playlists/:id', async (ctx) => {
ctx.body = await getPlaylist(playlistId(ctx));
});