mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Jellyfin Integration. Assisted by Claude Fable 5.
This commit is contained in:
1 parent
b9767a7471
commit
f637ba0191
32 files changed
+2802
-171
No files matched your search
+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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user