diff --git a/CLAUDE.md b/CLAUDE.md index 9710aae..2e85993 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ - `core/providers/{netease,qq}/` 实现 `MusicProvider`(`core/types.ts`),在 `providers/index.ts` 注册;`resolveLink` 处理分享短链 - QQ:`qq/client.ts` 的 `musicu()` 在 comm 中带 `uin` + `authst` 并附 Cookie —— 缺这一步拿不到 VIP 音源 - `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`) - `db.ts` — `node:sqlite`;history / tasks / settings / playlists / playlist_items 表 - `web/` — Vite 8 + Vue 3.5 + Vue Router 5,无 UI 库 diff --git a/README.md b/README.md index 2a505fc..c89e076 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级 - QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持 - **播放**:在线曲目经服务端代理(标准 / 极高 / 无损可选),曲库文件直接播放;曲库里已有的歌优先放本地文件。播放队列、顺序 / 列表循环 / 单曲循环 / 随机、滚动歌词(含翻译)、系统媒体键与锁屏控制、空格暂停;队列与进度保存在本机,刷新后可继续 -- **歌单**:自建歌单可混排在线曲目与本地文件;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目 +- **歌单**:自建歌单可混排在线曲目与本地文件;粘贴网易云 / QQ 音乐歌单分享链接一键导入;从搜索、专辑、歌单、歌手、曲库文件夹或播放队列一键加入,拖动排序,一键下载曲库里还没有的在线曲目 - **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词 - **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON - **设置**:QQ 账号(扫码登录 / 导入 Cookie、VIP 校验、musickey 自动续期)、下载默认值、并发与间隔、访问密码、浅色 / 深色 diff --git a/server/src/core/providers/netease/index.ts b/server/src/core/providers/netease/index.ts index b104637..2cd5dc9 100644 --- a/server/src/core/providers/netease/index.ts +++ b/server/src/core/providers/netease/index.ts @@ -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)), }; diff --git a/server/src/playlists.ts b/server/src/playlists.ts index 5180296..498f4b6 100644 --- a/server/src/playlists.ts +++ b/server/src/playlists.ts @@ -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 = { 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 }; +} diff --git a/server/src/routes/api.ts b/server/src/routes/api.ts index 582a978..68328fa 100644 --- a/server/src/routes/api.ts +++ b/server/src/routes/api.ts @@ -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)); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b542856..10d5655 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -403,6 +403,7 @@ export const api = { emptyTrash: () => request('/library/trash', { method: 'DELETE' }), myPlaylists: () => request<{ items: MyPlaylistSummary[] }>('/playlists').then((r) => r.items), myPlaylist: (id: number) => request(`/playlists/${id}`), + importPlaylist: (url: string) => request<{ playlist: MyPlaylistSummary; fetched: number; total: number }>('/playlists/import', json('POST', { url })), createPlaylist: (name: string, items?: PlaylistItemInput[], description?: string) => request('/playlists', json('POST', { name, description, items })), updatePlaylist: (id: number, patch: { name?: string; description?: string }) => request(`/playlists/${id}`, json('PATCH', patch)), diff --git a/web/src/views/PlaylistsView.vue b/web/src/views/PlaylistsView.vue index 3073dcb..75d4669 100644 --- a/web/src/views/PlaylistsView.vue +++ b/web/src/views/PlaylistsView.vue @@ -7,27 +7,51 @@ import { api, type MyPlaylistSummary } from '../lib/api.ts'; import { formatTotal } from '../lib/format.ts'; import { fromPlaylistItem, player, playQueue, toggle } from '../lib/player.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 creating = ref(false); +const mode = ref<'create' | 'import' | null>(null); const name = ref(''); +const link = ref(''); +const importing = ref(false); const input = ref(null); onMounted(() => loadMyPlaylists(true)); -function startCreate() { - creating.value = true; +function start(next: 'create' | 'import') { + mode.value = next; name.value = ''; + link.value = ''; 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() { const title = name.value.trim(); if (!title) return; const created = await createMyPlaylist(title); if (created) { - creating.value = false; + mode.value = null; router.push(`/playlists/${created.id}`); } } @@ -54,12 +78,27 @@ async function playAll(p: MyPlaylistSummary) { 歌单 {{ myPlaylists.length }} 个 -
- + + - +
- +
+ + + +
+
+ + +
@@ -81,8 +120,11 @@ async function playAll(p: MyPlaylistSummary) {

还没有歌单

-

在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单。

- +

在搜索结果、专辑或曲库里勾选歌曲后点「加入歌单」,在线曲目和本地文件可以放进同一个歌单;也可以直接粘贴网易云 / QQ 音乐的歌单分享链接导入。

+
+ + +
@@ -101,6 +143,15 @@ async function playAll(p: MyPlaylistSummary) { height: 28px; } +.create.import .input { + width: 340px; +} + +.actions { + display: flex; + gap: 8px; +} + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); @@ -210,6 +261,21 @@ async function playAll(p: MyPlaylistSummary) { } @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-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 20px 14px;