feat: improving caching

This commit is contained in:
Chubby Granny Chaser
2025-10-15 13:58:40 +01:00
parent 136a44473f
commit 24106eaeab
35 changed files with 246 additions and 1061 deletions

View File

@@ -395,7 +395,6 @@
"stop_seeding": "Stop seeding",
"resume_seeding": "Resume seeding",
"options": "Manage",
"alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet",
"extract": "Extract files",
"extracting": "Extracting files…"
},
@@ -508,17 +507,6 @@
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
"create_torbox_account": "Click here if you don't have a TorBox account yet",
"real_debrid_account_linked": "Real-Debrid account linked",
"enable_all_debrid": "Enable All-Debrid",
"all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.",
"all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid",
"all_debrid_account_linked": "All-Debrid account linked successfully",
"alldebrid_missing_key": "Please provide an API key",
"alldebrid_invalid_key": "Invalid API key",
"alldebrid_blocked": "Your API key is geo-blocked or IP-blocked",
"alldebrid_banned": "This account has been banned",
"alldebrid_unknown_error": "An unknown error occurred",
"alldebrid_invalid_response": "Invalid response from All-Debrid",
"alldebrid_network_error": "Network error. Please check your connection",
"name_min_length": "Theme name must be at least 3 characters long",
"import_theme": "Import theme",
"import_theme_description": "You will import {{theme}} from the theme store",

View File

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

View File

@@ -383,7 +383,6 @@
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Gerenciar",
"alldebrid_size_not_supported": "Informações de download para AllDebrid ainda não são suportadas",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
},
@@ -496,17 +495,6 @@
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
"create_torbox_account": "Clique aqui se você ainda não tem uma conta do TorBox",
"real_debrid_account_linked": "Conta Real-Debrid associada",
"enable_all_debrid": "Habilitar All-Debrid",
"all_debrid_description": "All-Debrid é um downloader sem restrições que permite baixar rapidamente arquivos de várias fontes.",
"all_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine o All-Debrid",
"all_debrid_account_linked": "Conta All-Debrid vinculada com sucesso",
"alldebrid_missing_key": "Por favor, forneça uma chave de API",
"alldebrid_invalid_key": "Chave de API inválida",
"alldebrid_blocked": "Sua chave de API está bloqueada por geolocalização ou IP",
"alldebrid_banned": "Esta conta foi banida",
"alldebrid_unknown_error": "Ocorreu um erro desconhecido",
"alldebrid_invalid_response": "Resposta inválida do All-Debrid",
"alldebrid_network_error": "Erro de rede. Por favor, verifique sua conexão",
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",

View File

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

View File

@@ -394,7 +394,6 @@
"stop_seeding": "Остановить раздачу",
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"alldebrid_size_not_supported": "Информация о загрузке для AllDebrid пока не поддерживается",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
},
@@ -507,17 +506,6 @@
"create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid",
"create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"enable_all_debrid": "Включить All-Debrid",
"all_debrid_description": "All-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы из различных источников.",
"all_debrid_free_account_error": "Аккаунт \"{{username}}\" является бесплатным. Пожалуйста, оформите подписку на All-Debrid",
"all_debrid_account_linked": "Аккаунт All-Debrid успешно привязан",
"alldebrid_missing_key": "Пожалуйста, предоставьте API ключ",
"alldebrid_invalid_key": "Неверный API ключ",
"alldebrid_blocked": "Ваш API ключ заблокирован по геолокации или IP",
"alldebrid_banned": "Этот аккаунт был заблокирован",
"alldebrid_unknown_error": "Произошла неизвестная ошибка",
"alldebrid_invalid_response": "Неверный ответ от All-Debrid",
"alldebrid_network_error": "Ошибка сети. Пожалуйста, проверьте соединение",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",

View File

@@ -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": "Верхній лівий кут",

View File

@@ -1,10 +1,7 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { HydraApi } from "@main/services";
import {
importDownloadSourceToLocal,
invalidateDownloadSourcesCache,
} from "./helpers";
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal } from "./helpers";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
@@ -26,17 +23,6 @@ const addDownloadSource = async (
}
}
// Log for debugging - helps identify if repacks are being created
console.log(
`✅ Download source ${result.id} (${result.name}) created with ${repackCount} repacks`
);
console.log(
` Repack IDs: [${repackIds.slice(0, 5).join(", ")}${repackIds.length > 5 ? "..." : ""}]`
);
console.log(
` Object IDs: [${result.objectIds.slice(0, 5).join(", ")}${result.objectIds.length > 5 ? "..." : ""}]`
);
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
@@ -74,18 +60,15 @@ const addDownloadSource = async (
}
if (finalRepackCount !== repackCount) {
console.warn(
`⚠️ Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
logger.warn(
`Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
);
} else {
console.log(
`Final verification passed: ${finalRepackCount} repacks confirmed`
logger.info(
`Final verification passed: ${finalRepackCount} repacks confirmed`
);
}
// Invalidate cache to ensure fresh data on next read
invalidateDownloadSourcesCache();
return {
...result,
fingerprint,

View File

@@ -1,14 +1,12 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers";
import { invalidateIdCaches } from "./helpers";
const deleteAllDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
) => {
await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
// Invalidate caches after clearing all sources
invalidateDownloadSourcesCache();
invalidateIdCaches();
};

View File

@@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers";
import { invalidateIdCaches } from "./helpers";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
@@ -22,8 +22,6 @@ const deleteDownloadSource = async (
await downloadSourcesSublevel.del(`${id}`);
// Invalidate caches after deletion
invalidateDownloadSourcesCache();
invalidateIdCaches();
};

View File

@@ -3,8 +3,9 @@ import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import crypto from "crypto";
import { logger, ResourceCache } from "@main/services";
const downloadSourceSchema = z.object({
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
@@ -16,51 +17,49 @@ const downloadSourceSchema = z.object({
),
});
// Pre-computed title-to-Steam-ID mapping
type TitleHashMapping = Record<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
let titleHashMappingCacheTime = 0;
const TITLE_HASH_MAPPING_TTL = 86400000; // 24 hours
export type TitleHashMapping = Record<string, number[]>;
const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
const now = Date.now();
if (
titleHashMappingCache &&
now - titleHashMappingCacheTime < TITLE_HASH_MAPPING_TTL
) {
let titleHashMappingCache: TitleHashMapping | null = null;
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
if (titleHashMappingCache) {
return titleHashMappingCache;
}
try {
const response = await axios.get<TitleHashMapping>(
"https://cdn.losbroxas.org/results_a4c50f70c2.json",
{
timeout: 10000,
}
);
const cached =
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
if (cached) {
titleHashMappingCache = cached;
return cached;
}
titleHashMappingCache = response.data;
titleHashMappingCacheTime = now;
console.log(
`✅ Loaded title hash mapping with ${Object.keys(response.data).length} entries`
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
"sources-manifest",
"https://cdn.losbroxas.org/sources-manifest.json",
10000
);
return response.data;
titleHashMappingCache = fetched;
return fetched;
} catch (error) {
console.error("Failed to fetch title hash mapping:", error);
// Return empty mapping on error - will fall back to fuzzy matching
return {};
logger.error("Failed to fetch title hash mapping:", error);
return {} as TitleHashMapping;
}
};
const hashTitle = (title: string): string => {
export const hashTitle = (title: string): string => {
return crypto.createHash("sha256").update(title).digest("hex");
};
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
type FormattedSteamGame = { id: string; name: string; formattedName: string };
type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
const formatName = (name: string) => {
export const formatName = (name: string) => {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
@@ -68,7 +67,7 @@ const formatName = (name: string) => {
.replace(/[^a-z0-9]/g, "");
};
const formatRepackName = (name: string) => {
export const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
@@ -85,32 +84,17 @@ interface DownloadSource {
updatedAt: Date;
}
let downloadSourcesCache: Map<string, DownloadSource> | null = null;
let downloadSourcesCacheTime = 0;
const CACHE_TTL = 5000;
const getDownloadSourcesMap = async (): Promise<
Map<string, DownloadSource>
> => {
const now = Date.now();
if (downloadSourcesCache && now - downloadSourcesCacheTime < CACHE_TTL) {
return downloadSourcesCache;
}
const map = new Map();
for await (const [key, source] of downloadSourcesSublevel.iterator()) {
map.set(key, source);
}
downloadSourcesCache = map;
downloadSourcesCacheTime = now;
return map;
};
export const invalidateDownloadSourcesCache = () => {
downloadSourcesCache = null;
};
export const checkUrlExists = async (url: string): Promise<boolean> => {
const sources = await getDownloadSourcesMap();
for (const source of sources.values()) {
@@ -121,43 +105,49 @@ export const checkUrlExists = async (url: string): Promise<boolean> => {
return false;
};
let steamGamesCache: FormattedSteamGamesByLetter | null = null;
let steamGamesCacheTime = 0;
const STEAM_GAMES_CACHE_TTL = 300000;
let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
const now = Date.now();
if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) {
return steamGamesCache;
export const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
if (steamGamesFormattedCache) {
return steamGamesFormattedCache;
}
const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
let steamGames: SteamGamesByLetter;
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
"steam-games-by-letter"
);
if (cached) {
steamGames = cached;
} else {
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
"steam-games-by-letter",
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
}
const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(response.data)) {
for (const [letter, games] of Object.entries(steamGames)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesCache = formattedData;
steamGamesCacheTime = now;
steamGamesFormattedCache = formattedData;
return formattedData;
};
type SublevelIterator = AsyncIterable<[string, { id: number }]>;
export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
interface SublevelWithId {
export interface SublevelWithId {
iterator: () => SublevelIterator;
}
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
export const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
@@ -190,7 +180,7 @@ export const invalidateIdCaches = () => {
maxDownloadSourceId = null;
};
const addNewDownloads = async (
export const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
@@ -202,7 +192,6 @@ const addNewDownloads = async (
const batch = repacksSublevel.batch();
// Fetch the pre-computed hash mapping
const titleHashMapping = await getTitleHashMapping();
let hashMatchCount = 0;
let fuzzyMatchCount = 0;
@@ -212,20 +201,16 @@ const addNewDownloads = async (
let objectIds: string[] = [];
let usedHashMatch = false;
// FIRST: Try hash-based lookup (fast and accurate)
const titleHash = hashTitle(download.title);
const steamIdsFromHash = titleHashMapping[titleHash];
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
// Found in hash mapping - trust it completely
hashMatchCount++;
usedHashMatch = true;
// Use the Steam IDs directly as strings (trust the hash mapping)
objectIds = steamIdsFromHash.map(String);
}
// FALLBACK: Use fuzzy matching ONLY if hash lookup found nothing
if (!usedHashMatch) {
let gamesInSteam: FormattedSteamGame[] = [];
const formattedTitle = formatRepackName(download.title);
@@ -234,12 +219,10 @@ const addNewDownloads = async (
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
// Try exact prefix match first
gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.formattedName)
);
// If no exact prefix match, try contains match (more lenient)
if (gamesInSteam.length === 0) {
gamesInSteam = games.filter(
(game) =>
@@ -248,7 +231,6 @@ const addNewDownloads = async (
);
}
// If still no match, try checking all letters (not just first letter)
if (gamesInSteam.length === 0) {
for (const letter of Object.keys(steamGames)) {
const letterGames = steamGames[letter] || [];
@@ -275,13 +257,10 @@ const addNewDownloads = async (
}
}
// Add matched game IDs to source tracking
for (const id of objectIds) {
objectIdsOnSource.add(id);
}
// Create the repack even if no games matched
// This ensures all repacks from sources are imported
const repack = {
id: nextRepackId++,
objectIds: objectIds,
@@ -300,9 +279,8 @@ const addNewDownloads = async (
await batch.write();
// Log matching statistics
console.log(
`📊 Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
logger.info(
`Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
);
const existingSource = await downloadSourcesSublevel.get(
@@ -352,8 +330,6 @@ export const importDownloadSourceToLocal = async (
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
invalidateDownloadSourcesCache();
const objectIds = await addNewDownloads(
downloadSource,
response.data.downloads,
@@ -387,7 +363,5 @@ export const updateDownloadSourcePreservingTimestamp = async (
await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
invalidateDownloadSourcesCache();
return updatedSource;
};

View File

@@ -1,4 +1,4 @@
import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
export const syncDownloadSourcesFromApi = async () => {
@@ -14,6 +14,6 @@ export const syncDownloadSourcesFromApi = async () => {
}
}
} catch (error) {
console.error("Failed to sync download sources from API:", error);
logger.error("Failed to sync download sources from API:", error);
}
};

View File

@@ -1,267 +1,13 @@
import { registerEvent } from "../register-event";
import axios, { AxiosError } from "axios";
import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import { invalidateDownloadSourcesCache, invalidateIdCaches } from "./helpers";
import crypto from "crypto";
const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
// Pre-computed title-to-Steam-ID mapping (shared with helpers.ts)
type TitleHashMapping = Record<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
let titleHashMappingCacheTime = 0;
const TITLE_HASH_MAPPING_TTL = 86400000; // 24 hours
const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
const now = Date.now();
if (
titleHashMappingCache &&
now - titleHashMappingCacheTime < TITLE_HASH_MAPPING_TTL
) {
return titleHashMappingCache;
}
try {
const response = await axios.get<TitleHashMapping>(
"https://cdn.losbroxas.org/results_a4c50f70c2.json",
{
timeout: 10000,
}
);
titleHashMappingCache = response.data;
titleHashMappingCacheTime = now;
console.log(
`✅ Loaded title hash mapping with ${Object.keys(response.data).length} entries`
);
return response.data;
} catch (error) {
console.error("Failed to fetch title hash mapping:", error);
return {};
}
};
const hashTitle = (title: string): string => {
return crypto.createHash("sha256").update(title).digest("hex");
};
type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
type FormattedSteamGame = { id: string; name: string; formattedName: string };
type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
const formatName = (name: string) => {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
};
const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
let steamGamesCache: FormattedSteamGamesByLetter | null = null;
let steamGamesCacheTime = 0;
const STEAM_GAMES_CACHE_TTL = 300000;
const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
const now = Date.now();
if (steamGamesCache && now - steamGamesCacheTime < STEAM_GAMES_CACHE_TTL) {
return steamGamesCache;
}
const response = await axios.get<SteamGamesByLetter>(
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(response.data)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesCache = formattedData;
steamGamesCacheTime = now;
return formattedData;
};
type SublevelIterator = AsyncIterable<[string, { id: number }]>;
interface SublevelWithId {
iterator: () => SublevelIterator;
}
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
if (isRepackSublevel && maxRepackId !== null) {
return ++maxRepackId;
}
if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
return ++maxDownloadSourceId;
}
let maxId = 0;
for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) {
maxId = value.id;
}
}
if (isRepackSublevel) {
maxRepackId = maxId;
} else if (isDownloadSourceSublevel) {
maxDownloadSourceId = maxId;
}
return maxId + 1;
};
const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel);
const batch = repacksSublevel.batch();
// Fetch the pre-computed hash mapping
const titleHashMapping = await getTitleHashMapping();
let hashMatchCount = 0;
let fuzzyMatchCount = 0;
let noMatchCount = 0;
for (const download of downloads) {
let objectIds: string[] = [];
let usedHashMatch = false;
// FIRST: Try hash-based lookup (fast and accurate)
const titleHash = hashTitle(download.title);
const steamIdsFromHash = titleHashMapping[titleHash];
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
// Found in hash mapping - trust it completely
hashMatchCount++;
usedHashMatch = true;
// Use the Steam IDs directly as strings (trust the hash mapping)
objectIds = steamIdsFromHash.map(String);
}
// FALLBACK: Use fuzzy matching ONLY if hash lookup found nothing
if (!usedHashMatch) {
let gamesInSteam: FormattedSteamGame[] = [];
const formattedTitle = formatRepackName(download.title);
if (formattedTitle && formattedTitle.length > 0) {
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
// Try exact prefix match first
gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.formattedName)
);
// If no exact prefix match, try contains match (more lenient)
if (gamesInSteam.length === 0) {
gamesInSteam = games.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
}
// If still no match, try checking all letters (not just first letter)
if (gamesInSteam.length === 0) {
for (const letter of Object.keys(steamGames)) {
const letterGames = steamGames[letter] || [];
const matches = letterGames.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
if (matches.length > 0) {
gamesInSteam = matches;
break;
}
}
}
if (gamesInSteam.length > 0) {
fuzzyMatchCount++;
objectIds = gamesInSteam.map((game) => String(game.id));
} else {
noMatchCount++;
}
} else {
noMatchCount++;
}
}
// Add matched game IDs to source tracking
for (const id of objectIds) {
objectIdsOnSource.add(id);
}
// Create the repack even if no games matched
// This ensures all repacks from sources are imported
const repack = {
id: nextRepackId++,
objectIds: objectIds,
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
};
batch.put(`${repack.id}`, repack);
}
await batch.write();
// Log matching statistics
console.log(
`📊 Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
);
const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...existingSource,
objectIds: Array.from(objectIdsOnSource),
});
}
};
import {
invalidateIdCaches,
downloadSourceSchema,
getSteamGames,
addNewDownloads,
} from "./helpers";
const syncDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
@@ -358,8 +104,6 @@ const syncDownloadSources = async (
}
}
// Invalidate caches after all sync operations complete
invalidateDownloadSourcesCache();
invalidateIdCaches();
return newRepacksCount;

View File

@@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi } from "@main/services";
import { HydraApi, logger } from "@main/services";
const updateMissingFingerprints = async (
_event: Electron.IpcMainInvokeEvent
@@ -27,7 +27,7 @@ const updateMissingFingerprints = async (
return 0;
}
console.log(
logger.info(
`Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
);
@@ -53,7 +53,7 @@ const updateMissingFingerprints = async (
});
}
} catch (error) {
console.error(
logger.error(
`Failed to update fingerprint for source ${source.id}:`,
error
);

View File

@@ -61,7 +61,6 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-all-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
import "./download-sources/update-missing-fingerprints";

View File

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

View File

@@ -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<string, UserPreferences | null>(
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);
}

View File

@@ -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<AllDebridMagnetStatus> {
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<AllDebridDownloadUrl[]> {
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
}
}

View File

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

View File

@@ -1,4 +1,3 @@
export * from "./download-manager";
export * from "./real-debrid";
export * from "./all-debrid";
export * from "./torbox";

View File

@@ -18,3 +18,4 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./resource-cache";

View File

@@ -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<T = unknown> {
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<T = unknown>(
resourceName: string
): CachedResource<T> | 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<T = unknown>(
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<T = unknown>(
resourceName: string,
url: string,
timeout: number = 10000
): Promise<T> {
const cached = this.readCachedResource<T>(resourceName);
const headers: Record<string, string> = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
try {
const response = await axios.get<T>(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<T = unknown>(resourceName: string): T | null {
const cached = this.readCachedResource<T>(resourceName);
return cached?.data || null;
}
static async updateResourcesOnStartup(): Promise<void> {
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");
}
}

View File

@@ -93,8 +93,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
authenticateRealDebrid: (apiToken: string) =>
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
authenticateAllDebrid: (apiKey: string) =>
ipcRenderer.invoke("authenticateAllDebrid", apiKey),
authenticateTorBox: (apiToken: string) =>
ipcRenderer.invoke("authenticateTorBox", apiToken),

View File

@@ -11,7 +11,6 @@ export const DOWNLOADER_NAME = {
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.TorBox]: "TorBox",
[Downloader.AllDebrid]: "All-Debrid",
[Downloader.Hydra]: "Nimbus",
};

View File

@@ -8,7 +8,6 @@ import type {
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
AllDebridUser,
UserProfile,
FriendRequestAction,
UpdateProfileRequest,
@@ -193,9 +192,6 @@ declare global {
) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateAllDebrid: (
apiKey: string
) => Promise<AllDebridUser | { error_code: string }>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (

View File

@@ -114,15 +114,6 @@ export function DownloadGroup({
return <p>{t("deleting")}</p>;
}
if (download.downloader === Downloader.AllDebrid) {
return (
<>
<p>{progress}</p>
<p>{t("alldebrid_size_not_supported")}</p>
</>
);
}
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
@@ -190,15 +181,6 @@ export function DownloadGroup({
}
if (download.status === "active") {
if ((download.downloader as unknown as string) === "alldebrid") {
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
<p>{t("alldebrid_size_not_supported")}</p>
</>
);
}
return (
<>
<p>{formatDownloadProgress(download.progress)}</p>
@@ -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 [
{

View File

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

View File

@@ -501,6 +501,7 @@ export function GameReviews({
)}
<div
className="game-details__reviews-container"
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",

View File

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

View File

@@ -1,12 +0,0 @@
.settings-all-debrid {
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__description {
margin: 0;
color: var(--text-secondary);
}
}

View File

@@ -1,129 +0,0 @@
import { useContext, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
import "./settings-all-debrid.scss";
import { useAppSelector, useToast } from "@renderer/hooks";
import { settingsContext } from "@renderer/context";
const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys";
export function SettingsAllDebrid() {
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateUserPreferences } = useContext(settingsContext);
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
useAllDebrid: false,
allDebridApiKey: null as string | null,
});
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation("settings");
useEffect(() => {
if (userPreferences) {
setForm({
useAllDebrid: Boolean(userPreferences.allDebridApiKey),
allDebridApiKey: userPreferences.allDebridApiKey ?? null,
});
}
}, [userPreferences]);
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
setIsLoading(true);
event.preventDefault();
try {
if (form.useAllDebrid) {
if (!form.allDebridApiKey) {
showErrorToast(t("alldebrid_missing_key"));
return;
}
const result = await window.electron.authenticateAllDebrid(
form.allDebridApiKey
);
if ("error_code" in result) {
showErrorToast(t(result.error_code));
return;
}
if (!result.isPremium) {
showErrorToast(
t("all_debrid_free_account_error", { username: result.username })
);
return;
}
showSuccessToast(
t("all_debrid_account_linked"),
t("debrid_linked_message", { username: result.username })
);
} else {
showSuccessToast(t("changes_saved"));
}
updateUserPreferences({
allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null,
});
} catch (err: any) {
showErrorToast(t("alldebrid_unknown_error"));
} finally {
setIsLoading(false);
}
};
const isButtonDisabled =
(form.useAllDebrid && !form.allDebridApiKey) || isLoading;
return (
<form className="settings-all-debrid__form" onSubmit={handleFormSubmit}>
<p className="settings-all-debrid__description">
{t("all_debrid_description")}
</p>
<CheckboxField
label={t("enable_all_debrid")}
checked={form.useAllDebrid}
onChange={() =>
setForm((prev) => ({
...prev,
useAllDebrid: !form.useAllDebrid,
}))
}
/>
{form.useAllDebrid && (
<TextField
label={t("api_token")}
value={form.allDebridApiKey ?? ""}
type="password"
onChange={(event) =>
setForm({ ...form, allDebridApiKey: event.target.value })
}
rightContent={
<Button type="submit" disabled={isButtonDisabled}>
{t("save_changes")}
</Button>
}
placeholder="API Key"
hint={
<Trans i18nKey="debrid_api_token_hint" ns="settings">
<Link to={ALL_DEBRID_API_TOKEN_URL} />
</Trans>
}
/>
)}
</form>
);
}

View File

@@ -2,7 +2,6 @@ import { useState, useCallback, useMemo } from "react";
import { useFeature, useAppSelector } from "@renderer/hooks";
import { SettingsTorBox } from "./settings-torbox";
import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsAllDebrid } from "./settings-all-debrid";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
@@ -11,7 +10,6 @@ import "./settings-debrid.scss";
interface CollapseState {
torbox: boolean;
realDebrid: boolean;
allDebrid: boolean;
}
const sectionVariants = {
@@ -71,7 +69,6 @@ export function SettingsDebrid() {
return {
torbox: !userPreferences?.torBoxApiToken,
realDebrid: !userPreferences?.realDebridApiToken,
allDebrid: !userPreferences?.allDebridApiKey,
};
}, [userPreferences]);
@@ -178,51 +175,6 @@ export function SettingsDebrid() {
</AnimatePresence>
</div>
)}
<div className="settings-debrid__section">
<div className="settings-debrid__section-header">
<button
type="button"
className="settings-debrid__collapse-button"
onClick={() => toggleSection("allDebrid")}
aria-label={
collapseState.allDebrid
? "Expand All-Debrid section"
: "Collapse All-Debrid section"
}
>
<motion.div
variants={chevronVariants}
animate={collapseState.allDebrid ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h3 className="settings-debrid__section-title">All-Debrid</h3>
<span className="settings-debrid__beta-badge">BETA</span>
{userPreferences?.allDebridApiKey && (
<CheckCircleFillIcon
size={16}
className="settings-debrid__check-icon"
/>
)}
</div>
<AnimatePresence initial={true} mode="wait">
{!collapseState.allDebrid && (
<motion.div
key="alldebrid-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<SettingsAllDebrid />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
export enum Downloader {
RealDebrid,
AllDebrid,
Torrent,
Gofile,
PixelDrain,
@@ -56,7 +55,6 @@ export enum AuthPage {
export enum DownloadError {
NotCachedOnRealDebrid = "download_error_not_cached_on_real_debrid",
NotCachedInAllDebrid = "download_error_not_cached_in_alldebrid",
NotCachedOnTorBox = "download_error_not_cached_on_torbox",
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",

View File

@@ -124,7 +124,6 @@ export const getDownloadersForUri = (uri: string) => {
Downloader.Hydra,
Downloader.TorBox,
Downloader.RealDebrid,
Downloader.AllDebrid,
];
}
@@ -142,19 +141,6 @@ export const getDownloadersForUris = (uris: string[]) => {
return Array.from(downloadersSet);
};
export const steamUrlBuilder = {
library: (objectId: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/header.jpg`,
libraryHero: (objectId: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/library_hero.jpg`,
logo: (objectId: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/logo.png`,
cover: (objectId: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/library_600x900.jpg`,
icon: (objectId: string, clientIcon: string) =>
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
};
export const getDateLocale = (language: string) => {
if (language.startsWith("pt")) return ptBR;
if (language.startsWith("es")) return es;

View File

@@ -175,11 +175,3 @@ export interface SeedingStatus {
status: DownloadStatus;
uploadSpeed: number;
}
/* All-Debrid */
export interface AllDebridUser {
username: string;
email: string;
isPremium: boolean;
premiumUntil: string;
}

View File

@@ -99,7 +99,6 @@ export interface UserPreferences {
language?: string;
realDebridApiToken?: string | null;
torBoxApiToken?: string | null;
allDebridApiKey?: string | null;
preferQuitInsteadOfHiding?: boolean;
runAtStartup?: boolean;
startMinimized?: boolean;