diff --git a/.env.example b/.env.example index 051d8aa3..e13fc1bd 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ MAIN_VITE_API_URL= MAIN_VITE_AUTH_URL= MAIN_VITE_WS_URL= +MAIN_VITE_NIMBUS_API_URL= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_TORBOX_REFERRAL_CODE= MAIN_VITE_LAUNCHER_SUBDOMAIN= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5086d8e5..31354bc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,7 @@ jobs: MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -73,6 +74,7 @@ jobs: MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df01b358..45163c4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,9 +54,10 @@ jobs: MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} - MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} @@ -71,9 +72,10 @@ jobs: MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} - MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} diff --git a/package.json b/package.json index 99545c6e..09e7ceda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.6", + "version": "3.8.0", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 71e4b57e..40428bd7 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -1,4 +1,5 @@ import aria2p +from aria2p.client import ClientException as DownloadNotFound class HttpDownloader: def __init__(self): @@ -11,12 +12,16 @@ class HttpDownloader: ) ) - def start_download(self, url: str, save_path: str, header: str, out: str = None): + def start_download(self, url: str, save_path: str, header, out: str = None): if self.download: self.aria2.resume([self.download]) else: - downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out}) - + options = {"dir": save_path} + if header: + options["header"] = header + if out: + options["out"] = out + downloads = self.aria2.add(url, options=options) self.download = downloads[0] def pause_download(self): @@ -32,7 +37,11 @@ class HttpDownloader: if self.download == None: return None - download = self.aria2.get_download(self.download.gid) + try: + download = self.aria2.get_download(self.download.gid) + except DownloadNotFound: + self.download = None + return None response = { 'folderName': download.name, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 98c224ba..87ad52b3 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -175,6 +175,7 @@ "repacks_modal_description": "Choose the repack you want to download", "select_folder_hint": "To change the default folder, go to the <0>Settings", "download_now": "Download now", + "loading": "Loading...", "no_shop_details": "Could not retrieve shop details.", "download_options": "Download options", "download_path": "Download path", @@ -184,6 +185,12 @@ "open_screenshot": "Open screenshot {{number}}", "download_settings": "Download settings", "downloader": "Downloader", + "downloader_online": "Online", + "downloader_not_configured": "Available but not configured", + "downloader_offline": "Link is offline", + "downloader_not_available": "Not available", + "recommended": "Recommended", + "go_to_settings": "Go to Settings", "select_executable": "Select", "no_executable_selected": "No executable selected", "open_folder": "Open folder", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index fbbe33cd..666dd065 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -182,6 +182,12 @@ "open_screenshot": "Abrir captura número {{number}}", "download_settings": "Descargar ajustes", "downloader": "Descargador", + "downloader_online": "En línea", + "downloader_not_configured": "Disponible pero no configurado", + "downloader_offline": "El enlace está fuera de línea", + "downloader_not_available": "No disponible", + "recommended": "Recomendado", + "go_to_settings": "Ir a Ajustes", "select_executable": "Seleccionar", "no_executable_selected": "Sin ejecutable seleccionado", "open_folder": "Abrir carpeta", @@ -651,6 +657,7 @@ "sending": "Enviando", "friend_request_sent": "Solicitud de amistad enviada", "friends": "Amistades", + "badges": "Insignias", "friends_list": "Lista de amistades", "user_not_found": "Usuario no encontrado", "block_user": "Bloquear usuario", @@ -661,12 +668,16 @@ "ignore_request": "Ignorar solicitud", "cancel_request": "Cancelar solicitud", "undo_friendship": "Deshacer amistad", + "friendship_removed": "Amigo eliminado", "request_accepted": "Solicitud aceptada", "user_blocked_successfully": "Usuario bloqueado exitosamente", "user_block_modal_text": "Esto va a bloquear a {{displayName}}", "blocked_users": "Usuarios bloqueados", "unblock": "Desbloquear", "no_friends_added": "No tenés amistades añadidas", + "view_all": "Ver todo", + "load_more": "Cargar más", + "loading": "Cargando", "pending": "Pendiente", "no_pending_invites": "No tenés invitaciones pendientes", "no_blocked_users": "No has bloqueado a nadie", @@ -690,6 +701,7 @@ "report_reason_other": "Otros", "profile_reported": "Perfil reportado", "your_friend_code": "Tu código de amistad:", + "copy_friend_code": "Copiar código de amistad", "upload_banner": "Subir banner", "uploading_banner": "Subiendo banner…", "background_image_updated": "Imagen de fondo actualizada", @@ -714,6 +726,9 @@ "game_added_to_pinned": "Juego añadido a fijados", "user_reviews": "Reseñas", "loading_reviews": "Cargando reseñas...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "Ver Mi Wrapped 2025", + "view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}", "no_reviews": "Sin reseñas aún", "delete_review": "Eliminar reseña" }, @@ -766,5 +781,41 @@ "all_games": "Todos los Juegos", "recently_played": "Jugados Recientemente", "favorites": "Favoritos" + }, + "notifications_page": { + "title": "Notificaciones", + "mark_all_as_read": "Marcar todo como leído", + "clear_all": "Limpiar todo", + "loading": "Cargando...", + "empty_title": "Sin notificaciones", + "empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.", + "empty_filter_description": "No hay notificaciones que coincidan con este filtro.", + "filter_all": "Todas", + "filter_unread": "No leídas", + "filter_friends": "Amigos", + "filter_badges": "Insignias", + "filter_upvotes": "Votos", + "filter_local": "Locales", + "load_more": "Cargar más", + "dismiss": "Descartar", + "accept": "Aceptar", + "refuse": "Rechazar", + "notification": "Notificación", + "friend_request_received_title": "¡Nueva solicitud de amistad!", + "friend_request_received_description": "{{displayName}} quiere ser tu amigo", + "friend_request_accepted_title": "¡Solicitud de amistad aceptada!", + "friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad", + "badge_received_title": "¡Obtuviste una nueva insignia!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!", + "review_upvote_description": "Tu reseña recibió {{count}} nuevos votos", + "marked_all_as_read": "Todas las notificaciones marcadas como leídas", + "failed_to_mark_as_read": "Error al marcar las notificaciones como leídas", + "cleared_all": "Todas las notificaciones eliminadas", + "failed_to_clear": "Error al eliminar las notificaciones", + "failed_to_load": "Error al cargar las notificaciones", + "failed_to_dismiss": "Error al descartar la notificación", + "friend_request_accepted": "Solicitud de amistad aceptada", + "friend_request_refused": "Solicitud de amistad rechazada" } } diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 7499bfeb..ee5ef5dd 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -172,6 +172,12 @@ "open_screenshot": "Ver captura de tela {{number}}", "download_settings": "Ajustes do download", "downloader": "Downloader", + "downloader_online": "Online", + "downloader_not_configured": "Disponível mas não configurado", + "downloader_offline": "Link está offline", + "downloader_not_available": "Não disponível", + "recommended": "Recomendado", + "go_to_settings": "Ir para Configurações", "select_executable": "Explorar", "no_executable_selected": "Nenhum executável selecionado", "open_folder": "Abrir pasta", @@ -654,6 +660,7 @@ "see_profile": "Ver perfil", "friend_request_sent": "Pedido de amizade enviado", "friends": "Amigos", + "badges": "Insígnias", "add": "Adicionar", "sending": "Enviando", "friends_list": "Lista de amigos", @@ -666,12 +673,16 @@ "ignore_request": "Ignorar pedido", "cancel_request": "Cancelar pedido", "undo_friendship": "Desfazer amizade", + "friendship_removed": "Amigo removido", "request_accepted": "Pedido de amizade aceito", "user_blocked_successfully": "Usuário bloqueado com sucesso", "user_block_modal_text": "Bloquear {{displayName}}", "blocked_users": "Usuários bloqueados", "unblock": "Desbloquear", "no_friends_added": "Você ainda não possui amigos adicionados", + "view_all": "Ver todos", + "load_more": "Carregar mais", + "loading": "Carregando", "pending": "Pendentes", "no_pending_invites": "Você não possui convites de amizade pendentes", "no_blocked_users": "Você não tem nenhum usuário bloqueado", @@ -695,6 +706,7 @@ "report_reason_other": "Outro", "profile_reported": "Perfil reportado", "your_friend_code": "Seu código de amigo:", + "copy_friend_code": "Copiar código de amigo", "upload_banner": "Carregar banner", "uploading_banner": "Carregando banner…", "background_image_updated": "Imagem de fundo salva", @@ -723,6 +735,9 @@ "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "user_reviews": "Avaliações", "loading_reviews": "Carregando avaliações...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "Ver Meu Wrapped 2025", + "view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}", "no_reviews": "Ainda não há avaliações", "delete_review": "Excluir avaliação" }, @@ -775,5 +790,41 @@ "all_games": "Todos os Jogos", "recently_played": "Jogados Recentemente", "favorites": "Favoritos" + }, + "notifications_page": { + "title": "Notificações", + "mark_all_as_read": "Marcar todas como lidas", + "clear_all": "Limpar todas", + "loading": "Carregando...", + "empty_title": "Sem notificações", + "empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.", + "empty_filter_description": "Nenhuma notificação corresponde a este filtro.", + "filter_all": "Todas", + "filter_unread": "Não lidas", + "filter_friends": "Amigos", + "filter_badges": "Insígnias", + "filter_upvotes": "Votos", + "filter_local": "Locais", + "load_more": "Carregar mais", + "dismiss": "Descartar", + "accept": "Aceitar", + "refuse": "Recusar", + "notification": "Notificação", + "friend_request_received_title": "Nova solicitação de amizade!", + "friend_request_received_description": "{{displayName}} quer ser seu amigo", + "friend_request_accepted_title": "Solicitação de amizade aceita!", + "friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade", + "badge_received_title": "Você recebeu uma nova insígnia!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!", + "review_upvote_description": "Sua avaliação recebeu {{count}} novos votos", + "marked_all_as_read": "Todas as notificações marcadas como lidas", + "failed_to_mark_as_read": "Falha ao marcar notificações como lidas", + "cleared_all": "Todas as notificações limpas", + "failed_to_clear": "Falha ao limpar notificações", + "failed_to_load": "Falha ao carregar notificações", + "failed_to_dismiss": "Falha ao descartar notificação", + "friend_request_accepted": "Solicitação de amizade aceita", + "friend_request_refused": "Solicitação de amizade recusada" } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f0b7f764..ff863617 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -182,6 +182,12 @@ "open_screenshot": "Открыть скриншот {{number}}", "download_settings": "Параметры загрузки", "downloader": "Загрузчик", + "downloader_online": "Онлайн", + "downloader_not_configured": "Доступен, но не настроен", + "downloader_offline": "Ссылка недоступна", + "downloader_not_available": "Недоступно", + "recommended": "Рекомендуется", + "go_to_settings": "Перейти в настройки", "select_executable": "Выбрать", "no_executable_selected": "Файл не выбран", "open_folder": "Открыть папку", @@ -651,6 +657,7 @@ "sending": "Отправка", "friend_request_sent": "Запрос в друзья отправлен", "friends": "Друзья", + "badges": "Значки", "friends_list": "Список друзей", "user_not_found": "Пользователь не найден", "block_user": "Заблокировать пользователя", @@ -661,12 +668,16 @@ "ignore_request": "Игнорировать запрос", "cancel_request": "Отменить запрос", "undo_friendship": "Удалить друга", + "friendship_removed": "Друг удален", "request_accepted": "Запрос принят", "user_blocked_successfully": "Пользователь успешно заблокирован", "user_block_modal_text": "{{displayName}} будет заблокирован", "blocked_users": "Заблокированные пользователи", "unblock": "Разблокировать", "no_friends_added": "Вы ещё не добавили ни одного друга", + "view_all": "Показать все", + "load_more": "Загрузить еще", + "loading": "Загрузка", "pending": "Ожидание", "no_pending_invites": "У вас нет запросов ожидающих ответа", "no_blocked_users": "Вы не заблокировали ни одного пользователя", @@ -690,6 +701,7 @@ "report_reason_other": "Другое", "profile_reported": "Жалоба на профиль отправлена", "your_friend_code": "Код вашего друга:", + "copy_friend_code": "Копировать код друга", "upload_banner": "Загрузить баннер", "uploading_banner": "Загрузка баннера...", "background_image_updated": "Фоновое изображение обновлено", @@ -711,6 +723,9 @@ "karma_count": "карма", "user_reviews": "Отзывы", "loading_reviews": "Загрузка отзывов...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "Просмотреть мой Wrapped 2025", + "view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}", "no_reviews": "Пока нет отзывов", "delete_review": "Удалить отзыв" }, @@ -763,5 +778,41 @@ "all_games": "Все игры", "recently_played": "Недавно сыгранные", "favorites": "Избранное" + }, + "notifications_page": { + "title": "Уведомления", + "mark_all_as_read": "Отметить все как прочитанные", + "clear_all": "Очистить все", + "loading": "Загрузка...", + "empty_title": "Нет уведомлений", + "empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.", + "empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.", + "filter_all": "Все", + "filter_unread": "Непрочитанные", + "filter_friends": "Друзья", + "filter_badges": "Значки", + "filter_upvotes": "Голоса", + "filter_local": "Локальные", + "load_more": "Загрузить еще", + "dismiss": "Отклонить", + "accept": "Принять", + "refuse": "Отклонить", + "notification": "Уведомление", + "friend_request_received_title": "Новый запрос в друзья!", + "friend_request_received_description": "{{displayName}} хочет добавить вас в друзья", + "friend_request_accepted_title": "Запрос в друзья принят!", + "friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья", + "badge_received_title": "Вы получили новый значок!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!", + "review_upvote_description": "Ваш отзыв получил {{count}} новых голосов", + "marked_all_as_read": "Все уведомления отмечены как прочитанные", + "failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные", + "cleared_all": "Все уведомления очищены", + "failed_to_clear": "Не удалось очистить уведомления", + "failed_to_load": "Не удалось загрузить уведомления", + "failed_to_dismiss": "Не удалось отклонить уведомление", + "friend_request_accepted": "Запрос в друзья принят", + "friend_request_refused": "Запрос в друзья отклонен" } } diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index c36bf8ce..bc6746e2 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -8,6 +8,7 @@ import { DatanodesApi, MediafireApi, PixelDrainApi, + VikingFileApi, } from "../hosters"; import { PythonRPC } from "../python-rpc"; import { @@ -150,14 +151,28 @@ export class DownloadManager { if (!isDownloadingMetadata && !isCheckingFiles) { if (!download) return null; - await downloadsSublevel.put(downloadId, { + const updatedDownload = { ...download, bytesDownloaded, fileSize, progress, folderName, - status: "active", - }); + status: "active" as const, + }; + + await downloadsSublevel.put(downloadId, updatedDownload); + + return { + numPeers, + numSeeds, + downloadSpeed, + timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), + isDownloadingMetadata, + isCheckingFiles, + progress, + gameId: downloadId, + download: updatedDownload, + } as DownloadProgress; } return { @@ -499,6 +514,29 @@ export class DownloadManager { allow_multiple_connections: true, }; } + case Downloader.VikingFile: { + logger.log( + `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` + ); + try { + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + logger.log(`[DownloadManager] VikingFile direct URL obtained`); + return { + action: "start", + game_id: downloadId, + url: downloadUrl, + save_path: download.downloadPath, + header: + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + }; + } catch (error) { + logger.error( + `[DownloadManager] Error processing VikingFile download:`, + error + ); + throw error; + } + } } } diff --git a/src/main/services/hosters/buzzheavier.ts b/src/main/services/hosters/buzzheavier.ts index 9ef2d830..581f5a87 100644 --- a/src/main/services/hosters/buzzheavier.ts +++ b/src/main/services/hosters/buzzheavier.ts @@ -1,4 +1,6 @@ import axios from "axios"; +import http from "node:http"; +import https from "node:https"; import { HOSTER_USER_AGENT, extractHosterFilename, @@ -28,6 +30,12 @@ export class BuzzheavierApi { await axios.get(baseUrl, { headers: { "User-Agent": HOSTER_USER_AGENT }, timeout: 30000, + httpAgent: new http.Agent({ + family: 4, // Force IPv4 + }), + httpsAgent: new https.Agent({ + family: 4, // Force IPv4 + }), }); const downloadUrl = `${baseUrl}/download`; @@ -43,6 +51,12 @@ export class BuzzheavierApi { validateStatus: (status) => status === 200 || status === 204 || status === 301 || status === 302, timeout: 30000, + httpAgent: new http.Agent({ + family: 4, // Force IPv4 + }), + httpsAgent: new https.Agent({ + family: 4, // Force IPv4 + }), }); const hxRedirect = headResponse.headers["hx-redirect"]; diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index 5f918811..e22fb680 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -5,3 +5,4 @@ export * from "./mediafire"; export * from "./pixeldrain"; export * from "./buzzheavier"; export * from "./fuckingfast"; +export * from "./vikingfile"; diff --git a/src/main/services/hosters/vikingfile.ts b/src/main/services/hosters/vikingfile.ts new file mode 100644 index 00000000..0c6d30dc --- /dev/null +++ b/src/main/services/hosters/vikingfile.ts @@ -0,0 +1,46 @@ +import axios from "axios"; +import { logger } from "../logger"; + +interface UnlockResponse { + link: string; + hoster: string; +} + +export class VikingFileApi { + public static async getDownloadUrl(uri: string): Promise { + const unlockResponse = await axios.post( + `${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`, + { url: uri } + ); + + if (!unlockResponse.data.link) { + throw new Error("Failed to unlock VikingFile URL"); + } + + const redirectUrl = unlockResponse.data.link; + + try { + const redirectResponse = await axios.head(redirectUrl, { + maxRedirects: 0, + validateStatus: (status) => + status === 301 || status === 302 || status === 200, + }); + + if ( + redirectResponse.headers.location || + redirectResponse.status === 301 || + redirectResponse.status === 302 + ) { + return redirectResponse.headers.location || redirectUrl; + } + + return redirectUrl; + } catch (error) { + logger.error( + `[VikingFile] Error following redirect, using redirect URL:`, + error + ); + return redirectUrl; + } + } +} diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 2a1dce79..d04b00ab 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import http from "node:http"; import cp from "node:child_process"; import fs from "node:fs"; @@ -31,6 +32,9 @@ export class PythonRPC { public static readonly RPC_PORT = "8084"; public static readonly rpc = axios.create({ baseURL: `http://localhost:${this.RPC_PORT}`, + httpAgent: new http.Agent({ + family: 4, // Force IPv4 + }), }); private static pythonProcess: cp.ChildProcess | null = null; diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index 7b0ed536..888d8329 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_WS_URL: string; + readonly MAIN_VITE_NIMBUS_API_URL: string; readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string; readonly ELECTRON_RENDERER_URL: string; } diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 89de5503..d227969b 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -1,6 +1,6 @@ import { Downloader } from "@shared"; -export const VERSION_CODENAME = "Supernova"; +export const VERSION_CODENAME = "Harbinger"; export const DOWNLOADER_NAME = { [Downloader.RealDebrid]: "Real-Debrid", @@ -14,6 +14,7 @@ export const DOWNLOADER_NAME = { [Downloader.FuckingFast]: "FuckingFast", [Downloader.TorBox]: "TorBox", [Downloader.Hydra]: "Nimbus", + [Downloader.VikingFile]: "VikingFile", }; export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.scss b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss index 1b7c51e8..75add6d3 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.scss +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss @@ -19,23 +19,173 @@ color: globals.$body-color; } - &__downloaders { - display: grid; - gap: globals.$spacing-unit; - grid-template-columns: repeat(2, 1fr); + &__downloaders-list-wrapper { + border: 1px solid globals.$border-color; + overflow: hidden; + background-color: globals.$dark-background-color; } - &__downloader-option { - position: relative; + &__downloaders-list { + display: flex; + flex-direction: column; + gap: 0; + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + padding: 0; - &:only-child { - grid-column: 1 / -1; + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 12px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); } } - &__downloader-icon { - position: absolute; - left: calc(globals.$spacing-unit * 2); + &__downloader-item { + display: flex; + align-items: center; + gap: 8px; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); + border: 1px solid transparent; + border-bottom: 1px solid globals.$border-color; + border-radius: 0; + background-color: transparent; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + color: globals.$body-color; + font-size: 14px; + text-align: left; + height: 48px; + box-sizing: border-box; + + &:hover { + background-color: rgba(255, 255, 255, 0.05); + } + + &--selected { + background-color: rgba(255, 255, 255, 0.08); + } + + &--last { + border-bottom: none; + } + + &:disabled { + cursor: default; + + &:hover { + background-color: transparent; + } + + .download-settings-modal__downloader-name { + opacity: 0.5; + } + + .download-settings-modal__availability-indicator-wrapper { + opacity: 0.5; + } + } + } + + &__downloader-item-wrapper { + display: flex; + flex-direction: column; + } + + &__check-icon { + color: white; + flex-shrink: 0; + } + + &__check-icon-wrapper { + margin-left: auto; + display: flex; + align-items: center; + width: 20px; + height: 20px; + justify-content: center; + flex-shrink: 0; + } + + &__recommendation-badge { + margin-left: auto; + display: flex; + align-items: center; + height: 20px; + justify-content: center; + flex-shrink: 0; + + .badge { + padding: 2px 6px; + font-size: 10px; + line-height: 1.2; + height: 16px; + display: flex; + align-items: center; + white-space: nowrap; + } + } + + &__availability-indicator-wrapper { + display: flex; + align-items: center; + flex-shrink: 0; + } + + &__availability-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--available { + background-color: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); + } + + &--unavailable { + background-color: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); + } + + &--not-present { + background-color: #6b7280; + box-shadow: 0 0 6px rgba(107, 114, 128, 0.5); + } + + &--warning { + background-color: #eab308; + box-shadow: 0 0 6px rgba(234, 179, 8, 0.5); + } + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } + } + + &__availability-indicator--pulsating { + animation: pulse 2s ease-in-out infinite; } &__path-error { @@ -49,4 +199,17 @@ &__change-path-button { align-self: flex-end; } + + &__loading-spinner { + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } 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 a6c32b6e..0a2c6721 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 @@ -1,17 +1,25 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { + Badge, Button, CheckboxField, Link, Modal, TextField, } from "@renderer/components"; -import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; -import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; +import { + DownloadIcon, + SyncIcon, + CheckCircleFillIcon, +} from "@primer/octicons-react"; +import { Downloader, formatBytes, getDownloadersForUri } from "@shared"; import type { GameRepack } from "@types"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; +import { motion } from "framer-motion"; +import { Tooltip } from "react-tooltip"; +import { RealDebridInfoModal } from "./real-debrid-info-modal"; import "./download-settings-modal.scss"; export interface DownloadSettingsModalProps { @@ -51,6 +59,7 @@ export function DownloadSettingsModal({ const [hasWritePermission, setHasWritePermission] = useState( null ); + const [showRealDebridModal, setShowRealDebridModal] = useState(false); const { isFeatureEnabled, Feature } = useFeature(); @@ -78,18 +87,89 @@ export function DownloadSettingsModal({ } }, [visible, checkFolderWritePermission, selectedPath]); - const downloaders = useMemo(() => { - return getDownloadersForUris(repack?.uris ?? []); - }, [repack?.uris]); + const downloadOptions = useMemo(() => { + const unavailableUrisSet = new Set(repack?.unavailableUris ?? []); + + const downloaderMap = new Map< + Downloader, + { hasAvailable: boolean; hasUnavailable: boolean } + >(); + + if (repack) { + for (const uri of repack.uris) { + const uriDownloaders = getDownloadersForUri(uri); + const isAvailable = !unavailableUrisSet.has(uri); + + for (const downloader of uriDownloaders) { + const existing = downloaderMap.get(downloader); + if (existing) { + existing.hasAvailable = existing.hasAvailable || isAvailable; + existing.hasUnavailable = existing.hasUnavailable || !isAvailable; + } else { + downloaderMap.set(downloader, { + hasAvailable: isAvailable, + hasUnavailable: !isAvailable, + }); + } + } + } + } + + const allDownloaders = Object.values(Downloader).filter( + (value) => typeof value === "number" + ) as Downloader[]; + + const getDownloaderPriority = (option: { + isAvailable: boolean; + canHandle: boolean; + isAvailableButNotConfigured: boolean; + }) => { + if (option.isAvailable) return 0; + if (option.canHandle && !option.isAvailableButNotConfigured) return 1; + if (option.isAvailableButNotConfigured) return 2; + return 3; + }; + + return allDownloaders + .filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus + .map((downloader) => { + const status = downloaderMap.get(downloader); + const canHandle = status !== undefined; + const isAvailable = status?.hasAvailable ?? false; + + let isConfigured = true; + if (downloader === Downloader.RealDebrid) { + isConfigured = !!userPreferences?.realDebridApiToken; + } else if (downloader === Downloader.TorBox) { + isConfigured = !!userPreferences?.torBoxApiToken; + } + // } else if (downloader === Downloader.Hydra) { + // isConfigured = isFeatureEnabled(Feature.Nimbus); + // } + + const isAvailableButNotConfigured = + isAvailable && !isConfigured && canHandle; + + return { + downloader, + isAvailable: isAvailable && isConfigured, + canHandle, + isAvailableButNotConfigured, + }; + }) + .sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b)); + }, [ + repack, + userPreferences?.realDebridApiToken, + userPreferences?.torBoxApiToken, + isFeatureEnabled, + Feature, + ]); const getDefaultDownloader = useCallback( (availableDownloaders: Downloader[]) => { if (availableDownloaders.length === 0) return null; - if (availableDownloaders.includes(Downloader.Hydra)) { - return Downloader.Hydra; - } - if (availableDownloaders.includes(Downloader.RealDebrid)) { return Downloader.RealDebrid; } @@ -112,26 +192,12 @@ export function DownloadSettingsModal({ .then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath)); } - const filteredDownloaders = downloaders.filter((downloader) => { - if (downloader === Downloader.RealDebrid) - return userPreferences?.realDebridApiToken; - if (downloader === Downloader.TorBox) - return userPreferences?.torBoxApiToken; - if (downloader === Downloader.Hydra) - return isFeatureEnabled(Feature.Nimbus); - return true; - }); + const availableDownloaders = downloadOptions + .filter((option) => option.isAvailable) + .map((option) => option.downloader); - setSelectedDownloader(getDefaultDownloader(filteredDownloaders)); - }, [ - Feature, - isFeatureEnabled, - getDefaultDownloader, - userPreferences?.downloadsPath, - downloaders, - userPreferences?.realDebridApiToken, - userPreferences?.torBoxApiToken, - ]); + setSelectedDownloader(getDefaultDownloader(availableDownloaders)); + }, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]); const handleChooseDownloadsPath = async () => { const { filePaths } = await window.electron.showOpenDialog({ @@ -186,33 +252,144 @@ export function DownloadSettingsModal({
{t("downloader")} -
- {downloaders.map((downloader) => { - const shouldDisableButton = - (downloader === Downloader.RealDebrid && - !userPreferences?.realDebridApiToken) || - (downloader === Downloader.TorBox && - !userPreferences?.torBoxApiToken) || - (downloader === Downloader.Hydra && - !isFeatureEnabled(Feature.Nimbus)); +
+
+ {downloadOptions.map((option, index) => { + const isSelected = selectedDownloader === option.downloader; + const tooltipId = `availability-indicator-${option.downloader}`; + const isLastItem = index === downloadOptions.length - 1; - return ( - - ); - })} + + if (option.isAvailableButNotConfigured) { + return ( + + ); + } + + if (option.canHandle) { + return ( + + ); + } + + return ( + + ); + }; + + const getRightContent = () => { + if (isSelected) { + return ( + + + + ); + } + + if ( + option.downloader === Downloader.RealDebrid && + option.canHandle + ) { + return ( +
+ {t("recommended")} +
+ ); + } + + return null; + }; + + return ( +
+ +
+ ); + })} +
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({ disabled={ downloadStarting || selectedDownloader === null || - !hasWritePermission + !hasWritePermission || + downloadOptions.some( + (option) => + option.downloader === selectedDownloader && + (option.isAvailableButNotConfigured || + (!option.isAvailable && option.canHandle) || + !option.canHandle) + ) } > - - {t("download_now")} + {downloadStarting ? ( + <> + + {t("loading")} + + ) : ( + <> + + {t("download_now")} + + )}
+ + setShowRealDebridModal(false)} + /> ); } diff --git a/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.scss b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.scss new file mode 100644 index 00000000..5a97ae92 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.scss @@ -0,0 +1,36 @@ +@use "../../../scss/globals.scss"; + +.real-debrid-info-modal { + &__content { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2.5); + width: 100%; + max-width: 500px; + } + + &__description-container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + } + + &__description { + margin: 0; + color: globals.$body-color; + line-height: 1.6; + } + + &__create-account { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + color: #c0c1c7; + text-decoration: underline; + font-size: 14px; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.tsx b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.tsx new file mode 100644 index 00000000..be539db7 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Button, Link, Modal } from "@renderer/components"; +import { LinkExternalIcon } from "@primer/octicons-react"; +import "./real-debrid-info-modal.scss"; + +const realDebridReferralId = import.meta.env + .RENDERER_VITE_REAL_DEBRID_REFERRAL_ID; + +const REAL_DEBRID_URL = realDebridReferralId + ? `https://real-debrid.com/?id=${realDebridReferralId}` + : "https://real-debrid.com"; + +export interface RealDebridInfoModalProps { + visible: boolean; + onClose: () => void; +} + +export function RealDebridInfoModal({ + visible, + onClose, +}: Readonly) { + const { t } = useTranslation("game_details"); + const { t: tSettings } = useTranslation("settings"); + const navigate = useNavigate(); + + return ( + +
+
+

+ {tSettings("real_debrid_description")} +

+ + + {tSettings("create_real_debrid_account")} + +
+ + +
+
+ ); +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6e900175..5c28a27e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -10,6 +10,7 @@ export enum Downloader { Hydra, Buzzheavier, FuckingFast, + VikingFile, } export enum DownloadSourceStatus { diff --git a/src/shared/index.ts b/src/shared/index.ts index d54ef387..36996e1d 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -124,6 +124,9 @@ export const getDownloadersForUri = (uri: string) => { if (uri.startsWith("https://fuckingfast.co")) { return [Downloader.FuckingFast]; } + if (uri.startsWith("https://vikingfile.com")) { + return [Downloader.VikingFile]; + } if (realDebridHosts.some((host) => uri.startsWith(host))) return [Downloader.RealDebrid]; diff --git a/src/types/index.ts b/src/types/index.ts index d3d36006..3ddd660c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -20,6 +20,7 @@ export interface GameRepack { title: string; fileSize: string | null; uris: string[]; + unavailableUris: string[]; uploadDate: string | null; downloadSourceId: string; downloadSourceName: string;