mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #123 from ItsEricRao/main
Jellyfin Integration. Assisted by Claude Fable 5.
This commit is contained in:
32 files changed
+2802
-171
No files matched your search
+1
-1
@@ -10,7 +10,7 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
|
||||
+121
-25
@@ -6,7 +6,7 @@ import {
|
||||
} from "../ts-protocol/client.js";
|
||||
import { AudioPlayer } from "../audio/player.js";
|
||||
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
||||
import type { MusicProvider, Song } from "../music/provider.js";
|
||||
import type { MusicProvider, Platform, Song } from "../music/provider.js";
|
||||
import {
|
||||
parseCommand,
|
||||
canRunCommand,
|
||||
@@ -16,7 +16,13 @@ import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
import { splitTextIntoChunks } from "./text-chunk.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||
import type { BotConfig, SpotifyConfig } from "../data/config.js";
|
||||
import {
|
||||
isProviderEnabled,
|
||||
defaultPlatform,
|
||||
type BotConfig,
|
||||
type SpotifyConfig,
|
||||
} from "../data/config.js";
|
||||
import type { JellyfinPlaybackReporter } from "../music/jellyfin.js";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import {
|
||||
@@ -80,6 +86,7 @@ export interface BotInstanceOptions {
|
||||
localProvider?: MusicProvider;
|
||||
kugouProvider?: MusicProvider;
|
||||
spotifyProvider?: MusicProvider;
|
||||
jellyfinProvider?: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
@@ -132,6 +139,7 @@ export class BotInstance extends EventEmitter {
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private spotifyProvider: MusicProvider;
|
||||
private jellyfinProvider: MusicProvider;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -155,6 +163,9 @@ export class BotInstance extends EventEmitter {
|
||||
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
|
||||
private effectiveDuration: number | undefined;
|
||||
private playGate: Promise<unknown> = Promise.resolve();
|
||||
/** Per-bot Jellyfin playback-report session (start / ~10s progress / stop).
|
||||
* null when the wired provider has no reporting capability. */
|
||||
private jellyfinReporter: JellyfinPlaybackReporter | null = null;
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -167,6 +178,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.localProvider = options.localProvider ?? options.neteaseProvider;
|
||||
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
|
||||
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
|
||||
this.jellyfinProvider = options.jellyfinProvider ?? options.neteaseProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
@@ -176,6 +188,14 @@ export class BotInstance extends EventEmitter {
|
||||
this.player = new AudioPlayer(this.logger);
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
// Structural typing (like localProvider.sweepUnreferenced): only the real
|
||||
// JellyfinProvider exposes createPlaybackReporter, so the netease fallback
|
||||
// provider simply leaves reporting off.
|
||||
const jfReportable = this.jellyfinProvider as MusicProvider & {
|
||||
createPlaybackReporter?: () => JellyfinPlaybackReporter;
|
||||
};
|
||||
this.jellyfinReporter = jfReportable.createPlaybackReporter?.() ?? null;
|
||||
|
||||
// One long-lived Spotify sidecar controller per bot. Construction is
|
||||
// cheap and side-effect-free — nothing spawns until ensureStarted().
|
||||
const spotifyBase =
|
||||
@@ -312,6 +332,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.spotifyController.stop();
|
||||
this.currentSourceIsSpotify = false;
|
||||
this.player.stop();
|
||||
this.jellyfinReporter?.onStop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("disconnected");
|
||||
// A lifecycle change must not leave a stale auto-resume armed.
|
||||
@@ -327,6 +348,7 @@ export class BotInstance extends EventEmitter {
|
||||
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
||||
this.autoPaused = false;
|
||||
this._startIdlePoller();
|
||||
this._startJellyfinReportPoller();
|
||||
});
|
||||
|
||||
// React near-instantly to channel membership changes. The 30s idle
|
||||
@@ -397,6 +419,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.spotifyController.stop();
|
||||
this.currentSourceIsSpotify = false;
|
||||
this.player.stop();
|
||||
this.jellyfinReporter?.onStop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("disconnected");
|
||||
this.connected = false;
|
||||
@@ -475,6 +498,29 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ~10s Jellyfin progress reporting, following the _startIdlePoller pattern:
|
||||
* a self-rescheduling timeout guarded by this.connected, so it needs no
|
||||
* explicit teardown. When the current track is not (or no longer) a jellyfin
|
||||
* item, onStop() idempotently closes any open report session.
|
||||
*/
|
||||
private _startJellyfinReportPoller(): void {
|
||||
if (!this.jellyfinReporter) return;
|
||||
const tick = () => {
|
||||
if (!this.connected) return;
|
||||
const reporter = this.jellyfinReporter!;
|
||||
const current = this.queue.current();
|
||||
const state = this.player.getState();
|
||||
if (current?.platform === "jellyfin" && (state === "playing" || state === "paused")) {
|
||||
reporter.onTick(current.id, this.player.getElapsed(), state === "paused");
|
||||
} else {
|
||||
reporter.onStop();
|
||||
}
|
||||
setTimeout(tick, 10_000);
|
||||
};
|
||||
setTimeout(tick, 10_000);
|
||||
}
|
||||
|
||||
private _scheduleIdleCheck(): void {
|
||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||
@@ -659,28 +705,54 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider {
|
||||
getProviderFor(platform: Platform): MusicProvider {
|
||||
if (platform === "bilibili") return this.bilibiliProvider;
|
||||
if (platform === "youtube") return this.youtubeProvider;
|
||||
if (platform === "local") return this.localProvider;
|
||||
if (platform === "kugou") return this.kugouProvider;
|
||||
if (platform === "spotify") return this.spotifyProvider;
|
||||
if (platform === "jellyfin") return this.jellyfinProvider;
|
||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
/** Friendly gate for user-selected platforms (flags / URLs / REST params). */
|
||||
assertProviderEnabled(platform: Platform): void {
|
||||
if (!isProviderEnabled(this.config, platform)) {
|
||||
throw new Error(
|
||||
`音源未启用:${platform}(provider disabled — 需在配置 enabledProviders 中开启)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private disableFmMode(): void {
|
||||
this.isFmMode = false;
|
||||
this.fmProvider = null;
|
||||
this.fmRequesterName = undefined;
|
||||
}
|
||||
|
||||
/** Chat-command source flags. No flag → the configured default platform
|
||||
* (jellyfin unless disabled). Netease is no longer the implicit default,
|
||||
* so it gets an explicit -n flag. */
|
||||
private static readonly FLAG_PLATFORMS: ReadonlyArray<[string, Platform]> = [
|
||||
["b", "bilibili"],
|
||||
["q", "qq"],
|
||||
["y", "youtube"],
|
||||
["k", "kugou"],
|
||||
["s", "spotify"],
|
||||
["n", "netease"],
|
||||
["j", "jellyfin"],
|
||||
];
|
||||
|
||||
private getProvider(flags: Set<string>): MusicProvider {
|
||||
if (flags.has("b")) return this.bilibiliProvider;
|
||||
if (flags.has("q")) return this.qqProvider;
|
||||
if (flags.has("y")) return this.youtubeProvider;
|
||||
if (flags.has("k")) return this.kugouProvider;
|
||||
if (flags.has("s")) return this.spotifyProvider;
|
||||
return this.neteaseProvider;
|
||||
for (const [flag, platform] of BotInstance.FLAG_PLATFORMS) {
|
||||
if (flags.has(flag)) {
|
||||
this.assertProviderEnabled(platform);
|
||||
return this.getProviderFor(platform);
|
||||
}
|
||||
}
|
||||
const def = defaultPlatform(this.config);
|
||||
this.assertProviderEnabled(def);
|
||||
return this.getProviderFor(def);
|
||||
}
|
||||
|
||||
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
|
||||
@@ -793,6 +865,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.player.resume();
|
||||
}
|
||||
this.currentSourceIsSpotify = true;
|
||||
this.jellyfinReporter?.onStop();
|
||||
song.url = result.url;
|
||||
// No trial clip for Spotify — full-track duration only (the near-end
|
||||
// stall watchdog is disabled for the external stream anyway).
|
||||
@@ -823,6 +896,11 @@ export class BotInstance extends EventEmitter {
|
||||
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
||||
this.effectiveDuration = result.trialDuration ?? song.duration;
|
||||
this.player.play(result.url, 0, this.effectiveDuration);
|
||||
// Jellyfin playback reporting: open a session for jellyfin tracks (the
|
||||
// reporter closes the previous one itself); close any open session when
|
||||
// playback moves to another source. Fire-and-forget — never blocks play.
|
||||
if (song.platform === "jellyfin") this.jellyfinReporter?.onTrackStart(song.id);
|
||||
else this.jellyfinReporter?.onStop();
|
||||
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||
// change won't try to "resume" a track the user already restarted.
|
||||
this.autoPaused = false;
|
||||
@@ -878,6 +956,7 @@ export class BotInstance extends EventEmitter {
|
||||
// 2) id:/URL — fetch that exact song.
|
||||
const ref = parseSongRef(args);
|
||||
if (ref) {
|
||||
if (ref.platform) this.assertProviderEnabled(ref.platform);
|
||||
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
|
||||
const song = await provider.getSongDetail(ref.id);
|
||||
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
|
||||
@@ -1019,6 +1098,7 @@ export class BotInstance extends EventEmitter {
|
||||
}
|
||||
this.currentSourceIsSpotify = false;
|
||||
this.player.stop();
|
||||
this.jellyfinReporter?.onStop();
|
||||
this.autoPaused = false;
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("stopped");
|
||||
@@ -1081,6 +1161,7 @@ export class BotInstance extends EventEmitter {
|
||||
this.spotifyController.stop();
|
||||
this.currentSourceIsSpotify = false;
|
||||
this.player.stop();
|
||||
this.jellyfinReporter?.onStop();
|
||||
this.queue.clear();
|
||||
this.sweepLocalAudio("queue_cleared");
|
||||
this.disableFmMode();
|
||||
@@ -1141,14 +1222,14 @@ export class BotInstance extends EventEmitter {
|
||||
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
|
||||
// Determine if input is a numeric ID or a name search
|
||||
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
|
||||
const id = this.extractId(cmd.args);
|
||||
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||
const isDirectId = this.looksLikeCollectionId(cmd.args);
|
||||
|
||||
let playlistId: string;
|
||||
|
||||
if (isNumericId || id !== cmd.args) {
|
||||
// Input is a numeric ID or URL containing an ID — use existing logic
|
||||
if (isDirectId || id !== cmd.args) {
|
||||
// Input is a direct ID or URL containing an ID — use existing logic
|
||||
playlistId = id;
|
||||
} else {
|
||||
// Name-based search
|
||||
@@ -1196,12 +1277,12 @@ export class BotInstance extends EventEmitter {
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
|
||||
const id = this.extractId(cmd.args);
|
||||
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||
const isDirectId = this.looksLikeCollectionId(cmd.args);
|
||||
|
||||
let albumId: string;
|
||||
|
||||
if (isNumericId || id !== cmd.args) {
|
||||
// Input is a numeric ID or URL containing an ID — use directly
|
||||
if (isDirectId || id !== cmd.args) {
|
||||
// Input is a direct ID (numeric / Jellyfin GUID) or URL containing an ID — use directly
|
||||
albumId = id;
|
||||
} else {
|
||||
// Name-based search
|
||||
@@ -1360,15 +1441,21 @@ export class BotInstance extends EventEmitter {
|
||||
|
||||
private cmdHelp(): string {
|
||||
const p = this.config.commandPrefix;
|
||||
const def = defaultPlatform(this.config);
|
||||
// Only advertise source flags whose provider is actually enabled.
|
||||
const flagHelp = BotInstance.FLAG_PLATFORMS.filter(([, platform]) =>
|
||||
isProviderEnabled(this.config, platform),
|
||||
)
|
||||
.map(([flag, platform]) => `-${flag}=${platform}`)
|
||||
.join(" ");
|
||||
return [
|
||||
"TSMusicBot Commands:",
|
||||
`${p}play <song> — Search and play (most popular match)`,
|
||||
`${p}play -q <song> — Search from QQ Music`,
|
||||
`${p}play -b <song> — Search from BiliBili`,
|
||||
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
|
||||
`${p}play <song> — Search and play (default source: ${def})`,
|
||||
`${p}play -j <song> — Search from Jellyfin`,
|
||||
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
|
||||
`${p}search <name> — List top matches to pick a specific (same-name) song`,
|
||||
`${p}play #N — Play the Nth result of the last ${p}search`,
|
||||
`${p}play id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
|
||||
`${p}play id:<id> — Play an exact song by id / URL`,
|
||||
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
|
||||
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
|
||||
`${p}pause/resume — Pause/resume`,
|
||||
@@ -1379,11 +1466,9 @@ export class BotInstance extends EventEmitter {
|
||||
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
|
||||
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
||||
`${p}playlist <name or id> — Load playlist by name or ID`,
|
||||
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
|
||||
`${p}album <id> — Load album`,
|
||||
`${p}fm — Personal FM (NetEase)`,
|
||||
`${p}album <name or id> — Load album`,
|
||||
`${p}fm — Personal FM (Jellyfin: 收藏电台 Instant Mix)`,
|
||||
`${p}artist <name> — Play songs by artist (loop)`,
|
||||
`${p}artist -q <name> — Artist loop from QQ Music`,
|
||||
`${p}vote — Vote to skip`,
|
||||
`${p}lyrics — Show lyrics`,
|
||||
`${p}now — Current song info`,
|
||||
@@ -1471,6 +1556,17 @@ export class BotInstance extends EventEmitter {
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Direct collection ids: numeric (NetEase/QQ) or Jellyfin GUID ItemIds
|
||||
* (32 hex chars, optionally dashed) — never treat those as name searches. */
|
||||
private looksLikeCollectionId(raw: string): boolean {
|
||||
const t = raw.trim();
|
||||
return (
|
||||
/^\d+$/.test(t) ||
|
||||
/^[0-9a-fA-F]{32}$/.test(t) ||
|
||||
/^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/** Serialize queue-mutation + play sequences so concurrent requests can't
|
||||
* interleave (audible track must match queue.currentIndex). */
|
||||
runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
|
||||
+7
-1
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private spotifyProvider: MusicProvider;
|
||||
private jellyfinProvider: MusicProvider;
|
||||
private spotifyDataDir: string;
|
||||
private readonly spotifyOAuth?: SpotifyOAuth;
|
||||
private database: BotDatabase;
|
||||
@@ -102,7 +103,8 @@ export class BotManager extends EventEmitter {
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider,
|
||||
spotifyDataDir?: string,
|
||||
spotifyOAuth?: SpotifyOAuth
|
||||
spotifyOAuth?: SpotifyOAuth,
|
||||
jellyfinProvider?: MusicProvider
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
@@ -112,6 +114,7 @@ export class BotManager extends EventEmitter {
|
||||
this.localProvider = localProvider ?? neteaseProvider;
|
||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
||||
this.jellyfinProvider = jellyfinProvider ?? neteaseProvider;
|
||||
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
||||
this.spotifyOAuth = spotifyOAuth;
|
||||
// Let the local provider see which uploads are still referenced by any
|
||||
@@ -153,6 +156,7 @@ export class BotManager extends EventEmitter {
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
jellyfinProvider: this.jellyfinProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -296,6 +300,7 @@ export class BotManager extends EventEmitter {
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
jellyfinProvider: this.jellyfinProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
@@ -353,6 +358,7 @@ export class BotManager extends EventEmitter {
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
jellyfinProvider: this.jellyfinProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -26,6 +26,54 @@ export interface SpotifyConfig {
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
export interface JellyfinConfig {
|
||||
/** Base URL of the Jellyfin server, e.g. "https://jellyfin.example.com". */
|
||||
serverUrl: string;
|
||||
authMode: "userpass" | "apikey";
|
||||
// userpass mode
|
||||
username: string;
|
||||
password: string;
|
||||
// apikey mode: admin API key + the user whose library/favorites/playlists are used
|
||||
apiKey: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers gated by `enabledProviders`. Not listed here:
|
||||
* - "local" → governed by the existing `localAudioEnabled` flag
|
||||
* - "spotify" → governed by the existing `spotify.enabled` flag
|
||||
*/
|
||||
export const GATEABLE_PROVIDERS = [
|
||||
"jellyfin",
|
||||
"netease",
|
||||
"qq",
|
||||
"bilibili",
|
||||
"youtube",
|
||||
"kugou",
|
||||
] as const;
|
||||
export type GateableProvider = (typeof GATEABLE_PROVIDERS)[number];
|
||||
|
||||
/** Whether a platform may be used for search/playback under the current config. */
|
||||
export function isProviderEnabled(config: BotConfig, platform: string): boolean {
|
||||
if (platform === "local") return config.localAudioEnabled !== false;
|
||||
if (platform === "spotify") return config.spotify.enabled;
|
||||
return config.enabledProviders.includes(platform as GateableProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* The default platform for !play/!add/!playlist/!album and all REST/WebUI calls:
|
||||
* jellyfin when enabled, otherwise the first enabled provider in a fixed
|
||||
* priority order. Falls back to "netease" when nothing is enabled so callers
|
||||
* always get a provider — the enabled-gate then produces the friendly error.
|
||||
*/
|
||||
export function defaultPlatform(config: BotConfig): GateableProvider {
|
||||
if (config.enabledProviders.includes("jellyfin")) return "jellyfin";
|
||||
for (const p of ["netease", "qq", "kugou", "bilibili", "youtube"] as const) {
|
||||
if (config.enabledProviders.includes(p)) return p;
|
||||
}
|
||||
return "netease";
|
||||
}
|
||||
|
||||
export interface BotConfig {
|
||||
webPort: number;
|
||||
locale: "zh" | "en";
|
||||
@@ -51,6 +99,14 @@ export interface BotConfig {
|
||||
trustProxy: boolean;
|
||||
guestMode: GuestModeConfig;
|
||||
spotify: SpotifyConfig;
|
||||
jellyfin: JellyfinConfig;
|
||||
/**
|
||||
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
|
||||
* jellyfin-only: the legacy NetEase/QQ/Bilibili/YouTube/Kugou sources keep
|
||||
* compiling but stay disabled — their embedded sidecar API servers must not
|
||||
* start (or bind ports 3001/3200) unless listed here.
|
||||
*/
|
||||
enabledProviders: GateableProvider[];
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): BotConfig {
|
||||
@@ -95,6 +151,15 @@ export function getDefaultConfig(): BotConfig {
|
||||
deviceName: "TSMusicBot",
|
||||
bitrate: 320,
|
||||
},
|
||||
jellyfin: {
|
||||
serverUrl: "",
|
||||
authMode: "userpass",
|
||||
username: "",
|
||||
password: "",
|
||||
apiKey: "",
|
||||
userId: "",
|
||||
},
|
||||
enabledProviders: ["jellyfin"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,12 +286,42 @@ export function loadConfig(path: string): BotConfig {
|
||||
: defaults.spotify.bitrate,
|
||||
};
|
||||
|
||||
// Sanitize the jellyfin block on load, mirroring the spotify handling: a
|
||||
// hand-edited/legacy config.json must never smuggle wrong shapes past the
|
||||
// gate. Unknown/invalid sub-fields fall back to defaults.
|
||||
const partialJf = (partial.jellyfin ?? {}) as Partial<JellyfinConfig>;
|
||||
const jellyfin: JellyfinConfig = {
|
||||
serverUrl:
|
||||
typeof partialJf.serverUrl === "string"
|
||||
? partialJf.serverUrl.trim().replace(/\/+$/, "")
|
||||
: defaults.jellyfin.serverUrl,
|
||||
authMode:
|
||||
partialJf.authMode === "apikey" ? "apikey" : defaults.jellyfin.authMode,
|
||||
username:
|
||||
typeof partialJf.username === "string" ? partialJf.username : defaults.jellyfin.username,
|
||||
password:
|
||||
typeof partialJf.password === "string" ? partialJf.password : defaults.jellyfin.password,
|
||||
apiKey: typeof partialJf.apiKey === "string" ? partialJf.apiKey : defaults.jellyfin.apiKey,
|
||||
userId: typeof partialJf.userId === "string" ? partialJf.userId : defaults.jellyfin.userId,
|
||||
};
|
||||
|
||||
// enabledProviders → known providers only; a non-array falls back to the
|
||||
// default (["jellyfin"]). An explicitly-empty array is respected (operator
|
||||
// chose to disable every gateable source).
|
||||
const enabledProviders = Array.isArray(partial.enabledProviders)
|
||||
? partial.enabledProviders.filter((p): p is GateableProvider =>
|
||||
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
|
||||
)
|
||||
: defaults.enabledProviders;
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...partial,
|
||||
adminGroups,
|
||||
guestMode: gm,
|
||||
spotify,
|
||||
jellyfin,
|
||||
enabledProviders,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
||||
coverUrl: string;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
+23
-3
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
|
||||
import { loadConfig, saveConfig, migrateLegacyConfig, isProviderEnabled } from "./data/config.js";
|
||||
import { createDatabase } from "./data/database.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { createApiServerManager } from "./music/api-server.js";
|
||||
@@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js";
|
||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||
import { LocalMusicProvider } from "./music/local.js";
|
||||
import { KugouProvider } from "./music/kugou.js";
|
||||
import { JellyfinProvider } from "./music/jellyfin.js";
|
||||
import { SpotifyProvider } from "./music/spotify/provider.js";
|
||||
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
|
||||
import { createCookieStore } from "./music/auth.js";
|
||||
@@ -52,7 +53,14 @@ async function main() {
|
||||
const db = createDatabase(DB_PATH);
|
||||
|
||||
const apiServer = createApiServerManager(
|
||||
{ neteasePort: config.neteaseApiPort, qqMusicPort: config.qqMusicApiPort },
|
||||
{
|
||||
neteasePort: config.neteaseApiPort,
|
||||
qqMusicPort: config.qqMusicApiPort,
|
||||
// Provider gating: with the default (jellyfin-only) config, neither
|
||||
// sidecar starts and ports 3001/3200 are never bound.
|
||||
neteaseEnabled: isProviderEnabled(config, "netease"),
|
||||
qqEnabled: isProviderEnabled(config, "qq"),
|
||||
},
|
||||
logger
|
||||
);
|
||||
await apiServer.start();
|
||||
@@ -82,6 +90,16 @@ async function main() {
|
||||
const kugouCookie = cookieStore.load("kugou");
|
||||
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
|
||||
|
||||
// Jellyfin: admin-configured connection (no QR). The persisted blob carries
|
||||
// AccessToken + User.Id + DeviceId and lives alongside the other platform
|
||||
// cookies; a Jellyfin server that is unreachable at boot must not block
|
||||
// startup — the provider authenticates lazily on first use.
|
||||
const jellyfinProvider = new JellyfinProvider(logger);
|
||||
jellyfinProvider.configure(config.jellyfin);
|
||||
const jellyfinAuth = cookieStore.load("jellyfin");
|
||||
if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth);
|
||||
jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized));
|
||||
|
||||
const permissions = createPermissionStore(db.db);
|
||||
|
||||
// Single process-wide Spotify authorization (one Premium account for Stage 3).
|
||||
@@ -114,7 +132,8 @@ async function main() {
|
||||
kugouProvider,
|
||||
spotifyProvider,
|
||||
SPOTIFY_DATA_DIR,
|
||||
spotifyOAuth
|
||||
spotifyOAuth,
|
||||
jellyfinProvider
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
@@ -127,6 +146,7 @@ async function main() {
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
spotifyProvider,
|
||||
jellyfinProvider,
|
||||
database: db,
|
||||
avatarStore,
|
||||
config,
|
||||
|
||||
+31
-18
@@ -5,6 +5,10 @@ import type { Server } from "node:http";
|
||||
export interface ApiServerOptions {
|
||||
neteasePort: number;
|
||||
qqMusicPort: number;
|
||||
/** Provider gating (#enabledProviders): when false, the corresponding
|
||||
* embedded sidecar API server is never started and its port never bound. */
|
||||
neteaseEnabled?: boolean;
|
||||
qqEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiServerManager {
|
||||
@@ -65,34 +69,43 @@ export function createApiServerManager(
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
// Provider gating: with the jellyfin-only default config neither legacy
|
||||
// sidecar starts, so ports 3001/3200 are never opened.
|
||||
if (options.neteaseEnabled === false && options.qqEnabled === false) {
|
||||
logger.info("NetEase/QQ providers disabled — embedded music API servers not started");
|
||||
return;
|
||||
}
|
||||
logger.info("Starting embedded music API servers...");
|
||||
|
||||
// Start NetEase Cloud Music API
|
||||
try {
|
||||
const portFree = await isPortFree(options.neteasePort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||
neteaseServer = app;
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase Cloud Music API started"
|
||||
);
|
||||
if (options.neteaseEnabled !== false) {
|
||||
try {
|
||||
const portFree = await isPortFree(options.neteasePort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||
neteaseServer = app;
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase Cloud Music API started"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
}
|
||||
|
||||
// Start QQ Music API. Older versions auto-started on import; the
|
||||
// current fork (2.2.11+) only listens when run as `require.main`,
|
||||
// so we explicitly call .listen() on the imported Koa app and keep
|
||||
// the server handle for clean shutdown.
|
||||
if (options.qqEnabled === false) return;
|
||||
try {
|
||||
const portFree = await isPortFree(options.qqMusicPort);
|
||||
if (!portFree) {
|
||||
|
||||
+8
-4
@@ -1,9 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** Platforms with a persisted credential blob. For jellyfin the "cookie" is a
|
||||
* JSON string carrying the access token / userId / deviceId (see jellyfin.ts). */
|
||||
type CookiePlatform = "netease" | "qq" | "bilibili" | "kugou" | "jellyfin";
|
||||
|
||||
export interface CookieStore {
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string;
|
||||
save(platform: CookiePlatform, cookie: string): void;
|
||||
load(platform: CookiePlatform): string;
|
||||
}
|
||||
|
||||
export function createCookieStore(cookieDir: string): CookieStore {
|
||||
@@ -12,7 +16,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
}
|
||||
|
||||
return {
|
||||
save(platform: "netease" | "qq" | "bilibili" | "kugou", cookie: string): void {
|
||||
save(platform: CookiePlatform, cookie: string): void {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
@@ -21,7 +25,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
);
|
||||
},
|
||||
|
||||
load(platform: "netease" | "qq" | "bilibili" | "kugou"): string {
|
||||
load(platform: CookiePlatform): string {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
if (!fs.existsSync(filePath)) return "";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
ticksToSeconds,
|
||||
TICKS_PER_SECOND,
|
||||
buildCoverUrl,
|
||||
buildStreamUrl,
|
||||
buildUniversalUrl,
|
||||
mapJellyfinSong,
|
||||
mapJellyfinAlbum,
|
||||
mapJellyfinPlaylist,
|
||||
mapJellyfinLyrics,
|
||||
describeJellyfinError,
|
||||
JELLYFIN_QUALITY_LEVELS,
|
||||
JellyfinProvider,
|
||||
} from "./jellyfin.js";
|
||||
import type { JellyfinConfig } from "../data/config.js";
|
||||
|
||||
function cfg(partial: Partial<JellyfinConfig> = {}): JellyfinConfig {
|
||||
return {
|
||||
serverUrl: "https://jf.example.com",
|
||||
authMode: "apikey",
|
||||
username: "",
|
||||
password: "",
|
||||
apiKey: "KEY",
|
||||
userId: "user-1",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider with a mocked axios instance; returns the request spy. */
|
||||
function mockProvider(config = cfg(), responder?: (req: any) => any) {
|
||||
const p = new JellyfinProvider();
|
||||
p.configure(config);
|
||||
const request = vi.fn(async (req: any) => ({
|
||||
data: responder ? responder(req) : { Items: [] },
|
||||
}));
|
||||
(p as any).api = { request, post: vi.fn() };
|
||||
return { p, request };
|
||||
}
|
||||
|
||||
describe("Jellyfin mapping helpers", () => {
|
||||
it("converts ticks to seconds (1 tick = 100ns)", () => {
|
||||
expect(ticksToSeconds(TICKS_PER_SECOND)).toBe(1);
|
||||
expect(ticksToSeconds(2_275_000_000)).toBeCloseTo(227.5, 3);
|
||||
expect(ticksToSeconds(undefined)).toBe(0);
|
||||
expect(ticksToSeconds(NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("maps a Jellyfin audio item to Song (GUID id stays a string)", () => {
|
||||
const song = mapJellyfinSong(
|
||||
{
|
||||
Id: "3fa85f6457174562b3fc2c963f66afa6",
|
||||
Name: "Track",
|
||||
Artists: ["A", "B"],
|
||||
AlbumArtist: "A",
|
||||
Album: "The Album",
|
||||
RunTimeTicks: 1_855_000_000,
|
||||
},
|
||||
"https://jf/x.jpg",
|
||||
);
|
||||
expect(song).toEqual({
|
||||
id: "3fa85f6457174562b3fc2c963f66afa6",
|
||||
name: "Track",
|
||||
artist: "A / B",
|
||||
album: "The Album",
|
||||
duration: 186, // 185.5s rounded
|
||||
coverUrl: "https://jf/x.jpg",
|
||||
platform: "jellyfin",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to AlbumArtist when Artists is empty", () => {
|
||||
const song = mapJellyfinSong({ Id: "1", Artists: [], AlbumArtist: "Solo" }, "");
|
||||
expect(song.artist).toBe("Solo");
|
||||
});
|
||||
|
||||
it("maps albums and playlists with ChildCount", () => {
|
||||
const album = mapJellyfinAlbum(
|
||||
{ Id: "a1", Name: "LP", AlbumArtist: "X", ChildCount: 10 },
|
||||
"c",
|
||||
);
|
||||
expect(album).toMatchObject({ id: "a1", artist: "X", songCount: 10, platform: "jellyfin" });
|
||||
const pl = mapJellyfinPlaylist({ Id: "p1", Name: "Mix", ChildCount: 7 }, "c");
|
||||
expect(pl).toMatchObject({ id: "p1", songCount: 7, platform: "jellyfin" });
|
||||
});
|
||||
|
||||
it("cover URL prefers the item's Primary image, falls back to the album's", () => {
|
||||
const own = buildCoverUrl("https://jf", "K", {
|
||||
Id: "i1",
|
||||
ImageTags: { Primary: "tag1" },
|
||||
AlbumId: "a1",
|
||||
AlbumPrimaryImageTag: "tag2",
|
||||
});
|
||||
expect(own).toBe("https://jf/Items/i1/Images/Primary?maxWidth=512&tag=tag1&api_key=K");
|
||||
const album = buildCoverUrl("https://jf", "K", {
|
||||
Id: "i1",
|
||||
AlbumId: "a1",
|
||||
AlbumPrimaryImageTag: "tag2",
|
||||
});
|
||||
expect(album).toBe("https://jf/Items/a1/Images/Primary?maxWidth=512&tag=tag2&api_key=K");
|
||||
expect(buildCoverUrl("https://jf", "K", { Id: "i1" })).toBe("");
|
||||
});
|
||||
|
||||
it("builds direct and transcoded stream URLs", () => {
|
||||
expect(buildStreamUrl("https://jf", "K", "i1")).toBe(
|
||||
"https://jf/Audio/i1/stream?static=true&api_key=K",
|
||||
);
|
||||
const url = buildUniversalUrl("https://jf", {
|
||||
apiKey: "K",
|
||||
userId: "u",
|
||||
deviceId: "d",
|
||||
itemId: "i1",
|
||||
kbps: 320,
|
||||
});
|
||||
expect(url).toContain("https://jf/Audio/i1/universal?");
|
||||
expect(url).toContain("maxStreamingBitrate=320000");
|
||||
expect(url).toContain("transcodingContainer=mp3");
|
||||
expect(url).toContain("transcodingProtocol=http");
|
||||
// The container list must be URL-encoded ("|" → %7C)
|
||||
expect(url).toContain("container=mp3%2Caac%2Cm4a%7Caac%2Cflac%2Cwebma%2Cwebm%2Cwav%2Cogg");
|
||||
});
|
||||
|
||||
it("maps lyrics with tick offsets; entries without Start collapse to 0; empty text dropped", () => {
|
||||
const lines = mapJellyfinLyrics({
|
||||
Lyrics: [
|
||||
{ Text: "line two", Start: 125_000_000 },
|
||||
{ Text: "line one", Start: 5_000_000 },
|
||||
{ Text: " ", Start: 1 },
|
||||
{ Text: "untimed" },
|
||||
],
|
||||
});
|
||||
expect(lines).toEqual([
|
||||
{ time: 0, text: "untimed" },
|
||||
{ time: 0.5, text: "line one" },
|
||||
{ time: 12.5, text: "line two" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("mapJellyfinLyrics tolerates junk payloads", () => {
|
||||
expect(mapJellyfinLyrics(null)).toEqual([]);
|
||||
expect(mapJellyfinLyrics({})).toEqual([]);
|
||||
expect(mapJellyfinLyrics({ Lyrics: "nope" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("describes common connection errors in a friendly way", () => {
|
||||
expect(describeJellyfinError({ response: { status: 401 } })).toContain("401");
|
||||
expect(describeJellyfinError({ code: "ECONNREFUSED" })).toContain("无法连接");
|
||||
expect(describeJellyfinError(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JellyfinProvider", () => {
|
||||
it("quality: defaults to direct, ignores foreign (NetEase) values", () => {
|
||||
const p = new JellyfinProvider();
|
||||
expect(p.getQuality()).toBe("direct");
|
||||
p.setQuality("exhigh"); // NetEase value from the legacy platform-less broadcast
|
||||
expect(p.getQuality()).toBe("direct");
|
||||
p.setQuality("320");
|
||||
expect(p.getQuality()).toBe("320");
|
||||
expect(JELLYFIN_QUALITY_LEVELS[0].value).toBe("direct");
|
||||
});
|
||||
|
||||
it("getSongUrl: direct tier → static stream; 320 tier → universal transcode", async () => {
|
||||
const { p } = mockProvider();
|
||||
p.setCookie(JSON.stringify({ deviceId: "dev-1" }));
|
||||
const direct = await p.getSongUrl("item1");
|
||||
expect(direct?.url).toBe("https://jf.example.com/Audio/item1/stream?static=true&api_key=KEY");
|
||||
|
||||
p.setQuality("320");
|
||||
const transcoded = await p.getSongUrl("item1");
|
||||
expect(transcoded?.url).toContain("/Audio/item1/universal?");
|
||||
expect(transcoded?.url).toContain("maxStreamingBitrate=320000");
|
||||
expect(transcoded?.url).toContain("api_key=KEY");
|
||||
});
|
||||
|
||||
it("search hits /Items for Audio, MusicAlbum and Playlist with paging", async () => {
|
||||
const { p, request } = mockProvider();
|
||||
await p.search("mozart", 30, 60);
|
||||
const types = request.mock.calls.map((c) => c[0].params?.IncludeItemTypes);
|
||||
expect(types).toContain("Audio");
|
||||
expect(types).toContain("MusicAlbum");
|
||||
expect(types).toContain("Playlist");
|
||||
for (const call of request.mock.calls) {
|
||||
expect(call[0].params.Limit).toBe(30);
|
||||
expect(call[0].params.StartIndex).toBe(60);
|
||||
expect(call[0].params.userId).toBe("user-1");
|
||||
expect(call[0].headers["X-Emby-Token"]).toBe("KEY");
|
||||
}
|
||||
});
|
||||
|
||||
it("userpass: authenticates once via AuthenticateByName, persists token, retries once on 401", async () => {
|
||||
const p = new JellyfinProvider();
|
||||
p.configure(cfg({ authMode: "userpass", username: "eric", password: "pw", apiKey: "", userId: "" }));
|
||||
const persisted: string[] = [];
|
||||
p.setPersist((s) => persisted.push(s));
|
||||
|
||||
let tokenCounter = 0;
|
||||
const post = vi.fn(async (..._args: any[]) => ({
|
||||
data: { AccessToken: `tok-${++tokenCounter}`, User: { Id: "u9" } },
|
||||
}));
|
||||
// First data request 401s (expired token), the retry succeeds.
|
||||
let dataCalls = 0;
|
||||
const request = vi.fn(async (req: any) => {
|
||||
dataCalls++;
|
||||
if (dataCalls === 1) {
|
||||
const err: any = new Error("Unauthorized");
|
||||
err.response = { status: 401 };
|
||||
throw err;
|
||||
}
|
||||
return { data: { Items: [{ Id: "s1", Name: "N" }] }, config: req };
|
||||
});
|
||||
(p as any).api = { request, post };
|
||||
|
||||
const songs = await p.getPlaylistSongs("pl1");
|
||||
expect(songs).toHaveLength(1);
|
||||
// login ran twice: initial auth + re-auth after the 401
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
const authHeader = post.mock.calls[0][2].headers.Authorization as string;
|
||||
expect(authHeader).toContain('MediaBrowser Client="TSMusicBot"');
|
||||
expect(authHeader).toContain("DeviceId=");
|
||||
expect(post.mock.calls[0][1]).toEqual({ Username: "eric", Pw: "pw" });
|
||||
// token + userId + deviceId persisted like cookies
|
||||
const last = JSON.parse(persisted[persisted.length - 1]);
|
||||
expect(last.accessToken).toBe("tok-2");
|
||||
expect(last.userId).toBe("u9");
|
||||
expect(last.deviceId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("userpass: surfaces the error when the retry also 401s (single retry only)", async () => {
|
||||
const p = new JellyfinProvider();
|
||||
p.configure(cfg({ authMode: "userpass", username: "eric", password: "bad", apiKey: "" }));
|
||||
const post = vi.fn(async () => ({ data: { AccessToken: "t", User: { Id: "u" } } }));
|
||||
const err: any = new Error("Unauthorized");
|
||||
err.response = { status: 401 };
|
||||
const request = vi.fn(async () => {
|
||||
throw err;
|
||||
});
|
||||
(p as any).api = { request, post };
|
||||
await expect(p.getPlaylistSongs("pl1")).rejects.toThrow("Unauthorized");
|
||||
expect(request).toHaveBeenCalledTimes(2); // original + exactly one retry
|
||||
});
|
||||
|
||||
it("setCookie/getCookie round-trip restores token, userId and deviceId", () => {
|
||||
const p = new JellyfinProvider();
|
||||
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
|
||||
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
|
||||
p.setCookie("not json"); // junk must not corrupt state
|
||||
expect(JSON.parse(p.getCookie())).toEqual({ accessToken: "T", userId: "U", deviceId: "D" });
|
||||
});
|
||||
|
||||
it("getLyrics: 404 means no lyrics (empty), other errors surface", async () => {
|
||||
const notFound: any = new Error("nf");
|
||||
notFound.response = { status: 404 };
|
||||
const { p } = mockProvider(cfg(), () => {
|
||||
throw notFound;
|
||||
});
|
||||
expect(await p.getLyrics("i1")).toEqual([]);
|
||||
|
||||
const boom: any = new Error("server down");
|
||||
boom.response = { status: 500 };
|
||||
const { p: p2 } = mockProvider(cfg(), () => {
|
||||
throw boom;
|
||||
});
|
||||
await expect(p2.getLyrics("i1")).rejects.toThrow("server down");
|
||||
});
|
||||
|
||||
it("getAuthStatus reflects /System/Info reachability; unconfigured = logged out", async () => {
|
||||
const blank = new JellyfinProvider();
|
||||
expect(await blank.getAuthStatus()).toEqual({ loggedIn: false });
|
||||
|
||||
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS" }));
|
||||
expect(await p.getAuthStatus()).toEqual({ loggedIn: true, nickname: "API Key@NAS" });
|
||||
|
||||
const { p: down } = mockProvider(cfg(), () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
});
|
||||
expect(await down.getAuthStatus()).toEqual({ loggedIn: false });
|
||||
});
|
||||
|
||||
it("getPersonalFm seeds InstantMix from a random favorite", async () => {
|
||||
const { p, request } = mockProvider(cfg(), (req: any) => {
|
||||
if (req.url.includes("/InstantMix")) {
|
||||
return { Items: [{ Id: "m1", Name: "Mixed" }] };
|
||||
}
|
||||
if (req.params?.Filters === "IsFavorite") {
|
||||
return { Items: [{ Id: "fav1", Name: "Fav" }] };
|
||||
}
|
||||
return { Items: [] };
|
||||
});
|
||||
const songs = await p.getPersonalFm();
|
||||
expect(songs.map((s) => s.id)).toEqual(["m1"]);
|
||||
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
|
||||
expect(mixCall![0].url).toContain("/Items/fav1/InstantMix");
|
||||
});
|
||||
|
||||
it("getPersonalFm falls back: favorites → recently played → random", async () => {
|
||||
const { p, request } = mockProvider(cfg(), (req: any) => {
|
||||
if (req.url.includes("/InstantMix")) return { Items: [{ Id: "m2" }] };
|
||||
if (req.params?.Filters === "IsFavorite") return { Items: [] };
|
||||
if (req.params?.Filters === "IsPlayed") return { Items: [{ Id: "recent1" }] };
|
||||
return { Items: [] };
|
||||
});
|
||||
const songs = await p.getPersonalFm();
|
||||
expect(songs.map((s) => s.id)).toEqual(["m2"]);
|
||||
const mixCall = request.mock.calls.find((c) => c[0].url.includes("/InstantMix"));
|
||||
expect(mixCall![0].url).toContain("/Items/recent1/InstantMix");
|
||||
});
|
||||
|
||||
it("playback reporter posts start/progress/stopped with PositionTicks", async () => {
|
||||
const bodies: { url: string; data: any }[] = [];
|
||||
const { p } = mockProvider(cfg(), () => ({}));
|
||||
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (req: any) => {
|
||||
bodies.push({ url: req.url, data: req.data });
|
||||
return { data: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const reporter = p.createPlaybackReporter();
|
||||
reporter.onTrackStart("song1");
|
||||
reporter.onTick("song1", 90.5, false);
|
||||
reporter.onStop();
|
||||
await vi.waitFor(() => expect(bodies).toHaveLength(3));
|
||||
|
||||
expect(bodies[0].url).toContain("/Sessions/Playing");
|
||||
expect(bodies[0].data.ItemId).toBe("song1");
|
||||
const sessionId = bodies[0].data.PlaySessionId;
|
||||
expect(sessionId).toBeTruthy();
|
||||
|
||||
expect(bodies[1].url).toContain("/Sessions/Playing/Progress");
|
||||
expect(bodies[1].data.PositionTicks).toBe(905_000_000);
|
||||
expect(bodies[1].data.PlaySessionId).toBe(sessionId);
|
||||
|
||||
// Stop carries the LAST reported position so a natural track end counts as played.
|
||||
expect(bodies[2].url).toContain("/Sessions/Playing/Stopped");
|
||||
expect(bodies[2].data.PositionTicks).toBe(905_000_000);
|
||||
});
|
||||
|
||||
it("reporter: a new track start closes the previous session; failures are swallowed", async () => {
|
||||
const bodies: { url: string; data: any }[] = [];
|
||||
const { p } = mockProvider(cfg(), () => ({}));
|
||||
((p as any).api.request as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (req: any) => {
|
||||
bodies.push({ url: req.url, data: req.data });
|
||||
if (req.url.includes("/Sessions")) throw new Error("reporting endpoint down");
|
||||
return { data: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const reporter = p.createPlaybackReporter();
|
||||
reporter.onTrackStart("a");
|
||||
reporter.onTick("a", 200, false);
|
||||
reporter.onTrackStart("b"); // must post Stopped for "a" at pos 200, then Playing for "b"
|
||||
reporter.onStop();
|
||||
await vi.waitFor(() => expect(bodies.length).toBeGreaterThanOrEqual(5));
|
||||
|
||||
const stopped = bodies.filter((b) => b.url.includes("/Stopped"));
|
||||
expect(stopped[0].data.ItemId).toBe("a");
|
||||
expect(stopped[0].data.PositionTicks).toBe(2_000_000_000);
|
||||
expect(stopped[1].data.ItemId).toBe("b");
|
||||
// ticks for stale items are ignored
|
||||
reporter.onTick("a", 999, false);
|
||||
expect(bodies.filter((b) => b.url.includes("/Progress"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("testConnection reports server info on success and friendly errors on failure", async () => {
|
||||
const { p } = mockProvider(cfg(), () => ({ ServerName: "NAS", Version: "10.9.11" }));
|
||||
expect(await p.testConnection()).toEqual({ ok: true, serverName: "NAS", version: "10.9.11" });
|
||||
|
||||
const bad = new JellyfinProvider();
|
||||
bad.configure(cfg({ apiKey: "", userId: "" }));
|
||||
const res = await bad.testConnection();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toContain("apiKey");
|
||||
});
|
||||
|
||||
it("configure drops the cached token when credentials change, keeps deviceId", () => {
|
||||
const p = new JellyfinProvider();
|
||||
p.configure(cfg());
|
||||
p.setCookie(JSON.stringify({ accessToken: "T", userId: "U", deviceId: "D" }));
|
||||
p.configure(cfg({ serverUrl: "https://other.example.com" }));
|
||||
const state = JSON.parse(p.getCookie());
|
||||
expect(state.accessToken).toBe("");
|
||||
expect(state.deviceId).toBe("D");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,741 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import crypto from "node:crypto";
|
||||
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||
import type { JellyfinConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
Playlist,
|
||||
PlaylistDetail,
|
||||
LyricLine,
|
||||
SearchResult,
|
||||
QrCodeResult,
|
||||
AuthStatus,
|
||||
Album,
|
||||
} from "./provider.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** 1 tick = 100 ns → 10,000,000 ticks = 1 second (Jellyfin RunTimeTicks / lyric Start). */
|
||||
export const TICKS_PER_SECOND = 10_000_000;
|
||||
|
||||
export function ticksToSeconds(ticks: number | null | undefined): number {
|
||||
if (typeof ticks !== "number" || !Number.isFinite(ticks)) return 0;
|
||||
return ticks / TICKS_PER_SECOND;
|
||||
}
|
||||
|
||||
const CLIENT_NAME = "TSMusicBot";
|
||||
|
||||
/** Best-effort package version for the MediaBrowser auth header. */
|
||||
const PKG_VERSION = (() => {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"),
|
||||
) as { version?: string };
|
||||
return pkg.version ?? "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
})();
|
||||
|
||||
// Jellyfin quality tiers replace the NetEase labels for this source. "direct"
|
||||
// (default) streams the original file — the bot re-encodes to Opus anyway, so
|
||||
// this is max quality. The transcode tiers exist for remote/low-bandwidth
|
||||
// Jellyfin servers.
|
||||
export const JELLYFIN_QUALITY_LEVELS = [
|
||||
{ value: "direct", label: "原始直传 Direct", bitrate: 0 },
|
||||
{ value: "320", label: "320kbps 转码", bitrate: 320 },
|
||||
{ value: "192", label: "192kbps 转码", bitrate: 192 },
|
||||
{ value: "128", label: "128kbps 转码", bitrate: 128 },
|
||||
] as const;
|
||||
|
||||
/** Subset of Jellyfin's BaseItemDto that this provider consumes. */
|
||||
export interface JellyfinItem {
|
||||
Id: string;
|
||||
Name?: string;
|
||||
Artists?: string[];
|
||||
AlbumArtist?: string;
|
||||
Album?: string;
|
||||
AlbumId?: string;
|
||||
AlbumPrimaryImageTag?: string;
|
||||
RunTimeTicks?: number;
|
||||
ImageTags?: { Primary?: string };
|
||||
ChildCount?: number;
|
||||
SongCount?: number;
|
||||
Overview?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary cover art URL for an item; falls back to the parent album's primary
|
||||
* image (AlbumPrimaryImageTag) and finally to "" (the UI renders a placeholder
|
||||
* for missing art). The api_key query param authenticates browser <img> loads
|
||||
* and the TS-avatar download alike.
|
||||
*/
|
||||
export function buildCoverUrl(baseUrl: string, apiKey: string, item: JellyfinItem): string {
|
||||
if (!baseUrl) return "";
|
||||
const key = apiKey ? `&api_key=${encodeURIComponent(apiKey)}` : "";
|
||||
if (item.ImageTags?.Primary) {
|
||||
return `${baseUrl}/Items/${item.Id}/Images/Primary?maxWidth=512&tag=${item.ImageTags.Primary}${key}`;
|
||||
}
|
||||
if (item.AlbumId && item.AlbumPrimaryImageTag) {
|
||||
return `${baseUrl}/Items/${item.AlbumId}/Images/Primary?maxWidth=512&tag=${item.AlbumPrimaryImageTag}${key}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Direct (untranscoded) stream URL — the default playback path. */
|
||||
export function buildStreamUrl(baseUrl: string, apiKey: string, itemId: string): string {
|
||||
const params = new URLSearchParams({ static: "true", api_key: apiKey });
|
||||
return `${baseUrl}/Audio/${itemId}/stream?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Server-side transcode URL for the 320/192/128 kbps tiers. */
|
||||
export function buildUniversalUrl(
|
||||
baseUrl: string,
|
||||
o: { apiKey: string; userId: string; deviceId: string; itemId: string; kbps: number },
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
userId: o.userId,
|
||||
deviceId: o.deviceId,
|
||||
api_key: o.apiKey,
|
||||
maxStreamingBitrate: String(o.kbps * 1000),
|
||||
container: "mp3,aac,m4a|aac,flac,webma,webm,wav,ogg",
|
||||
transcodingContainer: "mp3",
|
||||
transcodingProtocol: "http",
|
||||
});
|
||||
return `${baseUrl}/Audio/${o.itemId}/universal?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function mapJellyfinSong(item: JellyfinItem, coverUrl: string): Song {
|
||||
return {
|
||||
id: String(item.Id),
|
||||
name: item.Name ?? "",
|
||||
artist: (item.Artists?.length ? item.Artists : [item.AlbumArtist ?? ""])
|
||||
.filter(Boolean)
|
||||
.join(" / "),
|
||||
album: item.Album ?? "",
|
||||
duration: Math.round(ticksToSeconds(item.RunTimeTicks)),
|
||||
coverUrl,
|
||||
platform: "jellyfin",
|
||||
};
|
||||
}
|
||||
|
||||
export function mapJellyfinAlbum(item: JellyfinItem, coverUrl: string): Album {
|
||||
return {
|
||||
id: String(item.Id),
|
||||
name: item.Name ?? "",
|
||||
artist: item.AlbumArtist ?? (item.Artists ?? []).join(" / "),
|
||||
coverUrl,
|
||||
songCount: item.ChildCount ?? item.SongCount ?? 0,
|
||||
platform: "jellyfin",
|
||||
};
|
||||
}
|
||||
|
||||
export function mapJellyfinPlaylist(item: JellyfinItem, coverUrl: string): Playlist {
|
||||
return {
|
||||
id: String(item.Id),
|
||||
name: item.Name ?? "",
|
||||
coverUrl,
|
||||
songCount: item.ChildCount ?? item.SongCount ?? 0,
|
||||
platform: "jellyfin",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `GET /Audio/{id}/Lyrics` → the bot's synced-lyric format. `Start` is in
|
||||
* ticks; entries without Start (plain-text lyrics) collapse to time 0 so the
|
||||
* lyric view still renders them, just without sync. Translation stays empty —
|
||||
* Jellyfin has no translated-lyrics concept.
|
||||
*/
|
||||
export function mapJellyfinLyrics(payload: unknown): LyricLine[] {
|
||||
const lyrics = (payload as { Lyrics?: { Text?: string; Start?: number }[] })?.Lyrics;
|
||||
if (!Array.isArray(lyrics)) return [];
|
||||
return lyrics
|
||||
.filter((l) => typeof l?.Text === "string" && l.Text.trim() !== "")
|
||||
.map((l) => ({ time: ticksToSeconds(l.Start), text: l.Text!.trim() }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/** Operator-friendly connection/auth error description (test-connection UI + logs). */
|
||||
export function describeJellyfinError(err: unknown): string {
|
||||
const e = err as AxiosError;
|
||||
if (e?.response) {
|
||||
const s = e.response.status;
|
||||
if (s === 401) return "认证失败:用户名/密码或 API Key 不正确 (401 Unauthorized)";
|
||||
if (s === 403) return "该账号没有访问权限 (403 Forbidden)";
|
||||
if (s === 404) return "接口不存在——请确认地址指向 Jellyfin 根路径 (404 Not Found)";
|
||||
return `Jellyfin 返回 HTTP ${s}`;
|
||||
}
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code === "ECONNREFUSED") return "无法连接 Jellyfin 服务器 (connection refused)";
|
||||
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "无法解析服务器地址 (DNS lookup failed)";
|
||||
if (code === "ETIMEDOUT" || code === "ECONNABORTED") return "连接 Jellyfin 超时 (timeout)";
|
||||
return (err as Error)?.message ?? String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bot playback reporting handle. One playback session (PlaySessionId) per
|
||||
* track start; the previous session is closed with its last known position so
|
||||
* Jellyfin's played/PlayCount bookkeeping sees natural track ends as ~complete
|
||||
* plays and mid-track skips as partial ones. Every call is fire-and-forget —
|
||||
* reporting must never affect playback.
|
||||
*/
|
||||
export interface JellyfinPlaybackReporter {
|
||||
onTrackStart(itemId: string): void;
|
||||
onTick(itemId: string, positionSec: number, paused: boolean): void;
|
||||
onStop(): void;
|
||||
}
|
||||
|
||||
interface PersistedAuth {
|
||||
accessToken?: string;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
function emptyConfig(): JellyfinConfig {
|
||||
return { serverUrl: "", authMode: "userpass", username: "", password: "", apiKey: "", userId: "" };
|
||||
}
|
||||
|
||||
export class JellyfinProvider implements MusicProvider {
|
||||
readonly platform = "jellyfin" as const;
|
||||
private api: AxiosInstance;
|
||||
private cfg: JellyfinConfig = emptyConfig();
|
||||
private token = "";
|
||||
private userId = "";
|
||||
private deviceId = "";
|
||||
private quality: string = "direct";
|
||||
private loginPromise: Promise<void> | null = null;
|
||||
private persistFn: ((serialized: string) => void) | null = null;
|
||||
private logger: Logger | null = null;
|
||||
|
||||
constructor(logger?: Logger) {
|
||||
this.api = axios.create({ timeout: 10000 });
|
||||
this.logger = logger ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply (or re-apply, on settings save) the admin-configured connection.
|
||||
* Changing any credential-relevant field drops the cached token so the next
|
||||
* request re-authenticates; the stable deviceId survives reconfiguration.
|
||||
*/
|
||||
configure(cfg: JellyfinConfig): void {
|
||||
const prev = this.cfg;
|
||||
this.cfg = { ...cfg, serverUrl: (cfg.serverUrl ?? "").trim().replace(/\/+$/, "") };
|
||||
const credsChanged =
|
||||
prev.serverUrl !== this.cfg.serverUrl ||
|
||||
prev.authMode !== this.cfg.authMode ||
|
||||
prev.username !== this.cfg.username ||
|
||||
prev.password !== this.cfg.password ||
|
||||
prev.apiKey !== this.cfg.apiKey ||
|
||||
prev.userId !== this.cfg.userId;
|
||||
if (credsChanged) {
|
||||
this.token = "";
|
||||
this.userId = "";
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.cfg.serverUrl.length > 0;
|
||||
}
|
||||
|
||||
/** Wire the on-disk persistence used after a successful login (cookie store). */
|
||||
setPersist(fn: (serialized: string) => void): void {
|
||||
this.persistFn = fn;
|
||||
}
|
||||
|
||||
setQuality(quality: string): void {
|
||||
// Ignore foreign values: the legacy platform-less POST /api/music/quality
|
||||
// broadcasts NetEase levels to every provider, which must not clobber the
|
||||
// jellyfin default ("direct").
|
||||
if (JELLYFIN_QUALITY_LEVELS.some((l) => l.value === quality)) {
|
||||
this.quality = quality;
|
||||
}
|
||||
}
|
||||
|
||||
getQuality(): string {
|
||||
return this.quality;
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
private authHeader(): string {
|
||||
return (
|
||||
`MediaBrowser Client="${CLIENT_NAME}", Device="${CLIENT_NAME}", ` +
|
||||
`DeviceId="${this.deviceId}", Version="${PKG_VERSION}"`
|
||||
);
|
||||
}
|
||||
|
||||
private ensureDeviceId(): void {
|
||||
if (!this.deviceId) {
|
||||
this.deviceId = crypto.randomUUID();
|
||||
this.persistState();
|
||||
}
|
||||
}
|
||||
|
||||
private persistState(): void {
|
||||
try {
|
||||
this.persistFn?.(this.getCookie());
|
||||
} catch (err) {
|
||||
this.logger?.warn({ err }, "Failed to persist Jellyfin auth state");
|
||||
}
|
||||
}
|
||||
|
||||
private async login(): Promise<void> {
|
||||
this.ensureDeviceId();
|
||||
const res = await this.api.post(
|
||||
`${this.cfg.serverUrl}/Users/AuthenticateByName`,
|
||||
{ Username: this.cfg.username, Pw: this.cfg.password },
|
||||
{ headers: { Authorization: this.authHeader() } },
|
||||
);
|
||||
const token = res.data?.AccessToken as string | undefined;
|
||||
const userId = res.data?.User?.Id as string | undefined;
|
||||
if (!token || !userId) {
|
||||
throw new Error("Jellyfin 登录响应缺少 AccessToken/User.Id");
|
||||
}
|
||||
this.token = token;
|
||||
this.userId = userId;
|
||||
this.persistState();
|
||||
this.logger?.info({ userId }, "Jellyfin authenticated");
|
||||
}
|
||||
|
||||
private async ensureAuth(): Promise<void> {
|
||||
if (!this.cfg.serverUrl) {
|
||||
throw new Error("Jellyfin 未配置服务器地址 (server URL not configured)");
|
||||
}
|
||||
if (this.cfg.authMode === "apikey") {
|
||||
if (!this.cfg.apiKey || !this.cfg.userId) {
|
||||
throw new Error("Jellyfin API Key 模式需要 apiKey 和 userId");
|
||||
}
|
||||
this.token = this.cfg.apiKey;
|
||||
this.userId = this.cfg.userId;
|
||||
this.ensureDeviceId();
|
||||
return;
|
||||
}
|
||||
if (this.token && this.userId) return;
|
||||
if (!this.cfg.username) {
|
||||
throw new Error("Jellyfin 未配置用户名/密码 (username not configured)");
|
||||
}
|
||||
if (!this.loginPromise) {
|
||||
this.loginPromise = this.login().finally(() => {
|
||||
this.loginPromise = null;
|
||||
});
|
||||
}
|
||||
return this.loginPromise;
|
||||
}
|
||||
|
||||
/** Authenticated request with a single re-auth retry on 401 (userpass mode). */
|
||||
private async request<T = unknown>(
|
||||
method: "get" | "post",
|
||||
path: string,
|
||||
opts: { params?: Record<string, unknown>; data?: unknown; retried?: boolean } = {},
|
||||
): Promise<T> {
|
||||
await this.ensureAuth();
|
||||
// Callers snapshot `userId: this.userId` while building params, which is
|
||||
// still empty before the first login. ensureAuth() has populated it by
|
||||
// now, so backfill the stale-empty snapshot instead of sending userId="".
|
||||
const params = opts.params ? { ...opts.params } : undefined;
|
||||
if (params && "userId" in params && !params.userId) params.userId = this.userId;
|
||||
try {
|
||||
const res = await this.api.request<T>({
|
||||
method,
|
||||
url: this.cfg.serverUrl + path,
|
||||
params,
|
||||
data: opts.data,
|
||||
headers: { "X-Emby-Token": this.token },
|
||||
});
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const status = (err as AxiosError).response?.status;
|
||||
if (status === 401 && this.cfg.authMode === "userpass" && !opts.retried) {
|
||||
this.token = "";
|
||||
this.userId = "";
|
||||
return this.request(method, path, { ...opts, retried: true });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MusicProvider ---
|
||||
|
||||
private coverFor(item: JellyfinItem): string {
|
||||
return buildCoverUrl(this.cfg.serverUrl, this.token, item);
|
||||
}
|
||||
|
||||
private mapSongs(items: JellyfinItem[] | undefined | null): Song[] {
|
||||
return (items ?? []).map((i) => mapJellyfinSong(i, this.coverFor(i)));
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||
const common = {
|
||||
searchTerm: query,
|
||||
Recursive: true,
|
||||
Limit: limit,
|
||||
StartIndex: offset,
|
||||
userId: this.userId,
|
||||
};
|
||||
const [songRes, albumRes, playlistRes] = await Promise.all([
|
||||
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: { ...common, IncludeItemTypes: "Audio" },
|
||||
}),
|
||||
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: { ...common, IncludeItemTypes: "MusicAlbum", Fields: "ChildCount" },
|
||||
}),
|
||||
this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: { ...common, IncludeItemTypes: "Playlist", Fields: "ChildCount" },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
songs: this.mapSongs(songRes?.Items),
|
||||
albums: (albumRes?.Items ?? []).map((i) => mapJellyfinAlbum(i, this.coverFor(i))),
|
||||
playlists: (playlistRes?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i))),
|
||||
};
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null> {
|
||||
await this.ensureAuth();
|
||||
const level = quality ?? this.quality;
|
||||
const tier = JELLYFIN_QUALITY_LEVELS.find((l) => l.value === level);
|
||||
if (!tier || tier.value === "direct") {
|
||||
return { url: buildStreamUrl(this.cfg.serverUrl, this.token, songId) };
|
||||
}
|
||||
return {
|
||||
url: buildUniversalUrl(this.cfg.serverUrl, {
|
||||
apiKey: this.token,
|
||||
userId: this.userId,
|
||||
deviceId: this.deviceId,
|
||||
itemId: songId,
|
||||
kbps: tier.bitrate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: { ids: songId, userId: this.userId },
|
||||
});
|
||||
const item = res?.Items?.[0];
|
||||
return item ? mapJellyfinSong(item, this.coverFor(item)) : null;
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>(
|
||||
"get",
|
||||
`/Playlists/${playlistId}/Items`,
|
||||
{ params: { userId: this.userId } },
|
||||
);
|
||||
return this.mapSongs(res?.Items);
|
||||
}
|
||||
|
||||
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
parentId: albumId,
|
||||
sortBy: "ParentIndexNumber,IndexNumber",
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return this.mapSongs(res?.Items);
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
return this.getUserPlaylists();
|
||||
}
|
||||
|
||||
async getUserPlaylists(): Promise<Playlist[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
IncludeItemTypes: "Playlist",
|
||||
Recursive: true,
|
||||
Fields: "ChildCount",
|
||||
SortBy: "SortName",
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return (res?.Items ?? []).map((i) => mapJellyfinPlaylist(i, this.coverFor(i)));
|
||||
}
|
||||
|
||||
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: { ids: playlistId, Fields: "ChildCount,Overview", userId: this.userId },
|
||||
});
|
||||
const item = res?.Items?.[0];
|
||||
if (!item) return null;
|
||||
return {
|
||||
id: String(item.Id),
|
||||
name: item.Name ?? "",
|
||||
description: item.Overview ?? "",
|
||||
coverUrl: this.coverFor(item),
|
||||
songCount: item.ChildCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||
try {
|
||||
const res = await this.request<unknown>("get", `/Audio/${songId}/Lyrics`);
|
||||
return mapJellyfinLyrics(res);
|
||||
} catch (err) {
|
||||
// 404 = the track simply has no lyrics — not an error.
|
||||
if ((err as AxiosError).response?.status === 404) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// No QR-code login concept — connection is global and admin-configured.
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
return { qrUrl: "", key: "" };
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
setCookie(cookie: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(cookie) as PersistedAuth;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (typeof parsed.accessToken === "string") this.token = parsed.accessToken;
|
||||
if (typeof parsed.userId === "string") this.userId = parsed.userId;
|
||||
if (typeof parsed.deviceId === "string") this.deviceId = parsed.deviceId;
|
||||
}
|
||||
} catch {
|
||||
// Not the JSON blob we persist — ignore rather than corrupt auth state.
|
||||
}
|
||||
}
|
||||
|
||||
getCookie(): string {
|
||||
return JSON.stringify({
|
||||
accessToken: this.token,
|
||||
userId: this.userId,
|
||||
deviceId: this.deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
if (!this.cfg.serverUrl) return { loggedIn: false };
|
||||
try {
|
||||
const info = await this.request<{ ServerName?: string }>("get", "/System/Info");
|
||||
const who = this.cfg.authMode === "apikey" ? "API Key" : this.cfg.username;
|
||||
return { loggedIn: true, nickname: `${who}@${info?.ServerName ?? "Jellyfin"}` };
|
||||
} catch (err) {
|
||||
this.logger?.debug({ err: describeJellyfinError(err) }, "Jellyfin auth status check failed");
|
||||
return { loggedIn: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated round-trip to /System/Info for the Settings "test connection"
|
||||
* button. `candidate` (when given) tests form values in a throwaway instance
|
||||
* so a failed test never disturbs the live token state.
|
||||
*/
|
||||
async testConnection(
|
||||
candidate?: JellyfinConfig,
|
||||
): Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }> {
|
||||
const probe = candidate ? new JellyfinProvider() : this;
|
||||
if (candidate) probe.configure(candidate);
|
||||
try {
|
||||
const info = await probe.request<{ ServerName?: string; Version?: string }>(
|
||||
"get",
|
||||
"/System/Info",
|
||||
);
|
||||
return { ok: true, serverName: info?.ServerName, version: info?.Version };
|
||||
} catch (err) {
|
||||
return { ok: false, error: describeJellyfinError(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal FM = Instant Mix seeded from a random favorite, falling back to a
|
||||
* random recently-played track, then a random library track. A final safety
|
||||
* net returns 50 random tracks when Instant Mix itself yields nothing.
|
||||
*/
|
||||
async getPersonalFm(): Promise<Song[]> {
|
||||
const seed = await this.pickFmSeed();
|
||||
if (seed) {
|
||||
const mix = await this.request<{ Items?: JellyfinItem[] }>(
|
||||
"get",
|
||||
`/Items/${seed}/InstantMix`,
|
||||
{ params: { userId: this.userId, limit: 50 } },
|
||||
);
|
||||
const songs = this.mapSongs(mix?.Items);
|
||||
if (songs.length > 0) return songs;
|
||||
}
|
||||
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
SortBy: "Random",
|
||||
Limit: 50,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return this.mapSongs(random?.Items);
|
||||
}
|
||||
|
||||
private async pickFmSeed(): Promise<string | null> {
|
||||
const favorite = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
Filters: "IsFavorite",
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
SortBy: "Random",
|
||||
Limit: 1,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
if (favorite?.Items?.[0]?.Id) return favorite.Items[0].Id;
|
||||
|
||||
const played = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
Filters: "IsPlayed",
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
SortBy: "DatePlayed",
|
||||
SortOrder: "Descending",
|
||||
Limit: 20,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
const playedItems = played?.Items ?? [];
|
||||
if (playedItems.length > 0) {
|
||||
return playedItems[Math.floor(Math.random() * playedItems.length)].Id;
|
||||
}
|
||||
|
||||
const random = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
SortBy: "Random",
|
||||
Limit: 1,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return random?.Items?.[0]?.Id ?? null;
|
||||
}
|
||||
|
||||
// --- Home sections ---
|
||||
|
||||
async getLatestAlbums(limit = 12): Promise<Album[]> {
|
||||
await this.ensureAuth();
|
||||
const items = await this.request<JellyfinItem[]>(
|
||||
"get",
|
||||
`/Users/${this.userId}/Items/Latest`,
|
||||
{ params: { IncludeItemTypes: "MusicAlbum", Limit: limit, Fields: "ChildCount" } },
|
||||
);
|
||||
return (Array.isArray(items) ? items : []).map((i) =>
|
||||
mapJellyfinAlbum(i, this.coverFor(i)),
|
||||
);
|
||||
}
|
||||
|
||||
async getMostPlayed(limit = 12): Promise<Song[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
SortBy: "PlayCount",
|
||||
SortOrder: "Descending",
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
Filters: "IsPlayed",
|
||||
Limit: limit,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return this.mapSongs(res?.Items);
|
||||
}
|
||||
|
||||
async getFavoriteSongs(limit = 100): Promise<Song[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
Filters: "IsFavorite",
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
SortBy: "SortName",
|
||||
Limit: limit,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return this.mapSongs(res?.Items);
|
||||
}
|
||||
|
||||
async getGenres(limit = 30): Promise<{ id: string; name: string }[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/MusicGenres", {
|
||||
params: { userId: this.userId, Limit: limit, SortBy: "SortName" },
|
||||
});
|
||||
return (res?.Items ?? []).map((i) => ({ id: String(i.Id), name: i.Name ?? "" }));
|
||||
}
|
||||
|
||||
async getGenreSongs(genreId: string, limit = 100): Promise<Song[]> {
|
||||
const res = await this.request<{ Items?: JellyfinItem[] }>("get", "/Items", {
|
||||
params: {
|
||||
GenreIds: genreId,
|
||||
IncludeItemTypes: "Audio",
|
||||
Recursive: true,
|
||||
SortBy: "AlbumArtist,Album,SortName",
|
||||
Limit: limit,
|
||||
userId: this.userId,
|
||||
},
|
||||
});
|
||||
return this.mapSongs(res?.Items);
|
||||
}
|
||||
|
||||
// --- Playback reporting (Sessions API) ---
|
||||
|
||||
private async report(path: string, body: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
await this.request("post", path, { data: body });
|
||||
} catch (err) {
|
||||
// Reporting must never affect playback — log and swallow.
|
||||
this.logger?.debug(
|
||||
{ err: describeJellyfinError(err), path },
|
||||
"Jellyfin playback report failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createPlaybackReporter(): JellyfinPlaybackReporter {
|
||||
// One session per track start; remember the last reported position so the
|
||||
// implicit stop on track change carries a sane PositionTicks (a natural
|
||||
// track end reports ~full duration → Jellyfin counts the play).
|
||||
let current: { itemId: string; sessionId: string; lastPosSec: number } | null = null;
|
||||
const report = this.report.bind(this);
|
||||
return {
|
||||
onTrackStart(itemId: string): void {
|
||||
if (current) {
|
||||
void report("/Sessions/Playing/Stopped", {
|
||||
ItemId: current.itemId,
|
||||
PlaySessionId: current.sessionId,
|
||||
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
|
||||
});
|
||||
}
|
||||
current = { itemId, sessionId: crypto.randomUUID(), lastPosSec: 0 };
|
||||
void report("/Sessions/Playing", {
|
||||
ItemId: itemId,
|
||||
PlaySessionId: current.sessionId,
|
||||
PositionTicks: 0,
|
||||
CanSeek: true,
|
||||
});
|
||||
},
|
||||
onTick(itemId: string, positionSec: number, paused: boolean): void {
|
||||
if (!current || current.itemId !== itemId) return;
|
||||
current.lastPosSec = positionSec;
|
||||
void report("/Sessions/Playing/Progress", {
|
||||
ItemId: itemId,
|
||||
PlaySessionId: current.sessionId,
|
||||
PositionTicks: Math.round(positionSec * TICKS_PER_SECOND),
|
||||
IsPaused: paused,
|
||||
});
|
||||
},
|
||||
onStop(): void {
|
||||
if (!current) return;
|
||||
void report("/Sessions/Playing/Stopped", {
|
||||
ItemId: current.itemId,
|
||||
PlaySessionId: current.sessionId,
|
||||
PositionTicks: Math.round(current.lastPosSec * TICKS_PER_SECOND),
|
||||
});
|
||||
current = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -1,3 +1,15 @@
|
||||
/** All music source platforms. Jellyfin ItemIds are GUID strings — never
|
||||
* assume numeric ids when handling a generic Platform. */
|
||||
export type Platform =
|
||||
| "netease"
|
||||
| "qq"
|
||||
| "bilibili"
|
||||
| "youtube"
|
||||
| "local"
|
||||
| "kugou"
|
||||
| "spotify"
|
||||
| "jellyfin";
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -5,7 +17,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
platform: Platform;
|
||||
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
|
||||
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
|
||||
vip?: boolean;
|
||||
@@ -27,7 +39,7 @@ export interface Playlist {
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
platform: Platform;
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
@@ -44,7 +56,7 @@ export interface Album {
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
platform: Platform;
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
@@ -72,7 +84,7 @@ export interface AuthStatus {
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
|
||||
readonly platform: Platform;
|
||||
|
||||
search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
|
||||
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import { getDefaultConfig, type JellyfinConfig } from "../../data/config.js";
|
||||
import { createAuthRouter } from "./auth.js";
|
||||
|
||||
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
|
||||
return { platform } as unknown as MusicProvider;
|
||||
}
|
||||
|
||||
describe("auth router POST /jellyfin/test", () => {
|
||||
function mount(
|
||||
stored: Partial<JellyfinConfig>,
|
||||
user: unknown = { role: "admin" },
|
||||
) {
|
||||
const config = getDefaultConfig();
|
||||
Object.assign(config.jellyfin, stored);
|
||||
const testConnection = vi.fn().mockResolvedValue({ ok: true, serverName: "JF" });
|
||||
const jellyfin = { platform: "jellyfin", testConnection } as unknown as MusicProvider;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as { user?: unknown }).user = user; next(); });
|
||||
app.use(
|
||||
"/api/auth",
|
||||
createAuthRouter(
|
||||
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
|
||||
pino({ level: "silent" }), undefined, undefined, undefined, jellyfin, config,
|
||||
),
|
||||
);
|
||||
return { app, testConnection };
|
||||
}
|
||||
|
||||
it("fills empty credential fields from the stored config (masked password case)", async () => {
|
||||
const { app, testConnection } = mount({
|
||||
serverUrl: "https://old.example.com",
|
||||
username: "bob",
|
||||
password: "stored-pw",
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/auth/jellyfin/test")
|
||||
.send({ serverUrl: "https://new.example.com", username: "bob", password: "" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverUrl: "https://new.example.com",
|
||||
username: "bob",
|
||||
password: "stored-pw",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes freshly entered credentials through", async () => {
|
||||
const { app, testConnection } = mount({});
|
||||
await request(app).post("/api/auth/jellyfin/test").send({
|
||||
serverUrl: "https://jf.example.com",
|
||||
authMode: "apikey",
|
||||
apiKey: "key123",
|
||||
userId: "u1",
|
||||
});
|
||||
expect(testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authMode: "apikey", apiKey: "key123", userId: "u1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is 403 for a member lacking platform.auth", async () => {
|
||||
const { app, testConnection } = mount({}, { role: "member", capabilities: new Set([]) });
|
||||
const res = await request(app).post("/api/auth/jellyfin/test").send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+52
-1
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { CookieStore } from "../../music/auth.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotConfig, JellyfinConfig } from "../../data/config.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
|
||||
@@ -13,7 +14,9 @@ export function createAuthRouter(
|
||||
logger: Logger,
|
||||
cookieStore?: CookieStore,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider
|
||||
spotifyProvider?: MusicProvider,
|
||||
jellyfinProvider?: MusicProvider,
|
||||
config?: BotConfig
|
||||
): Router {
|
||||
const router = Router();
|
||||
// YouTube is auth-less; we only use this instance so /auth/status can
|
||||
@@ -25,6 +28,7 @@ export function createAuthRouter(
|
||||
if (platform === "youtube") return youtubeProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
||||
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
@@ -84,6 +88,45 @@ export function createAuthRouter(
|
||||
}
|
||||
});
|
||||
|
||||
// Jellyfin has no QR/cookie flow — the connection is admin-configured. This
|
||||
// round-trips /System/Info so Settings can verify form values BEFORE saving.
|
||||
// Empty/missing credential fields fall back to the stored config, so a
|
||||
// masked (not re-entered) password still tests the live setup.
|
||||
router.post("/jellyfin/test", requirePermission("platform.auth"), async (req, res) => {
|
||||
const testable = jellyfinProvider as
|
||||
| (MusicProvider & {
|
||||
testConnection?: (
|
||||
candidate?: JellyfinConfig,
|
||||
) => Promise<{ ok: boolean; serverName?: string; version?: string; error?: string }>;
|
||||
})
|
||||
| undefined;
|
||||
if (!testable?.testConnection) {
|
||||
res.status(501).json({ error: "Jellyfin provider not available" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = (req.body ?? {}) as Partial<JellyfinConfig>;
|
||||
const stored = config?.jellyfin;
|
||||
const str = (v: unknown, fallback: string) =>
|
||||
typeof v === "string" && v.trim() !== "" ? v.trim() : fallback;
|
||||
const candidate: JellyfinConfig = {
|
||||
serverUrl: str(body.serverUrl, stored?.serverUrl ?? ""),
|
||||
authMode:
|
||||
body.authMode === "apikey" || body.authMode === "userpass"
|
||||
? body.authMode
|
||||
: stored?.authMode ?? "userpass",
|
||||
username: str(body.username, stored?.username ?? ""),
|
||||
password: str(body.password, stored?.password ?? ""),
|
||||
apiKey: str(body.apiKey, stored?.apiKey ?? ""),
|
||||
userId: str(body.userId, stored?.userId ?? ""),
|
||||
};
|
||||
res.json(await testable.testConnection(candidate));
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Jellyfin test connection failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
|
||||
try {
|
||||
const { phone } = req.body;
|
||||
@@ -139,6 +182,14 @@ export function createAuthRouter(
|
||||
.json({ error: "YouTube does not use cookies (uses yt-dlp binary)" });
|
||||
return;
|
||||
}
|
||||
// Jellyfin auth is server-configured (Settings → connection card), not
|
||||
// cookie-based; falling through would clobber the NetEase cookie entry.
|
||||
if (platform === "jellyfin") {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Jellyfin 通过 Settings 配置连接,不支持手动 Cookie" });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(platform);
|
||||
provider.setCookie(cookie);
|
||||
const plat = platform === "bilibili" ? "bilibili" as const
|
||||
|
||||
+87
-1
@@ -13,7 +13,7 @@ import { createAvatarStore } from "../../data/avatars.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||
import { getDefaultConfig, type BotConfig, type JellyfinConfig } from "../../data/config.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
import type { BotManager } from "../../bot/manager.js";
|
||||
|
||||
@@ -561,3 +561,89 @@ describe("bot router /settings guest-mode gating + persistence", () => {
|
||||
expect(config.spotify.clientId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bot router /settings jellyfin block + enabledProviders", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let config: BotConfig;
|
||||
let botDb: BotDatabase;
|
||||
let configureCalls: JellyfinConfig[];
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-jf-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
config = getDefaultConfig();
|
||||
configureCalls = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function mountBot(): express.Express {
|
||||
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
|
||||
const avatarStore = createAvatarStore(tmpDir);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as { user?: unknown }).user = { role: "admin" }; next(); });
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(
|
||||
fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore,
|
||||
undefined, undefined, undefined,
|
||||
{ configure: (cfg: JellyfinConfig) => configureCalls.push({ ...cfg }) },
|
||||
),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("GET /settings exposes a masked jellyfin block (no password/apiKey echo)", async () => {
|
||||
config.jellyfin.serverUrl = "https://jf.example.com";
|
||||
config.jellyfin.password = "secret";
|
||||
const res = await request(mountBot()).get("/api/bot/settings");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.jellyfin).toEqual({
|
||||
serverUrl: "https://jf.example.com",
|
||||
authMode: "userpass",
|
||||
username: "",
|
||||
userId: "",
|
||||
hasPassword: true,
|
||||
hasApiKey: false,
|
||||
});
|
||||
expect(res.body.enabledProviders).toEqual(["jellyfin"]);
|
||||
});
|
||||
|
||||
it("POST /settings merges jellyfin, keeps stored secrets on blank, hot-configures", async () => {
|
||||
config.jellyfin.password = "stored-pw";
|
||||
const app = mountBot();
|
||||
const res = await request(app).post("/api/bot/settings").send({
|
||||
jellyfin: { serverUrl: "https://jf.example.com///", username: "bob", password: "" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// Trailing slashes normalized; blank password kept the stored one.
|
||||
expect(config.jellyfin.serverUrl).toBe("https://jf.example.com");
|
||||
expect(config.jellyfin.username).toBe("bob");
|
||||
expect(config.jellyfin.password).toBe("stored-pw");
|
||||
expect(res.body.jellyfin.hasPassword).toBe(true);
|
||||
// Live provider re-configured with the post-merge values.
|
||||
expect(configureCalls).toHaveLength(1);
|
||||
expect(configureCalls[0].serverUrl).toBe("https://jf.example.com");
|
||||
expect(configureCalls[0].password).toBe("stored-pw");
|
||||
// Persisted to disk.
|
||||
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
expect(onDisk.jellyfin.username).toBe("bob");
|
||||
});
|
||||
|
||||
it("POST /settings filters enabledProviders to known providers", async () => {
|
||||
const res = await request(mountBot()).post("/api/bot/settings").send({
|
||||
enabledProviders: ["jellyfin", "netease", "bogus", 42],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabledProviders).toEqual(["jellyfin", "netease"]);
|
||||
expect(config.enabledProviders).toEqual(["jellyfin", "netease"]);
|
||||
// No jellyfin block in the request → no reconfigure call.
|
||||
expect(configureCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+55
-2
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import type { BotManager } from "../../bot/manager.js";
|
||||
import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js";
|
||||
import { saveConfig } from "../../data/config.js";
|
||||
import type { BotConfig, GuestModeConfig, SpotifyConfig, JellyfinConfig, GateableProvider } from "../../data/config.js";
|
||||
import { saveConfig, GATEABLE_PROVIDERS } from "../../data/config.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotDatabase } from "../../data/database.js";
|
||||
import type { AvatarStore } from "../../data/avatars.js";
|
||||
@@ -25,6 +25,11 @@ export function createBotRouter(
|
||||
// Settings must push creds here too, or search stays empty until a restart.
|
||||
// Structural type = SpotifyProvider.setCreds.
|
||||
spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void },
|
||||
// The live JellyfinProvider, so a Settings save re-points the connection
|
||||
// without a restart (configure() drops the cached token only when
|
||||
// credential-relevant fields actually changed). Structural type =
|
||||
// JellyfinProvider.configure.
|
||||
jellyfinProvider?: { configure(cfg: JellyfinConfig): void },
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -39,6 +44,17 @@ export function createBotRouter(
|
||||
hasClientSecret: config.spotify.clientSecret.length > 0,
|
||||
});
|
||||
|
||||
// Masked jellyfin view: password and apiKey are write-only — only their
|
||||
// presence is reported (mirrors maskedSpotify's clientSecret handling).
|
||||
const maskedJellyfin = () => ({
|
||||
serverUrl: config.jellyfin.serverUrl,
|
||||
authMode: config.jellyfin.authMode,
|
||||
username: config.jellyfin.username,
|
||||
userId: config.jellyfin.userId,
|
||||
hasPassword: config.jellyfin.password.length > 0,
|
||||
hasApiKey: config.jellyfin.apiKey.length > 0,
|
||||
});
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const all = botManager.getAllBots().map((b) => b.getStatus());
|
||||
const u = req.user!;
|
||||
@@ -59,6 +75,8 @@ export function createBotRouter(
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
spotify: maskedSpotify(),
|
||||
jellyfin: maskedJellyfin(),
|
||||
enabledProviders: config.enabledProviders,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,8 +148,41 @@ export function createBotRouter(
|
||||
}
|
||||
}
|
||||
|
||||
// Partial-merge the jellyfin block (same contract as spotify): invalid
|
||||
// sub-fields are ignored; password/apiKey are write-only and only stored
|
||||
// when non-empty so a blank (masked) field never wipes them.
|
||||
const jf = req.body?.jellyfin;
|
||||
if (jf && typeof jf === "object") {
|
||||
const t = config.jellyfin;
|
||||
if (typeof jf.serverUrl === "string") {
|
||||
t.serverUrl = jf.serverUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
if (jf.authMode === "userpass" || jf.authMode === "apikey") t.authMode = jf.authMode;
|
||||
if (typeof jf.username === "string") t.username = jf.username;
|
||||
if (typeof jf.password === "string" && jf.password.length > 0) t.password = jf.password;
|
||||
if (typeof jf.apiKey === "string" && jf.apiKey.length > 0) t.apiKey = jf.apiKey;
|
||||
if (typeof jf.userId === "string") t.userId = jf.userId;
|
||||
}
|
||||
|
||||
// enabledProviders: full replace, known providers only (mirrors loadConfig).
|
||||
// An empty array is a valid "all gateable sources off". NOTE: the NetEase/QQ
|
||||
// sidecar API servers are only started at boot, so newly enabling those two
|
||||
// still needs a restart; jellyfin and the rest take effect immediately.
|
||||
const ep = req.body?.enabledProviders;
|
||||
if (Array.isArray(ep)) {
|
||||
config.enabledProviders = ep.filter((p: unknown): p is GateableProvider =>
|
||||
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
|
||||
);
|
||||
}
|
||||
|
||||
saveConfig(configPath, config);
|
||||
|
||||
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live
|
||||
// provider — a Settings save must work without a restart.
|
||||
if (jf && typeof jf === "object") {
|
||||
jellyfinProvider?.configure(config.jellyfin);
|
||||
}
|
||||
|
||||
// I2: only when the spotify block was present, push the (possibly UI-entered)
|
||||
// Client ID into the live process-wide OAuth so Connect works without a
|
||||
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
|
||||
@@ -166,6 +217,8 @@ export function createBotRouter(
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
spotify: maskedSpotify(),
|
||||
jellyfin: maskedJellyfin(),
|
||||
enabledProviders: config.enabledProviders,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import type { MusicProvider, SearchResult } from "../../music/provider.js";
|
||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
|
||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
|
||||
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
|
||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
return {
|
||||
platform,
|
||||
search: vi.fn().mockResolvedValue(empty),
|
||||
@@ -47,3 +49,96 @@ describe("music router GET /search offset pagination", () => {
|
||||
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("music router provider gating (enabledProviders) + jellyfin endpoints", () => {
|
||||
function jellyfinFake(): MusicProvider {
|
||||
return {
|
||||
platform: "jellyfin",
|
||||
search: vi.fn().mockResolvedValue(empty),
|
||||
getQuality: vi.fn().mockReturnValue("direct"),
|
||||
getLatestAlbums: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "a1", name: "Album", platform: "jellyfin" }]),
|
||||
getFavoriteSongs: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as MusicProvider;
|
||||
}
|
||||
|
||||
function mount(config: BotConfig) {
|
||||
const netease = fakeProvider("netease");
|
||||
const jellyfin = jellyfinFake();
|
||||
const router = createMusicRouter(
|
||||
netease,
|
||||
fakeProvider("qq"),
|
||||
fakeProvider("bilibili"),
|
||||
pino({ level: "silent" }),
|
||||
undefined,
|
||||
config,
|
||||
fakeProvider("kugou"),
|
||||
fakeProvider("spotify"),
|
||||
jellyfin,
|
||||
);
|
||||
const app = express();
|
||||
app.use("/api/music", router);
|
||||
return { app, netease, jellyfin };
|
||||
}
|
||||
|
||||
it("routes a platform-less /search to the default platform (jellyfin)", async () => {
|
||||
const { app, netease, jellyfin } = mount(getDefaultConfig());
|
||||
const res = await request(app).get("/api/music/search?q=hello");
|
||||
expect(res.status).toBe(200);
|
||||
expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||
expect(netease.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a disabled platform with 400 without calling its provider", async () => {
|
||||
// Default config enables jellyfin only.
|
||||
const { app, netease } = mount(getDefaultConfig());
|
||||
const res = await request(app).get("/api/music/search?q=hello&platform=netease");
|
||||
expect(res.status).toBe(400);
|
||||
expect(netease.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows an explicitly re-enabled legacy platform", async () => {
|
||||
const config = getDefaultConfig();
|
||||
config.enabledProviders = ["jellyfin", "netease"];
|
||||
const { app, netease } = mount(config);
|
||||
const res = await request(app).get("/api/music/search?q=hello&platform=netease");
|
||||
expect(res.status).toBe(200);
|
||||
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
|
||||
});
|
||||
|
||||
it("GET /providers reports enabled sources and the default platform", async () => {
|
||||
const { app } = mount(getDefaultConfig());
|
||||
const res = await request(app).get("/api/music/providers");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.default).toBe("jellyfin");
|
||||
expect(res.body.enabled).toContain("jellyfin");
|
||||
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
|
||||
expect(res.body.enabled).not.toContain("netease");
|
||||
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
|
||||
});
|
||||
|
||||
it("GET /jellyfin/latest-albums returns provider data", async () => {
|
||||
const { app, jellyfin } = mount(getDefaultConfig());
|
||||
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
(jellyfin as unknown as { getLatestAlbums: ReturnType<typeof vi.fn> }).getLatestAlbums,
|
||||
).toHaveBeenCalledWith(5);
|
||||
expect(res.body.albums).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled", async () => {
|
||||
const config = getDefaultConfig();
|
||||
config.enabledProviders = ["netease"];
|
||||
const { app } = mount(config);
|
||||
const res = await request(app).get("/api/music/jellyfin/latest-albums");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("GET /jellyfin/favorites denies unauthenticated/guest access", async () => {
|
||||
const { app } = mount(getDefaultConfig());
|
||||
const res = await request(app).get("/api/music/jellyfin/favorites");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
+170
-20
@@ -1,8 +1,8 @@
|
||||
import express, { Router } from "express";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import express, { Router, type Response } from "express";
|
||||
import type { MusicProvider, Song, Album } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotConfig } from "../../data/config.js";
|
||||
import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
@@ -15,7 +15,8 @@ export function createMusicRouter(
|
||||
localProvider?: MusicProvider,
|
||||
config?: BotConfig,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider
|
||||
spotifyProvider?: MusicProvider,
|
||||
jellyfinProvider?: MusicProvider
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
@@ -30,9 +31,26 @@ export function createMusicRouter(
|
||||
if (platform === "local" && localProvider) return localProvider;
|
||||
if (platform === "kugou" && kugouProvider) return kugouProvider;
|
||||
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
|
||||
if (platform === "jellyfin" && jellyfinProvider) return jellyfinProvider;
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider gating for user-supplied platform params. No platform → the
|
||||
* configured default (jellyfin unless disabled). A disabled platform gets a
|
||||
* friendly 400 and null back — the handler must return immediately.
|
||||
* Without a config (unit-test routers), everything stays enabled.
|
||||
*/
|
||||
function resolveProvider(platform: unknown, res: Response): MusicProvider | null {
|
||||
const requested = typeof platform === "string" && platform ? platform : undefined;
|
||||
const target = requested ?? (config ? defaultPlatform(config) : "netease");
|
||||
if (config && !isProviderEnabled(config, target)) {
|
||||
res.status(400).json({ error: `音源未启用:${target} (provider disabled)` });
|
||||
return null;
|
||||
}
|
||||
return getProvider(target);
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/local/upload",
|
||||
authorize({ capability: "player.queue", guestFlag: "addToQueue" }),
|
||||
@@ -95,7 +113,8 @@ export function createMusicRouter(
|
||||
res.json({ songs: [], playlists: [], albums: [] });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(platform as string);
|
||||
const provider = resolveProvider(platform, res);
|
||||
if (!provider) return;
|
||||
// Server-side pagination: offset lets the web load past the first page.
|
||||
// Clamp to >= 0 so a bad/negative value falls back to the first page.
|
||||
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
|
||||
@@ -124,15 +143,21 @@ export function createMusicRouter(
|
||||
// backend lands (Stage 2/3), so surfacing them in the default all-sources
|
||||
// view would only yield results that get skipped. Spotify search remains
|
||||
// available from its own tab via /search?platform=spotify.
|
||||
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||
neteaseProvider.search(q as string, parsedLimit),
|
||||
qqProvider.search(q as string, parsedLimit),
|
||||
bilibiliProvider.search(q as string, parsedLimit),
|
||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
kugouProvider ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve({ songs: [], albums: [], playlists: [] }),
|
||||
// Provider gating (#enabledProviders): disabled sources are skipped, not
|
||||
// searched. Jellyfin — the primary source — leads the merged results.
|
||||
const enabled = (p: string) => !config || isProviderEnabled(config, p);
|
||||
const none = { songs: [], albums: [], playlists: [] };
|
||||
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
|
||||
jellyfinProvider && enabled("jellyfin") ? jellyfinProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
enabled("netease") ? neteaseProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
enabled("qq") ? qqProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
enabled("bilibili") ? bilibiliProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
localProvider && isLocalAudioEnabled() ? localProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
kugouProvider && enabled("kugou") ? kugouProvider.search(q as string, parsedLimit) : Promise.resolve(none),
|
||||
]);
|
||||
|
||||
const songs = [
|
||||
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.songs : []),
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
@@ -140,10 +165,12 @@ export function createMusicRouter(
|
||||
...(kugouResult.status === "fulfilled" ? kugouResult.value.songs : []),
|
||||
];
|
||||
const albums = [
|
||||
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.albums : []),
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
|
||||
];
|
||||
const playlists = [
|
||||
...(jellyfinResult.status === "fulfilled" ? jellyfinResult.value.playlists : []),
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
|
||||
];
|
||||
@@ -161,7 +188,8 @@ export function createMusicRouter(
|
||||
res.status(403).json({ error: "本地音频播放已关闭" });
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
const song = await provider.getSongDetail(req.params.id);
|
||||
if (!song) {
|
||||
res.status(404).json({ error: "Song not found" });
|
||||
@@ -175,7 +203,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/playlist/:id", async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
const songs = await provider.getPlaylistSongs(req.params.id);
|
||||
res.json({ songs });
|
||||
} catch (err) {
|
||||
@@ -185,7 +214,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/recommend/playlists", async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
const playlists = await provider.getRecommendPlaylists();
|
||||
res.json({ playlists });
|
||||
} catch (err) {
|
||||
@@ -195,7 +225,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/album/:id", async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
const songs = await provider.getAlbumSongs(req.params.id);
|
||||
res.json({ songs });
|
||||
} catch (err) {
|
||||
@@ -205,7 +236,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/lyrics/:id", async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
const lyrics = await provider.getLyrics(req.params.id);
|
||||
res.json({ lyrics });
|
||||
} catch (err) {
|
||||
@@ -215,7 +247,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
if (!provider.getDailyRecommendSongs) {
|
||||
res.status(501).json({ error: "Not supported by this provider" });
|
||||
return;
|
||||
@@ -230,7 +263,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/personal/fm", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
if (!provider.getPersonalFm) {
|
||||
res.status(501).json({ error: "Not supported by this provider" });
|
||||
return;
|
||||
@@ -245,7 +279,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/user/playlists", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
if (!provider.getUserPlaylists) {
|
||||
res.status(501).json({ error: "Not supported by this provider" });
|
||||
return;
|
||||
@@ -260,7 +295,8 @@ export function createMusicRouter(
|
||||
|
||||
router.get("/playlist/:id/detail", async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
const provider = resolveProvider(req.query.platform, res);
|
||||
if (!provider) return;
|
||||
if (!provider.getPlaylistDetail) {
|
||||
res.status(501).json({ error: "Not supported by this provider" });
|
||||
return;
|
||||
@@ -294,6 +330,114 @@ export function createMusicRouter(
|
||||
}
|
||||
});
|
||||
|
||||
// Enabled sources + default platform, for the web UI (source tabs, default
|
||||
// search/playback source). Without a config (unit-test routers) everything
|
||||
// reports enabled with the legacy netease default.
|
||||
router.get("/providers", (_req, res) => {
|
||||
const ALL_PLATFORMS = [
|
||||
"jellyfin",
|
||||
"netease",
|
||||
"qq",
|
||||
"bilibili",
|
||||
"youtube",
|
||||
"kugou",
|
||||
"spotify",
|
||||
"local",
|
||||
];
|
||||
res.json({
|
||||
enabled: config ? ALL_PLATFORMS.filter((p) => isProviderEnabled(config, p)) : ALL_PLATFORMS,
|
||||
default: config ? defaultPlatform(config) : "netease",
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Jellyfin home-page data ───────────────────────────────────────────
|
||||
// The concrete JellyfinProvider surface these endpoints consume; the router
|
||||
// only knows the MusicProvider interface, so narrow structurally (same
|
||||
// pattern as localProvider.uploadAudio above).
|
||||
type JellyfinHomeProvider = MusicProvider & {
|
||||
getLatestAlbums?: (limit?: number) => Promise<Album[]>;
|
||||
getMostPlayed?: (limit?: number) => Promise<Song[]>;
|
||||
getFavoriteSongs?: (limit?: number) => Promise<Song[]>;
|
||||
getGenres?: (limit?: number) => Promise<{ id: string; name: string }[]>;
|
||||
getGenreSongs?: (genreId: string, limit?: number) => Promise<Song[]>;
|
||||
};
|
||||
const jellyfinHome = jellyfinProvider as JellyfinHomeProvider | undefined;
|
||||
|
||||
/** 501 when no provider is wired, 400 when the source is disabled. */
|
||||
function jellyfinOrReject(res: Response): JellyfinHomeProvider | null {
|
||||
if (!jellyfinHome) {
|
||||
res.status(501).json({ error: "Jellyfin provider not configured" });
|
||||
return null;
|
||||
}
|
||||
if (config && !isProviderEnabled(config, "jellyfin")) {
|
||||
res.status(400).json({ error: "音源未启用:jellyfin (provider disabled)" });
|
||||
return null;
|
||||
}
|
||||
return jellyfinHome;
|
||||
}
|
||||
|
||||
router.get("/jellyfin/latest-albums", async (req, res) => {
|
||||
try {
|
||||
const p = jellyfinOrReject(res);
|
||||
if (!p) return;
|
||||
const limit = parseInt(req.query.limit as string) || 12;
|
||||
res.json({ albums: (await p.getLatestAlbums?.(limit)) ?? [] });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Jellyfin latest albums failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/jellyfin/most-played", async (req, res) => {
|
||||
try {
|
||||
const p = jellyfinOrReject(res);
|
||||
if (!p) return;
|
||||
const limit = parseInt(req.query.limit as string) || 12;
|
||||
res.json({ songs: (await p.getMostPlayed?.(limit)) ?? [] });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Jellyfin most played failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Favorites are account-level data — guests don't get them (matches the
|
||||
// requireNotGuest gate on /user/playlists).
|
||||
router.get("/jellyfin/favorites", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const p = jellyfinOrReject(res);
|
||||
if (!p) return;
|
||||
const limit = parseInt(req.query.limit as string) || 100;
|
||||
res.json({ songs: (await p.getFavoriteSongs?.(limit)) ?? [] });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Jellyfin favorites failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/jellyfin/genres", async (req, res) => {
|
||||
try {
|
||||
const p = jellyfinOrReject(res);
|
||||
if (!p) return;
|
||||
const limit = parseInt(req.query.limit as string) || 30;
|
||||
res.json({ genres: (await p.getGenres?.(limit)) ?? [] });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Jellyfin genres failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/jellyfin/genre/:id/songs", async (req, res) => {
|
||||
try {
|
||||
const p = jellyfinOrReject(res);
|
||||
if (!p) return;
|
||||
const limit = parseInt(req.query.limit as string) || 100;
|
||||
res.json({ songs: (await p.getGenreSongs?.(req.params.id, limit)) ?? [] });
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Jellyfin genre songs failed");
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current quality
|
||||
router.get("/quality", requireNotGuest, (_req, res) => {
|
||||
res.json({
|
||||
@@ -303,6 +447,7 @@ export function createMusicRouter(
|
||||
local: localProvider?.getQuality() ?? "original",
|
||||
kugou: kugouProvider?.getQuality() ?? "128",
|
||||
spotify: spotifyProvider?.getQuality() ?? "320",
|
||||
jellyfin: jellyfinProvider?.getQuality() ?? "direct",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,6 +473,11 @@ export function createMusicRouter(
|
||||
if ((!platform || platform === "spotify") && spotifyProvider) {
|
||||
spotifyProvider.setQuality(quality);
|
||||
}
|
||||
// Safe in the platform-less broadcast: JellyfinProvider.setQuality ignores
|
||||
// values outside its own tier list (direct/320/192/128).
|
||||
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
|
||||
jellyfinProvider.setQuality(quality);
|
||||
}
|
||||
logger.info({ quality, platform }, "Audio quality changed");
|
||||
res.json({ success: true, quality });
|
||||
});
|
||||
|
||||
@@ -40,6 +40,10 @@ export function createPlayerRouter(
|
||||
if (platform === "qq") return "-q";
|
||||
if (platform === "youtube") return "-y";
|
||||
if (platform === "kugou") return "-k";
|
||||
if (platform === "jellyfin") return "-j";
|
||||
// The flag-less default is now the configured default platform (jellyfin
|
||||
// unless disabled), so netease needs an explicit flag.
|
||||
if (platform === "netease") return "-n";
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -121,7 +125,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -296,7 +300,7 @@ export function createPlayerRouter(
|
||||
// Use the bot's own provider lookup — it already knows about youtube,
|
||||
// which the router's constructor params did not.
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -389,7 +393,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
@@ -620,7 +624,7 @@ export function createPlayerRouter(
|
||||
return;
|
||||
}
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou"
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube" || platform === "local" || platform === "kugou" || platform === "jellyfin"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
+8
-2
@@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js";
|
||||
import { createSpotifyRouter } from "./api/spotify.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
||||
import type { JellyfinProvider } from "../music/jellyfin.js";
|
||||
import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";
|
||||
import {
|
||||
isGoLibrespotPresent,
|
||||
@@ -49,6 +50,7 @@ export interface WebServerOptions {
|
||||
localProvider: MusicProvider;
|
||||
kugouProvider: MusicProvider;
|
||||
spotifyProvider: MusicProvider;
|
||||
jellyfinProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
configPath: string;
|
||||
@@ -141,11 +143,15 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
// runtime object is a SpotifyProvider (see index.ts); WebServerOptions types
|
||||
// it as the wider MusicProvider, so narrow it here for the setCreds contract.
|
||||
options.spotifyProvider as SpotifyProvider,
|
||||
// Same live-reconfigure contract for Jellyfin: a Settings save re-points
|
||||
// the connection without a restart. Runtime object is a JellyfinProvider
|
||||
// (see index.ts); WebServerOptions types it as the wider MusicProvider.
|
||||
options.jellyfinProvider as JellyfinProvider,
|
||||
)
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
@@ -153,7 +159,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
));
|
||||
app.use(
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider)
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.config)
|
||||
);
|
||||
if (options.spotifyOAuth) {
|
||||
app.use(
|
||||
|
||||
Reference in new issue
Block a user