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/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 71e4b57e..0ddf5f8e 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -11,12 +11,19 @@ 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: + if isinstance(header, list): + options["header"] = header + else: + options["header"] = header + if out: + options["out"] = out + downloads = self.aria2.add(url, options=options) self.download = downloads[0] def pause_download(self): diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index c36bf8ce..7fcdcd02 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 { @@ -499,6 +500,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/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..dae971c4 --- /dev/null +++ b/src/main/services/hosters/vikingfile.ts @@ -0,0 +1,51 @@ +import axios from "axios"; + +interface UnlockResponse { + link: string; + hoster: string; +} + +export class VikingFileApi { + private static readonly browserHeaders = { + "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", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + Referer: "https://vikingfile.com/", + }; + + 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; + + // Follow the redirect to get the final Cloudflare storage URL + try { + const redirectResponse = await axios.head(redirectUrl, { + headers: this.browserHeaders, + 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) { + // Fallback to the redirect URL if following redirect fails + return redirectUrl; + } + } +} 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..153fd644 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -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..b2885cb3 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,67 @@ color: globals.$body-color; } - &__downloaders { - display: grid; - gap: globals.$spacing-unit; - grid-template-columns: repeat(2, 1fr); + &__downloaders-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + max-height: 200px; + overflow-y: auto; + border: 1px solid globals.$border-color; + border-radius: 4px; + padding: calc(globals.$spacing-unit / 2); + background-color: globals.$dark-background-color; } - &__downloader-option { - position: relative; + &__downloader-item { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1.5); + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); + border: 1px solid transparent; + border-radius: 4px; + 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; - &:only-child { - grid-column: 1 / -1; + &:hover:not(&--disabled) { + background-color: rgba(255, 255, 255, 0.05); + } + + &--selected { + background-color: rgba(255, 255, 255, 0.08); + } + + &--disabled { + opacity: 0.5; + cursor: not-allowed; } } - &__downloader-icon { - position: absolute; - left: calc(globals.$spacing-unit * 2); + &__downloader-name { + flex: 1; + } + + &__availability-indicator { + width: 10px; + height: 10px; + 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); + } } &__path-error { 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..f488d23a 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 @@ -7,8 +7,13 @@ import { Modal, TextField, } from "@renderer/components"; -import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; -import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; +import { DownloadIcon } from "@primer/octicons-react"; +import { + Downloader, + formatBytes, + getDownloadersForUri, + getDownloadersForUris, +} from "@shared"; import type { GameRepack } from "@types"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; @@ -82,6 +87,41 @@ export function DownloadSettingsModal({ return getDownloadersForUris(repack?.uris ?? []); }, [repack?.uris]); + const downloadOptions = useMemo(() => { + if (!repack) return []; + + const unavailableUrisSet = new Set(repack.unavailableUris ?? []); + + const downloaderMap = new Map< + Downloader, + { hasAvailable: boolean; hasUnavailable: boolean } + >(); + + for (const uri of repack.uris) { + const uriDownloaders = getDownloadersForUri(uri); + if (uriDownloaders.length > 0) { + const downloader = uriDownloaders[0]; + const isAvailable = !unavailableUrisSet.has(uri); + + 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, + }); + } + } + } + + return Array.from(downloaderMap.entries()).map(([downloader, status]) => ({ + downloader, + isAvailable: status.hasAvailable, + })); + }, [repack]); + const getDefaultDownloader = useCallback( (availableDownloaders: Downloader[]) => { if (availableDownloaders.length === 0) return null; @@ -186,31 +226,47 @@ export function DownloadSettingsModal({
{t("downloader")} -
- {downloaders.map((downloader) => { - const shouldDisableButton = - (downloader === Downloader.RealDebrid && +
+ {downloadOptions.map((option) => { + const isUnavailable = !option.isAvailable; + const shouldDisableOption = + isUnavailable || + (option.downloader === Downloader.RealDebrid && !userPreferences?.realDebridApiToken) || - (downloader === Downloader.TorBox && + (option.downloader === Downloader.TorBox && !userPreferences?.torBoxApiToken) || - (downloader === Downloader.Hydra && + (option.downloader === Downloader.Hydra && !isFeatureEnabled(Feature.Nimbus)); + const isSelected = selectedDownloader === option.downloader; + return ( - + + {DOWNLOADER_NAME[option.downloader]} + + + ); })}
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 de792b05..2330c93b 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;