Jellyfin Integration. Assisted by Claude Fable 5.

This commit is contained in:
itsericrao committed 2026-07-06 23:07:20 +08:00
1 parent b9767a7471
commit f637ba0191
32 files changed
+2802 -171

No files matched your search

+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
});
+96 -1
View File
@@ -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
View File
@@ -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 });
});
+8 -4
View File
@@ -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
View File
@@ -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(