feat: adding proper path for real debrid downloads

This commit is contained in:
Hydra 2024-05-07 15:42:09 +01:00
commit 5ec56bda5b
No known key found for this signature in database
16 changed files with 279 additions and 214 deletions

View File

@ -53,6 +53,7 @@
"jsdom": "^24.0.0",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"node-7z-archive": "^1.1.7",
"parse-torrent": "^11.0.16",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0",

View File

@ -127,7 +127,9 @@
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install"
"install": "Install",
"real_debrid": "Real Debrid",
"torrent": "Torrent"
},
"settings": {
"downloads_path": "Downloads path",
@ -138,9 +140,11 @@
"telemetry": "Telemetry",
"telemetry_description": "Enable anonymous usage statistics",
"real_debrid_api_token_description": "(Optional) Real Debrid API token",
"quit_app_instead_hiding": "Quit Hydra instead of minimizing to tray",
"launch_with_system": "Launch Hydra on system start-up",
"general": "General",
"behavior": "Behavior",
"quit_app_instead_hiding": "Close app instead of minimizing to tray",
"launch_with_system": "Launch app on system start-up"
"real_debrid": "Real Debrid"
},
"notifications": {
"download_complete": "Download complete",

View File

@ -26,7 +26,7 @@ const startGameDownload = async (
});
const downloader = userPreferences?.realDebridApiToken
? Downloader.Http
? Downloader.RealDebrid
: Downloader.Torrent;
const [game, repack] = await Promise.all([

View File

@ -4,7 +4,7 @@ import type { Game } from "@main/entity";
import { Downloader } from "@shared";
import { writePipe } from "./fifo";
import { HTTPDownloader } from "./downloaders";
import { RealDebridDownloader } from "./downloaders";
export class DownloadManager {
private static gameDownloading: Game;
@ -25,7 +25,7 @@ export class DownloadManager {
) {
writePipe.write({ action: "cancel" });
} else {
HTTPDownloader.destroy();
RealDebridDownloader.destroy();
}
}
@ -36,7 +36,7 @@ export class DownloadManager {
) {
writePipe.write({ action: "pause" });
} else {
HTTPDownloader.destroy();
RealDebridDownloader.destroy();
}
}
@ -51,7 +51,7 @@ export class DownloadManager {
save_path: game!.downloadPath,
});
} else {
HTTPDownloader.startDownload(game!);
RealDebridDownloader.startDownload(game!);
}
this.gameDownloading = game!;
@ -68,7 +68,7 @@ export class DownloadManager {
save_path: game!.downloadPath,
});
} else {
HTTPDownloader.startDownload(game!);
RealDebridDownloader.startDownload(game!);
}
this.gameDownloading = game!;

View File

@ -1,2 +1,2 @@
export * from "./http.downloader";
export * from "./real-debrid.downloader";
export * from "./torrent.downloader";

View File

@ -4,11 +4,12 @@ import path from "node:path";
import fs from "node:fs";
import EasyDL from "easydl";
import { GameStatus } from "@shared";
import { fullArchive } from "node-7z-archive";
import { Downloader } from "./downloader";
import { RealDebridClient } from "../real-debrid";
export class HTTPDownloader extends Downloader {
export class RealDebridDownloader extends Downloader {
private static download: EasyDL;
private static downloadSize = 0;
@ -22,52 +23,48 @@ export class HTTPDownloader extends Downloader {
return 1;
}
static async getDownloadUrl(game: Game) {
const torrents = await RealDebridClient.getAllTorrentsFromUser();
const hash = RealDebridClient.extractSHA1FromMagnet(game!.repack.magnet);
let torrent = torrents.find((t) => t.hash === hash);
if (!torrent) {
const magnet = await RealDebridClient.addMagnet(game!.repack.magnet);
if (magnet && magnet.id) {
await RealDebridClient.selectAllFiles(magnet.id);
torrent = await RealDebridClient.getInfo(magnet.id);
}
}
if (torrent) {
const { links } = torrent;
const { download } = await RealDebridClient.unrestrictLink(links[0]);
if (!download) {
throw new Error("Torrent not cached on Real Debrid");
}
return download;
}
throw new Error();
}
private static createFolderIfNotExists(path: string) {
if (!fs.existsSync(path)) {
fs.mkdirSync(path);
}
}
private static async startDecompression(
rarFile: string,
dest: string,
game: Game
) {
await fullArchive(rarFile, dest);
const updatePayload: QueryDeepPartialEntity<Game> = {
status: GameStatus.Finished,
progress: 1,
};
await this.updateGameProgress(game.id, updatePayload, {
timeRemaining: 0,
});
}
static destroy() {
if (this.download) {
this.download.destroy();
}
}
static async startDownload(game: Game) {
if (this.download) this.download.destroy();
const downloadUrl = await this.getDownloadUrl(game);
const downloadUrl = decodeURIComponent(
await RealDebridClient.getDownloadUrl(game)
);
const filename = path.basename(downloadUrl);
const folderName = path.basename(filename, path.extname(filename));
const fullDownloadPath = path.join(game.downloadPath!, folderName);
const downloadPath = path.join(game.downloadPath!, folderName);
this.createFolderIfNotExists(downloadPath);
this.createFolderIfNotExists(fullDownloadPath);
this.download = new EasyDL(downloadUrl, fullDownloadPath);
this.download = new EasyDL(downloadUrl, path.join(downloadPath, filename));
const metadata = await this.download.metadata();
@ -76,7 +73,7 @@ export class HTTPDownloader extends Downloader {
const updatePayload: QueryDeepPartialEntity<Game> = {
status: GameStatus.Downloading,
fileSize: metadata.size,
folderName: folderName,
folderName,
};
const downloadStatus = {
@ -87,11 +84,8 @@ export class HTTPDownloader extends Downloader {
this.download.on("progress", async ({ total }) => {
const updatePayload: QueryDeepPartialEntity<Game> = {
status:
total.percentage === 100
? GameStatus.Finished
: GameStatus.Downloading,
progress: total.percentage / 100,
status: GameStatus.Downloading,
progress: Math.min(0.99, total.percentage / 100),
bytesDownloaded: total.bytes,
};
@ -102,11 +96,22 @@ export class HTTPDownloader extends Downloader {
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
});
}
static destroy() {
if (this.download) {
this.download.destroy();
}
this.download.on("end", async () => {
const updatePayload: QueryDeepPartialEntity<Game> = {
status: GameStatus.Decompressing,
progress: 0.99,
};
await this.updateGameProgress(game.id, updatePayload, {
timeRemaining: 0,
});
this.startDecompression(
path.join(downloadPath, filename),
downloadPath,
game
);
});
}
}

View File

@ -62,6 +62,34 @@ export class RealDebridClient {
return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase();
}
static async getDownloadUrl(game: Game) {
const torrents = await RealDebridClient.getAllTorrentsFromUser();
const hash = RealDebridClient.extractSHA1FromMagnet(game!.repack.magnet);
let torrent = torrents.find((t) => t.hash === hash);
if (!torrent) {
const magnet = await RealDebridClient.addMagnet(game!.repack.magnet);
if (magnet && magnet.id) {
await RealDebridClient.selectAllFiles(magnet.id);
torrent = await RealDebridClient.getInfo(magnet.id);
}
}
if (torrent) {
const { links } = torrent;
const { download } = await RealDebridClient.unrestrictLink(links[0]);
if (!download) {
throw new Error("Torrent not cached on Real Debrid");
}
return download;
}
throw new Error();
}
static async authorize(apiToken: string) {
this.instance = axios.create({
baseURL: base,

View File

@ -66,7 +66,7 @@ export function Downloads() {
};
const downloaderName = {
[Downloader.Http]: t("real_debrid"),
[Downloader.RealDebrid]: t("real_debrid"),
[Downloader.Torrent]: t("torrent"),
};

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { ShopDetails, SteamMovies, SteamScreenshot } from "@types";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import * as styles from "./game-details.css";
import * as styles from "./gallery-slider.css";
export interface GallerySliderProps {
gameDetails: ShopDetails | null;
@ -22,6 +22,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
}
return 0;
});
const [mediaIndex, setMediaIndex] = useState<number>(0);
const [arrowShow, setArrowShow] = useState(false);
@ -41,6 +42,10 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
});
};
useEffect(() => {
setMediaIndex(0);
}, [gameDetails]);
useEffect(() => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
@ -49,10 +54,10 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
}
}, [mediaIndex, mediaCount]);
}, [gameDetails, mediaIndex, mediaCount]);
const hasScreenshots = gameDetails && gameDetails.screenshots.length > 0;
const hasMovies = gameDetails && gameDetails.movies.length > 0;
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
const hasMovies = gameDetails && gameDetails.movies?.length;
return (
<>
@ -72,6 +77,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }}
autoPlay
loop
muted
>
<source src={video.webm.max.replace("http", "https")} />
@ -112,7 +118,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
<div className={styles.gallerySliderPreview} ref={scrollContainerRef}>
{hasMovies &&
gameDetails.movies.map((video: SteamMovies, i: number) => (
gameDetails.movies?.map((video: SteamMovies, i: number) => (
<img
key={video.id}
onClick={() => setMediaIndex(i)}

View File

@ -79,95 +79,6 @@ export const descriptionContent = style({
height: "100%",
});
export const gallerySliderContainer = style({
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
});
export const gallerySliderMedia = style({
width: "100%",
height: "100%",
display: "block",
flexShrink: 0,
flexGrow: "0",
transition: "translate 0.3s ease-in-out",
borderRadius: "4px",
});
export const gallerySliderAnimationContainer = style({
width: "100%",
height: "100%",
display: "flex",
position: "relative",
overflow: "hidden",
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
});
export const gallerySliderPreview = style({
width: "100%",
paddingTop: "0.5rem",
height: "100%",
display: "flex",
position: "relative",
overflowX: "auto",
overflowY: "hidden",
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
"::-webkit-scrollbar-thumb": {
width: "20%",
},
});
export const gallerySliderMediaPreview = style({
cursor: "pointer",
width: "20%",
height: "20%",
display: "block",
flexShrink: 0,
flexGrow: 0,
opacity: 0.3,
paddingRight: "5px",
transition: "translate 300ms ease-in-out",
borderRadius: "4px",
":hover": {
opacity: 1,
},
});
export const gallerySliderMediaPreviewActive = style({
opacity: 1,
});
export const gallerySliderButton = style({
all: "unset",
display: "block",
position: "absolute",
top: 0,
bottom: 0,
padding: "1rem",
cursor: "pointer",
transition: "background-color 100ms ease-in-out",
":hover": {
backgroundColor: "rgb(0, 0, 0, 0.2)",
},
});
export const gallerySliderIcons = style({
fill: vars.color.muted,
width: "2rem",
height: "2rem",
});
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
width: "100%",

View File

@ -257,6 +257,13 @@ export function GameDetails() {
}}
className={styles.description}
/>
<small>
All screenshots and movies displayed on this page are the
property of Steam and/or their respective owners. We do not
claim ownership of any content unless otherwise stated. All
content is used for informational and promotional purposes only.
</small>
</div>
<div className={styles.contentSidebar}>

View File

@ -24,3 +24,8 @@ export const downloadsPathField = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const settingsCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});

View File

@ -5,7 +5,11 @@ import * as styles from "./settings.css";
import { useTranslation } from "react-i18next";
import { UserPreferences } from "@types";
const categories = ["general", "behavior", "real_debrid"];
export function Settings() {
const [currentCategory, setCurrentCategory] = useState(categories.at(0)!);
const [form, setForm] = useState({
downloadsPath: "",
downloadNotificationsEnabled: false,
@ -61,62 +65,80 @@ export function Settings() {
}
};
return (
<section className={styles.container}>
<div className={styles.content}>
<div className={styles.downloadsPathField}>
<TextField
label={t("downloads_path")}
value={form.downloadsPath}
readOnly
disabled
const renderCategory = () => {
if (currentCategory === "general") {
return (
<>
<div className={styles.downloadsPathField}>
<TextField
label={t("downloads_path")}
value={form.downloadsPath}
readOnly
disabled
/>
<Button
style={{ alignSelf: "flex-end" }}
theme="outline"
onClick={handleChooseDownloadsPath}
>
{t("change")}
</Button>
</div>
<h3>{t("notifications")}</h3>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"downloadNotificationsEnabled",
!form.downloadNotificationsEnabled
)
}
/>
<Button
style={{ alignSelf: "flex-end" }}
theme="outline"
onClick={handleChooseDownloadsPath}
>
{t("change")}
</Button>
</div>
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"repackUpdatesNotificationsEnabled",
!form.repackUpdatesNotificationsEnabled
)
}
/>
<h3>{t("notifications")}</h3>
<h3>{t("telemetry")}</h3>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"downloadNotificationsEnabled",
!form.downloadNotificationsEnabled
)
}
<CheckboxField
label={t("telemetry_description")}
checked={form.telemetryEnabled}
onChange={() =>
updateUserPreferences("telemetryEnabled", !form.telemetryEnabled)
}
/>
</>
);
}
if (currentCategory === "real_debrid") {
return (
<TextField
label={t("real_debrid_api_token_description")}
value={form.realDebridApiToken ?? ""}
type="password"
onChange={(event) => {
updateUserPreferences("realDebridApiToken", event.target.value);
}}
placeholder="API Token"
/>
);
}
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"repackUpdatesNotificationsEnabled",
!form.repackUpdatesNotificationsEnabled
)
}
/>
<h3>{t("telemetry")}</h3>
<CheckboxField
label={t("telemetry_description")}
checked={form.telemetryEnabled}
onChange={() =>
updateUserPreferences("telemetryEnabled", !form.telemetryEnabled)
}
/>
<h3>{t("behavior")}</h3>
return (
<>
<CheckboxField
label={t("quit_app_instead_hiding")}
checked={form.preferQuitInsteadOfHiding}
@ -128,15 +150,6 @@ export function Settings() {
}
/>
<TextField
label={t("real_debrid_api_token_description")}
value={form.realDebridApiToken ?? ""}
type="password"
onChange={(event) => {
updateUserPreferences("realDebridApiToken", event.target.value);
}}
/>
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
@ -145,6 +158,27 @@ export function Settings() {
}}
checked={form.runAtStartup}
/>
</>
);
};
return (
<section className={styles.container}>
<div className={styles.content}>
<section className={styles.settingsCategories}>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => setCurrentCategory(category)}
>
{t(category)}
</Button>
))}
</section>
<h3>{t(currentCategory)}</h3>
{renderCategory()}
</div>
</section>
);

View File

@ -5,11 +5,12 @@ export enum GameStatus {
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
Decompressing = "decompressing",
Finished = "finished",
}
export enum Downloader {
Http,
RealDebrid,
Torrent,
}

View File

@ -16,7 +16,7 @@ export interface SteamScreenshot {
export interface SteamVideoSource {
max: string;
'480': string;
"480": string;
}
export interface SteamMovies {
@ -35,7 +35,7 @@ export interface SteamAppDetails {
short_description: string;
publishers: string[];
genres: SteamGenre[];
movies: SteamMovies[];
movies?: SteamMovies[];
screenshots: SteamScreenshot[];
pc_requirements: {
minimum: string;

View File

@ -1440,7 +1440,7 @@
dependencies:
undici-types "~5.26.4"
"@types/node@^18.11.18":
"@types/node@^18.11.18", "@types/node@^18.7.13":
version "18.19.31"
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz"
integrity sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==
@ -1523,6 +1523,11 @@
resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087"
integrity sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==
"@types/when@^2.4.34":
version "2.4.41"
resolved "https://registry.yarnpkg.com/@types/when/-/when-2.4.41.tgz#e16e685aa739c696a582b10afc5f1306964846a2"
integrity sha512-o/j5X9Bnv6mMG4ZcNJur8UaU17Rl0mLbTZvWcODVVy+Xdh8LEc7s6I0CvbEuTP786LTa0OyJby5P4hI7C+ZJNg==
"@types/yauzl@^2.9.1":
version "2.10.3"
resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz"
@ -4375,7 +4380,12 @@ minimatch@^8.0.2:
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6:
minimist@1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6, minimist@^1.2.8:
version "1.2.8"
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@ -4484,6 +4494,20 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
node-7z-archive@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/node-7z-archive/-/node-7z-archive-1.1.7.tgz#0b037701e016a651d6040b63d8781b2e7102facd"
integrity sha512-gtpWpajFyzeObGiYI9RDq76x5ULnxInvZ1OfA0/MD+2VezcMmMQMK6ITqkvsGEqVy4w/psvmIyowVDoSURAJHg==
dependencies:
fs-extra "^10.1.0"
minimist "^1.2.8"
node-sys "^1.2.2"
node-unar "^1.0.8"
node-wget-fetch "^1.1.3"
when "^3.7.8"
optionalDependencies:
"@types/when" "^2.4.34"
node-abi@^3.3.0:
version "3.62.0"
resolved "https://registry.npmjs.org/node-abi/-/node-abi-3.62.0.tgz"
@ -4522,6 +4546,40 @@ node-releases@^2.0.14:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz"
integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
node-stream-zip@^1.12.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/node-stream-zip/-/node-stream-zip-1.15.0.tgz#158adb88ed8004c6c49a396b50a6a5de3bca33ea"
integrity sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==
node-sys@^1.1.7, node-sys@^1.2.2:
version "1.2.4"
resolved "https://registry.yarnpkg.com/node-sys/-/node-sys-1.2.4.tgz#db9c50fd93c8fc62bc4eafe93eae0fd3696c8028"
integrity sha512-71sIz+zgaHfSmP1vHTHXUVb77PqncIB1MBij+Q43fQSz7ceSLrrO5RTTBlnYWDU/M0fEFTZw3Zui/lVeJvoeag==
dependencies:
minimist "1.2.6"
which "^2.0.2"
optionalDependencies:
"@types/node" "^18.7.13"
node-unar@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/node-unar/-/node-unar-1.0.8.tgz#fbf5b05da2ac24278b6160f3b46231d56a73a673"
integrity sha512-AnEdWmV8/Dx1qMB5O2VcemoBmNzW1mhibYNl3YDUI7cVohVuobuIZwxrtRedItO05A6PiLp/HNw1ryg7M17H5g==
dependencies:
node-sys "^1.1.7"
when "^3.7.8"
optionalDependencies:
fs-extra "^9.0.1"
node-stream-zip "^1.12.0"
node-wget-fetch "^1.1.2"
node-wget-fetch@^1.1.2, node-wget-fetch@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/node-wget-fetch/-/node-wget-fetch-1.1.3.tgz#1e4aea2d7093393a961bb9c07cf5c5e33913c437"
integrity sha512-TmjZeeL/zAcB4fpok2iJ6FLbjVzSsjKi7rdk0womqvUY2ouitsEN0kGekndshaB7ENnXocrcgUudpvB4Jo3+LA==
dependencies:
node-fetch "^2.6.7"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@ -5964,6 +6022,11 @@ whatwg-url@^5.0.0:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
when@^3.7.8:
version "3.7.8"
resolved "https://registry.yarnpkg.com/when/-/when-3.7.8.tgz#c7130b6a7ea04693e842cdc9e7a1f2aa39a39f82"
integrity sha512-5cZ7mecD3eYcMiCH4wtRPA5iFJZ50BJYDfckI5RRpQiktMiYTcn0ccLTZOvcbBume+1304fQztxeNzNS9Gvrnw==
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"