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 ( +
+ +
+ 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) => ( + + ))} +
+ )} +

+ 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"; +}