diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 0484c875c8..126ec4ad2b 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -26,4 +26,5 @@ export const widgetRouter = createTRPCRouter({ networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)), firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)), notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)), + tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), }); diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts new file mode 100644 index 0000000000..d46c80c66c --- /dev/null +++ b/packages/api/src/router/widgets/tracearr.ts @@ -0,0 +1,48 @@ +import { observable } from "@trpc/server/observable"; + +import type { TracearrDashboardData } from "@homarr/integrations/types"; +import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const tracearrRouter = createTRPCRouter({ + getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = tracearrRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }), + ); + + return results; + }), + subscribeToDashboard: publicProcedure + .concat(createManyIntegrationMiddleware("query", "tracearr")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; dashboard: TracearrDashboardData; timestamp: Date }>((emit) => { + const unsubscribes = ctx.integrations.map((integration) => { + const innerHandler = tracearrRequestHandler.handler(integration, {}); + return innerHandler.subscribe((dashboard) => { + emit.next({ + integrationId: integration.id, + dashboard, + timestamp: new Date(), + }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), +}); diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts index c7dc369138..de9ecadaf2 100644 --- a/packages/common/src/date.ts +++ b/packages/common/src/date.ts @@ -6,6 +6,17 @@ dayjs.extend(isBetween); const validUnits = ["h", "d", "w", "M", "y"] as UnitTypeShort[]; +export function formatDuration(milliseconds: number) { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + } + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + export const isDateWithin = (date: Date, relativeDate: string): boolean => { if (relativeDate.length < 2) { throw new Error("Relative date must be at least 2 characters long"); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 7d1bc2023f..5ab8021245 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -18,6 +18,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server"; import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; import { networkControllerJob } from "./jobs/integrations/network-controller"; import { refreshNotificationsJob } from "./jobs/integrations/notifications"; +import { tracearrJob } from "./jobs/integrations/tracearr"; import { minecraftServerStatusJob } from "./jobs/minecraft-server-status"; import { pingJob } from "./jobs/ping"; import { rssFeedsJob } from "./jobs/rss-feeds"; @@ -50,6 +51,7 @@ export const jobGroup = createCronJobGroup({ firewallInterfaces: firewallInterfacesJob, refreshNotifications: refreshNotificationsJob, weather: weatherJob, + tracearr: tracearrJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/tracearr.ts b/packages/cron-jobs/src/jobs/integrations/tracearr.ts new file mode 100644 index 0000000000..f80130374a --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/tracearr.ts @@ -0,0 +1,14 @@ +import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; + +import { createCronJob } from "../../lib"; + +export const tracearrJob = createCronJob("tracearr", EVERY_5_SECONDS).withCallback( + createRequestIntegrationJobHandler(tracearrRequestHandler.handler, { + widgetKinds: ["tracearr"], + getInput: { + tracearr: () => ({}), + }, + }), +); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f4edb4c171..2f64023c20 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -342,6 +342,14 @@ export const integrationDefs = { // @ts-expect-error TS2345 documentationUrl: createDocumentationLink("/docs/integrations/immich"), }, + tracearr: { + name: "Tracearr", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", + category: ["mediaMonitoring"], + // @ts-expect-error - docs page will be created when integration is merged + documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", @@ -429,6 +437,7 @@ export const integrationCategories = [ "notifications", "firewall", "photoService", + "mediaMonitoring", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 4dcfccfd9b..cf94bd3fa8 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -33,5 +33,6 @@ export const widgetKinds = [ "systemDisks", "immich-serverStats", "immich-albumCarousel", + "tracearr", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 69db53a92d..b35fa5f639 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -41,6 +41,7 @@ import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; import { QuayIntegration } from "../quay/quay-integration"; import { SeerrIntegration } from "../seerr/seerr-integration"; +import { TracearrIntegration } from "../tracearr/tracearr-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnraidIntegration } from "../unraid/unraid-integration"; @@ -111,6 +112,7 @@ export const integrationCreators = { truenas: TrueNasIntegration, unraid: UnraidIntegration, coolify: CoolifyIntegration, + tracearr: TracearrIntegration, glances: GlancesIntegration, immich: ImmichIntegration, } satisfies Record Promise]>; diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index ec6c7e4b54..22ad8b1ea1 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -58,24 +58,30 @@ export abstract class Integration { private createUrl( inputUrl: string, path: `/${string}`, - queryParams?: Record, + queryParams?: Record, ) { const baseUrl = removeTrailingSlash(inputUrl); const url = new URL(`${baseUrl}${path}`); if (queryParams) { for (const [key, value] of Object.entries(queryParams)) { + if (value === null || value === undefined) { + continue; + } url.searchParams.set(key, value instanceof Date ? value.toISOString() : value.toString()); } } return url; } - protected url(path: `/${string}`, queryParams?: Record) { + protected url(path: `/${string}`, queryParams?: Record) { return this.createUrl(this.integration.url, path, queryParams); } - protected externalUrl(path: `/${string}`, queryParams?: Record) { + protected externalUrl( + path: `/${string}`, + queryParams?: Record, + ) { return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams); } diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 6a72787d0b..3514e022b2 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -30,6 +30,7 @@ export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { ICalIntegration } from "./ical/ical-integration"; export { CoolifyIntegration } from "./coolify/coolify-integration"; export { ImmichIntegration } from "./immich/immich-integration"; +export { TracearrIntegration } from "./tracearr/tracearr-integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -56,6 +57,7 @@ export type { export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration"; +export type { TracearrDashboardData } from "./tracearr/tracearr-types"; // Schemas export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts new file mode 100644 index 0000000000..0ad7001a2f --- /dev/null +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -0,0 +1,220 @@ +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + }; + } + + private async proxyImageAsync( + imageProxy: ImageProxy, + url: string | null | undefined, + uniqueId: string, + discriminator: string, + ): Promise { + 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; + } + } +} diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts new file mode 100644 index 0000000000..099d584a94 --- /dev/null +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -0,0 +1,233 @@ +import { z } from "zod"; + +export const tracearrServerStatusSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.union([z.literal("plex"), z.literal("jellyfin"), z.literal("emby")]), + online: z.boolean(), + activeStreams: z.number(), +}); + +export type TracearrServerStatus = z.infer; + +export const tracearrHealthResponseSchema = z.object({ + status: z.literal("ok"), + timestamp: z.string(), + servers: z.array(tracearrServerStatusSchema), +}); + +export type TracearrHealthResponse = z.infer; + +export interface TracearrStatsResponse { + activeStreams: number; + totalUsers: number; + totalSessions: number; + recentViolations: number; + timestamp: string; +} + +export interface TracearrSourceVideoDetails { + bitrate?: number; + framerate?: string; + dynamicRange?: string; + aspectRatio?: number; + profile?: string; + level?: string; + colorSpace?: string; + colorDepth?: number; +} + +export interface TracearrSourceAudioDetails { + bitrate?: number; + channelLayout?: string; + language?: string; + sampleRate?: number; +} + +export interface TracearrStreamVideoDetails { + bitrate?: number; + width?: number; + height?: number; + framerate?: string; + dynamicRange?: string; +} + +export interface TracearrStreamAudioDetails { + bitrate?: number; + channels?: number; + language?: string; +} + +export interface TracearrTranscodeInfo { + containerDecision: "directplay" | "copy" | "transcode"; + sourceContainer?: string; + streamContainer?: string; + hwRequested?: boolean; + hwDecoding?: string; + hwEncoding?: string; + speed?: number; + throttled?: boolean; + reasons?: string[]; +} + +export interface TracearrSubtitleInfo { + decision?: string; + codec?: string; + language?: string; + forced?: boolean; +} + +export interface TracearrStream { + id: string; + serverId: string; + serverName: string; + username: string; + userThumb: string | null; + userAvatarUrl: string | null; + mediaTitle: string; + mediaType: "movie" | "episode" | "track" | "live" | "photo" | "unknown"; + showTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + year: number | null; + thumbPath: string | null; + posterUrl: string | null; + durationMs: number | null; + state: "playing" | "paused" | "stopped"; + progressMs: number; + startedAt: string; + isTranscode: boolean | null; + videoDecision: "directplay" | "copy" | "transcode" | null; + audioDecision: "directplay" | "copy" | "transcode" | null; + bitrate: number | null; + sourceVideoCodec: string | null; + sourceAudioCodec: string | null; + sourceAudioChannels: number | null; + sourceVideoWidth: number | null; + sourceVideoHeight: number | null; + sourceVideoDetails: TracearrSourceVideoDetails | null; + sourceAudioDetails: TracearrSourceAudioDetails | null; + streamVideoCodec: string | null; + streamAudioCodec: string | null; + streamVideoDetails: TracearrStreamVideoDetails | null; + streamAudioDetails: TracearrStreamAudioDetails | null; + transcodeInfo: TracearrTranscodeInfo | null; + subtitleInfo: TracearrSubtitleInfo | null; + resolution: string | null; + sourceVideoCodecDisplay: string | null; + sourceAudioCodecDisplay: string | null; + audioChannelsDisplay: string | null; + streamVideoCodecDisplay: string | null; + streamAudioCodecDisplay: string | null; + device: string | null; + player: string | null; + product: string | null; + platform: string | null; +} + +export interface TracearrServerStreamSummary { + serverId: string; + serverName: string; + total: number; + transcodes: number; + directStreams: number; + directPlays: number; + totalBitrate: string; +} + +export interface TracearrStreamsSummary { + total: number; + transcodes: number; + directStreams: number; + directPlays: number; + totalBitrate: string; + byServer: TracearrServerStreamSummary[]; +} + +export interface TracearrStreamsResponse { + data: TracearrStream[]; + summary: TracearrStreamsSummary; +} + +export interface TracearrViolation { + id: string; + serverId: string; + serverName: string; + severity: "low" | "medium" | "high"; + acknowledged: boolean; + data: Record; + createdAt: string; + rule: { + id: string; + type: string; + name: string; + }; + user: { + id: string; + username: string; + thumbUrl: string | null; + avatarUrl: string | null; + }; +} + +export interface TracearrViolationsResponse { + data: TracearrViolation[]; + meta: { + total: number; + page: number; + pageSize: number; + }; +} + +export interface TracearrHistorySession { + id: string; + serverId: string; + serverName: string; + state: "playing" | "paused" | "stopped"; + mediaType: "movie" | "episode" | "track" | "live" | "photo" | "unknown"; + mediaTitle: string; + showTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + year: number | null; + thumbPath: string | null; + posterUrl: string | null; + durationMs: number; + progressMs: number; + totalDurationMs: number; + startedAt: string; + stoppedAt: string | null; + watched: boolean; + segmentCount: number; + device: string | null; + player: string | null; + product: string | null; + platform: string | null; + isTranscode: boolean; + videoDecision: "directplay" | "copy" | "transcode" | null; + audioDecision: "directplay" | "copy" | "transcode" | null; + bitrate: number | null; + resolution: string | null; + user: { + id: string; + username: string; + thumbUrl: string | null; + avatarUrl: string | null; + }; +} + +export interface TracearrHistoryResponse { + data: TracearrHistorySession[]; + meta: { + total: number; + page: number; + pageSize: number; + }; +} + +export interface TracearrDashboardData { + stats: TracearrStatsResponse; + streams: TracearrStreamsResponse; + violations: TracearrViolationsResponse | null; + recentActivity: TracearrHistoryResponse | null; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index d4be3a9c22..debdd041b4 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -12,3 +12,4 @@ export * from "./unifi-controller/unifi-controller-types"; export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; export * from "./coolify/coolify-types"; +export * from "./tracearr/tracearr-types"; diff --git a/packages/request-handler/src/tracearr.ts b/packages/request-handler/src/tracearr.ts new file mode 100644 index 0000000000..8f593ac034 --- /dev/null +++ b/packages/request-handler/src/tracearr.ts @@ -0,0 +1,19 @@ +import dayjs from "dayjs"; + +import { createIntegrationAsync } from "@homarr/integrations"; +import type { TracearrDashboardData } from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const tracearrRequestHandler = createCachedIntegrationRequestHandler< + TracearrDashboardData, + "tracearr", + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getDashboardDataAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "tracearrDashboard", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 4580d847da..77d37810f0 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1996,6 +1996,62 @@ "title": "Instances" } }, + "tracearr": { + "name": "Tracearr", + "description": "Enhanced media server monitoring with detailed stream and transcoding data", + "option": { + "showStreams": { + "label": "Show active streams" + }, + "showStats": { + "label": "Show stats summary" + }, + "showRecentActivity": { + "label": "Show recent activity" + }, + "showViolations": { + "label": "Show violations" + } + }, + "stats": { + "activeStreams": "Active Streams", + "totalUsers": "Total Users", + "transcodes": "Transcodes", + "bandwidth": "Bandwidth" + }, + "streams": { + "empty": "No active streams", + "user": "User", + "media": "Media", + "state": "State", + "quality": "Quality", + "device": "Device", + "progress": "Progress", + "videoDecision": { + "directPlay": "Direct Play", + "transcode": "Transcode", + "directStream": "Direct Stream" + } + }, + + "recentActivity": { + "title": "Recent Activity", + "empty": "No recent activity", + "watched": "Watched", + "partial": "Partial" + }, + "violations": { + "title": "Violations", + "empty": "No recent violations", + "rule": "Rule", + "user": "User" + }, + "noSectionsEnabled": "No sections enabled", + "error": { + "noIntegration": "No Tracearr integration configured", + "internalServerError": "Failed to fetch Tracearr data" + } + }, "common": { "location": { "query": "City / Postal code", @@ -3518,6 +3574,9 @@ }, "weather": { "label": "Weather" + }, + "tracearr": { + "label": "Tracearr" } }, "interval": { diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index f1782a6c9a..6f5f2b03d0 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -42,6 +42,7 @@ import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as stockPrice from "./stocks"; import * as systemDisks from "./system-disks"; import * as systemResources from "./system-resources"; +import * as tracearr from "./tracearr"; import * as video from "./video"; import * as weather from "./weather"; @@ -83,6 +84,7 @@ export const widgetImports = { systemDisks, "immich-serverStats": immichServerStats, "immich-albumCarousel": immichAlbumCarousel, + tracearr, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx new file mode 100644 index 0000000000..1b26b6c67c --- /dev/null +++ b/packages/widgets/src/tracearr/component.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { ScrollArea, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import type { TracearrDashboardData } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationDataError } from "../errors/no-data-integration"; +import { RecentActivityList } from "./recent-activity-section"; +import { StatsBar } from "./stats-section"; +import { StreamsList } from "./streams-section"; +import { ViolationsList } from "./violations-section"; + +export default function TracearrWidget({ + options, + integrationIds, + width, + isEditMode, +}: WidgetComponentProps<"tracearr">) { + if (integrationIds.length === 0) { + throw new NoIntegrationDataError(); + } + + return ; +} + +interface TracearrContentProps { + integrationIds: string[]; + options: WidgetComponentProps<"tracearr">["options"]; + width: number; + isEditMode: boolean; +} + +function TracearrContent({ integrationIds, options, width, isEditMode }: TracearrContentProps) { + const t = useScopedI18n("widget.tracearr"); + const [dashboardData] = clientApi.widget.tracearr.getDashboard.useSuspenseQuery({ integrationIds }); + + const utils = clientApi.useUtils(); + clientApi.widget.tracearr.subscribeToDashboard.useSubscription( + { integrationIds }, + { + enabled: !isEditMode, + onData(newData) { + utils.widget.tracearr.getDashboard.setData({ integrationIds }, (prevData) => { + if (!prevData) return prevData; + return prevData.map((instance) => + instance.integrationId === newData.integrationId + ? { ...instance, dashboard: newData.dashboard, updatedAt: newData.timestamp } + : instance, + ); + }); + }, + }, + ); + + // Merge data from all integrations + const combined = dashboardData.reduce( + (acc, item) => { + const { stats, streams, violations, recentActivity } = item.dashboard; + const vData = violations ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } }; + const aData = recentActivity ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } }; + + return { + stats: { + activeStreams: acc.stats.activeStreams + stats.activeStreams, + totalUsers: acc.stats.totalUsers + stats.totalUsers, + totalSessions: acc.stats.totalSessions + stats.totalSessions, + recentViolations: acc.stats.recentViolations + stats.recentViolations, + timestamp: stats.timestamp, + }, + streams: { + data: [...acc.streams.data, ...streams.data], + summary: { + total: acc.streams.summary.total + streams.summary.total, + transcodes: acc.streams.summary.transcodes + streams.summary.transcodes, + directStreams: acc.streams.summary.directStreams + streams.summary.directStreams, + directPlays: acc.streams.summary.directPlays + streams.summary.directPlays, + totalBitrate: streams.summary.totalBitrate, + byServer: [...acc.streams.summary.byServer, ...streams.summary.byServer], + }, + }, + violations: { + data: [...(acc.violations?.data ?? []), ...vData.data], + meta: { + total: (acc.violations?.meta.total ?? 0) + vData.meta.total, + page: 1, + pageSize: 5, + }, + }, + recentActivity: { + data: [...(acc.recentActivity?.data ?? []), ...aData.data], + meta: { + total: (acc.recentActivity?.meta.total ?? 0) + aData.meta.total, + page: 1, + pageSize: 5, + }, + }, + }; + }, + { + stats: { activeStreams: 0, totalUsers: 0, totalSessions: 0, recentViolations: 0, timestamp: "" }, + streams: { + data: [], + summary: { total: 0, transcodes: 0, directStreams: 0, directPlays: 0, totalBitrate: "0", byServer: [] }, + }, + violations: null, + recentActivity: null, + }, + ); + + const noSectionsEnabled = + !options.showStats && !options.showStreams && !options.showRecentActivity && !options.showViolations; + + return ( + + + {options.showStats && } + {options.showStreams && } + {options.showViolations && } + {options.showRecentActivity && } + {noSectionsEnabled && ( + + {t("noSectionsEnabled")} + + )} + + + ); +} diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts new file mode 100644 index 0000000000..44936cb676 --- /dev/null +++ b/packages/widgets/src/tracearr/index.ts @@ -0,0 +1,32 @@ +import { IconActivityHeartbeat, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("tracearr", { + icon: IconActivityHeartbeat, + createOptions() { + return optionsBuilder.from((factory) => ({ + showStreams: factory.switch({ + defaultValue: true, + }), + showStats: factory.switch({ + defaultValue: true, + }), + + showRecentActivity: factory.switch({ + defaultValue: true, + }), + showViolations: factory.switch({ + defaultValue: true, + }), + })); + }, + supportedIntegrations: ["tracearr"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.tracearr.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/tracearr/recent-activity-section.tsx b/packages/widgets/src/tracearr/recent-activity-section.tsx new file mode 100644 index 0000000000..84fe35c3b1 --- /dev/null +++ b/packages/widgets/src/tracearr/recent-activity-section.tsx @@ -0,0 +1,56 @@ +import { Avatar, Badge, Group, Paper, Stack, Text } from "@mantine/core"; + +import type { TracearrHistorySession } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +export function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] }) { + const t = useScopedI18n("widget.tracearr"); + + return ( + + + {t("recentActivity.title")} + + {sessions.length === 0 ? ( + + {t("recentActivity.empty")} + + ) : ( + + {sessions.map((session) => { + const mediaLabel = + session.mediaType === "episode" && session.showTitle + ? `${session.showTitle} - S${session.seasonNumber ?? 0}E${session.episodeNumber ?? 0}` + : session.mediaTitle; + + return ( + + + + + + + {mediaLabel} + + + {session.user.username} • {session.serverName} + + + + + + {session.watched ? t("recentActivity.watched") : t("recentActivity.partial")} + + + {new Date(session.startedAt).toLocaleDateString()} + + + + + ); + })} + + )} + + ); +} diff --git a/packages/widgets/src/tracearr/stats-section.tsx b/packages/widgets/src/tracearr/stats-section.tsx new file mode 100644 index 0000000000..efa2d8b323 --- /dev/null +++ b/packages/widgets/src/tracearr/stats-section.tsx @@ -0,0 +1,49 @@ +import { Group, Paper, SimpleGrid, Stack, Text } from "@mantine/core"; +import { IconDevices, IconNetwork, IconUsers, IconVideo } from "@tabler/icons-react"; + +import type { TracearrDashboardData } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +export function StatsBar({ + stats, + summary, + width, +}: { + stats: TracearrDashboardData["stats"]; + summary: TracearrDashboardData["streams"]["summary"]; + width: number; +}) { + const t = useScopedI18n("widget.tracearr"); + const cols = width > 400 ? 4 : width > 250 ? 2 : 1; + + return ( + + } label={t("stats.activeStreams")} value={stats.activeStreams} /> + } label={t("stats.totalUsers")} value={stats.totalUsers} /> + } + label={t("stats.transcodes")} + value={`${summary.transcodes}/${summary.total}`} + /> + } label={t("stats.bandwidth")} value={summary.totalBitrate} /> + + ); +} + +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) { + return ( + + + {icon} + + + {label} + + + {value} + + + + + ); +} diff --git a/packages/widgets/src/tracearr/streams-section.tsx b/packages/widgets/src/tracearr/streams-section.tsx new file mode 100644 index 0000000000..e7908b7baf --- /dev/null +++ b/packages/widgets/src/tracearr/streams-section.tsx @@ -0,0 +1,128 @@ +import { Badge, Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"; + +import { formatDuration } from "@homarr/common"; +import type { TracearrStream } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +export function StreamsList({ streams, width }: { streams: TracearrStream[]; width: number }) { + const t = useScopedI18n("widget.tracearr"); + + if (streams.length === 0) { + return ( + + {t("streams.empty")} + + ); + } + + return ( + + {streams.map((stream) => ( + + ))} + + ); +} + +function StreamCard({ stream, compact }: { stream: TracearrStream; compact: boolean }) { + const t = useScopedI18n("widget.tracearr"); + const progressPercent = + stream.durationMs && stream.durationMs > 0 ? (stream.progressMs / stream.durationMs) * 100 : 0; + + const mediaLabel = + stream.mediaType === "episode" && stream.showTitle + ? `${stream.showTitle} - S${stream.seasonNumber ?? 0}E${stream.episodeNumber ?? 0} - ${stream.mediaTitle}` + : stream.mediaTitle; + + const videoDecisionLabel = + stream.videoDecision === "directplay" + ? t("streams.videoDecision.directPlay") + : stream.videoDecision === "transcode" + ? t("streams.videoDecision.transcode") + : stream.videoDecision === "copy" + ? t("streams.videoDecision.directStream") + : null; + + return ( + + {stream.posterUrl && ( + + )} + + + + {stream.state === "playing" ? ( + + ) : ( + + )} + + {stream.username} + + + + {stream.resolution && ( + + {stream.resolution} + + )} + {videoDecisionLabel && ( + + {videoDecisionLabel} + + )} + + + + + + {mediaLabel} + + + + {stream.durationMs && stream.durationMs > 0 && ( + + + + {formatDuration(stream.progressMs)} / {formatDuration(stream.durationMs)} + + + )} + + + {stream.device && ( + + {stream.player ?? stream.device} + + )} + + + + ); +} diff --git a/packages/widgets/src/tracearr/violations-section.tsx b/packages/widgets/src/tracearr/violations-section.tsx new file mode 100644 index 0000000000..a35c5a9330 --- /dev/null +++ b/packages/widgets/src/tracearr/violations-section.tsx @@ -0,0 +1,66 @@ +import { Avatar, Badge, Group, Paper, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import type { TracearrViolation } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +export function ViolationsList({ violations }: { violations: TracearrViolation[] }) { + const t = useScopedI18n("widget.tracearr"); + + return ( + + + {t("violations.title")} + + {violations.length === 0 ? ( + + {t("violations.empty")} + + ) : ( + + {violations.map((violation) => ( + + + + + + + + {violation.user.username} + + + {t("violations.rule")}: {violation.rule.name} + + + + + + {violation.severity} + + + {new Date(violation.createdAt).toLocaleDateString()} + + + + + ))} + + )} + + ); +}