mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4a12d8bd |
No files matched your search
@@ -42,7 +42,7 @@
|
|||||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||||
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
||||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
|
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单。多人共用时,每个网页端用户可在 **设置 → 账户** 扫码绑定**自己的网易云账号**,之后他在网页端开启的网易云私人 FM 按他自己的口味推荐(未绑定则用机器人的共享账号;TS 聊天里的 `!fm` 仍用共享账号)
|
||||||
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
||||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ describe("buildFfmpegArgs", () => {
|
|||||||
expect(ssIdx).toBeGreaterThan(iIdx);
|
expect(ssIdx).toBeGreaterThan(iIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
|
|
||||||
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
|
|
||||||
const ssIdx = args.indexOf("-ss");
|
|
||||||
expect(args[ssIdx + 1]).toBe("3600");
|
|
||||||
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
|
|
||||||
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not insert -ss when seekSeconds is 0", () => {
|
it("does not insert -ss when seekSeconds is 0", () => {
|
||||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
||||||
expect(args).not.toContain("-ss");
|
expect(args).not.toContain("-ss");
|
||||||
|
|||||||
+2
-9
@@ -76,9 +76,8 @@ export function cleanupTempDir(dir: string): void {
|
|||||||
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
const isHttp = /^https?:\/\//i.test(url);
|
const isHttp = /^https?:\/\//i.test(url);
|
||||||
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
|
|
||||||
|
|
||||||
if (isBilibili) {
|
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
|
||||||
args.push(
|
args.push(
|
||||||
"-headers",
|
"-headers",
|
||||||
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
||||||
@@ -104,15 +103,9 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
"-reconnect_on_http_error", "4xx,5xx",
|
"-reconnect_on_http_error", "4xx,5xx",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
|
|
||||||
// to the byte offset. Output-side seek would download and decode everything
|
|
||||||
// before the target first — minutes for a resume deep into a 3-hour video
|
|
||||||
// (#161), long enough to trip the stall watchdog.
|
|
||||||
const inputSideSeek = isBilibili;
|
|
||||||
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
|
|
||||||
args.push("-i", url);
|
args.push("-i", url);
|
||||||
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
|
||||||
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
|
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||||
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
|
|||||||
@@ -1590,97 +1590,3 @@ describe("BotInstance Bilibili multi-P resolution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("resumeInterruptedStream — long B站 streams dying mid-play (#161)", () => {
|
|
||||||
const resumeInterruptedStream = (BotInstance.prototype as any).resumeInterruptedStream as (
|
|
||||||
this: unknown,
|
|
||||||
) => Promise<boolean>;
|
|
||||||
|
|
||||||
function makeCtx(opts: { platform?: string; elapsed?: number; duration?: number; url?: string | null } = {}) {
|
|
||||||
const song: any = {
|
|
||||||
id: "BV1abc", name: "Long", artist: "A", album: "", coverUrl: "",
|
|
||||||
platform: opts.platform ?? "bilibili", duration: opts.duration ?? 10_000, url: "old",
|
|
||||||
};
|
|
||||||
let elapsed = opts.elapsed ?? 1000;
|
|
||||||
let state: "idle" | "playing" = "idle";
|
|
||||||
const provider = {
|
|
||||||
getSongUrl: vi.fn(async () => (opts.url === null ? null : { url: opts.url ?? "https://fresh.test/a.m4s" })),
|
|
||||||
};
|
|
||||||
const ctx: any = {
|
|
||||||
song,
|
|
||||||
provider,
|
|
||||||
connected: true,
|
|
||||||
effectiveDuration: song.duration,
|
|
||||||
streamRecovery: null,
|
|
||||||
queue: { current: vi.fn(() => song) },
|
|
||||||
player: {
|
|
||||||
getElapsed: vi.fn(() => elapsed),
|
|
||||||
getState: vi.fn(() => state),
|
|
||||||
play: vi.fn(() => { state = "playing"; }),
|
|
||||||
},
|
|
||||||
getProviderFor: vi.fn(() => provider),
|
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
|
||||||
emit: vi.fn(),
|
|
||||||
setElapsed: (v: number) => { elapsed = v; state = "idle"; },
|
|
||||||
};
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("re-resolves the URL and resumes at the current position when a B站 stream ends early", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 1000, duration: 10_000 });
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
|
|
||||||
expect(ctx.provider.getSongUrl).toHaveBeenCalledWith("BV1abc");
|
|
||||||
expect(ctx.player.play).toHaveBeenCalledWith("https://fresh.test/a.m4s", 1000, 10_000);
|
|
||||||
expect(ctx.song.url).toBe("https://fresh.test/a.m4s");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing near the real end of the track (normal EOF)", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 9_990, duration: 10_000 });
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
|
|
||||||
expect(ctx.provider.getSongUrl).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing for other platforms or an unknown duration", async () => {
|
|
||||||
expect(await resumeInterruptedStream.call(makeCtx({ platform: "netease" }))).toBe(false);
|
|
||||||
const unknown = makeCtx({ duration: 0 });
|
|
||||||
unknown.effectiveDuration = 0;
|
|
||||||
expect(await resumeInterruptedStream.call(unknown)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives up after 3 attempts that make no progress, then lets the queue advance", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 1000 });
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
ctx.setElapsed(1000);
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
|
|
||||||
}
|
|
||||||
ctx.setElapsed(1000);
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
|
|
||||||
expect(ctx.player.play).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets the attempt budget once a resume actually plays on for a while", async () => {
|
|
||||||
const ctx = makeCtx({ elapsed: 1000 });
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
ctx.setElapsed(1000);
|
|
||||||
await resumeInterruptedStream.call(ctx);
|
|
||||||
}
|
|
||||||
ctx.setElapsed(2000); // the last resume played ~16 more minutes
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls through to advancing when no fresh URL can be fetched", async () => {
|
|
||||||
const ctx = makeCtx({ url: null });
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(false);
|
|
||||||
expect(ctx.player.play).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not clobber a different track the user started while the URL was resolving", async () => {
|
|
||||||
const ctx = makeCtx();
|
|
||||||
ctx.provider.getSongUrl.mockImplementation(async () => {
|
|
||||||
ctx.queue.current.mockReturnValue({ id: "other" }); // user ran !next meanwhile
|
|
||||||
return { url: "https://fresh.test/a.m4s" };
|
|
||||||
});
|
|
||||||
expect(await resumeInterruptedStream.call(ctx)).toBe(true); // handled: don't advance again
|
|
||||||
expect(ctx.player.play).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+4
-78
@@ -193,8 +193,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
private lastSearchResults: Song[] = [];
|
private lastSearchResults: Song[] = [];
|
||||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||||
private effectiveDuration: number | undefined;
|
private effectiveDuration: number | undefined;
|
||||||
/** Resume attempts for the current song's stream (#161); see resumeInterruptedStream. */
|
|
||||||
private streamRecovery: { song: QueuedSong; attempts: number; position: number } | null = null;
|
|
||||||
private playGate: Promise<unknown> = Promise.resolve();
|
private playGate: Promise<unknown> = Promise.resolve();
|
||||||
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
||||||
* null when the wired provider has no reporting capability. */
|
* null when the wired provider has no reporting capability. */
|
||||||
@@ -324,19 +322,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.player.on("trackEnd", () => {
|
this.player.on("trackEnd", () => {
|
||||||
this.resumeInterruptedStream()
|
this.logger.debug("Track ended, advancing queue");
|
||||||
.catch((err) => {
|
this.playNext().catch((err) => {
|
||||||
this.logger.warn({ err }, "Stream resume failed");
|
this.logger.error({ err }, "playNext failed after trackEnd");
|
||||||
return false;
|
});
|
||||||
})
|
|
||||||
.then((resumed) => {
|
|
||||||
if (resumed) return;
|
|
||||||
this.logger.debug("Track ended, advancing queue");
|
|
||||||
return this.playNext();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({ err }, "playNext failed after trackEnd");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.player.on("error", (err: Error) => {
|
this.player.on("error", (err: Error) => {
|
||||||
@@ -1127,69 +1116,6 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platforms whose CDN stream can die mid-file on long content (#89, #161). */
|
|
||||||
private static readonly RESUMABLE_PLATFORMS: ReadonlySet<Platform> = new Set(["bilibili"]);
|
|
||||||
/** A track that ends within this many seconds of its duration ended normally. */
|
|
||||||
private static readonly STREAM_END_TOLERANCE_S = 30;
|
|
||||||
private static readonly MAX_STREAM_RESUMES = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the player reports a track end. If a B站 stream ended long
|
|
||||||
* before its known duration, the CDN dropped it (#161): fetch a fresh URL
|
|
||||||
* and continue from where it stopped instead of skipping the rest of a
|
|
||||||
* 2-3 hour video. Gives up after MAX_STREAM_RESUMES attempts that make no
|
|
||||||
* real progress, so a truly broken stream still advances the queue.
|
|
||||||
*
|
|
||||||
* Returns true when it handled the end (resumed, or a newer track has
|
|
||||||
* already taken over), false when the caller should advance the queue.
|
|
||||||
*/
|
|
||||||
private async resumeInterruptedStream(): Promise<boolean> {
|
|
||||||
const song = this.queue.current();
|
|
||||||
if (!song || !this.connected || !BotInstance.RESUMABLE_PLATFORMS.has(song.platform)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const duration = this.effectiveDuration ?? song.duration;
|
|
||||||
const position = Math.floor(this.player.getElapsed());
|
|
||||||
if (!(duration > 0) || duration - position <= BotInstance.STREAM_END_TOLERANCE_S) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recovery = this.streamRecovery;
|
|
||||||
if (
|
|
||||||
!recovery ||
|
|
||||||
recovery.song !== song ||
|
|
||||||
position - recovery.position > BotInstance.STREAM_END_TOLERANCE_S
|
|
||||||
) {
|
|
||||||
this.streamRecovery = { song, attempts: 0, position };
|
|
||||||
}
|
|
||||||
const state = this.streamRecovery!;
|
|
||||||
if (state.attempts >= BotInstance.MAX_STREAM_RESUMES) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ songId: song.id, position, duration, attempts: state.attempts },
|
|
||||||
"Stream keeps ending early — giving up and advancing",
|
|
||||||
);
|
|
||||||
this.streamRecovery = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
state.attempts++;
|
|
||||||
state.position = position;
|
|
||||||
|
|
||||||
this.logger.warn(
|
|
||||||
{ songId: song.id, position, duration, attempt: state.attempts },
|
|
||||||
"Stream ended before the track did — resuming with a fresh URL",
|
|
||||||
);
|
|
||||||
const result = await this.getProviderFor(song.platform).getSongUrl(song.id);
|
|
||||||
// The user may have skipped/stopped while we were resolving; never
|
|
||||||
// clobber whatever is playing now.
|
|
||||||
if (this.queue.current() !== song || this.player.getState() !== "idle") return true;
|
|
||||||
if (!result?.url || !this.connected) return false;
|
|
||||||
|
|
||||||
song.url = result.url;
|
|
||||||
this.player.play(result.url, position, duration);
|
|
||||||
this.emit("stateChange");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.profileManager.onSongChange(song);
|
await this.profileManager.onSongChange(song);
|
||||||
|
|||||||
@@ -345,3 +345,36 @@ describe("guest principal migration", () => {
|
|||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("user music cookies (#164)", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
const addUser = (id: string) =>
|
||||||
|
botDb.db
|
||||||
|
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
|
||||||
|
.run(id, id, "x", 0, 0, "member");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
addUser("u1");
|
||||||
|
addUser("u2");
|
||||||
|
});
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("stores, overwrites and deletes a cookie per user and platform", () => {
|
||||||
|
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
|
||||||
|
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a");
|
||||||
|
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=b");
|
||||||
|
expect(botDb.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=b");
|
||||||
|
expect(botDb.getUserMusicCookie("u2", "netease")).toBeNull();
|
||||||
|
expect(botDb.getUserMusicCookie("u1", "qq")).toBeNull();
|
||||||
|
expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(true);
|
||||||
|
expect(botDb.deleteUserMusicCookie("u1", "netease")).toBe(false);
|
||||||
|
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a user's cookies when the user is deleted", () => {
|
||||||
|
botDb.setUserMusicCookie("u1", "netease", "MUSIC_U=a");
|
||||||
|
botDb.db.prepare("DELETE FROM users WHERE id = ?").run("u1");
|
||||||
|
expect(botDb.getUserMusicCookie("u1", "netease")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -144,6 +144,10 @@ export interface BotDatabase {
|
|||||||
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
||||||
getFavorites(userId: string): FavoritePlaylist[];
|
getFavorites(userId: string): FavoritePlaylist[];
|
||||||
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
||||||
|
// Per-user music account cookies (#164).
|
||||||
|
getUserMusicCookie(userId: string, platform: string): string | null;
|
||||||
|
setUserMusicCookie(userId: string, platform: string, cookie: string): void;
|
||||||
|
deleteUserMusicCookie(userId: string, platform: string): boolean;
|
||||||
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
|
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
|
||||||
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
|
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
|
||||||
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
|
||||||
@@ -328,6 +332,17 @@ function initTables(db: Database.Database): void {
|
|||||||
fmPlatform TEXT NOT NULL DEFAULT '',
|
fmPlatform TEXT NOT NULL DEFAULT '',
|
||||||
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- A web user's own music-platform login (#164), used for their personal
|
||||||
|
-- FM instead of the bot's shared account. Secret: never sent to clients.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_music_cookies (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
cookie TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (userId, platform),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +468,17 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const selectUserMusicCookie = db.prepare(
|
||||||
|
`SELECT cookie FROM user_music_cookies WHERE userId = ? AND platform = ?`,
|
||||||
|
);
|
||||||
|
const upsertUserMusicCookie = db.prepare(`
|
||||||
|
INSERT INTO user_music_cookies (userId, platform, cookie) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(userId, platform) DO UPDATE SET cookie = excluded.cookie, updatedAt = datetime('now')
|
||||||
|
`);
|
||||||
|
const deleteUserMusicCookieStmt = db.prepare(
|
||||||
|
`DELETE FROM user_music_cookies WHERE userId = ? AND platform = ?`,
|
||||||
|
);
|
||||||
|
|
||||||
// A corrupt/hand-edited songs blob must never throw into a route or the
|
// A corrupt/hand-edited songs blob must never throw into a route or the
|
||||||
// restore path — degrade to an empty list instead.
|
// restore path — degrade to an empty list instead.
|
||||||
const parseSongs = (raw: string): StoredSong[] => {
|
const parseSongs = (raw: string): StoredSong[] => {
|
||||||
@@ -634,6 +660,19 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
return row !== undefined;
|
return row !== undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUserMusicCookie(userId, platform) {
|
||||||
|
const row = selectUserMusicCookie.get(userId, platform) as { cookie: string } | undefined;
|
||||||
|
return row?.cookie ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setUserMusicCookie(userId, platform, cookie) {
|
||||||
|
upsertUserMusicCookie.run(userId, platform, cookie);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUserMusicCookie(userId, platform) {
|
||||||
|
return deleteUserMusicCookieStmt.run(userId, platform).changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
saveQueue(ownerId, name, songs) {
|
saveQueue(ownerId, name, songs) {
|
||||||
if (songs.length > MAX_QUEUE_SONGS) {
|
if (songs.length > MAX_QUEUE_SONGS) {
|
||||||
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
|
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { BiliBiliProvider, pickStableAudioUrl } from "./bilibili.js";
|
import { BiliBiliProvider } from "./bilibili.js";
|
||||||
|
|
||||||
describe("BiliBiliProvider.search pagination", () => {
|
describe("BiliBiliProvider.search pagination", () => {
|
||||||
function mockProvider() {
|
function mockProvider() {
|
||||||
@@ -162,44 +162,3 @@ describe("BiliBiliProvider multi-P support", () => {
|
|||||||
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
|
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pickStableAudioUrl (#161 long streams dying mid-play)", () => {
|
|
||||||
const pcdn = "https://xy1x2x3x4xy.mcdn.bilivideo.cn:4483/upgcxcode/1/2/3/3-1-30280.m4s?e=x&deadline=1";
|
|
||||||
const szbdyd = "https://cn-hk-eq-01-01.szbdyd.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
|
|
||||||
const upos = "https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
|
|
||||||
const upos2 = "https://upos-sz-mirror08c.bilivideo.com/upgcxcode/1/2/3/3-1-30280.m4s?deadline=1";
|
|
||||||
|
|
||||||
it("prefers an upos/cos mirror over a PCDN baseUrl", () => {
|
|
||||||
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd, upos] })).toBe(upos);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the baseUrl when it is already a stable host", () => {
|
|
||||||
expect(pickStableAudioUrl({ baseUrl: upos, backupUrl: [upos2] })).toBe(upos);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts the snake_case field names", () => {
|
|
||||||
expect(pickStableAudioUrl({ base_url: pcdn, backup_url: [upos2] })).toBe(upos2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the baseUrl when every candidate is PCDN", () => {
|
|
||||||
expect(pickStableAudioUrl({ baseUrl: pcdn, backupUrl: [szbdyd] })).toBe(pcdn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined when there is no url at all", () => {
|
|
||||||
expect(pickStableAudioUrl({})).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getSongUrl returns the stable mirror of the best stream", async () => {
|
|
||||||
const p = new BiliBiliProvider();
|
|
||||||
(p as any).cidCache.set("BV1abc", 42);
|
|
||||||
(p as any).api = {
|
|
||||||
get: vi.fn().mockResolvedValue({
|
|
||||||
data: { data: { dash: { audio: [
|
|
||||||
{ bandwidth: 64000, baseUrl: "https://upos-sz-mirrorcos.bilivideo.com/low.m4s" },
|
|
||||||
{ bandwidth: 320000, baseUrl: pcdn, backupUrl: [upos] },
|
|
||||||
] } } },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
expect((await p.getSongUrl("BV1abc"))?.url).toBe(upos);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-32
@@ -42,37 +42,6 @@ export interface BiliVideoPartsResult {
|
|||||||
parts: BiliVideoPart[];
|
parts: BiliVideoPart[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PCDN / P2P edge hosts (xy*.mcdn.bilivideo.cn:<port>, *.szbdyd.com). Their
|
|
||||||
* sessions get cut mid-file, which kills long streams partway (#89, #161),
|
|
||||||
* and a reconnect to the same host rarely recovers.
|
|
||||||
*/
|
|
||||||
const BILI_PCDN_HOST = /\.mcdn\.bilivideo\.cn$|\.szbdyd\.com$/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the audio URL least likely to die mid-stream: the first upos/cos
|
|
||||||
* mirror among baseUrl + backupUrl, else the baseUrl as before.
|
|
||||||
*/
|
|
||||||
export function pickStableAudioUrl(stream: {
|
|
||||||
baseUrl?: string;
|
|
||||||
base_url?: string;
|
|
||||||
backupUrl?: string[];
|
|
||||||
backup_url?: string[];
|
|
||||||
}): string | undefined {
|
|
||||||
const primary = stream.baseUrl ?? stream.base_url;
|
|
||||||
const candidates = [primary, ...(stream.backupUrl ?? stream.backup_url ?? [])].filter(
|
|
||||||
(u): u is string => typeof u === "string" && u.length > 0,
|
|
||||||
);
|
|
||||||
const stable = candidates.find((u) => {
|
|
||||||
try {
|
|
||||||
return !BILI_PCDN_HOST.test(new URL(u).hostname);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return stable ?? primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析带有分P信息的 B站 ID 或 URL。
|
* 解析带有分P信息的 B站 ID 或 URL。
|
||||||
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
|
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
|
||||||
@@ -384,7 +353,7 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
||||||
);
|
);
|
||||||
|
|
||||||
const biliUrl = pickStableAudioUrl(best);
|
const biliUrl = best.baseUrl ?? best.base_url;
|
||||||
return biliUrl ? { url: biliUrl } : null;
|
return biliUrl ? { url: biliUrl } : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -139,3 +139,48 @@ describe("NeteaseProvider.search pagination", () => {
|
|||||||
expect(callByType(get, 10).offset).toBe(0);
|
expect(callByType(get, 10).offset).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NeteaseProvider per-user login (#164)", () => {
|
||||||
|
function withGet(p: NeteaseProvider, impl: (path: string, cfg: any) => any) {
|
||||||
|
const get = vi.fn(async (path: string, cfg: any) => ({ data: impl(path, cfg) }));
|
||||||
|
(p as any).api = { get, defaults: { baseURL: "http://127.0.0.1:3001" } };
|
||||||
|
return get;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pollQrLogin returns the cookie without touching the shared account", async () => {
|
||||||
|
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||||||
|
p.setCookie("MUSIC_U=shared");
|
||||||
|
withGet(p, () => ({ code: 803, cookie: "MUSIC_U=personal" }));
|
||||||
|
expect(await p.pollQrLogin("k")).toEqual({ status: "confirmed", cookie: "MUSIC_U=personal" });
|
||||||
|
expect(p.getCookie()).toBe("MUSIC_U=shared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pollQrLogin maps the waiting / scanned / expired codes", async () => {
|
||||||
|
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||||||
|
let code = 801;
|
||||||
|
withGet(p, () => ({ code }));
|
||||||
|
expect(await p.pollQrLogin("k")).toEqual({ status: "waiting" });
|
||||||
|
code = 802;
|
||||||
|
expect(await p.pollQrLogin("k")).toEqual({ status: "scanned" });
|
||||||
|
code = 800;
|
||||||
|
expect(await p.pollQrLogin("k")).toEqual({ status: "expired" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checkQrCodeStatus still stores the cookie on the shared provider (admin login)", async () => {
|
||||||
|
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||||||
|
withGet(p, () => ({ code: 803, cookie: "MUSIC_U=admin" }));
|
||||||
|
expect(await p.checkQrCodeStatus("k")).toBe("confirmed");
|
||||||
|
expect(p.getCookie()).toBe("MUSIC_U=admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withCookie gives a view that fetches FM with the other account's cookie", async () => {
|
||||||
|
const p = new NeteaseProvider("http://127.0.0.1:3001");
|
||||||
|
p.setCookie("MUSIC_U=shared");
|
||||||
|
const personal = p.withCookie("MUSIC_U=personal");
|
||||||
|
const get = withGet(personal, () => ({ data: [] }));
|
||||||
|
await personal.getPersonalFm();
|
||||||
|
expect(get.mock.calls[0][1].params.cookie).toBe("MUSIC_U=personal");
|
||||||
|
expect(p.getCookie()).toBe("MUSIC_U=shared");
|
||||||
|
expect(personal.platform).toBe("netease");
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-9
@@ -111,8 +111,10 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private cookie = "";
|
private cookie = "";
|
||||||
private quality = "exhigh";
|
private quality = "exhigh";
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -243,25 +245,47 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
async checkQrCodeStatus(
|
async checkQrCodeStatus(
|
||||||
key: string
|
key: string
|
||||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||||
|
const { status, cookie } = await this.pollQrLogin(key);
|
||||||
|
if (cookie) this.cookie = cookie;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll a QR login and hand back the resulting cookie WITHOUT storing it on
|
||||||
|
* this provider — for a web user linking their own account (#164), which
|
||||||
|
* must never replace the bot's shared login.
|
||||||
|
*/
|
||||||
|
async pollQrLogin(
|
||||||
|
key: string
|
||||||
|
): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }> {
|
||||||
const res = await this.api.get("/login/qr/check", {
|
const res = await this.api.get("/login/qr/check", {
|
||||||
params: { key, timestamp: Date.now() },
|
params: { key, timestamp: Date.now() },
|
||||||
});
|
});
|
||||||
const code = res.data?.code;
|
switch (res.data?.code) {
|
||||||
switch (code) {
|
|
||||||
case 801:
|
case 801:
|
||||||
return "waiting";
|
return { status: "waiting" };
|
||||||
case 802:
|
case 802:
|
||||||
return "scanned";
|
return { status: "scanned" };
|
||||||
case 803:
|
case 803:
|
||||||
if (res.data?.cookie) {
|
return res.data?.cookie
|
||||||
this.cookie = res.data.cookie;
|
? { status: "confirmed", cookie: res.data.cookie }
|
||||||
}
|
: { status: "confirmed" };
|
||||||
return "confirmed";
|
|
||||||
default:
|
default:
|
||||||
return "expired";
|
return { status: "expired" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider for the same API server logged in as another account (#164):
|
||||||
|
* a web user's personal FM uses their own taste instead of the shared login.
|
||||||
|
*/
|
||||||
|
withCookie(cookie: string): NeteaseProvider {
|
||||||
|
const view = new NeteaseProvider(this.baseUrl);
|
||||||
|
view.setQuality(this.quality);
|
||||||
|
view.setCookie(cookie);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
async sendSmsCode(phone: string): Promise<boolean> {
|
async sendSmsCode(phone: string): Promise<boolean> {
|
||||||
const res = await this.api.get("/captcha/sent", {
|
const res = await this.api.get("/captcha/sent", {
|
||||||
params: { phone },
|
params: { phone },
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createDatabase } from "../../data/database.js";
|
||||||
|
import { createPersonalMusicRouter } from "./personal-music.js";
|
||||||
|
import { createPlayerRouter } from "./player.js";
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const db = createDatabase(":memory:");
|
||||||
|
db.db
|
||||||
|
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
|
||||||
|
.run("u1", "alice", "x", 0, 0, "member");
|
||||||
|
const personalView = {
|
||||||
|
getAuthStatus: vi.fn(async () => ({ loggedIn: true, nickname: "Alice163" })),
|
||||||
|
};
|
||||||
|
const provider: any = {
|
||||||
|
platform: "netease",
|
||||||
|
getQrCode: vi.fn(async () => ({ qrUrl: "u", qrImg: "data:img", key: "k1" })),
|
||||||
|
pollQrLogin: vi.fn(async () => ({ status: "waiting" })),
|
||||||
|
withCookie: vi.fn(() => personalView),
|
||||||
|
setCookie: vi.fn(),
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).user = { id: "u1", username: "alice", role: "member" };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api/me/music", createPersonalMusicRouter(db, provider, pino({ level: "silent" })));
|
||||||
|
return { app, db, provider, personalView };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("personal music account router (#164)", () => {
|
||||||
|
it("reports not linked until the user logs in", async () => {
|
||||||
|
const { app } = mount();
|
||||||
|
const res = await request(app).get("/api/me/music/netease/status");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ linked: false, loggedIn: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a QR code", async () => {
|
||||||
|
const { app } = mount();
|
||||||
|
const res = await request(app).post("/api/me/music/netease/qrcode");
|
||||||
|
expect(res.body).toEqual({ qrUrl: "u", qrImg: "data:img", key: "k1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the cookie for this user on confirm, never on the shared provider, and never returns it", async () => {
|
||||||
|
const { app, db, provider } = mount();
|
||||||
|
provider.pollQrLogin.mockResolvedValue({ status: "confirmed", cookie: "MUSIC_U=alice" });
|
||||||
|
const res = await request(app).get("/api/me/music/netease/qrcode/status").query({ key: "k1" });
|
||||||
|
expect(res.body).toEqual({ status: "confirmed" });
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain("MUSIC_U");
|
||||||
|
expect(db.getUserMusicCookie("u1", "netease")).toBe("MUSIC_U=alice");
|
||||||
|
expect(provider.setCookie).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a key to poll", async () => {
|
||||||
|
const { app } = mount();
|
||||||
|
expect((await request(app).get("/api/me/music/netease/qrcode/status")).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the linked account's nickname via a view on the user's cookie", async () => {
|
||||||
|
const { app, db, provider } = mount();
|
||||||
|
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
|
||||||
|
const res = await request(app).get("/api/me/music/netease/status");
|
||||||
|
expect(res.body).toEqual({ linked: true, loggedIn: true, nickname: "Alice163" });
|
||||||
|
expect(provider.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlinks", async () => {
|
||||||
|
const { app, db } = mount();
|
||||||
|
db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
|
||||||
|
expect((await request(app).delete("/api/me/music/netease")).status).toBe(200);
|
||||||
|
expect(db.getUserMusicCookie("u1", "netease")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("web FM uses the caller's linked NetEase account (#164)", () => {
|
||||||
|
async function startFm(opts: { linked: boolean; role?: string; platform?: string }) {
|
||||||
|
const db = createDatabase(":memory:");
|
||||||
|
db.db
|
||||||
|
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)")
|
||||||
|
.run("u1", "alice", "x", 0, 0, "member");
|
||||||
|
if (opts.linked) db.setUserMusicCookie("u1", "netease", "MUSIC_U=alice");
|
||||||
|
const personal = { platform: "netease", personal: true };
|
||||||
|
const shared: any = { platform: "netease", pollQrLogin: vi.fn(), withCookie: vi.fn(() => personal) };
|
||||||
|
const qq: any = { platform: "qq" };
|
||||||
|
const bot = {
|
||||||
|
id: "b1",
|
||||||
|
getProviderFor: (p: string) => (p === "qq" ? qq : shared),
|
||||||
|
startFm: vi.fn(async (_provider: unknown) => "Personal FM started"),
|
||||||
|
};
|
||||||
|
const botManager: any = { getBot: () => bot };
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).user = {
|
||||||
|
id: "u1", username: "alice", role: opts.role ?? "member",
|
||||||
|
capabilities: new Set(["player.control"]), bots: "all", guest: { playMode: true },
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api/player", createPlayerRouter(botManager, pino({ level: "silent" }), db));
|
||||||
|
const res = await request(app).post("/api/player/b1/fm").send({ platform: opts.platform ?? "netease" });
|
||||||
|
return { res, bot, shared, personal, qq };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts FM on the user's own account when linked", async () => {
|
||||||
|
const { res, bot, shared, personal } = await startFm({ linked: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(shared.withCookie).toHaveBeenCalledWith("MUSIC_U=alice");
|
||||||
|
expect(bot.startFm.mock.calls[0][0]).toBe(personal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the shared account when the user has not linked one", async () => {
|
||||||
|
const { bot, shared } = await startFm({ linked: false });
|
||||||
|
expect(bot.startFm.mock.calls[0][0]).toBe(shared);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves other platforms alone", async () => {
|
||||||
|
const { bot, qq } = await startFm({ linked: true, platform: "qq" });
|
||||||
|
expect(bot.startFm.mock.calls[0][0]).toBe(qq);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import type { MusicProvider, QrCodeResult } from "../../music/provider.js";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider that can log a web user into their OWN account without touching
|
||||||
|
* the bot's shared login, and hand out a view bound to that account (#164).
|
||||||
|
*/
|
||||||
|
export interface PersonalLoginProvider {
|
||||||
|
getQrCode(): Promise<QrCodeResult>;
|
||||||
|
pollQrLogin(key: string): Promise<{ status: "waiting" | "scanned" | "confirmed" | "expired"; cookie?: string }>;
|
||||||
|
withCookie(cookie: string): MusicProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsPersonalLogin(
|
||||||
|
provider: MusicProvider | undefined,
|
||||||
|
): provider is MusicProvider & PersonalLoginProvider {
|
||||||
|
const p = provider as Partial<PersonalLoginProvider> | undefined;
|
||||||
|
return typeof p?.pollQrLogin === "function" && typeof p.withCookie === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own NetEase account, used for their personal FM instead of the
|
||||||
|
* bot's shared login (#164). Every route acts on req.user only; the cookie is
|
||||||
|
* stored server-side and never sent back to the browser.
|
||||||
|
*/
|
||||||
|
export function createPersonalMusicRouter(
|
||||||
|
database: BotDatabase,
|
||||||
|
neteaseProvider: MusicProvider,
|
||||||
|
logger: Logger,
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
const platform = "netease";
|
||||||
|
|
||||||
|
router.use((_req, res, next) => {
|
||||||
|
if (!supportsPersonalLogin(neteaseProvider)) {
|
||||||
|
res.status(501).json({ error: "Personal login not supported" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const provider = neteaseProvider as MusicProvider & PersonalLoginProvider;
|
||||||
|
|
||||||
|
router.get("/netease/status", async (req, res) => {
|
||||||
|
const cookie = database.getUserMusicCookie(req.user!.id, platform);
|
||||||
|
if (!cookie) {
|
||||||
|
res.json({ linked: false, loggedIn: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await provider.withCookie(cookie).getAuthStatus();
|
||||||
|
res.json({ linked: true, ...status });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, "Personal NetEase status check failed");
|
||||||
|
res.json({ linked: true, loggedIn: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/netease/qrcode", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await provider.getQrCode());
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Personal NetEase QR generation failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/netease/qrcode/status", async (req, res) => {
|
||||||
|
const key = req.query.key;
|
||||||
|
if (typeof key !== "string" || !key) {
|
||||||
|
res.status(400).json({ error: "key is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { status, cookie } = await provider.pollQrLogin(key);
|
||||||
|
if (status === "confirmed" && cookie) {
|
||||||
|
database.setUserMusicCookie(req.user!.id, platform, cookie);
|
||||||
|
logger.info({ userId: req.user!.id, platform }, "Personal music account linked");
|
||||||
|
}
|
||||||
|
res.json({ status });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Personal NetEase QR status check failed");
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/netease", (req, res) => {
|
||||||
|
database.deleteUserMusicCookie(req.user!.id, platform);
|
||||||
|
logger.info({ userId: req.user!.id, platform }, "Personal music account unlinked");
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+10
-1
@@ -6,6 +6,7 @@ import type { Logger } from "../../logger.js";
|
|||||||
import { parseCommand } from "../../bot/commands.js";
|
import { parseCommand } from "../../bot/commands.js";
|
||||||
import { requireBotAccess } from "../middleware/requirePermission.js";
|
import { requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
import { authorize } from "../middleware/authorize.js";
|
import { authorize } from "../middleware/authorize.js";
|
||||||
|
import { supportsPersonalLogin } from "./personal-music.js";
|
||||||
|
|
||||||
export function createPlayerRouter(
|
export function createPlayerRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
@@ -124,11 +125,19 @@ export function createPlayerRouter(
|
|||||||
rejectDisabledLocalAudio(res);
|
rejectDisabledLocalAudio(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = bot.getProviderFor(
|
let provider = bot.getProviderFor(
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||||
? platform
|
? platform
|
||||||
: "netease"
|
: "netease"
|
||||||
);
|
);
|
||||||
|
// A signed-in user who linked their own NetEase account gets FM from
|
||||||
|
// THEIR taste, not the bot's shared login (#164). Songs still resolve
|
||||||
|
// through the shared provider when played.
|
||||||
|
const user = (req as any).user;
|
||||||
|
if (provider.platform === "netease" && user && user.role !== "guest" && database) {
|
||||||
|
const cookie = database.getUserMusicCookie(user.id, "netease");
|
||||||
|
if (cookie && supportsPersonalLogin(provider)) provider = provider.withCookie(cookie);
|
||||||
|
}
|
||||||
const message = await bot.startFm(provider, requesterName(req));
|
const message = await bot.startFm(provider, requesterName(req));
|
||||||
res.json({
|
res.json({
|
||||||
ok:
|
ok:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createUsersRouter } from "./api/users.js";
|
|||||||
import { createAuditStore } from "../data/audit.js";
|
import { createAuditStore } from "../data/audit.js";
|
||||||
import { createAuditRouter } from "./api/audit.js";
|
import { createAuditRouter } from "./api/audit.js";
|
||||||
import { createFavoritesRouter } from "./api/favorites.js";
|
import { createFavoritesRouter } from "./api/favorites.js";
|
||||||
|
import { createPersonalMusicRouter } from "./api/personal-music.js";
|
||||||
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
||||||
import { createSpotifyRouter } from "./api/spotify.js";
|
import { createSpotifyRouter } from "./api/spotify.js";
|
||||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||||
@@ -203,6 +204,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||||
|
// The caller's own NetEase login for their personal FM (#164). Guests share
|
||||||
|
// one anonymous identity, so they cannot link an account.
|
||||||
|
app.use(
|
||||||
|
"/api/me/music",
|
||||||
|
requireNotGuest,
|
||||||
|
createPersonalMusicRouter(options.database, options.neteaseProvider, logger),
|
||||||
|
);
|
||||||
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
|
// Saved queues (Feature 1, #119). Members + admins only (requireNotGuest);
|
||||||
// the router itself 403s every route unless savedQueuesEnabled is on.
|
// the router itself 403s every route unless savedQueuesEnabled is on.
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<template>
|
||||||
|
<!-- The signed-in user's own NetEase account, used for THEIR 私人FM instead
|
||||||
|
of the bot's shared login (#164). -->
|
||||||
|
<div class="account-card">
|
||||||
|
<div class="account-header">
|
||||||
|
<Icon icon="mdi:radio" class="account-icon" />
|
||||||
|
<div class="account-info">
|
||||||
|
<div class="account-name">我的网易云账号(私人FM)</div>
|
||||||
|
<div class="account-status" :class="{ logged: status.loggedIn }">
|
||||||
|
<template v-if="status.loggedIn">已绑定: {{ status.nickname }}</template>
|
||||||
|
<template v-else-if="status.linked">已绑定,但登录已失效,请重新扫码</template>
|
||||||
|
<template v-else>未绑定 — 私人FM使用机器人的共享账号</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
绑定后,你在网页端开启的网易云私人FM会按你自己的口味推荐;其他人不受影响。
|
||||||
|
仅保存在服务器上,不会显示给任何人。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="login-methods">
|
||||||
|
<button class="login-btn" :disabled="qr.loading" @click="startQrLogin">
|
||||||
|
<Icon icon="mdi:qrcode" />
|
||||||
|
{{ status.linked ? '重新扫码绑定' : '扫码绑定' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="status.linked" class="login-btn" @click="unlink">
|
||||||
|
<Icon icon="mdi:link-off" />
|
||||||
|
解除绑定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="qr.loading" class="qr-loading">
|
||||||
|
<Icon icon="mdi:loading" class="spin" />
|
||||||
|
生成二维码中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="qr.dataUrl" class="qr-wrap">
|
||||||
|
<img :src="qr.dataUrl" class="qr-image" alt="QR Code" />
|
||||||
|
<div class="qr-status" :class="qr.status">
|
||||||
|
<template v-if="qr.status === 'waiting'">
|
||||||
|
<Icon icon="mdi:cellphone" /> 请使用网易云音乐APP扫码
|
||||||
|
</template>
|
||||||
|
<template v-else-if="qr.status === 'scanned'">
|
||||||
|
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
|
||||||
|
</template>
|
||||||
|
<template v-else-if="qr.status === 'confirmed'">
|
||||||
|
<Icon icon="mdi:check-circle" /> 绑定成功!
|
||||||
|
</template>
|
||||||
|
<template v-else-if="qr.status === 'expired'">
|
||||||
|
<Icon icon="mdi:refresh" /> 二维码已过期
|
||||||
|
<button class="btn-link" @click="startQrLogin">重新生成</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const BASE = '/api/me/music/netease';
|
||||||
|
|
||||||
|
const status = reactive({ linked: false, loggedIn: false, nickname: '' });
|
||||||
|
const qr = reactive({
|
||||||
|
loading: false,
|
||||||
|
dataUrl: '',
|
||||||
|
key: '',
|
||||||
|
status: 'waiting' as 'waiting' | 'scanned' | 'confirmed' | 'expired',
|
||||||
|
});
|
||||||
|
const error = ref('');
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/status`);
|
||||||
|
status.linked = Boolean(res.data?.linked);
|
||||||
|
status.loggedIn = Boolean(res.data?.loggedIn);
|
||||||
|
status.nickname = res.data?.nickname ?? '';
|
||||||
|
} catch {
|
||||||
|
// Leave the last known state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startQrLogin() {
|
||||||
|
stopPolling();
|
||||||
|
error.value = '';
|
||||||
|
qr.loading = true;
|
||||||
|
qr.dataUrl = '';
|
||||||
|
qr.status = 'waiting';
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`${BASE}/qrcode`);
|
||||||
|
const { qrUrl, qrImg, key } = res.data;
|
||||||
|
qr.key = key;
|
||||||
|
// Dark-on-light only: many in-app scanners can't read an inverted code.
|
||||||
|
qr.dataUrl = qrImg || (await QRCode.toDataURL(qrUrl, {
|
||||||
|
width: 200,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' },
|
||||||
|
}));
|
||||||
|
pollTimer = setInterval(pollQrStatus, 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.response?.data?.error ?? '二维码生成失败';
|
||||||
|
} finally {
|
||||||
|
qr.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollQrStatus() {
|
||||||
|
if (!qr.key) return;
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/qrcode/status`, { params: { key: qr.key } });
|
||||||
|
qr.status = res.data.status;
|
||||||
|
if (qr.status === 'confirmed') {
|
||||||
|
stopPolling();
|
||||||
|
await refreshStatus();
|
||||||
|
} else if (qr.status === 'expired') {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore poll errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlink() {
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await axios.delete(BASE);
|
||||||
|
stopPolling();
|
||||||
|
qr.dataUrl = '';
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.response?.data?.error ?? '解除绑定失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshStatus);
|
||||||
|
onUnmounted(stopPolling);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.account-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
&.logged { color: var(--color-online); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-methods {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
&:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
&:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
&.confirmed { color: var(--color-online); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e26a6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
||||||
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||||
|
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
||||||
@@ -1161,6 +1162,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import AvatarUpload from '../components/AvatarUpload.vue';
|
import AvatarUpload from '../components/AvatarUpload.vue';
|
||||||
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
||||||
|
import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
import { useSession } from '../composables/useSession.js';
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
|||||||
Reference in new issue
Block a user