feat: 粘贴网易云 / QQ 音乐歌单分享链接导入为自建歌单
- POST /api/playlists/import:resolveLink 识别歌单 / 专辑链接,拉取全部曲目后建歌单,简介注明来源与原简介 - 歌单页新增「导入歌单」;上游只返回部分曲目时提示实际获取数 - 网易云歌单详情补上 description Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 库
|
||||||
|
|||||||
@@ -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)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user