Merge branch 'feature/add-download-sources' into feature/game-options-modal

This commit is contained in:
Zamitto 2024-06-05 13:35:02 -03:00
commit 87eaf021b4
19 changed files with 208 additions and 96 deletions

View file

@ -24,6 +24,7 @@ nsis:
uninstallDisplayName: ${productName} uninstallDisplayName: ${productName}
createDesktopShortcut: always createDesktopShortcut: always
oneClick: false oneClick: false
allowToChangeInstallationDirectory: true
mac: mac:
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:

View file

@ -163,16 +163,20 @@
"validate_download_source": "Validate", "validate_download_source": "Validate",
"remove_download_source": "Remove", "remove_download_source": "Remove",
"add_download_source": "Add source", "add_download_source": "Add source",
"download_options_zero": "No download option", "download_count_zero": "No downloads in list",
"download_options_one": "{{countFormatted}} download option", "download_count_one": "{{countFormatted}} download in list",
"download_options_other": "{{countFormatted}} download options", "download_count_other": "{{countFormatted}} downloads in list",
"download_options_zero": "No download available",
"download_options_one": "{{countFormatted}} download available",
"download_options_other": "{{countFormatted}} downloads available",
"download_source_url": "Download source URL", "download_source_url": "Download source URL",
"add_download_source_description": "Insert the URL containing the JSON file", "add_download_source_description": "Insert the URL containing the .json file",
"download_source_up_to_date": "Up-to-date", "download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored", "download_source_errored": "Errored",
"resync_download_sources": "Resync", "sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed", "removed_download_source": "Download source removed",
"added_download_source": "Added download source" "added_download_source": "Added download source",
"download_sources_synced": "All download sources are synced"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",

View file

@ -145,7 +145,7 @@
"launch_with_system": "Iniciar o Hydra junto com o sistema", "launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral", "general": "Geral",
"behavior": "Comportamento", "behavior": "Comportamento",
"download_sources": "Bibliotecas de download", "download_sources": "Fontes de download",
"language": "Idioma", "language": "Idioma",
"real_debrid_api_token": "Token de API", "real_debrid_api_token": "Token de API",
"enable_real_debrid": "Habilitar Real-Debrid", "enable_real_debrid": "Habilitar Real-Debrid",
@ -156,20 +156,24 @@
"real_debrid_linked_message": "Conta \"{{username}}\" vinculada", "real_debrid_linked_message": "Conta \"{{username}}\" vinculada",
"save_changes": "Salvar mudanças", "save_changes": "Salvar mudanças",
"changes_saved": "Ajustes salvos com sucesso", "changes_saved": "Ajustes salvos com sucesso",
"download_sources_description": "Hydra vai buscar links de download em todas as bibliotecas habilitadas. A URL da biblioteca deve ser um link direto para um arquivo .json contendo uma lista de links.", "download_sources_description": "Hydra vai buscar links de download em todas as fonte habilitadas. A URL da fonte deve ser um link direto para um arquivo .json contendo uma lista de links.",
"validate_download_source": "Validar", "validate_download_source": "Validar",
"remove_download_source": "Remover", "remove_download_source": "Remover",
"add_download_source": "Adicionar biblioteca", "add_download_source": "Adicionar fonte",
"download_options_zero": "Sem opções de download", "download_count_zero": "Sem downloads na lista",
"download_options_one": "{{countFormatted}} opcão de download", "download_count_one": "{{countFormatted}} download na lista",
"download_options_other": "{{countFormatted}} opções de download", "download_count_other": "{{countFormatted}} downloads na lista",
"download_source_url": "URL da biblioteca", "download_options_zero": "Sem downloads disponíveis",
"add_download_source_description": "Insira a URL contendo o arquivo JSON", "download_options_one": "{{countFormatted}} download disponível",
"download_source_up_to_date": "Atualizado", "download_options_other": "{{countFormatted}} downloads disponíveis",
"download_source_url": "URL da fonte",
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou", "download_source_errored": "Falhou",
"resync_download_sources": "Resincronizar", "sync_download_sources": "Sincronizar",
"removed_download_source": "Biblioteca removida", "removed_download_source": "Fonte removida",
"added_download_source": "Biblioteca adicionada" "added_download_source": "Fonte adicionada",
"download_sources_synced": "As fontes foram sincronizadas"
}, },
"notifications": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",

View file

@ -24,6 +24,9 @@ export class DownloadSource {
@Column("text", { nullable: true }) @Column("text", { nullable: true })
etag: string | null; etag: string | null;
@Column("int", { default: 0 })
downloadCount: number;
@Column("text", { default: DownloadSourceStatus.UpToDate }) @Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus; status: DownloadSourceStatus;

View file

@ -18,7 +18,11 @@ const addDownloadSource = async (
async (transactionalEntityManager) => { async (transactionalEntityManager) => {
const downloadSource = await transactionalEntityManager const downloadSource = await transactionalEntityManager
.getRepository(DownloadSource) .getRepository(DownloadSource)
.save({ url, name: source.name }); .save({
url,
name: source.name,
downloadCount: source.downloads.length,
});
await insertDownloadsFromSource( await insertDownloadsFromSource(
transactionalEntityManager, transactionalEntityManager,

View file

@ -0,0 +1,7 @@
import { registerEvent } from "../register-event";
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
fetchDownloadSourcesAndUpdate();
registerEvent("syncDownloadSources", syncDownloadSources);

View file

@ -37,6 +37,7 @@ import "./download-sources/get-download-sources";
import "./download-sources/validate-download-source"; import "./download-sources/validate-download-source";
import "./download-sources/add-download-source"; import "./download-sources/add-download-source";
import "./download-sources/remove-download-source"; import "./download-sources/remove-download-source";
import "./download-sources/sync-download-sources";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());

View file

@ -52,19 +52,22 @@ export const fetchDownloadSourcesAndUpdate = async () => {
await dataSource.transaction(async (transactionalEntityManager) => { await dataSource.transaction(async (transactionalEntityManager) => {
for (const result of results) { for (const result of results) {
await transactionalEntityManager.getRepository(DownloadSource).update( if (result.etag !== null) {
{ id: result.id }, await transactionalEntityManager.getRepository(DownloadSource).update(
{ { id: result.id },
etag: result.etag, {
status: result.status, etag: result.etag,
} status: result.status,
); downloadCount: result.downloads.length,
}
);
await insertDownloadsFromSource( await insertDownloadsFromSource(
transactionalEntityManager, transactionalEntityManager,
result, result,
result.downloads result.downloads
); );
}
} }
await RepacksManager.updateRepacks(); await RepacksManager.updateRepacks();

View file

@ -6,6 +6,7 @@ import {
import { UserPreferences } from "./entity"; import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid"; import { RealDebridClient } from "./services/real-debrid";
import { fetchDownloadSourcesAndUpdate } from "./helpers"; import { fetchDownloadSourcesAndUpdate } from "./helpers";
import { publishNewRepacksNotifications } from "./services/notifications";
startMainLoop(); startMainLoop();
@ -29,7 +30,9 @@ const loadState = async (userPreferences: UserPreferences | null) => {
if (nextQueueItem?.game.status === "active") if (nextQueueItem?.game.status === "active")
DownloadManager.startDownload(nextQueueItem.game); DownloadManager.startDownload(nextQueueItem.game);
fetchDownloadSourcesAndUpdate(); fetchDownloadSourcesAndUpdate().then(() => {
publishNewRepacksNotifications(300);
});
}; };
userPreferencesRepository userPreferencesRepository

View file

@ -1,15 +1,10 @@
import Aria2, { StatusResponse } from "aria2"; import Aria2, { StatusResponse } from "aria2";
import { import { downloadQueueRepository, gameRepository } from "@main/repository";
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import { Notification } from "electron";
import { t } from "i18next";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { DownloadProgress } from "@types"; import { DownloadProgress } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
@ -18,6 +13,7 @@ import { startAria2 } from "./aria2c";
import { sleep } from "@main/helpers"; import { sleep } from "@main/helpers";
import { logger } from "./logger"; import { logger } from "./logger";
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { publishDownloadCompleteNotification } from "./notifications";
export class DownloadManager { export class DownloadManager {
private static downloads = new Map<number, string>(); private static downloads = new Map<number, string>();
@ -69,26 +65,6 @@ export class DownloadManager {
return -1; return -1;
} }
static async publishNotification() {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled && this.game) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: this.game.title,
}),
}).show();
}
}
private static getFolderName(status: StatusResponse) { private static getFolderName(status: StatusResponse) {
if (status.bittorrent?.info) return status.bittorrent.info.name; if (status.bittorrent?.info) return status.bittorrent.info.name;
return ""; return "";
@ -222,7 +198,7 @@ export class DownloadManager {
} }
if (progress === 1 && this.game && !isDownloadingMetadata) { if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification(); await publishDownloadCompleteNotification(this.game);
await downloadQueueRepository.delete({ game: this.game }); await downloadQueueRepository.delete({ game: this.game });

View file

@ -0,0 +1,44 @@
import { Notification } from "electron";
import { t } from "i18next";
import { Game } from "@main/entity";
import { userPreferencesRepository } from "@main/repository";
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: game.title,
}),
}).show();
}
};
export const publishNewRepacksNotifications = async (count: number) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (count > 0 && userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: count,
}),
}).show();
}
};

View file

@ -38,6 +38,7 @@ export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
results.push({ results.push({
...downloadSource, ...downloadSource,
downloads: [], downloads: [],
etag: null,
status: isNotModified status: isNotModified
? DownloadSourceStatus.UpToDate ? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored, : DownloadSourceStatus.Errored,

View file

@ -58,6 +58,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addDownloadSource", url), ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (id: number) => removeDownloadSource: (id: number) =>
ipcRenderer.invoke("removeDownloadSource", id), ipcRenderer.invoke("removeDownloadSource", id),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
/* Library */ /* Library */
addGameToLibrary: ( addGameToLibrary: (

View file

@ -103,7 +103,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
isWindows: window.electron.platform === "win32", isWindows: window.electron.platform === "win32",
})} })}
> >
<div className={styles.section}> <section className={styles.section}>
<button <button
type="button" type="button"
className={styles.backButton({ className={styles.backButton({
@ -122,7 +122,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
> >
{title} {title}
</h3> </h3>
</div> </section>
<section className={styles.section}> <section className={styles.section}>
<div className={styles.search({ focused: isFocused })}> <div className={styles.search({ focused: isFocused })}>

View file

@ -88,6 +88,7 @@ declare global {
) => Promise<{ name: string; downloadCount: number }>; ) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>; addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>; removeDownloadSource: (id: number) => Promise<void>;
syncDownloadSources: () => Promise<void>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>; getDiskFreeSpace: (path: string) => Promise<DiskSpace>;

View file

@ -47,21 +47,20 @@ export function Downloads() {
complete: [], complete: [],
}; };
const result = library const result = library.reduce((prev, next) => {
.filter((game) => { /* Game has been manually added to the library */
return game.downloadPath; if (!next.status) return prev;
})
.reduce((prev, next) => {
if (lastPacket?.game.id === next.id) {
return { ...prev, downloading: [...prev.downloading, next] };
}
if (next.downloadQueue || next.status === "paused") { /* Is downloading */
return { ...prev, queued: [...prev.queued, next] }; if (lastPacket?.game.id === next.id)
} return { ...prev, downloading: [...prev.downloading, next] };
return { ...prev, complete: [...prev.complete, next] }; /* Is either queued or paused */
}, initialValue); if (next.downloadQueue || next.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
const queued = orderBy( const queued = orderBy(
result.queued, result.queued,

View file

@ -1,5 +1,6 @@
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadSourceField = style({ export const downloadSourceField = style({
display: "flex", display: "flex",
@ -14,14 +15,24 @@ export const downloadSources = style({
flexDirection: "column", flexDirection: "column",
}); });
export const downloadSourceItem = style({ export const downloadSourceItem = recipe({
display: "flex", base: {
flexDirection: "column", display: "flex",
backgroundColor: vars.color.darkBackground, flexDirection: "column",
borderRadius: "8px", backgroundColor: vars.color.darkBackground,
padding: `${SPACING_UNIT * 2}px`, borderRadius: "8px",
gap: `${SPACING_UNIT}px`, padding: `${SPACING_UNIT * 2}px`,
border: `solid 1px ${vars.color.border}`, gap: `${SPACING_UNIT}px`,
border: `solid 1px ${vars.color.border}`,
transition: "all ease 0.2s",
},
variants: {
isSyncing: {
true: {
opacity: vars.opacity.disabled,
},
},
},
}); });
export const downloadSourceItemHeader = style({ export const downloadSourceItemHeader = style({
@ -36,3 +47,10 @@ export const downloadSourcesHeader = style({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}); });
export const separator = style({
height: "100%",
width: "1px",
backgroundColor: vars.color.border,
margin: `${SPACING_UNIT}px 0`,
});

View file

@ -9,11 +9,14 @@ import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal"; import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks"; import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
export function SettingsDownloadSources() { export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
useState(false); useState(false);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]); const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
useState(false);
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@ -41,6 +44,20 @@ export function SettingsDownloadSources() {
showSuccessToast(t("added_download_source")); showSuccessToast(t("added_download_source"));
}; };
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
window.electron
.syncDownloadSources()
.then(() => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
})
.finally(() => {
setIsSyncingDownloadSources(false);
});
};
const statusTitle = { const statusTitle = {
[DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"), [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
[DownloadSourceStatus.Errored]: t("download_source_errored"), [DownloadSourceStatus.Errored]: t("download_source_errored"),
@ -62,25 +79,31 @@ export function SettingsDownloadSources() {
<Button <Button
type="button" type="button"
theme="outline" theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)} disabled={!downloadSources.length || isSyncingDownloadSources}
onClick={syncDownloadSources}
> >
<PlusCircleIcon /> <SyncIcon />
{t("add_download_source")} {t("sync_download_sources")}
</Button> </Button>
<Button <Button
type="button" type="button"
theme="outline" theme="outline"
disabled={!downloadSources.length} onClick={() => setShowAddDownloadSourceModal(true)}
> >
<SyncIcon /> <PlusCircleIcon />
{t("resync_download_sources")} {t("add_download_source")}
</Button> </Button>
</div> </div>
<ul className={styles.downloadSources}> <ul className={styles.downloadSources}>
{downloadSources.map((downloadSource) => ( {downloadSources.map((downloadSource) => (
<li key={downloadSource.id} className={styles.downloadSourceItem}> <li
key={downloadSource.id}
className={styles.downloadSourceItem({
isSyncing: isSyncingDownloadSources,
})}
>
<div className={styles.downloadSourceItemHeader}> <div className={styles.downloadSourceItemHeader}>
<h2>{downloadSource.name}</h2> <h2>{downloadSource.name}</h2>
@ -88,12 +111,30 @@ export function SettingsDownloadSources() {
<Badge>{statusTitle[downloadSource.status]}</Badge> <Badge>{statusTitle[downloadSource.status]}</Badge>
</div> </div>
<small> <div
{t("download_options", { style={{
count: downloadSource.repackCount, display: "flex",
countFormatted: downloadSource.repackCount.toLocaleString(), alignItems: "center",
})} gap: `${SPACING_UNIT}px`,
</small> }}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
<div className={styles.separator} />
<small>
{t("download_options", {
count: downloadSource.repackCount,
countFormatted: downloadSource.repackCount.toLocaleString(),
})}
</small>
</div>
</div> </div>
<div className={styles.downloadSourceField}> <div className={styles.downloadSourceField}>

View file

@ -248,6 +248,7 @@ export interface DownloadSource {
url: string; url: string;
repackCount: number; repackCount: number;
status: DownloadSourceStatus; status: DownloadSourceStatus;
downloadCount: number;
etag: string | null; etag: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;