From 24106eaeabc07efc2424df1fb6cb4a88e1231605 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 15 Oct 2025 13:58:40 +0100 Subject: [PATCH] feat: improving caching --- src/locales/en/translation.json | 12 - src/locales/hu/translation.json | 12 - src/locales/pt-BR/translation.json | 12 - src/locales/ro/translation.json | 6 +- src/locales/ru/translation.json | 12 - src/locales/uk/translation.json | 14 +- .../download-sources/add-download-source.ts | 29 +- .../delete-all-download-sources.ts | 4 +- .../delete-download-source.ts | 4 +- src/main/events/download-sources/helpers.ts | 136 +++----- .../sync-download-sources-from-api.ts | 4 +- .../download-sources/sync-download-sources.ts | 268 +-------------- .../update-missing-fingerprints.ts | 6 +- src/main/events/index.ts | 1 - .../authenticate-all-debrid.ts | 17 - src/main/main.ts | 9 +- src/main/services/download/all-debrid.ts | 315 ------------------ .../services/download/download-manager.ts | 22 -- src/main/services/download/index.ts | 1 - src/main/services/index.ts | 1 + src/main/services/resource-cache.ts | 157 +++++++++ src/preload/index.ts | 2 - src/renderer/src/constants.ts | 1 - src/renderer/src/declaration.d.ts | 4 - .../src/pages/downloads/download-group.tsx | 22 +- .../src/pages/game-details/game-details.scss | 16 +- .../src/pages/game-details/game-reviews.tsx | 1 + .../modals/download-settings-modal.tsx | 5 - .../pages/settings/settings-all-debrid.scss | 12 - .../pages/settings/settings-all-debrid.tsx | 129 ------- .../src/pages/settings/settings-debrid.tsx | 48 --- src/shared/constants.ts | 2 - src/shared/index.ts | 14 - src/types/download.types.ts | 8 - src/types/level.types.ts | 1 - 35 files changed, 246 insertions(+), 1061 deletions(-) delete mode 100644 src/main/events/user-preferences/authenticate-all-debrid.ts delete mode 100644 src/main/services/download/all-debrid.ts create mode 100644 src/main/services/resource-cache.ts delete mode 100644 src/renderer/src/pages/settings/settings-all-debrid.scss delete mode 100644 src/renderer/src/pages/settings/settings-all-debrid.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 066fd8a8..5b2da2d5 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -395,7 +395,6 @@ "stop_seeding": "Stop seeding", "resume_seeding": "Resume seeding", "options": "Manage", - "alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet", "extract": "Extract files", "extracting": "Extracting files…" }, @@ -508,17 +507,6 @@ "create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet", "create_torbox_account": "Click here if you don't have a TorBox account yet", "real_debrid_account_linked": "Real-Debrid account linked", - "enable_all_debrid": "Enable All-Debrid", - "all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.", - "all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid", - "all_debrid_account_linked": "All-Debrid account linked successfully", - "alldebrid_missing_key": "Please provide an API key", - "alldebrid_invalid_key": "Invalid API key", - "alldebrid_blocked": "Your API key is geo-blocked or IP-blocked", - "alldebrid_banned": "This account has been banned", - "alldebrid_unknown_error": "An unknown error occurred", - "alldebrid_invalid_response": "Invalid response from All-Debrid", - "alldebrid_network_error": "Network error. Please check your connection", "name_min_length": "Theme name must be at least 3 characters long", "import_theme": "Import theme", "import_theme_description": "You will import {{theme}} from the theme store", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 40945d50..7d868aee 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -394,7 +394,6 @@ "stop_seeding": "Seedelés leállítása", "resume_seeding": "Seedelés folytatása", "options": "Kezelés", - "alldebrid_size_not_supported": "Letöltési információ az AllDebrid-hez még nem támogatott", "extract": "Fájlok kibontása", "extracting": "Fájlok kibontása…" }, @@ -506,17 +505,6 @@ "create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod", "create_torbox_account": "Kattints ide ha még nincs TorBox fiókod", "real_debrid_account_linked": "Real-Debrid fiók összekapcsolva", - "enable_all_debrid": "All-Debrid bekapcsolása", - "all_debrid_description": "Az All-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését különböző forrásokból.", - "all_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel az All-Debridre", - "all_debrid_account_linked": "All-Debrid fiók összekapcsolva", - "alldebrid_missing_key": "Kérlek adj meg egy API key-t", - "alldebrid_invalid_key": "Érvénytelen API key", - "alldebrid_blocked": "Az API key-ed Földrajzilag vagy IP-alapján van blokkolva", - "alldebrid_banned": "Ez a fiók kitiltásra került", - "alldebrid_unknown_error": "Egy ismeretlen hiba történt", - "alldebrid_invalid_response": "Érvénytelen válasz az All-Debrid felől", - "alldebrid_network_error": "Hálózati hiba. Ellenőrízd az internetkapcsolatod", "name_min_length": "A téma neve legalább 3 karakter hosszú legyen", "import_theme": "Téma importálása", "import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index c578a91c..0f710ec4 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -383,7 +383,6 @@ "stop_seeding": "Parar de semear", "resume_seeding": "Semear", "options": "Gerenciar", - "alldebrid_size_not_supported": "Informações de download para AllDebrid ainda não são suportadas", "extract": "Extrair arquivos", "extracting": "Extraindo arquivos…" }, @@ -496,17 +495,6 @@ "create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid", "create_torbox_account": "Clique aqui se você ainda não tem uma conta do TorBox", "real_debrid_account_linked": "Conta Real-Debrid associada", - "enable_all_debrid": "Habilitar All-Debrid", - "all_debrid_description": "All-Debrid é um downloader sem restrições que permite baixar rapidamente arquivos de várias fontes.", - "all_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine o All-Debrid", - "all_debrid_account_linked": "Conta All-Debrid vinculada com sucesso", - "alldebrid_missing_key": "Por favor, forneça uma chave de API", - "alldebrid_invalid_key": "Chave de API inválida", - "alldebrid_blocked": "Sua chave de API está bloqueada por geolocalização ou IP", - "alldebrid_banned": "Esta conta foi banida", - "alldebrid_unknown_error": "Ocorreu um erro desconhecido", - "alldebrid_invalid_response": "Resposta inválida do All-Debrid", - "alldebrid_network_error": "Erro de rede. Por favor, verifique sua conexão", "name_min_length": "O nome do tema deve ter pelo menos 3 caracteres", "import_theme": "Importar tema", "import_theme_description": "Você irá importar {{theme}} da loja de temas", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index be02c7b4..8ed6fd39 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -135,11 +135,7 @@ "real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid", "debrid_linked_message": "Contul \"{{username}}\" a fost legat", "save_changes": "Salvează modificările", - "changes_saved": "Modificările au fost salvate cu succes", - "enable_all_debrid": "Activează All-Debrid", - "all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.", - "all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid", - "all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes" + "changes_saved": "Modificările au fost salvate cu succes" }, "notifications": { "download_complete": "Descărcare completă", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 0dd55837..895db29d 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -394,7 +394,6 @@ "stop_seeding": "Остановить раздачу", "resume_seeding": "Продолжить раздачу", "options": "Управлять", - "alldebrid_size_not_supported": "Информация о загрузке для AllDebrid пока не поддерживается", "extract": "Распаковать файлы", "extracting": "Распаковка файлов…" }, @@ -507,17 +506,6 @@ "create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid", "create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox", "real_debrid_account_linked": "Аккаунт Real-Debrid привязан", - "enable_all_debrid": "Включить All-Debrid", - "all_debrid_description": "All-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы из различных источников.", - "all_debrid_free_account_error": "Аккаунт \"{{username}}\" является бесплатным. Пожалуйста, оформите подписку на All-Debrid", - "all_debrid_account_linked": "Аккаунт All-Debrid успешно привязан", - "alldebrid_missing_key": "Пожалуйста, предоставьте API ключ", - "alldebrid_invalid_key": "Неверный API ключ", - "alldebrid_blocked": "Ваш API ключ заблокирован по геолокации или IP", - "alldebrid_banned": "Этот аккаунт был заблокирован", - "alldebrid_unknown_error": "Произошла неизвестная ошибка", - "alldebrid_invalid_response": "Неверный ответ от All-Debrid", - "alldebrid_network_error": "Ошибка сети. Пожалуйста, проверьте соединение", "name_min_length": "Название темы должно содержать не менее 3 символов", "import_theme": "Импортировать тему", "import_theme_description": "Вы импортируете {{theme}} из магазина тем", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index cd8395da..323d8ad5 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -395,8 +395,7 @@ "resume_seeding": "Продовжити сідінг", "options": "Налаштування", "extract": "Розархівувати файли", - "extracting": "Розархівовування файлів…", - "alldebrid_size_not_supported": "Інформація про завантаження для AllDebrid поки не підтримується" + "extracting": "Розархівовування файлів…" }, "settings": { "downloads_path": "Тека завантажень", @@ -519,17 +518,6 @@ "installing_common_redist": "Встановлюється…", "show_download_speed_in_megabytes": "Показувати швидкість завантаження в мегабайтах на секунду", "extract_files_by_default": "Розпаковувати файли після завантаження", - "enable_all_debrid": "Увімкнути All-Debrid", - "all_debrid_description": "All-Debrid - це необмежений завантажувач, який дозволяє швидко завантажувати файли з різних джерел.", - "all_debrid_free_account_error": "Обліковий запис \"{{username}}\" є безкоштовним. Будь ласка, оформіть підписку на All-Debrid", - "all_debrid_account_linked": "Обліковий запис All-Debrid успішно прив'язано", - "alldebrid_missing_key": "Будь ласка, надайте API-ключ", - "alldebrid_invalid_key": "Невірний API-ключ", - "alldebrid_blocked": "Ваш API-ключ заблоковано за геолокацією або IP", - "alldebrid_banned": "Цей обліковий запис було заблоковано", - "alldebrid_unknown_error": "Сталася невідома помилка", - "alldebrid_invalid_response": "Невірна відповідь від All-Debrid", - "alldebrid_network_error": "Помилка мережі. Будь ласка, перевірте з'єднання", "enable_steam_achievements": "Увімкнути пошук досягнень Steam", "achievement_custom_notification_position": "Позиція сповіщень про досягнення", "top-left": "Верхній лівий кут", diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index 801b0635..c3e30488 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -1,10 +1,7 @@ import { registerEvent } from "../register-event"; import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { HydraApi } from "@main/services"; -import { - importDownloadSourceToLocal, - invalidateDownloadSourcesCache, -} from "./helpers"; +import { HydraApi, logger } from "@main/services"; +import { importDownloadSourceToLocal } from "./helpers"; const addDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, @@ -26,17 +23,6 @@ const addDownloadSource = async ( } } - // Log for debugging - helps identify if repacks are being created - console.log( - `✅ Download source ${result.id} (${result.name}) created with ${repackCount} repacks` - ); - console.log( - ` Repack IDs: [${repackIds.slice(0, 5).join(", ")}${repackIds.length > 5 ? "..." : ""}]` - ); - console.log( - ` Object IDs: [${result.objectIds.slice(0, 5).join(", ")}${result.objectIds.length > 5 ? "..." : ""}]` - ); - await HydraApi.post("/profile/download-sources", { urls: [url], }); @@ -74,18 +60,15 @@ const addDownloadSource = async ( } if (finalRepackCount !== repackCount) { - console.warn( - `⚠️ Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}` + logger.warn( + `Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}` ); } else { - console.log( - `✅ Final verification passed: ${finalRepackCount} repacks confirmed` + logger.info( + `Final verification passed: ${finalRepackCount} repacks confirmed` ); } - // Invalidate cache to ensure fresh data on next read - invalidateDownloadSourcesCache(); - return { ...result, fingerprint, diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts index cdb781b7..cbf3958f 100644 --- a/src/main/events/download-sources/delete-all-download-sources.ts +++ b/src/main/events/download-sources/delete-all-download-sources.ts @@ -1,14 +1,12 @@ import { registerEvent } from "../register-event"; import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers"; +import { invalidateIdCaches } from "./helpers"; const deleteAllDownloadSources = async ( _event: Electron.IpcMainInvokeEvent ) => { await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]); - // Invalidate caches after clearing all sources - invalidateDownloadSourcesCache(); invalidateIdCaches(); }; diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts index 72de8746..5322b96c 100644 --- a/src/main/events/download-sources/delete-download-source.ts +++ b/src/main/events/download-sources/delete-download-source.ts @@ -1,6 +1,6 @@ import { registerEvent } from "../register-event"; import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers"; +import { invalidateIdCaches } from "./helpers"; const deleteDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, @@ -22,8 +22,6 @@ const deleteDownloadSource = async ( await downloadSourcesSublevel.del(`${id}`); - // Invalidate caches after deletion - invalidateDownloadSourcesCache(); invalidateIdCaches(); }; diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts index 7f49499d..ca765c3f 100644 --- a/src/main/events/download-sources/helpers.ts +++ b/src/main/events/download-sources/helpers.ts @@ -3,8 +3,9 @@ import { z } from "zod"; import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; import { DownloadSourceStatus } from "@shared"; import crypto from "crypto"; +import { logger, ResourceCache } from "@main/services"; -const downloadSourceSchema = z.object({ +export const downloadSourceSchema = z.object({ name: z.string().max(255), downloads: z.array( z.object({ @@ -16,51 +17,49 @@ const downloadSourceSchema = z.object({ ), }); -// Pre-computed title-to-Steam-ID mapping -type TitleHashMapping = Record; -let titleHashMappingCache: TitleHashMapping | null = null; -let titleHashMappingCacheTime = 0; -const TITLE_HASH_MAPPING_TTL = 86400000; // 24 hours +export type TitleHashMapping = Record; -const getTitleHashMapping = async (): Promise => { - const now = Date.now(); - if ( - titleHashMappingCache && - now - titleHashMappingCacheTime < TITLE_HASH_MAPPING_TTL - ) { +let titleHashMappingCache: TitleHashMapping | null = null; + +export const getTitleHashMapping = async (): Promise => { + if (titleHashMappingCache) { return titleHashMappingCache; } try { - const response = await axios.get( - "https://cdn.losbroxas.org/results_a4c50f70c2.json", - { - timeout: 10000, - } - ); + const cached = + ResourceCache.getCachedData("sources-manifest"); + if (cached) { + titleHashMappingCache = cached; + return cached; + } - titleHashMappingCache = response.data; - titleHashMappingCacheTime = now; - console.log( - `✅ Loaded title hash mapping with ${Object.keys(response.data).length} entries` + const fetched = await ResourceCache.fetchAndCache( + "sources-manifest", + "https://cdn.losbroxas.org/sources-manifest.json", + 10000 ); - return response.data; + titleHashMappingCache = fetched; + return fetched; } catch (error) { - console.error("Failed to fetch title hash mapping:", error); - // Return empty mapping on error - will fall back to fuzzy matching - return {}; + logger.error("Failed to fetch title hash mapping:", error); + return {} as TitleHashMapping; } }; -const hashTitle = (title: string): string => { +export const hashTitle = (title: string): string => { return crypto.createHash("sha256").update(title).digest("hex"); }; -type SteamGamesByLetter = Record; -type FormattedSteamGame = { id: string; name: string; formattedName: string }; -type FormattedSteamGamesByLetter = Record; +export type SteamGamesByLetter = Record; +export type FormattedSteamGame = { + id: string; + name: string; + formattedName: string; +}; +export type FormattedSteamGamesByLetter = Record; -const formatName = (name: string) => { +export const formatName = (name: string) => { return name .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") @@ -68,7 +67,7 @@ const formatName = (name: string) => { .replace(/[^a-z0-9]/g, ""); }; -const formatRepackName = (name: string) => { +export const formatRepackName = (name: string) => { return formatName(name.replace("[DL]", "")); }; @@ -85,32 +84,17 @@ interface DownloadSource { updatedAt: Date; } -let downloadSourcesCache: Map | null = null; -let downloadSourcesCacheTime = 0; -const CACHE_TTL = 5000; - const getDownloadSourcesMap = async (): Promise< Map > => { - const now = Date.now(); - if (downloadSourcesCache && now - downloadSourcesCacheTime < CACHE_TTL) { - return downloadSourcesCache; - } - const map = new Map(); for await (const [key, source] of downloadSourcesSublevel.iterator()) { map.set(key, source); } - downloadSourcesCache = map; - downloadSourcesCacheTime = now; return map; }; -export const invalidateDownloadSourcesCache = () => { - downloadSourcesCache = null; -}; - export const checkUrlExists = async (url: string): Promise => { const sources = await getDownloadSourcesMap(); for (const source of sources.values()) { @@ -121,43 +105,49 @@ export const checkUrlExists = async (url: string): Promise => { return false; }; -let steamGamesCache: FormattedSteamGamesByLetter | null = null; -let steamGamesCacheTime = 0; -const STEAM_GAMES_CACHE_TTL = 300000; +let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null; -const getSteamGames = async (): Promise => { - const now = Date.now(); - if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) { - return steamGamesCache; +export const getSteamGames = async (): Promise => { + if (steamGamesFormattedCache) { + return steamGamesFormattedCache; } - const response = await axios.get( - `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` + 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(response.data)) { + for (const [letter, games] of Object.entries(steamGames)) { formattedData[letter] = games.map((game) => ({ ...game, formattedName: formatName(game.name), })); } - steamGamesCache = formattedData; - steamGamesCacheTime = now; + steamGamesFormattedCache = formattedData; return formattedData; }; -type SublevelIterator = AsyncIterable<[string, { id: number }]>; +export type SublevelIterator = AsyncIterable<[string, { id: number }]>; -interface SublevelWithId { +export interface SublevelWithId { iterator: () => SublevelIterator; } let maxRepackId: number | null = null; let maxDownloadSourceId: number | null = null; -const getNextId = async (sublevel: SublevelWithId): Promise => { +export const getNextId = async (sublevel: SublevelWithId): Promise => { const isRepackSublevel = sublevel === repacksSublevel; const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel; @@ -190,7 +180,7 @@ export const invalidateIdCaches = () => { maxDownloadSourceId = null; }; -const addNewDownloads = async ( +export const addNewDownloads = async ( downloadSource: { id: number; name: string }, downloads: z.infer["downloads"], steamGames: FormattedSteamGamesByLetter @@ -202,7 +192,6 @@ const addNewDownloads = async ( const batch = repacksSublevel.batch(); - // Fetch the pre-computed hash mapping const titleHashMapping = await getTitleHashMapping(); let hashMatchCount = 0; let fuzzyMatchCount = 0; @@ -212,20 +201,16 @@ const addNewDownloads = async ( let objectIds: string[] = []; let usedHashMatch = false; - // FIRST: Try hash-based lookup (fast and accurate) const titleHash = hashTitle(download.title); const steamIdsFromHash = titleHashMapping[titleHash]; if (steamIdsFromHash && steamIdsFromHash.length > 0) { - // Found in hash mapping - trust it completely hashMatchCount++; usedHashMatch = true; - // Use the Steam IDs directly as strings (trust the hash mapping) objectIds = steamIdsFromHash.map(String); } - // FALLBACK: Use fuzzy matching ONLY if hash lookup found nothing if (!usedHashMatch) { let gamesInSteam: FormattedSteamGame[] = []; const formattedTitle = formatRepackName(download.title); @@ -234,12 +219,10 @@ const addNewDownloads = async ( const [firstLetter] = formattedTitle; const games = steamGames[firstLetter] || []; - // Try exact prefix match first gamesInSteam = games.filter((game) => formattedTitle.startsWith(game.formattedName) ); - // If no exact prefix match, try contains match (more lenient) if (gamesInSteam.length === 0) { gamesInSteam = games.filter( (game) => @@ -248,7 +231,6 @@ const addNewDownloads = async ( ); } - // If still no match, try checking all letters (not just first letter) if (gamesInSteam.length === 0) { for (const letter of Object.keys(steamGames)) { const letterGames = steamGames[letter] || []; @@ -275,13 +257,10 @@ const addNewDownloads = async ( } } - // Add matched game IDs to source tracking for (const id of objectIds) { objectIdsOnSource.add(id); } - // Create the repack even if no games matched - // This ensures all repacks from sources are imported const repack = { id: nextRepackId++, objectIds: objectIds, @@ -300,9 +279,8 @@ const addNewDownloads = async ( await batch.write(); - // Log matching statistics - console.log( - `📊 Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}` + logger.info( + `Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}` ); const existingSource = await downloadSourcesSublevel.get( @@ -352,8 +330,6 @@ export const importDownloadSourceToLocal = async ( await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource); - invalidateDownloadSourcesCache(); - const objectIds = await addNewDownloads( downloadSource, response.data.downloads, @@ -387,7 +363,5 @@ export const updateDownloadSourcePreservingTimestamp = async ( await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource); - invalidateDownloadSourcesCache(); - return updatedSource; }; 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 index 23769839..3cac8819 100644 --- a/src/main/events/download-sources/sync-download-sources-from-api.ts +++ b/src/main/events/download-sources/sync-download-sources-from-api.ts @@ -1,4 +1,4 @@ -import { HydraApi } from "@main/services"; +import { HydraApi, logger } from "@main/services"; import { importDownloadSourceToLocal, checkUrlExists } from "./helpers"; export const syncDownloadSourcesFromApi = async () => { @@ -14,6 +14,6 @@ export const syncDownloadSourcesFromApi = async () => { } } } catch (error) { - console.error("Failed to sync download sources from API:", 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 75a50459..88861074 100644 --- a/src/main/events/download-sources/sync-download-sources.ts +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -1,267 +1,13 @@ import { registerEvent } from "../register-event"; import axios, { AxiosError } from "axios"; -import { z } from "zod"; import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; import { DownloadSourceStatus } from "@shared"; -import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers"; -import crypto from "crypto"; - -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), - }) - ), -}); - -// Pre-computed title-to-Steam-ID mapping (shared with helpers.ts) -type TitleHashMapping = Record; -let titleHashMappingCache: TitleHashMapping | null = null; -let titleHashMappingCacheTime = 0; -const TITLE_HASH_MAPPING_TTL = 86400000; // 24 hours - -const getTitleHashMapping = async (): Promise => { - const now = Date.now(); - if ( - titleHashMappingCache && - now - titleHashMappingCacheTime < TITLE_HASH_MAPPING_TTL - ) { - return titleHashMappingCache; - } - - try { - const response = await axios.get( - "https://cdn.losbroxas.org/results_a4c50f70c2.json", - { - timeout: 10000, - } - ); - - titleHashMappingCache = response.data; - titleHashMappingCacheTime = now; - console.log( - `✅ Loaded title hash mapping with ${Object.keys(response.data).length} entries` - ); - return response.data; - } catch (error) { - console.error("Failed to fetch title hash mapping:", error); - return {}; - } -}; - -const hashTitle = (title: string): string => { - return crypto.createHash("sha256").update(title).digest("hex"); -}; - -type SteamGamesByLetter = Record; -type FormattedSteamGame = { id: string; name: string; formattedName: string }; -type FormattedSteamGamesByLetter = Record; - -const formatName = (name: string) => { - return name - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]/g, ""); -}; - -const formatRepackName = (name: string) => { - return formatName(name.replace("[DL]", "")); -}; - -let steamGamesCache: FormattedSteamGamesByLetter | null = null; -let steamGamesCacheTime = 0; -const STEAM_GAMES_CACHE_TTL = 300000; - -const getSteamGames = async (): Promise => { - const now = Date.now(); - if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) { - return steamGamesCache; - } - - const response = await axios.get( - `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` - ); - - const formattedData: FormattedSteamGamesByLetter = {}; - for (const [letter, games] of Object.entries(response.data)) { - formattedData[letter] = games.map((game) => ({ - ...game, - formattedName: formatName(game.name), - })); - } - - steamGamesCache = formattedData; - steamGamesCacheTime = now; - return formattedData; -}; - -type SublevelIterator = AsyncIterable<[string, { id: number }]>; - -interface SublevelWithId { - iterator: () => SublevelIterator; -} - -let maxRepackId: number | null = null; -let maxDownloadSourceId: number | null = null; - -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; -}; - -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(); - - // Fetch the pre-computed hash mapping - const titleHashMapping = await getTitleHashMapping(); - let hashMatchCount = 0; - let fuzzyMatchCount = 0; - let noMatchCount = 0; - - for (const download of downloads) { - let objectIds: string[] = []; - let usedHashMatch = false; - - // FIRST: Try hash-based lookup (fast and accurate) - const titleHash = hashTitle(download.title); - const steamIdsFromHash = titleHashMapping[titleHash]; - - if (steamIdsFromHash && steamIdsFromHash.length > 0) { - // Found in hash mapping - trust it completely - hashMatchCount++; - usedHashMatch = true; - - // Use the Steam IDs directly as strings (trust the hash mapping) - objectIds = steamIdsFromHash.map(String); - } - - // FALLBACK: Use fuzzy matching ONLY if hash lookup found nothing - if (!usedHashMatch) { - let gamesInSteam: FormattedSteamGame[] = []; - const formattedTitle = formatRepackName(download.title); - - if (formattedTitle && formattedTitle.length > 0) { - const [firstLetter] = formattedTitle; - const games = steamGames[firstLetter] || []; - - // Try exact prefix match first - gamesInSteam = games.filter((game) => - formattedTitle.startsWith(game.formattedName) - ); - - // If no exact prefix match, try contains match (more lenient) - if (gamesInSteam.length === 0) { - gamesInSteam = games.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - } - - // If still no match, try checking all letters (not just first letter) - 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++; - } - } - - // Add matched game IDs to source tracking - for (const id of objectIds) { - objectIdsOnSource.add(id); - } - - // Create the repack even if no games matched - // This ensures all repacks from sources are imported - 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(); - - // Log matching statistics - console.log( - `📊 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), - }); - } -}; +import { + invalidateIdCaches, + downloadSourceSchema, + getSteamGames, + addNewDownloads, +} from "./helpers"; const syncDownloadSources = async ( _event: Electron.IpcMainInvokeEvent @@ -358,8 +104,6 @@ const syncDownloadSources = async ( } } - // Invalidate caches after all sync operations complete - invalidateDownloadSourcesCache(); invalidateIdCaches(); return newRepacksCount; diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts index ac748779..7fd43c63 100644 --- a/src/main/events/download-sources/update-missing-fingerprints.ts +++ b/src/main/events/download-sources/update-missing-fingerprints.ts @@ -1,6 +1,6 @@ import { registerEvent } from "../register-event"; import { downloadSourcesSublevel } from "@main/level"; -import { HydraApi } from "@main/services"; +import { HydraApi, logger } from "@main/services"; const updateMissingFingerprints = async ( _event: Electron.IpcMainInvokeEvent @@ -27,7 +27,7 @@ const updateMissingFingerprints = async ( return 0; } - console.log( + logger.info( `Updating fingerprints for ${sourcesNeedingFingerprints.length} sources` ); @@ -53,7 +53,7 @@ const updateMissingFingerprints = async ( }); } } catch (error) { - console.error( + logger.error( `Failed to update fingerprint for source ${source.id}:`, error ); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 26c3b4bd..8d21aa11 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -61,7 +61,6 @@ import "./user-preferences/auto-launch"; import "./autoupdater/check-for-updates"; import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; -import "./user-preferences/authenticate-all-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; import "./download-sources/update-missing-fingerprints"; diff --git a/src/main/events/user-preferences/authenticate-all-debrid.ts b/src/main/events/user-preferences/authenticate-all-debrid.ts deleted file mode 100644 index 713db965..00000000 --- a/src/main/events/user-preferences/authenticate-all-debrid.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AllDebridClient } from "@main/services/download/all-debrid"; -import { registerEvent } from "../register-event"; - -const authenticateAllDebrid = async ( - _event: Electron.IpcMainInvokeEvent, - apiKey: string -) => { - AllDebridClient.authorize(apiKey); - const result = await AllDebridClient.getUser(); - if ("error_code" in result) { - return { error_code: result.error_code }; - } - - return result.user; -}; - -registerEvent("authenticateAllDebrid", authenticateAllDebrid); diff --git a/src/main/main.ts b/src/main/main.ts index 9b8ecc2b..5eecb101 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,7 +8,6 @@ import { CommonRedistManager, TorBoxClient, RealDebridClient, - AllDebridClient, Aria2, DownloadManager, HydraApi, @@ -17,11 +16,15 @@ import { Ludusavi, Lock, DeckyPlugin, + ResourceCache, } from "@main/services"; export const loadState = async () => { await Lock.acquireLock(); + ResourceCache.initialize(); + await ResourceCache.updateResourcesOnStartup(); + const userPreferences = await db.get( levelKeys.userPreferences, { @@ -39,10 +42,6 @@ export const loadState = async () => { RealDebridClient.authorize(userPreferences.realDebridApiToken); } - if (userPreferences?.allDebridApiKey) { - AllDebridClient.authorize(userPreferences.allDebridApiKey); - } - if (userPreferences?.torBoxApiToken) { TorBoxClient.authorize(userPreferences.torBoxApiToken); } diff --git a/src/main/services/download/all-debrid.ts b/src/main/services/download/all-debrid.ts deleted file mode 100644 index 05ee56c6..00000000 --- a/src/main/services/download/all-debrid.ts +++ /dev/null @@ -1,315 +0,0 @@ -import axios, { AxiosInstance } from "axios"; -import type { AllDebridUser } from "@types"; -import { logger } from "@main/services"; - -interface AllDebridMagnetStatus { - id: number; - filename: string; - size: number; - status: string; - statusCode: number; - downloaded: number; - uploaded: number; - seeders: number; - downloadSpeed: number; - uploadSpeed: number; - uploadDate: number; - completionDate: number; - links: Array<{ - link: string; - filename: string; - size: number; - }>; -} - -interface AllDebridError { - code: string; - message: string; -} - -interface AllDebridDownloadUrl { - link: string; - size?: number; - filename?: string; -} - -export class AllDebridClient { - private static instance: AxiosInstance; - private static readonly baseURL = "https://api.alldebrid.com/v4"; - - static authorize(apiKey: string) { - logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty"); - this.instance = axios.create({ - baseURL: this.baseURL, - params: { - agent: "hydra", - apikey: apiKey, - }, - }); - } - - static async getUser() { - try { - const response = await this.instance.get<{ - status: string; - data?: { user: AllDebridUser }; - error?: AllDebridError; - }>("/user"); - - logger.info("[AllDebrid] API Response:", response.data); - - if (response.data.status === "error") { - const error = response.data.error; - logger.error("[AllDebrid] API Error:", error); - if (error?.code === "AUTH_MISSING_APIKEY") { - return { error_code: "alldebrid_missing_key" }; - } - if (error?.code === "AUTH_BAD_APIKEY") { - return { error_code: "alldebrid_invalid_key" }; - } - if (error?.code === "AUTH_BLOCKED") { - return { error_code: "alldebrid_blocked" }; - } - if (error?.code === "AUTH_USER_BANNED") { - return { error_code: "alldebrid_banned" }; - } - return { error_code: "alldebrid_unknown_error" }; - } - - if (!response.data.data?.user) { - logger.error("[AllDebrid] No user data in response"); - return { error_code: "alldebrid_invalid_response" }; - } - - logger.info( - "[AllDebrid] Successfully got user:", - response.data.data.user.username - ); - return { user: response.data.data.user }; - } catch (error: any) { - logger.error("[AllDebrid] Request Error:", error); - if (error.response?.data?.error) { - return { error_code: "alldebrid_invalid_key" }; - } - return { error_code: "alldebrid_network_error" }; - } - } - - private static async uploadMagnet(magnet: string) { - try { - logger.info("[AllDebrid] Uploading magnet with params:", { magnet }); - - const response = await this.instance.get("/magnet/upload", { - params: { - magnets: [magnet], - }, - }); - - logger.info( - "[AllDebrid] Upload Magnet Raw Response:", - JSON.stringify(response.data, null, 2) - ); - - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } - - const magnetInfo = response.data.data.magnets[0]; - logger.info( - "[AllDebrid] Magnet Info:", - JSON.stringify(magnetInfo, null, 2) - ); - - if (magnetInfo.error) { - throw new Error(magnetInfo.error.message); - } - - return magnetInfo.id; - } catch (error: any) { - logger.error("[AllDebrid] Upload Magnet Error:", error); - throw error; - } - } - - private static async checkMagnetStatus( - magnetId: number - ): Promise { - try { - logger.info("[AllDebrid] Checking magnet status for ID:", magnetId); - - const response = await this.instance.get(`/magnet/status`, { - params: { - id: magnetId, - }, - }); - - logger.info( - "[AllDebrid] Check Magnet Status Raw Response:", - JSON.stringify(response.data, null, 2) - ); - - if (!response.data) { - throw new Error("No response data received"); - } - - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } - - // Verificăm noua structură a răspunsului - const magnetData = response.data.data?.magnets; - if (!magnetData || typeof magnetData !== "object") { - logger.error( - "[AllDebrid] Invalid response structure:", - JSON.stringify(response.data, null, 2) - ); - throw new Error("Invalid magnet status response format"); - } - - // Convertim răspunsul în formatul așteptat - const magnetStatus: AllDebridMagnetStatus = { - id: magnetData.id, - filename: magnetData.filename, - size: magnetData.size, - status: magnetData.status, - statusCode: magnetData.statusCode, - downloaded: magnetData.downloaded, - uploaded: magnetData.uploaded, - seeders: magnetData.seeders, - downloadSpeed: magnetData.downloadSpeed, - uploadSpeed: magnetData.uploadSpeed, - uploadDate: magnetData.uploadDate, - completionDate: magnetData.completionDate, - links: magnetData.links.map((link) => ({ - link: link.link, - filename: link.filename, - size: link.size, - })), - }; - - logger.info( - "[AllDebrid] Magnet Status:", - JSON.stringify(magnetStatus, null, 2) - ); - - return magnetStatus; - } catch (error: any) { - logger.error("[AllDebrid] Check Magnet Status Error:", error); - throw error; - } - } - - private static async unlockLink(link: string) { - try { - const response = await this.instance.get<{ - status: string; - data?: { link: string }; - error?: AllDebridError; - }>("/link/unlock", { - params: { - link, - }, - }); - - if (response.data.status === "error") { - throw new Error(response.data.error?.message || "Unknown error"); - } - - const unlockedLink = response.data.data?.link; - if (!unlockedLink) { - throw new Error("No download link received from AllDebrid"); - } - - return unlockedLink; - } catch (error: any) { - logger.error("[AllDebrid] Unlock Link Error:", error); - throw error; - } - } - - public static async getDownloadUrls( - uri: string - ): Promise { - try { - logger.info("[AllDebrid] Getting download URLs for URI:", uri); - - if (uri.startsWith("magnet:")) { - logger.info("[AllDebrid] Detected magnet link, uploading..."); - // 1. Upload magnet - const magnetId = await this.uploadMagnet(uri); - logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId); - - // 2. Verificăm statusul până când avem link-uri - let retries = 0; - let magnetStatus: AllDebridMagnetStatus; - - do { - magnetStatus = await this.checkMagnetStatus(magnetId); - logger.info( - "[AllDebrid] Magnet status:", - magnetStatus.status, - "statusCode:", - magnetStatus.statusCode - ); - - if (magnetStatus.statusCode === 4) { - // Ready - // Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează - const unlockedLinks = await Promise.all( - magnetStatus.links.map(async (link) => { - try { - const unlockedLink = await this.unlockLink(link.link); - logger.info( - "[AllDebrid] Successfully unlocked link:", - unlockedLink - ); - - return { - link: unlockedLink, - size: link.size, - filename: link.filename, - }; - } catch (error) { - logger.error( - "[AllDebrid] Failed to unlock link:", - link.link, - error - ); - throw new Error("Failed to unlock all links"); - } - }) - ); - - logger.info( - "[AllDebrid] Got unlocked download links:", - unlockedLinks - ); - console.log("[AllDebrid] FINAL LINKS →", unlockedLinks); - return unlockedLinks; - } - - if (retries++ > 30) { - // Maximum 30 de încercări - throw new Error("Timeout waiting for magnet to be ready"); - } - - await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări - } while (magnetStatus.statusCode !== 4); - } else { - logger.info("[AllDebrid] Regular link, unlocking..."); - // Pentru link-uri normale, doar debridam link-ul - const downloadUrl = await this.unlockLink(uri); - logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl); - return [ - { - link: downloadUrl, - }, - ]; - } - } catch (error: any) { - logger.error("[AllDebrid] Get Download URLs Error:", error); - throw error; - } - return []; // Add default return for TypeScript - } -} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 7c256b51..4dcebbb0 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -17,7 +17,6 @@ import { } from "./types"; import { calculateETA, getDirSize } from "./helpers"; import { RealDebridClient } from "./real-debrid"; -import { AllDebridClient } from "./all-debrid"; import path from "path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; @@ -379,27 +378,6 @@ export class DownloadManager { allow_multiple_connections: true, }; } - case Downloader.AllDebrid: { - const downloadUrls = await AllDebridClient.getDownloadUrls( - download.uri - ); - - if (!downloadUrls.length) - throw new Error(DownloadError.NotCachedInAllDebrid); - - const totalSize = downloadUrls.reduce( - (total, url) => total + (url.size || 0), - 0 - ); - - return { - action: "start", - game_id: downloadId, - url: downloadUrls.map((d) => d.link), - save_path: download.downloadPath, - total_size: totalSize, - }; - } case Downloader.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index c28d560b..f4e2eddc 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,4 +1,3 @@ export * from "./download-manager"; export * from "./real-debrid"; -export * from "./all-debrid"; export * from "./torbox"; diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 88b39d1b..c98f09e1 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -18,3 +18,4 @@ export * from "./library-sync"; export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; +export * from "./resource-cache"; diff --git a/src/main/services/resource-cache.ts b/src/main/services/resource-cache.ts new file mode 100644 index 00000000..0c44af81 --- /dev/null +++ b/src/main/services/resource-cache.ts @@ -0,0 +1,157 @@ +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: `${process.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/preload/index.ts b/src/preload/index.ts index 527c4cd5..da914b92 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -93,8 +93,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("autoLaunch", autoLaunchProps), authenticateRealDebrid: (apiToken: string) => ipcRenderer.invoke("authenticateRealDebrid", apiToken), - authenticateAllDebrid: (apiKey: string) => - ipcRenderer.invoke("authenticateAllDebrid", apiKey), authenticateTorBox: (apiToken: string) => ipcRenderer.invoke("authenticateTorBox", apiToken), diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index c5a30f33..3329a0cc 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -11,7 +11,6 @@ export const DOWNLOADER_NAME = { [Downloader.Datanodes]: "Datanodes", [Downloader.Mediafire]: "Mediafire", [Downloader.TorBox]: "TorBox", - [Downloader.AllDebrid]: "All-Debrid", [Downloader.Hydra]: "Nimbus", }; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 8de36342..9f882aed 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -8,7 +8,6 @@ import type { UserPreferences, StartGameDownloadPayload, RealDebridUser, - AllDebridUser, UserProfile, FriendRequestAction, UpdateProfileRequest, @@ -193,9 +192,6 @@ declare global { ) => Promise; /* User preferences */ authenticateRealDebrid: (apiToken: string) => Promise; - authenticateAllDebrid: ( - apiKey: string - ) => Promise; authenticateTorBox: (apiToken: string) => Promise; getUserPreferences: () => Promise; updateUserPreferences: ( diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index bfa27792..06e9face 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -114,15 +114,6 @@ export function DownloadGroup({ return

{t("deleting")}

; } - if (download.downloader === Downloader.AllDebrid) { - return ( - <> -

{progress}

-

{t("alldebrid_size_not_supported")}

- - ); - } - if (isGameDownloading) { if (lastPacket?.isDownloadingMetadata) { return

{t("downloading_metadata")}

; @@ -190,15 +181,6 @@ export function DownloadGroup({ } if (download.status === "active") { - if ((download.downloader as unknown as string) === "alldebrid") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

{t("alldebrid_size_not_supported")}

- - ); - } - return ( <>

{formatDownloadProgress(download.progress)}

@@ -293,9 +275,7 @@ export function DownloadGroup({ (download?.downloader === Downloader.RealDebrid && !userPreferences?.realDebridApiToken) || (download?.downloader === Downloader.TorBox && - !userPreferences?.torBoxApiToken) || - (download?.downloader === Downloader.AllDebrid && - !userPreferences?.allDebridApiKey); + !userPreferences?.torBoxApiToken); return [ { diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index 555d0797..56022b07 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -224,6 +224,12 @@ $hero-height: 300px; margin-top: calc(globals.$spacing-unit * 3); } + &__reviews-container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 4); + } + &__reviews-separator { height: 1px; background: rgba(255, 255, 255, 0.1); @@ -264,16 +270,6 @@ $hero-height: 300px; } &__review-item { - background: linear-gradient( - to right, - globals.$dark-background-color 0%, - globals.$dark-background-color 30%, - globals.$background-color 100% - ); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - padding: calc(globals.$spacing-unit * 2); - margin-bottom: calc(globals.$spacing-unit * 2); overflow: hidden; word-wrap: break-word; } diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx index 467b53b2..851852b2 100644 --- a/src/renderer/src/pages/game-details/game-reviews.tsx +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -501,6 +501,7 @@ export function GameReviews({ )}
0 ? 0.5 : 1, transition: "opacity 0.2s ease", diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index fec50c94..a6c32b6e 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -117,8 +117,6 @@ export function DownloadSettingsModal({ return userPreferences?.realDebridApiToken; if (downloader === Downloader.TorBox) return userPreferences?.torBoxApiToken; - if (downloader === Downloader.AllDebrid) - return userPreferences?.allDebridApiKey; if (downloader === Downloader.Hydra) return isFeatureEnabled(Feature.Nimbus); return true; @@ -133,7 +131,6 @@ export function DownloadSettingsModal({ downloaders, userPreferences?.realDebridApiToken, userPreferences?.torBoxApiToken, - userPreferences?.allDebridApiKey, ]); const handleChooseDownloadsPath = async () => { @@ -194,8 +191,6 @@ export function DownloadSettingsModal({ const shouldDisableButton = (downloader === Downloader.RealDebrid && !userPreferences?.realDebridApiToken) || - (downloader === Downloader.AllDebrid && - !userPreferences?.allDebridApiKey) || (downloader === Downloader.TorBox && !userPreferences?.torBoxApiToken) || (downloader === Downloader.Hydra && diff --git a/src/renderer/src/pages/settings/settings-all-debrid.scss b/src/renderer/src/pages/settings/settings-all-debrid.scss deleted file mode 100644 index 4427ca7d..00000000 --- a/src/renderer/src/pages/settings/settings-all-debrid.scss +++ /dev/null @@ -1,12 +0,0 @@ -.settings-all-debrid { - &__form { - display: flex; - flex-direction: column; - gap: 1rem; - } - - &__description { - margin: 0; - color: var(--text-secondary); - } -} diff --git a/src/renderer/src/pages/settings/settings-all-debrid.tsx b/src/renderer/src/pages/settings/settings-all-debrid.tsx deleted file mode 100644 index aa821bc1..00000000 --- a/src/renderer/src/pages/settings/settings-all-debrid.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useContext, useEffect, useState } from "react"; -import { Trans, useTranslation } from "react-i18next"; - -import { Button, CheckboxField, Link, TextField } from "@renderer/components"; -import "./settings-all-debrid.scss"; - -import { useAppSelector, useToast } from "@renderer/hooks"; - -import { settingsContext } from "@renderer/context"; - -const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys"; - -export function SettingsAllDebrid() { - const userPreferences = useAppSelector( - (state) => state.userPreferences.value - ); - - const { updateUserPreferences } = useContext(settingsContext); - - const [isLoading, setIsLoading] = useState(false); - const [form, setForm] = useState({ - useAllDebrid: false, - allDebridApiKey: null as string | null, - }); - - const { showSuccessToast, showErrorToast } = useToast(); - - const { t } = useTranslation("settings"); - - useEffect(() => { - if (userPreferences) { - setForm({ - useAllDebrid: Boolean(userPreferences.allDebridApiKey), - allDebridApiKey: userPreferences.allDebridApiKey ?? null, - }); - } - }, [userPreferences]); - - const handleFormSubmit: React.FormEventHandler = async ( - event - ) => { - setIsLoading(true); - event.preventDefault(); - - try { - if (form.useAllDebrid) { - if (!form.allDebridApiKey) { - showErrorToast(t("alldebrid_missing_key")); - return; - } - - const result = await window.electron.authenticateAllDebrid( - form.allDebridApiKey - ); - - if ("error_code" in result) { - showErrorToast(t(result.error_code)); - return; - } - - if (!result.isPremium) { - showErrorToast( - t("all_debrid_free_account_error", { username: result.username }) - ); - return; - } - - showSuccessToast( - t("all_debrid_account_linked"), - t("debrid_linked_message", { username: result.username }) - ); - } else { - showSuccessToast(t("changes_saved")); - } - - updateUserPreferences({ - allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null, - }); - } catch (err: any) { - showErrorToast(t("alldebrid_unknown_error")); - } finally { - setIsLoading(false); - } - }; - - const isButtonDisabled = - (form.useAllDebrid && !form.allDebridApiKey) || isLoading; - - return ( -
-

- {t("all_debrid_description")} -

- - - setForm((prev) => ({ - ...prev, - useAllDebrid: !form.useAllDebrid, - })) - } - /> - - {form.useAllDebrid && ( - - setForm({ ...form, allDebridApiKey: event.target.value }) - } - rightContent={ - - } - placeholder="API Key" - hint={ - - - - } - /> - )} - - ); -} diff --git a/src/renderer/src/pages/settings/settings-debrid.tsx b/src/renderer/src/pages/settings/settings-debrid.tsx index f4ac183a..a2214698 100644 --- a/src/renderer/src/pages/settings/settings-debrid.tsx +++ b/src/renderer/src/pages/settings/settings-debrid.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useMemo } from "react"; import { useFeature, useAppSelector } from "@renderer/hooks"; import { SettingsTorBox } from "./settings-torbox"; import { SettingsRealDebrid } from "./settings-real-debrid"; -import { SettingsAllDebrid } from "./settings-all-debrid"; import { motion, AnimatePresence } from "framer-motion"; import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; @@ -11,7 +10,6 @@ import "./settings-debrid.scss"; interface CollapseState { torbox: boolean; realDebrid: boolean; - allDebrid: boolean; } const sectionVariants = { @@ -71,7 +69,6 @@ export function SettingsDebrid() { return { torbox: !userPreferences?.torBoxApiToken, realDebrid: !userPreferences?.realDebridApiToken, - allDebrid: !userPreferences?.allDebridApiKey, }; }, [userPreferences]); @@ -178,51 +175,6 @@ export function SettingsDebrid() {
)} - -
-
- -

All-Debrid

- BETA - {userPreferences?.allDebridApiKey && ( - - )} -
- - - {!collapseState.allDebrid && ( - - - - )} - -
); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 352ffe12..851aec49 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,6 +1,5 @@ export enum Downloader { RealDebrid, - AllDebrid, Torrent, Gofile, PixelDrain, @@ -56,7 +55,6 @@ export enum AuthPage { export enum DownloadError { NotCachedOnRealDebrid = "download_error_not_cached_on_real_debrid", - NotCachedInAllDebrid = "download_error_not_cached_in_alldebrid", NotCachedOnTorBox = "download_error_not_cached_on_torbox", GofileQuotaExceeded = "download_error_gofile_quota_exceeded", RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized", diff --git a/src/shared/index.ts b/src/shared/index.ts index 9a4b4516..615fa36e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -124,7 +124,6 @@ export const getDownloadersForUri = (uri: string) => { Downloader.Hydra, Downloader.TorBox, Downloader.RealDebrid, - Downloader.AllDebrid, ]; } @@ -142,19 +141,6 @@ export const getDownloadersForUris = (uris: string[]) => { return Array.from(downloadersSet); }; -export const steamUrlBuilder = { - library: (objectId: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/header.jpg`, - libraryHero: (objectId: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/library_hero.jpg`, - logo: (objectId: string) => - `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/logo.png`, - cover: (objectId: string) => - `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/library_600x900.jpg`, - icon: (objectId: string, clientIcon: string) => - `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`, -}; - export const getDateLocale = (language: string) => { if (language.startsWith("pt")) return ptBR; if (language.startsWith("es")) return es; diff --git a/src/types/download.types.ts b/src/types/download.types.ts index d19a3b83..004d8f27 100644 --- a/src/types/download.types.ts +++ b/src/types/download.types.ts @@ -175,11 +175,3 @@ export interface SeedingStatus { status: DownloadStatus; uploadSpeed: number; } - -/* All-Debrid */ -export interface AllDebridUser { - username: string; - email: string; - isPremium: boolean; - premiumUntil: string; -} diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 93925068..053bd218 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -99,7 +99,6 @@ export interface UserPreferences { language?: string; realDebridApiToken?: string | null; torBoxApiToken?: string | null; - allDebridApiKey?: string | null; preferQuitInsteadOfHiding?: boolean; runAtStartup?: boolean; startMinimized?: boolean;