diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 668f1547..7710066d 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -194,6 +194,7 @@ "download_in_progress": "Download in progress", "download_paused": "Download paused", "last_downloaded_option": "Last downloaded option", + "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", "create_shortcut_success": "Shortcut created successfully", "you_might_need_to_restart_steam": "You might need to restart Steam to see the changes", diff --git a/src/main/events/download-sources/get-download-sources-check-baseline.ts b/src/main/events/download-sources/get-download-sources-check-baseline.ts new file mode 100644 index 00000000..2f3ab377 --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-check-baseline.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesCheckBaseline } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesCheckBaselineHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesCheckBaseline(); +}; + +registerEvent( + "getDownloadSourcesCheckBaseline", + getDownloadSourcesCheckBaselineHandler +); diff --git a/src/main/events/download-sources/get-download-sources-since-value.ts b/src/main/events/download-sources/get-download-sources-since-value.ts new file mode 100644 index 00000000..cbd06faf --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-since-value.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesSinceValue } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesSinceValueHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesSinceValue(); +}; + +registerEvent( + "getDownloadSourcesSinceValue", + getDownloadSourcesSinceValueHandler +); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0ab5499a..162b08d7 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -64,6 +64,8 @@ import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; import "./download-sources/sync-download-sources"; +import "./download-sources/get-download-sources-check-baseline"; +import "./download-sources/get-download-sources-since-value"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index f7071932..4b60b962 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -1,18 +1,59 @@ import { levelKeys } from "./keys"; import { db } from "../level"; +import { logger } from "@main/services"; -export const getLastDownloadSourcesCheck = async (): Promise => { +// Gets when we last started the app (for next API call's 'since') +export const getDownloadSourcesCheckBaseline = async (): Promise< + string | null +> => { try { - const timestamp = await db.get(levelKeys.lastDownloadSourcesCheck); + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); return timestamp; } catch (error) { - // Key doesn't exist yet + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources check baseline not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources check baseline", + error + ); + } return null; } }; -export const updateLastDownloadSourcesCheck = async ( +// Updates to current time (when app starts) +export const updateDownloadSourcesCheckBaseline = async ( timestamp: string ): Promise => { - await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); +}; + +// Gets the 'since' value the API used in the last check (for modal comparison) +export const getDownloadSourcesSinceValue = async (): Promise< + string | null +> => { + try { + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + return timestamp; + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources since value not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources since value", + error + ); + } + return null; + } +}; + +// Saves the 'since' value we used in the API call (for modal to compare against) +export const updateDownloadSourcesSinceValue = async ( + timestamp: string +): Promise => { + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); }; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 536f9dca..89c33f8d 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,5 +18,6 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", - lastDownloadSourcesCheck: "lastDownloadSourcesCheck", + downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app + downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison) }; diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index d2ccb531..f8b853a7 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -1,8 +1,9 @@ import { HydraApi } from "./hydra-api"; import { gamesSublevel, - getLastDownloadSourcesCheck, - updateLastDownloadSourcesCheck, + getDownloadSourcesCheckBaseline, + updateDownloadSourcesCheckBaseline, + updateDownloadSourcesSinceValue, downloadSourcesSublevel, } from "@main/level"; import { logger } from "./logger"; @@ -17,6 +18,89 @@ interface DownloadSourcesChangeResponse { } export class DownloadSourcesChecker { + private static async clearStaleBadges( + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => + game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info( + `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` + ); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ + gameId: `${game.shop}:${game.objectId}`, + count: 0, + }); + } + } + + return clearedPayload; + } + + private static async processApiResponse( + response: unknown, + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + if (!response || !Array.isArray(response)) { + return []; + } + + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount, + }); + + logger.info( + `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` + ); + } + } + } + + return gamesWithNewOptions; + } + + private static sendNewDownloadOptionsEvent( + clearedPayload: { gameId: string; count: number }[], + gamesWithNewOptions: { gameId: string; count: number }[] + ): void { + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send( + "on-new-download-options", + eventPayload + ); + } + + logger.info( + `Found new download options for ${gamesWithNewOptions.length} games` + ); + } + static async checkForChanges(): Promise { logger.info("DownloadSourcesChecker.checkForChanges() called"); @@ -51,35 +135,16 @@ export class DownloadSourcesChecker { return; } - // Get last check timestamp or use a default (24 hours ago) - const lastCheck = await getLastDownloadSourcesCheck(); + // Get when we LAST started the app (for this check's 'since' parameter) + const previousBaseline = await getDownloadSourcesCheckBaseline(); const since = - lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - logger.info(`Last check: ${lastCheck}, using since: ${since}`); + previousBaseline || + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + logger.info(`Using since: ${since} (from last app start)`); // Clear any previously stored new download option counts so badges don't persist across restarts - const previouslyFlaggedGames = nonCustomGames.filter( - (game: Game) => - (game as Game).newDownloadOptionsCount && - (game as Game).newDownloadOptionsCount! > 0 - ); - - const clearedPayload: { gameId: string; count: number }[] = []; - if (previouslyFlaggedGames.length > 0) { - logger.info( - `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` - ); - for (const game of previouslyFlaggedGames) { - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: undefined, - }); - clearedPayload.push({ - gameId: `${game.shop}:${game.objectId}`, - count: 0, - }); - } - } + const clearedPayload = await this.clearStaleBadges(nonCustomGames); // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ @@ -108,52 +173,25 @@ export class DownloadSourcesChecker { logger.info("API call completed, response:", response); - // Update the last check timestamp - await updateLastDownloadSourcesCheck(new Date().toISOString()); + // Save the 'since' value we just used (for modal to compare against) + await updateDownloadSourcesSinceValue(since); + logger.info(`Saved 'since' value: ${since} (for modal comparison)`); + + // Update baseline to NOW (for next app start's 'since') + const now = new Date().toISOString(); + await updateDownloadSourcesCheckBaseline(now); + logger.info( + `Updated baseline to: ${now} (will be 'since' on next app start)` + ); // Process the response and store newDownloadOptionsCount for games with new options - if (response && Array.isArray(response)) { - const gamesWithNewOptions: { gameId: string; count: number }[] = []; + const gamesWithNewOptions = await this.processApiResponse( + response, + nonCustomGames + ); - for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { - if (gameUpdate.newDownloadOptionsCount > 0) { - const game = nonCustomGames.find( - (g) => - g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId - ); - - if (game) { - // Store the new download options count in the game data - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, - }); - - gamesWithNewOptions.push({ - gameId: `${game.shop}:${game.objectId}`, - count: gameUpdate.newDownloadOptionsCount, - }); - - logger.info( - `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` - ); - } - } - } - - // Send IPC event to renderer to clear stale badges and set fresh counts from response - const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; - if (eventPayload.length > 0 && WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send( - "on-new-download-options", - eventPayload - ); - } - - logger.info( - `Found new download options for ${gamesWithNewOptions.length} games` - ); - } + // Send IPC event to renderer to clear stale badges and set fresh counts from response + this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); logger.info("Download sources check completed successfully"); } catch (error) { diff --git a/src/preload/index.ts b/src/preload/index.ts index a1a0d959..3a879d70 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), + getDownloadSourcesCheckBaseline: () => + ipcRenderer.invoke("getDownloadSourcesCheckBaseline"), + getDownloadSourcesSinceValue: () => + ipcRenderer.invoke("getDownloadSourcesSinceValue"), /* Library */ toggleAutomaticCloudSync: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 30b5c67a..b5d2492d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -214,6 +214,8 @@ declare global { ) => Promise; getDownloadSources: () => Promise; syncDownloadSources: () => Promise; + getDownloadSourcesCheckBaseline: () => Promise; + getDownloadSourcesSinceValue: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts index 34cc7bf7..dfc9b3b4 100644 --- a/src/renderer/src/hooks/use-download-options-listener.ts +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -8,9 +8,9 @@ export function useDownloadOptionsListener() { useEffect(() => { const unsubscribe = window.electron.onNewDownloadOptions( (gamesWithNewOptions) => { - gamesWithNewOptions.forEach(({ gameId, count }) => { + for (const { gameId, count } of gamesWithNewOptions) { dispatch(updateGameNewDownloadOptions({ gameId, count })); - }); + } } ); 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 ba9778fd..9bffc676 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -45,12 +45,27 @@ &__repack-title { color: globals.$muted-color; word-break: break-word; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); } &__repack-info { font-size: globals.$small-font-size; } + &__new-badge { + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + flex-shrink: 0; + } + &__no-results { width: 100%; padding: calc(globals.$spacing-unit * 4) 0; 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 306e8647..1627d8e9 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,8 +15,7 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import type { DownloadSource } from "@types"; -import type { GameRepack } from "@types"; +import type { DownloadSource, GameRepack } from "@types"; import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; @@ -53,6 +52,10 @@ export function RepacksModal({ const [hashesInDebrid, setHashesInDebrid] = useState>( {} ); + const [lastCheckTimestamp, setLastCheckTimestamp] = useState( + null + ); + const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true); const { game, repacks } = useContext(gameDetailsContext); @@ -66,8 +69,8 @@ export function RepacksModal({ return null; } - const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i; - const match = magnet.match(hashRegex); + const hashRegex = /xt=urn:btih:([a-f0-9]+)/i; + const match = hashRegex.exec(magnet); return match ? match[1].toLowerCase() : null; }; @@ -97,6 +100,21 @@ export function RepacksModal({ fetchDownloadSources(); }, []); + useEffect(() => { + const fetchLastCheckTimestamp = async () => { + setIsLoadingTimestamp(true); + + const timestamp = await window.electron.getDownloadSourcesSinceValue(); + + setLastCheckTimestamp(timestamp); + setIsLoadingTimestamp(false); + }; + + if (visible) { + fetchLastCheckTimestamp(); + } + }, [visible, repacks]); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -158,6 +176,19 @@ export function RepacksModal({ return repack.uris.some((uri) => uri.includes(game.download!.uri)); }; + const isNewRepack = (repack: GameRepack): boolean => { + // Don't show badge while loading timestamp + if (isLoadingTimestamp) return false; + + if (!lastCheckTimestamp || !repack.createdAt) { + return false; + } + + const lastCheckUtc = new Date(lastCheckTimestamp).toISOString(); + + return repack.createdAt > lastCheckUtc; + }; + const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); useEffect(() => { @@ -273,7 +304,14 @@ export function RepacksModal({ onClick={() => handleRepackClick(repack)} className="repacks-modal__repack-button" > -

{repack.title}

+

+ {repack.title} + {isNewRepack(repack) && ( + + {t("new_download_option")} + + )} +

{isLastDownloadedOption && ( {t("last_downloaded_option")} diff --git a/src/types/index.ts b/src/types/index.ts index 4b13c496..f435bc60 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,6 +23,7 @@ export interface GameRepack { uploadDate: string | null; downloadSourceId: string; downloadSourceName: string; + createdAt: string; } export interface DownloadSource {