feat: make Jellyfin an optional source instead of the default

Revert the jellyfin-only default introduced by PR #123 so upgrading
users keep their online sources; Jellyfin becomes opt-in:

- default enabledProviders is now the online set (netease/qq/bilibili/
  youtube/kugou); defaultPlatform() uses a fixed priority order
  (netease -> qq -> kugou -> jellyfin -> bilibili -> youtube) instead
  of jellyfin-first, so chat/REST/WebUI default to netease again
- Settings: Jellyfin card is always visible with a new enable toggle
  (its enabled bit is enabledProviders membership); guards against
  clobbering other providers before the list loads
- Setup wizard: saving the Jellyfin step auto-enables the source when
  a server URL was entered
- Search/player store fallbacks flip from jellyfin to netease; !help
  no longer hardcodes Jellyfin lines
- tests: update default-platform assertions, add coverage for the new
  default set, legacy configs without enabledProviders, priority
  order, and explicit jellyfin-only configs
- README: reframe Jellyfin as optional (badges, command table, quality
  tiers, dedicated section, changelog), document the enabledProviders
  default and the v1.10.0 jellyfin-only window fix, credit @ItsEricRao

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Fable 5 committed 2026-07-14 00:54:34 +08:00
1 parent 3841fa80d3
commit 750ad9b1cc
12 files changed
+270 -72

No files matched your search

+2 -1
View File
@@ -612,7 +612,8 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
hasPassword: true,
hasApiKey: false,
});
expect(res.body.enabledProviders).toEqual(["jellyfin"]);
// Default: online sources on, jellyfin opt-in (not listed).
expect(res.body.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
});
it("POST /settings merges jellyfin, keeps stored secrets on blank, hot-configures", async () => {
+26 -21
View File
@@ -82,44 +82,51 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
return { app, netease, jellyfin };
}
it("routes a platform-less /search to the default platform (jellyfin)", async () => {
it("routes a platform-less /search to the default platform (netease)", 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();
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
expect(jellyfin.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");
// Default config leaves jellyfin (opt-in) disabled.
const { app, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(400);
expect(netease.search).not.toHaveBeenCalled();
expect(jellyfin.search).not.toHaveBeenCalled();
});
it("allows an explicitly re-enabled legacy platform", async () => {
it("allows an explicitly enabled jellyfin 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");
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
const { app, jellyfin } = mount(config);
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
expect(jellyfin.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.default).toBe("netease");
expect(res.body.enabled).toContain("netease");
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
expect(res.body.enabled).not.toContain("netease");
expect(res.body.enabled).not.toContain("jellyfin"); // opt-in, off by default
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
});
/** Default config plus the opt-in jellyfin source enabled. */
function configWithJellyfin() {
const config = getDefaultConfig();
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
return config;
}
it("GET /jellyfin/latest-albums returns provider data", async () => {
const { app, jellyfin } = mount(getDefaultConfig());
const { app, jellyfin } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
expect(res.status).toBe(200);
expect(
@@ -128,16 +135,14 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
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);
it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled (the default)", async () => {
const { app } = mount(getDefaultConfig());
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 { app } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/favorites");
expect(res.status).toBe(401);
});
+6 -4
View File
@@ -37,9 +37,10 @@ export function createMusicRouter(
/**
* 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.
* configured default (see defaultPlatform(); netease in the default config).
* 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;
@@ -144,7 +145,8 @@ export function createMusicRouter(
// view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify.
// Provider gating (#enabledProviders): disabled sources are skipped, not
// searched. Jellyfin — the primary source — leads the merged results.
// searched. Jellyfin (an opt-in source) leads the merged results when
// enabled — a self-hosted library match is almost always the wanted one.
const enabled = (p: string) => !config || isProviderEnabled(config, p);
const none = { songs: [], albums: [], playlists: [] };
const [jellyfinResult, neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([