diff --git a/client/__tests__/GameDetailsModal.test.tsx b/client/__tests__/GameDetailsModal.test.tsx
index 8dd6697e5..3f2442ed9 100644
--- a/client/__tests__/GameDetailsModal.test.tsx
+++ b/client/__tests__/GameDetailsModal.test.tsx
@@ -489,6 +489,33 @@ describe("GameDetailsModal", () => {
expect(screen.getByText("Show less")).toBeInTheDocument();
});
+ it("suggests installed versions only from downloads that completed", async () => {
+ const download = (id: string, downloadTitle: string, status: string) => ({
+ id,
+ gameId: "1",
+ downloadTitle,
+ status,
+ category: "main",
+ });
+ (global.fetch as ReturnType).mockImplementation(
+ makeFetchMock({
+ "/api/games/1/downloads": [
+ download("a", "Test.Game.v1.1-RUNE", "imported"),
+ download("b", "Test.Game.v1.2-RUNE", "completed"),
+ download("c", "Test.Game.v1.3-RUNE", "failed"),
+ download("d", "Test.Game.v1.4-RUNE", "downloading"),
+ ],
+ })
+ );
+
+ renderComponent();
+
+ expect(await screen.findByTestId("button-version-suggestion-v1.2")).toBeInTheDocument();
+ expect(screen.getByTestId("button-version-suggestion-v1.1")).toBeInTheDocument();
+ expect(screen.queryByTestId("button-version-suggestion-v1.3")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("button-version-suggestion-v1.4")).not.toBeInTheDocument();
+ });
+
it("renders the Your rating section", () => {
renderComponent();
// Links tab is forceMount-ed; always in DOM
diff --git a/client/__tests__/InstalledVersionField.test.tsx b/client/__tests__/InstalledVersionField.test.tsx
new file mode 100644
index 000000000..f94ed4a9d
--- /dev/null
+++ b/client/__tests__/InstalledVersionField.test.tsx
@@ -0,0 +1,208 @@
+/** @vitest-environment jsdom */
+import React from "react";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createTestQueryClient } from "./test-utils";
+import InstalledVersionField, { getVersionSuggestions } from "@/components/InstalledVersionField";
+
+const mockToast = vi.fn();
+vi.mock("@/hooks/use-toast", () => ({
+ useToast: () => ({ toast: mockToast }),
+}));
+
+const apiRequest = vi.fn();
+vi.mock("@/lib/queryClient", () => ({
+ apiRequest: (...args: unknown[]) => apiRequest(...args),
+}));
+
+const gameId = "game-1";
+
+function renderField(installedVersion: string | null, releaseNames: string[] = []) {
+ render(
+
+
+
+ );
+ return screen.getByLabelText("Installed version");
+}
+
+describe("getVersionSuggestions", () => {
+ it("lists distinct versions newest first, without the current one", () => {
+ expect(
+ getVersionSuggestions(
+ [
+ "Game.v1.2-RUNE",
+ "Game.Update.v1.10-RUNE",
+ "Game.v1.2-GOG",
+ "Game.Update.v1.3-RUNE",
+ "Game-NoVersion",
+ ],
+ "v1.3"
+ )
+ ).toEqual(["v1.10", "v1.2"]);
+ });
+});
+
+describe("InstalledVersionField", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ apiRequest.mockResolvedValue({ ok: true, json: async () => ({}) });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("shows the stored version", () => {
+ expect(renderField("v1.2.3")).toHaveValue("v1.2.3");
+ });
+
+ it("saves the typed version on Enter", async () => {
+ const input = renderField(null);
+ fireEvent.change(input, { target: { value: " v2.0 " } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() =>
+ expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: "v2.0",
+ })
+ );
+ });
+
+ it("does not save on an Enter that confirms an IME composition", () => {
+ const input = renderField(null);
+ fireEvent.change(input, { target: { value: "v2" } });
+ fireEvent.keyDown(input, { key: "Enter", isComposing: true });
+ expect(apiRequest).not.toHaveBeenCalled();
+ });
+
+ it("clears the version when emptied and blurred", async () => {
+ const input = renderField("v1.0");
+ fireEvent.change(input, { target: { value: "" } });
+ fireEvent.blur(input);
+ await waitFor(() =>
+ expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: null,
+ })
+ );
+ });
+
+ it("follows a new server value but keeps a draft being edited", () => {
+ const client = createTestQueryClient();
+ const view = (installedVersion: string | null, id = gameId) => (
+
+
+
+ );
+ const { rerender } = render(view("v1.0"));
+ const input = screen.getByLabelText("Installed version");
+
+ rerender(view("v1.1"));
+ expect(input).toHaveValue("v1.1");
+
+ fireEvent.change(input, { target: { value: "v2" } });
+ rerender(view("v1.2"));
+ expect(input).toHaveValue("v2");
+
+ rerender(view("v3.0", "game-2"));
+ expect(input).toHaveValue("v3.0");
+ });
+
+ it("does not save when the value did not change", () => {
+ const input = renderField("v1.0");
+ fireEvent.blur(input);
+ expect(apiRequest).not.toHaveBeenCalled();
+ });
+
+ it("applies a suggestion from the game's downloads", async () => {
+ renderField(null, ["Game.Update.v1.4-RUNE"]);
+ fireEvent.click(screen.getByRole("button", { name: "Set installed version to v1.4" }));
+ await waitFor(() =>
+ expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: "v1.4",
+ })
+ );
+ });
+
+ it("keeps focus in the input when a suggestion is pressed", () => {
+ renderField(null, ["Game.Update.v1.4-RUNE"]);
+ const button = screen.getByRole("button", { name: "Set installed version to v1.4" });
+ // A cancelled mousedown means the input never blurs (and never saves its draft) first.
+ expect(fireEvent.mouseDown(button)).toBe(false);
+ });
+
+ it("sends a value typed during a save only after that save settles", async () => {
+ let finishFirst: (value: unknown) => void = () => {};
+ apiRequest.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
+ const input = renderField(null);
+ fireEvent.change(input, { target: { value: "v2" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
+ fireEvent.change(input, { target: { value: "v3" } });
+ fireEvent.blur(input);
+ expect(apiRequest).toHaveBeenCalledTimes(1);
+
+ finishFirst({ ok: true, json: async () => ({}) });
+ await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
+ expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: "v3",
+ });
+ });
+
+ it("undoes an in-flight save when the stored value is typed back", async () => {
+ let finishFirst: (value: unknown) => void = () => {};
+ apiRequest.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
+ const input = renderField("v1");
+ fireEvent.change(input, { target: { value: "v2" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
+ fireEvent.change(input, { target: { value: "v1" } });
+ fireEvent.blur(input);
+
+ finishFirst({ ok: true, json: async () => ({}) });
+ await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
+ expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: "v1",
+ });
+ });
+
+ it("still saves the stored value typed back while the refetch is pending", async () => {
+ const client = createTestQueryClient();
+ let finishRefetch: () => void = () => {};
+ vi.spyOn(client, "invalidateQueries").mockReturnValueOnce(
+ new Promise((resolve) => (finishRefetch = resolve))
+ );
+ render(
+
+
+
+ );
+ const input = screen.getByLabelText("Installed version");
+ fireEvent.change(input, { target: { value: "v2" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() => expect(client.invalidateQueries).toHaveBeenCalled());
+ fireEvent.change(input, { target: { value: "v1" } });
+ fireEvent.blur(input);
+
+ finishRefetch();
+ await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
+ expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: "v1",
+ });
+ });
+
+ it("restores the stored value and warns when saving fails", async () => {
+ apiRequest.mockRejectedValue(new Error("boom"));
+ const input = renderField("v1.0");
+ fireEvent.change(input, { target: { value: "v9" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() => expect(mockToast).toHaveBeenCalled());
+ expect(input).toHaveValue("v1.0");
+ });
+});
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 35a601971..e986392ff 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -9,6 +9,7 @@ import Header from "@/components/Header";
import MobileBottomNav from "@/components/MobileBottomNav";
import { getPageTitle } from "@/components/navigation-items";
import { useBackgroundNotifications } from "@/hooks/use-background-notifications";
+import { useGameUpdatedSocket } from "@/hooks/use-game-updated-socket";
import { AuthProvider } from "@/lib/auth";
import { Suspense, lazy, useEffect, useLayoutEffect, useRef } from "react";
import LoadingFallback from "@/components/LoadingFallback";
@@ -145,6 +146,7 @@ function Router() {
function AppContent() {
// Enable background notifications for downloads
useBackgroundNotifications();
+ useGameUpdatedSocket();
return ;
}
diff --git a/client/src/components/GameDetailsModal.tsx b/client/src/components/GameDetailsModal.tsx
index 9cdb1ea04..39c44795a 100644
--- a/client/src/components/GameDetailsModal.tsx
+++ b/client/src/components/GameDetailsModal.tsx
@@ -87,6 +87,7 @@ import {
import { NexusModsIcon } from "./NexusModsIcon";
import GameJournalTab from "./GameJournalTab";
import FileCategorySelect from "./FileCategorySelect";
+import InstalledVersionField from "./InstalledVersionField";
import { getSocket } from "@/lib/socket";
import { useToast } from "@/hooks/use-toast";
import { useHiddenMutation } from "@/hooks/use-hidden-mutation";
@@ -97,6 +98,7 @@ import { type XrelGameStatus } from "@shared/xrel-types";
import StatusBadge, { getStatusLabel } from "./StatusBadge";
import { apiRequest } from "@/lib/queryClient";
import { cn, safeUrl, formatBytes, isDiscoveryId } from "@/lib/utils";
+import { carriesBaseGameVersion } from "@shared/version-utils";
const GameDownloadDialog = lazy(() => import("./GameDownloadDialog"));
@@ -635,6 +637,19 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
refetchInterval: 5000,
});
+ const downloadReleaseNames = useMemo(
+ () =>
+ gameDownloads
+ // Only downloads that really landed: a failed or pending one was never installed.
+ .filter(
+ (download) =>
+ (download.status === "completed" || download.status === "imported") &&
+ carriesBaseGameVersion(download.downloadTitle, download.category)
+ )
+ .map((download) => download.downloadTitle),
+ [gameDownloads]
+ );
+
const {
data: xrelStatus,
isLoading: xrelStatusLoading,
@@ -1241,6 +1256,14 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
)}
+ {!isDiscoveryId(game.id) && (
+
+ )}
diff --git a/client/src/components/GameDownloadDialog.tsx b/client/src/components/GameDownloadDialog.tsx
index abce05438..e33e6f7a1 100644
--- a/client/src/components/GameDownloadDialog.tsx
+++ b/client/src/components/GameDownloadDialog.tsx
@@ -80,6 +80,7 @@ import {
groupDownloadsByCategory,
type DownloadCategory,
} from "@shared/download-categorizer";
+import { inferReleaseCategory } from "@shared/version-utils";
import type { ReleaseType } from "@shared/typesafe-types";
import {
parseReleaseMetadata,
@@ -543,6 +544,11 @@ export default function GameDownloadDialog({ game, open, onOpenChange }: GameDow
title: download.title,
gameId: game?.id,
downloadType: isUsenetItem(download) ? "usenet" : "torrent",
+ releaseCategory: inferReleaseCategory(
+ download.title,
+ download.aiReleaseType,
+ download.aiReleaseTypeConfidence
+ ),
});
results.push(await response.json());
}
diff --git a/client/src/components/InstalledVersionField.tsx b/client/src/components/InstalledVersionField.tsx
new file mode 100644
index 000000000..b7296e669
--- /dev/null
+++ b/client/src/components/InstalledVersionField.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Check, Loader2, Tag } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { apiRequest } from "@/lib/queryClient";
+import { compareVersions, extractVersionFromReleaseName } from "@shared/version-utils";
+
+interface InstalledVersionFieldProps {
+ readonly gameId: string;
+ readonly installedVersion: string | null;
+ /** Release names of the game's downloads, mined for version suggestions. */
+ readonly releaseNames: readonly string[];
+}
+
+const MAX_SUGGESTIONS = 6;
+
+/** Distinct versions found in release names, newest first when they can be compared. */
+export function getVersionSuggestions(
+ releaseNames: readonly string[],
+ current: string | null
+): string[] {
+ const seen = new Map();
+ for (const name of releaseNames) {
+ const version = extractVersionFromReleaseName(name);
+ if (version && !seen.has(version.toLowerCase())) seen.set(version.toLowerCase(), version);
+ }
+ const currentKey = current?.trim().toLowerCase();
+ return (
+ Array.from(seen.values())
+ .filter((version) => version.toLowerCase() !== currentKey)
+ // Newest first: compare the right-hand version against the left-hand one.
+ .sort((left, right) => compareVersions(right, left) ?? 0)
+ .slice(0, MAX_SUGGESTIONS)
+ );
+}
+
+export default function InstalledVersionField({
+ gameId,
+ installedVersion,
+ releaseNames,
+}: InstalledVersionFieldProps) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const [draft, setDraft] = useState(installedVersion ?? "");
+ // A value entered while a save is in flight; sent once that save settles, so saves never
+ // race and the latest value always lands last.
+ const queued = useRef<{ value: string | null } | null>(null);
+
+ // The server value the draft last followed, and for which game.
+ const synced = useRef({ gameId, value: installedVersion ?? "" });
+
+ // Follow the server value (another tab, a finished download detecting a newer version),
+ // unless the user has edited the draft since: a refetch must not wipe what they are typing.
+ useEffect(() => {
+ const next = installedVersion ?? "";
+ const previous = synced.current;
+ synced.current = { gameId, value: next };
+ setDraft((draft) => (previous.gameId !== gameId || draft === previous.value ? next : draft));
+ }, [gameId, installedVersion]);
+
+ const mutation = useMutation({
+ mutationFn: async (value) => {
+ await apiRequest("PATCH", `/api/games/${gameId}/installed-version`, {
+ installedVersion: value,
+ });
+ },
+ // Stays pending until the games refetch lands: until then `installedVersion` is stale, and
+ // typing the old value back must still count as a change.
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["/api/games"] }),
+ onError: () => {
+ if (queued.current) return; // a newer value is about to be sent
+ setDraft(installedVersion ?? "");
+ toast({ description: "Failed to save the installed version", variant: "destructive" });
+ },
+ onSettled: (_data, _error, sent) => {
+ const next = queued.current;
+ queued.current = null;
+ if (next && next.value !== sent) mutation.mutate(next.value);
+ },
+ });
+
+ const suggestions = useMemo(
+ () => getVersionSuggestions(releaseNames, draft),
+ [releaseNames, draft]
+ );
+
+ const save = (value: string) => {
+ const next = value.trim() || null;
+ // Checked before comparing with the stored value, which is stale while a save is in
+ // flight: going back to it then still needs a save to undo the in-flight one.
+ if (mutation.isPending) {
+ // Enter then blur would otherwise send the same value twice before the refetch lands.
+ queued.current = mutation.variables === next ? null : { value: next };
+ return;
+ }
+ if (next === (installedVersion ?? null)) return;
+ mutation.mutate(next);
+ };
+
+ const showSaved =
+ !mutation.isPending && mutation.isSuccess && !!draft && draft === (installedVersion ?? "");
+
+ return (
+
+
+
+ Installed version
+
+
+ setDraft(e.target.value)}
+ onBlur={() => save(draft)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ // Enter confirming an IME candidate is not a submit.
+ if (e.nativeEvent.isComposing) return;
+ e.preventDefault();
+ save(draft);
+ } else if (e.key === "Escape" && draft !== (installedVersion ?? "")) {
+ e.stopPropagation();
+ setDraft(installedVersion ?? "");
+ }
+ }}
+ data-testid="input-installed-version"
+ />
+
+ {mutation.isPending && }
+ {showSaved && }
+
+
+ {suggestions.length > 0 && (
+
+ From your downloads:
+ {suggestions.map((version) => (
+ e.preventDefault()}
+ onClick={() => {
+ setDraft(version);
+ save(version);
+ }}
+ data-testid={`button-version-suggestion-${version}`}
+ >
+ {version}
+
+ ))}
+
+ )}
+
+ Detected from release names when a download finishes. Updates that are not newer than this
+ version are not notified.
+
+
+ );
+}
diff --git a/client/src/hooks/use-game-updated-socket.ts b/client/src/hooks/use-game-updated-socket.ts
new file mode 100644
index 000000000..f93adb338
--- /dev/null
+++ b/client/src/hooks/use-game-updated-socket.ts
@@ -0,0 +1,24 @@
+import { useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { getSocket } from "@/lib/socket";
+
+/**
+ * Refreshes the games query when the server changes a game in the background (e.g. a finished
+ * download recording a newer installed version). Mounted app-wide: the games query never goes
+ * stale on its own, so an event missed while no details modal is open would otherwise leave the
+ * library showing old data until a reload.
+ */
+export function useGameUpdatedSocket(): void {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ const socket = getSocket();
+ const handler = () => {
+ queryClient.invalidateQueries({ queryKey: ["/api/games"] });
+ };
+ socket.on("gameUpdated", handler);
+ return () => {
+ socket.off("gameUpdated", handler);
+ };
+ }, [queryClient]);
+}
diff --git a/docs/API.md b/docs/API.md
index a47bd71fb..934cdb252 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -69,6 +69,7 @@ JWT even where a table row doesn't repeat `authenticateToken`.
| PATCH | `/api/games/:id/status` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `sanitizeGameStatus`, `validateRequest` | `{ status: "wanted"\|"owned"\|"completed"\|"downloading" }` via `updateGameStatusSchema` | Updated `Game`; 404 if not found |
| PATCH | `/api/games/:id/hidden` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | `{ hidden: boolean }` via `updateGameHiddenSchema` | Updated `Game`; 404 if not found |
| PATCH | `/api/games/:id/user-rating` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | `{ userRating: number\|null }` (0.5–10, step 0.5) via `updateGameUserRatingSchema` | Updated `Game`; 404 if not found |
+| PATCH | `/api/games/:id/installed-version` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | `{ installedVersion: string\|null }` (free text, trimmed, ≤64; blank clears) via `updateGameInstalledVersionSchema` | Updated `Game`; 404 if not found. Auto-search skips update releases whose version is not newer than this |
| POST | `/api/games/refresh-metadata` | JWT + `igdbRateLimiter` | — | `{ success, message, updatedCount, errorCount }` |
| DELETE | `/api/games/:id` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | Query: `deleteFiles?` ("true" to also remove the game's files on disk) | `{ success: true, fileDeletion }`; 404 if not found. `fileDeletion` is `null` when `deleteFiles` wasn't set, otherwise `{ deleted: true, path }` or `{ deleted: false, reason: "outside-library-root"\|"delete-failed", path }`. Files are only deleted when `libraryPath` resolves inside the configured library root **or** inside a root folder with `allowDelete: true` (see Root Folders below) |
| GET | `/api/games/:id/downloads` | JWT + `sanitizeGameId`, `validateRequest` (verifies game ownership) | — | `GameDownload[]`; 404 game not found, 403 not owner |
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index ba4f829e1..663b5c884 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file.
- **Time to Beat** via IGDB's official endpoint (#1063).
- **Sort menu** on the Library page, plus an indexer-priority sort option for downloads (#980, #963).
- **DLC & expansions**: games now persist their IGDB expansions and show them in a new DLC tab on the game detail page, with cover art, release year, and a category badge (#1105).
+- **Installed version**: each game records the version you have installed, editable in the game details modal with suggestions from your downloads' release names. It is filled in automatically when a full-game or update download finishes, and update releases that are not newer than it no longer raise "Game Updates Available" (#1142).
- **xREL**: surfaces a release's nuke reason with a "Nuked" badge (#948).
- **Screenshot lightbox**: carousel navigation with arrow-key and swipe support, plus an image counter (#804).
- **Wishlist**: configurable grid column count (#871).
diff --git a/migrations-pg/0008_spotty_celestials.sql b/migrations-pg/0008_spotty_celestials.sql
new file mode 100644
index 000000000..b639a63a5
--- /dev/null
+++ b/migrations-pg/0008_spotty_celestials.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "game_downloads" ADD COLUMN "category" text;--> statement-breakpoint
+ALTER TABLE "games" ADD COLUMN "installed_version" text;
\ No newline at end of file
diff --git a/migrations-pg/meta/0008_snapshot.json b/migrations-pg/meta/0008_snapshot.json
new file mode 100644
index 000000000..4e8c305a3
--- /dev/null
+++ b/migrations-pg/meta/0008_snapshot.json
@@ -0,0 +1,2313 @@
+{
+ "id": "961d0664-04d8-4424-82a2-e6864113f154",
+ "prevId": "569ebd28-47ce-4be9-9daf-24d98a63ba0e",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.ai_auto_download_holds": {
+ "name": "ai_auto_download_holds",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "release_title": {
+ "name": "release_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "ai_auto_download_holds_game_title_idx": {
+ "name": "ai_auto_download_holds_game_title_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "release_title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ai_auto_download_holds_game_id_games_id_fk": {
+ "name": "ai_auto_download_holds_game_id_games_id_fk",
+ "tableFrom": "ai_auto_download_holds",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_idx": {
+ "name": "api_keys_key_hash_idx",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "api_keys_user_id_idx": {
+ "name": "api_keys_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "api_keys_user_id_users_id_fk": {
+ "name": "api_keys_user_id_users_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.downloaders": {
+ "name": "downloaders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "use_ssl": {
+ "name": "use_ssl",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "url_path": {
+ "name": "url_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allow_self_signed_certificate": {
+ "name": "allow_self_signed_certificate",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "allow_insecure_lan": {
+ "name": "allow_insecure_lan",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "download_path": {
+ "name": "download_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'games'"
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'Questarr'"
+ },
+ "add_stopped": {
+ "name": "add_stopped",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "remove_completed": {
+ "name": "remove_completed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "post_import_category": {
+ "name": "post_import_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_downloads": {
+ "name": "game_downloads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "downloader_id": {
+ "name": "downloader_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "download_type": {
+ "name": "download_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'torrent'"
+ },
+ "download_hash": {
+ "name": "download_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "download_title": {
+ "name": "download_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'downloading'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "game_downloads_downloader_hash_idx": {
+ "name": "game_downloads_downloader_hash_idx",
+ "columns": [
+ {
+ "expression": "downloader_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "download_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_downloads_game_id_games_id_fk": {
+ "name": "game_downloads_game_id_games_id_fk",
+ "tableFrom": "game_downloads",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_downloads_downloader_id_downloaders_id_fk": {
+ "name": "game_downloads_downloader_id_downloaders_id_fk",
+ "tableFrom": "game_downloads",
+ "tableTo": "downloaders",
+ "columnsFrom": ["downloader_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_files": {
+ "name": "game_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "download_id": {
+ "name": "download_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_name": {
+ "name": "original_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stored_name": {
+ "name": "stored_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category_overridden": {
+ "name": "category_overridden",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "game_files_game_id_idx": {
+ "name": "game_files_game_id_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "game_files_download_id_idx": {
+ "name": "game_files_download_id_idx",
+ "columns": [
+ {
+ "expression": "download_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_files_game_id_games_id_fk": {
+ "name": "game_files_game_id_games_id_fk",
+ "tableFrom": "game_files",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_files_download_id_game_downloads_id_fk": {
+ "name": "game_files_download_id_game_downloads_id_fk",
+ "tableFrom": "game_files",
+ "tableTo": "game_downloads",
+ "columnsFrom": ["download_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_journal_entries": {
+ "name": "game_journal_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "game_journal_entries_game_user_idx": {
+ "name": "game_journal_entries_game_user_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_journal_entries_game_id_games_id_fk": {
+ "name": "game_journal_entries_game_id_games_id_fk",
+ "tableFrom": "game_journal_entries",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_journal_entries_user_id_users_id_fk": {
+ "name": "game_journal_entries_user_id_users_id_fk",
+ "tableFrom": "game_journal_entries",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_milestones": {
+ "name": "game_milestones",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "game_milestones_game_user_idx": {
+ "name": "game_milestones_game_user_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_milestones_game_id_games_id_fk": {
+ "name": "game_milestones_game_id_games_id_fk",
+ "tableFrom": "game_milestones",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_milestones_user_id_users_id_fk": {
+ "name": "game_milestones_user_id_users_id_fk",
+ "tableFrom": "game_milestones",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_screenshots": {
+ "name": "game_screenshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "game_screenshots_game_user_idx": {
+ "name": "game_screenshots_game_user_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_screenshots_game_id_games_id_fk": {
+ "name": "game_screenshots_game_id_games_id_fk",
+ "tableFrom": "game_screenshots",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_screenshots_user_id_users_id_fk": {
+ "name": "game_screenshots_user_id_users_id_fk",
+ "tableFrom": "game_screenshots",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.games": {
+ "name": "games",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "igdb_id": {
+ "name": "igdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steam_appid": {
+ "name": "steam_appid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_date": {
+ "name": "release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "platforms": {
+ "name": "platforms",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_platform_id": {
+ "name": "target_platform_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_platform_name": {
+ "name": "target_platform_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "themes": {
+ "name": "themes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "publishers": {
+ "name": "publishers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "developers": {
+ "name": "developers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "screenshots": {
+ "name": "screenshots",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'manual'"
+ },
+ "igdb_websites": {
+ "name": "igdb_websites",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expansions": {
+ "name": "expansions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aggregated_rating": {
+ "name": "aggregated_rating",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_to_beat_hastily": {
+ "name": "time_to_beat_hastily",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_to_beat_normally": {
+ "name": "time_to_beat_normally",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_to_beat_completely": {
+ "name": "time_to_beat_completely",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'wanted'"
+ },
+ "original_release_date": {
+ "name": "original_release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_status": {
+ "name": "release_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'upcoming'"
+ },
+ "early_access": {
+ "name": "early_access",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "hidden": {
+ "name": "hidden",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_adult_content": {
+ "name": "is_adult_content",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_age_restricted": {
+ "name": "is_age_restricted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "user_rating": {
+ "name": "user_rating",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "library_path": {
+ "name": "library_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_version": {
+ "name": "installed_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "search_results_available": {
+ "name": "search_results_available",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "search_results_available_at": {
+ "name": "search_results_available_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "update_search_results_available": {
+ "name": "update_search_results_available",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "packs_search_results_available": {
+ "name": "packs_search_results_available",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "games_user_id_users_id_fk": {
+ "name": "games_user_id_users_id_fk",
+ "tableFrom": "games",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.import_task_items": {
+ "name": "import_task_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_name": {
+ "name": "item_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "game_title": {
+ "name": "game_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "import_task_items_task_id_idx": {
+ "name": "import_task_items_task_id_idx",
+ "columns": [
+ {
+ "expression": "task_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "import_task_items_task_id_import_tasks_id_fk": {
+ "name": "import_task_items_task_id_import_tasks_id_fk",
+ "tableFrom": "import_task_items",
+ "tableTo": "import_tasks",
+ "columnsFrom": ["task_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.import_tasks": {
+ "name": "import_tasks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "task_type": {
+ "name": "task_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_items": {
+ "name": "total_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "added_items": {
+ "name": "added_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "skipped_items": {
+ "name": "skipped_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "failed_items": {
+ "name": "failed_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "import_tasks_user_id_users_id_fk": {
+ "name": "import_tasks_user_id_users_id_fk",
+ "tableFrom": "import_tasks",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.indexers": {
+ "name": "indexers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protocol": {
+ "name": "protocol",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'torznab'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "categories": {
+ "name": "categories",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "rss_enabled": {
+ "name": "rss_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "auto_search_enabled": {
+ "name": "auto_search_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "allow_insecure_lan": {
+ "name": "allow_insecure_lan",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read": {
+ "name": "read",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.path_mappings": {
+ "name": "path_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "remote_path": {
+ "name": "remote_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_path": {
+ "name": "local_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_host": {
+ "name": "remote_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.platform_mappings": {
+ "name": "platform_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "igdb_platform_id": {
+ "name": "igdb_platform_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_platform_name": {
+ "name": "source_platform_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.release_blacklist": {
+ "name": "release_blacklist",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "release_title": {
+ "name": "release_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "indexer_name": {
+ "name": "indexer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {
+ "release_blacklist_game_title_idx": {
+ "name": "release_blacklist_game_title_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "release_title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "release_blacklist_game_id_games_id_fk": {
+ "name": "release_blacklist_game_id_games_id_fk",
+ "tableFrom": "release_blacklist",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.root_folders": {
+ "name": "root_folders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "allow_delete": {
+ "name": "allow_delete",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "accessible": {
+ "name": "accessible",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disk_free_bytes": {
+ "name": "disk_free_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disk_total_bytes": {
+ "name": "disk_total_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_scanned_at": {
+ "name": "last_scanned_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "root_folders_path_unique": {
+ "name": "root_folders_path_unique",
+ "nullsNotDistinct": false,
+ "columns": ["path"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rss_feed_items": {
+ "name": "rss_feed_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "feed_id": {
+ "name": "feed_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "guid": {
+ "name": "guid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pub_date": {
+ "name": "pub_date",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_name": {
+ "name": "source_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "igdb_game_id": {
+ "name": "igdb_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "igdb_game_name": {
+ "name": "igdb_game_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "rss_feed_items_feed_id_rss_feeds_id_fk": {
+ "name": "rss_feed_items_feed_id_rss_feeds_id_fk",
+ "tableFrom": "rss_feed_items",
+ "tableTo": "rss_feeds",
+ "columnsFrom": ["feed_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rss_feeds": {
+ "name": "rss_feeds",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'custom'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "mapping": {
+ "name": "mapping",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_check": {
+ "name": "last_check",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'ok'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.system_config": {
+ "name": "system_config",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_settings": {
+ "name": "user_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auto_search_enabled": {
+ "name": "auto_search_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "auto_download_enabled": {
+ "name": "auto_download_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "search_interval_hours": {
+ "name": "search_interval_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 6
+ },
+ "igdb_rate_limit_per_second": {
+ "name": "igdb_rate_limit_per_second",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "download_rules": {
+ "name": "download_rules",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_auto_search": {
+ "name": "last_auto_search",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "xrel_scene_releases": {
+ "name": "xrel_scene_releases",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "xrel_p2p_releases": {
+ "name": "xrel_p2p_releases",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "auto_search_unreleased": {
+ "name": "auto_search_unreleased",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "steam_sync_failures": {
+ "name": "steam_sync_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "steam_sync_enabled": {
+ "name": "steam_sync_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "steam_sync_interval_hours": {
+ "name": "steam_sync_interval_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 24
+ },
+ "last_steam_sync": {
+ "name": "last_steam_sync",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preferred_release_groups": {
+ "name": "preferred_release_groups",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filter_by_preferred_groups": {
+ "name": "filter_by_preferred_groups",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "release_name_blacklist": {
+ "name": "release_name_blacklist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preferred_platform": {
+ "name": "preferred_platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hide_adult_content": {
+ "name": "hide_adult_content",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "hide_age_restricted_content": {
+ "name": "hide_age_restricted_content",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "hide_shelved_by_default": {
+ "name": "hide_shelved_by_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "hide_owned_in_has_results": {
+ "name": "hide_owned_in_has_results",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "enable_post_processing": {
+ "name": "enable_post_processing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "auto_unpack": {
+ "name": "auto_unpack",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "rename_pattern": {
+ "name": "rename_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{Title} ({Region})'"
+ },
+ "overwrite_existing": {
+ "name": "overwrite_existing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "transfer_mode": {
+ "name": "transfer_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'hardlink'"
+ },
+ "import_platform_ids": {
+ "name": "import_platform_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "ignored_extensions": {
+ "name": "ignored_extensions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "min_file_size": {
+ "name": "min_file_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "library_root": {
+ "name": "library_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'/data'"
+ },
+ "auto_delete_after_import": {
+ "name": "auto_delete_after_import",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sort_extras": {
+ "name": "sort_extras",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "telemetry_enabled": {
+ "name": "telemetry_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_settings_user_id_users_id_fk": {
+ "name": "user_settings_user_id_users_id_fk",
+ "tableFrom": "user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_settings_user_id_unique": {
+ "name": "user_settings_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id_64": {
+ "name": "steam_id_64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": ["username"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.xrel_notified_releases": {
+ "name": "xrel_notified_releases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "xrel_release_id": {
+ "name": "xrel_release_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "xrel_notified_releases_game_id_games_id_fk": {
+ "name": "xrel_notified_releases_game_id_games_id_fk",
+ "tableFrom": "xrel_notified_releases",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/migrations-pg/meta/_journal.json b/migrations-pg/meta/_journal.json
index 73268fcd6..adc7dc486 100644
--- a/migrations-pg/meta/_journal.json
+++ b/migrations-pg/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1791193363035,
"tag": "0007_sticky_thor",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "7",
+ "when": 1791226213942,
+ "tag": "0008_spotty_celestials",
+ "breakpoints": true
}
]
}
diff --git a/migrations/0043_groovy_slyde.sql b/migrations/0043_groovy_slyde.sql
new file mode 100644
index 000000000..5d6bfe9f2
--- /dev/null
+++ b/migrations/0043_groovy_slyde.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `game_downloads` ADD `category` text;--> statement-breakpoint
+ALTER TABLE `games` ADD `installed_version` text;
\ No newline at end of file
diff --git a/migrations/meta/0043_snapshot.json b/migrations/meta/0043_snapshot.json
new file mode 100644
index 000000000..474f9ca1d
--- /dev/null
+++ b/migrations/meta/0043_snapshot.json
@@ -0,0 +1,2344 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "07de75d5-fb0e-439c-a9a9-fb209dd4b3b4",
+ "prevId": "eb7a6251-80b1-4ba9-8d4c-12d0d198d3b6",
+ "tables": {
+ "ai_auto_download_holds": {
+ "name": "ai_auto_download_holds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_title": {
+ "name": "release_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "ai_auto_download_holds_game_title_idx": {
+ "name": "ai_auto_download_holds_game_title_idx",
+ "columns": ["game_id", "release_title"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "ai_auto_download_holds_game_id_games_id_fk": {
+ "name": "ai_auto_download_holds_game_id_games_id_fk",
+ "tableFrom": "ai_auto_download_holds",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_idx": {
+ "name": "api_keys_key_hash_idx",
+ "columns": ["key_hash"],
+ "isUnique": true
+ },
+ "api_keys_user_id_idx": {
+ "name": "api_keys_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "api_keys_user_id_users_id_fk": {
+ "name": "api_keys_user_id_users_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "downloaders": {
+ "name": "downloaders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "use_ssl": {
+ "name": "use_ssl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "url_path": {
+ "name": "url_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "allow_self_signed_certificate": {
+ "name": "allow_self_signed_certificate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "allow_insecure_lan": {
+ "name": "allow_insecure_lan",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "download_path": {
+ "name": "download_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'games'"
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'Questarr'"
+ },
+ "add_stopped": {
+ "name": "add_stopped",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "remove_completed": {
+ "name": "remove_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "post_import_category": {
+ "name": "post_import_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "game_downloads": {
+ "name": "game_downloads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "downloader_id": {
+ "name": "downloader_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "download_type": {
+ "name": "download_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'torrent'"
+ },
+ "download_hash": {
+ "name": "download_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "download_title": {
+ "name": "download_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'downloading'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "game_downloads_downloader_hash_idx": {
+ "name": "game_downloads_downloader_hash_idx",
+ "columns": ["downloader_id", "download_hash"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "game_downloads_game_id_games_id_fk": {
+ "name": "game_downloads_game_id_games_id_fk",
+ "tableFrom": "game_downloads",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_downloads_downloader_id_downloaders_id_fk": {
+ "name": "game_downloads_downloader_id_downloaders_id_fk",
+ "tableFrom": "game_downloads",
+ "tableTo": "downloaders",
+ "columnsFrom": ["downloader_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "game_files": {
+ "name": "game_files",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "download_id": {
+ "name": "download_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "original_name": {
+ "name": "original_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stored_name": {
+ "name": "stored_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "category_overridden": {
+ "name": "category_overridden",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "game_files_game_id_idx": {
+ "name": "game_files_game_id_idx",
+ "columns": ["game_id"],
+ "isUnique": false
+ },
+ "game_files_download_id_idx": {
+ "name": "game_files_download_id_idx",
+ "columns": ["download_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "game_files_game_id_games_id_fk": {
+ "name": "game_files_game_id_games_id_fk",
+ "tableFrom": "game_files",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_files_download_id_game_downloads_id_fk": {
+ "name": "game_files_download_id_game_downloads_id_fk",
+ "tableFrom": "game_files",
+ "tableTo": "game_downloads",
+ "columnsFrom": ["download_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "game_journal_entries": {
+ "name": "game_journal_entries",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "game_journal_entries_game_user_idx": {
+ "name": "game_journal_entries_game_user_idx",
+ "columns": ["game_id", "user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "game_journal_entries_game_id_games_id_fk": {
+ "name": "game_journal_entries_game_id_games_id_fk",
+ "tableFrom": "game_journal_entries",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_journal_entries_user_id_users_id_fk": {
+ "name": "game_journal_entries_user_id_users_id_fk",
+ "tableFrom": "game_journal_entries",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "game_milestones": {
+ "name": "game_milestones",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "game_milestones_game_user_idx": {
+ "name": "game_milestones_game_user_idx",
+ "columns": ["game_id", "user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "game_milestones_game_id_games_id_fk": {
+ "name": "game_milestones_game_id_games_id_fk",
+ "tableFrom": "game_milestones",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_milestones_user_id_users_id_fk": {
+ "name": "game_milestones_user_id_users_id_fk",
+ "tableFrom": "game_milestones",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "game_screenshots": {
+ "name": "game_screenshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "game_screenshots_game_user_idx": {
+ "name": "game_screenshots_game_user_idx",
+ "columns": ["game_id", "user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "game_screenshots_game_id_games_id_fk": {
+ "name": "game_screenshots_game_id_games_id_fk",
+ "tableFrom": "game_screenshots",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_screenshots_user_id_users_id_fk": {
+ "name": "game_screenshots_user_id_users_id_fk",
+ "tableFrom": "game_screenshots",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "games": {
+ "name": "games",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "igdb_id": {
+ "name": "igdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "steam_appid": {
+ "name": "steam_appid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "release_date": {
+ "name": "release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platforms": {
+ "name": "platforms",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_platform_id": {
+ "name": "target_platform_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_platform_name": {
+ "name": "target_platform_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "themes": {
+ "name": "themes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publishers": {
+ "name": "publishers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "developers": {
+ "name": "developers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "screenshots": {
+ "name": "screenshots",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'manual'"
+ },
+ "igdb_websites": {
+ "name": "igdb_websites",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expansions": {
+ "name": "expansions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "aggregated_rating": {
+ "name": "aggregated_rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_to_beat_hastily": {
+ "name": "time_to_beat_hastily",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_to_beat_normally": {
+ "name": "time_to_beat_normally",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "time_to_beat_completely": {
+ "name": "time_to_beat_completely",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'wanted'"
+ },
+ "original_release_date": {
+ "name": "original_release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "release_status": {
+ "name": "release_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'upcoming'"
+ },
+ "early_access": {
+ "name": "early_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "hidden": {
+ "name": "hidden",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_adult_content": {
+ "name": "is_adult_content",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_age_restricted": {
+ "name": "is_age_restricted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "user_rating": {
+ "name": "user_rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_path": {
+ "name": "library_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "installed_version": {
+ "name": "installed_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_results_available": {
+ "name": "search_results_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "search_results_available_at": {
+ "name": "search_results_available_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "update_search_results_available": {
+ "name": "update_search_results_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "packs_search_results_available": {
+ "name": "packs_search_results_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "games_user_id_users_id_fk": {
+ "name": "games_user_id_users_id_fk",
+ "tableFrom": "games",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "import_task_items": {
+ "name": "import_task_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_name": {
+ "name": "item_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "game_title": {
+ "name": "game_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "import_task_items_task_id_idx": {
+ "name": "import_task_items_task_id_idx",
+ "columns": ["task_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "import_task_items_task_id_import_tasks_id_fk": {
+ "name": "import_task_items_task_id_import_tasks_id_fk",
+ "tableFrom": "import_task_items",
+ "tableTo": "import_tasks",
+ "columnsFrom": ["task_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "import_tasks": {
+ "name": "import_tasks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "task_type": {
+ "name": "task_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_items": {
+ "name": "total_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "added_items": {
+ "name": "added_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "skipped_items": {
+ "name": "skipped_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "failed_items": {
+ "name": "failed_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "import_tasks_user_id_users_id_fk": {
+ "name": "import_tasks_user_id_users_id_fk",
+ "tableFrom": "import_tasks",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "indexers": {
+ "name": "indexers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol": {
+ "name": "protocol",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'torznab'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "categories": {
+ "name": "categories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "rss_enabled": {
+ "name": "rss_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "auto_search_enabled": {
+ "name": "auto_search_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "allow_insecure_lan": {
+ "name": "allow_insecure_lan",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read": {
+ "name": "read",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "path_mappings": {
+ "name": "path_mappings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_path": {
+ "name": "remote_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_path": {
+ "name": "local_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_host": {
+ "name": "remote_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "platform_mappings": {
+ "name": "platform_mappings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "igdb_platform_id": {
+ "name": "igdb_platform_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_platform_name": {
+ "name": "source_platform_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "release_blacklist": {
+ "name": "release_blacklist",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_title": {
+ "name": "release_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "indexer_name": {
+ "name": "indexer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "release_blacklist_game_title_idx": {
+ "name": "release_blacklist_game_title_idx",
+ "columns": ["game_id", "release_title"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "release_blacklist_game_id_games_id_fk": {
+ "name": "release_blacklist_game_id_games_id_fk",
+ "tableFrom": "release_blacklist",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "root_folders": {
+ "name": "root_folders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "allow_delete": {
+ "name": "allow_delete",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "accessible": {
+ "name": "accessible",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "disk_free_bytes": {
+ "name": "disk_free_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "disk_total_bytes": {
+ "name": "disk_total_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_scanned_at": {
+ "name": "last_scanned_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "root_folders_path_unique": {
+ "name": "root_folders_path_unique",
+ "columns": ["path"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rss_feed_items": {
+ "name": "rss_feed_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feed_id": {
+ "name": "feed_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "guid": {
+ "name": "guid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pub_date": {
+ "name": "pub_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_name": {
+ "name": "source_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "igdb_game_id": {
+ "name": "igdb_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "igdb_game_name": {
+ "name": "igdb_game_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "rss_feed_items_feed_id_rss_feeds_id_fk": {
+ "name": "rss_feed_items_feed_id_rss_feeds_id_fk",
+ "tableFrom": "rss_feed_items",
+ "tableTo": "rss_feeds",
+ "columnsFrom": ["feed_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rss_feeds": {
+ "name": "rss_feeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "mapping": {
+ "name": "mapping",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_check": {
+ "name": "last_check",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'ok'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "system_config": {
+ "name": "system_config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_settings": {
+ "name": "user_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auto_search_enabled": {
+ "name": "auto_search_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "auto_download_enabled": {
+ "name": "auto_download_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_interval_hours": {
+ "name": "search_interval_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "igdb_rate_limit_per_second": {
+ "name": "igdb_rate_limit_per_second",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "download_rules": {
+ "name": "download_rules",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_auto_search": {
+ "name": "last_auto_search",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "xrel_scene_releases": {
+ "name": "xrel_scene_releases",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "xrel_p2p_releases": {
+ "name": "xrel_p2p_releases",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "auto_search_unreleased": {
+ "name": "auto_search_unreleased",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "steam_sync_failures": {
+ "name": "steam_sync_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "steam_sync_enabled": {
+ "name": "steam_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "steam_sync_interval_hours": {
+ "name": "steam_sync_interval_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 24
+ },
+ "last_steam_sync": {
+ "name": "last_steam_sync",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferred_release_groups": {
+ "name": "preferred_release_groups",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "filter_by_preferred_groups": {
+ "name": "filter_by_preferred_groups",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "release_name_blacklist": {
+ "name": "release_name_blacklist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferred_platform": {
+ "name": "preferred_platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hide_adult_content": {
+ "name": "hide_adult_content",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "hide_age_restricted_content": {
+ "name": "hide_age_restricted_content",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "hide_shelved_by_default": {
+ "name": "hide_shelved_by_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "hide_owned_in_has_results": {
+ "name": "hide_owned_in_has_results",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "enable_post_processing": {
+ "name": "enable_post_processing",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "auto_unpack": {
+ "name": "auto_unpack",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "rename_pattern": {
+ "name": "rename_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{Title} ({Region})'"
+ },
+ "overwrite_existing": {
+ "name": "overwrite_existing",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "transfer_mode": {
+ "name": "transfer_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hardlink'"
+ },
+ "import_platform_ids": {
+ "name": "import_platform_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "ignored_extensions": {
+ "name": "ignored_extensions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "min_file_size": {
+ "name": "min_file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "library_root": {
+ "name": "library_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'/data'"
+ },
+ "auto_delete_after_import": {
+ "name": "auto_delete_after_import",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_extras": {
+ "name": "sort_extras",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "telemetry_enabled": {
+ "name": "telemetry_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "user_settings_user_id_unique": {
+ "name": "user_settings_user_id_unique",
+ "columns": ["user_id"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "user_settings_user_id_users_id_fk": {
+ "name": "user_settings_user_id_users_id_fk",
+ "tableFrom": "user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "steam_id_64": {
+ "name": "steam_id_64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": ["username"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "xrel_notified_releases": {
+ "name": "xrel_notified_releases",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "xrel_release_id": {
+ "name": "xrel_release_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "xrel_notified_releases_game_id_games_id_fk": {
+ "name": "xrel_notified_releases_game_id_games_id_fk",
+ "tableFrom": "xrel_notified_releases",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index d8a1bcfd8..d91faa092 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -309,6 +309,13 @@
"when": 1791193362024,
"tag": "0042_outstanding_arclight",
"breakpoints": true
+ },
+ {
+ "idx": 43,
+ "version": "6",
+ "when": 1791226212843,
+ "tag": "0043_groovy_slyde",
+ "breakpoints": true
}
]
}
diff --git a/scripts/extract-changelog.mjs b/scripts/extract-changelog.mjs
index 20dfb1ea9..56b6016ff 100644
--- a/scripts/extract-changelog.mjs
+++ b/scripts/extract-changelog.mjs
@@ -16,8 +16,15 @@ if (!version) {
const changelogPath = path.join(repoRoot, "docs", "CHANGELOG.md");
const lines = fs.readFileSync(changelogPath, "utf8").split("\n");
-const headingPattern = new RegExp(`^## \\[${version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]`);
-const startIndex = lines.findIndex((line) => headingPattern.test(line));
+const findHeading = (v) => {
+ const pattern = new RegExp(`^## \\[${v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]`);
+ return lines.findIndex((line) => pattern.test(line));
+};
+
+// A pre-release ("1.5.0-rc1") without its own entry uses the notes of the version it leads to.
+let startIndex = findHeading(version);
+const baseVersion = version.replace(/-.*$/, "");
+if (startIndex === -1 && baseVersion !== version) startIndex = findHeading(baseVersion);
if (startIndex === -1) {
console.error(`No CHANGELOG.md entry found for version ${version}`);
diff --git a/server/__tests__/api_routes.test.ts b/server/__tests__/api_routes.test.ts
index 8a0a0b8ee..f7b75f461 100644
--- a/server/__tests__/api_routes.test.ts
+++ b/server/__tests__/api_routes.test.ts
@@ -972,6 +972,63 @@ describe("API Routes - Extended Coverage", () => {
});
});
+ describe("PATCH /api/games/:id/installed-version", () => {
+ const gameId = "123e4567-e89b-12d3-a456-426614174000";
+
+ it("should set a trimmed version scoped to the authenticated user", async () => {
+ vi.mocked(storage.updateGameInstalledVersion).mockResolvedValue({
+ id: gameId,
+ installedVersion: "v1.2.3",
+ } as unknown as Game);
+
+ const response = await request(app)
+ .patch(`/api/games/${gameId}/installed-version`)
+ .send({ installedVersion: " v1.2.3 " });
+ expect(response.status).toBe(200);
+ expect(vi.mocked(storage.updateGameInstalledVersion)).toHaveBeenCalledWith(
+ gameId,
+ "user-1",
+ "v1.2.3"
+ );
+ const { notifyUser } = await import("../socket.js");
+ expect(vi.mocked(notifyUser)).toHaveBeenCalledWith("gameUpdated", gameId);
+ });
+
+ it("should clear the version with a blank value", async () => {
+ vi.mocked(storage.updateGameInstalledVersion).mockResolvedValue({
+ id: gameId,
+ installedVersion: null,
+ } as unknown as Game);
+
+ const response = await request(app)
+ .patch(`/api/games/${gameId}/installed-version`)
+ .send({ installedVersion: " " });
+ expect(response.status).toBe(200);
+ expect(vi.mocked(storage.updateGameInstalledVersion)).toHaveBeenCalledWith(
+ gameId,
+ "user-1",
+ null
+ );
+ });
+
+ it("should reject a version longer than 64 characters", async () => {
+ const response = await request(app)
+ .patch(`/api/games/${gameId}/installed-version`)
+ .send({ installedVersion: "v".repeat(65) });
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe("Invalid installed version data");
+ });
+
+ it("should return 404 when the game is not the user's", async () => {
+ vi.mocked(storage.updateGameInstalledVersion).mockResolvedValue(undefined);
+
+ const response = await request(app)
+ .patch(`/api/games/${gameId}/installed-version`)
+ .send({ installedVersion: "v1" });
+ expect(response.status).toBe(404);
+ });
+ });
+
describe("PATCH /api/games/:id/user-rating", () => {
const gameId = "123e4567-e89b-12d3-a456-426614174000";
@@ -3055,6 +3112,26 @@ describe("API Routes - Extended Coverage", () => {
);
});
+ it("stores the category picked for the claimed download", async () => {
+ vi.mocked(storage.getTrackedDownloadKeys).mockResolvedValue(new Set());
+ mockSabnzbdClaimTarget();
+ vi.mocked(storage.addGameDownload).mockResolvedValue(undefined as any);
+
+ const res = await request(app).post("/api/downloads/claim").send({
+ downloaderId: "dl-1",
+ downloadHash: USENET_MIXED_CASE_ID,
+ downloadTitle: "Expansion.Name.v5.0",
+ currentStatus: "downloading",
+ category: "dlc",
+ gameId: "game-1",
+ });
+
+ expect(res.status).toBe(200);
+ expect(storage.addGameDownload).toHaveBeenCalledWith(
+ expect.objectContaining({ category: "dlc" })
+ );
+ });
+
it("rejects a claim as a duplicate when the mixed-case Usenet id is already tracked", async () => {
vi.mocked(storage.getTrackedDownloadKeys).mockResolvedValue(
new Set([`dl-1:${USENET_MIXED_CASE_ID}`])
diff --git a/server/__tests__/api_routes_async_download.test.ts b/server/__tests__/api_routes_async_download.test.ts
index f481537cf..e0b640f37 100644
--- a/server/__tests__/api_routes_async_download.test.ts
+++ b/server/__tests__/api_routes_async_download.test.ts
@@ -142,7 +142,12 @@ describe("POST /api/downloads — async qBittorrent tracking", () => {
mockGameRow(opts.gameId, opts.title);
}
- async function postDownload(payload: { url: string; title: string; gameId: string }) {
+ async function postDownload(payload: {
+ url: string;
+ title: string;
+ gameId: string;
+ releaseCategory?: string;
+ }) {
const res = await request(app).post("/api/downloads").send(payload);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
@@ -265,6 +270,43 @@ describe("POST /api/downloads — async qBittorrent tracking", () => {
);
});
+ it("stores the release category the client classified the download as", async () => {
+ const gameId = "123e4567-e89b-12d3-a456-426614174002";
+ mockSuccessCase({
+ fallback: {
+ success: true,
+ id: "realhash123",
+ downloaderId: "d-1",
+ downloaderName: "qBittorrent",
+ attemptedDownloaders: ["qBittorrent"],
+ },
+ gdId: "gd-2",
+ gameId,
+ hash: "realhash123",
+ title: "Expansion.Name.v5.0",
+ });
+
+ await postDownload({
+ url: "https://example.com/sync.torrent",
+ title: "Expansion.Name.v5.0",
+ gameId,
+ releaseCategory: "dlc",
+ });
+
+ expect(storage.addGameDownload).toHaveBeenCalledWith(
+ expect.objectContaining({ category: "dlc" })
+ );
+ });
+
+ it("rejects an unknown release category", async () => {
+ const res = await request(app).post("/api/downloads").send({
+ url: "https://example.com/sync.torrent",
+ title: "Game",
+ releaseCategory: "bogus",
+ });
+ expect(res.status).toBe(400);
+ });
+
it("prefers result.id over result.correlationTag when both are present", async () => {
// Edge case: downloader returns both a real hash AND a correlationTag.
// The route must use the real hash (id), not the tag.
diff --git a/server/__tests__/cron_autosearch.test.ts b/server/__tests__/cron_autosearch.test.ts
index 2b901dd63..04a122c51 100644
--- a/server/__tests__/cron_autosearch.test.ts
+++ b/server/__tests__/cron_autosearch.test.ts
@@ -30,6 +30,7 @@ const mockAddNotification = vi.fn();
const mockUpdateGameSearchResultsAvailable = vi.fn();
const mockUpdateGameSearchResultsByCategory = vi.fn();
const mockUpdateGameStatus = vi.fn();
+const mockUpdateGame = vi.fn();
const mockAddGameDownload = vi.fn();
const mockGetEnabledDownloaders = vi.fn().mockResolvedValue([]);
const mockGetReleaseBlacklistSet = vi.fn();
@@ -48,6 +49,7 @@ vi.mock("../storage.js", () => ({
updateGameSearchResultsAvailable: mockUpdateGameSearchResultsAvailable,
updateGameSearchResultsByCategory: mockUpdateGameSearchResultsByCategory,
updateGameStatus: mockUpdateGameStatus,
+ updateGame: mockUpdateGame,
addGameDownload: mockAddGameDownload,
getEnabledDownloaders: mockGetEnabledDownloaders,
getReleaseBlacklistSet: mockGetReleaseBlacklistSet,
@@ -366,6 +368,52 @@ describe("Cron - checkAutoSearch", () => {
);
});
+ it("should not notify updates that are not newer than the installed version", async () => {
+ const game = {
+ ...baseGame,
+ status: "owned" as const,
+ releaseStatus: "released" as const,
+ installedVersion: "v1.2",
+ };
+ const settings = { ...baseSettings, notifyUpdates: true };
+
+ mockGetWantedGamesGroupedByUser.mockResolvedValue(new Map([[userId, []]]));
+ mockGetUserGames.mockResolvedValue([game]);
+ mockGetUserSettings.mockResolvedValue(settings);
+ mockSearchAllIndexers.mockResolvedValue({ items: [UPDATE_ITEM], errors: [], total: 1 });
+
+ await checkAutoSearch();
+
+ expect(mockAddNotification).not.toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Game Updates Available" })
+ );
+ expect(mockUpdateGameSearchResultsByCategory).toHaveBeenCalledWith(
+ game.id,
+ expect.objectContaining({ updates: false })
+ );
+ });
+
+ it("should notify updates newer than the installed version", async () => {
+ const game = {
+ ...baseGame,
+ status: "owned" as const,
+ releaseStatus: "released" as const,
+ installedVersion: "1.0",
+ };
+ const settings = { ...baseSettings, notifyUpdates: true };
+
+ mockGetWantedGamesGroupedByUser.mockResolvedValue(new Map([[userId, []]]));
+ mockGetUserGames.mockResolvedValue([game]);
+ mockGetUserSettings.mockResolvedValue(settings);
+ mockSearchAllIndexers.mockResolvedValue({ items: [UPDATE_ITEM], errors: [], total: 1 });
+
+ await checkAutoSearch();
+
+ expect(mockAddNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Game Updates Available" })
+ );
+ });
+
it("should still notify game availability for wanted games when main results exist", async () => {
const game = { ...baseGame, status: "wanted" as const, releaseStatus: "released" as const };
const settings = { ...baseSettings, notifyUpdates: true, autoDownloadEnabled: false };
diff --git a/server/__tests__/cron_download_status.test.ts b/server/__tests__/cron_download_status.test.ts
index 855da989d..6515d1b59 100644
--- a/server/__tests__/cron_download_status.test.ts
+++ b/server/__tests__/cron_download_status.test.ts
@@ -32,6 +32,7 @@ const mockAddNotification = vi.fn();
const mockGetUserSettings = vi.fn();
const mockGetImportConfig = vi.fn();
const mockGetDownloadsByGameId = vi.fn();
+const mockReplaceGameInstalledVersion = vi.fn();
vi.mock("../storage.js", () => ({
storage: {
@@ -44,6 +45,7 @@ vi.mock("../storage.js", () => ({
getUserSettings: mockGetUserSettings,
getImportConfig: mockGetImportConfig,
getDownloadsByGameId: mockGetDownloadsByGameId,
+ replaceGameInstalledVersion: mockReplaceGameInstalledVersion,
},
}));
@@ -471,6 +473,46 @@ describe("Cron - checkDownloadStatus", () => {
expect(mockProcessImport).not.toHaveBeenCalled();
});
+ it("records the release's version when a download completes with post-processing disabled", async () => {
+ const versioned = { ...baseDownload, downloadTitle: "Test.Game.v1.4.2-RUNE" };
+ mockGetDownloadingGameDownloads.mockResolvedValue([versioned]);
+ mockGetDownloader.mockResolvedValue(baseDownloader);
+ mockGetImportConfig.mockResolvedValue({ enablePostProcessing: false });
+ mockGetAllDownloads.mockResolvedValue([
+ {
+ id: "SABnzbd_nzo_abc123",
+ name: versioned.downloadTitle,
+ status: "completed",
+ progress: 100,
+ downloadType: "usenet",
+ },
+ ]);
+
+ await checkDownloadStatus();
+
+ expect(mockReplaceGameInstalledVersion).toHaveBeenCalledWith(versioned.gameId, null, "v1.4.2");
+ });
+
+ it("leaves the version to the import when post-processing is enabled", async () => {
+ const versioned = { ...baseDownload, downloadTitle: "Test.Game.v1.4.2-RUNE" };
+ mockGetDownloadingGameDownloads.mockResolvedValue([versioned]);
+ mockGetDownloader.mockResolvedValue(baseDownloader);
+ mockGetImportConfig.mockResolvedValue({ enablePostProcessing: true });
+ mockGetAllDownloads.mockResolvedValue([
+ {
+ id: "SABnzbd_nzo_abc123",
+ name: versioned.downloadTitle,
+ status: "completed",
+ progress: 100,
+ downloadType: "usenet",
+ },
+ ]);
+
+ await checkDownloadStatus();
+
+ expect(mockReplaceGameInstalledVersion).not.toHaveBeenCalled();
+ });
+
it("should keep a playing game's status when its update download completes", async () => {
mockGetGame.mockResolvedValue({
id: "game-1",
diff --git a/server/__tests__/database_storage_extended.test.ts b/server/__tests__/database_storage_extended.test.ts
index 3ee0136af..df4bdf0a2 100644
--- a/server/__tests__/database_storage_extended.test.ts
+++ b/server/__tests__/database_storage_extended.test.ts
@@ -184,6 +184,13 @@ describe("DatabaseStorage Extended Coverage", () => {
const ratingUpdated = await storage.updateGameUserRating(game.id, userId, 8.5);
expect(ratingUpdated?.userRating).toBe(8.5);
+ const versionUpdated = await storage.updateGameInstalledVersion(game.id, userId, "v1.2.3");
+ expect(versionUpdated?.installedVersion).toBe("v1.2.3");
+ expect(await storage.updateGameInstalledVersion(game.id, "other-user", "v9")).toBeUndefined();
+ expect(await storage.replaceGameInstalledVersion(game.id, "v1.0", "v1.3")).toBe(false);
+ expect(await storage.replaceGameInstalledVersion(game.id, "v1.2.3", "v1.3")).toBe(true);
+ expect((await storage.getGame(game.id))?.installedVersion).toBe("v1.3");
+
await storage.updateGameSearchResultsAvailable(game.id, true);
const refetched = await storage.getGame(game.id);
expect(refetched?.searchResultsAvailable).toBe(true);
diff --git a/server/__tests__/dialect-parity.test.ts b/server/__tests__/dialect-parity.test.ts
index 1b0067e3d..6167cdea2 100644
--- a/server/__tests__/dialect-parity.test.ts
+++ b/server/__tests__/dialect-parity.test.ts
@@ -114,6 +114,16 @@ describe.each(activeTestDialects())("storage behaviour on %s", (dialect) => {
expect(after.every((g) => g.status === "downloaded")).toBe(true);
});
+ it("replaceGameInstalledVersion writes only over the expected value", async () => {
+ const [user] = await storage().getAllUsers();
+ const [game] = await storage().getUserGames(user.id);
+ expect(await storage().replaceGameInstalledVersion(game.id, "v9", "v1.0")).toBe(false);
+ expect(await storage().replaceGameInstalledVersion(game.id, null, "v1.0")).toBe(true);
+ expect(await storage().replaceGameInstalledVersion(game.id, null, "v2.0")).toBe(false);
+ expect(await storage().replaceGameInstalledVersion(game.id, "v1.0", "v1.1")).toBe(true);
+ expect((await storage().getGame(game.id))?.installedVersion).toBe("v1.1");
+ });
+
it("aggregates come back as numbers, not strings", async () => {
const [user] = await storage().getAllUsers();
const status = await storage().getDashboardStatus(user.id);
diff --git a/server/__tests__/fixtures/common-route-mocks.ts b/server/__tests__/fixtures/common-route-mocks.ts
index 5b7c5aec1..bc67d755e 100644
--- a/server/__tests__/fixtures/common-route-mocks.ts
+++ b/server/__tests__/fixtures/common-route-mocks.ts
@@ -68,6 +68,7 @@ export function createStorageMock() {
updateGameStatus: vi.fn(),
updateGameHidden: vi.fn(),
updateGameUserRating: vi.fn(),
+ updateGameInstalledVersion: vi.fn(),
updateGame: vi.fn(),
getGameJournalEntries: vi.fn().mockResolvedValue([]),
addGameJournalEntry: vi.fn(),
diff --git a/server/__tests__/game-version.test.ts b/server/__tests__/game-version.test.ts
new file mode 100644
index 000000000..9397d1e9a
--- /dev/null
+++ b/server/__tests__/game-version.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../logger.js", () => ({
+ logger: { info: vi.fn(), warn: vi.fn() },
+}));
+const notifyUser = vi.fn();
+vi.mock("../socket.js", () => ({ notifyUser }));
+
+const { recordVersionFromCompletedDownload } = await import("../game-version.js");
+
+describe("recordVersionFromCompletedDownload", () => {
+ const replaceGameInstalledVersion = vi.fn();
+ const getGame = vi.fn();
+ const store = { getGame, replaceGameInstalledVersion };
+ const record = (installedVersion: string | null, title: string) => {
+ getGame.mockResolvedValue({ id: "g1", installedVersion });
+ return recordVersionFromCompletedDownload(store, "g1", title);
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ replaceGameInstalledVersion.mockResolvedValue(true);
+ });
+
+ it("records the version of a finished full-game download when none is known", async () => {
+ const recorded = await record(null, "Test.Game.v1.2.3-RUNE");
+ expect(recorded).toBe("v1.2.3");
+ expect(replaceGameInstalledVersion).toHaveBeenCalledWith("g1", null, "v1.2.3");
+ expect(notifyUser).toHaveBeenCalledWith("gameUpdated", "g1");
+ });
+
+ it("moves the version forward on a newer update", async () => {
+ await record("v1.2", "Test.Game.v1.2.Update.v1.3-RUNE");
+ expect(replaceGameInstalledVersion).toHaveBeenCalledWith("g1", "v1.2", "v1.3");
+ });
+
+ it("never moves the version backwards or across numbering schemes", async () => {
+ await record("v1.5", "Test.Game.Update.v1.3-RUNE");
+ await record("Build 1234", "Test.Game.v1.3-RUNE");
+ expect(replaceGameInstalledVersion).not.toHaveBeenCalled();
+ });
+
+ it("ignores DLC releases and releases without a version", async () => {
+ await record(null, "Test.Game.Season.Pass.DLC.v2.0-RUNE");
+ await record(null, "Test.Game-RUNE");
+ expect(replaceGameInstalledVersion).not.toHaveBeenCalled();
+ });
+
+ it("takes the version from full-game editions, even those bundling DLC", async () => {
+ expect(await record(null, "Test.Game.Deluxe.Edition.v1.4-RUNE")).toBe("v1.4");
+ expect(await record(null, "Test.Game.GOTY.v2.0.incl.DLC-GOG")).toBe("v2.0");
+ expect(await record(null, "Test.Game.Complete.Edition.v3.1-RUNE")).toBe("v3.1");
+ });
+
+ it("trusts the category the user picked over the title", async () => {
+ getGame.mockResolvedValue({ id: "g1", installedVersion: null });
+ // Claimed as DLC although the title reads like the base game.
+ expect(
+ await recordVersionFromCompletedDownload(store, "g1", "Expansion.Name.v5.0", "dlc")
+ ).toBeNull();
+ // Claimed as the main game although the title mentions DLC.
+ expect(
+ await recordVersionFromCompletedDownload(store, "g1", "Game.Season.Pass.DLC.v2.0", "main")
+ ).toBe("v2.0");
+ });
+
+ it("does not notify when the version changed before the write", async () => {
+ replaceGameInstalledVersion.mockResolvedValue(false);
+ expect(await record("v1.0", "Test.Game.Update.v1.1-RUNE")).toBeNull();
+ expect(notifyUser).not.toHaveBeenCalled();
+ });
+
+ it("compares again after losing the write to a concurrent older import", async () => {
+ // A v1.2 import wrote between this v1.3 import's read and its write.
+ getGame
+ .mockResolvedValueOnce({ id: "g1", installedVersion: "v1.0" })
+ .mockResolvedValueOnce({ id: "g1", installedVersion: "v1.2" });
+ replaceGameInstalledVersion.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
+ const recorded = await recordVersionFromCompletedDownload(store, "g1", "Game.Update.v1.3-RUNE");
+ expect(recorded).toBe("v1.3");
+ expect(replaceGameInstalledVersion).toHaveBeenLastCalledWith("g1", "v1.2", "v1.3");
+ expect(notifyUser).toHaveBeenCalledWith("gameUpdated", "g1");
+ });
+
+ it("stops when a concurrent import already wrote a newer version", async () => {
+ getGame
+ .mockResolvedValueOnce({ id: "g1", installedVersion: "v1.0" })
+ .mockResolvedValueOnce({ id: "g1", installedVersion: "v1.4" });
+ replaceGameInstalledVersion.mockResolvedValueOnce(false);
+ expect(
+ await recordVersionFromCompletedDownload(store, "g1", "Game.Update.v1.3-RUNE")
+ ).toBeNull();
+ expect(replaceGameInstalledVersion).toHaveBeenCalledTimes(1);
+ });
+
+ it("compares against the current row, not the caller's snapshot", async () => {
+ // The user moved to v2.0 while the v1.5 import was running.
+ expect(await record("v2.0", "Test.Game.v1.5-RUNE")).toBeNull();
+ expect(getGame).toHaveBeenCalledWith("g1");
+ expect(replaceGameInstalledVersion).not.toHaveBeenCalled();
+ });
+
+ it("swallows storage failures", async () => {
+ replaceGameInstalledVersion.mockRejectedValue(new Error("db down"));
+ await expect(record(null, "Test.Game.v1.0-RUNE")).resolves.toBeNull();
+ });
+});
diff --git a/server/__tests__/helpers/import-test-helpers.ts b/server/__tests__/helpers/import-test-helpers.ts
index d5f1184b2..1ae26c900 100644
--- a/server/__tests__/helpers/import-test-helpers.ts
+++ b/server/__tests__/helpers/import-test-helpers.ts
@@ -26,6 +26,7 @@ export function makeGame(overrides: Partial = {}): Game {
isAdultContent: false,
isAgeRestricted: false,
libraryPath: null,
+ installedVersion: null,
originalReleaseDate: null,
releaseStatus: null,
addedAt: null,
diff --git a/server/__tests__/import_manager.test.ts b/server/__tests__/import_manager.test.ts
index 1505e581b..fa95d12f9 100644
--- a/server/__tests__/import_manager.test.ts
+++ b/server/__tests__/import_manager.test.ts
@@ -36,6 +36,7 @@ describe("ImportManager", () => {
updateGameDownloadStatus: vi.fn(),
updateGameStatus: vi.fn(),
updateGame: vi.fn(),
+ replaceGameInstalledVersion: vi.fn(),
addNotification: vi.fn().mockResolvedValue(undefined),
getUserSettings: vi.fn().mockResolvedValue(undefined),
};
@@ -324,6 +325,42 @@ describe("ImportManager", () => {
);
});
+ it("records the release's version on the game once the import is finalized", async () => {
+ storage.getGameDownload.mockResolvedValue({
+ id: "dl-1",
+ gameId: "g1",
+ downloaderId: "d1",
+ downloadTitle: "My.Game.Update.v1.2-RUNE",
+ });
+ storage.getGame.mockResolvedValue({
+ id: "g1",
+ title: "My Game",
+ userId: "u1",
+ status: "owned",
+ platforms: [6],
+ installedVersion: "v1.1",
+ });
+ storage.getImportConfig.mockResolvedValue({ ...baseConfig, libraryRoot: "/safe/root" });
+
+ const manager = new ImportManager(
+ storage as never, // NOSONAR
+ pathService as never, // NOSONAR
+ platformService as never, // NOSONAR
+ archiveService as never // NOSONAR
+ );
+
+ await manager.confirmImport("dl-1", {
+ strategy: "pc",
+ originalPath: "/downloads/source-folder",
+ proposedPath: "/safe/root/PC/My Game",
+ needsReview: false,
+ transferMode: "move",
+ });
+
+ expect(storage.updateGameDownloadStatus).toHaveBeenCalledWith("dl-1", "imported");
+ expect(storage.replaceGameInstalledVersion).toHaveBeenCalledWith("g1", "v1.1", "v1.2");
+ });
+
it.each(["playing", "shelved", "completed"])(
"keeps a %s game's status when an import finishes for it",
async (status) => {
diff --git a/server/cron.ts b/server/cron.ts
index 00600deec..f1655e675 100644
--- a/server/cron.ts
+++ b/server/cron.ts
@@ -30,6 +30,8 @@ import {
type NotificationPreferences,
} from "../shared/schema.js";
import { categorizeDownload } from "../shared/download-categorizer.js";
+import { isReleasePossiblyNewer } from "../shared/version-utils.js";
+import { recordVersionFromCompletedDownload } from "./game-version.js";
import {
releaseMatchesGame,
normalizeTitle,
@@ -957,6 +959,13 @@ export async function checkDownloadStatus() {
} else {
// Update DB - mark as completed
await storage.updateGameDownloadStatus(download.id, "completed");
+ // With post-processing on, the import records it once the files are in place.
+ await recordVersionFromCompletedDownload(
+ storage,
+ download.gameId,
+ download.downloadTitle,
+ download.category
+ );
// Update Game status to 'owned' (which means we have the files), unless
// the user already moved it past that (e.g. an update for a game they're playing).
@@ -1442,6 +1451,7 @@ export async function checkAutoSearch() {
downloadTitle: item.title,
status: "downloading",
downloadType: item.downloadType,
+ category: "main",
});
// Update game status
@@ -1544,7 +1554,12 @@ export async function checkAutoSearch() {
preferredGroups,
settings.filterByPreferredGroups ?? false
);
- const updateItems = deduplicateByTitle(groupFilteredUpdate, indexerPriorityMap);
+ // Drop update releases whose version is provably not newer than the one the user
+ // has installed, so a game already on v1.5 isn't flagged for a v1.4 patch.
+ const versionFilteredUpdate = groupFilteredUpdate.filter((item) =>
+ isReleasePossiblyNewer(item.title, game.installedVersion)
+ );
+ const updateItems = deduplicateByTitle(versionFilteredUpdate, indexerPriorityMap);
// Packs/add-ons are content for owned games, surfaced like updates.
const platformFilteredPacks = applyPreferredPlatformFilter(
diff --git a/server/game-version.ts b/server/game-version.ts
new file mode 100644
index 000000000..c340d6da6
--- /dev/null
+++ b/server/game-version.ts
@@ -0,0 +1,67 @@
+import {
+ carriesBaseGameVersion,
+ compareVersions,
+ extractVersionFromReleaseName,
+} from "../shared/version-utils.js";
+import type { IStorage } from "./storage.js";
+import { logger } from "./logger.js";
+import { notifyUser } from "./socket.js";
+
+/**
+ * Records the version carried by an installed download's release name (e.g. "v1.2.3") as the
+ * game's installed version. Call it once the files are really in place: when the download
+ * finishes with post-processing off, or when the import is finalized. Only the full game
+ * (editions included) or an update counts (a DLC's version says nothing about the base game),
+ * and a known version is only ever moved forward: an older or incomparable release never
+ * overwrites what the user has. `category` is the one stored with the download, if any;
+ * otherwise it is inferred from the title.
+ * Never throws, so a failure here can't break the download or import flow. Returns the version
+ * recorded, if any.
+ */
+export async function recordVersionFromCompletedDownload(
+ store: Pick,
+ gameId: string,
+ downloadTitle: string,
+ category: string | null = null
+): Promise {
+ try {
+ if (!carriesBaseGameVersion(downloadTitle, category)) return null;
+
+ const detected = extractVersionFromReleaseName(downloadTitle);
+ if (!detected) return null;
+
+ // Read the current row: an import can run for minutes, during which the user may have
+ // edited the version or another import may have advanced it. The write only lands if the
+ // version is still the one just compared against; if something else wrote first, compare
+ // again against what it wrote, so a concurrent older import can't hold back a newer one.
+ let game: Awaited>;
+ for (let attempt = 0; ; attempt++) {
+ game = await store.getGame(gameId);
+ if (!game) return null;
+ if (game.installedVersion?.trim()) {
+ const cmp = compareVersions(detected, game.installedVersion);
+ if (cmp === null || cmp <= 0) return null;
+ }
+ const written = await store.replaceGameInstalledVersion(
+ game.id,
+ game.installedVersion ?? null,
+ detected
+ );
+ if (written) break;
+ if (attempt >= 2) return null;
+ }
+ logger.info(
+ { gameId: game.id, previous: game.installedVersion, installedVersion: detected },
+ "Recorded game version from completed download"
+ );
+ // Lets an open game details modal refresh the games query, which is otherwise never stale.
+ notifyUser("gameUpdated", game.id);
+ return detected;
+ } catch (error) {
+ logger.warn(
+ { error, gameId, item: downloadTitle },
+ "Failed to record game version from completed download"
+ );
+ return null;
+ }
+}
diff --git a/server/middleware.ts b/server/middleware.ts
index 44c369e05..2fa88eb59 100644
--- a/server/middleware.ts
+++ b/server/middleware.ts
@@ -500,6 +500,10 @@ export const sanitizeDownloaderDownloadData = [
.trim()
.isLength({ max: 100 })
.withMessage("Category must be at most 100 characters"),
+ body("releaseCategory")
+ .optional()
+ .isIn(["main", "update", "dlc", "extra", "packs"])
+ .withMessage("Invalid release category"),
body("downloadType")
.optional()
.trim()
diff --git a/server/routes.ts b/server/routes.ts
index eeacb8c38..320c951b3 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -13,6 +13,7 @@ import {
updateGameStatusSchema,
updateGameHiddenSchema,
updateGameUserRatingSchema,
+ updateGameInstalledVersionSchema,
updateGameTargetPlatformSchema,
insertIndexerSchema,
insertDownloaderSchema,
@@ -532,6 +533,34 @@ function respondWithZodError(res: Response, error: z.ZodError, message: string):
return res.status(400).json({ error: message, details: error.issues });
}
+/**
+ * Builds a handler for a PATCH that sets one per-user field on a game: parses the body with
+ * `schema`, applies it through `update` (scoped to the authenticated user) and returns the
+ * updated game, or 404 when the game isn't the user's.
+ */
+function patchOwnGameField(
+ schema: z.ZodType,
+ update: (id: string, userId: string, body: T) => Promise,
+ labels: { invalid: string; log: string; failure: string }
+) {
+ return async (req: Request, res: Response) => {
+ try {
+ const { id } = req.params as { id: string };
+ const updatedGame = await update(id, req.user!.id, schema.parse(req.body));
+ if (!updatedGame) {
+ return res.status(404).json({ error: "Game not found" });
+ }
+ return res.json(updatedGame);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return respondWithZodError(res, error, labels.invalid);
+ }
+ routesLogger.error({ error }, labels.log);
+ return res.status(500).json({ error: labels.failure });
+ }
+ };
+}
+
// Helper to parse category query param which might be string, array, or comma-separated
export function parseCategories(input: unknown): string[] | undefined {
if (!input) return undefined;
@@ -1840,26 +1869,38 @@ export async function registerRoutes(app: Express): Promise {
sensitiveEndpointLimiter,
sanitizeGameId,
validateRequest,
- async (req: Request, res: Response) => {
- try {
- const { id } = req.params as { id: string };
- const userId = req.user!.id;
- const { userRating } = updateGameUserRatingSchema.parse(req.body);
-
- const updatedGame = await storage.updateGameUserRating(id, userId, userRating);
- if (!updatedGame) {
- return res.status(404).json({ error: "Game not found" });
- }
+ patchOwnGameField(
+ updateGameUserRatingSchema,
+ (id, userId, { userRating }) => storage.updateGameUserRating(id, userId, userRating),
+ {
+ invalid: "Invalid user rating data",
+ log: "error updating game user rating",
+ failure: "Failed to update user rating",
+ }
+ )
+ );
- return res.json(updatedGame);
- } catch (error) {
- if (error instanceof z.ZodError) {
- return respondWithZodError(res, error, "Invalid user rating data");
- }
- routesLogger.error({ error }, "error updating game user rating");
- return res.status(500).json({ error: "Failed to update user rating" });
+ // Set the version of the game the user has installed (free text), or null to clear it.
+ // Used to skip "update available" notifications for releases that aren't newer.
+ app.patch(
+ "/api/games/:id/installed-version",
+ sensitiveEndpointLimiter,
+ sanitizeGameId,
+ validateRequest,
+ patchOwnGameField(
+ updateGameInstalledVersionSchema,
+ async (id, userId, { installedVersion }) => {
+ const game = await storage.updateGameInstalledVersion(id, userId, installedVersion);
+ // Lets the user's other tabs refresh their games query, which is otherwise never stale.
+ if (game) (await import("./socket.js")).notifyUser("gameUpdated", game.id);
+ return game;
+ },
+ {
+ invalid: "Invalid installed version data",
+ log: "error updating game installed version",
+ failure: "Failed to update installed version",
}
- }
+ )
);
// Update the per-game download target, or clear it to use the account default.
@@ -4066,6 +4107,7 @@ export async function registerRoutes(app: Express): Promise {
downloadTitle,
downloadType: isUsenetDownloaderType(downloader.type) ? "usenet" : "torrent",
status: downloadStatus,
+ category,
})
);
@@ -4234,6 +4276,7 @@ export async function registerRoutes(app: Express): Promise {
downloadTitle: item.downloadTitle,
downloadType: isUsenetDownloaderType(downloader.type) ? "usenet" : "torrent",
status: downloadStatus,
+ category: item.category,
})
);
await storage.updateGameSearchResultsAvailable(resolvedGameId, false);
@@ -4312,8 +4355,17 @@ export async function registerRoutes(app: Express): Promise {
validateRequest,
async (req: Request, res: Response) => {
try {
- const { url, title, category, downloadPath, priority, gameId, downloadType, password } =
- req.body;
+ const {
+ url,
+ title,
+ category,
+ downloadPath,
+ priority,
+ gameId,
+ downloadType,
+ password,
+ releaseCategory,
+ } = req.body;
if (!url || !title) {
return res.status(400).json({ error: "URL and title are required" });
@@ -4372,6 +4424,7 @@ export async function registerRoutes(app: Express): Promise {
downloadTitle: title,
status: "downloading",
downloadType: downloadType || "torrent",
+ category: releaseCategory ?? null,
});
// An update/DLC grabbed for a game the user is playing (or has
diff --git a/server/services/ImportManager.ts b/server/services/ImportManager.ts
index cbbe857d3..dea840d9d 100644
--- a/server/services/ImportManager.ts
+++ b/server/services/ImportManager.ts
@@ -26,6 +26,7 @@ import { logger } from "../logger.js";
import { extractHostnameFromUrl } from "../url-utils.js";
import { isSensitivePath, assertWithinRoots } from "../path-security.js";
import { notifyUser } from "../socket.js";
+import { recordVersionFromCompletedDownload } from "../game-version.js";
import { resolvePrefs } from "../notification-prefs.js";
import { appriseClient } from "../apprise.js";
import { type SecurityScanService, type ScanResult } from "../security-scan.js";
@@ -539,11 +540,32 @@ export class ImportManager {
): Promise {
await this.storage.updateGameDownloadStatus(downloadId, "imported");
await this.storage.updateGame(game.id, { libraryPath });
+ await this.recordInstalledVersion(downloadId, game);
if (game.status !== "owned" && !isUserCuratedGameStatus(game.status)) {
await this.storage.updateGameStatus(game.id, { status: "owned" }, { preserveCurated: true });
}
}
+ /** Best-effort: a version that can't be recorded must not fail an import that succeeded. */
+ private async recordInstalledVersion(
+ downloadId: string,
+ game: NonNullable>>
+ ): Promise {
+ try {
+ const download = await this.storage.getGameDownload(downloadId);
+ if (download) {
+ await recordVersionFromCompletedDownload(
+ this.storage,
+ game.id,
+ download.downloadTitle,
+ download.category
+ );
+ }
+ } catch (error) {
+ logger.warn({ error, downloadId }, "[ImportManager] Could not record the installed version");
+ }
+ }
+
private async verifyLocalPath(
downloadId: string,
localPath: string,
diff --git a/server/storage.ts b/server/storage.ts
index 3f544ddad..317a51ea9 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -243,6 +243,20 @@ export interface IStorage {
userId: string,
userRating: number | null
): Promise;
+ updateGameInstalledVersion(
+ id: string,
+ userId: string,
+ installedVersion: string | null
+ ): Promise;
+ /**
+ * Sets a game's installed version only if it still equals `expected` (null = not set), so a
+ * detected version can't overwrite one written in between. Returns whether it was written.
+ */
+ replaceGameInstalledVersion(
+ id: string,
+ expected: string | null,
+ installedVersion: string
+ ): Promise;
updateGameSearchResultsAvailable(gameId: string, available: boolean): Promise;
updateGameSearchResultsByCategory(
gameId: string,
@@ -675,6 +689,7 @@ export class MemStorage implements IStorage {
packsSearchResultsAvailable: false,
userRating: null,
libraryPath: null,
+ installedVersion: null,
addedAt: new Date(),
completedAt: null,
};
@@ -736,6 +751,30 @@ export class MemStorage implements IStorage {
return updatedGame;
}
+ updateGameInstalledVersion(
+ id: string,
+ userId: string,
+ installedVersion: string | null
+ ): Promise {
+ const game = this.games.get(id);
+ if (game?.userId !== userId) return Promise.resolve(undefined);
+
+ const updatedGame: Game = { ...game, installedVersion };
+ this.games.set(id, updatedGame);
+ return Promise.resolve(updatedGame);
+ }
+
+ replaceGameInstalledVersion(
+ id: string,
+ expected: string | null,
+ installedVersion: string
+ ): Promise {
+ const game = this.games.get(id);
+ if (!game || (game.installedVersion ?? null) !== expected) return Promise.resolve(false);
+ this.games.set(id, { ...game, installedVersion });
+ return Promise.resolve(true);
+ }
+
async getGameJournalEntries(gameId: string, userId: string): Promise {
return Array.from(this.gameJournalEntries.values())
.filter((entry) => entry.gameId === gameId && entry.userId === userId)
@@ -1238,6 +1277,7 @@ export class MemStorage implements IStorage {
downloadType: insertGameDownload.downloadType || "torrent",
errorMessage: insertGameDownload.errorMessage ?? null,
fileSize: insertGameDownload.fileSize ?? null,
+ category: insertGameDownload.category ?? null,
addedAt: new Date(),
completedAt: null,
};
@@ -2322,6 +2362,33 @@ export class DatabaseStorage implements IStorage {
return updatedGame || undefined;
}
+ async updateGameInstalledVersion(
+ id: string,
+ userId: string,
+ installedVersion: string | null
+ ): Promise {
+ const [updatedGame] = await db
+ .update(games)
+ .set({ installedVersion })
+ .where(and(eq(games.id, id), eq(games.userId, userId)))
+ .returning();
+ return updatedGame || undefined;
+ }
+
+ async replaceGameInstalledVersion(
+ id: string,
+ expected: string | null,
+ installedVersion: string
+ ): Promise {
+ const current =
+ expected === null ? isNull(games.installedVersion) : eq(games.installedVersion, expected);
+ const result = await db
+ .update(games)
+ .set({ installedVersion })
+ .where(and(eq(games.id, id), current));
+ return affectedRows(result) > 0;
+ }
+
async getGameJournalEntries(gameId: string, userId: string): Promise {
return db
.select()
@@ -2759,6 +2826,7 @@ export class DatabaseStorage implements IStorage {
status: gameDownloads.status,
errorMessage: gameDownloads.errorMessage,
fileSize: gameDownloads.fileSize,
+ category: gameDownloads.category,
addedAt: gameDownloads.addedAt,
completedAt: gameDownloads.completedAt,
downloaderName: downloaders.name,
diff --git a/shared/__tests__/version-utils.test.ts b/shared/__tests__/version-utils.test.ts
new file mode 100644
index 000000000..74a700b23
--- /dev/null
+++ b/shared/__tests__/version-utils.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect } from "vitest";
+import {
+ compareVersions,
+ extractVersionFromReleaseName,
+ carriesBaseGameVersion,
+ inferReleaseCategory,
+ isReleasePossiblyNewer,
+ parseVersion,
+} from "../version-utils";
+
+describe("extractVersionFromReleaseName", () => {
+ it.each([
+ ["Cyberpunk.2077.v2.12-GOG", "v2.12"],
+ ["Hades.II.v123456-RUNE", "v123456"],
+ ["setup_baldurs_gate_3_v4.1.1.6072089_(64bit)_(74309).exe", "v4.1.1.6072089"],
+ ["Elden Ring [v 1.10] [FitGirl Repack]", "v1.10"],
+ ["Starfield.Update.1.7.23-RUNE", "v1.7.23"],
+ ["Starfield.Update.v1.7.29-RUNE", "v1.7.29"],
+ ["Valheim.Build.12345678-P2P", "Build 12345678"],
+ ["Game build_998 Linux", "Build 998"],
+ ["Game.v1.0.Update.v1.1-RUNE", "v1.1"], // the update's target wins
+ ["Game.v1.0.Update.v2-RUNE", "v2"],
+ ["Game.v1.0.to.v1.1.Patch-RUNE", "v1.1"], // highest of several
+ ["Game.v1.0.to.1.1.Patch-RUNE", "v1.1"], // the target without "v"
+ ["Game.v1.4.Update.Build.5000-RUNE", "Build 5000"], // the update's build target wins
+ ["Game.Update.v1.0.to.v1.1-RUNE", "v1.1"], // an update's target, not its start
+ ["Game.Update.1.0.to.1.1-RUNE", "v1.1"], // the same without "v"
+ ["Game.Update.v1.0.to.1.1-RUNE", "v1.1"], // or with only one end prefixed
+ ["Game.Update.v1.10.20-RUNE", "v1.10.20"], // not the tail "10.20"
+ ["Game.Update.v1.2.Win.11.0-RUNE", "v1.2"], // a number outside a range is not a target
+ ["Game.Update.Build.1000.to.Build.1200-RUNE", "Build 1200"], // same for build ranges
+ ["Game.Patch.Build.1000.to.Build.1200-RUNE", "Build 1200"], // without the word Update too
+ ["Game.Update.Build.1000.to.1200-RUNE", "Build 1200"], // a bare build target
+ ["Game.Build.1000.to.1200.Patch-RUNE", "Build 1200"],
+ ["Game.Patch.v1.2-RUNE", "v1.2"], // "Patch" names its target like "Update"
+ ["Game.Patch.1.2-RUNE", "v1.2"],
+ ["Game.v1.2.Repack-PREDATOR", "v1.2"], // a group name is not a pre-release tag
+ ["Game.Crackfix.v1.2-RUNE", "v1.2"], // "fix" inside a word is no qualifier
+ ])("finds the version in %s", (name, expected) => {
+ expect(extractVersionFromReleaseName(name)).toBe(expected);
+ });
+
+ it.each([
+ "Test.Game-RUNE",
+ "Some.Game.Update.2-CODEX", // second update pack, not a version
+ "Game.x64v2.Edition", // "v2" glued to a word
+ "Game.dev1.2.Repack",
+ "Game.Update.v1.2-beta-RUNE", // pre-releases: a stable v1.2 would supersede it
+ "Game.v2.0.RC1-GOG",
+ "Game v1.3 Preview",
+ "Game.v1.2.Early.Access-P2P", // qualifiers that order differently from the bare number
+ "Game.Update.v1.2.Hotfix-RUNE",
+ "Game.v1.2.Fix-RUNE",
+ "Game.Hotfix.v1.2-RUNE", // the qualifier can come first too
+ "Game.Update.v1.2b-RUNE", // a letter-suffixed version is not cut short to v1
+ "Game.v1.2b-RUNE",
+ "Game.Beta.v1.2-RUNE",
+ "Game.Early.Access.v1.2-RUNE",
+ "Game.v1.2.Update-RUNE", // an update whose target isn't named
+ "Game.v1.2.Update.2-CODEX",
+ "Game.v1.2.Patch-RUNE",
+ ])("finds nothing in %s", (name) => {
+ expect(extractVersionFromReleaseName(name)).toBeNull();
+ });
+});
+
+describe("parseVersion", () => {
+ it("accepts user-typed forms", () => {
+ expect(parseVersion("1.2.3")).toEqual({ kind: "version", parts: [1, 2, 3] });
+ expect(parseVersion(" V1.05 ")).toEqual({ kind: "version", parts: [1, 5] });
+ expect(parseVersion("Build 42")).toEqual({ kind: "build", parts: [42] });
+ });
+
+ it("rejects free text", () => {
+ expect(parseVersion("latest")).toBeNull();
+ expect(parseVersion("1.2 hotfix")).toBeNull();
+ expect(parseVersion("")).toBeNull();
+ expect(parseVersion(null)).toBeNull();
+ });
+});
+
+describe("compareVersions", () => {
+ it("compares dotted versions segment by segment", () => {
+ expect(compareVersions("v1.10", "v1.9")).toBe(1);
+ expect(compareVersions("1.2", "v1.2.0")).toBe(0);
+ expect(compareVersions("v1.2", "1.2.1")).toBe(-1);
+ });
+
+ it("compares build numbers and bare numbers", () => {
+ expect(compareVersions("Build 200", "build 100")).toBe(1);
+ expect(compareVersions("v123457", "v123456")).toBe(1);
+ });
+
+ it("returns null across numbering schemes", () => {
+ expect(compareVersions("Build 200", "v1.2")).toBeNull();
+ expect(compareVersions("v20231005", "v1.2")).toBeNull();
+ expect(compareVersions("latest", "v1.2")).toBeNull();
+ });
+});
+
+describe("inferReleaseCategory", () => {
+ it("files full-game editions as the main game", () => {
+ expect(inferReleaseCategory("The.Witcher.3.Complete.Edition.v4.04-GOG")).toBe("main");
+ expect(inferReleaseCategory("Game.GOTY.v2.0.incl.DLC-GOG")).toBe("main");
+ });
+
+ it("keeps real DLC and a confident AI classification", () => {
+ expect(inferReleaseCategory("Game.Season.Pass.DLC-RUNE")).toBe("dlc");
+ expect(inferReleaseCategory("Game_DLC_v5.0")).toBe("dlc"); // "_" separates words too
+ expect(inferReleaseCategory("Game.Season.Pass.v5.0")).toBe("dlc");
+ expect(inferReleaseCategory("Game.Downloadable.Content.v5.0")).toBe("dlc");
+ expect(inferReleaseCategory("Game.Add-On.v5.0")).toBe("packs");
+ expect(inferReleaseCategory("Expansion.Name.v5.0", "dlc", 0.95)).toBe("dlc");
+ expect(inferReleaseCategory("Game.Complete.Edition-GOG", "dlc", 0.95)).toBe("dlc");
+ });
+});
+
+describe("carriesBaseGameVersion", () => {
+ it("follows the stored category when there is one", () => {
+ expect(carriesBaseGameVersion("Expansion.Name.v5.0", "dlc")).toBe(false);
+ expect(carriesBaseGameVersion("Game.Season.Pass.DLC.v2.0", "update")).toBe(true);
+ });
+
+ it("reads the title otherwise, counting editions as the full game", () => {
+ expect(carriesBaseGameVersion("Game.v1.2-RUNE", null)).toBe(true);
+ expect(carriesBaseGameVersion("Game.GOTY.v2.0.incl.DLC-GOG", null)).toBe(true);
+ expect(carriesBaseGameVersion("Game.Season.Pass.DLC.v5.0-RUNE", null)).toBe(false);
+ expect(carriesBaseGameVersion("Game.OST-RUNE", undefined)).toBe(false);
+ });
+});
+
+describe("isReleasePossiblyNewer", () => {
+ it("is true when nothing is known", () => {
+ expect(isReleasePossiblyNewer("Game.Update.v1.1-RUNE", null)).toBe(true);
+ expect(isReleasePossiblyNewer("Game.Update-RUNE", "v1.2")).toBe(true);
+ expect(isReleasePossiblyNewer("Game.Update.v1.2b-RUNE", "v1")).toBe(true);
+ expect(isReleasePossiblyNewer("Game.Update.v1.1-RUNE", "latest")).toBe(true);
+ });
+
+ it("is false for an equal or older release", () => {
+ expect(isReleasePossiblyNewer("Game.Update.v1.1-RUNE", "v1.1")).toBe(false);
+ expect(isReleasePossiblyNewer("Game.Update.v1.1-RUNE", "1.2")).toBe(false);
+ });
+
+ it("is true for a newer release", () => {
+ expect(isReleasePossiblyNewer("Game.Update.v1.3-RUNE", "1.2")).toBe(true);
+ });
+});
diff --git a/shared/schema.pg.ts b/shared/schema.pg.ts
index c3b2a8df1..30356af6f 100644
--- a/shared/schema.pg.ts
+++ b/shared/schema.pg.ts
@@ -171,6 +171,7 @@ export const games = pgTable("games", {
isAgeRestricted: boolean("is_age_restricted").notNull().default(false),
userRating: doublePrecision("user_rating"),
libraryPath: text("library_path"),
+ installedVersion: text("installed_version"),
searchResultsAvailable: boolean("search_results_available").default(false).notNull(),
searchResultsAvailableAt: timestampMs("search_results_available_at"),
updateSearchResultsAvailable: boolean("update_search_results_available").default(false).notNull(),
@@ -250,6 +251,7 @@ export const gameDownloads = pgTable(
status: text("status").notNull().default("downloading"),
errorMessage: text("error_message"),
fileSize: bigint("file_size", { mode: "number" }),
+ category: text("category"),
addedAt: timestampMs("added_at").default(sql`(EXTRACT(EPOCH FROM now()) * 1000)::bigint`),
completedAt: timestampMs("completed_at"),
},
diff --git a/shared/schema.ts b/shared/schema.ts
index c6f68456f..ef2ea469d 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -224,6 +224,7 @@ export const games = sqliteTable("games", {
isAgeRestricted: integer("is_age_restricted", { mode: "boolean" }).notNull().default(false),
userRating: real("user_rating"),
libraryPath: text("library_path"),
+ installedVersion: text("installed_version"),
searchResultsAvailable: integer("search_results_available", { mode: "boolean" })
.default(false)
.notNull(),
@@ -322,6 +323,9 @@ export const gameDownloads = sqliteTable(
status: text("status").notNull().default("downloading"),
errorMessage: text("error_message"),
fileSize: integer("file_size"),
+ // The category the user picked when claiming the download (main, update, dlc...); null when
+ // it was only ever inferred from the title.
+ category: text("category"),
addedAt: integer("added_at", { mode: "timestamp_ms" }).default(
sql`(strftime('%s', 'now') * 1000)`
),
@@ -583,6 +587,16 @@ export const updateGameUserRatingSchema = z.object({
.nullable(),
});
+export const updateGameInstalledVersionSchema = z.object({
+ // Free text ("v1.2.3", "Build 12345", "1.05 hotfix"...); blank clears it.
+ installedVersion: z
+ .string()
+ .trim()
+ .max(64, "installedVersion must be at most 64 characters")
+ .nullable()
+ .transform((v) => v || null),
+});
+
export const insertGameJournalEntrySchema = createInsertSchema(gameJournalEntries, {
note: (schema) => schema.trim().min(1, "Note cannot be empty").max(10000),
}).omit({
diff --git a/shared/version-utils.ts b/shared/version-utils.ts
new file mode 100644
index 000000000..b9764609e
--- /dev/null
+++ b/shared/version-utils.ts
@@ -0,0 +1,244 @@
+import { categorizeDownload, type DownloadCategory } from "./download-categorizer.js";
+import type { ReleaseType } from "./typesafe-types.js";
+
+/**
+ * Game version helpers: detect a version in a release name, and compare two versions.
+ *
+ * Versions are free text on the game (users can type anything), so every function here is
+ * conservative: when two versions can't be meaningfully compared, the answer is "unknown"
+ * (null) rather than a guess, and callers treat unknown as "might be newer".
+ */
+
+export type ParsedVersionKind = "version" | "build";
+
+export interface ParsedVersion {
+ kind: ParsedVersionKind;
+ parts: number[];
+}
+
+// A version token must not be glued to a preceding letter/digit ("dev1.2", "x64v2"), but `_`,
+// `.`, `-`, `(`, `[` and spaces are all used as separators in release names.
+const NOT_PRECEDED_BY_ALNUM = "(?": otherwise "v1.2b" would
+// backtrack to "v1" (the "." after it satisfies the boundary). Such a version reads as none.
+const VERSION_END = String.raw`(?![a-z0-9]|\.\d)`;
+
+// "v1.2.3", "v123456", "v1.0.2.34567", "v 1.05"
+const V_VERSION = new RegExp(
+ String.raw`${NOT_PRECEDED_BY_ALNUM}v\s?(\d+(?:\.\d+)*)${VERSION_END}`,
+ "i"
+);
+const V_VERSION_ALL = new RegExp(V_VERSION.source, "gi");
+// "Build 12345", "Build.12345", "build_12345"
+const BUILD_VERSION = new RegExp(
+ String.raw`${NOT_PRECEDED_BY_ALNUM}build[\s._-]?(\d+)${NOT_FOLLOWED_BY_ALNUM}`,
+ "i"
+);
+// "Update.v2", "Update.1.05", "Patch 2.1.3" -- a version right after "Update" or "Patch". Without a "v" it
+// needs a dot, so "Update 2" (the second update pack) isn't read as version 2.
+const UPDATE_VERSION = new RegExp(
+ String.raw`${NOT_PRECEDED_BY_ALNUM}(?:update|patch)[\s._-](?:v\s?(\d+(?:\.\d+)*)|(\d+(?:\.\d+)+))${VERSION_END}`,
+ "i"
+);
+// "Update.Build.5000" -- an update whose target is a build number.
+const UPDATE_BUILD_VERSION = new RegExp(
+ String.raw`${NOT_PRECEDED_BY_ALNUM}(?:update|patch)[\s._-]build[\s._-]?(\d+)${NOT_FOLLOWED_BY_ALNUM}`,
+ "i"
+);
+
+// "v1.2-beta", "1.2.RC1", "v2.0 Preview", "v1.2.Early.Access", "v1.2.Hotfix": a qualifier whose
+// ordering against the bare number ("v1.2") isn't captured by it -- a later stable release or a
+// hotfix of the same number differs. Names carrying one are treated as having no readable version.
+// The qualifier can also come first ("Game.Hotfix.v1.2", "Game.Early.Access.v1.2").
+const QUALIFIER = String.raw`(?:alpha|beta|rc|pre|preview|dev|early[\s._-]?access|hot[\s._-]?fix|fix)`;
+const QUALIFIED_VERSION = new RegExp(
+ String.raw`\d[\s._-]?${QUALIFIER}(?![a-z])|(? 0) {
+ return highestRangeTarget(releaseName, highestVVersion(releaseName, null));
+ }
+ if (countMatches(releaseName, V_VERSION_ALL) > 1) return highestVVersion(releaseName, null);
+ if (buildNumbers(releaseName).length > 1) return highestBuild(releaseName);
+ return null;
+ }
+ // Otherwise the highest of the versions named, then of the builds.
+ return highestVVersion(releaseName, null) ?? highestBuild(releaseName);
+}
+
+function countMatches(text: string, pattern: RegExp): number {
+ return Array.from(text.matchAll(pattern)).length;
+}
+
+function highestVVersion(text: string, initial: string | null): string | null {
+ let best = initial;
+ for (const match of text.matchAll(V_VERSION_ALL)) {
+ const candidate = `v${match[1]}`;
+ if (best === null || (compareVersions(candidate, best) ?? 0) > 0) best = candidate;
+ }
+ return best;
+}
+
+function highestRangeTarget(text: string, initial: string | null): string | null {
+ let best = initial;
+ for (const match of text.matchAll(RANGE_TARGET_ALL)) {
+ const candidate = `v${match[1] ?? match[2]}`;
+ if (best === null || (compareVersions(candidate, best) ?? 0) > 0) best = candidate;
+ }
+ return best;
+}
+
+// Builds named in `text`, plus, once a build is named, a bare target after "to"
+// ("Build.1000.to.1200").
+function buildNumbers(text: string): number[] {
+ const builds = Array.from(text.matchAll(BUILD_VERSION_ALL), (match) => Number(match[1]));
+ if (builds.length === 0) return builds;
+ return builds.concat(Array.from(text.matchAll(BUILD_RANGE_TARGET_ALL), (m) => Number(m[1])));
+}
+
+function highestBuild(text: string): string | null {
+ const builds = buildNumbers(text);
+ return builds.length === 0 ? null : `Build ${Math.max(...builds)}`;
+}
+
+/**
+ * Parses a version as typed by a user or produced by extractVersionFromReleaseName:
+ * "1.2.3", "v1.2.3", "V 1.05", "Build 12345". Anything else returns null.
+ */
+export function parseVersion(input: string | null | undefined): ParsedVersion | null {
+ const trimmed = input?.trim();
+ if (!trimmed) return null;
+ const build = /^build[\s._-]?(\d+)$/i.exec(trimmed);
+ if (build?.[1]) return { kind: "build", parts: [Number(build[1])] };
+ const version = /^v?\s?(\d+(?:\.\d+)*)$/i.exec(trimmed);
+ if (version?.[1]) return { kind: "version", parts: version[1].split(".").map(Number) };
+ return null;
+}
+
+/**
+ * Compares two versions: positive when `a` is newer, negative when older, 0 when equal, and
+ * null when they can't be compared (unparseable, a build number against a dotted version, or a
+ * bare number like "v20231005" against a dotted "v1.2" -- those are different numbering schemes).
+ */
+export function compareVersions(
+ a: string | null | undefined,
+ b: string | null | undefined
+): number | null {
+ const pa = parseVersion(a);
+ const pb = parseVersion(b);
+ if (!pa || !pb || pa.kind !== pb.kind) return null;
+ if (pa.kind === "version" && (pa.parts.length === 1) !== (pb.parts.length === 1)) return null;
+
+ const length = Math.max(pa.parts.length, pb.parts.length);
+ for (let i = 0; i < length; i++) {
+ const diff = (pa.parts[i] ?? 0) - (pb.parts[i] ?? 0);
+ if (diff !== 0) return diff > 0 ? 1 : -1;
+ }
+ return 0;
+}
+
+/**
+ * Whether a release may hold something newer than the version the user has installed. True
+ * unless the release name carries a version that is provably equal to or older than it.
+ */
+export function isReleasePossiblyNewer(
+ releaseName: string,
+ installedVersion: string | null | undefined
+): boolean {
+ if (!parseVersion(installedVersion)) return true;
+ const releaseVersion = extractVersionFromReleaseName(releaseName);
+ if (!releaseVersion) return true;
+ const cmp = compareVersions(releaseVersion, installedVersion);
+ return cmp === null || cmp > 0;
+}
+
+// The categorizer files edition names ("Deluxe", "GOTY", "Complete") under DLC because those
+// releases bundle DLC, but they are full games and their version is the base game's. Only these
+// words mark a release that is DLC alone, once bundle mentions ("incl.DLC", "+ all DLCs") are
+// set aside.
+const DLC_ONLY = /\b(?:dlc|downloadable content|expansion|season pass)\b/i;
+const BUNDLED_DLC = /(?:\bincl(?:uding)?|\bwith|\+)[\s._-]*(?:all[\s._-]*)?dlcs?\b/gi;
+
+/**
+ * The category to store with a download grabbed from search results: the categorizer's (title,
+ * or AI classification when more confident), except that a title filed under DLC only for its
+ * edition words ("Complete.Edition", "GOTY.incl.DLC") is the full game.
+ */
+export function inferReleaseCategory(
+ downloadTitle: string,
+ aiReleaseType?: ReleaseType | null,
+ aiReleaseTypeConfidence?: number | null
+): DownloadCategory {
+ // The categorizer matches words, some multiword ("season pass"), which release separators
+ // hide ("Game_DLC_v5.0", "Game.Season.Pass.v5.0"); a spaced title is tried when the raw one
+ // reads as the full game, so markers spelled with a separator ("Add-On") still match.
+ const spaced = downloadTitle.replace(/[._-]+/g, " ");
+ const title = categorizeDownload(downloadTitle).category === "main" ? spaced : downloadTitle;
+ const byTitle = categorizeDownload(title);
+ const result = categorizeDownload(title, aiReleaseType, aiReleaseTypeConfidence);
+ const titleDecided =
+ result.category === byTitle.category && result.confidence === byTitle.confidence;
+ if (
+ titleDecided &&
+ result.category === "dlc" &&
+ !DLC_ONLY.test(spaced.replace(BUNDLED_DLC, ""))
+ ) {
+ return "main";
+ }
+ return result.category;
+}
+
+/**
+ * Whether a download's version is the base game's: true for the full game (editions included)
+ * and updates, false for DLC, packs and extras. `category` is the one stored with the download
+ * (picked by the user, or inferred when it was grabbed); without it, the title decides.
+ */
+export function carriesBaseGameVersion(
+ downloadTitle: string,
+ category: string | null | undefined
+): boolean {
+ const effective = category || inferReleaseCategory(downloadTitle);
+ return effective === "main" || effective === "update";
+}