ci: showing new badge in repack-modal

This commit is contained in:
Moyasee
2025-10-31 23:17:06 +02:00
parent 4dd3c9de76
commit efab242c74
13 changed files with 253 additions and 84 deletions

View File

@@ -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",

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -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";

View File

@@ -1,18 +1,59 @@
import { levelKeys } from "./keys";
import { db } from "../level";
import { logger } from "@main/services";
export const getLastDownloadSourcesCheck = async (): Promise<string | null> => {
// 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<void> => {
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<void> => {
const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp);
};

View File

@@ -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)
};

View File

@@ -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<void> {
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) {

View File

@@ -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: (

View File

@@ -214,6 +214,8 @@ declare global {
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -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 }));
});
}
}
);

View File

@@ -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;

View File

@@ -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<Record<string, boolean>>(
{}
);
const [lastCheckTimestamp, setLastCheckTimestamp] = useState<string | null>(
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"
>
<p className="repacks-modal__repack-title">{repack.title}</p>
<p className="repacks-modal__repack-title">
{repack.title}
{isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
</p>
{isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge>

View File

@@ -23,6 +23,7 @@ export interface GameRepack {
uploadDate: string | null;
downloadSourceId: string;
downloadSourceName: string;
createdAt: string;
}
export interface DownloadSource {