Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding how long to beat integration #10

Merged
merged 4 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading