mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-22 18:33:56 +00:00
feat: using api download sources
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useRepacks,
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setGameRunning,
|
||||
setIsImportingSources,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
@@ -40,8 +38,6 @@ export function App() {
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const {
|
||||
@@ -199,36 +195,6 @@ export function App() {
|
||||
});
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
dispatch(setIsImportingSources(true));
|
||||
|
||||
try {
|
||||
// Initial repacks load
|
||||
await updateRepacks();
|
||||
|
||||
// Sync all local sources (check for updates)
|
||||
const newRepacksCount = await window.electron.syncDownloadSources();
|
||||
|
||||
if (newRepacksCount > 0) {
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
}
|
||||
|
||||
// Update fingerprints for sources that don't have them
|
||||
await window.electron.updateMissingFingerprints();
|
||||
|
||||
// Update repacks AFTER all syncing and fingerprint updates are complete
|
||||
await updateRepacks();
|
||||
} catch (error) {
|
||||
console.error("Error syncing download sources:", error);
|
||||
// Still update repacks even if sync fails
|
||||
await updateRepacks();
|
||||
} finally {
|
||||
dispatch(setIsImportingSources(false));
|
||||
}
|
||||
})();
|
||||
}, [updateRepacks, dispatch]);
|
||||
|
||||
const loadAndApplyTheme = useCallback(async () => {
|
||||
const activeTheme = await window.electron.getActiveCustomTheme();
|
||||
if (activeTheme?.code) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import type { GameStats } from "@types";
|
||||
import type { GameStats, ShopAssets } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
||||
@@ -8,15 +8,15 @@ import "./game-card.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { StarRating } from "../star-rating/star-rating";
|
||||
import { useCallback, useState, useMemo } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> {
|
||||
game: any;
|
||||
game: ShopAssets;
|
||||
}
|
||||
|
||||
const shopIcon = {
|
||||
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
|
||||
const uniqueRepackers = Array.from(
|
||||
new Set(repacks.map((repack) => repack.repacker))
|
||||
);
|
||||
|
||||
const handleHover = useCallback(() => {
|
||||
if (!stats) {
|
||||
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
|
||||
@@ -45,14 +38,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const firstThreeRepackers = useMemo(
|
||||
() => uniqueRepackers.slice(0, 3),
|
||||
[uniqueRepackers]
|
||||
);
|
||||
const remainingCount = useMemo(
|
||||
() => uniqueRepackers.length - 3,
|
||||
[uniqueRepackers]
|
||||
);
|
||||
console.log("game", game);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -75,18 +61,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
<p className="game-card__title">{game.title}</p>
|
||||
</div>
|
||||
|
||||
{uniqueRepackers.length > 0 ? (
|
||||
{game.downloadSources.length > 0 ? (
|
||||
<ul className="game-card__download-options">
|
||||
{firstThreeRepackers.map((repacker) => (
|
||||
<li key={repacker}>
|
||||
<Badge>{repacker}</Badge>
|
||||
{game.downloadSources.slice(0, 3).map((sourceName) => (
|
||||
<li key={sourceName}>
|
||||
<Badge>{sourceName}</Badge>
|
||||
</li>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
{game.downloadSources.length > 3 && (
|
||||
<li>
|
||||
<Badge>
|
||||
+{remainingCount}{" "}
|
||||
{t("game_card:available", { count: remainingCount })}
|
||||
+{game.downloadSources.length - 3}{" "}
|
||||
{t("game_card:available", {
|
||||
count: game.downloadSources.length - 3,
|
||||
})}
|
||||
</Badge>
|
||||
</li>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
@@ -13,11 +6,11 @@ import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useRepacks,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
LibraryGame,
|
||||
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const repacks = useMemo(() => {
|
||||
return getRepacksForObjectId(objectId);
|
||||
}, [getRepacksForObjectId, objectId]);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
const location = useLocation();
|
||||
@@ -287,19 +275,6 @@ export function GameDetailsContextProvider({
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const lastDownloadedOption = useMemo(() => {
|
||||
if (game?.download) {
|
||||
const repack = repacks.find((repack) =>
|
||||
repack.uris.some((uri) => uri.includes(game.download!.uri))
|
||||
);
|
||||
|
||||
if (!repack) return null;
|
||||
return repack;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [game?.download, repacks]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onUpdateAchievements(
|
||||
objectId,
|
||||
@@ -315,6 +290,34 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [objectId, shop, userDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDownloadSources = async () => {
|
||||
try {
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
|
||||
const params = {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
downloadSourceIds: sources.map((source) => source.id),
|
||||
};
|
||||
|
||||
const downloads = await window.electron.hydraApi.get<GameRepack[]>(
|
||||
`/games/${shop}/${objectId}/download-sources`,
|
||||
{
|
||||
params,
|
||||
needsAuth: false,
|
||||
}
|
||||
);
|
||||
|
||||
setRepacks(downloads);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch download sources:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDownloadSources();
|
||||
}, [shop, objectId]);
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
@@ -359,7 +362,7 @@ export function GameDetailsContextProvider({
|
||||
stats,
|
||||
achievements,
|
||||
hasNSFWContentBlocked,
|
||||
lastDownloadedOption,
|
||||
lastDownloadedOption: null,
|
||||
setHasNSFWContentBlocked,
|
||||
selectGameExecutable,
|
||||
updateGame,
|
||||
|
||||
22
src/renderer/src/declaration.d.ts
vendored
22
src/renderer/src/declaration.d.ts
vendored
@@ -31,8 +31,6 @@ import type {
|
||||
Game,
|
||||
DiskUsage,
|
||||
DownloadSource,
|
||||
DownloadSourceValidationResult,
|
||||
GameRepack,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
@@ -210,20 +208,12 @@ declare global {
|
||||
|
||||
/* Download sources */
|
||||
addDownloadSource: (url: string) => Promise<DownloadSource>;
|
||||
updateMissingFingerprints: () => Promise<number>;
|
||||
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
|
||||
getDownloadSources: () => Promise<
|
||||
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
|
||||
>;
|
||||
deleteDownloadSource: (id: number) => Promise<void>;
|
||||
deleteAllDownloadSources: () => Promise<void>;
|
||||
validateDownloadSource: (
|
||||
url: string
|
||||
) => Promise<DownloadSourceValidationResult>;
|
||||
syncDownloadSources: () => Promise<number>;
|
||||
getDownloadSourcesList: () => Promise<DownloadSource[]>;
|
||||
checkDownloadSourceExists: (url: string) => Promise<boolean>;
|
||||
getAllRepacks: () => Promise<GameRepack[]>;
|
||||
removeDownloadSource: (
|
||||
removeAll = false,
|
||||
downloadSourceId?: string
|
||||
) => Promise<void>;
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
syncDownloadSources: () => Promise<DownloadSource[]>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
export interface DownloadSourcesState {
|
||||
isImporting: boolean;
|
||||
}
|
||||
|
||||
const initialState: DownloadSourcesState = {
|
||||
isImporting: false,
|
||||
};
|
||||
|
||||
export const downloadSourcesSlice = createSlice({
|
||||
name: "downloadSources",
|
||||
initialState,
|
||||
reducers: {
|
||||
setIsImportingSources: (state, action) => {
|
||||
state.isImporting = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setIsImportingSources } = downloadSourcesSlice.actions;
|
||||
@@ -6,6 +6,4 @@ export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./game-running.slice";
|
||||
export * from "./subscription-slice";
|
||||
export * from "./repacks-slice";
|
||||
export * from "./download-sources-slice";
|
||||
export * from "./catalogue-search";
|
||||
|
||||
@@ -5,5 +5,4 @@ export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-format";
|
||||
export * from "./use-repacks";
|
||||
export * from "./use-feature";
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAppDispatch } from "./redux";
|
||||
import { setGenres, setTags } from "@renderer/features";
|
||||
import type { DownloadSource } from "@types";
|
||||
|
||||
export const externalResourcesInstance = axios.create({
|
||||
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
|
||||
@@ -12,6 +13,7 @@ export function useCatalogue() {
|
||||
|
||||
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
|
||||
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
|
||||
const getSteamUserTags = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
|
||||
@@ -37,17 +39,25 @@ export function useCatalogue() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getDownloadSources = useCallback(() => {
|
||||
window.electron.getDownloadSources().then((results) => {
|
||||
setDownloadSources(results.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSteamUserTags();
|
||||
getSteamGenres();
|
||||
getSteamPublishers();
|
||||
getSteamDevelopers();
|
||||
getDownloadSources();
|
||||
}, [
|
||||
getSteamUserTags,
|
||||
getSteamGenres,
|
||||
getSteamPublishers,
|
||||
getSteamDevelopers,
|
||||
getDownloadSources,
|
||||
]);
|
||||
|
||||
return { steamPublishers, steamDevelopers };
|
||||
return { steamPublishers, downloadSources, steamDevelopers };
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { setRepacks } from "@renderer/features";
|
||||
import { useCallback } from "react";
|
||||
import { RootState } from "@renderer/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useAppDispatch } from "./redux";
|
||||
|
||||
export function useRepacks() {
|
||||
const dispatch = useAppDispatch();
|
||||
const repacks = useSelector((state: RootState) => state.repacks.value);
|
||||
|
||||
const getRepacksForObjectId = useCallback(
|
||||
(objectId: string) => {
|
||||
return repacks.filter((repack) => repack.objectIds.includes(objectId));
|
||||
},
|
||||
[repacks]
|
||||
);
|
||||
|
||||
const updateRepacks = useCallback(async () => {
|
||||
const repacks = await window.electron.getAllRepacks();
|
||||
dispatch(
|
||||
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
return { getRepacksForObjectId, updateRepacks };
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { CatalogueSearchResult, DownloadSource } from "@types";
|
||||
import type {
|
||||
CatalogueSearchResult,
|
||||
CatalogueSearchPayload,
|
||||
DownloadSource,
|
||||
} from "@types";
|
||||
|
||||
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -29,13 +33,12 @@ export default function Catalogue() {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cataloguePageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { steamDevelopers, steamPublishers } = useCatalogue();
|
||||
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
|
||||
|
||||
const { steamGenres, steamUserTags } = useAppSelector(
|
||||
(state) => state.catalogueSearch
|
||||
);
|
||||
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
|
||||
@@ -51,24 +54,41 @@ export default function Catalogue() {
|
||||
const { t, i18n } = useTranslation("catalogue");
|
||||
|
||||
const debouncedSearch = useRef(
|
||||
debounce(async (filters, pageSize, offset) => {
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
debounce(
|
||||
async (
|
||||
filters: CatalogueSearchPayload,
|
||||
downloadSources: DownloadSource[],
|
||||
pageSize: number,
|
||||
offset: number
|
||||
) => {
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
const response = await window.electron.hydraApi.post<{
|
||||
edges: CatalogueSearchResult[];
|
||||
count: number;
|
||||
}>("/catalogue/search", {
|
||||
data: { ...filters, take: pageSize, skip: offset },
|
||||
needsAuth: false,
|
||||
});
|
||||
const requestData = {
|
||||
...filters,
|
||||
take: pageSize,
|
||||
skip: offset,
|
||||
downloadSourceIds: downloadSources.map(
|
||||
(downloadSource) => downloadSource.id
|
||||
),
|
||||
};
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
const response = await window.electron.hydraApi.post<{
|
||||
edges: CatalogueSearchResult[];
|
||||
count: number;
|
||||
}>("/catalogue/search", {
|
||||
data: requestData,
|
||||
needsAuth: false,
|
||||
});
|
||||
|
||||
setResults(response.edges);
|
||||
setItemsCount(response.count);
|
||||
setIsLoading(false);
|
||||
}, 500)
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
setResults(response.edges);
|
||||
setItemsCount(response.count);
|
||||
setIsLoading(false);
|
||||
},
|
||||
500
|
||||
)
|
||||
).current;
|
||||
|
||||
const decodeHTML = (s: string) =>
|
||||
@@ -79,18 +99,17 @@ export default function Catalogue() {
|
||||
setIsLoading(true);
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
|
||||
debouncedSearch(
|
||||
filters,
|
||||
downloadSources,
|
||||
PAGE_SIZE,
|
||||
(page - 1) * PAGE_SIZE
|
||||
);
|
||||
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [filters, page, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getDownloadSourcesList().then((sources) => {
|
||||
setDownloadSources(sources.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, []);
|
||||
}, [filters, downloadSources, page, debouncedSearch]);
|
||||
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
@@ -168,7 +187,7 @@ export default function Catalogue() {
|
||||
value: publisher,
|
||||
})),
|
||||
];
|
||||
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
|
||||
}, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
|
||||
|
||||
const filterSections = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
|
||||
import { useAppSelector, useLibrary } from "@renderer/hooks";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
|
||||
|
||||
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
|
||||
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
|
||||
|
||||
const [added, setAdded] = useState(false);
|
||||
@@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueRepackers = useMemo(() => {
|
||||
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
|
||||
}, [repacks]);
|
||||
|
||||
const genres = useMemo(() => {
|
||||
return game.genres?.map((genre) => {
|
||||
const index = steamGenres["en"]?.findIndex(
|
||||
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
|
||||
<span className="game-item__genres">{genres.join(", ")}</span>
|
||||
|
||||
<div className="game-item__repackers">
|
||||
{uniqueRepackers.map((repacker) => (
|
||||
<Badge key={repacker}>{repacker}</Badge>
|
||||
{game.downloadSources.map((sourceName) => (
|
||||
<Badge key={sourceName}>{sourceName}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,8 +144,6 @@ export function GameReviews({
|
||||
}
|
||||
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
|
||||
|
||||
console.log("reviews", reviews);
|
||||
|
||||
const loadReviews = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!objectId) return;
|
||||
@@ -440,8 +438,6 @@ export function GameReviews({
|
||||
});
|
||||
}, [reviews]);
|
||||
|
||||
console.log("reviews", reviews);
|
||||
|
||||
return (
|
||||
<div className="game-details__reviews-section">
|
||||
{showReviewPrompt &&
|
||||
|
||||
@@ -54,7 +54,7 @@ export function RepacksModal({
|
||||
{}
|
||||
);
|
||||
|
||||
const { repacks, game } = useContext(gameDetailsContext);
|
||||
const { game, repacks } = useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
@@ -88,6 +88,15 @@ export function RepacksModal({
|
||||
});
|
||||
}, [repacks, isFeatureEnabled, Feature]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDownloadSources = async () => {
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
setDownloadSources(sources);
|
||||
};
|
||||
|
||||
fetchDownloadSources();
|
||||
}, []);
|
||||
|
||||
const sortedRepacks = useMemo(() => {
|
||||
return orderBy(
|
||||
repacks,
|
||||
@@ -103,23 +112,13 @@ export function RepacksModal({
|
||||
);
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getDownloadSourcesList().then((sources) => {
|
||||
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
|
||||
const filteredSources = sources.filter(
|
||||
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
|
||||
);
|
||||
setDownloadSources(filteredSources);
|
||||
});
|
||||
}, [sortedRepacks]);
|
||||
|
||||
useEffect(() => {
|
||||
const term = filterTerm.trim().toLowerCase();
|
||||
|
||||
const byTerm = sortedRepacks.filter((repack) => {
|
||||
if (!term) return true;
|
||||
const lowerTitle = repack.title.toLowerCase();
|
||||
const lowerRepacker = repack.repacker.toLowerCase();
|
||||
const lowerRepacker = repack.downloadSourceName.toLowerCase();
|
||||
return lowerTitle.includes(term) || lowerRepacker.includes(term);
|
||||
});
|
||||
|
||||
@@ -130,7 +129,7 @@ export function RepacksModal({
|
||||
(src) =>
|
||||
src.fingerprint &&
|
||||
selectedFingerprints.includes(src.fingerprint) &&
|
||||
src.name === repack.repacker
|
||||
src.name === repack.downloadSourceName
|
||||
);
|
||||
});
|
||||
|
||||
@@ -281,7 +280,7 @@ export function RepacksModal({
|
||||
)}
|
||||
|
||||
<p className="repacks-modal__repack-info">
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.fileSize} - {repack.downloadSourceName} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -40,14 +40,20 @@ export default function Home() {
|
||||
setCurrentCatalogueCategory(category);
|
||||
setIsLoading(true);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
take: "12",
|
||||
skip: "0",
|
||||
});
|
||||
const downloadSources = await window.electron.getDownloadSources();
|
||||
|
||||
const params = {
|
||||
take: 12,
|
||||
skip: 0,
|
||||
downloadSourceIds: downloadSources.map((source) => source.id),
|
||||
};
|
||||
|
||||
const catalogue = await window.electron.hydraApi.get<ShopAssets[]>(
|
||||
`/catalogue/${category}?${params.toString()}`,
|
||||
{ needsAuth: false }
|
||||
`/catalogue/${category}`,
|
||||
{
|
||||
params,
|
||||
needsAuth: false,
|
||||
}
|
||||
);
|
||||
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
|
||||
@@ -38,4 +38,11 @@
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: globals.$spacing-unit;
|
||||
margin-top: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import type { DownloadSourceValidationResult } from "@types";
|
||||
import { setIsImportingSources } from "@renderer/features";
|
||||
import { SyncIcon } from "@primer/octicons-react";
|
||||
import "./add-download-source-modal.scss";
|
||||
|
||||
@@ -28,7 +25,6 @@ export function AddDownloadSourceModal({
|
||||
onClose,
|
||||
onAddDownloadSource,
|
||||
}: Readonly<AddDownloadSourceModalProps>) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -48,77 +44,38 @@ export function AddDownloadSourceModal({
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
const [validationResult, setValidationResult] =
|
||||
useState<DownloadSourceValidationResult | null>(null);
|
||||
|
||||
const { sourceUrl } = useContext(settingsContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setIsLoading(true);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormValues) => {
|
||||
const exists = await window.electron.checkDownloadSourceExists(
|
||||
values.url
|
||||
);
|
||||
try {
|
||||
await window.electron.addDownloadSource(values.url);
|
||||
|
||||
if (exists) {
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: t("source_already_exists"),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = await window.electron.validateDownloadSource(
|
||||
values.url
|
||||
);
|
||||
|
||||
setValidationResult(validationResult);
|
||||
setUrl(values.url);
|
||||
},
|
||||
[setError, t]
|
||||
);
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
} catch (error) {
|
||||
console.error("Failed to add download source:", error);
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: "Failed to add download source. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue("url", "");
|
||||
clearErrors();
|
||||
setIsLoading(false);
|
||||
setValidationResult(null);
|
||||
|
||||
if (sourceUrl) {
|
||||
setValue("url", sourceUrl);
|
||||
handleSubmit(onSubmit)();
|
||||
}
|
||||
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
if (validationResult) {
|
||||
setIsLoading(true);
|
||||
dispatch(setIsImportingSources(true));
|
||||
|
||||
try {
|
||||
// Single call that handles: import → API sync → fingerprint
|
||||
await window.electron.addDownloadSource(url);
|
||||
|
||||
// Close modal and update UI
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
} catch (error) {
|
||||
console.error("Failed to add download source:", error);
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: "Failed to import source. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
dispatch(setIsImportingSources(false));
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [visible, clearErrors, setValue, sourceUrl]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Prevent closing while importing
|
||||
if (isLoading) return;
|
||||
onClose();
|
||||
};
|
||||
@@ -132,49 +89,32 @@ export function AddDownloadSourceModal({
|
||||
clickOutsideToClose={!isLoading}
|
||||
>
|
||||
<div className="add-download-source-modal__container">
|
||||
<TextField
|
||||
{...register("url")}
|
||||
label={t("download_source_url")}
|
||||
placeholder={t("insert_valid_json_url")}
|
||||
error={errors.url?.message}
|
||||
rightContent={
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextField
|
||||
{...register("url")}
|
||||
label={t("download_source_url")}
|
||||
placeholder={t("insert_valid_json_url")}
|
||||
error={errors.url?.message}
|
||||
/>
|
||||
|
||||
<div className="add-download-source-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
className="add-download-source-modal__validate-button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{t("validate_download_source")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{validationResult && (
|
||||
<div className="add-download-source-modal__validation-result">
|
||||
<div className="add-download-source-modal__validation-info">
|
||||
<h4>{validationResult?.name}</h4>
|
||||
<small>
|
||||
{t("found_download_option", {
|
||||
count: validationResult?.downloadCount,
|
||||
countFormatted:
|
||||
validationResult?.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddDownloadSource}
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting || isLoading}>
|
||||
{isLoading && (
|
||||
<SyncIcon className="add-download-source-modal__spinner" />
|
||||
)}
|
||||
{isLoading ? t("importing") : t("import")}
|
||||
{isLoading ? t("adding") : t("add_download_source")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TrashIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
|
||||
import { useAppDispatch, useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -35,7 +35,6 @@ export function SettingsDownloadSources() {
|
||||
useState(false);
|
||||
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
|
||||
useState(false);
|
||||
const [isFetchingSources, setIsFetchingSources] = useState(true);
|
||||
|
||||
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
|
||||
|
||||
@@ -46,37 +45,29 @@ export function SettingsDownloadSources() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
await window.electron
|
||||
.getDownloadSourcesList()
|
||||
.then((sources) => {
|
||||
setDownloadSources(sources);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetchingSources(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDownloadSources();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceUrl) setShowAddDownloadSourceModal(true);
|
||||
}, [sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDownloadSources = async () => {
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
setDownloadSources(sources);
|
||||
};
|
||||
|
||||
fetchDownloadSources();
|
||||
}, []);
|
||||
|
||||
const handleRemoveSource = async (downloadSource: DownloadSource) => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
|
||||
try {
|
||||
await window.electron.deleteDownloadSource(downloadSource.id);
|
||||
await window.electron.removeDownloadSource(downloadSource.url);
|
||||
|
||||
await window.electron.removeDownloadSource(false, downloadSource.id);
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
setDownloadSources(sources as DownloadSource[]);
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
await getDownloadSources();
|
||||
updateRepacks();
|
||||
} catch (error) {
|
||||
console.error("Failed to remove download source:", error);
|
||||
} finally {
|
||||
setIsRemovingDownloadSource(false);
|
||||
}
|
||||
@@ -86,53 +77,44 @@ export function SettingsDownloadSources() {
|
||||
setIsRemovingDownloadSource(true);
|
||||
|
||||
try {
|
||||
await window.electron.deleteAllDownloadSources();
|
||||
await window.electron.removeDownloadSource("", true);
|
||||
|
||||
showSuccessToast(t("removed_download_sources"));
|
||||
await getDownloadSources();
|
||||
setShowConfirmationDeleteAllSourcesModal(false);
|
||||
updateRepacks();
|
||||
await window.electron.removeDownloadSource(true);
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
setDownloadSources(sources as DownloadSource[]);
|
||||
showSuccessToast(t("removed_all_download_sources"));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove all download sources:", error);
|
||||
} finally {
|
||||
setIsRemovingDownloadSource(false);
|
||||
setShowConfirmationDeleteAllSourcesModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
// Refresh sources list and repacks after import completes
|
||||
await getDownloadSources();
|
||||
|
||||
// Force repacks update to ensure UI reflects new data
|
||||
await updateRepacks();
|
||||
|
||||
showSuccessToast(t("added_download_source"));
|
||||
try {
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
setDownloadSources(sources as DownloadSource[]);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh download sources:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const syncDownloadSources = async () => {
|
||||
setIsSyncingDownloadSources(true);
|
||||
|
||||
try {
|
||||
// Sync local sources (check for updates)
|
||||
await window.electron.syncDownloadSources();
|
||||
|
||||
// Refresh sources and repacks AFTER sync completes
|
||||
await getDownloadSources();
|
||||
await updateRepacks();
|
||||
|
||||
showSuccessToast(t("download_sources_synced"));
|
||||
} catch (error) {
|
||||
console.error("Error syncing download sources:", error);
|
||||
// Still refresh the UI even if sync fails
|
||||
await getDownloadSources();
|
||||
await updateRepacks();
|
||||
const sources = await window.electron.syncDownloadSources();
|
||||
setDownloadSources(sources);
|
||||
} finally {
|
||||
setIsSyncingDownloadSources(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusTitle = {
|
||||
[DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
|
||||
[DownloadSourceStatus.Errored]: t("download_source_errored"),
|
||||
[DownloadSourceStatus.PendingMatching]: t(
|
||||
"download_source_pending_matching"
|
||||
),
|
||||
[DownloadSourceStatus.Matched]: t("download_source_matched"),
|
||||
[DownloadSourceStatus.Matching]: t("download_source_matching"),
|
||||
[DownloadSourceStatus.Failed]: t("download_source_failed"),
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
@@ -180,8 +162,7 @@ export function SettingsDownloadSources() {
|
||||
disabled={
|
||||
!downloadSources.length ||
|
||||
isSyncingDownloadSources ||
|
||||
isRemovingDownloadSource ||
|
||||
isFetchingSources
|
||||
isRemovingDownloadSource
|
||||
}
|
||||
onClick={syncDownloadSources}
|
||||
>
|
||||
@@ -197,8 +178,7 @@ export function SettingsDownloadSources() {
|
||||
disabled={
|
||||
isRemovingDownloadSource ||
|
||||
isSyncingDownloadSources ||
|
||||
!downloadSources.length ||
|
||||
isFetchingSources
|
||||
!downloadSources.length
|
||||
}
|
||||
>
|
||||
<TrashIcon />
|
||||
@@ -209,11 +189,7 @@ export function SettingsDownloadSources() {
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => setShowAddDownloadSourceModal(true)}
|
||||
disabled={
|
||||
isSyncingDownloadSources ||
|
||||
isFetchingSources ||
|
||||
isRemovingDownloadSource
|
||||
}
|
||||
disabled={isSyncingDownloadSources || isRemovingDownloadSource}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{t("add_download_source")}
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
userDetailsSlice,
|
||||
gameRunningSlice,
|
||||
subscriptionSlice,
|
||||
repacksSlice,
|
||||
downloadSourcesSlice,
|
||||
catalogueSearchSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
@@ -23,8 +21,6 @@ export const store = configureStore({
|
||||
userDetails: userDetailsSlice.reducer,
|
||||
gameRunning: gameRunningSlice.reducer,
|
||||
subscription: subscriptionSlice.reducer,
|
||||
repacks: repacksSlice.reducer,
|
||||
downloadSources: downloadSourcesSlice.reducer,
|
||||
catalogueSearch: catalogueSearchSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user