diff --git a/src/main/entity/download-source.entity.ts b/src/main/entity/download-source.entity.ts index ade42ce2..98e1f538 100644 --- a/src/main/entity/download-source.entity.ts +++ b/src/main/entity/download-source.entity.ts @@ -7,6 +7,7 @@ import { OneToMany, } from "typeorm"; import { Repack } from "./repack.entity"; +import { DownloadSourceStatus } from "@shared"; @Entity("download_source") export class DownloadSource { @@ -19,6 +20,12 @@ export class DownloadSource { @Column("text") name: string; + @Column("text", { nullable: true }) + etag: string | null; + + @Column("text", { default: "online" }) + status: DownloadSourceStatus; + @OneToMany(() => Repack, (repack) => repack.downloadSource, { cascade: true }) repacks: Repack[]; diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index cb311184..81ad50e8 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -1,12 +1,11 @@ import { registerEvent } from "../register-event"; -import { chunk } from "lodash-es"; import { dataSource } from "@main/data-source"; -import { DownloadSource, Repack } from "@main/entity"; +import { DownloadSource } from "@main/entity"; import axios from "axios"; -import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { downloadSourceSchema } from "../helpers/validators"; import { repackRepository } from "@main/repository"; import { repacksWorker } from "@main/workers"; +import { insertDownloadsFromSource } from "@main/helpers"; const addDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, @@ -22,30 +21,12 @@ const addDownloadSource = async ( .getRepository(DownloadSource) .save({ url, name: source.name }); - const repacks: QueryDeepPartialEntity[] = source.downloads.map( - (download) => ({ - title: download.title, - magnet: download.uris[0], - fileSize: download.fileSize, - repacker: source.name, - uploadDate: download.uploadDate, - downloadSource: { id: downloadSource.id }, - }) + await insertDownloadsFromSource( + transactionalEntityManager, + downloadSource, + source.downloads ); - const downloadsChunks = chunk(repacks, 800); - - for (const chunk of downloadsChunks) { - await transactionalEntityManager - .getRepository(Repack) - .createQueryBuilder() - .insert() - .values(chunk) - .updateEntity(false) - .orIgnore() - .execute(); - } - return downloadSource; } ); diff --git a/src/main/helpers/download-source.ts b/src/main/helpers/download-source.ts new file mode 100644 index 00000000..bbb7b3e2 --- /dev/null +++ b/src/main/helpers/download-source.ts @@ -0,0 +1,69 @@ +import { dataSource } from "@main/data-source"; +import { DownloadSource, Repack } from "@main/entity"; +import { downloadSourceSchema } from "@main/events/helpers/validators"; +import { downloadSourceRepository } from "@main/repository"; +import { downloadSourceWorker } from "@main/workers"; +import { chunk } from "lodash-es"; +import type { EntityManager } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { z } from "zod"; + +export const insertDownloadsFromSource = async ( + trx: EntityManager, + downloadSource: DownloadSource, + downloads: z.infer["downloads"] +) => { + const repacks: QueryDeepPartialEntity[] = downloads.map( + (download) => ({ + title: download.title, + magnet: download.uris[0], + fileSize: download.fileSize, + repacker: downloadSource.name, + uploadDate: download.uploadDate, + downloadSource: { id: downloadSource.id }, + }) + ); + + const downloadsChunks = chunk(repacks, 800); + + for (const chunk of downloadsChunks) { + await trx + .getRepository(Repack) + .createQueryBuilder() + .insert() + .values(chunk) + .updateEntity(false) + .orIgnore() + .execute(); + } +}; + +export const fetchDownloadSourcesAndUpdate = async () => { + const downloadSources = await downloadSourceRepository.find({ + order: { + id: "desc", + }, + }); + + const results = await downloadSourceWorker.run(downloadSources, { + name: "getUpdatedRepacks", + }); + + await dataSource.transaction(async (transactionalEntityManager) => { + for (const result of results) { + await transactionalEntityManager.getRepository(DownloadSource).update( + { id: result.id }, + { + etag: result.etag, + status: result.status, + } + ); + + await insertDownloadsFromSource( + transactionalEntityManager, + result, + result.downloads + ); + } + }); +}; diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 3e40c2b8..db1d13ff 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -58,3 +58,4 @@ export const requestWebPage = async (url: string) => { }; export * from "./ps"; +export * from "./download-source"; diff --git a/src/main/main.ts b/src/main/main.ts index b81398cf..616bf873 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,6 +8,7 @@ import { UserPreferences } from "./entity"; import { RealDebridClient } from "./services/real-debrid"; import { Not } from "typeorm"; import { repacksWorker } from "./workers"; +import { fetchDownloadSourcesAndUpdate } from "./helpers"; startMainLoop(); @@ -27,15 +28,14 @@ const loadState = async (userPreferences: UserPreferences | null) => { if (game) DownloadManager.startDownload(game); - repackRepository - .find({ - order: { - createdAt: "DESC", - }, - }) - .then((repacks) => { - repacksWorker.run(repacks, { name: "setRepacks" }); - }); + const repacks = await repackRepository.find({ + order: { + createdAt: "DESC", + }, + }); + + repacksWorker.run(repacks, { name: "setRepacks" }); + fetchDownloadSourcesAndUpdate(); }; userPreferencesRepository diff --git a/src/main/workers/download-source.worker.ts b/src/main/workers/download-source.worker.ts new file mode 100644 index 00000000..d99154b2 --- /dev/null +++ b/src/main/workers/download-source.worker.ts @@ -0,0 +1,49 @@ +import { downloadSourceSchema } from "@main/events/helpers/validators"; +import { DownloadSourceStatus } from "@shared"; +import type { DownloadSource } from "@types"; +import axios, { AxiosError, AxiosHeaders } from "axios"; +import { z } from "zod"; + +export type DownloadSourceResponse = z.infer & { + etag: string | null; + status: DownloadSourceStatus; +}; + +export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => { + const results: DownloadSourceResponse[] = []; + + for (const downloadSource of downloadSources) { + const headers = new AxiosHeaders(); + + if (downloadSource.etag) { + headers.set("If-None-Match", downloadSource.etag); + } + + try { + const response = await axios.get(downloadSource.url, { + headers, + }); + + const source = downloadSourceSchema.parse(response.data); + + results.push({ + ...downloadSource, + downloads: source.downloads, + etag: response.headers["etag"], + status: DownloadSourceStatus.UpToDate, + }); + } catch (err: unknown) { + const isNotModified = (err as AxiosError).response?.status === 304; + + results.push({ + ...downloadSource, + downloads: [], + status: isNotModified + ? DownloadSourceStatus.UpToDate + : DownloadSourceStatus.Errored, + }); + } + } + + return results; +}; diff --git a/src/main/workers/index.ts b/src/main/workers/index.ts index fd17995e..3bf883cc 100644 --- a/src/main/workers/index.ts +++ b/src/main/workers/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import steamGamesWorkerPath from "./steam-games.worker?modulePath"; import repacksWorkerPath from "./repacks.worker?modulePath"; +import downloadSourceWorkerPath from "./download-source.worker?modulePath"; import Piscina from "piscina"; @@ -16,3 +17,7 @@ export const steamGamesWorker = new Piscina({ export const repacksWorker = new Piscina({ filename: repacksWorkerPath, }); + +export const downloadSourceWorker = new Piscina({ + filename: downloadSourceWorkerPath, +}); diff --git a/src/renderer/src/components/badge/badge.css.ts b/src/renderer/src/components/badge/badge.css.ts new file mode 100644 index 00000000..5cc674b5 --- /dev/null +++ b/src/renderer/src/components/badge/badge.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css"; +import { SPACING_UNIT } from "../../theme.css"; + +export const badge = style({ + color: "#c0c1c7", + fontSize: "10px", + padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`, + border: "solid 1px #c0c1c7", + borderRadius: "4px", + display: "flex", + alignItems: "center", +}); diff --git a/src/renderer/src/components/badge/badge.tsx b/src/renderer/src/components/badge/badge.tsx new file mode 100644 index 00000000..0ff34782 --- /dev/null +++ b/src/renderer/src/components/badge/badge.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import * as styles from "./badge.css"; + +export interface BadgeProps { + children: React.ReactNode; +} + +export function Badge({ children }: BadgeProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/src/components/game-card/game-card.css.ts b/src/renderer/src/components/game-card/game-card.css.ts index 3047ce2f..b05b38b6 100644 --- a/src/renderer/src/components/game-card/game-card.css.ts +++ b/src/renderer/src/components/game-card/game-card.css.ts @@ -69,16 +69,7 @@ export const downloadOptions = style({ padding: "0", gap: `${SPACING_UNIT}px`, flexWrap: "wrap", -}); - -export const downloadOption = style({ - color: "#c0c1c7", - fontSize: "10px", - padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`, - border: "solid 1px #c0c1c7", - borderRadius: "4px", - display: "flex", - alignItems: "center", + listStyle: "none", }); export const specifics = style({ diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 616f9191..7df5aa13 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -5,6 +5,7 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import * as styles from "./game-card.css"; import { useTranslation } from "react-i18next"; +import { Badge } from "../badge/badge"; export interface GameCardProps extends React.DetailedHTMLProps< @@ -39,8 +40,8 @@ export function GameCard({ game, ...props }: GameCardProps) { {uniqueRepackers.length > 0 ? (
    {uniqueRepackers.map((repacker) => ( -
  • - {repacker} +
  • + {repacker}
  • ))}
diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 3885b300..fd266ef1 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -10,3 +10,4 @@ export * from "./checkbox-field/checkbox-field"; export * from "./link/link"; export * from "./select-field/select-field"; export * from "./toast/toast"; +export * from "./badge/badge"; diff --git a/src/renderer/src/pages/downloads/downloads.css.ts b/src/renderer/src/pages/downloads/downloads.css.ts index 913bec47..a670b8a5 100644 --- a/src/renderer/src/pages/downloads/downloads.css.ts +++ b/src/renderer/src/pages/downloads/downloads.css.ts @@ -21,16 +21,6 @@ export const downloadTitle = style({ }, }); -export const downloaderName = style({ - color: "#c0c1c7", - fontSize: "10px", - padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`, - border: "solid 1px #c0c1c7", - borderRadius: "4px", - display: "flex", - alignItems: "center", -}); - export const downloads = style({ width: "100%", gap: `${SPACING_UNIT * 2}px`, diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index f41d048b..a91b3d77 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Button, TextField } from "@renderer/components"; +import { Badge, Button, TextField } from "@renderer/components"; import { buildGameDetailsPath, formatDownloadProgress, @@ -255,9 +255,7 @@ export function Downloads() { />
- - {DOWNLOADER_NAME[game.downloader]} - + {DOWNLOADER_NAME[game.downloader]}
diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index c7d9bbde..a4314283 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -75,6 +75,7 @@ export function AddDownloadSourceModal({ - - - ))} + + {t("download_options", { + count: downloadSource.repackCount, + countFormatted: downloadSource.repackCount.toLocaleString(), + })} + + + +
+ + + +
+ + ))} + ); } diff --git a/src/shared/index.ts b/src/shared/index.ts index dc4b57d0..7a2933d7 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -3,6 +3,11 @@ export enum Downloader { Torrent, } +export enum DownloadSourceStatus { + UpToDate, + Errored, +} + const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; export const formatBytes = (bytes: number): string => { diff --git a/src/types/index.ts b/src/types/index.ts index 25b64361..fce68c8d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import type { Aria2Status } from "aria2"; -import type { Downloader } from "@shared"; +import type { DownloadSourceStatus, Downloader } from "@shared"; import { ProgressInfo, UpdateInfo } from "electron-updater"; export type GameShop = "steam" | "epic"; @@ -240,6 +240,8 @@ export interface DownloadSource { name: string; url: string; repackCount: number; + status: DownloadSourceStatus; + etag: string | null; createdAt: Date; updatedAt: Date; }