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
+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>;
|
||||
|
||||
Reference in new issue
Block a user