Skip to content

Commit

Permalink
Merge pull request #10 from hydralauncher/feature/adding-how-long-to-…
Browse files Browse the repository at this point in the history
…beat-integration

feat: adding how long to beat integration
  • Loading branch information
Hydra authored Apr 15, 2024
2 parents 66a1153 + bf413e5 commit ef035e4
Show file tree
Hide file tree
Showing 24 changed files with 407 additions and 72 deletions.
5 changes: 5 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
semi: true,
trailingComma: "all",
singleQuote: false,
};
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Hydra

<a href="https://discord.gg/hydralauncher" target="_blank">![Discord](https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c)</a>
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)
![GitHub package.json version](https://img.shields.io/github/package-json/v/hydralauncher/hydra)

Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using [libtorrent](https://www.libtorrent.org/).
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@
"release_date": "Released in {{date}}",
"publisher": "Published by {{publisher}}",
"copy_link_to_clipboard": "Copy link",
"copied_link_to_clipboard": "Link copied"
"copied_link_to_clipboard": "Link copied",
"hours": "hours",
"minutes": "minutes",
"accuracy": "{{accuracy}}% accuracy"
},
"activation": {
"title": "Activate Hydra",
Expand Down
5 changes: 4 additions & 1 deletion src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@
"release_date": "Fecha de lanzamiento {{date}}",
"publisher": "Publicado por {{publisher}}",
"copy_link_to_clipboard": "Copiar enlace",
"copied_link_to_clipboard": "Enlace copiado"
"copied_link_to_clipboard": "Enlace copiado",
"hours": "horas",
"minutes": "minutos",
"accuracy": "{{accuracy}}% precisión"
},
"activation": {
"title": "Activar Hydra",
Expand Down
5 changes: 4 additions & 1 deletion src/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@
"release_date": "Lançado em {{date}}",
"publisher": "Publicado por {{publisher}}",
"copy_link_to_clipboard": "Copiar link",
"copied_link_to_clipboard": "Link copiado"
"copied_link_to_clipboard": "Link copiado",
"hours": "horas",
"minutes": "minutos",
"accuracy": "{{accuracy}}% de precisão"
},
"activation": {
"title": "Ativação",
Expand Down
25 changes: 25 additions & 0 deletions src/main/events/catalogue/get-how-long-to-beat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";

import { registerEvent } from "../register-event";

const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
_shop: GameShop,
title: string
): Promise<HowLongToBeatCategory[] | null> => {
const response = await searchHowLongToBeat(title);
const game = response.data.find(
(game) => game.profile_steam === Number(objectID)
);

if (!game) return null;
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
return howLongToBeat;
};

registerEvent(getHowLongToBeat, {
name: "getHowLongToBeat",
memoize: true,
});
1 change: 1 addition & 0 deletions src/main/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import "./misc/show-open-dialog";
import "./library/remove-game";
import "./library/delete-game-folder";
import "./catalogue/get-random-game";
import "./catalogue/get-how-long-to-beat";

ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());
Expand Down
8 changes: 5 additions & 3 deletions src/main/helpers/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* String formatting */

export const removeReleaseYearFromName = (name: string) => name;
export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, "");

export const removeSymbolsFromName = (name: string) => name;
export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, "");

export const removeSpecialEditionFromName = (name: string) =>
name.replace(
/(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited) Edition/g,
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
""
);

Expand Down
60 changes: 60 additions & 0 deletions src/main/services/how-long-to-beat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { formatName } from "@main/helpers";
import axios from "axios";
import { JSDOM } from "jsdom";
import { requestWebPage } from "./repack-tracker/helpers";
import { HowLongToBeatCategory } from "@types";

export interface HowLongToBeatResult {
game_id: number;
profile_steam: number;
}

export interface HowLongToBeatSearchResponse {
data: HowLongToBeatResult[];
}

export const searchHowLongToBeat = async (gameName: string) => {
const response = await axios.post(
"https://howlongtobeat.com/api/search",
{
searchType: "games",
searchTerms: formatName(gameName).split(" "),
searchPage: 1,
size: 100,
},
{
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
Referer: "https://howlongtobeat.com/",
},
},
);

return response.data as HowLongToBeatSearchResponse;
};

export const getHowLongToBeatGame = async (
id: string,
): Promise<HowLongToBeatCategory[]> => {
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);

const { window } = new JSDOM(response);
const { document } = window;

const $ul = document.querySelector(".shadow_shadow ul");
const $lis = Array.from($ul.children);

return $lis.map(($li) => {
const title = $li.querySelector("h4").textContent;
const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);

const accuracy = accuracyClassName.split("time_").at(1);

return {
title,
duration: $li.querySelector("h5").textContent,
accuracy,
};
});
};
1 change: 1 addition & 0 deletions src/main/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from "./update-resolver";
export * from "./window-manager";
export * from "./fifo";
export * from "./torrent-client";
export * from "./how-long-to-beat";
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld("electron", {
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),

/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
Expand Down
24 changes: 5 additions & 19 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,12 @@ import {
import * as styles from "./app.css";
import { themeClass } from "./theme.css";

import debounce from "lodash/debounce";
import type { DebouncedFunc } from "lodash";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
clearSearch,
setUserPreferences,
setRepackersFriendlyNames,
setSearchResults,
} from "@renderer/features";

document.body.classList.add(themeClass);
Expand All @@ -36,8 +33,6 @@ export function App() {
const navigate = useNavigate();
const location = useLocation();

const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);

const search = useAppSelector((state) => state.search.value);

useEffect(() => {
Expand All @@ -61,7 +56,7 @@ export function App() {
}

addPacket(downloadProgress);
}
},
);

return () => {
Expand All @@ -72,26 +67,17 @@ export function App() {
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
if (debouncedFunc.current) debouncedFunc.current.cancel();

if (query === "") {
navigate(-1);
return;
}

if (location.pathname !== "/search") {
navigate("/search");
}

debouncedFunc.current = debounce(() => {
window.electron.searchGames(query).then((results) => {
dispatch(setSearchResults(results));
});
}, 300);

debouncedFunc.current();
navigate(`/search/${query}`, {
replace: location.pathname.startsWith("/search"),
});
},
[dispatch, location.pathname, navigate]
[dispatch, location.pathname, navigate],
);

const handleClear = useCallback(() => {
Expand Down
63 changes: 60 additions & 3 deletions src/renderer/components/header/header.css.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import type { ComplexStyleRule } from "@vanilla-extract/css";
import { style } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";

import { SPACING_UNIT, vars } from "@renderer/theme.css";

export const slideIn = keyframes({
"0%": { transform: "translateX(20px)", opacity: "0" },
"100%": {
transform: "translateX(0)",
opacity: "1",
},
});

export const slideOut = keyframes({
"0%": { transform: "translateX(0px)", opacity: "1" },
"100%": {
transform: "translateX(20px)",
opacity: "0",
},
});

export const header = recipe({
base: {
Expand Down Expand Up @@ -83,9 +100,49 @@ export const actionButton = style({
},
});

export const leftContent = style({
export const section = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
height: "100%",
});

export const backButton = recipe({
base: {
color: vars.color.bodyText,
cursor: "pointer",
WebkitAppRegion: "no-drag",
position: "absolute",
transition: "transform ease 0.2s",
animationDuration: "0.2s",
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
} as ComplexStyleRule,
variants: {
enabled: {
true: {
animationName: slideIn,
},
false: {
opacity: "0",
pointerEvents: "none",
animationName: slideOut,
},
},
},
});

export const title = recipe({
base: {
transition: "all ease 0.2s",
},
variants: {
hasBackButton: {
true: {
transform: "translateX(28px)",
},
},
},
});
Loading

0 comments on commit ef035e4

Please sign in to comment.