From d10b8ada32e9aeca7f13431790c1565d47432943 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Thu, 12 Feb 2026 01:23:33 +0000 Subject: [PATCH 01/11] feat: inital integration working --- packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/tracearr.ts | 50 ++++ packages/cron-jobs/src/index.ts | 2 + .../src/jobs/integrations/tracearr.ts | 14 + packages/definitions/src/integration.ts | 9 + packages/definitions/src/widget.ts | 1 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 2 + .../src/tracearr/tracearr-integration.ts | 103 ++++++++ .../src/tracearr/tracearr-types.ts | 149 +++++++++++ packages/integrations/src/types.ts | 1 + packages/request-handler/src/tracearr.ts | 19 ++ packages/translation/src/lang/en.json | 34 +++ packages/widgets/src/index.tsx | 2 + packages/widgets/src/tracearr/component.tsx | 250 ++++++++++++++++++ packages/widgets/src/tracearr/index.ts | 25 ++ 16 files changed, 664 insertions(+) create mode 100644 packages/api/src/router/widgets/tracearr.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/tracearr.ts create mode 100644 packages/integrations/src/tracearr/tracearr-integration.ts create mode 100644 packages/integrations/src/tracearr/tracearr-types.ts create mode 100644 packages/request-handler/src/tracearr.ts create mode 100644 packages/widgets/src/tracearr/component.tsx create mode 100644 packages/widgets/src/tracearr/index.ts diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 8f2d775db0..7ff8ff40eb 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -25,4 +25,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..241657b3c2 --- /dev/null +++ b/packages/api/src/router/widgets/tracearr.ts @@ -0,0 +1,50 @@ +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/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 d97b0c31bd..0b3cfaa489 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -313,6 +313,14 @@ export const integrationDefs = { // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/coolify"), }, + 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", @@ -399,6 +407,7 @@ export const integrationCategories = [ "releasesProvider", "notifications", "firewall", + "mediaMonitoring", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 8d5b657d2a..77337672c5 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -31,5 +31,6 @@ export const widgetKinds = [ "systemResources", "coolify", "systemDisks", + "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 d1ad60ad06..7c3ad4993a 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -37,6 +37,7 @@ import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; import { QuayIntegration } from "../quay/quay-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"; @@ -105,6 +106,7 @@ export const integrationCreators = { truenas: TrueNasIntegration, unraid: UnraidIntegration, coolify: CoolifyIntegration, + tracearr: TracearrIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index a2f1287df8..08602940f6 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -26,6 +26,7 @@ export { UnraidIntegration } from "./unraid/unraid-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { ICalIntegration } from "./ical/ical-integration"; export { CoolifyIntegration } from "./coolify/coolify-integration"; +export { TracearrIntegration } from "./tracearr/tracearr-integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -51,6 +52,7 @@ export type { } from "./interfaces/media-transcoding/media-transcoding-types"; export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; +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..740834249a --- /dev/null +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -0,0 +1,103 @@ +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { + TracearrDashboardData, + TracearrHealthResponse, + TracearrStatsResponse, + TracearrStreamsResponse, +} 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 (await response.json()) as TracearrHealthResponse; + } + + /** + * GET /api/v1/public/stats + * Dashboard statistics with optional server filter + */ + public async getStatsAsync(serverId?: string): Promise { + const queryParams: Record = {}; + if (serverId) { + queryParams.serverId = serverId; + } + + const url = this.url("/api/v1/public/stats", queryParams); + 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 queryParams: Record = {}; + if (serverId) { + queryParams.serverId = serverId; + } + + const url = this.url("/api/v1/public/streams", queryParams); + const response = await fetchWithTrustedCertificatesAsync(url, { + headers: this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return (await response.json()) as TracearrStreamsResponse; + } + + /** + * Get combined dashboard data (stats + streams) + */ + public async getDashboardDataAsync(): Promise { + const [stats, streams] = await Promise.all([this.getStatsAsync(), this.getStreamsAsync()]); + + return { stats, streams }; + } + + private getAuthHeaders(): Record { + return { + Authorization: `Bearer ${this.getSecretValue("apiKey")}`, + }; + } +} diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts new file mode 100644 index 0000000000..98628a9a89 --- /dev/null +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -0,0 +1,149 @@ +export interface TracearrServerStatus { + id: string; + name: string; + type: "plex" | "jellyfin" | "emby"; + online: boolean; + activeStreams: number; +} + +export interface TracearrHealthResponse { + status: "ok"; + timestamp: string; + servers: TracearrServerStatus[]; +} + +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 TracearrDashboardData { + stats: TracearrStatsResponse; + streams: TracearrStreamsResponse; +} 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 ef354baaf3..8fb113a8e9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1996,6 +1996,37 @@ "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" + } + }, + "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" + }, + "error": { + "noIntegration": "No Tracearr integration configured", + "internalServerError": "Failed to fetch Tracearr data" + } + }, "common": { "location": { "query": "City / Postal code", @@ -3472,6 +3503,9 @@ }, "weather": { "label": "Weather" + }, + "tracearr": { + "label": "Tracearr" } }, "interval": { diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index ec075d4670..22a1c14c23 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -40,6 +40,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"; @@ -79,6 +80,7 @@ export const widgetImports = { systemResources, coolify, systemDisks, + 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..6ffc9e7d19 --- /dev/null +++ b/packages/widgets/src/tracearr/component.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { Badge, Group, Paper, Progress, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { IconDevices, IconNetwork, IconPlayerPause, IconPlayerPlay, IconUsers, IconVideo } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { TracearrDashboardData, TracearrStream } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +export default function TracearrWidget({ options, integrationIds }: WidgetComponentProps<"tracearr">) { + const t = useScopedI18n("widget.tracearr"); + + if (integrationIds.length === 0) { + return ( + + {t("error.noIntegration")} + + ); + } + + return ; +} + +interface TracearrContentProps { + integrationIds: string[]; + options: WidgetComponentProps<"tracearr">["options"]; +} + +function TracearrContent({ integrationIds, options }: TracearrContentProps) { + const [dashboardData] = clientApi.widget.tracearr.getDashboard.useSuspenseQuery({ integrationIds }); + + const utils = clientApi.useUtils(); + clientApi.widget.tracearr.subscribeToDashboard.useSubscription( + { integrationIds }, + { + 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) => ({ + stats: { + activeStreams: acc.stats.activeStreams + item.dashboard.stats.activeStreams, + totalUsers: acc.stats.totalUsers + item.dashboard.stats.totalUsers, + totalSessions: acc.stats.totalSessions + item.dashboard.stats.totalSessions, + recentViolations: acc.stats.recentViolations + item.dashboard.stats.recentViolations, + timestamp: item.dashboard.stats.timestamp, + }, + streams: { + data: [...acc.streams.data, ...item.dashboard.streams.data], + summary: { + total: acc.streams.summary.total + item.dashboard.streams.summary.total, + transcodes: acc.streams.summary.transcodes + item.dashboard.streams.summary.transcodes, + directStreams: acc.streams.summary.directStreams + item.dashboard.streams.summary.directStreams, + directPlays: acc.streams.summary.directPlays + item.dashboard.streams.summary.directPlays, + totalBitrate: item.dashboard.streams.summary.totalBitrate, + byServer: [...acc.streams.summary.byServer, ...item.dashboard.streams.summary.byServer], + }, + }, + }), + { + stats: { activeStreams: 0, totalUsers: 0, totalSessions: 0, recentViolations: 0, timestamp: "" }, + streams: { + data: [], + summary: { total: 0, transcodes: 0, directStreams: 0, directPlays: 0, totalBitrate: "0", byServer: [] }, + }, + }, + ); + + return ( + + + {options.showStats && } + {options.showStreams && } + {!options.showStats && !options.showStreams && ( + + No sections enabled + + )} + + + ); +} + +function StatsBar({ + stats, + summary, +}: { + stats: TracearrDashboardData["stats"]; + summary: TracearrDashboardData["streams"]["summary"]; +}) { + const t = useScopedI18n("widget.tracearr"); + + 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} + + + + + ); +} + +function StreamsList({ streams }: { streams: TracearrStream[] }) { + const t = useScopedI18n("widget.tracearr"); + + if (streams.length === 0) { + return ( + + {t("streams.empty")} + + ); + } + + return ( + + {streams.map((stream) => ( + + ))} + + ); +} + +function StreamCard({ stream }: { stream: TracearrStream }) { + const progressPercent = + stream.durationMs && stream.durationMs > 0 ? (stream.progressMs / stream.durationMs) * 100 : 0; + + const 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")}`; + }; + + 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" + ? "Direct Play" + : stream.videoDecision === "transcode" + ? "Transcode" + : stream.videoDecision === "copy" + ? "Direct Stream" + : null; + + return ( + + + + + {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/index.ts b/packages/widgets/src/tracearr/index.ts new file mode 100644 index 0000000000..af76cc9999 --- /dev/null +++ b/packages/widgets/src/tracearr/index.ts @@ -0,0 +1,25 @@ +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, + }), + })); + }, + supportedIntegrations: ["tracearr"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.tracearr.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); From 77c3ae4f3fd60de9d126d4d21fe54f66130b971a Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Thu, 12 Feb 2026 20:59:52 +0000 Subject: [PATCH 02/11] feat: activitty and violations --- .../src/tracearr/tracearr-integration.ts | 82 +++++- .../src/tracearr/tracearr-types.ts | 69 +++++ packages/translation/src/lang/en.json | 18 ++ packages/widgets/src/tracearr/component.tsx | 241 ++++++++++++++---- packages/widgets/src/tracearr/index.ts | 7 + 5 files changed, 369 insertions(+), 48 deletions(-) diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index 740834249a..2f1445cc57 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -1,5 +1,6 @@ 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"; @@ -7,8 +8,10 @@ import type { TestingResult } from "../base/test-connection/test-connection-serv import type { TracearrDashboardData, TracearrHealthResponse, + TracearrHistoryResponse, TracearrStatsResponse, TracearrStreamsResponse, + TracearrViolationsResponse, } from "./tracearr-types"; export class TracearrIntegration extends Integration { @@ -87,12 +90,87 @@ export class TracearrIntegration extends Integration { } /** - * Get combined dashboard data (stats + streams) + * 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(); + return json as TracearrViolationsResponse; + } + + /** + * 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(); + + await Promise.all( + json.data.map(async (session) => { + if (session.user.avatarUrl) { + try { + session.user = { ...session.user }; + const cleanUrl = session.user.avatarUrl?.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 URLs hash identically since they only differ after byte ~90. + const baseUrl = this.url(cleanUrl as `/${string}`).toString(); + const fullUrl = baseUrl.replace("?", `?_uid=${session.user.id}&`); + + session.user.avatarUrl = await imageProxy.createImageAsync(fullUrl, this.getAuthHeaders()); + } catch { + session.user.avatarUrl = null; + } + } + }), + ); + + return json; + } + + /** + * 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()]); - return { stats, streams }; + const [violationsResult, historyResult] = await Promise.allSettled([ + this.getViolationsAsync(), + this.getHistoryAsync(), + ]); + + return { + stats, + streams, + violations: violationsResult.status === "fulfilled" ? violationsResult.value : undefined, + recentActivity: historyResult.status === "fulfilled" ? historyResult.value : undefined, + }; } private getAuthHeaders(): Record { diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts index 98628a9a89..fb6a64540d 100644 --- a/packages/integrations/src/tracearr/tracearr-types.ts +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -143,7 +143,76 @@ export interface TracearrStreamsResponse { summary: TracearrStreamsSummary; } +export interface TracearrViolation { + id: string; + userId: string; + username: string; + ruleName: string; + mediaTitle: string; + serverId: string; + serverName: string; + detectedAt: string; + resolved: boolean; +} + +export interface TracearrViolationsResponse { + data: TracearrViolation[]; + meta: { + total: number; + page: number; + pageSize: number; + }; +} + +export interface TracearrHistorySession { + id: string; + serverId: string; + serverName: string; + state: string; + mediaType: string; + mediaTitle: string; + showTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + year: number | null; + thumbPath: string | null; + posterUrl: string | null; + durationMs: number; + progressMs: string | number; + totalDurationMs: string | 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: string | null; + audioDecision: string | 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; + recentActivity?: TracearrHistoryResponse; } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8fb113a8e9..ba741cf47e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2005,6 +2005,12 @@ }, "showStats": { "label": "Show stats summary" + }, + "showRecentActivity": { + "label": "Show recent activity" + }, + "showViolations": { + "label": "Show violations" } }, "stats": { @@ -2022,6 +2028,18 @@ "device": "Device", "progress": "Progress" }, + + "recentActivity": { + "title": "Recent Activity", + "empty": "No recent activity" + }, + "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" diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 6ffc9e7d19..57db6c5aaf 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -1,15 +1,33 @@ "use client"; -import { Badge, Group, Paper, Progress, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; -import { IconDevices, IconNetwork, IconPlayerPause, IconPlayerPlay, IconUsers, IconVideo } from "@tabler/icons-react"; +import { Avatar, Badge, Group, Paper, Progress, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { + IconAlertTriangle, + IconDevices, + IconNetwork, + IconPlayerPause, + IconPlayerPlay, + IconUsers, + IconVideo, +} from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import type { TracearrDashboardData, TracearrStream } from "@homarr/integrations/types"; +import type { + TracearrDashboardData, + TracearrHistorySession, + TracearrStream, + TracearrViolation, +} from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -export default function TracearrWidget({ options, integrationIds }: WidgetComponentProps<"tracearr">) { +export default function TracearrWidget({ + options, + integrationIds, + width, + isEditMode, +}: WidgetComponentProps<"tracearr">) { const t = useScopedI18n("widget.tracearr"); if (integrationIds.length === 0) { @@ -20,21 +38,25 @@ export default function TracearrWidget({ options, integrationIds }: WidgetCompon ); } - return ; + return ; } interface TracearrContentProps { integrationIds: string[]; options: WidgetComponentProps<"tracearr">["options"]; + width: number; + isEditMode: boolean; } -function TracearrContent({ integrationIds, options }: TracearrContentProps) { +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; @@ -50,26 +72,48 @@ function TracearrContent({ integrationIds, options }: TracearrContentProps) { // Merge data from all integrations const combined = dashboardData.reduce( - (acc, item) => ({ - stats: { - activeStreams: acc.stats.activeStreams + item.dashboard.stats.activeStreams, - totalUsers: acc.stats.totalUsers + item.dashboard.stats.totalUsers, - totalSessions: acc.stats.totalSessions + item.dashboard.stats.totalSessions, - recentViolations: acc.stats.recentViolations + item.dashboard.stats.recentViolations, - timestamp: item.dashboard.stats.timestamp, - }, - streams: { - data: [...acc.streams.data, ...item.dashboard.streams.data], - summary: { - total: acc.streams.summary.total + item.dashboard.streams.summary.total, - transcodes: acc.streams.summary.transcodes + item.dashboard.streams.summary.transcodes, - directStreams: acc.streams.summary.directStreams + item.dashboard.streams.summary.directStreams, - directPlays: acc.streams.summary.directPlays + item.dashboard.streams.summary.directPlays, - totalBitrate: item.dashboard.streams.summary.totalBitrate, - byServer: [...acc.streams.summary.byServer, ...item.dashboard.streams.summary.byServer], + (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: { @@ -79,14 +123,19 @@ function TracearrContent({ integrationIds, options }: TracearrContentProps) { }, ); + const noSectionsEnabled = + !options.showStats && !options.showStreams && !options.showRecentActivity && !options.showViolations; + return ( - {options.showStats && } - {options.showStreams && } - {!options.showStats && !options.showStreams && ( + {options.showStats && } + {options.showStreams && } + {options.showViolations && } + {options.showRecentActivity && } + {noSectionsEnabled && ( - No sections enabled + {t("noSectionsEnabled")} )} @@ -94,17 +143,22 @@ function TracearrContent({ integrationIds, options }: TracearrContentProps) { ); } +// --- Stats Section --- + 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} /> {icon} - + {label} @@ -135,7 +189,9 @@ function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string ); } -function StreamsList({ streams }: { streams: TracearrStream[] }) { +// --- Streams Section --- + +function StreamsList({ streams, width }: { streams: TracearrStream[]; width: number }) { const t = useScopedI18n("widget.tracearr"); if (streams.length === 0) { @@ -149,27 +205,16 @@ function StreamsList({ streams }: { streams: TracearrStream[] }) { return ( {streams.map((stream) => ( - + ))} ); } -function StreamCard({ stream }: { stream: TracearrStream }) { +function StreamCard({ stream, compact }: { stream: TracearrStream; compact: boolean }) { const progressPercent = stream.durationMs && stream.durationMs > 0 ? (stream.progressMs / stream.durationMs) * 100 : 0; - const 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")}`; - }; - const mediaLabel = stream.mediaType === "episode" && stream.showTitle ? `${stream.showTitle} - S${stream.seasonNumber ?? 0}E${stream.episodeNumber ?? 0} - ${stream.mediaTitle}` @@ -198,7 +243,7 @@ function StreamCard({ stream }: { stream: TracearrStream }) { {stream.username} - + {stream.resolution && ( {stream.resolution} @@ -248,3 +293,107 @@ function StreamCard({ stream }: { stream: TracearrStream }) { ); } + +// --- Violations Section --- + +function ViolationsList({ violations }: { violations: TracearrViolation[] }) { + const t = useScopedI18n("widget.tracearr"); + + return ( + + + {t("violations.title")} + + {violations.length === 0 ? ( + + {t("violations.empty")} + + ) : ( + + {violations.map((violation) => ( + + + + + + + {violation.mediaTitle} + + + {t("violations.rule")}: {violation.ruleName} + + + + + {violation.username} + + + + ))} + + )} + + ); +} + +// --- Recent Activity Section --- + +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 ? "Watched" : "Partial"} + + + + ); + })} + + )} + + ); +} + +// --- Utility --- + +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")}`; +} diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index af76cc9999..44936cb676 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -13,6 +13,13 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr" showStats: factory.switch({ defaultValue: true, }), + + showRecentActivity: factory.switch({ + defaultValue: true, + }), + showViolations: factory.switch({ + defaultValue: true, + }), })); }, supportedIntegrations: ["tracearr"], From 42ed6ea4ad4f0ec966ccb7fc618e2ce4d5ce8f41 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Thu, 12 Feb 2026 21:23:22 +0000 Subject: [PATCH 03/11] feat better live view --- .../src/tracearr/tracearr-integration.ts | 59 ++++++++++++++----- packages/widgets/src/tracearr/component.tsx | 49 ++++++++++++--- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index 2f1445cc57..75860d7f26 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -86,7 +86,21 @@ export class TracearrIntegration extends Integration { throw new ResponseError(response); } - return (await response.json()) as TracearrStreamsResponse; + const json = (await response.json()) as TracearrStreamsResponse; + const imageProxy = new ImageProxy(); + + 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 json; } /** @@ -133,19 +147,13 @@ export class TracearrIntegration extends Integration { await Promise.all( json.data.map(async (session) => { if (session.user.avatarUrl) { - try { - session.user = { ...session.user }; - const cleanUrl = session.user.avatarUrl?.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 URLs hash identically since they only differ after byte ~90. - const baseUrl = this.url(cleanUrl as `/${string}`).toString(); - const fullUrl = baseUrl.replace("?", `?_uid=${session.user.id}&`); - - session.user.avatarUrl = await imageProxy.createImageAsync(fullUrl, this.getAuthHeaders()); - } catch { - session.user.avatarUrl = null; - } + session.user = { ...session.user }; + session.user.avatarUrl = await this.proxyImageAsync( + imageProxy, + session.user.avatarUrl, + session.user.id, + "avatar", + ); } }), ); @@ -178,4 +186,27 @@ export class TracearrIntegration extends Integration { Authorization: `Bearer ${this.getSecretValue("apiKey")}`, }; } + + private async proxyImageAsync( + imageProxy: ImageProxy, + url: string | null | undefined, + uniqueId: string, + discriminator: string, + ): Promise { + if (!url) return null; + try { + 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/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 57db6c5aaf..971a1fa3a2 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -1,6 +1,18 @@ "use client"; -import { Avatar, Badge, Group, Paper, Progress, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { + Avatar, + Badge, + Box, + Group, + Paper, + Progress, + ScrollArea, + SimpleGrid, + Stack, + Text, + Tooltip, +} from "@mantine/core"; import { IconAlertTriangle, IconDevices, @@ -230,8 +242,26 @@ function StreamCard({ stream, compact }: { stream: TracearrStream; compact: bool : null; return ( - - + + {stream.posterUrl && ( + + )} + {stream.state === "playing" ? ( @@ -359,7 +389,7 @@ function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] } : session.mediaTitle; return ( - + @@ -372,9 +402,14 @@ function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] } - - {session.watched ? "Watched" : "Partial"} - + + + {session.watched ? "Watched" : "Partial"} + + + {new Date(session.startedAt).toLocaleDateString()} + + ); From 615f27d032d677c688133d2a7297dc98e934d506 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Thu, 12 Feb 2026 21:49:48 +0000 Subject: [PATCH 04/11] feat: violations simple --- .../src/tracearr/tracearr-integration.ts | 20 +++++++++-- .../src/tracearr/tracearr-types.ts | 21 ++++++++---- packages/widgets/src/tracearr/component.tsx | 33 +++++++++++++++---- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index 75860d7f26..d17047dae1 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -120,8 +120,24 @@ export class TracearrIntegration extends Integration { throw new ResponseError(response); } - const json = await response.json(); - return json as TracearrViolationsResponse; + const json = (await response.json()) as TracearrViolationsResponse; + const imageProxy = new ImageProxy(); + + await Promise.all( + json.data.map(async (violation) => { + if (violation.user.avatarUrl) { + violation.user = { ...violation.user }; + violation.user.avatarUrl = await this.proxyImageAsync( + imageProxy, + violation.user.avatarUrl, + violation.user.id, + "avatar", + ); + } + }), + ); + + return json; } /** diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts index fb6a64540d..f6e39f547f 100644 --- a/packages/integrations/src/tracearr/tracearr-types.ts +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -145,14 +145,23 @@ export interface TracearrStreamsResponse { export interface TracearrViolation { id: string; - userId: string; - username: string; - ruleName: string; - mediaTitle: string; serverId: string; serverName: string; - detectedAt: string; - resolved: boolean; + 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 { diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 971a1fa3a2..e7de9364d7 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -344,19 +344,40 @@ function ViolationsList({ violations }: { violations: TracearrViolation[] }) { - + + - {violation.mediaTitle} + {violation.user.username} - {t("violations.rule")}: {violation.ruleName} + {t("violations.rule")}: {violation.rule.name} - - {violation.username} - + + + {violation.severity} + + + {new Date(violation.createdAt).toLocaleDateString()} + + ))} From ebfad85b26d6a918a57fc5651405ac40f4542b79 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Sat, 14 Feb 2026 11:35:20 +0000 Subject: [PATCH 05/11] fix: final changes and cleanup --- packages/definitions/src/integration.ts | 1 + packages/integrations/src/tracearr/tracearr-types.ts | 12 ++++++------ packages/translation/src/lang/en.json | 11 +++++++++-- packages/widgets/src/tracearr/component.tsx | 9 +++++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 0b3cfaa489..834bed2162 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -332,6 +332,7 @@ export const integrationDefs = { "downloadClient", "healthMonitoring", "indexerManager", + "mediaMonitoring", "mediaRelease", "mediaRequest", "mediaService", diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts index f6e39f547f..5100b0403f 100644 --- a/packages/integrations/src/tracearr/tracearr-types.ts +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -177,8 +177,8 @@ export interface TracearrHistorySession { id: string; serverId: string; serverName: string; - state: string; - mediaType: string; + state: "playing" | "paused" | "stopped"; + mediaType: "movie" | "episode" | "track" | "live" | "photo" | "unknown"; mediaTitle: string; showTitle: string | null; seasonNumber: number | null; @@ -187,8 +187,8 @@ export interface TracearrHistorySession { thumbPath: string | null; posterUrl: string | null; durationMs: number; - progressMs: string | number; - totalDurationMs: string | number; + progressMs: number; + totalDurationMs: number; startedAt: string; stoppedAt: string | null; watched: boolean; @@ -198,8 +198,8 @@ export interface TracearrHistorySession { product: string | null; platform: string | null; isTranscode: boolean; - videoDecision: string | null; - audioDecision: string | null; + videoDecision: "directplay" | "copy" | "transcode" | null; + audioDecision: "directplay" | "copy" | "transcode" | null; bitrate: number | null; resolution: string | null; user: { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ba741cf47e..dee4757649 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2026,12 +2026,19 @@ "state": "State", "quality": "Quality", "device": "Device", - "progress": "Progress" + "progress": "Progress", + "videoDecision": { + "directPlay": "Direct Play", + "transcode": "Transcode", + "directStream": "Direct Stream" + } }, "recentActivity": { "title": "Recent Activity", - "empty": "No recent activity" + "empty": "No recent activity", + "watched": "Watched", + "partial": "Partial" }, "violations": { "title": "Violations", diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index e7de9364d7..916a555919 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -224,6 +224,7 @@ function StreamsList({ streams, width }: { streams: TracearrStream[]; width: num } 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; @@ -234,11 +235,11 @@ function StreamCard({ stream, compact }: { stream: TracearrStream; compact: bool const videoDecisionLabel = stream.videoDecision === "directplay" - ? "Direct Play" + ? t("streams.videoDecision.directPlay") : stream.videoDecision === "transcode" - ? "Transcode" + ? t("streams.videoDecision.transcode") : stream.videoDecision === "copy" - ? "Direct Stream" + ? t("streams.videoDecision.directStream") : null; return ( @@ -425,7 +426,7 @@ function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] } - {session.watched ? "Watched" : "Partial"} + {session.watched ? t("recentActivity.watched") : t("recentActivity.partial")} {new Date(session.startedAt).toLocaleDateString()} From 101837a7cf9b3ea8c73d62a7dd9006fe1c463f75 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Sat, 14 Feb 2026 11:43:05 +0000 Subject: [PATCH 06/11] fix: prettier --- packages/api/src/router/widgets/tracearr.ts | 34 ++++++++++----------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index 241657b3c2..d46c80c66c 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -7,26 +7,24 @@ 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 }); + 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 { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }), + ); - return results; - }), + return results; + }), subscribeToDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "tracearr")) .subscription(({ ctx }) => { From 061922fb9f3ceba1dbefc6549406cb812a78c2d7 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Wed, 18 Feb 2026 21:37:19 +0000 Subject: [PATCH 07/11] fix: PR suggestions --- packages/common/src/date.ts | 11 + packages/widgets/src/tracearr/component.tsx | 343 +----------------- .../src/tracearr/recent-activity-section.tsx | 56 +++ .../widgets/src/tracearr/stats-section.tsx | 49 +++ .../widgets/src/tracearr/streams-section.tsx | 128 +++++++ .../src/tracearr/violations-section.tsx | 66 ++++ 6 files changed, 318 insertions(+), 335 deletions(-) create mode 100644 packages/widgets/src/tracearr/recent-activity-section.tsx create mode 100644 packages/widgets/src/tracearr/stats-section.tsx create mode 100644 packages/widgets/src/tracearr/streams-section.tsx create mode 100644 packages/widgets/src/tracearr/violations-section.tsx 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/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 916a555919..d521b8fe5a 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -1,38 +1,17 @@ "use client"; -import { - Avatar, - Badge, - Box, - Group, - Paper, - Progress, - ScrollArea, - SimpleGrid, - Stack, - Text, - Tooltip, -} from "@mantine/core"; -import { - IconAlertTriangle, - IconDevices, - IconNetwork, - IconPlayerPause, - IconPlayerPlay, - IconUsers, - IconVideo, -} from "@tabler/icons-react"; +import { ScrollArea, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import type { - TracearrDashboardData, - TracearrHistorySession, - TracearrStream, - TracearrViolation, -} from "@homarr/integrations/types"; +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, @@ -40,14 +19,8 @@ export default function TracearrWidget({ width, isEditMode, }: WidgetComponentProps<"tracearr">) { - const t = useScopedI18n("widget.tracearr"); - if (integrationIds.length === 0) { - return ( - - {t("error.noIntegration")} - - ); + throw new NoIntegrationDataError(); } return ; @@ -154,303 +127,3 @@ function TracearrContent({ integrationIds, options, width, isEditMode }: Tracear ); } - -// --- Stats Section --- - -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} - - - - - ); -} - -// --- Streams Section --- - -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} - - )} - - - - ); -} - -// --- Violations Section --- - -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()} - - - - - ))} - - )} - - ); -} - -// --- Recent Activity Section --- - -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()} - - - - - ); - })} - - )} - - ); -} - -// --- Utility --- - -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")}`; -} 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()} + + + + + ))} + + )} + + ); +} From f0ea20d8aa8d900a9a9784f69ca12917ab1978ae Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Thu, 19 Feb 2026 23:29:00 +0000 Subject: [PATCH 08/11] feat: fix cmments from PR --- packages/integrations/src/base/integration.ts | 12 +++- .../src/tracearr/tracearr-integration.ts | 67 ++++++++++--------- .../src/tracearr/tracearr-types.ts | 4 +- packages/widgets/src/tracearr/component.tsx | 2 + 4 files changed, 47 insertions(+), 38 deletions(-) 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/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index d17047dae1..2e0c0daa10 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { ImageProxy } from "@homarr/image-proxy"; @@ -14,6 +16,20 @@ import type { TracearrViolationsResponse, } from "./tracearr-types"; +const TracearrHealthResponseSchema = z.object({ + status: z.literal("ok"), + timestamp: z.string(), + servers: z.array( + 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 class TracearrIntegration extends Integration { protected async testingAsync(input: IntegrationTestingInput): Promise { const healthUrl = this.url("/api/v1/public/health"); @@ -42,7 +58,7 @@ export class TracearrIntegration extends Integration { throw new ResponseError(response); } - return (await response.json()) as TracearrHealthResponse; + return TracearrHealthResponseSchema.parse(await response.json()) as TracearrHealthResponse; } /** @@ -50,12 +66,7 @@ export class TracearrIntegration extends Integration { * Dashboard statistics with optional server filter */ public async getStatsAsync(serverId?: string): Promise { - const queryParams: Record = {}; - if (serverId) { - queryParams.serverId = serverId; - } - - const url = this.url("/api/v1/public/stats", queryParams); + const url = this.url("/api/v1/public/stats", { serverId }); const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getAuthHeaders(), }); @@ -72,12 +83,7 @@ export class TracearrIntegration extends Integration { * Active playback sessions with codec and quality details */ public async getStreamsAsync(serverId?: string): Promise { - const queryParams: Record = {}; - if (serverId) { - queryParams.serverId = serverId; - } - - const url = this.url("/api/v1/public/streams", queryParams); + const url = this.url("/api/v1/public/streams", { serverId }); const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getAuthHeaders(), }); @@ -125,15 +131,12 @@ export class TracearrIntegration extends Integration { await Promise.all( json.data.map(async (violation) => { - if (violation.user.avatarUrl) { - violation.user = { ...violation.user }; - violation.user.avatarUrl = await this.proxyImageAsync( - imageProxy, - violation.user.avatarUrl, - violation.user.id, - "avatar", - ); - } + violation.user.avatarUrl = await this.proxyImageAsync( + imageProxy, + violation.user.avatarUrl, + violation.user.id, + "avatar", + ); }), ); @@ -162,15 +165,12 @@ export class TracearrIntegration extends Integration { await Promise.all( json.data.map(async (session) => { - if (session.user.avatarUrl) { - session.user = { ...session.user }; - session.user.avatarUrl = await this.proxyImageAsync( - imageProxy, - session.user.avatarUrl, - session.user.id, - "avatar", - ); - } + session.user.avatarUrl = await this.proxyImageAsync( + imageProxy, + session.user.avatarUrl, + session.user.id, + "avatar", + ); }), ); @@ -192,8 +192,8 @@ export class TracearrIntegration extends Integration { return { stats, streams, - violations: violationsResult.status === "fulfilled" ? violationsResult.value : undefined, - recentActivity: historyResult.status === "fulfilled" ? historyResult.value : undefined, + violations: violationsResult.status === "fulfilled" ? violationsResult.value : null, + recentActivity: historyResult.status === "fulfilled" ? historyResult.value : null, }; } @@ -211,6 +211,7 @@ export class TracearrIntegration extends Integration { ): 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. diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts index 5100b0403f..c805f8571d 100644 --- a/packages/integrations/src/tracearr/tracearr-types.ts +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -222,6 +222,6 @@ export interface TracearrHistoryResponse { export interface TracearrDashboardData { stats: TracearrStatsResponse; streams: TracearrStreamsResponse; - violations?: TracearrViolationsResponse; - recentActivity?: TracearrHistoryResponse; + violations: TracearrViolationsResponse | null; + recentActivity: TracearrHistoryResponse | null; } diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index d521b8fe5a..1b26b6c67c 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -105,6 +105,8 @@ function TracearrContent({ integrationIds, options, width, isEditMode }: Tracear data: [], summary: { total: 0, transcodes: 0, directStreams: 0, directPlays: 0, totalBitrate: "0", byServer: [] }, }, + violations: null, + recentActivity: null, }, ); From b178fe0d1c1d12cb9e6ec415dba49b21b6be27d9 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Fri, 27 Feb 2026 22:18:09 +0000 Subject: [PATCH 09/11] fix: pr suggestions --- .../src/tracearr/tracearr-integration.ts | 97 +++++++++---------- .../src/tracearr/tracearr-types.ts | 30 +++--- 2 files changed, 62 insertions(+), 65 deletions(-) diff --git a/packages/integrations/src/tracearr/tracearr-integration.ts b/packages/integrations/src/tracearr/tracearr-integration.ts index 2e0c0daa10..0ad7001a2f 100644 --- a/packages/integrations/src/tracearr/tracearr-integration.ts +++ b/packages/integrations/src/tracearr/tracearr-integration.ts @@ -1,5 +1,3 @@ -import { z } from "zod"; - import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { ImageProxy } from "@homarr/image-proxy"; @@ -7,6 +5,7 @@ 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, @@ -16,20 +15,6 @@ import type { TracearrViolationsResponse, } from "./tracearr-types"; -const TracearrHealthResponseSchema = z.object({ - status: z.literal("ok"), - timestamp: z.string(), - servers: z.array( - 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 class TracearrIntegration extends Integration { protected async testingAsync(input: IntegrationTestingInput): Promise { const healthUrl = this.url("/api/v1/public/health"); @@ -58,7 +43,7 @@ export class TracearrIntegration extends Integration { throw new ResponseError(response); } - return TracearrHealthResponseSchema.parse(await response.json()) as TracearrHealthResponse; + return tracearrHealthResponseSchema.parse(await response.json()); } /** @@ -95,18 +80,20 @@ export class TracearrIntegration extends Integration { const json = (await response.json()) as TracearrStreamsResponse; const imageProxy = new ImageProxy(); - 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 json; + 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; + }), + ), + }; } /** @@ -129,18 +116,20 @@ export class TracearrIntegration extends Integration { const json = (await response.json()) as TracearrViolationsResponse; const imageProxy = new ImageProxy(); - await Promise.all( - json.data.map(async (violation) => { - violation.user.avatarUrl = await this.proxyImageAsync( - imageProxy, - violation.user.avatarUrl, - violation.user.id, - "avatar", - ); - }), - ); - - return json; + 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; + }), + ), + }; } /** @@ -163,18 +152,20 @@ export class TracearrIntegration extends Integration { const json = (await response.json()) as TracearrHistoryResponse; const imageProxy = new ImageProxy(); - await Promise.all( - json.data.map(async (session) => { - session.user.avatarUrl = await this.proxyImageAsync( - imageProxy, - session.user.avatarUrl, - session.user.id, - "avatar", - ); - }), - ); - - return json; + 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; + }), + ), + }; } /** diff --git a/packages/integrations/src/tracearr/tracearr-types.ts b/packages/integrations/src/tracearr/tracearr-types.ts index c805f8571d..099d584a94 100644 --- a/packages/integrations/src/tracearr/tracearr-types.ts +++ b/packages/integrations/src/tracearr/tracearr-types.ts @@ -1,16 +1,22 @@ -export interface TracearrServerStatus { - id: string; - name: string; - type: "plex" | "jellyfin" | "emby"; - online: boolean; - activeStreams: number; -} +import { z } from "zod"; -export interface TracearrHealthResponse { - status: "ok"; - timestamp: string; - servers: TracearrServerStatus[]; -} +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; From 77c668eacd019f0ec8fe2a7cd4351ff4c32597d7 Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Tue, 10 Mar 2026 20:27:53 +0000 Subject: [PATCH 10/11] fix: remove new item from mock integration --- packages/definitions/src/integration.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 2303059466..fb455b8375 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -346,7 +346,7 @@ export const integrationDefs = { name: "Tracearr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", - category: ["mediaMonitoring"], + category: ["healthMonitoring"], // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), }, @@ -361,7 +361,6 @@ export const integrationDefs = { "downloadClient", "healthMonitoring", "indexerManager", - "mediaMonitoring", "mediaRelease", "mediaRequest", "mediaService", From be0c31b9c7eb08715f82cf75e6b652f35e0e93df Mon Sep 17 00:00:00 2001 From: StrandedTurtle Date: Tue, 10 Mar 2026 22:46:15 +0000 Subject: [PATCH 11/11] fix: add back media monitoring --- packages/definitions/src/integration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index fb455b8375..2f64023c20 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -346,7 +346,7 @@ export const integrationDefs = { name: "Tracearr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", - category: ["healthMonitoring"], + category: ["mediaMonitoring"], // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), },