mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-24 11:21:02 +00:00
Merge branch 'main' into feat/disabling-update-badges
This commit is contained in:
@@ -29,20 +29,23 @@ import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
|
||||
private static extractFilename(url: string, originalUrl?: string): string | undefined {
|
||||
if (originalUrl?.includes('#')) {
|
||||
const hashPart = originalUrl.split('#')[1];
|
||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
||||
private static extractFilename(
|
||||
url: string,
|
||||
originalUrl?: string
|
||||
): string | undefined {
|
||||
if (originalUrl?.includes("#")) {
|
||||
const hashPart = originalUrl.split("#")[1];
|
||||
if (hashPart && !hashPart.startsWith("http")) return hashPart;
|
||||
}
|
||||
|
||||
if (url.includes('#')) {
|
||||
const hashPart = url.split('#')[1];
|
||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
||||
if (url.includes("#")) {
|
||||
const hashPart = url.split("#")[1];
|
||||
if (hashPart && !hashPart.startsWith("http")) return hashPart;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const filename = urlObj.pathname.split('/').pop();
|
||||
const filename = urlObj.pathname.split("/").pop();
|
||||
if (filename?.length) return filename;
|
||||
} catch {
|
||||
// Invalid URL
|
||||
@@ -52,13 +55,20 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static sanitizeFilename(filename: string): string {
|
||||
return filename.replace(/[<>:"/\\|?*]/g, '_');
|
||||
return filename.replace(/[<>:"/\\|?*]/g, "_");
|
||||
}
|
||||
|
||||
private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) {
|
||||
private static createDownloadPayload(
|
||||
directUrl: string,
|
||||
originalUrl: string,
|
||||
downloadId: string,
|
||||
savePath: string
|
||||
) {
|
||||
const filename = this.extractFilename(directUrl, originalUrl);
|
||||
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
|
||||
|
||||
const sanitizedFilename = filename
|
||||
? this.sanitizeFilename(filename)
|
||||
: undefined;
|
||||
|
||||
if (sanitizedFilename) {
|
||||
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||
}
|
||||
@@ -98,7 +108,9 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static async getDownloadStatus() {
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||
"/status"
|
||||
);
|
||||
if (response.data === null || !this.downloadingGameId) return null;
|
||||
const downloadId = this.downloadingGameId;
|
||||
|
||||
@@ -114,7 +126,8 @@ export class DownloadManager {
|
||||
status,
|
||||
} = response.data;
|
||||
|
||||
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
const download = await downloadsSublevel.get(downloadId);
|
||||
@@ -179,7 +192,10 @@ export class DownloadManager {
|
||||
if (progress === 1 && download) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
|
||||
if (
|
||||
userPreferences?.seedAfterDownloadComplete &&
|
||||
download.downloader === Downloader.Torrent
|
||||
) {
|
||||
await downloadsSublevel.put(gameId, {
|
||||
...download,
|
||||
status: "seeding",
|
||||
@@ -200,13 +216,22 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
if (shouldExtract) {
|
||||
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||
const gameFilesManager = new GameFilesManager(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) {
|
||||
if (
|
||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
||||
download.folderName?.endsWith(ext)
|
||||
)
|
||||
) {
|
||||
gameFilesManager.extractDownloadedFile();
|
||||
} else {
|
||||
gameFilesManager
|
||||
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
|
||||
.extractFilesInDirectory(
|
||||
path.join(download.downloadPath, download.folderName!)
|
||||
)
|
||||
.then(() => gameFilesManager.setExtractionComplete());
|
||||
}
|
||||
}
|
||||
@@ -214,11 +239,13 @@ export class DownloadManager {
|
||||
const downloads = await downloadsSublevel
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => sortBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
));
|
||||
.then((games) =>
|
||||
sortBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
)
|
||||
);
|
||||
|
||||
const [nextItemOnQueue] = downloads;
|
||||
|
||||
@@ -245,7 +272,9 @@ export class DownloadManager {
|
||||
|
||||
if (!download) return;
|
||||
|
||||
const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName));
|
||||
const totalSize = await getDirSize(
|
||||
path.join(download.downloadPath, status.folderName)
|
||||
);
|
||||
|
||||
if (totalSize < status.fileSize) {
|
||||
await this.cancelDownload(status.gameId);
|
||||
@@ -266,7 +295,10 @@ export class DownloadManager {
|
||||
|
||||
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: downloadKey,
|
||||
} as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
@@ -357,24 +389,44 @@ export class DownloadManager {
|
||||
};
|
||||
}
|
||||
case Downloader.Buzzheavier: {
|
||||
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
|
||||
logger.log(
|
||||
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||
);
|
||||
try {
|
||||
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
||||
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
|
||||
return this.createDownloadPayload(
|
||||
directUrl,
|
||||
download.uri,
|
||||
downloadId,
|
||||
download.downloadPath
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
|
||||
logger.error(
|
||||
`[DownloadManager] Error processing Buzzheavier download:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.FuckingFast: {
|
||||
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
|
||||
logger.log(
|
||||
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||
);
|
||||
try {
|
||||
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
||||
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
|
||||
return this.createDownloadPayload(
|
||||
directUrl,
|
||||
download.uri,
|
||||
downloadId,
|
||||
download.downloadPath
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
|
||||
logger.error(
|
||||
`[DownloadManager] Error processing FuckingFast download:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -419,7 +471,9 @@ export class DownloadManager {
|
||||
};
|
||||
}
|
||||
case Downloader.Hydra: {
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||
download.uri
|
||||
);
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,9 @@ export class BuzzheavierApi {
|
||||
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = url.split("#")[0];
|
||||
logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`);
|
||||
logger.log(
|
||||
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
|
||||
);
|
||||
|
||||
await axios.get(baseUrl, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
@@ -46,7 +48,9 @@ export class BuzzheavierApi {
|
||||
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||
if (!hxRedirect) {
|
||||
logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`);
|
||||
logger.error(
|
||||
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
|
||||
);
|
||||
throw new Error(
|
||||
"Could not extract download link. File may be deleted or is a directory."
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
|
||||
private static secondsToMilliseconds(seconds: number) {
|
||||
return seconds * 1000;
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
export * from "./notifications/local-notifications";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
|
||||
import { SystemPath } from "../system-path";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||
import { LocalNotificationManager } from "./local-notifications";
|
||||
|
||||
const getStaticImage = async (path: string) => {
|
||||
return processProfileImage(path, "jpg")
|
||||
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
}
|
||||
);
|
||||
|
||||
const title = t("download_complete", { ns: "notifications" });
|
||||
const body = t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
});
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: await downloadImage(game.iconUrl),
|
||||
}).show();
|
||||
}
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"DOWNLOAD_COMPLETE",
|
||||
title,
|
||||
body,
|
||||
{
|
||||
pictureUrl: game.iconUrl,
|
||||
url: `/game/${game.shop}/${game.objectId}`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNotificationUpdateReadyToInstall = async (
|
||||
version: string
|
||||
) => {
|
||||
const title = t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
});
|
||||
const body = t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
});
|
||||
|
||||
new Notification({
|
||||
title: t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
}),
|
||||
body: t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: trayIcon,
|
||||
})
|
||||
.on("click", () => {
|
||||
restartAndInstallUpdate();
|
||||
})
|
||||
.show();
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"UPDATE_AVAILABLE",
|
||||
title,
|
||||
body
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNewFriendRequestNotification = async (
|
||||
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
|
||||
};
|
||||
|
||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||
const title = t("extraction_complete", { ns: "notifications" });
|
||||
const body = t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
});
|
||||
|
||||
new Notification({
|
||||
title: t("extraction_complete", { ns: "notifications" }),
|
||||
body: t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"EXTRACTION_COMPLETE",
|
||||
title,
|
||||
body,
|
||||
{
|
||||
url: `/game/${game.shop}/${game.objectId}`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNewAchievementNotification = async (info: {
|
||||
|
||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { localNotificationsSublevel } from "@main/level";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { LocalNotification, LocalNotificationType } from "@types";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export class LocalNotificationManager {
|
||||
private static generateId(): string {
|
||||
return crypto.randomBytes(8).toString("hex");
|
||||
}
|
||||
|
||||
static async createNotification(
|
||||
type: LocalNotificationType,
|
||||
title: string,
|
||||
description: string,
|
||||
options?: {
|
||||
pictureUrl?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
): Promise<LocalNotification> {
|
||||
const id = this.generateId();
|
||||
const notification: LocalNotification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
pictureUrl: options?.pictureUrl ?? null,
|
||||
url: options?.url ?? null,
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
|
||||
// Notify renderer about new notification
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-local-notification-created",
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
static async getNotifications(): Promise<LocalNotification[]> {
|
||||
const notifications: LocalNotification[] = [];
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
notifications.push(value);
|
||||
}
|
||||
|
||||
// Sort by createdAt descending
|
||||
return notifications.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
static async getUnreadCount(): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static async markAsRead(id: string): Promise<void> {
|
||||
const notification = await localNotificationsSublevel.get(id);
|
||||
if (notification) {
|
||||
notification.isRead = true;
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
}
|
||||
}
|
||||
|
||||
static async markAllAsRead(): Promise<void> {
|
||||
const batch = localNotificationsSublevel.batch();
|
||||
|
||||
for await (const [key, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
value.isRead = true;
|
||||
batch.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.write();
|
||||
}
|
||||
|
||||
static async deleteNotification(id: string): Promise<void> {
|
||||
await localNotificationsSublevel.del(id);
|
||||
}
|
||||
|
||||
static async clearAll(): Promise<void> {
|
||||
await localNotificationsSublevel.clear();
|
||||
}
|
||||
}
|
||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Notification } from "@main/generated/envelope";
|
||||
import { WindowManager } from "@main/services/window-manager";
|
||||
|
||||
export const notificationEvent = (payload: Notification) => {
|
||||
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
|
||||
notificationCount: payload.notificationCount,
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
|
||||
import { logger } from "../logger";
|
||||
import { friendRequestEvent } from "./events/friend-request";
|
||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||
import { notificationEvent } from "./events/notification";
|
||||
|
||||
export class WSClient {
|
||||
private static ws: WebSocket | null = null;
|
||||
@@ -51,6 +52,10 @@ export class WSClient {
|
||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||
}
|
||||
|
||||
if (envelope.payload.oneofKind === "notification") {
|
||||
notificationEvent(envelope.payload.notification);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||
|
||||
Reference in New Issue
Block a user