feat: P7 在线播放(播放队列、媒体控制)与自建歌单
- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage - 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件 - 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -25,7 +25,8 @@ export function createApp(): Koa {
|
||||
});
|
||||
|
||||
app.use(authGuard);
|
||||
app.use(bodyParser());
|
||||
// 歌单 / 播放队列一次可能提交上千首
|
||||
app.use(bodyParser({ jsonLimit: '8mb' }));
|
||||
app.use(api.routes()).use(api.allowedMethods());
|
||||
|
||||
// 前端静态资源 + SPA 回退
|
||||
|
||||
@@ -50,6 +50,31 @@ db.exec(`
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, id);
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS playlist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
source TEXT,
|
||||
track_id TEXT,
|
||||
path TEXT,
|
||||
name TEXT NOT NULL,
|
||||
artists TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
album_id TEXT,
|
||||
cover TEXT,
|
||||
duration INTEGER,
|
||||
added_at TEXT NOT NULL,
|
||||
UNIQUE (playlist_id, item_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_items_pos ON playlist_items(playlist_id, position);
|
||||
`);
|
||||
|
||||
export interface HistoryEntry {
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface LibraryEntry {
|
||||
size: number;
|
||||
mtime: number;
|
||||
hasLyric?: boolean;
|
||||
/** 音频文件由路径推断的歌名 / 歌手 / 专辑 */
|
||||
title?: string;
|
||||
artists?: string;
|
||||
album?: string;
|
||||
/** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */
|
||||
cover?: string;
|
||||
/** 没有文件夹图片时,取前几个子文件夹(专辑)的封面拼图 */
|
||||
@@ -94,6 +98,7 @@ export async function listDir(rel: string | undefined) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const isAudio = AUDIO_EXTS.has(ext);
|
||||
const base = name.slice(0, -ext.length);
|
||||
const guess = isAudio ? describeLocal(abs) : undefined;
|
||||
entries.push({
|
||||
name,
|
||||
path: toRel(abs),
|
||||
@@ -101,6 +106,9 @@ export async function listDir(rel: string | undefined) {
|
||||
size: s.size,
|
||||
mtime: s.mtimeMs,
|
||||
hasLyric: isAudio ? nameSet.has(`${base}.lrc`) : undefined,
|
||||
title: guess?.name,
|
||||
artists: guess?.artists,
|
||||
album: guess?.album,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -367,3 +375,49 @@ export async function startArtistImages(): Promise<ArtistImageJob> {
|
||||
})();
|
||||
return current;
|
||||
}
|
||||
|
||||
// ---------- 播放 / 歌单用的本地曲目 ----------
|
||||
|
||||
export interface LocalTrack {
|
||||
path: string;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
/** 所在文件夹的封面(相对音乐目录) */
|
||||
cover?: string;
|
||||
/** 同名 .lrc 歌词 */
|
||||
lrc: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const MAX_LOCAL_TRACKS = 5000;
|
||||
|
||||
/** 由路径推断显示信息;"歌手/歌名.mp3" 这种两层结构也把上层目录当歌手 */
|
||||
export function describeLocal(abs: string): Pick<LocalTrack, 'name' | 'artists' | 'album'> {
|
||||
const g = guessFromPath(abs);
|
||||
const parent = path.dirname(abs);
|
||||
const artist = g.artist ?? (path.resolve(parent) !== path.resolve(config.musicDir) ? path.basename(parent) : '');
|
||||
return { name: g.title, artists: artist, album: g.album ?? '' };
|
||||
}
|
||||
|
||||
/** 文件或目录下的全部音频(递归),最多 MAX_LOCAL_TRACKS 首 */
|
||||
export async function listLocalTracks(rel: string): Promise<{ items: LocalTrack[]; truncated: boolean }> {
|
||||
const target = resolveInLibrary(rel);
|
||||
if (!(await fs.promises.stat(target).catch(() => null))) throw httpError(404, '路径不存在');
|
||||
const files = await collectAudio(target);
|
||||
const dirs = new Map<string, { names: Set<string>; cover?: string }>();
|
||||
const items: LocalTrack[] = [];
|
||||
for (const abs of files.slice(0, MAX_LOCAL_TRACKS)) {
|
||||
const dir = path.dirname(abs);
|
||||
let info = dirs.get(dir);
|
||||
if (!info) {
|
||||
const image = dirImage(dir);
|
||||
info = { names: new Set(await fs.promises.readdir(dir).catch(() => [])), cover: image ? toRel(image) : undefined };
|
||||
dirs.set(dir, info);
|
||||
}
|
||||
const base = path.basename(abs, path.extname(abs));
|
||||
const size = (await fs.promises.stat(abs).catch(() => null))?.size ?? 0;
|
||||
items.push({ path: toRel(abs), ...describeLocal(abs), cover: info.cover, lrc: info.names.has(`${base}.lrc`), size });
|
||||
}
|
||||
return { items, truncated: files.length > MAX_LOCAL_TRACKS };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 自建歌单:在线曲目(source + id)与本地曲库文件(相对路径)可以混排在同一个歌单里
|
||||
import fs from 'node:fs';
|
||||
import type { Source } from './core/types.ts';
|
||||
import { db } from './db.ts';
|
||||
import { bus } from './events.ts';
|
||||
import { resolveInLibrary } from './library.ts';
|
||||
import { findInLibrary } from './libraryIndex.ts';
|
||||
|
||||
const MAX_ITEMS = 5000;
|
||||
|
||||
export interface PlaylistItemInput {
|
||||
source?: Source | null;
|
||||
id?: string | null;
|
||||
path?: string | null;
|
||||
name: string;
|
||||
artists?: string;
|
||||
album?: string;
|
||||
albumId?: string | null;
|
||||
cover?: string | null;
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
key: string;
|
||||
source: Source | null;
|
||||
trackId: string | null;
|
||||
path: string | null;
|
||||
name: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
albumId: string | null;
|
||||
cover: string | null;
|
||||
duration: number | null;
|
||||
addedAt: string;
|
||||
/** 在线曲目在曲库中的同名文件 */
|
||||
localFile?: string;
|
||||
/** 本地文件已被移动或删除 */
|
||||
missing?: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
covers: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function httpError(status: number, message: string): Error {
|
||||
return Object.assign(new Error(message), { status });
|
||||
}
|
||||
|
||||
function tx<T>(fn: () => T): T {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const out = fn();
|
||||
db.exec('COMMIT');
|
||||
return out;
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function changed(id: number) {
|
||||
db.prepare('UPDATE playlists SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
||||
bus.emit('playlists', { id });
|
||||
}
|
||||
|
||||
function text(value: unknown, max: number): string {
|
||||
return String(value ?? '').trim().slice(0, max);
|
||||
}
|
||||
|
||||
/** 校验并规范化前端传来的曲目;无效项丢弃 */
|
||||
function normalize(raw: unknown): (PlaylistItemInput & { key: string }) | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const name = text(r.name, 300);
|
||||
if (!name) return null;
|
||||
const base = {
|
||||
name,
|
||||
artists: text(r.artists, 300),
|
||||
album: text(r.album, 300),
|
||||
albumId: r.albumId ? String(r.albumId) : null,
|
||||
cover: r.cover ? text(r.cover, 1000) : null,
|
||||
duration: Number.isFinite(Number(r.duration)) && Number(r.duration) > 0 ? Math.round(Number(r.duration)) : null,
|
||||
};
|
||||
if (r.source === 'netease' || r.source === 'qq') {
|
||||
const id = text(r.id, 100);
|
||||
if (!id) return null;
|
||||
return { ...base, source: r.source, id, path: null, key: `${r.source}:${id}` };
|
||||
}
|
||||
if (typeof r.path === 'string' && r.path) {
|
||||
resolveInLibrary(r.path); // 越界路径直接报错
|
||||
const path = r.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
return { ...base, source: null, id: null, path, key: `file:${path}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
const ITEM_COLUMNS = `id, item_key AS key, source, track_id AS trackId, path, name, artists, album, album_id AS albumId,
|
||||
cover, duration, added_at AS addedAt`;
|
||||
|
||||
function querySummaries(id?: number): PlaylistSummary[] {
|
||||
const one = id !== undefined;
|
||||
const args = one ? [id] : [];
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.description, p.created_at AS createdAt, p.updated_at AS updatedAt,
|
||||
COUNT(i.id) AS count, COALESCE(SUM(i.duration), 0) AS duration
|
||||
FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id
|
||||
${one ? 'WHERE p.id = ?' : ''}
|
||||
GROUP BY p.id ORDER BY p.updated_at DESC, p.id DESC`,
|
||||
)
|
||||
.all(...args) as unknown as Array<Omit<PlaylistSummary, 'covers'>>;
|
||||
// 每个歌单取前 4 张不同的封面做拼图
|
||||
const coverRows = db
|
||||
.prepare(
|
||||
`SELECT pid, cover FROM (
|
||||
SELECT playlist_id AS pid, cover, ROW_NUMBER() OVER (PARTITION BY playlist_id ORDER BY MIN(position)) AS rn
|
||||
FROM playlist_items WHERE cover IS NOT NULL ${one ? 'AND playlist_id = ?' : ''} GROUP BY playlist_id, cover
|
||||
) WHERE rn <= 4`,
|
||||
)
|
||||
.all(...args) as Array<{ pid: number; cover: string }>;
|
||||
const covers = new Map<number, string[]>();
|
||||
for (const r of coverRows) covers.set(r.pid, [...(covers.get(r.pid) ?? []), r.cover]);
|
||||
return rows.map((r) => ({ ...r, covers: covers.get(r.id) ?? [] }));
|
||||
}
|
||||
|
||||
export function listPlaylists(): PlaylistSummary[] {
|
||||
return querySummaries();
|
||||
}
|
||||
|
||||
function summary(id: number): PlaylistSummary {
|
||||
const s = querySummaries(id)[0];
|
||||
if (!s) throw httpError(404, '歌单不存在');
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function getPlaylist(id: number): Promise<PlaylistSummary & { items: PlaylistItem[] }> {
|
||||
const head = summary(id);
|
||||
const items = db
|
||||
.prepare(`SELECT ${ITEM_COLUMNS} FROM playlist_items WHERE playlist_id = ? ORDER BY position, id`)
|
||||
.all(id) as unknown as PlaylistItem[];
|
||||
await Promise.all(
|
||||
items.map(async (it) => {
|
||||
if (it.path) {
|
||||
it.missing = !(await fs.promises.stat(resolveInLibrary(it.path)).catch(() => null))?.isFile();
|
||||
} else {
|
||||
it.localFile = findInLibrary(it);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...head, items };
|
||||
}
|
||||
|
||||
// ---------- 修改 ----------
|
||||
|
||||
export function createPlaylist(name: unknown, description: unknown, items: unknown): PlaylistSummary {
|
||||
const title = text(name, 100);
|
||||
if (!title) throw httpError(400, '请输入歌单名称');
|
||||
const now = new Date().toISOString();
|
||||
const id = tx(() => {
|
||||
const res = db
|
||||
.prepare('INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?, ?, ?, ?)')
|
||||
.run(title, text(description, 2000), now, now);
|
||||
const pid = Number(res.lastInsertRowid);
|
||||
if (Array.isArray(items)) insertItems(pid, items);
|
||||
return pid;
|
||||
});
|
||||
bus.emit('playlists', { id });
|
||||
return summary(id);
|
||||
}
|
||||
|
||||
export function updatePlaylist(id: number, patch: { name?: unknown; description?: unknown }): PlaylistSummary {
|
||||
summary(id);
|
||||
if (patch.name !== undefined) {
|
||||
const title = text(patch.name, 100);
|
||||
if (!title) throw httpError(400, '请输入歌单名称');
|
||||
db.prepare('UPDATE playlists SET name = ? WHERE id = ?').run(title, id);
|
||||
}
|
||||
if (patch.description !== undefined) db.prepare('UPDATE playlists SET description = ? WHERE id = ?').run(text(patch.description, 2000), id);
|
||||
changed(id);
|
||||
return summary(id);
|
||||
}
|
||||
|
||||
export function deletePlaylist(id: number): void {
|
||||
tx(() => {
|
||||
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(id);
|
||||
db.prepare('DELETE FROM playlists WHERE id = ?').run(id);
|
||||
});
|
||||
bus.emit('playlists', { id });
|
||||
}
|
||||
|
||||
function insertItems(id: number, raw: unknown[]): { added: number; duplicates: number } {
|
||||
const { count, maxPos } = db
|
||||
.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(position), -1) AS maxPos FROM playlist_items WHERE playlist_id = ?')
|
||||
.get(id) as { count: number; maxPos: number };
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO playlist_items
|
||||
(playlist_id, position, item_key, source, track_id, path, name, artists, album, album_id, cover, duration, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
let pos = maxPos + 1;
|
||||
let added = 0;
|
||||
let valid = 0;
|
||||
for (const r of raw) {
|
||||
const it = normalize(r);
|
||||
if (!it) continue;
|
||||
valid++;
|
||||
if (count + added >= MAX_ITEMS) throw httpError(400, `歌单最多 ${MAX_ITEMS} 首`);
|
||||
const res = insert.run(id, pos, it.key, it.source ?? null, it.id ?? null, it.path ?? null, it.name, it.artists ?? '', it.album ?? '', it.albumId ?? null, it.cover ?? null, it.duration ?? null, now);
|
||||
if (res.changes) {
|
||||
added++;
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
return { added, duplicates: valid - added };
|
||||
}
|
||||
|
||||
export function addItems(id: number, items: unknown): { added: number; duplicates: number } {
|
||||
summary(id);
|
||||
if (!Array.isArray(items) || !items.length) throw httpError(400, '没有要添加的歌曲');
|
||||
const res = tx(() => insertItems(id, items));
|
||||
changed(id);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function removeItems(id: number, itemIds: unknown): { removed: number } {
|
||||
if (!Array.isArray(itemIds) || !itemIds.length) return { removed: 0 };
|
||||
const ids = itemIds.map(Number).filter(Number.isInteger);
|
||||
const res = db
|
||||
.prepare(`DELETE FROM playlist_items WHERE playlist_id = ? AND id IN (${ids.map(() => '?').join(',')})`)
|
||||
.run(id, ...ids);
|
||||
changed(id);
|
||||
return { removed: Number(res.changes) };
|
||||
}
|
||||
|
||||
/** 按传入的条目 id 顺序重排;未列出的条目保持原相对顺序排在最后 */
|
||||
export function reorderItems(id: number, itemIds: unknown): void {
|
||||
if (!Array.isArray(itemIds)) throw httpError(400, '参数错误');
|
||||
const existing = (db.prepare('SELECT id FROM playlist_items WHERE playlist_id = ? ORDER BY position, id').all(id) as Array<{ id: number }>).map((r) => r.id);
|
||||
const known = new Set(existing);
|
||||
const ordered = [...new Set(itemIds.map(Number).filter((n) => known.has(n)))];
|
||||
const seen = new Set(ordered);
|
||||
for (const n of existing) if (!seen.has(n)) ordered.push(n);
|
||||
const update = db.prepare('UPDATE playlist_items SET position = ? WHERE id = ?');
|
||||
tx(() => ordered.forEach((itemId, i) => update.run(i, itemId)));
|
||||
changed(id);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
currentRetagJob,
|
||||
emptyTrash,
|
||||
listDir,
|
||||
listLocalTracks,
|
||||
moveToTrash,
|
||||
resolveInLibrary,
|
||||
startArtistImages,
|
||||
@@ -28,6 +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 { queue, type EnqueueItem } from '../queue.ts';
|
||||
import { getSettings, updateSettings } from '../settings.ts';
|
||||
import { proxyStream } from '../stream.ts';
|
||||
@@ -47,15 +49,18 @@ function body(ctx: Context): Record<string, any> {
|
||||
return (ctx.request as any).body || {};
|
||||
}
|
||||
|
||||
/** 给列表中的歌曲标记是否已下载过 */
|
||||
/** 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary) */
|
||||
/**
|
||||
* 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary)。
|
||||
* localFile 不论来源都给出曲库中的文件,播放时可直接用本地文件
|
||||
*/
|
||||
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
|
||||
const owned = downloadedIds(source, detail.tracks.map((t) => t.id));
|
||||
return {
|
||||
...detail,
|
||||
tracks: detail.tracks.map((t) => {
|
||||
const downloaded = owned.has(t.id);
|
||||
return { ...t, downloaded, inLibrary: downloaded ? undefined : findInLibrary(t) };
|
||||
const localFile = findInLibrary(t);
|
||||
return { ...t, downloaded, inLibrary: downloaded ? undefined : localFile, localFile };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -156,7 +161,8 @@ api.get('/:source/track/:id', async (ctx) => {
|
||||
const provider = getProvider(ctx.params.source!);
|
||||
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
|
||||
const downloaded = downloadedIds(provider.source, [meta.id]).has(meta.id);
|
||||
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : findInLibrary(meta) };
|
||||
const localFile = findInLibrary(meta);
|
||||
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : localFile, localFile };
|
||||
});
|
||||
|
||||
api.get('/:source/album/:id', async (ctx) => {
|
||||
@@ -264,6 +270,7 @@ api.get('/events', (ctx) => {
|
||||
history: () => send('history', {}),
|
||||
retag: (d) => send('retag', d),
|
||||
'artist-images': (d) => send('artist-images', d),
|
||||
playlists: (d) => send('playlists', d),
|
||||
};
|
||||
for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn);
|
||||
send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() });
|
||||
@@ -277,6 +284,50 @@ api.get('/events', (ctx) => {
|
||||
stream.on('error', cleanup);
|
||||
});
|
||||
|
||||
// ---------- 自建歌单 ----------
|
||||
|
||||
function playlistId(ctx: Context): number {
|
||||
const id = Number(ctx.params.id);
|
||||
if (!Number.isInteger(id)) ctx.throw(404, '歌单不存在');
|
||||
return id;
|
||||
}
|
||||
|
||||
api.get('/playlists', (ctx) => {
|
||||
ctx.body = { items: listPlaylists() };
|
||||
});
|
||||
|
||||
api.post('/playlists', (ctx) => {
|
||||
const { name, description, items } = body(ctx);
|
||||
ctx.body = createPlaylist(name, description, items);
|
||||
});
|
||||
|
||||
api.get('/playlists/:id', async (ctx) => {
|
||||
ctx.body = await getPlaylist(playlistId(ctx));
|
||||
});
|
||||
|
||||
api.patch('/playlists/:id', (ctx) => {
|
||||
const { name, description } = body(ctx);
|
||||
ctx.body = updatePlaylist(playlistId(ctx), { name, description });
|
||||
});
|
||||
|
||||
api.delete('/playlists/:id', (ctx) => {
|
||||
deletePlaylist(playlistId(ctx));
|
||||
ctx.body = { ok: true };
|
||||
});
|
||||
|
||||
api.post('/playlists/:id/items', (ctx) => {
|
||||
ctx.body = addItems(playlistId(ctx), body(ctx).items);
|
||||
});
|
||||
|
||||
api.post('/playlists/:id/items/remove', (ctx) => {
|
||||
ctx.body = removeItems(playlistId(ctx), body(ctx).ids);
|
||||
});
|
||||
|
||||
api.put('/playlists/:id/order', (ctx) => {
|
||||
reorderItems(playlistId(ctx), body(ctx).ids);
|
||||
ctx.body = { ok: true };
|
||||
});
|
||||
|
||||
// ---------- 历史 ----------
|
||||
|
||||
api.get('/history', (ctx) => {
|
||||
@@ -317,7 +368,7 @@ api.get('/library/file', async (ctx) => {
|
||||
const stat = await fs.promises.stat(abs).catch(() => null);
|
||||
if (!stat?.isFile()) throw Object.assign(new Error('文件不存在'), { status: 404 });
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
|
||||
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.aac': 'audio/aac', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
|
||||
ctx.type = types[ext] || 'application/octet-stream';
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
const range = ctx.get('range').match(/bytes=(\d*)-(\d*)/);
|
||||
@@ -366,6 +417,10 @@ api.post('/library/index', async (ctx) => {
|
||||
ctx.body = libraryIndexInfo();
|
||||
});
|
||||
|
||||
api.get('/library/tracks', async (ctx) => {
|
||||
ctx.body = await listLocalTracks(String(ctx.query.path ?? ''));
|
||||
});
|
||||
|
||||
api.get('/library/trash', async (ctx) => {
|
||||
ctx.body = await trashInfo();
|
||||
});
|
||||
|
||||
+12
-3
@@ -1,16 +1,24 @@
|
||||
// 运行时设置:config.json 提供默认值,网页上修改后存入 SQLite
|
||||
import { config, type DownloadDefaults } from './config.ts';
|
||||
import { isQualityKey } from './core/quality.ts';
|
||||
import { isQualityKey, type QualityKey } from './core/quality.ts';
|
||||
import { getSetting, setSetting } from './db.ts';
|
||||
import { bus } from './events.ts';
|
||||
|
||||
/** 在线播放可选的音质(更高音质文件过大,不适合边下边播) */
|
||||
export const PLAY_QUALITIES = ['standard', 'high', 'lossless'] as const satisfies readonly QualityKey[];
|
||||
export type PlayQuality = (typeof PLAY_QUALITIES)[number];
|
||||
|
||||
export interface Settings extends DownloadDefaults {
|
||||
concurrency: number;
|
||||
/** 批量任务之间的间隔,避免请求过快 */
|
||||
gapMs: number;
|
||||
/** 在线播放音质 */
|
||||
playQuality: PlayQuality;
|
||||
/** 曲库里已有同名歌曲时直接播放本地文件 */
|
||||
preferLocal: boolean;
|
||||
}
|
||||
|
||||
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400 };
|
||||
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400, playQuality: 'high', preferLocal: true };
|
||||
|
||||
let current: Settings = { ...BASE, ...getSetting<Partial<Settings>>('settings', {}) };
|
||||
|
||||
@@ -30,7 +38,8 @@ export function updateSettings(patch: Partial<Record<keyof Settings, unknown>>):
|
||||
if (patch.retries !== undefined) next.retries = clampInt(patch.retries, 1, 10, next.retries);
|
||||
if (patch.concurrency !== undefined) next.concurrency = clampInt(patch.concurrency, 1, 6, next.concurrency);
|
||||
if (patch.gapMs !== undefined) next.gapMs = clampInt(patch.gapMs, 0, 5000, next.gapMs);
|
||||
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover'] as const) {
|
||||
if (PLAY_QUALITIES.includes(patch.playQuality as PlayQuality)) next.playQuality = patch.playQuality as PlayQuality;
|
||||
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover', 'preferLocal'] as const) {
|
||||
if (typeof patch[key] === 'boolean') next[key] = patch[key] as boolean;
|
||||
}
|
||||
current = next;
|
||||
|
||||
+14
-6
@@ -1,19 +1,26 @@
|
||||
// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容
|
||||
// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 Range),绕过防盗链与混合内容
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Context } from 'koa';
|
||||
import { UA } from './core/net/http.ts';
|
||||
import { getProvider } from './core/providers/index.ts';
|
||||
import type { QualityKey } from './core/quality.ts';
|
||||
import { TtlCache } from './core/util.ts';
|
||||
import { getSettings } from './settings.ts';
|
||||
|
||||
const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200);
|
||||
// 音源直链通常有几分钟到几小时的有效期;拖动进度条时每个 Range 请求都会命中缓存
|
||||
const urlCache = new TtlCache<{ url: string; ext: string; quality: QualityKey }>(3 * 60 * 1000, 200);
|
||||
const MIME: Record<string, string> = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' };
|
||||
|
||||
export async function proxyStream(ctx: Context, source: string, id: string) {
|
||||
const provider = getProvider(source);
|
||||
const audio = await urlCache.getOrSet(`${source}:${id}`, async () => {
|
||||
const r = await provider.resolveAudio(id, 'high');
|
||||
return { url: r.url, ext: r.ext };
|
||||
});
|
||||
const quality = getSettings().playQuality;
|
||||
const audio = await urlCache
|
||||
.getOrSet(`${source}:${id}:${quality}`, async () => {
|
||||
const r = await provider.resolveAudio(id, quality);
|
||||
return { url: r.url, ext: r.ext, quality: r.quality };
|
||||
})
|
||||
// 版权下架等情况很常见,播放器会自动跳过,不必按服务端错误记日志
|
||||
.catch((e: Error) => ctx.throw(404, e.message));
|
||||
|
||||
const controller = new AbortController();
|
||||
ctx.req.on('close', () => controller.abort());
|
||||
@@ -26,6 +33,7 @@ export async function proxyStream(ctx: Context, source: string, id: string) {
|
||||
ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream');
|
||||
ctx.set('Accept-Ranges', 'bytes');
|
||||
ctx.set('Cache-Control', 'no-store');
|
||||
ctx.set('X-Audio-Quality', audio.quality);
|
||||
for (const h of ['content-length', 'content-range']) {
|
||||
const v = upstream.headers.get(h);
|
||||
if (v) ctx.set(h, v);
|
||||
|
||||
Reference in New Issue
Block a user