diff --git a/package.json b/package.json index e0eb1591..342b078a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.0", + "version": "3.7.1", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -57,7 +57,6 @@ "crc": "^4.3.2", "create-desktop-shortcuts": "^1.11.1", "date-fns": "^3.6.0", - "dexie": "^4.0.10", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "embla-carousel-autoplay": "^8.6.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index d8a3df70..bf2793c5 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…" }, @@ -447,6 +446,7 @@ "found_download_option_one": "Found {{countFormatted}} download option", "found_download_option_other": "Found {{countFormatted}} download options", "import": "Import", + "importing": "Importing...", "public": "Public", "private": "Private", "friends_only": "Friends only", @@ -507,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", @@ -598,6 +587,7 @@ "activity": "Recent Activity", "library": "Library", "pinned": "Pinned", + "sort_by": "Sort by:", "achievements_earned": "Achievements earned", "played_recently": "Played recently", "playtime": "Playtime", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 226f77af..de9ccfdb 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -376,6 +376,7 @@ "found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga", "found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas", "import": "Importar", + "importing": "Importando...", "public": "Público", "private": "Privado", "friends_only": "Sólo amigos", 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 90328a28..c969e3bf 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…" }, @@ -435,6 +434,7 @@ "found_download_option_one": "{{countFormatted}} opção de download encontrada", "found_download_option_other": "{{countFormatted}} opções de download encontradas", "import": "Importar", + "importing": "Importando...", "privacy": "Privacidade", "private": "Privado", "friends_only": "Apenas amigos", @@ -495,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", @@ -591,10 +580,18 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", "last_time_played": "Última sessão {{period}}", "activity": "Atividades recentes", "library": "Biblioteca", + "pinned": "Fixados", + "sort_by": "Ordenar por:", + "achievements_earned": "Conquistas obtidas", + "played_recently": "Jogados recentemente", + "playtime": "Tempo de jogo", "total_play_time": "Tempo total de jogo", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "no_recent_activity_title": "Hmmm… nada por aqui", "no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?", "display_name": "Nome de exibição", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index d36e3083..962504d4 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -267,6 +267,7 @@ "found_download_option_one": "{{countFormatted}} opção de transferência encontrada", "found_download_option_other": "{{countFormatted}} opções de transferência encontradas", "import": "Importar", + "importing": "A importar...", "privacy": "Privacidade", "private": "Privado", "friends_only": "Apenas amigos", @@ -376,10 +377,18 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", "last_time_played": "Última sessão {{period}}", "activity": "Atividade recente", "library": "Biblioteca", + "pinned": "Fixados", + "sort_by": "Ordenar por:", + "achievements_earned": "Conquistas obtidas", + "played_recently": "Jogados recentemente", + "playtime": "Tempo de jogo", "total_play_time": "Tempo total de jogo", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "no_recent_activity_title": "Hmmm… não há nada por aqui", "no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?", "display_name": "Nome de apresentação", 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 f3b25ee4..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": "Распаковка файлов…" }, @@ -446,6 +445,7 @@ "found_download_option_one": "Найден {{countFormatted}} вариант загрузки", "found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки", "import": "Импортировать", + "importing": "Импортируется...", "public": "Публичный", "private": "Частный", "friends_only": "Только для друзей", @@ -506,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 new file mode 100644 index 00000000..e51cae3e --- /dev/null +++ b/src/main/events/download-sources/add-download-source.ts @@ -0,0 +1,76 @@ +import { registerEvent } from "../register-event"; +import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; +import { HydraApi, logger } from "@main/services"; +import { importDownloadSourceToLocal } from "./helpers"; + +const addDownloadSource = async ( + _event: Electron.IpcMainInvokeEvent, + url: string +) => { + const result = await importDownloadSourceToLocal(url, true); + if (!result) { + throw new Error("Failed to import download source"); + } + + // 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++; + } + } + + await HydraApi.post("/profile/download-sources", { + urls: [url], + }); + + const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( + "/download-sources", + { + objectIds: result.objectIds, + }, + { needsAuth: false } + ); + + // Update the source with fingerprint + const updatedSource = await downloadSourcesSublevel.get(`${result.id}`); + if (updatedSource) { + await downloadSourcesSublevel.put(`${result.id}`, { + ...updatedSource, + fingerprint, + updatedAt: new Date(), + }); + } + + // 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"); + } + + // 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 new file mode 100644 index 00000000..36dd88ce --- /dev/null +++ b/src/main/events/download-sources/check-download-source-exists.ts @@ -0,0 +1,17 @@ +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/create-download-sources.ts b/src/main/events/download-sources/create-download-sources.ts deleted file mode 100644 index cf1f8f51..00000000 --- a/src/main/events/download-sources/create-download-sources.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const createDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent, - urls: string[] -) => { - await HydraApi.post("/profile/download-sources", { - urls, - }); -}; - -registerEvent("createDownloadSources", createDownloadSources); diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts new file mode 100644 index 00000000..cbf3958f --- /dev/null +++ b/src/main/events/download-sources/delete-all-download-sources.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..5322b96c --- /dev/null +++ b/src/main/events/download-sources/delete-download-source.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 00000000..db26ad01 --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-list.ts @@ -0,0 +1,19 @@ +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/helpers.ts b/src/main/events/download-sources/helpers.ts new file mode 100644 index 00000000..2e7489fd --- /dev/null +++ b/src/main/events/download-sources/helpers.ts @@ -0,0 +1,367 @@ +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/put-download-source.ts b/src/main/events/download-sources/put-download-source.ts deleted file mode 100644 index 72297059..00000000 --- a/src/main/events/download-sources/put-download-source.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const putDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - objectIds: string[] -) => { - return HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds, - }, - { needsAuth: false } - ); -}; - -registerEvent("putDownloadSource", putDownloadSource); 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 new file mode 100644 index 00000000..3cac8819 --- /dev/null +++ b/src/main/events/download-sources/sync-download-sources-from-api.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000..88861074 --- /dev/null +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -0,0 +1,115 @@ +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"; + +const syncDownloadSources = async ( + _event: Electron.IpcMainInvokeEvent +): Promise => { + let newRepacksCount = 0; + + 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 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 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; + } +}; + +registerEvent("syncDownloadSources", syncDownloadSources); diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts new file mode 100644 index 00000000..7fd43c63 --- /dev/null +++ b/src/main/events/download-sources/update-missing-fingerprints.ts @@ -0,0 +1,67 @@ +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 new file mode 100644 index 00000000..2bc86df7 --- /dev/null +++ b/src/main/events/download-sources/validate-download-source.ts @@ -0,0 +1,32 @@ +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 b690e8a3..8d21aa11 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -61,9 +61,16 @@ 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/put-download-source"; +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"; @@ -91,7 +98,6 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; -import "./download-sources/create-download-sources"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; 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 f85c008b..f2f2dd40 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -1,6 +1,6 @@ import { registerEvent } from "../register-event"; import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; -import { randomUUID } from "crypto"; +import { randomUUID } from "node:crypto"; import type { GameShop } from "@types"; const addCustomGameToLibrary = async ( diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts index 22490c07..d1d77e9f 100644 --- a/src/main/events/library/cleanup-unused-assets.ts +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -19,7 +19,6 @@ const getAllCustomGameAssets = async (): Promise => { }; const getUsedAssetPaths = async (): Promise> => { - // Get all custom games from the level database const { gamesSublevel } = await import("@main/level"); const allGames = await gamesSublevel.iterator().all(); @@ -30,7 +29,6 @@ const getUsedAssetPaths = async (): Promise> => { const usedPaths = new Set(); customGames.forEach((game) => { - // Extract file paths from local URLs if (game.iconUrl?.startsWith("local:")) { usedPaths.add(game.iconUrl.replace("local:", "")); } diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts index 07c3d6f7..1f5aea0f 100644 --- a/src/main/events/library/copy-custom-game-asset.ts +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -1,7 +1,7 @@ import { registerEvent } from "../register-event"; import fs from "node:fs"; import path from "node:path"; -import { randomUUID } from "crypto"; +import { randomUUID } from "node:crypto"; import { ASSETS_PATH } from "@main/constants"; const copyCustomGameAsset = async ( @@ -13,29 +13,23 @@ const copyCustomGameAsset = async ( throw new Error("Source file does not exist"); } - // Ensure assets directory exists if (!fs.existsSync(ASSETS_PATH)) { fs.mkdirSync(ASSETS_PATH, { recursive: true }); } - // Create custom games assets subdirectory const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games"); if (!fs.existsSync(customGamesAssetsPath)) { fs.mkdirSync(customGamesAssetsPath, { recursive: true }); } - // Get file extension const fileExtension = path.extname(sourcePath); - // Generate unique filename const uniqueId = randomUUID(); const fileName = `${assetType}-${uniqueId}${fileExtension}`; const destinationPath = path.join(customGamesAssetsPath, fileName); - // Copy the file await fs.promises.copyFile(sourcePath, destinationPath); - // Return the local URL format return `local:${destinationPath}`; }; diff --git a/src/main/events/repacks/get-all-repacks.ts b/src/main/events/repacks/get-all-repacks.ts new file mode 100644 index 00000000..6eb83a39 --- /dev/null +++ b/src/main/events/repacks/get-all-repacks.ts @@ -0,0 +1,16 @@ +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/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/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts new file mode 100644 index 00000000..59104e3c --- /dev/null +++ b/src/main/level/sublevels/download-sources.ts @@ -0,0 +1,22 @@ +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; +} + +export const downloadSourcesSublevel = db.sublevel( + levelKeys.downloadSources, + { + valueEncoding: "json", + } +); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index f78f09b8..7224fc64 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -6,3 +6,5 @@ export * from "./game-stats-cache"; 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 bba35169..6faacd52 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -17,4 +17,6 @@ export const levelKeys = { language: "language", screenState: "screenState", rpcPassword: "rpcPassword", + downloadSources: "downloadSources", + repacks: "repacks", }; diff --git a/src/main/level/sublevels/repacks.ts b/src/main/level/sublevels/repacks.ts new file mode 100644 index 00000000..6257665b --- /dev/null +++ b/src/main/level/sublevels/repacks.ts @@ -0,0 +1,22 @@ +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 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/hydra-api.ts b/src/main/services/hydra-api.ts index 138614b7..dd26e6f0 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -102,8 +102,14 @@ export class HydraApi { WindowManager.mainWindow.webContents.send("on-signin"); await clearGamesRemoteIds(); uploadGamesBatch(); + // WSClient.close(); // WSClient.connect(); + + const { syncDownloadSourcesFromApi } = await import( + "../events/download-sources/sync-download-sources-from-api" + ); + syncDownloadSourcesFromApi(); } } 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 0368d661..da914b92 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -93,19 +93,28 @@ 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), /* Download sources */ - putDownloadSource: (objectIds: string[]) => - ipcRenderer.invoke("putDownloadSource", objectIds), - createDownloadSources: (urls: string[]) => - ipcRenderer.invoke("createDownloadSources", urls), + 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 0a8ee1e2..74a2a97e 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -20,14 +20,12 @@ import { setUserDetails, setProfileBackground, setGameRunning, + setIsImportingSources, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; -import { downloadSourcesWorker } from "./workers"; -import { downloadSourcesTable } from "./dexie"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; -import { generateUUID } from "./helpers"; import { injectCustomCss, removeCustomCss } from "./helpers"; import "./app.scss"; @@ -137,15 +135,6 @@ export function App() { }, [fetchUserDetails, updateUserDetails, dispatch]); const onSignIn = useCallback(() => { - window.electron.getDownloadSources().then((sources) => { - sources.forEach((source) => { - downloadSourcesWorker.postMessage([ - "IMPORT_DOWNLOAD_SOURCE", - source.url, - ]); - }); - }); - fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); @@ -211,41 +200,34 @@ export function App() { }, [dispatch, draggingDisabled]); useEffect(() => { - updateRepacks(); + (async () => { + dispatch(setIsImportingSources(true)); - const id = generateUUID(); - const channel = new BroadcastChannel(`download_sources:sync:${id}`); + try { + // Initial repacks load + await updateRepacks(); - channel.onmessage = async (event: MessageEvent) => { - const newRepacksCount = event.data; - window.electron.publishNewRepacksNotification(newRepacksCount); - updateRepacks(); + // Sync all local sources (check for updates) + const newRepacksCount = await window.electron.syncDownloadSources(); - const downloadSources = await downloadSourcesTable.toArray(); + if (newRepacksCount > 0) { + window.electron.publishNewRepacksNotification(newRepacksCount); + } - await Promise.all( - downloadSources - .filter((source) => !source.fingerprint) - .map(async (downloadSource) => { - const { fingerprint } = await window.electron.putDownloadSource( - downloadSource.objectIds - ); + // Update fingerprints for sources that don't have them + await window.electron.updateMissingFingerprints(); - return downloadSourcesTable.update(downloadSource.id, { - fingerprint, - }); - }) - ); - - channel.close(); - }; - - downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); - - return () => { - channel.close(); - }; - }, [updateRepacks]); + // 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(); diff --git a/src/renderer/src/components/achievements/notification/achievement-notification.scss b/src/renderer/src/components/achievements/notification/achievement-notification.scss index 0a41782e..090c91c4 100644 --- a/src/renderer/src/components/achievements/notification/achievement-notification.scss +++ b/src/renderer/src/components/achievements/notification/achievement-notification.scss @@ -302,7 +302,8 @@ $margin-bottom: 28px; } &--rare &__trophy-overlay { - background: linear-gradient( + background: + linear-gradient( 118deg, #e8ad15 18.96%, #d5900f 26.41%, diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 6e790500..5752ba19 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -109,12 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
- +
diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss index 942384fe..1a8ca315 100644 --- a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss @@ -5,10 +5,7 @@ display: flex; flex-direction: column; gap: calc(globals.$spacing-unit * 3); - width: 100%; - max-width: 500px; - margin: 0 auto; - text-align: center; + min-width: 500px; } &__form { diff --git a/src/renderer/src/components/star-rating/star-rating.tsx b/src/renderer/src/components/star-rating/star-rating.tsx index 50d08afd..5a10b140 100644 --- a/src/renderer/src/components/star-rating/star-rating.tsx +++ b/src/renderer/src/components/star-rating/star-rating.tsx @@ -1,76 +1,31 @@ -import { StarIcon, StarFillIcon } from "@primer/octicons-react"; +import { StarFillIcon } from "@primer/octicons-react"; import "./star-rating.scss"; export interface StarRatingProps { rating: number | null; - maxStars?: number; size?: number; - showCalculating?: boolean; - calculatingText?: string; - hideIcon?: boolean; } -export function StarRating({ - rating, - maxStars = 5, - size = 12, - showCalculating = false, - calculatingText = "Calculating", - hideIcon = false, -}: Readonly) { - if (rating === null && showCalculating) { - return ( -
- {!hideIcon && } - {calculatingText} -
- ); - } - +export function StarRating({ rating, size = 12 }: Readonly) { if (rating === null || rating === undefined) { return ( -
- {!hideIcon && } - -
- ); - } - - const filledStars = Math.floor(rating); - const hasHalfStar = rating % 1 >= 0.5; - const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0); - - return ( -
- {Array.from({ length: filledStars }, (_, index) => ( +
- ))} - - {hasHalfStar && ( -
- - -
- )} - - {Array.from({ length: emptyStars }, (_, index) => ( - - ))} + +
+ ); + } + // Always use single star mode with numeric score + return ( +
+ {rating.toFixed(1)}
); 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 7bbecd97..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, @@ -31,6 +30,9 @@ import type { AchievementNotificationInfo, Game, DiskUsage, + DownloadSource, + DownloadSourceValidationResult, + GameRepack, } from "@types"; import type { AxiosProgressEvent } from "axios"; @@ -190,9 +192,6 @@ declare global { ) => Promise; /* User preferences */ authenticateRealDebrid: (apiToken: string) => Promise; - authenticateAllDebrid: ( - apiKey: string - ) => Promise; authenticateTorBox: (apiToken: string) => Promise; getUserPreferences: () => Promise; updateUserPreferences: ( @@ -210,14 +209,21 @@ declare global { createSteamShortcut: (shop: GameShop, objectId: string) => Promise; /* Download sources */ - putDownloadSource: ( - objectIds: string[] - ) => Promise<{ fingerprint: string }>; - createDownloadSources: (urls: string[]) => Promise; + 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; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/dexie.ts b/src/renderer/src/dexie.ts deleted file mode 100644 index 7991dc8a..00000000 --- a/src/renderer/src/dexie.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { GameShop, HowLongToBeatCategory } from "@types"; -import { Dexie } from "dexie"; - -export interface HowLongToBeatEntry { - id?: number; - objectId: string; - categories: HowLongToBeatCategory[]; - shop: GameShop; - createdAt: Date; - updatedAt: Date; -} - -export const db = new Dexie("Hydra"); - -db.version(9).stores({ - repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`, - downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`, - howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`, -}); - -export const downloadSourcesTable = db.table("downloadSources"); -export const repacksTable = db.table("repacks"); -export const howLongToBeatEntriesTable = db.table( - "howLongToBeatEntries" -); - -db.open(); diff --git a/src/renderer/src/features/download-sources-slice.ts b/src/renderer/src/features/download-sources-slice.ts new file mode 100644 index 00000000..52e58d26 --- /dev/null +++ b/src/renderer/src/features/download-sources-slice.ts @@ -0,0 +1,21 @@ +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 9d48c0df..3b602cff 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -7,4 +7,5 @@ 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/use-repacks.ts b/src/renderer/src/hooks/use-repacks.ts index dbc918b9..c024aaa4 100644 --- a/src/renderer/src/hooks/use-repacks.ts +++ b/src/renderer/src/hooks/use-repacks.ts @@ -1,4 +1,3 @@ -import { repacksTable } from "@renderer/dexie"; import { setRepacks } from "@renderer/features"; import { useCallback } from "react"; import { RootState } from "@renderer/store"; @@ -16,18 +15,11 @@ export function useRepacks() { [repacks] ); - const updateRepacks = useCallback(() => { - repacksTable.toArray().then((repacks) => { - dispatch( - setRepacks( - JSON.parse( - JSON.stringify( - repacks.filter((repack) => Array.isArray(repack.objectIds)) - ) - ) - ) - ); - }); + 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 3c6a2b80..07bcf3ff 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -1,16 +1,10 @@ import type { CatalogueSearchResult, DownloadSource } from "@types"; -import { - useAppDispatch, - useAppSelector, - useFormat, - useRepacks, -} from "@renderer/hooks"; +import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; import "./catalogue.scss"; -import { downloadSourcesTable } from "@renderer/dexie"; import { FilterSection } from "./filter-section"; import { setFilters, setPage } from "@renderer/features"; import { useTranslation } from "react-i18next"; @@ -56,8 +50,6 @@ export default function Catalogue() { const { t, i18n } = useTranslation("catalogue"); - const { getRepacksForObjectId } = useRepacks(); - const debouncedSearch = useRef( debounce(async (filters, pageSize, offset) => { const abortController = new AbortController(); @@ -95,10 +87,10 @@ export default function Catalogue() { }, [filters, page, debouncedSearch]); useEffect(() => { - downloadSourcesTable.toArray().then((sources) => { + window.electron.getDownloadSourcesList().then((sources) => { setDownloadSources(sources.filter((source) => !!source.fingerprint)); }); - }, [getRepacksForObjectId]); + }, []); const language = i18n.language.split("-")[0]; @@ -192,13 +184,15 @@ export default function Catalogue() { }, { title: t("download_sources"), - items: downloadSources.map((source) => ({ - label: source.name, - value: source.fingerprint, - checked: filters.downloadSourceFingerprints.includes( - source.fingerprint - ), - })), + items: downloadSources + .filter((source) => source.fingerprint) + .map((source) => ({ + label: source.name, + value: source.fingerprint!, + checked: filters.downloadSourceFingerprints.includes( + source.fingerprint! + ), + })), key: "downloadSourceFingerprints", }, { 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/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss index d1ae2481..6f9e753c 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss @@ -25,11 +25,6 @@ overflow: hidden; border-radius: 8px; - @media (min-width: 1024px) { - width: 80%; - max-height: 400px; - } - @media (min-width: 1280px) { width: 60%; max-height: 500px; @@ -72,10 +67,6 @@ overflow-y: hidden; gap: calc(globals.$spacing-unit / 2); - @media (min-width: 1024px) { - width: 80%; - } - @media (min-width: 1280px) { width: 60%; } 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/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss index 5400df07..33558601 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -55,11 +55,8 @@ border: 1px solid var(--color-border); border-radius: 8px; background-color: var(--color-background-secondary); - background-image: linear-gradient( - 45deg, - rgba(255, 255, 255, 0.1) 25%, - transparent 25% - ), + background-image: + linear-gradient(45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%), linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%); diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 2de46304..8fbdf4a2 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -19,6 +19,68 @@ export interface EditGameModalProps { type AssetType = "icon" | "logo" | "hero"; +interface ElectronFile extends File { + path?: string; +} + +interface GameWithOriginalAssets extends Game { + originalIconPath?: string; + originalLogoPath?: string; + originalHeroPath?: string; +} + +interface LibraryGameWithCustomOriginalAssets extends LibraryGame { + customOriginalIconPath?: string; + customOriginalLogoPath?: string; + customOriginalHeroPath?: string; +} + +interface AssetPaths { + icon: string; + logo: string; + hero: string; +} + +interface AssetUrls { + icon: string | null; + logo: string | null; + hero: string | null; +} + +interface RemovedAssets { + icon: boolean; + logo: boolean; + hero: boolean; +} + +const VALID_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +] as const; + +const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const; + +const INITIAL_ASSET_PATHS: AssetPaths = { + icon: "", + logo: "", + hero: "", +}; + +const INITIAL_REMOVED_ASSETS: RemovedAssets = { + icon: false, + logo: false, + hero: false, +}; + +const INITIAL_ASSET_URLS: AssetUrls = { + icon: null, + logo: null, + hero: null, +}; + export function EditGameModal({ visible, onClose, @@ -30,33 +92,18 @@ export function EditGameModal({ const { showSuccessToast, showErrorToast } = useToast(); const [gameName, setGameName] = useState(""); - const [assetPaths, setAssetPaths] = useState({ - icon: "", - logo: "", - hero: "", - }); - const [assetDisplayPaths, setAssetDisplayPaths] = useState({ - icon: "", - logo: "", - hero: "", - }); - const [originalAssetPaths, setOriginalAssetPaths] = useState({ - icon: "", - logo: "", - hero: "", - }); - const [removedAssets, setRemovedAssets] = useState({ - icon: false, - logo: false, - hero: false, - }); - const [defaultUrls, setDefaultUrls] = useState({ - icon: null as string | null, - logo: null as string | null, - hero: null as string | null, - }); + const [assetPaths, setAssetPaths] = useState(INITIAL_ASSET_PATHS); + const [assetDisplayPaths, setAssetDisplayPaths] = + useState(INITIAL_ASSET_PATHS); + const [originalAssetPaths, setOriginalAssetPaths] = + useState(INITIAL_ASSET_PATHS); + const [removedAssets, setRemovedAssets] = useState( + INITIAL_REMOVED_ASSETS + ); + const [defaultUrls, setDefaultUrls] = useState(INITIAL_ASSET_URLS); const [isUpdating, setIsUpdating] = useState(false); const [selectedAssetType, setSelectedAssetType] = useState("icon"); + const [dragOverTarget, setDragOverTarget] = useState(null); const isCustomGame = (game: LibraryGame | Game): boolean => { return game.shop === "custom"; @@ -66,12 +113,18 @@ export function EditGameModal({ return url?.startsWith("local:") ? url.replace("local:", "") : ""; }; + const capitalizeAssetType = (assetType: AssetType): string => { + return assetType.charAt(0).toUpperCase() + assetType.slice(1); + }; + const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { - // Check if assets were removed (URLs are null but original paths exist) - const iconRemoved = !game.iconUrl && (game as any).originalIconPath; - const logoRemoved = !game.logoImageUrl && (game as any).originalLogoPath; + const gameWithAssets = game as GameWithOriginalAssets; + const iconRemoved = + !game.iconUrl && Boolean(gameWithAssets.originalIconPath); + const logoRemoved = + !game.logoImageUrl && Boolean(gameWithAssets.originalLogoPath); const heroRemoved = - !game.libraryHeroImageUrl && (game as any).originalHeroPath; + !game.libraryHeroImageUrl && Boolean(gameWithAssets.originalHeroPath); setAssetPaths({ icon: extractLocalPath(game.iconUrl), @@ -84,15 +137,14 @@ export function EditGameModal({ hero: extractLocalPath(game.libraryHeroImageUrl), }); setOriginalAssetPaths({ - icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl), + icon: gameWithAssets.originalIconPath || extractLocalPath(game.iconUrl), logo: - (game as any).originalLogoPath || extractLocalPath(game.logoImageUrl), + gameWithAssets.originalLogoPath || extractLocalPath(game.logoImageUrl), hero: - (game as any).originalHeroPath || + gameWithAssets.originalHeroPath || extractLocalPath(game.libraryHeroImageUrl), }); - // Set removed assets state based on whether assets were explicitly removed setRemovedAssets({ icon: iconRemoved, logo: logoRemoved, @@ -102,13 +154,15 @@ export function EditGameModal({ const setNonCustomGameAssets = useCallback( (game: LibraryGame) => { - // Check if assets were removed (custom URLs are null but original paths exist) + const gameWithAssets = game as LibraryGameWithCustomOriginalAssets; const iconRemoved = - !game.customIconUrl && (game as any).customOriginalIconPath; + !game.customIconUrl && Boolean(gameWithAssets.customOriginalIconPath); const logoRemoved = - !game.customLogoImageUrl && (game as any).customOriginalLogoPath; + !game.customLogoImageUrl && + Boolean(gameWithAssets.customOriginalLogoPath); const heroRemoved = - !game.customHeroImageUrl && (game as any).customOriginalHeroPath; + !game.customHeroImageUrl && + Boolean(gameWithAssets.customOriginalHeroPath); setAssetPaths({ icon: extractLocalPath(game.customIconUrl), @@ -122,17 +176,16 @@ export function EditGameModal({ }); setOriginalAssetPaths({ icon: - (game as any).customOriginalIconPath || + gameWithAssets.customOriginalIconPath || extractLocalPath(game.customIconUrl), logo: - (game as any).customOriginalLogoPath || + gameWithAssets.customOriginalLogoPath || extractLocalPath(game.customLogoImageUrl), hero: - (game as any).customOriginalHeroPath || + gameWithAssets.customOriginalHeroPath || extractLocalPath(game.customHeroImageUrl), }); - // Set removed assets state based on whether assets were explicitly removed setRemovedAssets({ icon: iconRemoved, logo: logoRemoved, @@ -171,29 +224,22 @@ export function EditGameModal({ setSelectedAssetType(assetType); }; - const getAssetPath = (assetType: AssetType): string => { - return assetPaths[assetType]; - }; - const getAssetDisplayPath = (assetType: AssetType): string => { - // If asset was removed, don't show any path if (removedAssets[assetType]) { return ""; } - // Use display path first, then fall back to original path return assetDisplayPaths[assetType] || originalAssetPaths[assetType]; }; - const setAssetPath = (assetType: AssetType, path: string): void => { + const updateAssetPaths = ( + assetType: AssetType, + path: string, + displayPath: string + ): void => { setAssetPaths((prev) => ({ ...prev, [assetType]: path })); - }; - - const setAssetDisplayPath = (assetType: AssetType, path: string): void => { - setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path })); - }; - - const getDefaultUrl = (assetType: AssetType): string | null => { - return defaultUrls[assetType]; + setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: displayPath })); + setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: displayPath })); + setRemovedAssets((prev) => ({ ...prev, [assetType]: false })); }; const getOriginalAssetUrl = (assetType: AssetType): string | null => { @@ -217,7 +263,7 @@ export function EditGameModal({ filters: [ { name: t("edit_game_modal_image_filter"), - extensions: ["jpg", "jpeg", "png", "gif", "webp"], + extensions: [...IMAGE_EXTENSIONS], }, ], }); @@ -229,41 +275,26 @@ export function EditGameModal({ originalPath, assetType ); - setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); - setAssetDisplayPath(assetType, originalPath); - // Store the original path for display purposes - setOriginalAssetPaths((prev) => ({ - ...prev, - [assetType]: originalPath, - })); - // Clear the removed flag when a new asset is selected - setRemovedAssets((prev) => ({ ...prev, [assetType]: false })); + updateAssetPaths( + assetType, + copiedAssetUrl.replace("local:", ""), + originalPath + ); } catch (error) { console.error(`Failed to copy ${assetType} asset:`, error); - setAssetPath(assetType, originalPath); - setAssetDisplayPath(assetType, originalPath); - setOriginalAssetPaths((prev) => ({ - ...prev, - [assetType]: originalPath, - })); - // Clear the removed flag when a new asset is selected - setRemovedAssets((prev) => ({ ...prev, [assetType]: false })); + updateAssetPaths(assetType, originalPath, originalPath); } } }; const handleRestoreDefault = (assetType: AssetType) => { - // Mark asset as removed and clear paths (for both custom and non-custom games) setRemovedAssets((prev) => ({ ...prev, [assetType]: true })); - setAssetPath(assetType, ""); - setAssetDisplayPath(assetType, ""); - // Don't clear originalAssetPaths - keep them for reference but don't use them for display + setAssetPaths((prev) => ({ ...prev, [assetType]: "" })); + setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: "" })); }; const getOriginalTitle = (): string => { if (!game) return ""; - - // For non-custom games, the original title is from shopDetails assets return shopDetails?.assets?.title || game.title || ""; }; @@ -274,12 +305,10 @@ export function EditGameModal({ const isTitleChanged = useMemo((): boolean => { if (!game || isCustomGame(game)) return false; - const originalTitle = getOriginalTitle(); + const originalTitle = shopDetails?.assets?.title || game.title || ""; return gameName.trim() !== originalTitle.trim(); }, [game, gameName, shopDetails]); - const [dragOverTarget, setDragOverTarget] = useState(null); - const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -300,14 +329,9 @@ export function EditGameModal({ }; const validateImageFile = (file: File): boolean => { - const validTypes = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", - ]; - return validTypes.includes(file.type); + return VALID_IMAGE_TYPES.includes( + file.type as (typeof VALID_IMAGE_TYPES)[number] + ); }; const processDroppedFile = async (file: File, assetType: AssetType) => { @@ -321,10 +345,6 @@ export function EditGameModal({ try { let filePath: string; - interface ElectronFile extends File { - path?: string; - } - if ("path" in file && typeof (file as ElectronFile).path === "string") { filePath = (file as ElectronFile).path!; } else { @@ -351,12 +371,13 @@ export function EditGameModal({ assetType ); - const assetPath = copiedAssetUrl.replace("local:", ""); - setAssetPath(assetType, assetPath); - setAssetDisplayPath(assetType, filePath); - + updateAssetPaths( + assetType, + copiedAssetUrl.replace("local:", ""), + filePath + ); showSuccessToast( - `${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` + `${capitalizeAssetType(assetType)} updated successfully!` ); if (!("path" in file) && filePath) { @@ -387,63 +408,45 @@ export function EditGameModal({ } }; - // Helper function to prepare custom game assets const prepareCustomGameAssets = (game: LibraryGame | Game) => { - // For custom games, check if asset was explicitly removed - let iconUrl; - if (removedAssets.icon) { - iconUrl = null; - } else if (assetPaths.icon) { - iconUrl = `local:${assetPaths.icon}`; - } else { - iconUrl = game.iconUrl; - } + const iconUrl = removedAssets.icon + ? null + : assetPaths.icon + ? `local:${assetPaths.icon}` + : game.iconUrl; - let logoImageUrl; - if (removedAssets.logo) { - logoImageUrl = null; - } else if (assetPaths.logo) { - logoImageUrl = `local:${assetPaths.logo}`; - } else { - logoImageUrl = game.logoImageUrl; - } + const logoImageUrl = removedAssets.logo + ? null + : assetPaths.logo + ? `local:${assetPaths.logo}` + : game.logoImageUrl; - // For hero image, if removed, restore to the original gradient or keep the original - let libraryHeroImageUrl; - if (removedAssets.hero) { - // If the original hero was a gradient (data URL), keep it, otherwise generate a new one - const originalHero = game.libraryHeroImageUrl; - libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml") - ? originalHero - : generateRandomGradient(); - } else { - libraryHeroImageUrl = assetPaths.hero + const libraryHeroImageUrl = removedAssets.hero + ? game.libraryHeroImageUrl?.startsWith("data:image/svg+xml") + ? game.libraryHeroImageUrl + : generateRandomGradient() + : assetPaths.hero ? `local:${assetPaths.hero}` : game.libraryHeroImageUrl; - } return { iconUrl, logoImageUrl, libraryHeroImageUrl }; }; - // Helper function to prepare non-custom game assets const prepareNonCustomGameAssets = () => { - const hasIconPath = assetPaths.icon; - let customIconUrl: string | null = null; - if (!removedAssets.icon && hasIconPath) { - customIconUrl = `local:${assetPaths.icon}`; - } + const customIconUrl = + !removedAssets.icon && assetPaths.icon + ? `local:${assetPaths.icon}` + : null; - const hasLogoPath = assetPaths.logo; - let customLogoImageUrl: string | null = null; - if (!removedAssets.logo && hasLogoPath) { - customLogoImageUrl = `local:${assetPaths.logo}`; - } + const customLogoImageUrl = + !removedAssets.logo && assetPaths.logo + ? `local:${assetPaths.logo}` + : null; - const hasHeroPath = assetPaths.hero; - let customHeroImageUrl: string | null = null; - if (!removedAssets.hero && hasHeroPath) { - customHeroImageUrl = `local:${assetPaths.hero}`; - } + const customHeroImageUrl = + !removedAssets.hero && assetPaths.hero + ? `local:${assetPaths.hero}` + : null; return { customIconUrl, @@ -452,7 +455,6 @@ export function EditGameModal({ }; }; - // Helper function to update custom game const updateCustomGame = async (game: LibraryGame | Game) => { const { iconUrl, logoImageUrl, libraryHeroImageUrl } = prepareCustomGameAssets(game); @@ -470,7 +472,6 @@ export function EditGameModal({ }); }; - // Helper function to update non-custom game const updateNonCustomGame = async (game: LibraryGame) => { const { customIconUrl, customLogoImageUrl, customHeroImageUrl } = prepareNonCustomGameAssets(); @@ -521,43 +522,17 @@ export function EditGameModal({ } }; - // Helper function to reset form to initial state const resetFormToInitialState = useCallback( (game: LibraryGame | Game) => { setGameName(game.title || ""); - - // Reset removed assets state - setRemovedAssets({ - icon: false, - logo: false, - hero: false, - }); - - // Clear all asset paths to ensure clean state - setAssetPaths({ - icon: "", - logo: "", - hero: "", - }); - setAssetDisplayPaths({ - icon: "", - logo: "", - hero: "", - }); - setOriginalAssetPaths({ - icon: "", - logo: "", - hero: "", - }); + setRemovedAssets(INITIAL_REMOVED_ASSETS); + setAssetPaths(INITIAL_ASSET_PATHS); + setAssetDisplayPaths(INITIAL_ASSET_PATHS); + setOriginalAssetPaths(INITIAL_ASSET_PATHS); if (isCustomGame(game)) { setCustomGameAssets(game); - // Clear default URLs for custom games - setDefaultUrls({ - icon: null, - logo: null, - hero: null, - }); + setDefaultUrls(INITIAL_ASSET_URLS); } else { setNonCustomGameAssets(game as LibraryGame); } @@ -575,8 +550,8 @@ export function EditGameModal({ const isFormValid = gameName.trim(); const getPreviewUrl = (assetType: AssetType): string | undefined => { - const assetPath = getAssetPath(assetType); - const defaultUrl = getDefaultUrl(assetType); + const assetPath = assetPaths[assetType]; + const defaultUrl = defaultUrls[assetType]; if (game && !isCustomGame(game)) { return assetPath ? `local:${assetPath}` : defaultUrl || undefined; @@ -585,9 +560,9 @@ export function EditGameModal({ }; const renderImageSection = (assetType: AssetType) => { - const assetPath = getAssetPath(assetType); + const assetPath = assetPaths[assetType]; const assetDisplayPath = getAssetDisplayPath(assetType); - const defaultUrl = getDefaultUrl(assetType); + const defaultUrl = defaultUrls[assetType]; const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl); const isDragOver = dragOverTarget === assetType; 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 f2d1f974..7551a31e 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,7 +15,6 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import { downloadSourcesTable } from "@renderer/dexie"; import type { DownloadSource } from "@types"; import type { GameRepack } from "@types"; @@ -105,7 +104,7 @@ export function RepacksModal({ }, [repacks, hashesInDebrid]); useEffect(() => { - downloadSourcesTable.toArray().then((sources) => { + 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 @@ -129,6 +128,7 @@ export function RepacksModal({ return downloadSources.some( (src) => + src.fingerprint && selectedFingerprints.includes(src.fingerprint) && src.name === repack.repacker ); @@ -210,25 +210,32 @@ export function RepacksModal({ className={`repacks-modal__download-sources ${isFilterDrawerOpen ? "repacks-modal__download-sources--open" : ""}`} >
- {downloadSources.map((source) => { - const label = source.name || source.url; - const truncatedLabel = - label.length > 16 ? label.substring(0, 16) + "..." : label; - return ( -
- toggleFingerprint(source.fingerprint)} - /> -
- ); - })} + {downloadSources + .filter( + ( + source + ): source is DownloadSource & { fingerprint: string } => + source.fingerprint !== undefined + ) + .map((source) => { + const label = source.name || source.url; + const truncatedLabel = + label.length > 16 ? label.substring(0, 16) + "..." : label; + return ( +
+ toggleFingerprint(source.fingerprint)} + /> +
+ ); + })}
diff --git a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx index 9a29f150..61c90389 100644 --- a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx @@ -25,7 +25,7 @@ export function HowLongToBeatSection({ return `${value} ${t(durationTranslation[unit])}`; }; - if (!howLongToBeatData || !isLoading) return null; + if (!howLongToBeatData && !isLoading) return null; return ( diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index d16f4d3f..3056e414 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -17,7 +17,6 @@ import { StarIcon, } from "@primer/octicons-react"; import { HowLongToBeatSection } from "./how-long-to-beat-section"; -import { howLongToBeatEntriesTable } from "@renderer/dexie"; import { SidebarSection } from "../sidebar-section/sidebar-section"; import { buildGameAchievementPath } from "@renderer/helpers"; import { useSubscription } from "@renderer/hooks/use-subscription"; @@ -80,41 +79,22 @@ export function Sidebar() { if (objectId) { setHowLongToBeat({ isLoading: true, data: null }); - howLongToBeatEntriesTable - .where({ shop, objectId }) - .first() - .then(async (cachedHowLongToBeat) => { - if (cachedHowLongToBeat) { - setHowLongToBeat({ - isLoading: false, - data: cachedHowLongToBeat.categories, - }); - } else { - try { - const howLongToBeat = await window.electron.hydraApi.get< - HowLongToBeatCategory[] | null - >(`/games/${shop}/${objectId}/how-long-to-beat`, { - needsAuth: false, - }); - - if (howLongToBeat) { - howLongToBeatEntriesTable.add({ - objectId, - shop: "steam", - createdAt: new Date(), - updatedAt: new Date(), - categories: howLongToBeat, - }); - } - - setHowLongToBeat({ isLoading: false, data: howLongToBeat }); - } catch (err) { - setHowLongToBeat({ isLoading: false, data: null }); - } + // Directly fetch from API without checking cache + window.electron.hydraApi + .get( + `/games/${shop}/${objectId}/how-long-to-beat`, + { + needsAuth: false, } + ) + .then((howLongToBeatData) => { + setHowLongToBeat({ isLoading: false, data: howLongToBeatData }); + }) + .catch(() => { + setHowLongToBeat({ isLoading: false, data: null }); }); } - }, [objectId, shop, gameTitle]); + }, [objectId, shop]); return (