diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..0b0c009c --- /dev/null +++ b/.cursorrules @@ -0,0 +1,29 @@ +# Hydra Project Rules + +## Logging + +- **Always use `logger` instead of `console` for logging** in both main and renderer processes +- In main process: `import { logger } from "@main/services";` +- In renderer process: `import { logger } from "@renderer/logger";` +- Replace all instances of: + - `console.log()` → `logger.log()` + - `console.error()` → `logger.error()` + - `console.warn()` → `logger.warn()` + - `console.info()` → `logger.info()` + - `console.debug()` → `logger.debug()` +- Do not use `console` for any logging purposes + +## Internationalization (i18n) + +- All user-facing strings must be translated using i18next +- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");` +- Add new translation keys to `src/locales/en/translation.json` +- Never hardcode English strings in the UI code +- Placeholder text in form fields must also be translated + +## Code Style + +- Use ESLint and Prettier for code formatting +- Follow TypeScript strict mode conventions +- Use async/await instead of promises when possible +- Prefer named exports over default exports for utilities and services diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index 6aefac43..f7361883 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: main + branches: [main] jobs: build: @@ -19,7 +19,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.0 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5062c7ad..92fcebc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,12 @@ name: Build +on: + pull_request: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -on: pull_request - jobs: build: strategy: @@ -22,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile @@ -38,6 +39,12 @@ jobs: - name: Build with cx_Freeze run: python python_rpc/setup.py build + - name: Copy OpenSSL DLLs + if: matrix.os == 'windows-2022' + run: | + cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll + cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll + - name: Build Linux if: matrix.os == 'ubuntu-latest' run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ac359364..89e8b59f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ceb42c7..75ff209a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile @@ -39,6 +39,12 @@ jobs: - name: Build with cx_Freeze run: python python_rpc/setup.py build + - name: Copy OpenSSL DLLs + if: matrix.os == 'windows-2022' + run: | + cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll + cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll + - name: Build Linux if: matrix.os == 'ubuntu-latest' run: | diff --git a/package.json b/package.json index 342b078a..9ed25fa9 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,7 @@ "winreg": "^1.2.5", "ws": "^8.18.1", "yaml": "^2.6.1", - "yup": "^1.5.0", - "zod": "^3.24.1" + "yup": "^1.5.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.705.0", @@ -116,9 +115,9 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^33.4.11", + "electron": "^37.7.1", "electron-builder": "^26.0.12", - "electron-vite": "^3.0.0", + "electron-vite": "^4.0.1", "eslint": "^8.56.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", @@ -130,7 +129,7 @@ "sass-embedded": "^1.80.6", "ts-node": "^10.9.2", "typescript": "^5.3.3", - "vite": "5.4.20", + "vite": "5.4.21", "vite-plugin-svgr": "^4.5.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 15c3b5bd..cbaca1fb 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -428,6 +428,9 @@ "validate_download_source": "Validate", "remove_download_source": "Remove", "add_download_source": "Add source", + "adding": "Adding…", + "failed_add_download_source": "Failed to add download source. Please try again.", + "download_source_already_exists": "This download source URL already exists.", "download_count_zero": "No download options", "download_count_one": "{{countFormatted}} download option", "download_count_other": "{{countFormatted}} download options", @@ -435,9 +438,16 @@ "add_download_source_description": "Insert the URL of the .json file", "download_source_up_to_date": "Up-to-date", "download_source_errored": "Errored", + "download_source_pending_matching": "Updating soon", + "download_source_matched": "Up to date", + "download_source_matching": "Updating", + "download_source_failed": "Error", + "download_source_no_information": "No information available", "sync_download_sources": "Sync sources", "removed_download_source": "Download source removed", "removed_download_sources": "Download sources removed", + "removed_all_download_sources": "All download sources removed", + "download_sources_synced_successfully": "All download sources are synced", "cancel_button_confirmation_delete_all_sources": "No", "confirm_button_confirmation_delete_all_sources": "Yes, delete everything", "title_confirmation_delete_all_sources": "Delete all download sources", @@ -468,6 +478,7 @@ "seed_after_download_complete": "Seed after download complete", "show_hidden_achievement_description": "Show hidden achievements description before unlocking them", "account": "Account", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "You have no blocked users", "subscription_active_until": "Your Hydra Cloud is active until {{date}}", "manage_subscription": "Manage subscription", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 4ea77015..c9e908ac 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -416,6 +416,9 @@ "validate_download_source": "Validar", "remove_download_source": "Remover", "add_download_source": "Adicionar fonte", + "adding": "Adicionando…", + "failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.", + "download_source_already_exists": "Esta URL de fonte de download já existe.", "download_count_zero": "Sem downloads na lista", "download_count_one": "{{countFormatted}} download na lista", "download_count_other": "{{countFormatted}} downloads na lista", @@ -423,7 +426,13 @@ "add_download_source_description": "Insira a URL contendo o arquivo .json", "download_source_up_to_date": "Sincronizada", "download_source_errored": "Falhou", + "download_source_pending_matching": "Importando em breve", + "download_source_matched": "Sincronizada", + "download_source_matching": "Sincronizando", + "download_source_failed": "Erro", + "download_source_no_information": "Sem informações", "sync_download_sources": "Sincronizar", + "download_sources_synced_successfully": "Fontes de download sincronizadas", "removed_download_source": "Fonte removida", "removed_download_sources": "Fontes removidas", "cancel_button_confirmation_delete_all_sources": "Não", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 962504d4..2894cf65 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -252,7 +252,13 @@ "add_download_source_description": "Insere o URL que contém o ficheiro .json", "download_source_up_to_date": "Sincronizada", "download_source_errored": "Falhou", + "download_source_pending_matching": "A atualizar em breve", + "download_source_matched": "Atualizado", + "download_source_matching": "A atualizar", + "download_source_failed": "Erro", + "download_source_no_information": "Sem informações", "sync_download_sources": "Sincronizar", + "download_sources_synced_successfully": "Fontes de download sincronizadas", "removed_download_source": "Fonte removida", "cancel_button_confirmation_delete_all_sources": "Não", "confirm_button_confirmation_delete_all_sources": "Sim, apague tudo", diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index e51cae3e..bea009cb 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -1,76 +1,50 @@ import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { HydraApi, logger } from "@main/services"; -import { importDownloadSourceToLocal } from "./helpers"; +import { HydraApi } from "@main/services/hydra-api"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; +import { logger } from "@main/services"; const addDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, url: string ) => { - const result = await importDownloadSourceToLocal(url, true); - if (!result) { - throw new Error("Failed to import download source"); - } + try { + const existingSources = await downloadSourcesSublevel.values().all(); + const urlExists = existingSources.some((source) => source.url === url); - // Verify that repacks were actually written to the database (read-after-write) - // This ensures all async operations are complete before proceeding - let repackCount = 0; - for await (const [, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === result.id) { - repackCount++; + if (urlExists) { + throw new Error("Download source with this URL already exists"); } - } - await HydraApi.post("/profile/download-sources", { - urls: [url], - }); + const downloadSource = await HydraApi.post( + "/download-sources", + { + url, + }, + { needsAuth: false } + ); - const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds: result.objectIds, - }, - { needsAuth: false } - ); + if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) { + try { + await HydraApi.post("/profile/download-sources", { + urls: [url], + }); + } catch (error) { + logger.error("Failed to add download source to profile:", error); + } + } - // Update the source with fingerprint - const updatedSource = await downloadSourcesSublevel.get(`${result.id}`); - if (updatedSource) { - await downloadSourcesSublevel.put(`${result.id}`, { - ...updatedSource, - fingerprint, - updatedAt: new Date(), + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), }); - } - // Final verification: ensure the source with fingerprint is persisted - const finalSource = await downloadSourcesSublevel.get(`${result.id}`); - if (!finalSource || !finalSource.fingerprint) { - throw new Error("Failed to persist download source with fingerprint"); + return downloadSource; + } catch (error) { + logger.error("Failed to add download source:", error); + throw error; } - - // Verify repacks still exist after fingerprint update - let finalRepackCount = 0; - for await (const [, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === result.id) { - finalRepackCount++; - } - } - - if (finalRepackCount !== repackCount) { - logger.warn( - `Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}` - ); - } else { - logger.info( - `Final verification passed: ${finalRepackCount} repacks confirmed` - ); - } - - return { - ...result, - fingerprint, - }; }; registerEvent("addDownloadSource", addDownloadSource); diff --git a/src/main/events/download-sources/check-download-source-exists.ts b/src/main/events/download-sources/check-download-source-exists.ts deleted file mode 100644 index 36dd88ce..00000000 --- a/src/main/events/download-sources/check-download-source-exists.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel } from "@main/level"; - -const checkDownloadSourceExists = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -): Promise => { - for await (const [, source] of downloadSourcesSublevel.iterator()) { - if (source.url === url) { - return true; - } - } - - return false; -}; - -registerEvent("checkDownloadSourceExists", checkDownloadSourceExists); diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts deleted file mode 100644 index cbf3958f..00000000 --- a/src/main/events/download-sources/delete-all-download-sources.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateIdCaches } from "./helpers"; - -const deleteAllDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent -) => { - await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]); - - invalidateIdCaches(); -}; - -registerEvent("deleteAllDownloadSources", deleteAllDownloadSources); diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts deleted file mode 100644 index 5322b96c..00000000 --- a/src/main/events/download-sources/delete-download-source.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateIdCaches } from "./helpers"; - -const deleteDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - id: number -) => { - const repacksToDelete: string[] = []; - - for await (const [key, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === id) { - repacksToDelete.push(key); - } - } - - const batch = repacksSublevel.batch(); - for (const key of repacksToDelete) { - batch.del(key); - } - await batch.write(); - - await downloadSourcesSublevel.del(`${id}`); - - invalidateIdCaches(); -}; - -registerEvent("deleteDownloadSource", deleteDownloadSource); diff --git a/src/main/events/download-sources/get-download-sources-list.ts b/src/main/events/download-sources/get-download-sources-list.ts deleted file mode 100644 index db26ad01..00000000 --- a/src/main/events/download-sources/get-download-sources-list.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, DownloadSource } from "@main/level"; - -const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => { - const sources: DownloadSource[] = []; - - for await (const [, source] of downloadSourcesSublevel.iterator()) { - sources.push(source); - } - - // Sort by createdAt descending - sources.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - - return sources; -}; - -registerEvent("getDownloadSourcesList", getDownloadSourcesList); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index bbebd06c..48583d9e 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -1,8 +1,10 @@ -import { HydraApi } from "@main/services"; +import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; +import { orderBy } from "lodash-es"; const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get("/profile/download-sources"); + const allSources = await downloadSourcesSublevel.values().all(); + return orderBy(allSources, "createdAt", "desc"); }; registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts deleted file mode 100644 index 2e7489fd..00000000 --- a/src/main/events/download-sources/helpers.ts +++ /dev/null @@ -1,367 +0,0 @@ -import axios from "axios"; -import { z } from "zod"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { DownloadSourceStatus } from "@shared"; -import crypto from "node:crypto"; -import { logger, ResourceCache } from "@main/services"; - -export const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); - -export type TitleHashMapping = Record; - -let titleHashMappingCache: TitleHashMapping | null = null; - -export const getTitleHashMapping = async (): Promise => { - if (titleHashMappingCache) { - return titleHashMappingCache; - } - - try { - const cached = - ResourceCache.getCachedData("sources-manifest"); - if (cached) { - titleHashMappingCache = cached; - return cached; - } - - const fetched = await ResourceCache.fetchAndCache( - "sources-manifest", - "https://cdn.losbroxas.org/sources-manifest.json", - 10000 - ); - titleHashMappingCache = fetched; - return fetched; - } catch (error) { - logger.error("Failed to fetch title hash mapping:", error); - return {} as TitleHashMapping; - } -}; - -export const hashTitle = (title: string): string => { - return crypto.createHash("sha256").update(title).digest("hex"); -}; - -export type SteamGamesByLetter = Record; -export type FormattedSteamGame = { - id: string; - name: string; - formattedName: string; -}; -export type FormattedSteamGamesByLetter = Record; - -export const formatName = (name: string) => { - return name - .normalize("NFD") - .replaceAll(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replaceAll(/[^a-z0-9]/g, ""); -}; - -export const formatRepackName = (name: string) => { - return formatName(name.replace("[DL]", "")); -}; - -interface DownloadSource { - id: number; - url: string; - name: string; - etag: string | null; - status: number; - downloadCount: number; - objectIds: string[]; - fingerprint?: string; - createdAt: Date; - updatedAt: Date; -} - -const getDownloadSourcesMap = async (): Promise< - Map -> => { - const map = new Map(); - for await (const [key, source] of downloadSourcesSublevel.iterator()) { - map.set(key, source); - } - - return map; -}; - -export const checkUrlExists = async (url: string): Promise => { - const sources = await getDownloadSourcesMap(); - for (const source of sources.values()) { - if (source.url === url) { - return true; - } - } - return false; -}; - -let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null; - -export const getSteamGames = async (): Promise => { - if (steamGamesFormattedCache) { - return steamGamesFormattedCache; - } - - let steamGames: SteamGamesByLetter; - - const cached = ResourceCache.getCachedData( - "steam-games-by-letter" - ); - if (cached) { - steamGames = cached; - } else { - steamGames = await ResourceCache.fetchAndCache( - "steam-games-by-letter", - `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` - ); - } - - const formattedData: FormattedSteamGamesByLetter = {}; - for (const [letter, games] of Object.entries(steamGames)) { - formattedData[letter] = games.map((game) => ({ - ...game, - formattedName: formatName(game.name), - })); - } - - steamGamesFormattedCache = formattedData; - return formattedData; -}; - -export type SublevelIterator = AsyncIterable<[string, { id: number }]>; - -export interface SublevelWithId { - iterator: () => SublevelIterator; -} - -let maxRepackId: number | null = null; -let maxDownloadSourceId: number | null = null; - -export const getNextId = async (sublevel: SublevelWithId): Promise => { - const isRepackSublevel = sublevel === repacksSublevel; - const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel; - - if (isRepackSublevel && maxRepackId !== null) { - return ++maxRepackId; - } - - if (isDownloadSourceSublevel && maxDownloadSourceId !== null) { - return ++maxDownloadSourceId; - } - - let maxId = 0; - for await (const [, value] of sublevel.iterator()) { - if (value.id > maxId) { - maxId = value.id; - } - } - - if (isRepackSublevel) { - maxRepackId = maxId; - } else if (isDownloadSourceSublevel) { - maxDownloadSourceId = maxId; - } - - return maxId + 1; -}; - -export const invalidateIdCaches = () => { - maxRepackId = null; - maxDownloadSourceId = null; -}; - -export const addNewDownloads = async ( - downloadSource: { id: number; name: string }, - downloads: z.infer["downloads"], - steamGames: FormattedSteamGamesByLetter -) => { - const now = new Date(); - const objectIdsOnSource = new Set(); - - let nextRepackId = await getNextId(repacksSublevel); - - const batch = repacksSublevel.batch(); - - const titleHashMapping = await getTitleHashMapping(); - let hashMatchCount = 0; - let fuzzyMatchCount = 0; - let noMatchCount = 0; - - for (const download of downloads) { - let objectIds: string[] = []; - let usedHashMatch = false; - - const titleHash = hashTitle(download.title); - const steamIdsFromHash = titleHashMapping[titleHash]; - - if (steamIdsFromHash && steamIdsFromHash.length > 0) { - hashMatchCount++; - usedHashMatch = true; - - objectIds = steamIdsFromHash.map(String); - } - - if (!usedHashMatch) { - let gamesInSteam: FormattedSteamGame[] = []; - const formattedTitle = formatRepackName(download.title); - - if (formattedTitle && formattedTitle.length > 0) { - const [firstLetter] = formattedTitle; - const games = steamGames[firstLetter] || []; - - gamesInSteam = games.filter((game) => - formattedTitle.startsWith(game.formattedName) - ); - - if (gamesInSteam.length === 0) { - gamesInSteam = games.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - } - - if (gamesInSteam.length === 0) { - for (const letter of Object.keys(steamGames)) { - const letterGames = steamGames[letter] || []; - const matches = letterGames.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - if (matches.length > 0) { - gamesInSteam = matches; - break; - } - } - } - - if (gamesInSteam.length > 0) { - fuzzyMatchCount++; - objectIds = gamesInSteam.map((game) => String(game.id)); - } else { - noMatchCount++; - } - } else { - noMatchCount++; - } - } - - for (const id of objectIds) { - objectIdsOnSource.add(id); - } - - const repack = { - id: nextRepackId++, - objectIds: objectIds, - title: download.title, - uris: download.uris, - fileSize: download.fileSize, - repacker: downloadSource.name, - uploadDate: download.uploadDate, - downloadSourceId: downloadSource.id, - createdAt: now, - updatedAt: now, - }; - - batch.put(`${repack.id}`, repack); - } - - await batch.write(); - - logger.info( - `Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}` - ); - - const existingSource = await downloadSourcesSublevel.get( - `${downloadSource.id}` - ); - if (existingSource) { - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...existingSource, - objectIds: Array.from(objectIdsOnSource), - }); - } - - return Array.from(objectIdsOnSource); -}; - -export const importDownloadSourceToLocal = async ( - url: string, - throwOnDuplicate = false -) => { - const urlExists = await checkUrlExists(url); - if (urlExists) { - if (throwOnDuplicate) { - throw new Error("Download source with this URL already exists"); - } - return null; - } - - const response = await axios.get>(url); - - const steamGames = await getSteamGames(); - - const now = new Date(); - - const nextId = await getNextId(downloadSourcesSublevel); - - const downloadSource = { - id: nextId, - url, - name: response.data.name, - etag: response.headers["etag"] || null, - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - objectIds: [], - createdAt: now, - updatedAt: now, - }; - - await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource); - - const objectIds = await addNewDownloads( - downloadSource, - response.data.downloads, - steamGames - ); - - // Invalidate ID caches after creating new repacks to prevent ID collisions - invalidateIdCaches(); - - return { - ...downloadSource, - objectIds, - }; -}; - -export const updateDownloadSourcePreservingTimestamp = async ( - existingSource: DownloadSource, - url: string -) => { - const response = await axios.get>(url); - - const updatedSource = { - ...existingSource, - name: response.data.name, - etag: response.headers["etag"] || null, - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - updatedAt: new Date(), - // Preserve the original createdAt timestamp - }; - - await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource); - - return updatedSource; -}; diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts index bcc66998..9caeaba5 100644 --- a/src/main/events/download-sources/remove-download-source.ts +++ b/src/main/events/download-sources/remove-download-source.ts @@ -1,18 +1,27 @@ import { HydraApi } from "@main/services"; +import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; const removeDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, - url?: string, - removeAll = false + removeAll = false, + downloadSourceId?: string ) => { const params = new URLSearchParams({ all: removeAll.toString(), }); - if (url) params.set("url", url); + if (downloadSourceId) params.set("downloadSourceId", downloadSourceId); - return HydraApi.delete(`/profile/download-sources?${params.toString()}`); + if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) { + void HydraApi.delete(`/profile/download-sources?${params.toString()}`); + } + + if (removeAll) { + await downloadSourcesSublevel.clear(); + } else if (downloadSourceId) { + await downloadSourcesSublevel.del(downloadSourceId); + } }; registerEvent("removeDownloadSource", removeDownloadSource); diff --git a/src/main/events/download-sources/sync-download-sources-from-api.ts b/src/main/events/download-sources/sync-download-sources-from-api.ts deleted file mode 100644 index 3cac8819..00000000 --- a/src/main/events/download-sources/sync-download-sources-from-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HydraApi, logger } from "@main/services"; -import { importDownloadSourceToLocal, checkUrlExists } from "./helpers"; - -export const syncDownloadSourcesFromApi = async () => { - try { - const apiSources = await HydraApi.get< - { url: string; createdAt: string; updatedAt: string }[] - >("/profile/download-sources"); - - for (const apiSource of apiSources) { - const exists = await checkUrlExists(apiSource.url); - if (!exists) { - await importDownloadSourceToLocal(apiSource.url, false); - } - } - } catch (error) { - logger.error("Failed to sync download sources from API:", error); - } -}; diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts index 88861074..68a6be3f 100644 --- a/src/main/events/download-sources/sync-download-sources.ts +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -1,114 +1,28 @@ +import { HydraApi } from "@main/services"; import { registerEvent } from "../register-event"; -import axios, { AxiosError } from "axios"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { DownloadSourceStatus } from "@shared"; -import { - invalidateIdCaches, - downloadSourceSchema, - getSteamGames, - addNewDownloads, -} from "./helpers"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; -const syncDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - let newRepacksCount = 0; +const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { + const downloadSources = await downloadSourcesSublevel.values().all(); - try { - const downloadSources: Array<{ - id: number; - url: string; - name: string; - etag: string | null; - status: number; - downloadCount: number; - objectIds: string[]; - fingerprint?: string; - createdAt: Date; - updatedAt: Date; - }> = []; - for await (const [, source] of downloadSourcesSublevel.iterator()) { - downloadSources.push(source); - } + const response = await HydraApi.post( + "/download-sources/sync", + { + ids: downloadSources.map((downloadSource) => downloadSource.id), + }, + { needsAuth: false } + ); - const existingRepacks: Array<{ - id: number; - title: string; - uris: string[]; - repacker: string; - fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - downloadSourceId: number; - createdAt: Date; - updatedAt: Date; - }> = []; - for await (const [, repack] of repacksSublevel.iterator()) { - existingRepacks.push(repack); - } - - // Handle sources with missing fingerprints individually, don't delete all sources - const sourcesWithFingerprints = downloadSources.filter( - (source) => source.fingerprint - ); - const sourcesWithoutFingerprints = downloadSources.filter( - (source) => !source.fingerprint + for (const downloadSource of response) { + const existingDownloadSource = downloadSources.find( + (source) => source.id === downloadSource.id ); - // For sources without fingerprints, just continue with normal sync - // They will get fingerprints updated later by updateMissingFingerprints - const allSourcesToSync = [ - ...sourcesWithFingerprints, - ...sourcesWithoutFingerprints, - ]; - - for (const downloadSource of allSourcesToSync) { - const headers: Record = {}; - - if (downloadSource.etag) { - headers["If-None-Match"] = downloadSource.etag; - } - - try { - const response = await axios.get(downloadSource.url, { - headers, - }); - - const source = downloadSourceSchema.parse(response.data); - const steamGames = await getSteamGames(); - - const repacks = source.downloads.filter( - (download) => - !existingRepacks.some((repack) => repack.title === download.title) - ); - - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...downloadSource, - etag: response.headers["etag"] || null, - downloadCount: source.downloads.length, - status: DownloadSourceStatus.UpToDate, - }); - - await addNewDownloads(downloadSource, repacks, steamGames); - - newRepacksCount += repacks.length; - } catch (err: unknown) { - const isNotModified = (err as AxiosError).response?.status === 304; - - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...downloadSource, - status: isNotModified - ? DownloadSourceStatus.UpToDate - : DownloadSourceStatus.Errored, - }); - } - } - - invalidateIdCaches(); - - return newRepacksCount; - } catch (err) { - return -1; + await downloadSourcesSublevel.put(downloadSource.id, { + ...existingDownloadSource, + ...downloadSource, + }); } }; diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts deleted file mode 100644 index 7fd43c63..00000000 --- a/src/main/events/download-sources/update-missing-fingerprints.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel } from "@main/level"; -import { HydraApi, logger } from "@main/services"; - -const updateMissingFingerprints = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - const sourcesNeedingFingerprints: Array<{ - id: number; - objectIds: string[]; - }> = []; - - for await (const [, source] of downloadSourcesSublevel.iterator()) { - if ( - !source.fingerprint && - source.objectIds && - source.objectIds.length > 0 - ) { - sourcesNeedingFingerprints.push({ - id: source.id, - objectIds: source.objectIds, - }); - } - } - - if (sourcesNeedingFingerprints.length === 0) { - return 0; - } - - logger.info( - `Updating fingerprints for ${sourcesNeedingFingerprints.length} sources` - ); - - await Promise.all( - sourcesNeedingFingerprints.map(async (source) => { - try { - const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds: source.objectIds, - }, - { needsAuth: false } - ); - - const existingSource = await downloadSourcesSublevel.get( - `${source.id}` - ); - if (existingSource) { - await downloadSourcesSublevel.put(`${source.id}`, { - ...existingSource, - fingerprint, - updatedAt: new Date(), - }); - } - } catch (error) { - logger.error( - `Failed to update fingerprint for source ${source.id}:`, - error - ); - } - }) - ); - - return sourcesNeedingFingerprints.length; -}; - -registerEvent("updateMissingFingerprints", updateMissingFingerprints); diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts deleted file mode 100644 index 2bc86df7..00000000 --- a/src/main/events/download-sources/validate-download-source.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { registerEvent } from "../register-event"; -import axios from "axios"; -import { z } from "zod"; - -const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); - -const validateDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -) => { - const response = await axios.get>(url); - - const { name } = downloadSourceSchema.parse(response.data); - - return { - name, - etag: response.headers["etag"] || null, - downloadCount: response.data.downloads.length, - }; -}; - -registerEvent("validateDownloadSource", validateDownloadSource); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 8d21aa11..0ab5499a 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -63,14 +63,7 @@ import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; -import "./download-sources/update-missing-fingerprints"; -import "./download-sources/delete-download-source"; -import "./download-sources/delete-all-download-sources"; -import "./download-sources/validate-download-source"; import "./download-sources/sync-download-sources"; -import "./download-sources/get-download-sources-list"; -import "./download-sources/check-download-source-exists"; -import "./repacks/get-all-repacks"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts index f2f2dd40..6a90087e 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -37,6 +37,7 @@ const addCustomGameToLibrary = async ( logoImageUrl: logoImageUrl || "", logoPosition: null, coverImageUrl: iconUrl || "", + downloadSources: [], }; await gamesShopAssetsSublevel.put(gameKey, assets); diff --git a/src/main/events/repacks/get-all-repacks.ts b/src/main/events/repacks/get-all-repacks.ts deleted file mode 100644 index 6eb83a39..00000000 --- a/src/main/events/repacks/get-all-repacks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { repacksSublevel, GameRepack } from "@main/level"; - -const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => { - const repacks: GameRepack[] = []; - - for await (const [, repack] of repacksSublevel.iterator()) { - if (Array.isArray(repack.objectIds)) { - repacks.push(repack); - } - } - - return repacks; -}; - -registerEvent("getAllRepacks", getAllRepacks); diff --git a/src/main/helpers/migrate-download-sources.ts b/src/main/helpers/migrate-download-sources.ts new file mode 100644 index 00000000..fd627f20 --- /dev/null +++ b/src/main/helpers/migrate-download-sources.ts @@ -0,0 +1,27 @@ +import { downloadSourcesSublevel } from "@main/level"; +import { HydraApi } from "@main/services/hydra-api"; +import { DownloadSource } from "@types"; + +export const migrateDownloadSources = async () => { + const downloadSources = downloadSourcesSublevel.iterator(); + + for await (const [key, value] of downloadSources) { + if (!value.isRemote) { + const downloadSource = await HydraApi.post( + "/download-sources", + { + url: value.url, + }, + { needsAuth: false } + ); + + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + await downloadSourcesSublevel.del(key); + } + } +}; diff --git a/src/main/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts index 59104e3c..b6cdad0b 100644 --- a/src/main/level/sublevels/download-sources.ts +++ b/src/main/level/sublevels/download-sources.ts @@ -1,18 +1,6 @@ import { db } from "../level"; import { levelKeys } from "./keys"; - -export interface DownloadSource { - id: number; - name: string; - url: string; - status: number; - objectIds: string[]; - downloadCount: number; - fingerprint?: string; - etag: string | null; - createdAt: Date; - updatedAt: Date; -} +import type { DownloadSource } from "@types"; export const downloadSourcesSublevel = db.sublevel( levelKeys.downloadSources, diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 7224fc64..3619ae26 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,4 +7,3 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; -export * from "./repacks"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 6faacd52..a28690b2 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,5 +18,4 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", - repacks: "repacks", }; diff --git a/src/main/level/sublevels/repacks.ts b/src/main/level/sublevels/repacks.ts deleted file mode 100644 index 6257665b..00000000 --- a/src/main/level/sublevels/repacks.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db } from "../level"; -import { levelKeys } from "./keys"; - -export interface GameRepack { - id: number; - title: string; - uris: string[]; - repacker: string; - fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - downloadSourceId: number; - createdAt: Date; - updatedAt: Date; -} - -export const repacksSublevel = db.sublevel( - levelKeys.repacks, - { - valueEncoding: "json", - } -); diff --git a/src/main/main.ts b/src/main/main.ts index 5eecb101..ffb8f8a9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,15 +16,12 @@ import { Ludusavi, Lock, DeckyPlugin, - ResourceCache, } from "@main/services"; +import { migrateDownloadSources } from "./helpers/migrate-download-sources"; export const loadState = async () => { await Lock.acquireLock(); - ResourceCache.initialize(); - await ResourceCache.updateResourcesOnStartup(); - const userPreferences = await db.get( levelKeys.userPreferences, { @@ -53,8 +50,12 @@ export const loadState = async () => { DeckyPlugin.checkAndUpdateIfOutdated(); } - await HydraApi.setupApi().then(() => { + await HydraApi.setupApi().then(async () => { uploadGamesBatch(); + void migrateDownloadSources(); + + const { syncDownloadSourcesFromApi } = await import("./services/user"); + void syncDownloadSourcesFromApi(); // WSClient.connect(); }); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index dd26e6f0..12090df3 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -46,7 +46,7 @@ export class HydraApi { return this.userAuth.authToken !== ""; } - private static hasActiveSubscription() { + public static hasActiveSubscription() { const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0); return expiresAt > new Date(); } @@ -106,9 +106,7 @@ export class HydraApi { // WSClient.close(); // WSClient.connect(); - const { syncDownloadSourcesFromApi } = await import( - "../events/download-sources/sync-download-sources-from-api" - ); + const { syncDownloadSourcesFromApi } = await import("./user"); syncDownloadSourcesFromApi(); } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index c98f09e1..da4e6848 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -18,4 +18,4 @@ export * from "./library-sync"; export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; -export * from "./resource-cache"; +export * from "./user"; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index f7ea2744..c00e4961 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -72,6 +72,7 @@ export const mergeWithRemoteGames = async () => { logoImageUrl: game.logoImageUrl, iconUrl: game.iconUrl, logoPosition: game.logoPosition, + downloadSources: game.downloadSources, }); } }) diff --git a/src/main/services/resource-cache.ts b/src/main/services/resource-cache.ts deleted file mode 100644 index c59f873d..00000000 --- a/src/main/services/resource-cache.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { app } from "electron"; -import axios from "axios"; -import fs from "node:fs"; -import path from "node:path"; -import { logger } from "./logger"; - -interface CachedResource { - data: T; - etag: string | null; -} - -export class ResourceCache { - private static cacheDir: string; - - static initialize() { - this.cacheDir = path.join(app.getPath("userData"), "resource-cache"); - - if (!fs.existsSync(this.cacheDir)) { - fs.mkdirSync(this.cacheDir, { recursive: true }); - } - } - - private static getCacheFilePath(resourceName: string): string { - return path.join(this.cacheDir, `${resourceName}.json`); - } - - private static getEtagFilePath(resourceName: string): string { - return path.join(this.cacheDir, `${resourceName}.etag`); - } - - private static readCachedResource( - resourceName: string - ): CachedResource | null { - const dataPath = this.getCacheFilePath(resourceName); - const etagPath = this.getEtagFilePath(resourceName); - - if (!fs.existsSync(dataPath)) { - return null; - } - - try { - const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T; - const etag = fs.existsSync(etagPath) - ? fs.readFileSync(etagPath, "utf-8") - : null; - - return { data, etag }; - } catch (error) { - logger.error(`Failed to read cached resource ${resourceName}:`, error); - return null; - } - } - - private static writeCachedResource( - resourceName: string, - data: T, - etag: string | null - ): void { - const dataPath = this.getCacheFilePath(resourceName); - const etagPath = this.getEtagFilePath(resourceName); - - try { - fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8"); - - if (etag) { - fs.writeFileSync(etagPath, etag, "utf-8"); - } - - logger.info( - `Cached resource ${resourceName} with etag: ${etag || "none"}` - ); - } catch (error) { - logger.error(`Failed to write cached resource ${resourceName}:`, error); - } - } - - static async fetchAndCache( - resourceName: string, - url: string, - timeout: number = 10000 - ): Promise { - const cached = this.readCachedResource(resourceName); - const headers: Record = {}; - - if (cached?.etag) { - headers["If-None-Match"] = cached.etag; - } - - try { - const response = await axios.get(url, { - headers, - timeout, - }); - - const newEtag = response.headers["etag"] || null; - this.writeCachedResource(resourceName, response.data, newEtag); - - return response.data; - } catch (error: unknown) { - const axiosError = error as { - response?: { status?: number }; - message?: string; - }; - - if (axiosError.response?.status === 304 && cached) { - logger.info(`Resource ${resourceName} not modified, using cache`); - return cached.data; - } - - if (cached) { - logger.warn( - `Failed to fetch ${resourceName}, using cached version:`, - axiosError.message || "Unknown error" - ); - return cached.data; - } - - logger.error( - `Failed to fetch ${resourceName} and no cache available:`, - error - ); - throw error; - } - } - - static getCachedData(resourceName: string): T | null { - const cached = this.readCachedResource(resourceName); - return cached?.data || null; - } - - static async updateResourcesOnStartup(): Promise { - logger.info("Starting background resource cache update..."); - - const resources = [ - { - name: "steam-games-by-letter", - url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`, - }, - { - name: "sources-manifest", - url: "https://cdn.losbroxas.org/sources-manifest.json", - }, - ]; - - await Promise.allSettled( - resources.map(async (resource) => { - try { - await this.fetchAndCache(resource.name, resource.url); - } catch (error) { - logger.error(`Failed to update ${resource.name} on startup:`, error); - } - }) - ); - - logger.info("Resource cache update complete"); - } -} diff --git a/src/main/services/user/index.ts b/src/main/services/user/index.ts new file mode 100644 index 00000000..b1d8c9b7 --- /dev/null +++ b/src/main/services/user/index.ts @@ -0,0 +1,2 @@ +export * from "./get-user-data"; +export * from "./sync-download-sources"; diff --git a/src/main/services/user/sync-download-sources.ts b/src/main/services/user/sync-download-sources.ts new file mode 100644 index 00000000..ff9819ce --- /dev/null +++ b/src/main/services/user/sync-download-sources.ts @@ -0,0 +1,41 @@ +import { HydraApi, logger } from "../"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; + +export const syncDownloadSourcesFromApi = async () => { + if (!HydraApi.isLoggedIn() || !HydraApi.hasActiveSubscription()) { + return; + } + + try { + const profileSources = await HydraApi.get( + "/profile/download-sources" + ); + + const existingSources = await downloadSourcesSublevel.values().all(); + const existingUrls = new Set(existingSources.map((source) => source.url)); + + for (const downloadSource of profileSources) { + if (!existingUrls.has(downloadSource.url)) { + try { + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + logger.log( + `Synced download source from profile: ${downloadSource.url}` + ); + } catch (error) { + logger.error( + `Failed to sync download source ${downloadSource.url}:`, + error + ); + } + } + } + } catch (error) { + logger.error("Failed to sync download sources from API:", error); + } +}; diff --git a/src/preload/index.ts b/src/preload/index.ts index da914b92..f89ec4db 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ addDownloadSource: (url: string) => ipcRenderer.invoke("addDownloadSource", url), - updateMissingFingerprints: () => - ipcRenderer.invoke("updateMissingFingerprints"), removeDownloadSource: (url: string, removeAll?: boolean) => ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), - deleteDownloadSource: (id: number) => - ipcRenderer.invoke("deleteDownloadSource", id), - deleteAllDownloadSources: () => - ipcRenderer.invoke("deleteAllDownloadSources"), - validateDownloadSource: (url: string) => - ipcRenderer.invoke("validateDownloadSource", url), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), - getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"), - checkDownloadSourceExists: (url: string) => - ipcRenderer.invoke("checkDownloadSourceExists", url), - getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"), /* Library */ toggleAutomaticCloudSync: ( diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 74a2a97e..168a4435 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -7,7 +7,6 @@ import { useAppSelector, useDownload, useLibrary, - useRepacks, useToast, useUserDetails, } from "@renderer/hooks"; @@ -20,7 +19,6 @@ import { setUserDetails, setProfileBackground, setGameRunning, - setIsImportingSources, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; @@ -40,8 +38,6 @@ export function App() { const { t } = useTranslation("app"); - const { updateRepacks } = useRepacks(); - const { clearDownload, setLastPacket } = useDownload(); const { @@ -199,36 +195,6 @@ export function App() { }); }, [dispatch, draggingDisabled]); - useEffect(() => { - (async () => { - dispatch(setIsImportingSources(true)); - - try { - // Initial repacks load - await updateRepacks(); - - // Sync all local sources (check for updates) - const newRepacksCount = await window.electron.syncDownloadSources(); - - if (newRepacksCount > 0) { - window.electron.publishNewRepacksNotification(newRepacksCount); - } - - // Update fingerprints for sources that don't have them - await window.electron.updateMissingFingerprints(); - - // Update repacks AFTER all syncing and fingerprint updates are complete - await updateRepacks(); - } catch (error) { - console.error("Error syncing download sources:", error); - // Still update repacks even if sync fails - await updateRepacks(); - } finally { - dispatch(setIsImportingSources(false)); - } - })(); - }, [updateRepacks, dispatch]); - const loadAndApplyTheme = useCallback(async () => { const activeTheme = await window.electron.getActiveCustomTheme(); if (activeTheme?.code) { diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 5752ba19..edea8d50 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -1,5 +1,5 @@ import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; -import type { GameStats } from "@types"; +import type { GameStats, ShopAssets } from "@types"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; @@ -8,15 +8,15 @@ import "./game-card.scss"; import { useTranslation } from "react-i18next"; import { Badge } from "../badge/badge"; import { StarRating } from "../star-rating/star-rating"; -import { useCallback, useState, useMemo } from "react"; -import { useFormat, useRepacks } from "@renderer/hooks"; +import { useCallback, useState } from "react"; +import { useFormat } from "@renderer/hooks"; export interface GameCardProps extends React.DetailedHTMLProps< React.ButtonHTMLAttributes, HTMLButtonElement > { - game: any; + game: ShopAssets; } const shopIcon = { @@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) { const [stats, setStats] = useState(null); - const { getRepacksForObjectId } = useRepacks(); - const repacks = getRepacksForObjectId(game.objectId); - - const uniqueRepackers = Array.from( - new Set(repacks.map((repack) => repack.repacker)) - ); - const handleHover = useCallback(() => { if (!stats) { window.electron.getGameStats(game.objectId, game.shop).then((stats) => { @@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) { const { numberFormatter } = useFormat(); - const firstThreeRepackers = useMemo( - () => uniqueRepackers.slice(0, 3), - [uniqueRepackers] - ); - const remainingCount = useMemo( - () => uniqueRepackers.length - 3, - [uniqueRepackers] - ); - return ( )} - {rightContent} - {hintContent} ); } ); - TextField.displayName = "TextField"; diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 3706b02e..c5b88607 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,11 +1,4 @@ -import { - createContext, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { setHeaderTitle } from "@renderer/features"; import { getSteamLanguage } from "@renderer/helpers"; @@ -13,11 +6,11 @@ import { useAppDispatch, useAppSelector, useDownload, - useRepacks, useUserDetails, } from "@renderer/hooks"; import type { + GameRepack, GameShop, GameStats, LibraryGame, @@ -84,12 +77,7 @@ export function GameDetailsContextProvider({ const [isGameRunning, setIsGameRunning] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); - - const { getRepacksForObjectId } = useRepacks(); - - const repacks = useMemo(() => { - return getRepacksForObjectId(objectId); - }, [getRepacksForObjectId, objectId]); + const [repacks, setRepacks] = useState([]); const { i18n } = useTranslation("game_details"); const location = useLocation(); @@ -289,19 +277,6 @@ export function GameDetailsContextProvider({ } }, [location]); - const lastDownloadedOption = useMemo(() => { - if (game?.download) { - const repack = repacks.find((repack) => - repack.uris.some((uri) => uri.includes(game.download!.uri)) - ); - - if (!repack) return null; - return repack; - } - - return null; - }, [game?.download, repacks]); - useEffect(() => { const unsubscribe = window.electron.onUpdateAchievements( objectId, @@ -317,6 +292,34 @@ export function GameDetailsContextProvider({ }; }, [objectId, shop, userDetails]); + useEffect(() => { + const fetchDownloadSources = async () => { + try { + const sources = await window.electron.getDownloadSources(); + + const params = { + take: 100, + skip: 0, + downloadSourceIds: sources.map((source) => source.id), + }; + + const downloads = await window.electron.hydraApi.get( + `/games/${shop}/${objectId}/download-sources`, + { + params, + needsAuth: false, + } + ); + + setRepacks(downloads); + } catch (error) { + console.error("Failed to fetch download sources:", error); + } + }; + + fetchDownloadSources(); + }, [shop, objectId]); + const getDownloadsPath = async () => { if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; return window.electron.getDefaultDownloadsPath(); @@ -361,7 +364,7 @@ export function GameDetailsContextProvider({ stats, achievements, hasNSFWContentBlocked, - lastDownloadedOption, + lastDownloadedOption: null, setHasNSFWContentBlocked, selectGameExecutable, updateGame, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 9f882aed..fa4ab3d6 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -31,8 +31,6 @@ import type { Game, DiskUsage, DownloadSource, - DownloadSourceValidationResult, - GameRepack, } from "@types"; import type { AxiosProgressEvent } from "axios"; @@ -210,20 +208,12 @@ declare global { /* Download sources */ addDownloadSource: (url: string) => Promise; - updateMissingFingerprints: () => Promise; - removeDownloadSource: (url: string, removeAll?: boolean) => Promise; - getDownloadSources: () => Promise< - Pick[] - >; - deleteDownloadSource: (id: number) => Promise; - deleteAllDownloadSources: () => Promise; - validateDownloadSource: ( - url: string - ) => Promise; - syncDownloadSources: () => Promise; - getDownloadSourcesList: () => Promise; - checkDownloadSourceExists: (url: string) => Promise; - getAllRepacks: () => Promise; + removeDownloadSource: ( + removeAll = false, + downloadSourceId?: string + ) => Promise; + getDownloadSources: () => Promise; + syncDownloadSources: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/features/download-sources-slice.ts b/src/renderer/src/features/download-sources-slice.ts deleted file mode 100644 index 52e58d26..00000000 --- a/src/renderer/src/features/download-sources-slice.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; - -export interface DownloadSourcesState { - isImporting: boolean; -} - -const initialState: DownloadSourcesState = { - isImporting: false, -}; - -export const downloadSourcesSlice = createSlice({ - name: "downloadSources", - initialState, - reducers: { - setIsImportingSources: (state, action) => { - state.isImporting = action.payload; - }, - }, -}); - -export const { setIsImportingSources } = downloadSourcesSlice.actions; diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts index 3b602cff..a7e64e1f 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -6,6 +6,4 @@ export * from "./toast-slice"; export * from "./user-details-slice"; export * from "./game-running.slice"; export * from "./subscription-slice"; -export * from "./repacks-slice"; -export * from "./download-sources-slice"; export * from "./catalogue-search"; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 8140e0cd..73733e2b 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -5,5 +5,4 @@ export * from "./use-toast"; export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; -export * from "./use-repacks"; export * from "./use-feature"; diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts index 1d0aeb57..675f5013 100644 --- a/src/renderer/src/hooks/use-catalogue.ts +++ b/src/renderer/src/hooks/use-catalogue.ts @@ -2,6 +2,7 @@ import axios from "axios"; import { useCallback, useEffect, useState } from "react"; import { useAppDispatch } from "./redux"; import { setGenres, setTags } from "@renderer/features"; +import type { DownloadSource } from "@types"; export const externalResourcesInstance = axios.create({ baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, @@ -12,6 +13,7 @@ export function useCatalogue() { const [steamPublishers, setSteamPublishers] = useState([]); const [steamDevelopers, setSteamDevelopers] = useState([]); + const [downloadSources, setDownloadSources] = useState([]); const getSteamUserTags = useCallback(() => { externalResourcesInstance.get("/steam-user-tags.json").then((response) => { @@ -37,17 +39,25 @@ export function useCatalogue() { }); }, []); + const getDownloadSources = useCallback(() => { + window.electron.getDownloadSources().then((results) => { + setDownloadSources(results.filter((source) => !!source.fingerprint)); + }); + }, []); + useEffect(() => { getSteamUserTags(); getSteamGenres(); getSteamPublishers(); getSteamDevelopers(); + getDownloadSources(); }, [ getSteamUserTags, getSteamGenres, getSteamPublishers, getSteamDevelopers, + getDownloadSources, ]); - return { steamPublishers, steamDevelopers }; + return { steamPublishers, downloadSources, steamDevelopers }; } diff --git a/src/renderer/src/hooks/use-repacks.ts b/src/renderer/src/hooks/use-repacks.ts deleted file mode 100644 index c024aaa4..00000000 --- a/src/renderer/src/hooks/use-repacks.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { setRepacks } from "@renderer/features"; -import { useCallback } from "react"; -import { RootState } from "@renderer/store"; -import { useSelector } from "react-redux"; -import { useAppDispatch } from "./redux"; - -export function useRepacks() { - const dispatch = useAppDispatch(); - const repacks = useSelector((state: RootState) => state.repacks.value); - - const getRepacksForObjectId = useCallback( - (objectId: string) => { - return repacks.filter((repack) => repack.objectIds.includes(objectId)); - }, - [repacks] - ); - - const updateRepacks = useCallback(async () => { - const repacks = await window.electron.getAllRepacks(); - dispatch( - setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds))) - ); - }, [dispatch]); - - return { getRepacksForObjectId, updateRepacks }; -} diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index 07bcf3ff..bbeda906 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -1,4 +1,8 @@ -import type { CatalogueSearchResult, DownloadSource } from "@types"; +import type { + CatalogueSearchResult, + CatalogueSearchPayload, + DownloadSource, +} from "@types"; import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -29,13 +33,12 @@ export default function Catalogue() { const abortControllerRef = useRef(null); const cataloguePageRef = useRef(null); - const { steamDevelopers, steamPublishers } = useCatalogue(); + const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue(); const { steamGenres, steamUserTags } = useAppSelector( (state) => state.catalogueSearch ); - const [downloadSources, setDownloadSources] = useState([]); const [isLoading, setIsLoading] = useState(true); const [results, setResults] = useState([]); @@ -51,24 +54,41 @@ export default function Catalogue() { const { t, i18n } = useTranslation("catalogue"); const debouncedSearch = useRef( - debounce(async (filters, pageSize, offset) => { - const abortController = new AbortController(); - abortControllerRef.current = abortController; + debounce( + async ( + filters: CatalogueSearchPayload, + downloadSources: DownloadSource[], + pageSize: number, + offset: number + ) => { + const abortController = new AbortController(); + abortControllerRef.current = abortController; - const response = await window.electron.hydraApi.post<{ - edges: CatalogueSearchResult[]; - count: number; - }>("/catalogue/search", { - data: { ...filters, take: pageSize, skip: offset }, - needsAuth: false, - }); + const requestData = { + ...filters, + take: pageSize, + skip: offset, + downloadSourceIds: downloadSources.map( + (downloadSource) => downloadSource.id + ), + }; - if (abortController.signal.aborted) return; + const response = await window.electron.hydraApi.post<{ + edges: CatalogueSearchResult[]; + count: number; + }>("/catalogue/search", { + data: requestData, + needsAuth: false, + }); - setResults(response.edges); - setItemsCount(response.count); - setIsLoading(false); - }, 500) + if (abortController.signal.aborted) return; + + setResults(response.edges); + setItemsCount(response.count); + setIsLoading(false); + }, + 500 + ) ).current; const decodeHTML = (s: string) => @@ -79,18 +99,17 @@ export default function Catalogue() { setIsLoading(true); abortControllerRef.current?.abort(); - debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE); + debouncedSearch( + filters, + downloadSources, + PAGE_SIZE, + (page - 1) * PAGE_SIZE + ); return () => { debouncedSearch.cancel(); }; - }, [filters, page, debouncedSearch]); - - useEffect(() => { - window.electron.getDownloadSourcesList().then((sources) => { - setDownloadSources(sources.filter((source) => !!source.fingerprint)); - }); - }, []); + }, [filters, downloadSources, page, debouncedSearch]); const language = i18n.language.split("-")[0]; @@ -168,7 +187,7 @@ export default function Catalogue() { value: publisher, })), ]; - }, [filters, steamUserTags, steamGenresMapping, language, downloadSources]); + }, [filters, steamUserTags, downloadSources, steamGenresMapping, language]); const filterSections = useMemo(() => { return [ diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx index ecfe0f73..4583afd3 100644 --- a/src/renderer/src/pages/catalogue/game-item.tsx +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -1,6 +1,6 @@ import { Badge } from "@renderer/components"; import { buildGameDetailsPath } from "@renderer/helpers"; -import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useLibrary } from "@renderer/hooks"; import { useMemo, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; @@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) { const { steamGenres } = useAppSelector((state) => state.catalogueSearch); - const { getRepacksForObjectId } = useRepacks(); - - const repacks = getRepacksForObjectId(game.objectId); - const [isAddingToLibrary, setIsAddingToLibrary] = useState(false); const [added, setAdded] = useState(false); @@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) { } }; - const uniqueRepackers = useMemo(() => { - return Array.from(new Set(repacks.map((repack) => repack.repacker))); - }, [repacks]); - const genres = useMemo(() => { return game.genres?.map((genre) => { const index = steamGenres["en"]?.findIndex( @@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) { {genres.join(", ")}
- {uniqueRepackers.map((repacker) => ( - {repacker} + {game.downloadSources.map((sourceName) => ( + {sourceName} ))}
diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index c1b8b551..6bc28c10 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -103,7 +103,6 @@ export default function GameDetails() { automaticallyExtract: boolean ) => { const response = await startDownload({ - repackId: repack.id, objectId: objectId!, title: gameTitle, downloader, diff --git a/src/renderer/src/pages/game-details/hero.scss b/src/renderer/src/pages/game-details/hero.scss index 6bd63320..41264fe4 100644 --- a/src/renderer/src/pages/game-details/hero.scss +++ b/src/renderer/src/pages/game-details/hero.scss @@ -146,6 +146,8 @@ $hero-height: 350px; &__game-logo { width: 200px; align-self: flex-end; + object-fit: contain; + object-position: left bottom; @media (min-width: 768px) { width: 250px; @@ -153,6 +155,7 @@ $hero-height: 350px; @media (min-width: 1024px) { width: 300px; + max-height: 150px; } } 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 7551a31e..306e8647 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -54,7 +54,7 @@ export function RepacksModal({ {} ); - const { repacks, game } = useContext(gameDetailsContext); + const { game, repacks } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -88,6 +88,15 @@ export function RepacksModal({ }); }, [repacks, isFeatureEnabled, Feature]); + useEffect(() => { + const fetchDownloadSources = async () => { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + }; + + fetchDownloadSources(); + }, []); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -103,23 +112,13 @@ export function RepacksModal({ ); }, [repacks, hashesInDebrid]); - useEffect(() => { - window.electron.getDownloadSourcesList().then((sources) => { - const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker)); - const filteredSources = sources.filter( - (s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint - ); - setDownloadSources(filteredSources); - }); - }, [sortedRepacks]); - useEffect(() => { const term = filterTerm.trim().toLowerCase(); const byTerm = sortedRepacks.filter((repack) => { if (!term) return true; const lowerTitle = repack.title.toLowerCase(); - const lowerRepacker = repack.repacker.toLowerCase(); + const lowerRepacker = repack.downloadSourceName.toLowerCase(); return lowerTitle.includes(term) || lowerRepacker.includes(term); }); @@ -130,7 +129,7 @@ export function RepacksModal({ (src) => src.fingerprint && selectedFingerprints.includes(src.fingerprint) && - src.name === repack.repacker + src.name === repack.downloadSourceName ); }); @@ -281,7 +280,7 @@ export function RepacksModal({ )}

- {repack.fileSize} - {repack.repacker} -{" "} + {repack.fileSize} - {repack.downloadSourceName} -{" "} {repack.uploadDate ? formatDate(repack.uploadDate) : ""}

diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index 40bf181d..b8f632a6 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -40,14 +40,20 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const params = new URLSearchParams({ - take: "12", - skip: "0", - }); + const downloadSources = await window.electron.getDownloadSources(); + + const params = { + take: 12, + skip: 0, + downloadSourceIds: downloadSources.map((source) => source.id), + }; const catalogue = await window.electron.hydraApi.get( - `/catalogue/${category}?${params.toString()}`, - { needsAuth: false } + `/catalogue/${category}`, + { + params, + needsAuth: false, + } ); setCatalogue((prev) => ({ ...prev, [category]: catalogue })); diff --git a/src/renderer/src/pages/settings/add-download-source-modal.scss b/src/renderer/src/pages/settings/add-download-source-modal.scss index ea92ca71..d938f7f0 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.scss +++ b/src/renderer/src/pages/settings/add-download-source-modal.scss @@ -38,4 +38,11 @@ animation: spin 1s linear infinite; margin-right: calc(globals.$spacing-unit / 2); } + + &__actions { + display: flex; + justify-content: flex-end; + gap: globals.$spacing-unit; + margin-top: calc(globals.$spacing-unit * 2); + } } diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index c2b47513..af6f8b4d 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -1,15 +1,13 @@ -import { useCallback, useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Modal, TextField } from "@renderer/components"; import { settingsContext } from "@renderer/context"; import { useForm } from "react-hook-form"; -import { useAppDispatch } from "@renderer/hooks"; +import { logger } from "@renderer/logger"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; -import type { DownloadSourceValidationResult } from "@types"; -import { setIsImportingSources } from "@renderer/features"; import { SyncIcon } from "@primer/octicons-react"; import "./add-download-source-modal.scss"; @@ -28,7 +26,6 @@ export function AddDownloadSourceModal({ onClose, onAddDownloadSource, }: Readonly) { - const [url, setUrl] = useState(""); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation("settings"); @@ -48,77 +45,43 @@ export function AddDownloadSourceModal({ resolver: yupResolver(schema), }); - const [validationResult, setValidationResult] = - useState(null); - const { sourceUrl } = useContext(settingsContext); - const dispatch = useAppDispatch(); + const onSubmit = async (values: FormValues) => { + setIsLoading(true); - const onSubmit = useCallback( - async (values: FormValues) => { - const exists = await window.electron.checkDownloadSourceExists( - values.url - ); + try { + await window.electron.addDownloadSource(values.url); - if (exists) { - setError("url", { - type: "server", - message: t("source_already_exists"), - }); + onClose(); + onAddDownloadSource(); + } catch (error) { + logger.error("Failed to add download source:", error); + const errorMessage = + error instanceof Error && error.message.includes("already exists") + ? t("download_source_already_exists") + : t("failed_add_download_source"); - return; - } - - const validationResult = await window.electron.validateDownloadSource( - values.url - ); - - setValidationResult(validationResult); - setUrl(values.url); - }, - [setError, t] - ); + setError("url", { + type: "server", + message: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; useEffect(() => { setValue("url", ""); clearErrors(); setIsLoading(false); - setValidationResult(null); if (sourceUrl) { setValue("url", sourceUrl); - handleSubmit(onSubmit)(); } - }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]); - - const handleAddDownloadSource = async () => { - if (validationResult) { - setIsLoading(true); - dispatch(setIsImportingSources(true)); - - try { - // Single call that handles: import → API sync → fingerprint - await window.electron.addDownloadSource(url); - - // Close modal and update UI - onClose(); - onAddDownloadSource(); - } catch (error) { - console.error("Failed to add download source:", error); - setError("url", { - type: "server", - message: "Failed to import source. Please try again.", - }); - } finally { - setIsLoading(false); - dispatch(setIsImportingSources(false)); - } - } - }; + }, [visible, clearErrors, setValue, sourceUrl]); const handleClose = () => { - // Prevent closing while importing if (isLoading) return; onClose(); }; @@ -132,49 +95,32 @@ export function AddDownloadSourceModal({ clickOutsideToClose={!isLoading} >
- + + +
- } - /> - - {validationResult && ( -
-
-

{validationResult?.name}

- - {t("found_download_option", { - count: validationResult?.downloadCount, - countFormatted: - validationResult?.downloadCount.toLocaleString(), - })} - -
- - + +
- )} +
); diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx index 9cf35541..f2825cca 100644 --- a/src/renderer/src/pages/settings/settings-account.tsx +++ b/src/renderer/src/pages/settings/settings-account.tsx @@ -201,7 +201,7 @@ export function SettingsAccount() {
-

Hydra Cloud

+

{t("hydra_cloud")}

{getHydraCloudSectionContent().description}
diff --git a/src/renderer/src/pages/settings/settings-download-sources.scss b/src/renderer/src/pages/settings/settings-download-sources.scss index a12bdff3..df0f5c8b 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.scss +++ b/src/renderer/src/pages/settings/settings-download-sources.scss @@ -1,5 +1,14 @@ @use "../../scss/globals.scss"; +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .settings-download-sources { &__list { padding: 0; @@ -22,6 +31,17 @@ &--syncing { opacity: globals.$disabled-opacity; } + + &--pending { + opacity: 0.6; + } + } + + &__spinner { + animation: spin 1s linear infinite; + margin-right: calc(globals.$spacing-unit / 2); + width: 12px; + height: 12px; } &__item-header { diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index f873b321..75f0cc73 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -16,12 +16,13 @@ import { TrashIcon, } from "@primer/octicons-react"; import { AddDownloadSourceModal } from "./add-download-source-modal"; -import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks"; +import { useAppDispatch, useToast } from "@renderer/hooks"; import { DownloadSourceStatus } from "@shared"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; import { setFilters, clearFilters } from "@renderer/features"; import "./settings-download-sources.scss"; +import { logger } from "@renderer/logger"; export function SettingsDownloadSources() { const [ @@ -35,7 +36,6 @@ export function SettingsDownloadSources() { useState(false); const [isRemovingDownloadSource, setIsRemovingDownloadSource] = useState(false); - const [isFetchingSources, setIsFetchingSources] = useState(true); const { sourceUrl, clearSourceUrl } = useContext(settingsContext); @@ -46,37 +46,53 @@ export function SettingsDownloadSources() { const navigate = useNavigate(); - const { updateRepacks } = useRepacks(); - - const getDownloadSources = async () => { - await window.electron - .getDownloadSourcesList() - .then((sources) => { - setDownloadSources(sources); - }) - .finally(() => { - setIsFetchingSources(false); - }); - }; - - useEffect(() => { - getDownloadSources(); - }, []); - useEffect(() => { if (sourceUrl) setShowAddDownloadSourceModal(true); }, [sourceUrl]); + useEffect(() => { + const fetchDownloadSources = async () => { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + }; + + fetchDownloadSources(); + }, []); + + useEffect(() => { + const hasPendingOrMatchingSource = downloadSources.some( + (source) => + source.status === DownloadSourceStatus.PendingMatching || + source.status === DownloadSourceStatus.Matching + ); + + if (!hasPendingOrMatchingSource || !downloadSources.length) { + return; + } + + const intervalId = setInterval(async () => { + try { + await window.electron.syncDownloadSources(); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + } catch (error) { + logger.error("Failed to fetch download sources:", error); + } + }, 5000); + + return () => clearInterval(intervalId); + }, [downloadSources]); + const handleRemoveSource = async (downloadSource: DownloadSource) => { setIsRemovingDownloadSource(true); try { - await window.electron.deleteDownloadSource(downloadSource.id); - await window.electron.removeDownloadSource(downloadSource.url); - + await window.electron.removeDownloadSource(false, downloadSource.id); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); showSuccessToast(t("removed_download_source")); - await getDownloadSources(); - updateRepacks(); + } catch (error) { + logger.error("Failed to remove download source:", error); } finally { setIsRemovingDownloadSource(false); } @@ -86,53 +102,47 @@ export function SettingsDownloadSources() { setIsRemovingDownloadSource(true); try { - await window.electron.deleteAllDownloadSources(); - await window.electron.removeDownloadSource("", true); - - showSuccessToast(t("removed_download_sources")); - await getDownloadSources(); - setShowConfirmationDeleteAllSourcesModal(false); - updateRepacks(); + await window.electron.removeDownloadSource(true); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + showSuccessToast(t("removed_all_download_sources")); + } catch (error) { + logger.error("Failed to remove all download sources:", error); } finally { setIsRemovingDownloadSource(false); + setShowConfirmationDeleteAllSourcesModal(false); } }; const handleAddDownloadSource = async () => { - // Refresh sources list and repacks after import completes - await getDownloadSources(); - - // Force repacks update to ensure UI reflects new data - await updateRepacks(); - - showSuccessToast(t("added_download_source")); + try { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + } catch (error) { + logger.error("Failed to refresh download sources:", error); + } }; const syncDownloadSources = async () => { setIsSyncingDownloadSources(true); - try { - // Sync local sources (check for updates) await window.electron.syncDownloadSources(); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); - // Refresh sources and repacks AFTER sync completes - await getDownloadSources(); - await updateRepacks(); - - showSuccessToast(t("download_sources_synced")); - } catch (error) { - console.error("Error syncing download sources:", error); - // Still refresh the UI even if sync fails - await getDownloadSources(); - await updateRepacks(); + showSuccessToast(t("download_sources_synced_successfully")); } finally { setIsSyncingDownloadSources(false); } }; const statusTitle = { - [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"), - [DownloadSourceStatus.Errored]: t("download_source_errored"), + [DownloadSourceStatus.PendingMatching]: t( + "download_source_pending_matching" + ), + [DownloadSourceStatus.Matched]: t("download_source_matched"), + [DownloadSourceStatus.Matching]: t("download_source_matching"), + [DownloadSourceStatus.Failed]: t("download_source_failed"), }; const handleModalClose = () => { @@ -142,7 +152,7 @@ export function SettingsDownloadSources() { const navigateToCatalogue = (fingerprint?: string) => { if (!fingerprint) { - console.error("Cannot navigate: fingerprint is undefined"); + logger.error("Cannot navigate: fingerprint is undefined"); return; } @@ -180,8 +190,7 @@ export function SettingsDownloadSources() { disabled={ !downloadSources.length || isSyncingDownloadSources || - isRemovingDownloadSource || - isFetchingSources + isRemovingDownloadSource } onClick={syncDownloadSources} > @@ -197,8 +206,7 @@ export function SettingsDownloadSources() { disabled={ isRemovingDownloadSource || isSyncingDownloadSources || - !downloadSources.length || - isFetchingSources + !downloadSources.length } > @@ -209,11 +217,7 @@ export function SettingsDownloadSources() { type="button" theme="outline" onClick={() => setShowAddDownloadSourceModal(true)} - disabled={ - isSyncingDownloadSources || - isFetchingSources || - isRemovingDownloadSource - } + disabled={isSyncingDownloadSources || isRemovingDownloadSource} > {t("add_download_source")} @@ -223,16 +227,25 @@ export function SettingsDownloadSources() {
    {downloadSources.map((downloadSource) => { + const isPendingOrMatching = + downloadSource.status === DownloadSourceStatus.PendingMatching || + downloadSource.status === DownloadSourceStatus.Matching; + return (
  • {downloadSource.name}

    - {statusTitle[downloadSource.status]} + + {isPendingOrMatching && ( + + )} + {statusTitle[downloadSource.status]} +
    diff --git a/src/renderer/src/pages/settings/settings-real-debrid.tsx b/src/renderer/src/pages/settings/settings-real-debrid.tsx index 42ba6ad9..db3a29a3 100644 --- a/src/renderer/src/pages/settings/settings-real-debrid.tsx +++ b/src/renderer/src/pages/settings/settings-real-debrid.tsx @@ -133,7 +133,7 @@ export function SettingsRealDebrid() { {t("save_changes")} } - placeholder="API Token" + placeholder={t("api_token")} hint={ diff --git a/src/renderer/src/pages/settings/settings-torbox.tsx b/src/renderer/src/pages/settings/settings-torbox.tsx index 610dc942..46c8e2f9 100644 --- a/src/renderer/src/pages/settings/settings-torbox.tsx +++ b/src/renderer/src/pages/settings/settings-torbox.tsx @@ -116,7 +116,7 @@ export function SettingsTorBox() { onChange={(event) => setForm({ ...form, torBoxApiToken: event.target.value }) } - placeholder="API Token" + placeholder={t("api_token")} rightContent={