diff --git a/electron-builder.yml b/electron-builder.yml index 4f778e3b..2e4e5c2a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -24,6 +24,7 @@ nsis: uninstallDisplayName: ${productName} createDesktopShortcut: always oneClick: false + allowToChangeInstallationDirectory: true mac: entitlementsInherit: build/entitlements.mac.plist extendInfo: diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8b1abe79..8fab29e8 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -163,16 +163,20 @@ "validate_download_source": "Validate", "remove_download_source": "Remove", "add_download_source": "Add source", - "download_options_zero": "No download option", - "download_options_one": "{{countFormatted}} download option", - "download_options_other": "{{countFormatted}} download options", + "download_count_zero": "No downloads in list", + "download_count_one": "{{countFormatted}} download in list", + "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", - "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_errored": "Errored", - "resync_download_sources": "Resync", + "sync_download_sources": "Sync sources", "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": { "download_complete": "Download complete", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 008627bb..96705bbf 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -145,7 +145,7 @@ "launch_with_system": "Iniciar o Hydra junto com o sistema", "general": "Geral", "behavior": "Comportamento", - "download_sources": "Bibliotecas de download", + "download_sources": "Fontes de download", "language": "Idioma", "real_debrid_api_token": "Token de API", "enable_real_debrid": "Habilitar Real-Debrid", @@ -156,20 +156,24 @@ "real_debrid_linked_message": "Conta \"{{username}}\" vinculada", "save_changes": "Salvar mudanças", "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", "remove_download_source": "Remover", - "add_download_source": "Adicionar biblioteca", - "download_options_zero": "Sem opções de download", - "download_options_one": "{{countFormatted}} opcão de download", - "download_options_other": "{{countFormatted}} opções de download", - "download_source_url": "URL da biblioteca", - "add_download_source_description": "Insira a URL contendo o arquivo JSON", - "download_source_up_to_date": "Atualizado", + "add_download_source": "Adicionar fonte", + "download_count_zero": "Sem downloads na lista", + "download_count_one": "{{countFormatted}} download na lista", + "download_count_other": "{{countFormatted}} downloads na lista", + "download_options_zero": "Sem downloads disponíveis", + "download_options_one": "{{countFormatted}} download disponível", + "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", - "resync_download_sources": "Resincronizar", - "removed_download_source": "Biblioteca removida", - "added_download_source": "Biblioteca adicionada" + "sync_download_sources": "Sincronizar", + "removed_download_source": "Fonte removida", + "added_download_source": "Fonte adicionada", + "download_sources_synced": "As fontes foram sincronizadas" }, "notifications": { "download_complete": "Download concluído", diff --git a/src/main/entity/download-source.entity.ts b/src/main/entity/download-source.entity.ts index 44496f4f..dc59bac4 100644 --- a/src/main/entity/download-source.entity.ts +++ b/src/main/entity/download-source.entity.ts @@ -24,6 +24,9 @@ export class DownloadSource { @Column("text", { nullable: true }) etag: string | null; + @Column("int", { default: 0 }) + downloadCount: number; + @Column("text", { default: DownloadSourceStatus.UpToDate }) status: DownloadSourceStatus; diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index 5083476c..b0c0e470 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -18,7 +18,11 @@ const addDownloadSource = async ( async (transactionalEntityManager) => { const downloadSource = await transactionalEntityManager .getRepository(DownloadSource) - .save({ url, name: source.name }); + .save({ + url, + name: source.name, + downloadCount: source.downloads.length, + }); await insertDownloadsFromSource( transactionalEntityManager, diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts new file mode 100644 index 00000000..2e000e64 --- /dev/null +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -0,0 +1,7 @@ +import { registerEvent } from "../register-event"; +import { fetchDownloadSourcesAndUpdate } from "@main/helpers"; + +const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => + fetchDownloadSourcesAndUpdate(); + +registerEvent("syncDownloadSources", syncDownloadSources); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 23271eba..3d406b64 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -37,6 +37,7 @@ import "./download-sources/get-download-sources"; import "./download-sources/validate-download-source"; import "./download-sources/add-download-source"; import "./download-sources/remove-download-source"; +import "./download-sources/sync-download-sources"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/helpers/download-source.ts b/src/main/helpers/download-source.ts index a49246a7..012a4d24 100644 --- a/src/main/helpers/download-source.ts +++ b/src/main/helpers/download-source.ts @@ -52,19 +52,22 @@ export const fetchDownloadSourcesAndUpdate = async () => { await dataSource.transaction(async (transactionalEntityManager) => { for (const result of results) { - await transactionalEntityManager.getRepository(DownloadSource).update( - { id: result.id }, - { - etag: result.etag, - status: result.status, - } - ); + if (result.etag !== null) { + await transactionalEntityManager.getRepository(DownloadSource).update( + { id: result.id }, + { + etag: result.etag, + status: result.status, + downloadCount: result.downloads.length, + } + ); - await insertDownloadsFromSource( - transactionalEntityManager, - result, - result.downloads - ); + await insertDownloadsFromSource( + transactionalEntityManager, + result, + result.downloads + ); + } } await RepacksManager.updateRepacks(); diff --git a/src/main/main.ts b/src/main/main.ts index 185fdfe7..ea4e7dd9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,6 +6,7 @@ import { import { UserPreferences } from "./entity"; import { RealDebridClient } from "./services/real-debrid"; import { fetchDownloadSourcesAndUpdate } from "./helpers"; +import { publishNewRepacksNotifications } from "./services/notifications"; startMainLoop(); @@ -29,7 +30,9 @@ const loadState = async (userPreferences: UserPreferences | null) => { if (nextQueueItem?.game.status === "active") DownloadManager.startDownload(nextQueueItem.game); - fetchDownloadSourcesAndUpdate(); + fetchDownloadSourcesAndUpdate().then(() => { + publishNewRepacksNotifications(300); + }); }; userPreferencesRepository diff --git a/src/main/services/download-manager.ts b/src/main/services/download-manager.ts index 4d3d71a8..5c2c3f48 100644 --- a/src/main/services/download-manager.ts +++ b/src/main/services/download-manager.ts @@ -1,15 +1,10 @@ import Aria2, { StatusResponse } from "aria2"; -import { - downloadQueueRepository, - gameRepository, - userPreferencesRepository, -} from "@main/repository"; +import { downloadQueueRepository, gameRepository } from "@main/repository"; import { WindowManager } from "./window-manager"; import { RealDebridClient } from "./real-debrid"; -import { Notification } from "electron"; -import { t } from "i18next"; + import { Downloader } from "@shared"; import { DownloadProgress } from "@types"; import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; @@ -18,6 +13,7 @@ import { startAria2 } from "./aria2c"; import { sleep } from "@main/helpers"; import { logger } from "./logger"; import type { ChildProcess } from "node:child_process"; +import { publishDownloadCompleteNotification } from "./notifications"; export class DownloadManager { private static downloads = new Map(); @@ -69,26 +65,6 @@ export class DownloadManager { 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) { if (status.bittorrent?.info) return status.bittorrent.info.name; return ""; @@ -222,7 +198,7 @@ export class DownloadManager { } if (progress === 1 && this.game && !isDownloadingMetadata) { - await this.publishNotification(); + await publishDownloadCompleteNotification(this.game); await downloadQueueRepository.delete({ game: this.game }); diff --git a/src/main/services/notifications.ts b/src/main/services/notifications.ts new file mode 100644 index 00000000..127399f5 --- /dev/null +++ b/src/main/services/notifications.ts @@ -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(); + } +}; diff --git a/src/main/workers/download-source.worker.ts b/src/main/workers/download-source.worker.ts index d99154b2..cc33dd38 100644 --- a/src/main/workers/download-source.worker.ts +++ b/src/main/workers/download-source.worker.ts @@ -38,6 +38,7 @@ export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => { results.push({ ...downloadSource, downloads: [], + etag: null, status: isNotModified ? DownloadSourceStatus.UpToDate : DownloadSourceStatus.Errored, diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f0ca282..5e6d97af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -58,6 +58,7 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("addDownloadSource", url), removeDownloadSource: (id: number) => ipcRenderer.invoke("removeDownloadSource", id), + syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), /* Library */ addGameToLibrary: ( diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 85dddb84..f611cf4a 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -103,7 +103,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { isWindows: window.electron.platform === "win32", })} > -
+
+
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index f9bf8a7e..7f3fad3d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -88,6 +88,7 @@ declare global { ) => Promise<{ name: string; downloadCount: number }>; addDownloadSource: (url: string) => Promise; removeDownloadSource: (id: number) => Promise; + syncDownloadSources: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 1ed3a595..ce34e43f 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -47,21 +47,20 @@ export function Downloads() { complete: [], }; - const result = library - .filter((game) => { - return game.downloadPath; - }) - .reduce((prev, next) => { - if (lastPacket?.game.id === next.id) { - return { ...prev, downloading: [...prev.downloading, next] }; - } + const result = library.reduce((prev, next) => { + /* Game has been manually added to the library */ + if (!next.status) return prev; - if (next.downloadQueue || next.status === "paused") { - return { ...prev, queued: [...prev.queued, next] }; - } + /* Is downloading */ + if (lastPacket?.game.id === next.id) + return { ...prev, downloading: [...prev.downloading, next] }; - return { ...prev, complete: [...prev.complete, next] }; - }, initialValue); + /* Is either queued or paused */ + if (next.downloadQueue || next.status === "paused") + return { ...prev, queued: [...prev.queued, next] }; + + return { ...prev, complete: [...prev.complete, next] }; + }, initialValue); const queued = orderBy( result.queued, diff --git a/src/renderer/src/pages/settings/settings-download-sources.css.ts b/src/renderer/src/pages/settings/settings-download-sources.css.ts index 239c1be3..818de7b8 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.css.ts +++ b/src/renderer/src/pages/settings/settings-download-sources.css.ts @@ -1,5 +1,6 @@ import { style } from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "../../theme.css"; +import { recipe } from "@vanilla-extract/recipes"; export const downloadSourceField = style({ display: "flex", @@ -14,14 +15,24 @@ export const downloadSources = style({ flexDirection: "column", }); -export const downloadSourceItem = style({ - display: "flex", - flexDirection: "column", - backgroundColor: vars.color.darkBackground, - borderRadius: "8px", - padding: `${SPACING_UNIT * 2}px`, - gap: `${SPACING_UNIT}px`, - border: `solid 1px ${vars.color.border}`, +export const downloadSourceItem = recipe({ + base: { + display: "flex", + flexDirection: "column", + backgroundColor: vars.color.darkBackground, + borderRadius: "8px", + padding: `${SPACING_UNIT * 2}px`, + 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({ @@ -36,3 +47,10 @@ export const downloadSourcesHeader = style({ justifyContent: "space-between", alignItems: "center", }); + +export const separator = style({ + height: "100%", + width: "1px", + backgroundColor: vars.color.border, + margin: `${SPACING_UNIT}px 0`, +}); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index ca2a1694..a3dcd70a 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -9,11 +9,14 @@ import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react"; import { AddDownloadSourceModal } from "./add-download-source-modal"; import { useToast } from "@renderer/hooks"; import { DownloadSourceStatus } from "@shared"; +import { SPACING_UNIT } from "@renderer/theme.css"; export function SettingsDownloadSources() { const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = useState(false); const [downloadSources, setDownloadSources] = useState([]); + const [isSyncingDownloadSources, setIsSyncingDownloadSources] = + useState(false); const { t } = useTranslation("settings"); @@ -41,6 +44,20 @@ export function SettingsDownloadSources() { 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 = { [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"), [DownloadSourceStatus.Errored]: t("download_source_errored"), @@ -62,25 +79,31 @@ export function SettingsDownloadSources() {