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
+1 -1
View File
@@ -22,7 +22,7 @@
- `core/providers/{netease,qq}/` 实现 `MusicProvider`(`core/types.ts`),在 `providers/index.ts` 注册;`resolveLink` 处理分享短链 - `core/providers/{netease,qq}/` 实现 `MusicProvider`(`core/types.ts`),在 `providers/index.ts` 注册;`resolveLink` 处理分享短链
- QQ:`qq/client.ts` 的 `musicu()` 在 comm 中带 `uin` + `authst` 并附 Cookie —— 缺这一步拿不到 VIP 音源 - QQ:`qq/client.ts` 的 `musicu()` 在 comm 中带 `uin` + `authst` 并附 Cookie —— 缺这一步拿不到 VIP 音源
- `queue.ts` — 任务存 SQLite `tasks` 表,进度只在内存,经 `events.ts` 的 bus 推送;`routes/api.ts` 的 `/api/events` 是 SSE - `queue.ts` — 任务存 SQLite `tasks` 表,进度只在内存,经 `events.ts` 的 bus 推送;`routes/api.ts` 的 `/api/events` 是 SSE
- `playlists.ts` — 自建歌单(`playlists` / `playlist_items` 表);条目要么是在线曲目(source + track_id)要么是曲库文件(path),`item_key` 去重。读取时给在线曲目补 `localFile`、给文件补 `missing` - `playlists.ts` — 自建歌单(`playlists` / `playlist_items` 表);条目要么是在线曲目(source + track_id)要么是曲库文件(path),`item_key` 去重。读取时给在线曲目补 `localFile`、给文件补 `missing`。`importPlaylist` 经 `resolveLink` 把网易云 / QQ 歌单或专辑链接导入为新歌单(`POST /api/playlists/import`)
- `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(在线播放代理,音质取设置 `playQuality`) - `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(在线播放代理,音质取设置 `playQuality`)
- `db.ts` — `node:sqlite`;history / tasks / settings / playlists / playlist_items 表 - `db.ts` — `node:sqlite`;history / tasks / settings / playlists / playlist_items 表
- `web/` — Vite 8 + Vue 3.5 + Vue Router 5,无 UI 库 - `web/` — Vite 8 + Vue 3.5 + Vue Router 5,无 UI 库
+1 -1
View File
@@ -12,7 +12,7 @@
- **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级 - **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级
- QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持 - QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持
- **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续 - **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续
- **歌单**:自建歌单可混排在线曲目与本地文件;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目 - **歌单**:自建歌单可混排在线曲目与本地文件;粘贴网易云 / QQ 音乐歌单分享链接一键导入;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目
- **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词 - **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词
- **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON - **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON
- **设置**:QQ 账号(扫码登录 / 导入 Cookie、VIP 校验、musickey 自动续期)、下载默认值、并发与间隔、访问密码、浅色 / 深色 - **设置**:QQ 账号(扫码登录 / 导入 Cookie、VIP 校验、musickey 自动续期)、下载默认值、并发与间隔、访问密码、浅色 / 深色
@@ -131,6 +131,7 @@ export class NeteaseProvider implements MusicProvider {
name: data.name, name: data.name,
cover: data.coverImgUrl, cover: data.coverImgUrl,
creator: data.creator?.nickname, creator: data.creator?.nickname,
description: data.description || undefined,
trackCount: data.trackCount ?? data.tracks.length, trackCount: data.trackCount ?? data.tracks.length,
tracks: data.tracks.map((t: any) => this.toSummary(t)), tracks: data.tracks.map((t: any) => this.toSummary(t)),
}; };
+22
View File
@@ -1,5 +1,6 @@
// 自建歌单:在线曲目(source + id)与本地曲库文件(相对路径)可以混排在同一个歌单里 // 自建歌单:在线曲目(source + id)与本地曲库文件(相对路径)可以混排在同一个歌单里
import fs from 'node:fs'; import fs from 'node:fs';
import { getProvider, resolveLink } from './core/providers/index.ts';
import type { Source } from './core/types.ts'; import type { Source } from './core/types.ts';
import { db } from './db.ts'; import { db } from './db.ts';
import { bus } from './events.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))); tx(() => ordered.forEach((itemId, i) => update.run(i, itemId)));
changed(id); 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, zipStream,
} from '../library.ts'; } from '../library.ts';
import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.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 { queue, type EnqueueItem } from '../queue.ts';
import { getSettings, updateSettings } from '../settings.ts'; import { getSettings, updateSettings } from '../settings.ts';
import { proxyStream } from '../stream.ts'; import { proxyStream } from '../stream.ts';
@@ -301,6 +301,10 @@ api.post('/playlists', (ctx) => {
ctx.body = createPlaylist(name, description, items); 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) => { api.get('/playlists/:id', async (ctx) => {
ctx.body = await getPlaylist(playlistId(ctx)); ctx.body = await getPlaylist(playlistId(ctx));
}); });
+1
View File
@@ -403,6 +403,7 @@ export const api = {
emptyTrash: () => request('/library/trash', { method: 'DELETE' }), emptyTrash: () => request('/library/trash', { method: 'DELETE' }),
myPlaylists: () => request<{ items: MyPlaylistSummary[] }>('/playlists').then((r) => r.items), myPlaylists: () => request<{ items: MyPlaylistSummary[] }>('/playlists').then((r) => r.items),
myPlaylist: (id: number) => request<MyPlaylist>(`/playlists/${id}`), myPlaylist: (id: number) => request<MyPlaylist>(`/playlists/${id}`),
importPlaylist: (url: string) => request<{ playlist: MyPlaylistSummary; fetched: number; total: number }>('/playlists/import', json('POST', { url })),
createPlaylist: (name: string, items?: PlaylistItemInput[], description?: string) => createPlaylist: (name: string, items?: PlaylistItemInput[], description?: string) =>
request<MyPlaylistSummary>('/playlists', json('POST', { name, description, items })), request<MyPlaylistSummary>('/playlists', json('POST', { name, description, items })),
updatePlaylist: (id: number, patch: { name?: string; description?: string }) => request<MyPlaylistSummary>(`/playlists/${id}`, json('PATCH', patch)), updatePlaylist: (id: number, patch: { name?: string; description?: string }) => request<MyPlaylistSummary>(`/playlists/${id}`, json('PATCH', patch)),
+77 -11
View File
@@ -7,27 +7,51 @@ import { api, type MyPlaylistSummary } from '../lib/api.ts';
import { formatTotal } from '../lib/format.ts'; import { formatTotal } from '../lib/format.ts';
import { fromPlaylistItem, player, playQueue, toggle } from '../lib/player.ts'; import { fromPlaylistItem, player, playQueue, toggle } from '../lib/player.ts';
import { createMyPlaylist, loadMyPlaylists, myPlaylists } from '../lib/playlists.ts'; import { createMyPlaylist, loadMyPlaylists, myPlaylists } from '../lib/playlists.ts';
import { toastError } from '../lib/store.ts'; import { toast, toastError } from '../lib/store.ts';
const router = useRouter(); const router = useRouter();
const creating = ref(false); const mode = ref<'create' | 'import' | null>(null);
const name = ref(''); const name = ref('');
const link = ref('');
const importing = ref(false);
const input = ref<HTMLInputElement | null>(null); const input = ref<HTMLInputElement | null>(null);
onMounted(() => loadMyPlaylists(true)); onMounted(() => loadMyPlaylists(true));
function startCreate() { function start(next: 'create' | 'import') {
creating.value = true; mode.value = next;
name.value = ''; name.value = '';
link.value = '';
nextTick(() => input.value?.focus()); nextTick(() => input.value?.focus());
} }
async function importLink() {
const url = link.value.trim();
if (!url || importing.value) return;
importing.value = true;
try {
const { playlist, fetched, total } = await api.importPlaylist(url);
toast(
fetched < total ? `已导入「${playlist.name}」${playlist.count} 首(来源共 ${total} 首,只获取到 ${fetched} 首)` : `已导入「${playlist.name}」${playlist.count} 首`,
fetched < total ? 'info' : 'ok',
fetched < total ? 6000 : undefined,
);
mode.value = null;
loadMyPlaylists(true);
router.push(`/playlists/${playlist.id}`);
} catch (e) {
toastError(e);
} finally {
importing.value = false;
}
}
async function create() { async function create() {
const title = name.value.trim(); const title = name.value.trim();
if (!title) return; if (!title) return;
const created = await createMyPlaylist(title); const created = await createMyPlaylist(title);
if (created) { if (created) {
creating.value = false; mode.value = null;
router.push(`/playlists/${created.id}`); router.push(`/playlists/${created.id}`);
} }
} }
@@ -54,12 +78,27 @@ async function playAll(p: MyPlaylistSummary) {
歌单 歌单
<span v-if="myPlaylists?.length" class="count">{{ myPlaylists.length }} 个</span> <span v-if="myPlaylists?.length" class="count">{{ myPlaylists.length }} 个</span>
</h2> </h2>
<form v-if="creating" class="create" @submit.prevent="create"> <form v-if="mode === 'create'" class="create" @submit.prevent="create">
<input ref="input" v-model="name" class="input" maxlength="100" placeholder="歌单名称" @keydown.esc="creating = false" /> <input ref="input" v-model="name" class="input" maxlength="100" placeholder="歌单名称" @keydown.esc="mode = null" />
<button class="btn sm primary" type="submit" :disabled="!name.trim()">创建</button> <button class="btn sm primary" type="submit" :disabled="!name.trim()">创建</button>
<button class="btn sm ghost" type="button" @click="creating = false">取消</button> <button class="btn sm ghost" type="button" @click="mode = null">取消</button>
</form> </form>
<button v-else class="btn sm" type="button" @click="startCreate"><Icon name="plus" :size="15" /> 新建歌单</button> <form v-else-if="mode === 'import'" class="create import" @submit.prevent="importLink">
<input
ref="input"
v-model="link"
class="input"
:disabled="importing"
placeholder="粘贴网易云 / QQ 音乐歌单分享链接"
@keydown.esc="mode = null"
/>
<button class="btn sm primary" type="submit" :disabled="!link.trim() || importing">{{ importing ? '导入中…' : '导入' }}</button>
<button class="btn sm ghost" type="button" :disabled="importing" @click="mode = null">取消</button>
</form>
<div v-else class="actions">
<button class="btn sm" type="button" @click="start('import')"><Icon name="link" :size="15" /> 导入歌单</button>
<button class="btn sm" type="button" @click="start('create')"><Icon name="plus" :size="15" /> 新建歌单</button>
</div>
</div> </div>
</header> </header>
@@ -81,8 +120,11 @@ async function playAll(p: MyPlaylistSummary) {
<div v-else-if="myPlaylists" class="empty"> <div v-else-if="myPlaylists" class="empty">
<span class="empty-art"><Icon name="queue" :size="30" /></span> <span class="empty-art"><Icon name="queue" :size="30" /></span>
<p class="muted">还没有歌单</p> <p class="muted">还没有歌单</p>
<p class="faint small">在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单。</p> <p class="faint small">在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单;也可以直接粘贴网易云 / QQ 音乐的歌单分享链接导入。</p>
<button class="btn primary" type="button" @click="startCreate"><Icon name="plus" :size="16" /> 新建歌单</button> <div class="actions">
<button class="btn" type="button" @click="start('import')"><Icon name="link" :size="16" /> 导入歌单</button>
<button class="btn primary" type="button" @click="start('create')"><Icon name="plus" :size="16" /> 新建歌单</button>
</div>
</div> </div>
<div v-else class="grid"> <div v-else class="grid">
<div v-for="i in 5" :key="i" class="tile"><span class="art skeleton" /></div> <div v-for="i in 5" :key="i" class="tile"><span class="art skeleton" /></div>
@@ -101,6 +143,15 @@ async function playAll(p: MyPlaylistSummary) {
height: 28px; height: 28px;
} }
.create.import .input {
width: 340px;
}
.actions {
display: flex;
gap: 8px;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
@@ -210,6 +261,21 @@ async function playAll(p: MyPlaylistSummary) {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.row-between:has(.import) {
flex-wrap: wrap;
gap: 10px;
}
.create.import {
width: 100%;
}
.create.import .input {
flex: 1;
width: auto;
min-width: 0;
}
.grid { .grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 20px 14px; gap: 20px 14px;