feat: migrating games to level

This commit is contained in:
Chubby Granny Chaser 2025-01-20 10:09:49 +00:00
parent 1f0e195854
commit d760d0139d
No known key found for this signature in database
47 changed files with 219 additions and 941 deletions

View File

@ -1,11 +1,11 @@
import { DataSource } from "typeorm";
import { DownloadQueue, Game, UserPreferences } from "@main/entity";
import { UserPreferences } from "@main/entity";
import { databasePath } from "./constants";
export const dataSource = new DataSource({
type: "better-sqlite3",
entities: [Game, UserPreferences, DownloadQueue],
entities: [UserPreferences],
synchronize: false,
database: databasePath,
});

View File

@ -1,25 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { Game } from "./game.entity";
@Entity("download_queue")
export class DownloadQueue {
@PrimaryGeneratedColumn()
id: number;
@OneToOne("Game", "downloadQueue")
@JoinColumn()
game: Game;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1,90 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
} from "typeorm";
import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
export class Game {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
objectID: string;
@Column("text", { unique: true, nullable: true })
remoteId: string | null;
@Column("text")
title: string;
@Column("text", { nullable: true })
iconUrl: string | null;
@Column("text", { nullable: true })
folderName: string | null;
@Column("text", { nullable: true })
downloadPath: string | null;
@Column("text", { nullable: true })
executablePath: string | null;
@Column("text", { nullable: true })
launchOptions: string | null;
@Column("text", { nullable: true })
winePrefixPath: string | null;
@Column("int", { default: 0 })
playTimeInMilliseconds: number;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
status: GameStatus | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;
/**
* Progress is a float between 0 and 1
*/
@Column("float", { default: 0 })
progress: number;
@Column("int", { default: 0 })
bytesDownloaded: number;
@Column("datetime", { nullable: true })
lastTimePlayed: Date | null;
@Column("float", { default: 0 })
fileSize: number;
@Column("text", { nullable: true })
uri: string | null;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@Column("boolean", { default: false })
isDeleted: boolean;
@Column("boolean", { default: false })
shouldSeed: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1,3 +1 @@
export * from "./game.entity";
export * from "./user-preferences.entity";
export * from "./download-queue.entity";

View File

@ -1,31 +1,25 @@
import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
import { PythonRPC } from "@main/services/python-rpc";
import { db, levelKeys } from "@main/level";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
const databaseOperations = dataSource
.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.getRepository(DownloadQueue).delete({});
await transactionalEntityManager.getRepository(Game).delete({});
await db.batch([
{
type: "del",
key: levelKeys.auth,
},
{
type: "del",
key: levelKeys.user,
},
]);
})
const databaseOperations = db
.batch([
{
type: "del",
key: levelKeys.auth,
},
{
type: "del",
key: levelKeys.user,
},
])
.then(() => {
/* Removes all games being played */
gamesPlaytime.clear();
return Promise.all([gamesSublevel.clear(), downloadsSublevel.clear()]);
});
/* Cancels any ongoing downloads */

View File

@ -1,44 +0,0 @@
import { Document as YMLDocument } from "yaml";
import { Game } from "@main/entity";
import path from "node:path";
export const generateYML = (game: Game) => {
const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
const doc = new YMLDocument({
name: game.title,
game_slug: slugifiedGameTitle,
slug: `${slugifiedGameTitle}-installer`,
version: "Installer",
runner: "wine",
script: {
game: {
prefix: "$GAMEDIR",
arch: "win64",
working_dir: "$GAMEDIR",
},
installer: [
{
task: {
name: "create_prefix",
arch: "win64",
prefix: "$GAMEDIR",
},
},
{
task: {
executable: path.join(
game.downloadPath!,
game.folderName!,
"setup.exe"
),
name: "wineexec",
prefix: "$GAMEDIR",
},
},
],
},
});
return doc.toString();
};

View File

@ -1,5 +1,3 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { Game, GameShop } from "@types";
@ -8,7 +6,7 @@ import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
import { gamesSublevel, levelKeys } from "@main/level";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -16,41 +14,42 @@ const addGameToLibrary = async (
objectId: string,
title: string
) => {
return gameRepository
.update(
{
objectID: objectId,
},
{
shop,
status: null,
isDeleted: false,
}
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
if (game) {
await downloadsSublevel.del(gameKey);
const game: Game = {
title,
iconUrl,
objectId,
shop,
};
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
}
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,
});
} else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
const game: Game = {
title,
iconUrl,
objectId,
shop,
remoteId: null,
isDeleted: false,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
};
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
}
};
registerEvent("addGameToLibrary", addGameToLibrary);

View File

@ -1,13 +1,23 @@
import { registerEvent } from "../register-event";
import { gamesSublevel } from "@main/level";
import { downloadsSublevel, gamesSublevel } from "@main/level";
const getLibrary = async () => {
// TODO: Add sorting
return gamesSublevel
.values()
.iterator()
.all()
.then((results) => {
return results.filter((game) => game.isDeleted === false);
return Promise.all(
results
.filter(([_key, game]) => game.isDeleted === false)
.map(async ([key, game]) => {
const download = await downloadsSublevel.get(key);
return {
...game,
download,
};
})
);
});
};

View File

@ -1,13 +1,11 @@
import { shell } from "electron";
import path from "node:path";
import fs from "node:fs";
import { writeFile } from "node:fs/promises";
import { spawnSync, exec } from "node:child_process";
import { generateYML } from "../helpers/generate-lutris-yaml";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const executeGameInstaller = (filePath: string) => {
@ -29,18 +27,18 @@ const openGameInstaller = async (
shop: GameShop,
objectId: string
) => {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!game || game.isDeleted || !game.folderName) return true;
if (!download || !download.folderName) return true;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName!
download.downloadPath ?? (await getDownloadsPath()),
download.folderName!
);
if (!fs.existsSync(gamePath)) {
// TODO: LEVELDB Remove download?
// await gameRepository.update({ id: gameId }, { status: null });
await downloadsSublevel.del(downloadKey);
return true;
}
@ -70,13 +68,6 @@ const openGameInstaller = async (
);
}
if (spawnSync("which", ["lutris"]).status === 0) {
const ymlPath = path.join(gamePath, "setup.yml");
await writeFile(ymlPath, generateYML(game));
exec(`lutris --install "${ymlPath}"`);
return true;
}
shell.openPath(gamePath);
return true;
};

View File

@ -1,22 +1,31 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { parseExecutablePath } from "../helpers/parse-executable-path";
import { levelKeys } from "@main/level";
import { gamesSublevel } from "@main/level";
import { GameShop } from "@types";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number,
shop: GameShop,
objectId: string,
executablePath: string,
launchOptions: string | null
) => {
// TODO: revisit this for launchOptions
const parsedPath = parseExecutablePath(executablePath);
await gameRepository.update(
{ id: gameId },
{ executablePath: parsedPath, launchOptions }
);
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
launchOptions,
});
shell.openPath(parsedPath);
};

View File

@ -1,31 +1,17 @@
import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
import { GameShop } from "@types";
import { downloadsSublevel, levelKeys } from "@main/level";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
shop: GameShop,
objectId: string
) => {
await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.cancelDownload(gameId);
await DownloadManager.cancelDownload(shop, objectId);
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager.getRepository(Game).update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
await downloadsSublevel.del(levelKeys.game(shop, objectId));
};
registerEvent("cancelGameDownload", cancelGameDownload);

View File

@ -1,17 +1,25 @@
import { downloadsSublevel } from "@main/level";
import { levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services";
import { gameRepository } from "@main/repository";
import type { GameShop } from "@types";
const pauseGameSeed = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
shop: GameShop,
objectId: string
) => {
await gameRepository.update(gameId, {
status: "complete",
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download) return;
await downloadsSublevel.put(downloadKey, {
...download,
shouldSeed: false,
});
await DownloadManager.pauseSeeding(gameId);
await DownloadManager.pauseSeeding(download);
};
registerEvent("pauseGameSeed", pauseGameSeed);

View File

@ -1,11 +1,9 @@
import { Not } from "typeorm";
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,

View File

@ -1,29 +1,24 @@
import { downloadsSublevel } from "@main/level";
import { levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services";
import { Downloader } from "@shared";
import type { GameShop } from "@types";
const resumeGameSeed = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
shop: GameShop,
objectId: string
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
isDeleted: false,
downloader: Downloader.Torrent,
progress: 1,
},
});
const download = await downloadsSublevel.get(levelKeys.game(shop, objectId));
if (!game) return;
if (!download) return;
await gameRepository.update(gameId, {
status: "seeding",
await downloadsSublevel.put(levelKeys.game(shop, objectId), {
...download,
shouldSeed: true,
});
await DownloadManager.resumeSeeding(game);
await DownloadManager.resumeSeeding(download);
};
registerEvent("resumeGameSeed", resumeGameSeed);

View File

@ -3,14 +3,11 @@ import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import fs from "node:fs";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
import { knexClient, migrationConfig } from "./knex-client";
import { databaseDirectory } from "./constants";
import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2";
@ -50,21 +47,6 @@ if (process.defaultApp) {
app.setAsDefaultProtocolClient(PROTOCOL);
}
const runMigrations = async () => {
if (!fs.existsSync(databaseDirectory)) {
fs.mkdirSync(databaseDirectory, { recursive: true });
}
await knexClient.migrate.list(migrationConfig).then((result) => {
logger.log(
"Migrations to run:",
result[1].map((migration) => migration.name)
);
});
await knexClient.migrate.latest(migrationConfig);
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@ -76,14 +58,6 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
await runMigrations()
.then(() => {
logger.log("Migrations executed successfully");
})
.catch((err) => {
logger.log("Migrations failed to run:", err);
});
await dataSource.initialize();
await import("./main");

View File

@ -1,53 +1,6 @@
import knex, { Knex } from "knex";
import knex from "knex";
import { databasePath } from "./constants";
import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3";
import { RepackUris } from "./migrations/20240830143906_RepackUris";
import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language";
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
import { app } from "electron";
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
import { AddAchievementNotificationPreference } from "./migrations/20241013012900_add_achievement_notification_preference";
import { CreateUserSubscription } from "./migrations/20241015235142_create_user_subscription";
import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url";
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum";
import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download";
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
import { AddLaunchOptionsColumnToGame } from "./migrations/20241226044022_add_launch_options_column_to_game";
export type HydraMigration = Knex.Migration & { name: string };
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
getMigrations(): Promise<HydraMigration[]> {
return Promise.resolve([
Hydra2_0_3,
RepackUris,
UpdateUserLanguage,
EnsureRepackUris,
FixMissingColumns,
CreateGameAchievement,
AddAchievementNotificationPreference,
CreateUserSubscription,
AddBackgroundImageUrl,
AddWinePrefixToGame,
AddStartMinimizedColumn,
AddDisableNsfwAlertColumn,
AddShouldSeedColumn,
AddSeedAfterDownloadColumn,
AddHiddenAchievementDescriptionColumn,
AddLaunchOptionsColumnToGame,
]);
}
getMigrationName(migration: HydraMigration): string {
return migration.name;
}
getMigration(migration: HydraMigration): Promise<Knex.Migration> {
return Promise.resolve(migration);
}
}
export const knexClient = knex({
debug: !app.isPackaged,
@ -56,7 +9,3 @@ export const knexClient = knex({
filename: databasePath,
},
});
export const migrationConfig: Knex.MigratorConfig = {
migrationSource: new MigrationSource(),
};

View File

@ -0,0 +1,11 @@
import type { Download } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const downloadsSublevel = db.sublevel<string, Download>(
levelKeys.downloads,
{
valueEncoding: "json",
}
);

View File

@ -1,171 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const Hydra2_0_3: HydraMigration = {
name: "Hydra_2_0_3",
up: async (knex: Knex) => {
const timestamp = new Date().getTime();
await knex.schema.hasTable("migrations").then(async (exists) => {
if (exists) {
await knex.schema.dropTable("migrations");
}
});
await knex.schema.hasTable("download_source").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("download_source", (table) => {
table.increments("id").primary();
table
.text("url")
.unique({ indexName: "download_source_url_unique_" + timestamp });
table.text("name").notNullable();
table.text("etag");
table.integer("downloadCount").notNullable().defaultTo(0);
table.text("status").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("repack").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("repack", (table) => {
table.increments("id").primary();
table
.text("title")
.notNullable()
.unique({ indexName: "repack_title_unique_" + timestamp });
table
.text("magnet")
.notNullable()
.unique({ indexName: "repack_magnet_unique_" + timestamp });
table.integer("page");
table.text("repacker").notNullable();
table.text("fileSize").notNullable();
table.datetime("uploadDate").notNullable();
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
table
.integer("downloadSourceId")
.references("download_source.id")
.onDelete("CASCADE");
});
}
});
await knex.schema.hasTable("game").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("game", (table) => {
table.increments("id").primary();
table
.text("objectID")
.notNullable()
.unique({ indexName: "game_objectID_unique_" + timestamp });
table
.text("remoteId")
.unique({ indexName: "game_remoteId_unique_" + timestamp });
table.text("title").notNullable();
table.text("iconUrl");
table.text("folderName");
table.text("downloadPath");
table.text("executablePath");
table.integer("playTimeInMilliseconds").notNullable().defaultTo(0);
table.text("shop").notNullable();
table.text("status");
table.integer("downloader").notNullable().defaultTo(1);
table.float("progress").notNullable().defaultTo(0);
table.integer("bytesDownloaded").notNullable().defaultTo(0);
table.datetime("lastTimePlayed");
table.float("fileSize").notNullable().defaultTo(0);
table.text("uri");
table.boolean("isDeleted").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
table
.integer("repackId")
.references("repack.id")
.unique("repack_repackId_unique_" + timestamp);
});
}
});
await knex.schema.hasTable("user_preferences").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("user_preferences", (table) => {
table.increments("id").primary();
table.text("downloadsPath");
table.text("language").notNullable().defaultTo("en");
table.text("realDebridApiToken");
table
.boolean("downloadNotificationsEnabled")
.notNullable()
.defaultTo(0);
table
.boolean("repackUpdatesNotificationsEnabled")
.notNullable()
.defaultTo(0);
table.boolean("preferQuitInsteadOfHiding").notNullable().defaultTo(0);
table.boolean("runAtStartup").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("game_shop_cache").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("game_shop_cache", (table) => {
table.text("objectID").primary().notNullable();
table.text("shop").notNullable();
table.text("serializedData");
table.text("howLongToBeatSerializedData");
table.text("language");
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("download_queue").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("download_queue", (table) => {
table.increments("id").primary();
table
.integer("gameId")
.references("game.id")
.unique("download_queue_gameId_unique_" + timestamp);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("user_auth").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("user_auth", (table) => {
table.increments("id").primary();
table.text("userId").notNullable().defaultTo("");
table.text("displayName").notNullable().defaultTo("");
table.text("profileImageUrl");
table.text("accessToken").notNullable().defaultTo("");
table.text("refreshToken").notNullable().defaultTo("");
table.integer("tokenExpirationTimestamp").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
},
down: async (knex: Knex) => {
await knex.schema.dropTableIfExists("game");
await knex.schema.dropTableIfExists("repack");
await knex.schema.dropTableIfExists("download_queue");
await knex.schema.dropTableIfExists("user_auth");
await knex.schema.dropTableIfExists("game_shop_cache");
await knex.schema.dropTableIfExists("user_preferences");
await knex.schema.dropTableIfExists("download_source");
},
};

View File

@ -1,18 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const RepackUris: HydraMigration = {
name: "RepackUris",
up: async (knex: Knex) => {
await knex.schema.alterTable("repack", (table) => {
table.text("uris").notNullable().defaultTo("[]");
});
},
down: async (knex: Knex) => {
await knex.schema.alterTable("repack", (table) => {
table.integer("page");
table.dropColumn("uris");
});
},
};

View File

@ -1,13 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const UpdateUserLanguage: HydraMigration = {
name: "UpdateUserLanguage",
up: async (knex: Knex) => {
await knex("user_preferences")
.update("language", "pt-BR")
.where("language", "pt");
},
down: async (_knex: Knex) => {},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const EnsureRepackUris: HydraMigration = {
name: "EnsureRepackUris",
up: async (knex: Knex) => {
await knex.schema.hasColumn("repack", "uris").then(async (exists) => {
if (!exists) {
await knex.schema.table("repack", (table) => {
table.text("uris").notNullable().defaultTo("[]");
});
}
});
},
down: async (_knex: Knex) => {},
};

View File

@ -1,41 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const FixMissingColumns: HydraMigration = {
name: "FixMissingColumns",
up: async (knex: Knex) => {
const timestamp = new Date().getTime();
await knex.schema
.hasColumn("repack", "downloadSourceId")
.then(async (exists) => {
if (!exists) {
await knex.schema.table("repack", (table) => {
table
.integer("downloadSourceId")
.references("download_source.id")
.onDelete("CASCADE");
});
}
});
await knex.schema.hasColumn("game", "remoteId").then(async (exists) => {
if (!exists) {
await knex.schema.table("game", (table) => {
table
.text("remoteId")
.unique({ indexName: "game_remoteId_unique_" + timestamp });
});
}
});
await knex.schema.hasColumn("game", "uri").then(async (exists) => {
if (!exists) {
await knex.schema.table("game", (table) => {
table.text("uri");
});
}
});
},
down: async (_knex: Knex) => {},
};

View File

@ -1,20 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const CreateGameAchievement: HydraMigration = {
name: "CreateGameAchievement",
up: (knex: Knex) => {
return knex.schema.createTable("game_achievement", (table) => {
table.increments("id").primary();
table.text("objectId").notNullable();
table.text("shop").notNullable();
table.text("achievements");
table.text("unlockedAchievements");
table.unique(["objectId", "shop"]);
});
},
down: (knex: Knex) => {
return knex.schema.dropTable("game_achievement");
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddAchievementNotificationPreference: HydraMigration = {
name: "AddAchievementNotificationPreference",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("achievementNotificationsEnabled").defaultTo(true);
});
},
down: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("achievementNotificationsEnabled");
});
},
};

View File

@ -1,27 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const CreateUserSubscription: HydraMigration = {
name: "CreateUserSubscription",
up: async (knex: Knex) => {
return knex.schema.createTable("user_subscription", (table) => {
table.increments("id").primary();
table.string("subscriptionId").defaultTo("");
table
.text("userId")
.notNullable()
.references("user_auth.id")
.onDelete("CASCADE");
table.string("status").defaultTo("");
table.string("planId").defaultTo("");
table.string("planName").defaultTo("");
table.dateTime("expiresAt").nullable();
table.dateTime("createdAt").defaultTo(knex.fn.now());
table.dateTime("updatedAt").defaultTo(knex.fn.now());
});
},
down: async (knex: Knex) => {
return knex.schema.dropTable("user_subscription");
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddBackgroundImageUrl: HydraMigration = {
name: "AddBackgroundImageUrl",
up: (knex: Knex) => {
return knex.schema.alterTable("user_auth", (table) => {
return table.text("backgroundImageUrl").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_auth", (table) => {
return table.dropColumn("backgroundImageUrl");
});
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddWinePrefixToGame: HydraMigration = {
name: "AddWinePrefixToGame",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.text("winePrefixPath").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("winePrefixPath");
});
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddStartMinimizedColumn: HydraMigration = {
name: "AddStartMinimizedColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("startMinimized").notNullable().defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("startMinimized");
});
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddDisableNsfwAlertColumn: HydraMigration = {
name: "AddDisableNsfwAlertColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("disableNsfwAlert").notNullable().defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("disableNsfwAlert");
});
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddShouldSeedColumn: HydraMigration = {
name: "AddShouldSeedColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.boolean("shouldSeed").notNullable().defaultTo(true);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("shouldSeed");
});
},
};

View File

@ -1,20 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddSeedAfterDownloadColumn: HydraMigration = {
name: "AddSeedAfterDownloadColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table
.boolean("seedAfterDownloadComplete")
.notNullable()
.defaultTo(true);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("seedAfterDownloadComplete");
});
},
};

View File

@ -1,20 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddHiddenAchievementDescriptionColumn: HydraMigration = {
name: "AddHiddenAchievementDescriptionColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table
.boolean("showHiddenAchievementsDescription")
.notNullable()
.defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("showHiddenAchievementsDescription");
});
},
};

View File

@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddLaunchOptionsColumnToGame: HydraMigration = {
name: "AddLaunchOptionsColumnToGame",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.string("launchOptions").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("launchOptions");
});
},
};

View File

@ -1,11 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const MigrationName: HydraMigration = {
name: "MigrationName",
up: (knex: Knex) => {
return knex.schema.createTable("table_name", async (table) => {});
},
down: async (knex: Knex) => {},
};

View File

@ -1,9 +1,5 @@
import { dataSource } from "./data-source";
import { DownloadQueue, Game, UserPreferences } from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
import { UserPreferences } from "@main/entity";
export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);

View File

@ -1,6 +1,4 @@
import { gameRepository } from "@main/repository";
import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements";
import fs, { readdirSync } from "node:fs";
import {
@ -9,10 +7,9 @@ import {
findAllAchievementFiles,
getAlternativeObjectIds,
} from "./find-achivement-files";
import type { AchievementFile, UnlockedAchievement } from "@types";
import type { AchievementFile, Game, UnlockedAchievement } from "@types";
import { achievementsLogger } from "../logger";
import { Cracker } from "@shared";
import { IsNull, Not } from "typeorm";
import { publishCombinedNewAchievementNotification } from "../notifications";
import { gamesSublevel } from "@main/level";
@ -47,12 +44,12 @@ const watchAchievementsWindows = async () => {
};
const watchAchievementsWithWine = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
winePrefixPath: Not(IsNull()),
},
});
const games = await gamesSublevel
.values()
.all()
.then((games) =>
games.filter((game) => !game.isDeleted && game.winePrefixPath)
);
for (const game of games) {
const gameAchievementFiles = findAchievementFiles(game);
@ -188,11 +185,10 @@ export class AchievementWatcherManager {
};
private static preSearchAchievementsWindows = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
const games = await gamesSublevel
.values()
.all()
.then((games) => games.filter((game) => !game.isDeleted));
const gameAchievementFilesMap = findAllAchievementFiles();
@ -200,7 +196,7 @@ export class AchievementWatcherManager {
games.map((game) => {
const gameAchievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectID)) {
for (const objectId of getAlternativeObjectIds(game.objectId)) {
gameAchievementFiles.push(
...(gameAchievementFilesMap.get(objectId) || [])
);
@ -216,11 +212,10 @@ export class AchievementWatcherManager {
};
private static preSearchAchievementsWithWine = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
const games = await gamesSublevel
.values()
.all()
.then((games) => games.filter((game) => !game.isDeleted));
return Promise.all(
games.map((game) => {

View File

@ -254,7 +254,7 @@ export const findAchievementFiles = (game: Game) => {
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
for (const objectId of getAlternativeObjectIds(game.objectId)) {
const filePath = path.join(
game.winePrefixPath ?? "",
folderPath,

View File

@ -4,8 +4,7 @@ import {
} from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { UnlockedAchievement } from "@types";
import { Game } from "@main/entity";
import type { Game, UnlockedAchievement } from "@types";
export const updateLocalUnlockedAchivements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);

View File

@ -1,11 +1,6 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared";
import { WindowManager } from "../window-manager";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { userPreferencesRepository } from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress } from "@types";
import { GofileApi, QiwiApi, DatanodesApi } from "../hosters";
@ -23,7 +18,7 @@ import { logger } from "../logger";
import { downloadsSublevel, levelKeys } from "@main/level";
export class DownloadManager {
private static downloadingGameId: number | null = null;
private static downloadingGameId: string | null = null;
public static async startRPC(
download?: Download,
@ -34,13 +29,15 @@ export class DownloadManager {
? await this.getDownloadPayload(download).catch(() => undefined)
: undefined,
downloadsToSeed?.map((download) => ({
game_id: game.id,
url: game.uri!,
save_path: game.downloadPath!,
game_id: `${download.shop}-${download.objectId}`,
url: download.uri!,
save_path: download.downloadPath!,
}))
);
this.downloadingGameId = game?.id ?? null;
if (download) {
this.downloadingGameId = `${download.shop}-${download.objectId}`;
}
}
private static async getDownloadStatus() {

View File

@ -1,9 +1,9 @@
export interface PauseDownloadPayload {
game_id: number;
game_id: string;
}
export interface CancelDownloadPayload {
game_id: number;
game_id: string;
}
export enum LibtorrentStatus {

View File

@ -1,17 +1,15 @@
import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers";
import { steamUrlBuilder } from "@shared";
import { gamesSublevel, levelKeys } from "@main/level";
export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games")
.then(async (response) => {
for (const game of response) {
const localGame = await gameRepository.findOne({
where: {
objectID: game.objectId,
},
});
const localGame = await gamesSublevel.get(
levelKeys.game(game.shop, game.objectId)
);
if (localGame) {
const updatedLastTimePlayed =
@ -26,17 +24,12 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gameRepository.update(
{
objectID: game.objectId,
shop: "steam",
},
{
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
}
);
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...localGame,
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
});
} else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
@ -47,14 +40,15 @@ export const mergeWithRemoteGames = async () => {
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
gameRepository.insert({
objectID: game.objectId,
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false,
});
}
}

View File

@ -1,7 +1,6 @@
import { Notification, app } from "electron";
import { t } from "i18next";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { userPreferencesRepository } from "@main/repository";
import fs from "node:fs";
import axios from "axios";
@ -11,6 +10,7 @@ import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import type { Game } from "@types";
async function downloadImage(url: string | null) {
if (!url) return undefined;

View File

@ -10,7 +10,7 @@ import { Readable } from "node:stream";
import { app, dialog } from "electron";
interface GamePayload {
game_id: number;
game_id: string;
url: string;
save_path: string;
}

View File

@ -13,10 +13,11 @@ import i18next, { t } from "i18next";
import path from "node:path";
import icon from "@resources/icon.png?asset";
import trayIcon from "@resources/tray-icon.png?asset";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { IsNull, Not } from "typeorm";
import { userPreferencesRepository } from "@main/repository";
import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents";
import { gamesSublevel } from "@main/level";
import { slice, sortBy } from "lodash-es";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
@ -207,17 +208,22 @@ export class WindowManager {
}
const updateSystemTray = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
executablePath: Not(IsNull()),
lastTimePlayed: Not(IsNull()),
},
take: 5,
order: {
lastTimePlayed: "DESC",
},
});
const games = await gamesSublevel
.values()
.all()
.then((games) =>
slice(
sortBy(
games.filter(
(game) =>
!game.isDeleted && game.executablePath && game.lastTimePlayed
),
"lastTimePlayed",
"DESC"
),
5
)
);
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
games.map(({ title, executablePath }) => ({

View File

@ -101,9 +101,9 @@ export function GameDetailsContextProvider({
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectId(objectId!)
.getGameByObjectId(shop, objectId!)
.then((result) => setGame(result));
}, [setGame, objectId]);
}, [setGame, shop, objectId]);
const isGameDownloading = lastPacket?.game.id === game?.id;

View File

@ -9,7 +9,11 @@ import {
setGameDeleting,
removeGameFromDeleting,
} from "@renderer/features";
import type { DownloadProgress, StartGameDownloadPayload } from "@types";
import type {
DownloadProgress,
GameShop,
StartGameDownloadPayload,
} from "@types";
import { useDate } from "./use-date";
import { formatBytes } from "@shared";
@ -31,48 +35,48 @@ export function useDownload() {
return game;
};
const pauseDownload = async (gameId: number) => {
await window.electron.pauseGameDownload(gameId);
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
dispatch(clearDownload());
};
const resumeDownload = async (gameId: number) => {
await window.electron.resumeGameDownload(gameId);
const resumeDownload = async (shop: GameShop, objectId: string) => {
await window.electron.resumeGameDownload(shop, objectId);
return updateLibrary();
};
const removeGameInstaller = async (gameId: number) => {
dispatch(setGameDeleting(gameId));
const removeGameInstaller = async (shop: GameShop, objectId: string) => {
dispatch(setGameDeleting(objectId));
try {
await window.electron.deleteGameFolder(gameId);
await window.electron.deleteGameFolder(shop, objectId);
updateLibrary();
} finally {
dispatch(removeGameFromDeleting(gameId));
dispatch(removeGameFromDeleting(objectId));
}
};
const cancelDownload = async (gameId: number) => {
await window.electron.cancelGameDownload(gameId);
const cancelDownload = async (shop: GameShop, objectId: string) => {
await window.electron.cancelGameDownload(shop, objectId);
dispatch(clearDownload());
updateLibrary();
removeGameInstaller(gameId);
removeGameInstaller(shop, objectId);
};
const removeGameFromLibrary = (gameId: number) =>
window.electron.removeGameFromLibrary(gameId).then(() => {
const removeGameFromLibrary = (shop: GameShop, objectId: string) =>
window.electron.removeGameFromLibrary(shop, objectId).then(() => {
updateLibrary();
});
const pauseSeeding = async (gameId: number) => {
await window.electron.pauseGameSeed(gameId);
const pauseSeeding = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameSeed(shop, objectId);
await updateLibrary();
};
const resumeSeeding = async (gameId: number) => {
await window.electron.resumeGameSeed(gameId);
const resumeSeeding = async (shop: GameShop, objectId: string) => {
await window.electron.resumeGameSeed(shop, objectId);
await updateLibrary();
};
@ -90,8 +94,8 @@ export function useDownload() {
}
};
const isGameDeleting = (gameId: number) => {
return gamesWithDeletionInProgress.includes(gameId);
const isGameDeleting = (objectId: string) => {
return gamesWithDeletionInProgress.includes(objectId);
};
return {

View File

@ -33,7 +33,6 @@ export interface User {
export interface Game {
title: string;
iconUrl: string | null;
status: GameStatus | null;
playTimeInMilliseconds: number;
lastTimePlayed: Date | null;
objectId: string;
@ -58,6 +57,8 @@ export interface Download {
lastTimePlayed: Date | null;
fileSize: number;
shouldSeed: boolean;
// TODO: Rename to DownloadStatus
status: GameStatus | null;
timestamp: number;
}