diff --git a/package.json b/package.json index c3dd7ff5..31784bd8 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", + "get-port": "^7.1.0", "hls.js": "^1.5.12", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4bfd8375..a63eb860 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -382,6 +382,9 @@ "audio": "Audio", "filter_by_source": "Filter by source", "no_repacks_found": "No sources found for this game", + "source_online": "Source is online", + "source_partial": "Some links are offline", + "source_offline": "Source is offline", "delete_review": "Delete review", "remove_review": "Remove Review", "delete_review_modal_title": "Are you sure you want to delete your review?", diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index d04b00ab..6d89fac8 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -1,5 +1,6 @@ import axios from "axios"; import http from "node:http"; +import getPort, { portNumbers } from "get-port"; import cp from "node:child_process"; import fs from "node:fs"; @@ -27,11 +28,17 @@ const binaryNameByPlatform: Partial> = { win32: "hydra-python-rpc.exe", }; +const RPC_PORT_RANGE_START = 8080; +const RPC_PORT_RANGE_END = 9000; +const DEFAULT_RPC_PORT = 8084; +const HEALTH_CHECK_INTERVAL_MS = 100; +const HEALTH_CHECK_TIMEOUT_MS = 10000; + export class PythonRPC { public static readonly BITTORRENT_PORT = "5881"; - public static readonly RPC_PORT = "8084"; + public static readonly rpc = axios.create({ - baseURL: `http://localhost:${this.RPC_PORT}`, + baseURL: `http://localhost:${DEFAULT_RPC_PORT}`, httpAgent: new http.Agent({ family: 4, // Force IPv4 }), @@ -62,15 +69,46 @@ export class PythonRPC { return newPassword; } + private static async waitForHealthCheck(): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) { + try { + const response = await this.rpc.get("/healthcheck", { timeout: 1000 }); + if (response.status === 200) { + pythonRpcLogger.log("RPC health check passed"); + return; + } + } catch { + // Server not ready yet, continue polling + } + await new Promise((resolve) => + setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS) + ); + } + + throw new Error("RPC health check timed out"); + } + public static async spawn( initialDownload?: GamePayload, initialSeeding?: GamePayload[] ) { const rpcPassword = await this.getRPCPassword(); + const port = await getPort({ + port: [ + DEFAULT_RPC_PORT, + ...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END), + ], + }); + + this.rpc.defaults.baseURL = `http://localhost:${port}`; + pythonRpcLogger.log(`Using RPC port: ${port}`); + const commonArgs = [ this.BITTORRENT_PORT, - this.RPC_PORT, + String(port), rpcPassword, initialDownload ? JSON.stringify(initialDownload) : "", initialSeeding ? JSON.stringify(initialSeeding) : "", @@ -91,6 +129,7 @@ export class PythonRPC { ); app.quit(); + return; } const childProcess = cp.spawn(binaryPath, commonArgs, { @@ -99,7 +138,6 @@ export class PythonRPC { }); this.logStderr(childProcess.stderr); - this.pythonProcess = childProcess; } else { const scriptPath = path.join( @@ -115,11 +153,23 @@ export class PythonRPC { }); this.logStderr(childProcess.stderr); - this.pythonProcess = childProcess; } this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword; + + try { + await this.waitForHealthCheck(); + pythonRpcLogger.log(`Python RPC started successfully on port ${port}`); + } catch (err) { + pythonRpcLogger.log(`Failed to start Python RPC: ${err}`); + dialog.showErrorBox( + "RPC Error", + `Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.` + ); + this.kill(); + throw err; + } } public static kill() { diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index bd1209ec..ab2e073b 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import { BellIcon } from "@primer/octicons-react"; import { useAppSelector, useUserDetails } from "@renderer/hooks"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { Avatar } from "../avatar/avatar"; @@ -20,51 +20,60 @@ export function SidebarProfile() { const { gameRunning } = useAppSelector((state) => state.gameRunning); const [notificationCount, setNotificationCount] = useState(0); + const apiNotificationCountRef = useRef(0); + const hasFetchedInitialCount = useRef(false); - const fetchNotificationCount = useCallback(async () => { + const fetchLocalNotificationCount = useCallback(async () => { try { - // Always fetch local notification count const localCount = await window.electron.getLocalNotificationsCount(); - - // Fetch API notification count only if logged in - let apiCount = 0; - if (userDetails) { - try { - const response = - await window.electron.hydraApi.get( - "/profile/notifications/count", - { needsAuth: true } - ); - apiCount = response.count; - } catch { - // Ignore API errors - } - } - - setNotificationCount(localCount + apiCount); + setNotificationCount(localCount + apiNotificationCountRef.current); } catch (error) { - logger.error("Failed to fetch notification count", error); + logger.error("Failed to fetch local notification count", error); } - }, [userDetails]); + }, []); + const fetchApiNotificationCount = useCallback(async () => { + try { + const response = + await window.electron.hydraApi.get( + "/profile/notifications/count", + { needsAuth: true } + ); + apiNotificationCountRef.current = response.count; + } catch { + // Ignore API errors + } + fetchLocalNotificationCount(); + }, [fetchLocalNotificationCount]); + + // Initial fetch on mount (only once) useEffect(() => { - fetchNotificationCount(); + fetchLocalNotificationCount(); + }, [fetchLocalNotificationCount]); - const interval = setInterval(fetchNotificationCount, 60000); - return () => clearInterval(interval); - }, [fetchNotificationCount]); + // Fetch API count when user logs in (only if not already fetched) + useEffect(() => { + if (userDetails && !hasFetchedInitialCount.current) { + hasFetchedInitialCount.current = true; + fetchApiNotificationCount(); + } else if (!userDetails) { + hasFetchedInitialCount.current = false; + apiNotificationCountRef.current = 0; + fetchLocalNotificationCount(); + } + }, [userDetails, fetchApiNotificationCount, fetchLocalNotificationCount]); useEffect(() => { const unsubscribe = window.electron.onLocalNotificationCreated(() => { - fetchNotificationCount(); + fetchLocalNotificationCount(); }); return () => unsubscribe(); - }, [fetchNotificationCount]); + }, [fetchLocalNotificationCount]); useEffect(() => { const handleNotificationsChange = () => { - fetchNotificationCount(); + fetchLocalNotificationCount(); }; window.addEventListener("notificationsChanged", handleNotificationsChange); @@ -74,15 +83,18 @@ export function SidebarProfile() { handleNotificationsChange ); }; - }, [fetchNotificationCount]); + }, [fetchLocalNotificationCount]); useEffect(() => { - const unsubscribe = window.electron.onSyncNotificationCount(() => { - fetchNotificationCount(); - }); + const unsubscribe = window.electron.onSyncNotificationCount( + (notification) => { + apiNotificationCountRef.current = notification.notificationCount; + fetchLocalNotificationCount(); + } + ); return () => unsubscribe(); - }, [fetchNotificationCount]); + }, [fetchLocalNotificationCount]); const handleProfileClick = () => { if (userDetails === null) { diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index 420029c7..53ba7c4c 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -40,6 +40,34 @@ gap: calc(globals.$spacing-unit * 1); color: globals.$body-color; padding: calc(globals.$spacing-unit * 2); + padding-right: calc(globals.$spacing-unit * 4); + position: relative; + } + + &__availability-orb { + position: absolute; + top: calc(globals.$spacing-unit * 1.5); + right: calc(globals.$spacing-unit * 1.5); + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--online { + background-color: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); + } + + &--partial { + background-color: #eab308; + box-shadow: 0 0 6px rgba(234, 179, 8, 0.5); + } + + &--offline { + background-color: #ef4444; + opacity: 0.7; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); + } } &__repack-title { diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 683ce53a..1a1132f1 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -6,6 +6,7 @@ import { ChevronDownIcon, ChevronUpIcon, } from "@primer/octicons-react"; +import { Tooltip } from "react-tooltip"; import { Badge, @@ -185,6 +186,20 @@ export function RepacksModal({ ); }, [repacks, hashesInDebrid]); + const getRepackAvailabilityStatus = ( + repack: GameRepack + ): "online" | "partial" | "offline" => { + const unavailableSet = new Set(repack.unavailableUris ?? []); + const availableCount = repack.uris.filter( + (uri) => !unavailableSet.has(uri) + ).length; + const unavailableCount = repack.uris.length - availableCount; + + if (unavailableCount === 0) return "online"; + if (availableCount === 0) return "offline"; + return "partial"; + }; + useEffect(() => { const term = filterTerm.trim().toLowerCase(); @@ -363,6 +378,8 @@ export function RepacksModal({ filteredRepacks.map((repack) => { const isLastDownloadedOption = checkIfLastDownloadedOption(repack); + const availabilityStatus = getRepackAvailabilityStatus(repack); + const tooltipId = `availability-orb-${repack.id}`; return (