Files
homarr/packages/integrations/src/tracearr/tracearr-integration.ts
T

221 lines
6.7 KiB
TypeScript

import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ImageProxy } from "@homarr/image-proxy";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { tracearrHealthResponseSchema } from "./tracearr-types";
import type {
TracearrDashboardData,
TracearrHealthResponse,
TracearrHistoryResponse,
TracearrStatsResponse,
TracearrStreamsResponse,
TracearrViolationsResponse,
} from "./tracearr-types";
export class TracearrIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const healthUrl = this.url("/api/v1/public/health");
const healthResponse = await input.fetchAsync(healthUrl, {
headers: this.getAuthHeaders(),
});
if (!healthResponse.ok) {
throw new ResponseError(healthResponse);
}
return { success: true };
}
/**
* GET /api/v1/public/health
* Check server connectivity and status
*/
public async getHealthAsync(): Promise<TracearrHealthResponse> {
const url = this.url("/api/v1/public/health");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return tracearrHealthResponseSchema.parse(await response.json());
}
/**
* GET /api/v1/public/stats
* Dashboard statistics with optional server filter
*/
public async getStatsAsync(serverId?: string): Promise<TracearrStatsResponse> {
const url = this.url("/api/v1/public/stats", { serverId });
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return (await response.json()) as TracearrStatsResponse;
}
/**
* GET /api/v1/public/streams
* Active playback sessions with codec and quality details
*/
public async getStreamsAsync(serverId?: string): Promise<TracearrStreamsResponse> {
const url = this.url("/api/v1/public/streams", { serverId });
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
const json = (await response.json()) as TracearrStreamsResponse;
const imageProxy = new ImageProxy();
return {
...json,
data: await Promise.all(
json.data.map(async (stream) => {
if (stream.userAvatarUrl) {
stream.userAvatarUrl = await this.proxyImageAsync(imageProxy, stream.userAvatarUrl, stream.id, "avatar");
}
if (stream.posterUrl) {
stream.posterUrl = await this.proxyImageAsync(imageProxy, stream.posterUrl, stream.id, "poster");
}
return stream;
}),
),
};
}
/**
* GET /api/v1/public/violations
* Recent rule violations with optional pagination
*/
public async getViolationsAsync(pageSize = 5): Promise<TracearrViolationsResponse> {
const url = this.url("/api/v1/public/violations", {
page: "1",
pageSize: String(pageSize),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
const json = (await response.json()) as TracearrViolationsResponse;
const imageProxy = new ImageProxy();
return {
...json,
data: await Promise.all(
json.data.map(async (violation) => {
violation.user.avatarUrl = await this.proxyImageAsync(
imageProxy,
violation.user.avatarUrl,
violation.user.id,
"avatar",
);
return violation;
}),
),
};
}
/**
* GET /api/v1/public/history
* Session history with optional pagination
*/
public async getHistoryAsync(pageSize = 10): Promise<TracearrHistoryResponse> {
const url = this.url("/api/v1/public/history", {
page: "1",
pageSize: String(pageSize),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
const json = (await response.json()) as TracearrHistoryResponse;
const imageProxy = new ImageProxy();
return {
...json,
data: await Promise.all(
json.data.map(async (session) => {
session.user.avatarUrl = await this.proxyImageAsync(
imageProxy,
session.user.avatarUrl,
session.user.id,
"avatar",
);
return session;
}),
),
};
}
/**
* Get combined dashboard data (stats + streams + violations + history)
* Uses Promise.allSettled for optional endpoints so failures don't break the dashboard.
*/
public async getDashboardDataAsync(): Promise<TracearrDashboardData> {
const [stats, streams] = await Promise.all([this.getStatsAsync(), this.getStreamsAsync()]);
const [violationsResult, historyResult] = await Promise.allSettled([
this.getViolationsAsync(),
this.getHistoryAsync(),
]);
return {
stats,
streams,
violations: violationsResult.status === "fulfilled" ? violationsResult.value : null,
recentActivity: historyResult.status === "fulfilled" ? historyResult.value : null,
};
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
private async proxyImageAsync(
imageProxy: ImageProxy,
url: string | null | undefined,
uniqueId: string,
discriminator: string,
): Promise<string | null> {
if (!url) return null;
try {
// ImageProxy doesn't support fallback urls so we need to remove them
const cleanUrl = url.replace(/&fallback=[^&]+/, "").replace(/\?fallback=[^&]+&?/, "?");
// Build the full URL, then inject _uid as the FIRST query param.
// This is critical because ImageProxy uses bcrypt which truncates at 72 bytes.
// Without this, all long avatar/poster URLs from the same session hash identically
// since they share the same first 72+ bytes.
const baseUrl = this.url(cleanUrl as `/${string}`).toString();
const prefix = `_uid=${discriminator}_${uniqueId}&`;
const fullUrl = baseUrl.includes("?") ? baseUrl.replace("?", `?${prefix}`) : `${baseUrl}?${prefix}`;
return await imageProxy.createImageAsync(fullUrl, this.getAuthHeaders());
} catch {
return null;
}
}
}