merge dev into v2
This commit is contained in:
396 files changed
+56438
-26777
No files matched your search
@@ -23,10 +23,15 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setAsync(value: TValue) {
|
||||
logger.debug("Updating session in store", { store: channelName });
|
||||
/**
|
||||
* @param value session to store
|
||||
* @param options pass `ttlSeconds` when the credential has a known lifetime, so a stored
|
||||
* session can never outlive the token it holds
|
||||
*/
|
||||
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
|
||||
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
|
||||
try {
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(value)));
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(value)), options);
|
||||
} catch (error) {
|
||||
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import type { Response as UndiciResponse } from "undici";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
@@ -80,11 +82,47 @@ export const normalizeRealtimeSnapshot = (
|
||||
return events;
|
||||
};
|
||||
|
||||
interface BeszelSession {
|
||||
export interface BeszelSession {
|
||||
token: string;
|
||||
userId: string;
|
||||
/** Epoch milliseconds, taken from the token's `exp` claim. Undefined when it could not be read. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-authenticate this long before the token actually lapses, so a request can never be sent
|
||||
* with a token that expires while it is in flight.
|
||||
*/
|
||||
const sessionExpiryLeewayMs = 60_000;
|
||||
|
||||
/**
|
||||
* Reads the expiration out of a PocketBase auth token.
|
||||
*
|
||||
* @returns expiry as epoch milliseconds, or null when the token is malformed or carries no
|
||||
* numeric `exp` claim — in which case we fall back to the existing 401 retry path.
|
||||
*/
|
||||
export const parseTokenExpiration = (token: string): number | null => {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return null;
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const { exp } = JSON.parse(decoded) as { exp?: unknown };
|
||||
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Beszel answers requests carrying an expired token with `200` and an empty list rather than `401`,
|
||||
* because its collection rules filter on `@request.auth.id` instead of rejecting. The 401 retry in
|
||||
* `fetchWithAuthAsync` therefore never fires, so a stored session would otherwise be trusted
|
||||
* forever. Sessions have to expire themselves.
|
||||
*/
|
||||
export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
|
||||
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
|
||||
|
||||
export class BeszelIntegration extends Integration {
|
||||
private readonly sessionStore: SessionStore<BeszelSession>;
|
||||
|
||||
@@ -95,11 +133,19 @@ export class BeszelIntegration extends Integration {
|
||||
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
const existingSession = await this.sessionStore.getAsync();
|
||||
if (existingSession) {
|
||||
if (existingSession && !isSessionExpired(existingSession)) {
|
||||
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
if (existingSession) {
|
||||
logger.info("Stored Beszel session expired, re-authenticating", {
|
||||
integrationId: this.integration.id,
|
||||
expiresAt: existingSession.expiresAt,
|
||||
});
|
||||
await this.sessionStore.clearAsync();
|
||||
}
|
||||
|
||||
const authUrl = this.url("/api/collections/users/auth-with-password");
|
||||
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
||||
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
||||
@@ -118,9 +164,30 @@ export class BeszelIntegration extends Integration {
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||
await this.sessionStore.setAsync(session);
|
||||
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
||||
const expiresAt = parseTokenExpiration(data.token) ?? undefined;
|
||||
|
||||
// A token that has already lapsed by the time it reaches us means our clock and Beszel's
|
||||
// disagree by more than the token lifetime. Re-authenticating would only mint another dead
|
||||
// token, and using this one would silently produce the empty responses this check exists to
|
||||
// prevent — so fail loudly instead.
|
||||
if (expiresAt !== undefined && expiresAt <= Date.now()) {
|
||||
logger.warn("Beszel issued an already-expired token", { integrationId: this.integration.id, expiresAt });
|
||||
throw new ErrorWithMetadata("Beszel issued an already-expired authentication token, check for clock skew", {
|
||||
integrationId: this.integration.id,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id, expiresAt };
|
||||
// Give the stored entry the token's own lifetime, so the cache cannot outlive the credential
|
||||
// even if the session is never read again before it lapses.
|
||||
const ttlSeconds = expiresAt === undefined ? undefined : Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
|
||||
await this.sessionStore.setAsync(session, ttlSeconds === undefined ? undefined : { ttlSeconds });
|
||||
logger.debug("Saved Beszel session", {
|
||||
integrationId: this.integration.id,
|
||||
userId: session.userId,
|
||||
expiresAt,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -357,19 +424,41 @@ export class BeszelIntegration extends Integration {
|
||||
let emittedSnapshotCount = 0;
|
||||
|
||||
while (!signal.aborted) {
|
||||
let streamResponse: UndiciResponse | undefined;
|
||||
let streamAgent: Awaited<ReturnType<typeof createCertificateAgentAsync>> | undefined;
|
||||
const closeStreamAsync = async () => {
|
||||
const response = streamResponse;
|
||||
streamResponse = undefined;
|
||||
const agent = streamAgent;
|
||||
streamAgent = undefined;
|
||||
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
if (agent) await agent.close().catch(() => {});
|
||||
};
|
||||
|
||||
try {
|
||||
let session = await this.authenticateAsync();
|
||||
const openStream = async () =>
|
||||
await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: session.token,
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
signal,
|
||||
bodyTimeout: 0,
|
||||
});
|
||||
const openStream = async () => {
|
||||
streamAgent = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
|
||||
try {
|
||||
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
Authorization: session.token,
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
signal,
|
||||
bodyTimeout: 0,
|
||||
dispatcher: streamAgent,
|
||||
});
|
||||
streamResponse = response;
|
||||
return response;
|
||||
} catch (error) {
|
||||
await closeStreamAsync();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
logger.debug("Opening Beszel rt_metrics SSE connection", {
|
||||
integrationId: this.integration.id,
|
||||
@@ -378,6 +467,7 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
let response = await openStream();
|
||||
if (response.status === 401) {
|
||||
await closeStreamAsync();
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await openStream();
|
||||
@@ -402,14 +492,21 @@ export class BeszelIntegration extends Integration {
|
||||
clientId = frame.id || fallbackClientId;
|
||||
if (!clientId) throw new Error("PocketBase PB_CONNECT event did not include a client ID");
|
||||
|
||||
if (!streamAgent) throw new Error("Beszel realtime stream agent is not available");
|
||||
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
|
||||
method: "POST",
|
||||
headers: { Authorization: session.token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId, subscriptions: [topic] }),
|
||||
signal,
|
||||
dispatcher: streamAgent,
|
||||
});
|
||||
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
||||
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
||||
try {
|
||||
if (subscribeResponse.status === 401) await this.sessionStore.clearAsync();
|
||||
if (!subscribeResponse.ok) throw new ResponseError(subscribeResponse);
|
||||
await subscribeResponse.arrayBuffer();
|
||||
} finally {
|
||||
if (subscribeResponse.body) await subscribeResponse.body.cancel().catch(() => {});
|
||||
}
|
||||
logger.debug("Subscribed to Beszel rt_metrics topic", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
@@ -461,10 +558,12 @@ export class BeszelIntegration extends Integration {
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
await closeStreamAsync();
|
||||
}
|
||||
|
||||
if (!signal.aborted) throw new Error("Beszel rt_metrics SSE stream ended unexpectedly");
|
||||
} catch (error) {
|
||||
await closeStreamAsync();
|
||||
if (signal.aborted) break;
|
||||
if (error instanceof ResponseError && error.statusCode === 401 && retryAttempt === 0) {
|
||||
retryAttempt += 1;
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.hoisted(() => {
|
||||
const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
|
||||
const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
|
||||
const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
|
||||
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createGetSetChannel: () => ({
|
||||
@@ -32,7 +33,7 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
||||
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: closeAgent }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
@@ -153,6 +154,7 @@ describe("subscribeRealtimeMetrics", () => {
|
||||
|
||||
expect(subscriptionBodies).toEqual([{ clientId: "client-1", subscriptions: [topic] }]);
|
||||
expect(events.map((event) => event.type)).toEqual(["system_stats", "container_stats"]);
|
||||
expect(closeAgent).toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
process.env.ENABLE_DNS_CACHING = "false";
|
||||
});
|
||||
|
||||
// A stateful stand-in for redis, so a session actually survives between calls and the
|
||||
// expiry logic can be exercised end to end.
|
||||
const store = vi.hoisted(() => new Map<string, { value: string; ttlSeconds?: number }>());
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createGetSetChannel: (name: string) => ({
|
||||
getAsync: () => Promise.resolve(store.get(name)?.value ?? null),
|
||||
setAsync: (value: string, options?: { ttlSeconds?: number }) => {
|
||||
store.set(name, { value, ttlSeconds: options?.ttlSeconds });
|
||||
return Promise.resolve();
|
||||
},
|
||||
removeAsync: () => {
|
||||
store.delete(name);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
||||
ErrorWithMetadata: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
|
||||
createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
|
||||
createCertificateAgentAsync: vi.fn().mockResolvedValue({ close: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { BeszelIntegration, isSessionExpired, parseTokenExpiration } from "../beszel-integration";
|
||||
|
||||
const base64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const createToken = (expiresAtSeconds: number) => `header.${base64Url({ exp: expiresAtSeconds })}.signature`;
|
||||
|
||||
describe("parseTokenExpiration", () => {
|
||||
test("reads the exp claim as epoch milliseconds", () => {
|
||||
expect(parseTokenExpiration(createToken(1_800_000_000))).toBe(1_800_000_000_000);
|
||||
});
|
||||
|
||||
test("decodes base64url payloads containing - and _", () => {
|
||||
// A payload that base64-encodes with both + and /, which must be url-decoded first.
|
||||
const token = `header.${Buffer.from(JSON.stringify({ sub: "a>b?c>>", exp: 1_700_000_000 })).toString("base64url")}.sig`;
|
||||
expect(parseTokenExpiration(token)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["not-a-jwt", "no segments"],
|
||||
["header.%%%not-base64%%%.signature", "undecodable payload"],
|
||||
[`header.${base64Url({ id: "user-1" })}.signature`, "no exp claim"],
|
||||
[`header.${base64Url({ exp: "soon" })}.signature`, "non-numeric exp"],
|
||||
])("returns null for %s (%s)", (token) => {
|
||||
expect(parseTokenExpiration(token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const session = (expiresAt?: number) => ({ token: "t", userId: "u", expiresAt });
|
||||
|
||||
describe("isSessionExpired", () => {
|
||||
const now = Date.UTC(2026, 6, 26, 12, 0, 0);
|
||||
|
||||
test("treats a session with an unknown expiry as valid, falling back to the 401 retry", () => {
|
||||
expect(isSessionExpired(session(undefined), now)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps a session that is comfortably in the future", () => {
|
||||
expect(isSessionExpired(session(now + 60 * 60 * 1000), now)).toBe(false);
|
||||
});
|
||||
|
||||
test("expires a session that lapses within the leeway window", () => {
|
||||
expect(isSessionExpired(session(now + 30_000), now)).toBe(true);
|
||||
});
|
||||
|
||||
test("expires a session that has already lapsed", () => {
|
||||
expect(isSessionExpired(session(now - 1), now)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const createIntegration = () =>
|
||||
new BeszelIntegration({
|
||||
id: "test-beszel-session",
|
||||
name: "Test Beszel",
|
||||
url: "http://localhost:8090",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{ kind: "username", value: "user@example.com" },
|
||||
{ kind: "password", value: "password" },
|
||||
],
|
||||
});
|
||||
|
||||
describe("BeszelIntegration session lifecycle", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let authCount = 0;
|
||||
let issuedToken = "";
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
authCount = 0;
|
||||
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/collections/users/auth-with-password")) {
|
||||
authCount++;
|
||||
return Promise.resolve(Response.json({ token: issuedToken, record: { id: "user-1" } }));
|
||||
}
|
||||
if (url.includes("/api/collections/systems/records")) {
|
||||
return Promise.resolve(Response.json({ page: 1, perPage: 500, totalItems: 0, totalPages: 0, items: [] }));
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("reuses a stored session while its token is still valid", async () => {
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
|
||||
const integration = createIntegration();
|
||||
|
||||
await integration.getSystemsAsync();
|
||||
await integration.getSystemsAsync();
|
||||
|
||||
expect(authCount).toBe(1);
|
||||
});
|
||||
|
||||
test("persists the session with a ttl matching the token lifetime", async () => {
|
||||
const lifetimeSeconds = 7 * 24 * 60 * 60;
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
|
||||
|
||||
await createIntegration().getSystemsAsync();
|
||||
|
||||
const stored = store.get("session-store:test-beszel-session");
|
||||
expect(stored?.ttlSeconds).toBeGreaterThan(lifetimeSeconds - 60);
|
||||
expect(stored?.ttlSeconds).toBeLessThanOrEqual(lifetimeSeconds);
|
||||
});
|
||||
|
||||
test("re-authenticates once the stored token has expired", async () => {
|
||||
// Beszel replies 200 + empty list to an expired token instead of 401, so without an expiry
|
||||
// check the stale session would be reused forever. See homarr-labs/homarr#6470.
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const lifetimeSeconds = 7 * 24 * 60 * 60;
|
||||
const issuedAt = new Date("2026-07-26T00:00:00.000Z");
|
||||
vi.setSystemTime(issuedAt);
|
||||
issuedToken = createToken(Math.floor(issuedAt.getTime() / 1000) + lifetimeSeconds);
|
||||
const integration = createIntegration();
|
||||
|
||||
await integration.getSystemsAsync();
|
||||
expect(authCount).toBe(1);
|
||||
|
||||
// Step past the token's lifetime, exactly as an idle Homarr instance would.
|
||||
vi.setSystemTime(new Date(issuedAt.getTime() + (lifetimeSeconds + 1) * 1000));
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + lifetimeSeconds);
|
||||
await integration.getSystemsAsync();
|
||||
|
||||
expect(authCount).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a short-lived token that is valid now but lapses inside the leeway window", async () => {
|
||||
// The leeway exists to stop a token expiring mid-request; it must not reject a token that is
|
||||
// still valid, or an instance with a short authToken.duration could never authenticate.
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) + 30);
|
||||
|
||||
await expect(createIntegration().getSystemsAsync()).resolves.toEqual([]);
|
||||
expect(authCount).toBe(1);
|
||||
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("fails loudly when Beszel issues an already-expired token instead of returning empty data", async () => {
|
||||
issuedToken = createToken(Math.floor(Date.now() / 1000) - 600);
|
||||
|
||||
const caught = await createIntegration()
|
||||
.getSystemsAsync()
|
||||
.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
// The integration base class wraps thrown errors, so assert against the cause chain.
|
||||
const messages: string[] = [];
|
||||
for (let error: unknown = caught; error instanceof Error; error = error.cause) messages.push(error.message);
|
||||
expect(messages.join(" | ")).toMatch(/clock skew/i);
|
||||
|
||||
// The dead token must not be persisted, or it would be served to the next caller.
|
||||
expect(store.has("session-store:test-beszel-session")).toBe(false);
|
||||
expect(authCount).toBe(1);
|
||||
});
|
||||
|
||||
test("does not store a ttl when the token carries no readable expiry", async () => {
|
||||
issuedToken = "opaque-token-without-claims";
|
||||
|
||||
await createIntegration().getSystemsAsync();
|
||||
|
||||
expect(store.get("session-store:test-beszel-session")?.ttlSeconds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
||||
const torrents = await client.listTorrents({ limit: input.limit });
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
{ down: 0, up: 0 },
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
getAlbumInfo,
|
||||
getAllAlbums,
|
||||
getMyUser,
|
||||
getServerStatistics,
|
||||
init,
|
||||
searchAssets,
|
||||
searchRandom,
|
||||
searchUsers,
|
||||
} from "@immich/sdk";
|
||||
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
|
||||
@@ -42,6 +46,7 @@ export interface ImmichAsset {
|
||||
}
|
||||
|
||||
const logger = createLogger({ module: "immich-integration" });
|
||||
const randomPhotoCount = 50;
|
||||
|
||||
export class ImmichIntegration extends Integration {
|
||||
constructor(integration: IntegrationInput) {
|
||||
@@ -62,36 +67,36 @@ export class ImmichIntegration extends Integration {
|
||||
};
|
||||
}
|
||||
|
||||
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
|
||||
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
|
||||
this.initClient();
|
||||
const requestOptions = this.getRequestOptions();
|
||||
|
||||
if (!albumId) {
|
||||
const assets = await searchRandom(
|
||||
{
|
||||
randomSearchDto: {
|
||||
size: randomPhotoCount,
|
||||
type: AssetTypeEnum.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
},
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
id: "",
|
||||
albumName: "",
|
||||
assets: await this.createAssetsAsync(assets),
|
||||
};
|
||||
}
|
||||
|
||||
const [album, albumAssets] = await Promise.all([
|
||||
getAlbumInfo({ id: albumId }, requestOptions),
|
||||
this.searchAlbumAssetsAsync(albumId),
|
||||
]);
|
||||
const imageProxy = new ImageProxy();
|
||||
return {
|
||||
albumName: album.albumName,
|
||||
assets: await Promise.all(
|
||||
albumAssets.map(async (asset) => {
|
||||
const publicLink = await imageProxy.createImageAsync(
|
||||
this.url(`/api/assets/${asset.id}/original`).toString(),
|
||||
{
|
||||
"x-api-key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
originalPath: asset.originalPath,
|
||||
updatedAt: asset.updatedAt,
|
||||
publicLink,
|
||||
};
|
||||
}),
|
||||
),
|
||||
assets: await this.createAssetsAsync(albumAssets),
|
||||
id: album.id,
|
||||
};
|
||||
}
|
||||
@@ -133,6 +138,32 @@ export class ImmichIntegration extends Integration {
|
||||
return assets;
|
||||
}
|
||||
|
||||
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
|
||||
const imageProxy = new ImageProxy();
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const publicLink = await imageProxy.createImageAsync(
|
||||
// This is the URL generated by the SDK's viewAsset call. Preview images are
|
||||
// browser-compatible while avoiding the large original asset download.
|
||||
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
|
||||
{
|
||||
"x-api-key": this.getSecretValue("apiKey"),
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
thumbhash: asset.thumbhash,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
originalPath: asset.originalPath,
|
||||
updatedAt: asset.updatedAt,
|
||||
publicLink,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
|
||||
return {
|
||||
// Undici and node types are not the same
|
||||
|
||||
@@ -41,7 +41,11 @@ export interface StreamSession {
|
||||
audioCodec: string | null;
|
||||
videoCodec: string | null;
|
||||
};
|
||||
isVideoDirect: boolean;
|
||||
isAudioDirect: boolean;
|
||||
containerChanged: boolean;
|
||||
};
|
||||
bitrateKbps: number | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
|
||||
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
|
||||
const bitrateBps =
|
||||
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
|
||||
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
|
||||
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
@@ -98,7 +101,11 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
||||
videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null,
|
||||
},
|
||||
container: sessionInfo.TranscodingInfo?.Container ?? null,
|
||||
isVideoDirect: sessionInfo.TranscodingInfo?.IsVideoDirect ?? true,
|
||||
isAudioDirect: sessionInfo.TranscodingInfo?.IsAudioDirect ?? true,
|
||||
containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream",
|
||||
},
|
||||
bitrateKbps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||
import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
|
||||
import { subsonicResponseSchema } from "./navidrome-types";
|
||||
|
||||
@@ -22,7 +24,7 @@ const subsonicAuthErrorCodes = new Set([40, 41]);
|
||||
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
|
||||
[value].flat().filter((item): item is TValue => item !== undefined);
|
||||
|
||||
export class NavidromeIntegration extends Integration {
|
||||
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const url = this.url("/rest/ping.view", this.getAuthParams());
|
||||
const response = await input.fetchAsync(url);
|
||||
@@ -53,20 +55,49 @@ export class NavidromeIntegration extends Integration {
|
||||
}
|
||||
|
||||
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
|
||||
const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
|
||||
const [artistCount, libraryCounts] = await Promise.all([
|
||||
this.getArtistCountAsync(),
|
||||
this.getAlbumAndSongCountsAsync(),
|
||||
this.getNowPlayingAsync(),
|
||||
]);
|
||||
|
||||
return {
|
||||
artistCount,
|
||||
albumCount: libraryCounts.albumCount,
|
||||
songCount: libraryCounts.songCount,
|
||||
nowPlaying,
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const nowPlaying = await this.getNowPlayingAsync();
|
||||
|
||||
return nowPlaying.map(
|
||||
(entry): StreamSession => ({
|
||||
sessionId: `${entry.username}-${entry.playerName}`,
|
||||
sessionName: entry.playerName,
|
||||
user: {
|
||||
userId: entry.username,
|
||||
username: entry.username,
|
||||
profilePictureUrl: null,
|
||||
},
|
||||
currentlyPlaying: {
|
||||
type: "audio",
|
||||
name: entry.title,
|
||||
seasonName: entry.artist || undefined,
|
||||
episodeName: null,
|
||||
albumName: entry.album,
|
||||
episodeCount: null,
|
||||
playback: {
|
||||
state: null,
|
||||
positionMs: null,
|
||||
durationMs: null,
|
||||
},
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getArtistCountAsync(): Promise<number> {
|
||||
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
|
||||
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
|
||||
|
||||
@@ -67,5 +67,4 @@ export interface NavidromeDashboardData {
|
||||
artistCount: number;
|
||||
albumCount: number;
|
||||
songCount: number;
|
||||
nowPlaying: NavidromeNowPlayingEntry[];
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
// @vitest-environment node
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
@@ -34,24 +40,24 @@ const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
|
||||
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
|
||||
});
|
||||
|
||||
const subsonicFailed = (message: string) =>
|
||||
const subsonicFailed = (message: string, code = 70) =>
|
||||
JSON.stringify({
|
||||
"subsonic-response": { status: "failed", error: { code: 70, message } },
|
||||
"subsonic-response": { status: "failed", error: { code, message } },
|
||||
});
|
||||
|
||||
const makeAlbums = (count: number, songCount: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
id: `album-${index}`,
|
||||
name: `Album ${index}`,
|
||||
songCount,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
test("paginates album list and counts songs", async () => {
|
||||
const makeAlbums = (count: number, songCount: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `album-${i}`,
|
||||
name: `Album ${i}`,
|
||||
songCount,
|
||||
}));
|
||||
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlStr = toUrlString(url);
|
||||
|
||||
@@ -84,13 +90,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
>;
|
||||
}
|
||||
|
||||
if (urlStr.includes("getNowPlaying")) {
|
||||
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
||||
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
});
|
||||
@@ -101,7 +101,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
expect(result.albumCount).toBe(700);
|
||||
expect(result.songCount).toBe(500 * 10 + 200 * 5);
|
||||
expect(result.artistCount).toBe(1);
|
||||
expect(result.nowPlaying).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty library gracefully", async () => {
|
||||
@@ -120,13 +119,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (urlStr.includes("getNowPlaying")) {
|
||||
return Promise.resolve(
|
||||
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
||||
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
});
|
||||
@@ -137,7 +130,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
expect(result.artistCount).toBe(0);
|
||||
expect(result.albumCount).toBe(0);
|
||||
expect(result.songCount).toBe(0);
|
||||
expect(result.nowPlaying).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws on non-empty-library subsonic error", async () => {
|
||||
@@ -151,8 +143,10 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
const integration = createIntegration();
|
||||
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("now playing maps entries correctly", async () => {
|
||||
describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
|
||||
test("maps now playing entries to stream sessions", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlStr = toUrlString(url);
|
||||
|
||||
@@ -161,7 +155,22 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
new Response(
|
||||
subsonicOk({
|
||||
nowPlaying: {
|
||||
entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
|
||||
entry: [
|
||||
{
|
||||
title: "Song",
|
||||
artist: "Band",
|
||||
album: "LP",
|
||||
username: "user1",
|
||||
playerName: "Chrome",
|
||||
},
|
||||
{
|
||||
title: "Second Song",
|
||||
artist: "Second Artist",
|
||||
album: "Second Album",
|
||||
username: "user2",
|
||||
playerName: "Mobile",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
@@ -169,14 +178,72 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (urlStr.includes("getArtists")) {
|
||||
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
}
|
||||
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
});
|
||||
|
||||
if (urlStr.includes("getAlbumList2")) {
|
||||
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: true });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
sessionId: "user1-Chrome",
|
||||
sessionName: "Chrome",
|
||||
user: {
|
||||
userId: "user1",
|
||||
username: "user1",
|
||||
profilePictureUrl: null,
|
||||
},
|
||||
currentlyPlaying: {
|
||||
type: "audio",
|
||||
name: "Song",
|
||||
seasonName: "Band",
|
||||
episodeName: null,
|
||||
albumName: "LP",
|
||||
episodeCount: null,
|
||||
playback: {
|
||||
state: null,
|
||||
positionMs: null,
|
||||
durationMs: null,
|
||||
},
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "user2-Mobile",
|
||||
sessionName: "Mobile",
|
||||
user: {
|
||||
userId: "user2",
|
||||
username: "user2",
|
||||
profilePictureUrl: null,
|
||||
},
|
||||
currentlyPlaying: {
|
||||
type: "audio",
|
||||
name: "Second Song",
|
||||
seasonName: "Second Artist",
|
||||
episodeName: null,
|
||||
albumName: "Second Album",
|
||||
episodeCount: null,
|
||||
playback: {
|
||||
state: null,
|
||||
positionMs: null,
|
||||
durationMs: null,
|
||||
},
|
||||
location: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns an empty array when there are no now playing entries", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlStr = toUrlString(url);
|
||||
|
||||
if (urlStr.includes("getNowPlaying")) {
|
||||
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
}
|
||||
@@ -187,15 +254,31 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getDashboardDataAsync();
|
||||
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false });
|
||||
|
||||
expect(result.nowPlaying).toHaveLength(1);
|
||||
expect(result.nowPlaying[0]).toEqual({
|
||||
title: "Song",
|
||||
artist: "Band",
|
||||
album: "LP",
|
||||
username: "user1",
|
||||
playerName: "Chrome",
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("normalizes subsonic auth failures", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlStr = toUrlString(url);
|
||||
|
||||
if (urlStr.includes("getNowPlaying")) {
|
||||
return Promise.resolve(
|
||||
new Response(subsonicFailed("Wrong username or password", 40), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
|
||||
await expect(integration.getCurrentSessionsAsync({ showOnlyPlaying: true })).rejects.toMatchObject({
|
||||
message: "An unknown error occured while executing Integration method",
|
||||
cause: { message: "Wrong username or password" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,9 +185,15 @@ export class OverseerrIntegration
|
||||
}),
|
||||
);
|
||||
|
||||
return settled
|
||||
const fulfilled = settled
|
||||
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
if (fulfilled.length === 0) {
|
||||
throw new Error("Failed to resolve any media request information");
|
||||
}
|
||||
|
||||
return fulfilled;
|
||||
}
|
||||
|
||||
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
|
||||
|
||||
@@ -43,6 +43,12 @@ function parseResolution(
|
||||
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
|
||||
}
|
||||
|
||||
function isStreamDirect(decision: string | undefined): boolean {
|
||||
// Plex reports "transcode" when a stream is re-encoded. "copy" (remuxed
|
||||
// without re-encoding) and an absent decision both count as direct.
|
||||
return decision !== "transcode";
|
||||
}
|
||||
|
||||
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const token = super.getSecretValue("apiKey");
|
||||
@@ -87,6 +93,16 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
const videoStream = streams.find((stream) => stream.$.streamType === "1");
|
||||
const audioStream = streams.find((stream) => stream.$.streamType === "2");
|
||||
|
||||
const isVideoDirect = isStreamDirect(transcodeElement?.$.videoDecision);
|
||||
const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision);
|
||||
const containerChanged = Boolean(
|
||||
transcodeElement?.$.container &&
|
||||
mediaInfoElement?.$.container &&
|
||||
transcodeElement.$.container !== mediaInfoElement.$.container,
|
||||
);
|
||||
const bitrateKbps =
|
||||
parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate);
|
||||
|
||||
return {
|
||||
sessionId: sessionElement?.$.id ?? "unknown",
|
||||
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
|
||||
@@ -124,7 +140,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
audioCodec: transcodeElement?.$.audioCodec ?? null,
|
||||
videoCodec: transcodeElement?.$.videoCodec ?? null,
|
||||
},
|
||||
isVideoDirect,
|
||||
isAudioDirect,
|
||||
containerChanged,
|
||||
},
|
||||
bitrateKbps,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user