diff --git a/client/__tests__/AddGameModal.test.tsx b/client/__tests__/AddGameModal.test.tsx index 2d413f2d9..bdcdb10de 100644 --- a/client/__tests__/AddGameModal.test.tsx +++ b/client/__tests__/AddGameModal.test.tsx @@ -25,6 +25,8 @@ vi.mock("lucide-react", () => ({ Calendar: () =>
, Loader2: () =>
, Check: () =>
, + ChevronDown: () =>
, + ChevronUp: () =>
, X: () =>
, })); @@ -145,7 +147,7 @@ describe("AddGameModal", () => { // The effect runs on `open` change; simulate by typing and then looking for clear. const input = screen.getByDisplayValue("Hollow Knight"); fireEvent.change(input, { target: { value: "" } }); - expect(screen.getByDisplayValue("")).toBeInTheDocument(); + expect(screen.getByLabelText("Search games")).toHaveValue(""); }); it("shows Calendar icon for search results with a release date", async () => { @@ -195,6 +197,90 @@ describe("AddGameModal", () => { }); }); + it("requests at most 10 games and forwards a valid release year filter", async () => { + const searchResults = Array.from({ length: 12 }, (_, index) => ({ + ...makeSearchResult(`God of War ${index + 1}`, "2005-03-22"), + id: `igdb-${index + 1}`, + igdbId: 100 + index, + })); + setupFetch({ searchResults }); + renderModal({ initialQuery: "God of War" }); + fireEvent.click(screen.getByTestId("open-btn")); + + const yearInput = await screen.findByLabelText("Release year filter"); + fireEvent.change(yearInput, { target: { value: "2005" } }); + + await waitFor(() => { + const searchCalls = vi + .mocked(globalThis.fetch) + .mock.calls.filter(([url]) => String(url).includes("/api/igdb/search")); + expect( + searchCalls.some(([url]) => { + const params = new URL(String(url), "http://localhost").searchParams; + return params.get("limit") === "10" && params.get("year") === "2005"; + }) + ).toBe(true); + }); + + await waitFor(() => { + expect(screen.getAllByTestId(/^search-result-igdb-/)).toHaveLength(10); + }); + expect(screen.queryByText("God of War 11")).not.toBeInTheDocument(); + }); + + it("does not search while a nonempty release year is incomplete", async () => { + setupFetch({ searchResults: [makeSearchResult("God of War", "2005-03-22")] }); + renderModal({ initialQuery: "God of War" }); + fireEvent.click(screen.getByTestId("open-btn")); + + await screen.findByText("God of War"); + const searchCallsBefore = vi + .mocked(globalThis.fetch) + .mock.calls.filter(([url]) => String(url).includes("/api/igdb/search")).length; + + fireEvent.change(screen.getByLabelText("Release year filter"), { + target: { value: "20" }, + }); + + await waitFor(() => { + expect(screen.queryByText("God of War")).not.toBeInTheDocument(); + }); + const searchCallsAfter = vi + .mocked(globalThis.fetch) + .mock.calls.filter(([url]) => String(url).includes("/api/igdb/search")).length; + expect(searchCallsAfter).toBe(searchCallsBefore); + }); + + it("seeds a single discovered platform and persists its IGDB pair", async () => { + setupFetch({ + searchResults: [ + { + ...makeSearchResult("God of War", "2005-03-22"), + platforms: ["PlayStation 2"], + platformOptions: [{ id: 8, name: "PlayStation 2" }], + }, + ], + }); + renderModal({ initialQuery: "God of War" }); + fireEvent.click(screen.getByTestId("open-btn")); + + expect(await screen.findByLabelText("Target platform for God of War")).toHaveTextContent( + "PlayStation 2" + ); + fireEvent.click(screen.getByTestId("button-add-igdb-1")); + + await waitFor(() => { + const postCall = vi + .mocked(globalThis.fetch) + .mock.calls.find(([url, init]) => String(url) === "/api/games" && init?.method === "POST"); + expect(postCall).toBeDefined(); + expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({ + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + }); + }); + }); + it("shows the mobile configuration prompt when IGDB is not configured", async () => { mockIsMobile = true; setupFetch({ configured: false }); diff --git a/client/__tests__/GameDetailsModal.test.tsx b/client/__tests__/GameDetailsModal.test.tsx index 1107d42a6..6279e2a91 100644 --- a/client/__tests__/GameDetailsModal.test.tsx +++ b/client/__tests__/GameDetailsModal.test.tsx @@ -188,6 +188,46 @@ describe("GameDetailsModal", () => { expect(screen.getByTestId("badge-platform-ps5")).toBeInTheDocument(); }); + it("updates and clears the automatic download target", async () => { + (global.fetch as ReturnType).mockImplementation( + makeFetchMock({ + "/api/igdb/platforms": [ + { id: 8, name: "PlayStation 2" }, + { id: 48, name: "PlayStation 4" }, + ], + }) + ); + renderComponent(); + + const targetSelect = await screen.findByLabelText("Automatic download target"); + await screen.findByRole("option", { name: "PlayStation 2" }); + fireEvent.change(targetSelect, { target: { value: "8" } }); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/games/1/target-platform", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + }), + }) + ); + }); + + fireEvent.change(targetSelect, { target: { value: "" } }); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/games/1/target-platform", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ targetPlatformId: null, targetPlatformName: null }), + }) + ); + }); + }); + it("renders screenshots in Media tab", () => { renderComponent(); // Media tab uses forceMount so screenshots are always in the DOM (hidden until tab activated) diff --git a/client/src/components/AddGameModal.tsx b/client/src/components/AddGameModal.tsx index 66e6aa25f..0d1d12aa2 100644 --- a/client/src/components/AddGameModal.tsx +++ b/client/src/components/AddGameModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from "react"; -import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, @@ -21,6 +21,13 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Search, Plus, Star, AlertCircle, Calendar, Check, Loader2 } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { type Game, type InsertGame, type Config } from "@shared/schema"; @@ -29,9 +36,11 @@ import { Link } from "wouter"; import { apiFetch, apiRequest } from "@/lib/queryClient"; import { getAddGamePendingQuery, clearAddGamePendingQuery } from "@/lib/add-game-store"; import { useIsMobile } from "@/hooks/use-mobile"; +import { resolveTargetPlatform } from "@shared/title-utils"; interface SearchResult extends Game { inCollection?: boolean; + platformOptions?: IGDBPlatform[]; } interface AddGameModalProps { @@ -39,20 +48,39 @@ interface AddGameModalProps { initialQuery?: string; } +interface IGDBPlatform { + id: number; + name: string; +} + export default function AddGameModal({ children, initialQuery }: AddGameModalProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [showUndatedGames, setShowUndatedGames] = useState(false); + const [selectedPlatform, setSelectedPlatform] = useState("all"); + const [releaseYear, setReleaseYear] = useState(""); + const [targetPlatforms, setTargetPlatforms] = useState>({}); const { toast } = useToast(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); + const parsedReleaseYear = Number.parseInt(releaseYear, 10); + const hasValidReleaseYear = + /^\d{4}$/.test(releaseYear) && parsedReleaseYear >= 1950 && parsedReleaseYear <= 2100; + const canSearchWithReleaseYear = releaseYear === "" || hasValidReleaseYear; const { data: config } = useQuery({ queryKey: ["/api/config"], queryFn: () => apiRequest("GET", "/api/config").then((res) => res.json()), }); + const { data: platforms = [] } = useQuery({ + queryKey: ["/api/igdb/platforms"], + queryFn: () => apiRequest("GET", "/api/igdb/platforms").then((res) => res.json()), + enabled: open && !!config?.igdb?.configured, + staleTime: 24 * 60 * 60 * 1000, + }); + // Debounce search query useEffect(() => { const timer = setTimeout(() => { @@ -61,6 +89,11 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro return () => clearTimeout(timer); }, [searchQuery]); + // Per-result target overrides only apply to the current discovery result set. + useEffect(() => { + setTargetPlatforms({}); + }, [searchQuery, selectedPlatform, releaseYear, showUndatedGames]); + // Pre-fill search when modal opens (from prop or from the dashboard store) useEffect(() => { if (open) { @@ -75,12 +108,15 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro setSearchQuery(""); setDebouncedQuery(""); setShowUndatedGames(false); + setSelectedPlatform("all"); + setReleaseYear(""); + setTargetPlatforms({}); } }, [open, initialQuery]); // Search IGDB for games - const { data: searchResults = [], isLoading: isSearching } = useQuery({ - queryKey: ["/api/igdb/search", debouncedQuery, showUndatedGames], + const { data: searchResults = [], isFetching: isSearching } = useQuery({ + queryKey: ["/api/igdb/search", debouncedQuery, showUndatedGames, selectedPlatform, releaseYear], queryFn: async () => { if (!debouncedQuery.trim()) return []; const token = localStorage.getItem("token"); @@ -88,15 +124,22 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro if (token) { headers["Authorization"] = `Bearer ${token}`; } - const response = await fetch( - `/api/igdb/search?q=${encodeURIComponent(debouncedQuery)}&limit=10&includeUndated=${showUndatedGames}`, - { headers } - ); + const params = new URLSearchParams({ + q: debouncedQuery, + limit: "10", + includeUndated: String(showUndatedGames), + }); + if (selectedPlatform !== "all") params.set("platform", selectedPlatform); + if (hasValidReleaseYear) { + params.set("year", releaseYear); + } + const response = await fetch(`/api/igdb/search?${params.toString()}`, { headers }); if (!response.ok) throw new Error("Search failed"); - return response.json(); + const data: unknown = await response.json(); + return Array.isArray(data) ? data.slice(0, 10) : []; }, - enabled: debouncedQuery.trim().length > 2 && !!config?.igdb?.configured, - placeholderData: keepPreviousData, + enabled: + debouncedQuery.trim().length > 2 && canSearchWithReleaseYear && !!config?.igdb?.configured, }); // Get user's collection to check if games are already added @@ -140,9 +183,122 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro // Search is handled by the debounced query }; + const handleReleaseYearChange = (value: string) => { + setReleaseYear(value); + const parsedYear = Number.parseInt(value, 10); + if (/^\d{4}$/.test(value) && parsedYear >= 1950 && parsedYear <= 2100) { + setShowUndatedGames(false); + } + }; + + const renderDiscoveryFilters = (compact = false) => ( +
+
+ + handleReleaseYearChange(event.target.value)} + aria-label="Release year filter" + /> +
+
+
+

+ Show undated games first +

+ {!compact && ( +

+ Include titles without a release date and place them before dated results. +

+ )} +
+ +
+
+ ); + const getSupportedTargetOptions = (game: SearchResult) => + game.platformOptions?.filter(({ id, name }) => resolveTargetPlatform(id, name)) ?? []; + + const getTargetPlatformValue = (game: SearchResult) => { + const supportedOptions = getSupportedTargetOptions(game); + const key = String(game.igdbId ?? game.id); + const explicitValue = targetPlatforms[key]; + if (explicitValue && supportedOptions.some(({ id }) => String(id) === explicitValue)) { + return explicitValue; + } + if ( + selectedPlatform !== "all" && + supportedOptions.some(({ id }) => String(id) === selectedPlatform) + ) { + return selectedPlatform; + } + return supportedOptions.length === 1 ? String(supportedOptions[0].id) : "default"; + }; + + const renderTargetPlatformSelect = (game: SearchResult) => { + const supportedOptions = getSupportedTargetOptions(game); + if (game.inCollection || !supportedOptions?.length) return null; + const key = String(game.igdbId ?? game.id); + return ( + + ); + }; const handleAddGame = (searchResult: SearchResult) => { - // Map to InsertGame to filter out client-only fields before sending to server - const gameData = mapGameToInsertGame(searchResult); + const selectedTarget = getTargetPlatformValue(searchResult); + const targetPlatform = searchResult.platformOptions?.find( + ({ id }) => String(id) === selectedTarget + ); + const gameData = mapGameToInsertGame({ + ...searchResult, + targetPlatformId: targetPlatform?.id ?? null, + targetPlatformName: targetPlatform?.name ?? null, + }); addGameMutation.mutate(gameData); }; @@ -156,7 +312,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro // Mark games already in collection const resultsWithCollectionStatus: SearchResult[] = useMemo(() => { - return searchResults.map((game: Game) => ({ + return searchResults.slice(0, 10).map((game: Game) => ({ ...game, inCollection: game.igdbId != null ? userGameIgdbIds.has(game.igdbId) : false, })); @@ -212,14 +368,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro aria-label="Search games" />
-
- Show undated games first - -
+ {renderDiscoveryFilters(true)}
{/* Scrollable results */} @@ -318,6 +467,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro {game.summary}

)} + + {renderTargetPlatformSelect(game)}
))} @@ -378,15 +529,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro -
-
-

Show undated games first

-

- Include titles without a release date and place them before dated results. -

-
- -
+ {renderDiscoveryFilters()}
{isSearching && ( @@ -456,6 +599,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro ))}
+ {renderTargetPlatformSelect(game)} +
{game.platforms?.slice(0, 3).map((platform) => ( diff --git a/client/src/components/GameDetailsModal.tsx b/client/src/components/GameDetailsModal.tsx index 3dc502c00..57e0b94c8 100644 --- a/client/src/components/GameDetailsModal.tsx +++ b/client/src/components/GameDetailsModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, lazy, Suspense } from "react"; +import React, { useState, useEffect, useMemo, useRef, lazy, Suspense } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertDialog, @@ -79,6 +79,7 @@ import { useToast } from "@/hooks/use-toast"; import { useHiddenMutation } from "@/hooks/use-hidden-mutation"; import { useIsMobile } from "@/hooks/use-mobile"; import { type Game, type GameDownload } from "@shared/schema"; +import { resolveTargetPlatform } from "@shared/title-utils"; import StatusBadge, { getStatusLabel } from "./StatusBadge"; import { apiRequest } from "@/lib/queryClient"; import { cn, safeUrl, formatBytes, isDiscoveryId } from "@/lib/utils"; @@ -97,6 +98,11 @@ type FileDeletionResult = | { deleted: true; path: string | null } | { deleted: false; reason: "outside-library-root" | "delete-failed"; path: string }; +interface IgdbPlatformOption { + id: number; + name: string; +} + interface NexusMod { mod_id: number; name: string; @@ -354,6 +360,7 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai const [downloadOpen, setDownloadOpen] = useState(false); const [isSummaryExpanded, setIsSummaryExpanded] = useState(false); const [notesValue, setNotesValue] = useState(""); + const [targetPlatformValue, setTargetPlatformValue] = useState(""); const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); const [removeFromClient, setRemoveFromClient] = useState(true); const [deleteFiles, setDeleteFiles] = useState(true); @@ -372,7 +379,8 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai useEffect(() => { setIsSummaryExpanded(false); setNotesValue(game?.notes ?? ""); - }, [game?.id, game?.notes]); + setTargetPlatformValue(game?.targetPlatformId ? String(game.targetPlatformId) : ""); + }, [game?.id, game?.notes, game?.targetPlatformId]); useEffect(() => { setSelectedScreenshotIndex(null); @@ -460,6 +468,29 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai }; }, [open, game?.id, queryClient]); + const { data: targetPlatformOptions = [] } = useQuery({ + queryKey: ["/api/igdb/platforms"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/igdb/platforms"); + return res.json(); + }, + enabled: open && !!game?.id && !isDiscoveryId(game.id), + staleTime: 24 * 60 * 60 * 1000, + }); + + const supportedTargetPlatformOptions = useMemo(() => { + const options = targetPlatformOptions.filter(({ id, name }) => resolveTargetPlatform(id, name)); + if ( + game?.targetPlatformId && + game.targetPlatformName && + resolveTargetPlatform(game.targetPlatformId, game.targetPlatformName) && + !options.some(({ id }) => id === game.targetPlatformId) + ) { + return [{ id: game.targetPlatformId, name: game.targetPlatformName }, ...options]; + } + return options; + }, [targetPlatformOptions, game?.targetPlatformId, game?.targetPlatformName]); + const { data: gameDownloads = [], isLoading: downloadsLoading } = useQuery< GameDownloadWithDownloader[] >({ @@ -565,6 +596,23 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai }, }); + const targetPlatformMutation = useMutation({ + mutationFn: async (target: { + targetPlatformId: number | null; + targetPlatformName: string | null; + }) => { + await apiRequest("PATCH", `/api/games/${game?.id}/target-platform`, target); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/games"] }); + toast({ description: "Download target updated" }); + }, + onError: () => { + setTargetPlatformValue(game?.targetPlatformId ? String(game.targetPlatformId) : ""); + toast({ description: "Failed to update download target", variant: "destructive" }); + }, + }); + const notesMutation = useMutation({ mutationFn: async (notes: string | null) => { await apiRequest("PATCH", `/api/games/${game?.id}/notes`, { notes }); @@ -985,6 +1033,48 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai />
)} + {!isDiscoveryId(game.id) && ( +
+ + +

+ Overrides the account platform for automatic release matching. +

+
+ )}
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 00a6dc497..1e35cc67d 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -63,6 +63,8 @@ export function mapGameToInsertGame(game: Game): InsertGame { releaseDate: game.releaseDate || null, rating: game.rating, platforms: game.platforms, + targetPlatformId: game.targetPlatformId, + targetPlatformName: game.targetPlatformName, genres: game.genres, themes: game.themes, screenshots: game.screenshots, diff --git a/migrations/0026_simple_speed_demon.sql b/migrations/0026_simple_speed_demon.sql new file mode 100644 index 000000000..49ecc9db1 --- /dev/null +++ b/migrations/0026_simple_speed_demon.sql @@ -0,0 +1,2 @@ +ALTER TABLE `games` ADD `target_platform_id` integer;--> statement-breakpoint +ALTER TABLE `games` ADD `target_platform_name` text; \ No newline at end of file diff --git a/migrations/meta/0026_snapshot.json b/migrations/meta/0026_snapshot.json new file mode 100644 index 000000000..3da46f3a1 --- /dev/null +++ b/migrations/meta/0026_snapshot.json @@ -0,0 +1,1704 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "790ee634-17f7-4b5f-a627-1c9da7839304", + "prevId": "b8506e7a-cf45-4086-9f0f-135a115f9f8e", + "tables": { + "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 + }, + "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 + }, + "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": {} + }, + "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 + }, + "aggregated_rating": { + "name": "aggregated_rating", + "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 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_path": { + "name": "library_path", + "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 + }, + "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 + }, + "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": {} + }, + "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 + }, + "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 + }, + "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 + }, + "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": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index fbc6a2c2e..beeedc612 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1784581013215, "tag": "0025_damp_storm", "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1786323125910, + "tag": "0026_simple_speed_demon", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/__tests__/api_routes.test.ts b/server/__tests__/api_routes.test.ts index 3a7677081..a38598182 100644 --- a/server/__tests__/api_routes.test.ts +++ b/server/__tests__/api_routes.test.ts @@ -694,6 +694,51 @@ describe("API Routes - Extended Coverage", () => { }); }); + describe("PATCH /api/games/:id/target-platform", () => { + const gameId = "123e4567-e89b-12d3-a456-426614174000"; + + it("updates a complete IGDB target pair", async () => { + vi.mocked(storage.getGame).mockResolvedValue({ + id: gameId, + userId: "user-1", + } as unknown as Game); + vi.mocked(storage.updateGame).mockResolvedValue({ + id: gameId, + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + } as unknown as Game); + + const response = await request(app) + .patch(`/api/games/${gameId}/target-platform`) + .send({ targetPlatformId: 8, targetPlatformName: "PlayStation 2" }); + + expect(response.status).toBe(200); + expect(vi.mocked(storage.updateGame)).toHaveBeenCalledWith(gameId, { + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + }); + }); + + it("rejects an incomplete target pair", async () => { + const response = await request(app) + .patch(`/api/games/${gameId}/target-platform`) + .send({ targetPlatformId: 8, targetPlatformName: null }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe("Invalid target platform data"); + }); + + it("rejects a mismatched complete target pair before storage", async () => { + const response = await request(app) + .patch(`/api/games/${gameId}/target-platform`) + .send({ targetPlatformId: 8, targetPlatformName: "PlayStation 5" }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe("Invalid target platform data"); + expect(vi.mocked(storage.updateGame)).not.toHaveBeenCalled(); + }); + }); + describe("DELETE /api/games/:id", () => { it("should remove game", async () => { const gameId = "123e4567-e89b-12d3-a456-426614174000"; @@ -892,6 +937,19 @@ describe("API Routes - Extended Coverage", () => { undatedFirst: true, }); }); + it("should pass platform and release year filters to IGDB search", async () => { + vi.mocked(igdbClient.searchGames).mockResolvedValue([]); + + const response = await request(app).get( + "/api/igdb/search?q=God%20of%20War&limit=10&platform=8&year=2005" + ); + + expect(response.status).toBe(200); + expect(igdbClient.searchGames).toHaveBeenCalledWith("God of War", 20, { + platformId: 8, + releaseYear: 2005, + }); + }); }); describe("GET /api/igdb/popular", () => { diff --git a/server/__tests__/cron_autosearch.test.ts b/server/__tests__/cron_autosearch.test.ts index 1d4f13db8..2dae76a5f 100644 --- a/server/__tests__/cron_autosearch.test.ts +++ b/server/__tests__/cron_autosearch.test.ts @@ -771,6 +771,109 @@ describe("Cron - checkAutoSearch", () => { expect.objectContaining({ title: "Multiple Results Found" }) ); }); + + it("uses an explicit per-game target instead of a conflicting account preference", async () => { + const wantedGame = { + ...baseGame, + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + status: "wanted" as const, + releaseStatus: "released" as const, + }; + mockGetWantedGamesGroupedByUser.mockResolvedValue(new Map([[userId, [wantedGame]]])); + mockGetUserSettings.mockResolvedValue({ + ...baseSettings, + preferredPlatform: "PS5", + autoDownloadEnabled: true, + }); + mockGetEnabledDownloaders.mockResolvedValue([ + { id: "dl-1", name: "qBittorrent", type: "torrent", enabled: true }, + ]); + mockAddDownloadWithFallback.mockResolvedValue({ + success: true, + id: "hash-ps2", + downloaderId: "dl-1", + }); + mockSearchAllIndexers.mockResolvedValue({ + items: [ + PS5_ITEM, + { ...PS5_ITEM, title: "Test Game PS2-GROUP", link: "https://example.com/ps2" }, + ], + errors: [], + total: 2, + }); + + await checkAutoSearch(); + + expect(mockAddDownloadWithFallback).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ url: "https://example.com/ps2" }) + ); + }); + + it("fails closed and clears availability for a malformed saved target pair", async () => { + const malformedGame = { + ...baseGame, + targetPlatformId: 8, + targetPlatformName: null, + status: "wanted" as const, + releaseStatus: "released" as const, + }; + mockGetWantedGamesGroupedByUser.mockResolvedValue(new Map([[userId, [malformedGame]]])); + mockGetUserSettings.mockResolvedValue({ ...baseSettings, preferredPlatform: "PS5" }); + mockSearchAllIndexers.mockResolvedValue({ + items: [PS5_ITEM], + errors: [], + total: 1, + }); + + await checkAutoSearch(); + + expect(mockUpdateGameSearchResultsAvailable).toHaveBeenCalledWith(malformedGame.id, false); + expect(mockAddNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Game Available" }) + ); + }); + + it("filters owned-game updates using the explicit per-game target", async () => { + const ownedGame = { + ...baseGame, + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + status: "owned" as const, + releaseStatus: "released" as const, + searchResultsAvailable: false, + }; + mockGetWantedGamesGroupedByUser.mockResolvedValue(new Map([[userId, []]])); + mockGetUserGames.mockResolvedValue([ownedGame]); + mockGetUserSettings.mockResolvedValue({ + ...baseSettings, + preferredPlatform: "PS5", + notifyUpdates: true, + }); + mockSearchAllIndexers.mockResolvedValue({ + items: [ + { ...PS5_ITEM, title: "Test Game Update v1.1 PS5-GROUP" }, + { + ...PS5_ITEM, + title: "Test Game Update v1.1 PS2-GROUP", + link: "https://example.com/ps2-update", + }, + ], + errors: [], + total: 2, + }); + + await checkAutoSearch(); + + expect(mockUpdateGameSearchResultsAvailable).toHaveBeenCalledWith(ownedGame.id, true); + expect(mockAddNotification).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Game Updates Available", + message: "1 update(s) found for Test Game", + }) + ); + }); }); it("should not notify when all matched items are blacklisted", async () => { diff --git a/server/__tests__/fixtures/common-route-mocks.ts b/server/__tests__/fixtures/common-route-mocks.ts index e1ef786a1..bdfe4bb41 100644 --- a/server/__tests__/fixtures/common-route-mocks.ts +++ b/server/__tests__/fixtures/common-route-mocks.ts @@ -63,6 +63,7 @@ export function createStorageMock() { updateGameHidden: vi.fn(), updateGameUserRating: vi.fn(), updateGameNotes: vi.fn(), + updateGame: vi.fn(), updateGameSearchResultsAvailable: vi.fn().mockResolvedValue(undefined), updateUserPassword: vi.fn(), updateGamesBatch: vi.fn(), diff --git a/server/__tests__/helpers/import-test-helpers.ts b/server/__tests__/helpers/import-test-helpers.ts index 32f9e1b05..ac0812c50 100644 --- a/server/__tests__/helpers/import-test-helpers.ts +++ b/server/__tests__/helpers/import-test-helpers.ts @@ -15,6 +15,8 @@ export function makeGame(overrides: Partial = {}): Game { releaseDate: null, rating: null, platforms: [], + targetPlatformId: null, + targetPlatformName: null, genres: null, themes: null, publishers: null, diff --git a/server/__tests__/igdb.test.ts b/server/__tests__/igdb.test.ts index 89c64de26..f117c0d4e 100644 --- a/server/__tests__/igdb.test.ts +++ b/server/__tests__/igdb.test.ts @@ -169,6 +169,35 @@ describe("IGDBClient - Fallback Mechanism", { timeout: 20000 }, () => { expect(results.map((game) => game.name)).toEqual(["Undated Game", "Newer Game", "Older Game"]); }); + it("applies platform and release year filters before the IGDB result limit", async () => { + const authResponse = { + ok: true, + json: async () => ({ + access_token: "test-token", + expires_in: 3600, + token_type: "bearer", + }), + }; + const successResponse = { + ok: true, + json: async () => [{ id: 1, name: "God of War", first_release_date: 1110844800 }], + }; + + fetchMock.mockResolvedValueOnce(authResponse).mockResolvedValueOnce(successResponse); + + const { igdbClient } = await import("../igdb.js"); + await igdbClient.searchGames("God of War", 10, { platformId: 8, releaseYear: 2005 }); + + const gameRequest = fetchMock.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("api.igdb.com/v4/games") + ); + const body = String(gameRequest?.[1]?.body); + expect(body).toContain("platforms = (8)"); + expect(body).toContain("first_release_date >= 1104537600"); + expect(body).toContain("first_release_date < 1136073600"); + expect(body.indexOf("platforms = (8)")).toBeLessThan(body.indexOf("limit 10")); + }); + it("should return empty array when all search approaches fail", async () => { // Mock authentication response const authResponse = { @@ -507,6 +536,23 @@ describe("IGDBClient - formatGameData metadata fields", () => { expect(results[0].websites).toEqual(websites); }); + it("preserves IGDB platform ids and names for target selection", async () => { + const { igdbClient } = await import("../igdb.js"); + const result = igdbClient.formatGameData({ + id: 1, + name: "God of War", + platforms: [ + { id: 8, name: "PlayStation 2" }, + { id: 9, name: "PlayStation 3" }, + ], + }); + + expect(result.platformOptions).toEqual([ + { id: 8, name: "PlayStation 2" }, + { id: 9, name: "PlayStation 3" }, + ]); + }); + it("uses empty array for igdbWebsites when websites field is absent", async () => { mockAuthAndGame({ id: 1, name: "Test Game" }); diff --git a/server/cron.ts b/server/cron.ts index 72341ab0d..be422cd83 100644 --- a/server/cron.ts +++ b/server/cron.ts @@ -27,6 +27,7 @@ import { parseJsonStringArray, parseReleaseMetadata, matchesPlatformFilter, + resolveGamePlatformPreference, } from "../shared/title-utils.js"; const DELAY_THRESHOLD_DAYS = 7; @@ -1001,9 +1002,10 @@ export async function checkAutoSearch() { // Apply platform filter first (strict), then preferred groups filter, then // de-duplicate releases that appear on multiple indexers (keep highest-priority indexer). + const effectivePlatform = resolveGamePlatformPreference(game, preferredPlatform); const platformFilteredMain = applyPreferredPlatformFilter( searchResult.mainItems, - preferredPlatform + effectivePlatform ); const groupFilteredMain = applyPreferredGroupsFilter( platformFilteredMain, @@ -1124,9 +1126,10 @@ export async function checkAutoSearch() { const wasUpdateAvailable = game.searchResultsAvailable; + const effectivePlatform = resolveGamePlatformPreference(game, preferredPlatform); const platformFilteredUpdate = applyPreferredPlatformFilter( searchResult.updateItems, - preferredPlatform + effectivePlatform ); const groupFilteredUpdate = applyPreferredGroupsFilter( platformFilteredUpdate, diff --git a/server/igdb.ts b/server/igdb.ts index d6d66ef9c..b3e275447 100644 --- a/server/igdb.ts +++ b/server/igdb.ts @@ -85,6 +85,8 @@ export interface IGDBGame { interface SearchGamesOptions { includeUndated?: boolean; undatedFirst?: boolean; + platformId?: number; + releaseYear?: number; } interface IGDBAuthResponse { @@ -372,19 +374,34 @@ class IGDBClient { let attemptCount = 0; + const filters: string[] = []; + if (options.platformId) { + filters.push(`platforms = (${options.platformId})`); + } + if (options.releaseYear) { + const yearStart = Math.floor(Date.UTC(options.releaseYear, 0, 1) / 1000); + const nextYearStart = Math.floor(Date.UTC(options.releaseYear + 1, 0, 1) / 1000); + filters.push(`first_release_date >= ${yearStart}`); + filters.push(`first_release_date < ${nextYearStart}`); + } + const withFilters = (conditions: string[] = []) => { + const allConditions = [...conditions, ...filters]; + return allConditions.length > 0 ? `where ${allConditions.join(" & ")}; ` : ""; + }; + // Try multiple search approaches to maximize results const searchApproaches = [ // Approach 1: Full text search without category filter - `search "${sanitizedQuery}"; fields ${IGDB_GAME_FIELDS}; limit ${limit};`, + `search "${sanitizedQuery}"; fields ${IGDB_GAME_FIELDS}; ${withFilters()}limit ${limit};`, // Approach 2: Full text search with category filter - `search "${sanitizedQuery}"; fields ${IGDB_GAME_FIELDS}; where category = 0; limit ${limit};`, + `search "${sanitizedQuery}"; fields ${IGDB_GAME_FIELDS}; ${withFilters(["category = 0"])}limit ${limit};`, // Approach 3: Case-insensitive name matching without category - `fields ${IGDB_GAME_FIELDS}; where name ~= "${sanitizedQuery}"; limit ${limit};`, + `fields ${IGDB_GAME_FIELDS}; ${withFilters([`name ~= "${sanitizedQuery}"`])}limit ${limit};`, // Approach 4: Partial name matching without category - `fields ${IGDB_GAME_FIELDS}; where name ~ *"${sanitizedQuery}"*; sort rating desc; limit ${limit};`, + `fields ${IGDB_GAME_FIELDS}; ${withFilters([`name ~ *"${sanitizedQuery}"*`])}sort rating desc; limit ${limit};`, ]; for (let i = 0; i < searchApproaches.length && attemptCount < MAX_SEARCH_ATTEMPTS; i++) { @@ -448,7 +465,7 @@ class IGDBClient { const sanitizedWord = sanitizeIgdbInput(word); if (!sanitizedWord) return []; - const wordQuery = `fields ${IGDB_GAME_FIELDS}; where name ~ *"${sanitizedWord}"*; sort rating desc; limit ${limit};`; + const wordQuery = `fields ${IGDB_GAME_FIELDS}; ${withFilters([`name ~ *"${sanitizedWord}"*`])}sort rating desc; limit ${limit};`; // Cache word search results for 15 minutes return await this.makeRequest("games", wordQuery, 15 * 60 * 1000); } catch (error) { @@ -1079,6 +1096,7 @@ class IGDBClient { releaseDate: releaseDate ? releaseDate.toISOString().split("T")[0] : "", rating: igdbGame.rating ? Math.round(igdbGame.rating) / 10 : null, platforms: igdbGame.platforms?.map((p) => p.name) || [], + platformOptions: igdbGame.platforms?.map(({ id, name }) => ({ id, name })) || [], genres: igdbGame.genres?.map((g) => g.name) || [], themes: igdbGame.themes?.map((t) => t.name) || [], isAdultContent: hasEroticTheme(igdbGame), diff --git a/server/middleware.ts b/server/middleware.ts index adbe3d6ee..faf3d3215 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -97,6 +97,16 @@ export const sanitizeSearchQuery = [ .isBoolean() .withMessage("includeUndated must be a boolean") .toBoolean(), + query("platform") + .optional() + .isInt({ min: 1 }) + .withMessage("Platform must be a valid IGDB platform ID") + .toInt(), + query("year") + .optional() + .isInt({ min: 1950, max: 2100 }) + .withMessage("Year must be between 1950 and 2100") + .toInt(), ]; // Sanitization rules for game ID parameters diff --git a/server/routes.ts b/server/routes.ts index 9f3f4d09d..e04f7be16 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -12,6 +12,7 @@ import { updateGameHiddenSchema, updateGameUserRatingSchema, updateGameNotesSchema, + updateGameTargetPlatformSchema, insertIndexerSchema, insertDownloaderSchema, insertNotificationSchema, @@ -1377,6 +1378,36 @@ export async function registerRoutes(app: Express): Promise { } ); + // Update the per-game download target, or clear it to use the account default. + app.patch( + "/api/games/:id/target-platform", + sensitiveEndpointLimiter, + sanitizeGameId, + validateRequest, + async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = req.user!.id; + const target = updateGameTargetPlatformSchema.parse(req.body); + + if (!(await resolveOwnedGame(id, userId, res))) return; + + const updatedGame = await storage.updateGame(id, target); + if (!updatedGame) { + return res.status(404).json({ error: "Game not found" }); + } + + res.json(updatedGame); + } catch (error) { + if (error instanceof z.ZodError) { + return respondWithZodError(res, error, "Invalid target platform data"); + } + routesLogger.error({ error }, "error updating game target platform"); + res.status(500).json({ error: "Failed to update target platform" }); + } + } + ); + // Refresh metadata for all games app.post("/api/games/refresh-metadata", igdbRateLimiter, async (req, res) => { try { @@ -1734,7 +1765,7 @@ export async function registerRoutes(app: Express): Promise { validateRequest, async (req: Request, res: Response) => { try { - const { q, limit, includeUndated } = req.query; + const { q, limit, includeUndated, platform, year } = req.query; if (!q || typeof q !== "string") { return res.status(400).json({ error: "Search query required" }); } @@ -1747,10 +1778,13 @@ export async function registerRoutes(app: Express): Promise { : NaN; const limitNum = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 20 : Math.min(parsedLimit, 100); - const searchOptions = - typeof includeUndated === "boolean" + const searchOptions = { + ...(typeof includeUndated === "boolean" ? { includeUndated, undatedFirst: includeUndated } - : {}; + : {}), + ...(typeof platform === "number" ? { platformId: platform } : {}), + ...(typeof year === "number" ? { releaseYear: year } : {}), + }; const formattedGames = await fetchFilteredIgdbGames(req.user!.id, limitNum, (fetchLimit) => igdbClient.searchGames(q, fetchLimit, searchOptions) ); diff --git a/server/storage.ts b/server/storage.ts index c781eb244..25bb377d1 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -453,6 +453,8 @@ export class MemStorage implements IStorage { releaseDate: insertGame.releaseDate || null, rating: insertGame.rating || null, platforms: insertGame.platforms || null, + targetPlatformId: insertGame.targetPlatformId ?? null, + targetPlatformName: insertGame.targetPlatformName ?? null, genres: insertGame.genres || null, themes: insertGame.themes || null, publishers: insertGame.publishers || null, diff --git a/shared/__tests__/schema.test.ts b/shared/__tests__/schema.test.ts index 59bb79ea3..3a25434d1 100644 --- a/shared/__tests__/schema.test.ts +++ b/shared/__tests__/schema.test.ts @@ -1,6 +1,83 @@ import { describe, expect, it } from "vitest"; -import { insertDownloaderSchema, insertIndexerSchema } from "@shared/schema"; +import { + insertDownloaderSchema, + insertGameSchema, + insertIndexerSchema, + updateGameTargetPlatformSchema, +} from "@shared/schema"; + +describe("insertGameSchema", () => { + it("accepts a complete target platform pair", () => { + expect( + insertGameSchema.safeParse({ + title: "God of War", + targetPlatformId: 8, + targetPlatformName: "PlayStation 2", + }).success + ).toBe(true); + }); + + it.each([{ targetPlatformId: 8 }, { targetPlatformName: "PlayStation 2" }])( + "rejects partial target platform data", + (target) => { + const result = insertGameSchema.safeParse({ title: "God of War", ...target }); + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Target platform ID and name must be provided together", + }), + ]) + ); + } + ); + + it("rejects a mismatched complete target platform pair", () => { + const result = insertGameSchema.safeParse({ + title: "God of War", + targetPlatformId: 8, + targetPlatformName: "PlayStation 5", + }); + expect(result.success).toBe(false); + expect(result.error?.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Target platform ID and name must match a supported platform", + }), + ]) + ); + }); +}); + +describe("updateGameTargetPlatformSchema", () => { + it("allows an existing game to return to the account default", () => { + expect( + updateGameTargetPlatformSchema.parse({ + targetPlatformId: null, + targetPlatformName: null, + }) + ).toEqual({ targetPlatformId: null, targetPlatformName: null }); + }); + + it("rejects malformed target-platform updates", () => { + expect( + updateGameTargetPlatformSchema.safeParse({ + targetPlatformId: 8, + targetPlatformName: null, + }).success + ).toBe(false); + }); + + it("rejects mismatched target-platform updates", () => { + expect( + updateGameTargetPlatformSchema.safeParse({ + targetPlatformId: 8, + targetPlatformName: "PlayStation 5", + }).success + ).toBe(false); + }); +}); describe("insertIndexerSchema", () => { it("requires non-empty name, url, and apiKey", () => { diff --git a/shared/__tests__/title_utils.test.ts b/shared/__tests__/title_utils.test.ts index 2cec481d2..163a84ca9 100644 --- a/shared/__tests__/title_utils.test.ts +++ b/shared/__tests__/title_utils.test.ts @@ -7,6 +7,10 @@ import { parseReleaseMetadata, parseJsonStringArray, matchesPlatformFilter, + resolveGamePlatformPreference, + resolveTargetPlatform, + UNSUPPORTED_TARGET_PLATFORM, + CANONICAL_PLATFORMS, } from "../title-utils.js"; describe("title-utils", () => { @@ -109,9 +113,18 @@ describe("title-utils", () => { expect(parseReleaseMetadata("[GROUP] Game Name").group).toBe("GROUP"); }); - it("should detect various platforms", () => { + it("should detect modern and legacy platform aliases", () => { expect(parseReleaseMetadata("Game.PS5-GROUP").platform).toBe("PS5"); expect(parseReleaseMetadata("Game.Win64-GROUP").platform).toBe("PC"); + expect(parseReleaseMetadata("God.of.War.PS2.NTSC-GROUP").platform).toBe("PS2"); + expect(parseReleaseMetadata("God.of.War.PS2/USA-GROUP").platform).toBe("PS2"); + expect(parseReleaseMetadata("God.of.War.XPS2Y-GROUP").platform).toBeUndefined(); + expect(parseReleaseMetadata("Game.PSX-GROUP").platform).toBe("PS1"); + expect(parseReleaseMetadata("Game.NGC-GROUP").platform).toBe("GameCube"); + expect(parseReleaseMetadata("Game.X360-GROUP").platform).toBe("Xbox 360"); + expect(parseReleaseMetadata("Game-Xbox-Series-X-GROUP").platform).toBe("Xbox Series"); + expect(parseReleaseMetadata("Game-PlayStation-5-GROUP").platform).toBe("PS5"); + expect(parseReleaseMetadata("Game.DC-GROUP").platform).toBe("Dreamcast"); }); it("should parse Mac platform and DRM-Free tags", () => { const release = "Shadow.of.the.Tomb.Raider.MacOS.DRM-Free"; @@ -121,6 +134,41 @@ describe("title-utils", () => { }); }); + describe("target platform resolution", () => { + it("keeps the legacy account-wide Xbox umbrella selectable", () => { + expect(CANONICAL_PLATFORMS).toContain("Xbox"); + }); + + it("resolves stable IGDB ids and names to release labels", () => { + expect(resolveTargetPlatform(8, "PlayStation 2")).toBe("PS2"); + expect(resolveTargetPlatform(11, "Xbox")).toBe("Xbox Classic"); + expect(resolveTargetPlatform(23, "Dreamcast")).toBe("Dreamcast"); + }); + + it("fails closed for mismatched, partial, or unsupported target pairs", () => { + expect(resolveTargetPlatform(8, "PlayStation 5")).toBeNull(); + expect(resolveGamePlatformPreference({ targetPlatformId: 8 }, "PC")).toBe( + UNSUPPORTED_TARGET_PLATFORM + ); + expect( + resolveGamePlatformPreference( + { targetPlatformId: 9999, targetPlatformName: "Mystery Box" }, + "PC" + ) + ).toBe(UNSUPPORTED_TARGET_PLATFORM); + }); + + it("uses the account fallback only when the game has no explicit target", () => { + expect(resolveGamePlatformPreference({}, "PC")).toBe("PC"); + expect( + resolveGamePlatformPreference( + { targetPlatformId: 8, targetPlatformName: "PlayStation 2" }, + "PC" + ) + ).toBe("PS2"); + }); + }); + describe("parseJsonStringArray", () => { it("parses a valid JSON string array", () => { expect(parseJsonStringArray('["a","b","c"]')).toEqual(["a", "b", "c"]); diff --git a/shared/schema.ts b/shared/schema.ts index a71627370..6cd067c6a 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { sqliteTable, text, integer, real, uniqueIndex, index } from "drizzle-orm/sqlite-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { resolveTargetPlatform } from "./title-utils.js"; export const users = sqliteTable("users", { id: text("id").primaryKey(), @@ -155,6 +156,8 @@ export const games = sqliteTable("games", { releaseDate: text("release_date"), rating: real("rating"), platforms: text("platforms", { mode: "json" }).$type(), + targetPlatformId: integer("target_platform_id"), + targetPlatformName: text("target_platform_name"), genres: text("genres", { mode: "json" }).$type(), themes: text("themes", { mode: "json" }).$type(), publishers: text("publishers", { mode: "json" }).$type(), @@ -307,6 +310,8 @@ export const insertUserSchema = createInsertSchema(users).pick({ }); export const insertGameSchema = createInsertSchema(games, { + targetPlatformId: (schema) => schema.int().positive().nullable().optional(), + targetPlatformName: (schema) => schema.trim().min(1).max(100).nullable().optional(), status: (schema) => schema .nullable() @@ -327,11 +332,58 @@ export const insertGameSchema = createInsertSchema(games, { .nullable() .optional() .transform((val) => val ?? false), -}).omit({ - id: true, - addedAt: true, - completedAt: true, -}); +}) + .omit({ + id: true, + addedAt: true, + completedAt: true, + }) + .superRefine((game, ctx) => { + const hasTargetId = game.targetPlatformId != null; + const hasTargetName = game.targetPlatformName != null; + if (hasTargetId !== hasTargetName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [hasTargetId ? "targetPlatformName" : "targetPlatformId"], + message: "Target platform ID and name must be provided together", + }); + } else if ( + hasTargetId && + !resolveTargetPlatform(game.targetPlatformId, game.targetPlatformName) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targetPlatformName"], + message: "Target platform ID and name must match a supported platform", + }); + } + }); + +export const updateGameTargetPlatformSchema = z + .object({ + targetPlatformId: z.number().int().positive().nullable(), + targetPlatformName: z.string().trim().min(1).max(100).nullable(), + }) + .superRefine((target, ctx) => { + const hasTargetId = target.targetPlatformId != null; + const hasTargetName = target.targetPlatformName != null; + if (hasTargetId !== hasTargetName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [hasTargetId ? "targetPlatformName" : "targetPlatformId"], + message: "Target platform ID and name must be provided together", + }); + } else if ( + hasTargetId && + !resolveTargetPlatform(target.targetPlatformId, target.targetPlatformName) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["targetPlatformName"], + message: "Target platform ID and name must match a supported platform", + }); + } + }); export const GAME_STATUSES = ["wanted", "owned", "shelved", "completed", "downloading"] as const; export type GameStatus = (typeof GAME_STATUSES)[number]; diff --git a/shared/title-utils.ts b/shared/title-utils.ts index 09d871291..514295000 100644 --- a/shared/title-utils.ts +++ b/shared/title-utils.ts @@ -177,7 +177,7 @@ export interface ReleaseMetadata { * Parses a release name to extract as much metadata as possible. */ export function parseReleaseMetadata(releaseName: string): ReleaseMetadata { - const cleaned = releaseName.replace(/[._]/g, " "); + const cleaned = releaseName.replace(/[._-]/g, " "); // 1. Extract Group (usually after the last dash) // More robust group detection: some releases use [Group] at start or end, or -Group at end @@ -206,39 +206,10 @@ export function parseReleaseMetadata(releaseName: string): ReleaseMetadata { if (/\bjapanese\b/i.test(cleaned)) languages.push("Japanese"); if (/\benglish\b/i.test(cleaned)) languages.push("English"); - // 4. Extract Platform - const PLATFORM_PATTERNS: [RegExp, string][] = [ - [/\b(ps5|playstation\s*5)\b/i, "PS5"], - [/\b(ps4|playstation\s*4)\b/i, "PS4"], - [/\b(ps3|playstation\s*3)\b/i, "PS3"], - [/\b(ps2|playstation\s*2)\b/i, "PS2"], - [/\b(psx|ps1|playstation\s*1)\b/i, "PS1"], - [/\b(psp)\b/i, "PSP"], - [/\b(ps\s?vita|vita)\b/i, "PSVita"], - [/\b(xbox\s*series|xbsx|xss)\b/i, "Xbox Series"], - [/\b(xbox|x360|xbox360)\b/i, "Xbox"], - [/\b(nintendo\s*switch|switch|nsw)\b/i, "Switch"], - [/\b(game\s?cube|gamecube|ngc|gc)\b/i, "GameCube"], - [/\b(wii\s*u|wiiu)\b/i, "Wii U"], - [/\b(wii)\b/i, "Wii"], - [/\b(3ds)\b/i, "3DS"], - [/\b(nds|nintendo\s*ds|\bds\b)\b/i, "NDS"], - [/\b(n64|nintendo\s*64)\b/i, "N64"], - [/\b(snes|super\s*nintendo|super\s*nes)\b/i, "SNES"], - [/\b(nes|famicom)\b/i, "NES"], - [/\b(gba|game\s*boy\s*advance)\b/i, "GBA"], - [/\b(gbc|game\s*boy\s*color)\b/i, "GBC"], - [/\b(game\s*boy|\bgb\b)\b/i, "GB"], - [/\b(dreamcast|\bdc\b)\b/i, "Dreamcast"], - [/\b(megadrive|mega\s*drive|genesis)\b/i, "Mega Drive"], - [/\b(master\s*system|\bsms\b)\b/i, "Master System"], - [/\b(neo\s*geo|neogeo)\b/i, "Neo Geo"], - [/\b(atari\s*2600|a2600)\b/i, "Atari 2600"], - [/\b(pc|windows|win64|win32)\b/i, "PC"], - [/\b(linux)\b/i, "Linux"], - [/\b(mac|macos|osx)\b/i, "Mac"], - ]; - const platform = PLATFORM_PATTERNS.find(([regex]) => regex.test(cleaned))?.[1]; + // 4. Extract Platform using the shared target-platform catalog. + const platform = PLATFORM_CATALOG.find(({ releasePattern }) => + releasePattern.test(cleaned) + )?.canonical; // 5. DRM / Source let drm: string | undefined; @@ -262,21 +233,240 @@ export function parseReleaseMetadata(releaseName: string): ReleaseMetadata { } /** - * Canonical platform labels shared across settings, dialog filtering, and automation. - * These are user-facing labels — not raw IGDB platform names. + * Canonical release labels and their stable IGDB identities. Keep parsing aliases here so + * discovery selection and automation use the same cross-generation vocabulary. */ -export const CANONICAL_PLATFORMS = [ - "PC", - "PS5", - "PS4", - "PS3", - "Switch", - "Xbox Series", - "Xbox", - "Mac", - "Linux", +export const PLATFORM_CATALOG = [ + { + canonical: "PS5", + igdbIds: [167], + igdbNames: ["PlayStation 5"], + releasePattern: /(?:^|[^a-z0-9])(ps5|playstation\s*5)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PS4", + igdbIds: [48], + igdbNames: ["PlayStation 4"], + releasePattern: /(?:^|[^a-z0-9])(ps4|playstation\s*4)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PS3", + igdbIds: [9], + igdbNames: ["PlayStation 3"], + releasePattern: /(?:^|[^a-z0-9])(ps3|playstation\s*3)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PS2", + igdbIds: [8], + igdbNames: ["PlayStation 2"], + releasePattern: /(?:^|[^a-z0-9])(ps2|playstation\s*2)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PS1", + igdbIds: [7], + igdbNames: ["PlayStation"], + releasePattern: /(?:^|[^a-z0-9])(psx|ps1|playstation\s*1)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PSP", + igdbIds: [38], + igdbNames: ["PlayStation Portable"], + releasePattern: /(?:^|[^a-z0-9])(psp|playstation\s*portable)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PSVita", + igdbIds: [46], + igdbNames: ["PlayStation Vita"], + releasePattern: /(?:^|[^a-z0-9])(ps\s?vita|vita)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Xbox Series", + igdbIds: [169], + igdbNames: ["Xbox Series X|S"], + releasePattern: /(?:^|[^a-z0-9])(xbox\s*series(?:\s*[xs])?|xbsx|xss)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Xbox One", + igdbIds: [49], + igdbNames: ["Xbox One"], + releasePattern: /(?:^|[^a-z0-9])(xbox\s*one|xbone)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Xbox 360", + igdbIds: [12], + igdbNames: ["Xbox 360"], + releasePattern: /(?:^|[^a-z0-9])(xbox\s*360|xbox360|x360)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Xbox Classic", + igdbIds: [11], + igdbNames: ["Xbox"], + releasePattern: /(?:^|[^a-z0-9])(xbox(?:\s*(?:classic|original))?|xb)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Switch", + igdbIds: [130], + igdbNames: ["Nintendo Switch"], + releasePattern: /(?:^|[^a-z0-9])(nintendo\s*switch|switch|nsw)(?=$|[^a-z0-9])/i, + }, + { + canonical: "GameCube", + igdbIds: [21], + igdbNames: ["Nintendo GameCube"], + releasePattern: /(?:^|[^a-z0-9])(game\s?cube|ngc|gc)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Wii U", + igdbIds: [41], + igdbNames: ["Wii U"], + releasePattern: /(?:^|[^a-z0-9])(wii\s*u|wiiu)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Wii", + igdbIds: [5], + igdbNames: ["Wii"], + releasePattern: /(?:^|[^a-z0-9])(wii)(?=$|[^a-z0-9])/i, + }, + { + canonical: "3DS", + igdbIds: [37], + igdbNames: ["Nintendo 3DS"], + releasePattern: /(?:^|[^a-z0-9])(3ds)(?=$|[^a-z0-9])/i, + }, + { + canonical: "NDS", + igdbIds: [20], + igdbNames: ["Nintendo DS"], + releasePattern: /(?:^|[^a-z0-9])(nds|nintendo\s*ds|ds)(?=$|[^a-z0-9])/i, + }, + { + canonical: "N64", + igdbIds: [4], + igdbNames: ["Nintendo 64"], + releasePattern: /(?:^|[^a-z0-9])(n64|nintendo\s*64)(?=$|[^a-z0-9])/i, + }, + { + canonical: "SNES", + igdbIds: [19], + igdbNames: ["Super Nintendo Entertainment System"], + releasePattern: /(?:^|[^a-z0-9])(snes|super\s*nintendo|super\s*nes)(?=$|[^a-z0-9])/i, + }, + { + canonical: "NES", + igdbIds: [18], + igdbNames: ["Nintendo Entertainment System"], + releasePattern: /(?:^|[^a-z0-9])(nes|famicom)(?=$|[^a-z0-9])/i, + }, + { + canonical: "GBA", + igdbIds: [24], + igdbNames: ["Game Boy Advance"], + releasePattern: /(?:^|[^a-z0-9])(gba|game\s*boy\s*advance)(?=$|[^a-z0-9])/i, + }, + { + canonical: "GBC", + igdbIds: [22], + igdbNames: ["Game Boy Color"], + releasePattern: /(?:^|[^a-z0-9])(gbc|game\s*boy\s*color)(?=$|[^a-z0-9])/i, + }, + { + canonical: "GB", + igdbIds: [33], + igdbNames: ["Game Boy"], + releasePattern: /(?:^|[^a-z0-9])(game\s*boy|gb)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Dreamcast", + igdbIds: [23], + igdbNames: ["Dreamcast"], + releasePattern: /(?:^|[^a-z0-9])(dreamcast|dc)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Mega Drive", + igdbIds: [29], + igdbNames: ["Sega Mega Drive/Genesis"], + releasePattern: /(?:^|[^a-z0-9])(megadrive|mega\s*drive|genesis)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Master System", + igdbIds: [64], + igdbNames: ["Sega Master System/Mark III"], + releasePattern: /(?:^|[^a-z0-9])(master\s*system|sms)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Neo Geo", + igdbIds: [80], + igdbNames: ["Neo Geo AES"], + releasePattern: /(?:^|[^a-z0-9])(neo\s*geo|neogeo)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Atari 2600", + igdbIds: [59], + igdbNames: ["Atari 2600"], + releasePattern: /(?:^|[^a-z0-9])(atari\s*2600|a2600)(?=$|[^a-z0-9])/i, + }, + { + canonical: "PC", + igdbIds: [6], + igdbNames: ["PC (Microsoft Windows)"], + releasePattern: /(?:^|[^a-z0-9])(pc|windows|win64|win32)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Linux", + igdbIds: [3], + igdbNames: ["Linux"], + releasePattern: /(?:^|[^a-z0-9])(linux)(?=$|[^a-z0-9])/i, + }, + { + canonical: "Mac", + igdbIds: [14], + igdbNames: ["Mac"], + releasePattern: /(?:^|[^a-z0-9])(mac|macos|osx)(?=$|[^a-z0-9])/i, + }, ] as const; -export type CanonicalPlatform = (typeof CANONICAL_PLATFORMS)[number]; + +export type CanonicalPlatform = (typeof PLATFORM_CATALOG)[number]["canonical"]; +// Keep the legacy account-wide Xbox umbrella selectable while explicit game targets use +// generation-specific catalog entries. +export const CANONICAL_PLATFORMS: readonly string[] = [ + "Xbox", + ...PLATFORM_CATALOG.map(({ canonical }) => canonical), +]; + +const normalizePlatformName = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, ""); + +/** Resolves a stored IGDB platform pair to the release label used by automation. */ +export function resolveTargetPlatform( + targetPlatformId: number | null | undefined, + targetPlatformName: string | null | undefined +): CanonicalPlatform | null { + if (targetPlatformId == null || !targetPlatformName) return null; + const normalizedName = normalizePlatformName(targetPlatformName); + return ( + PLATFORM_CATALOG.find( + ({ igdbIds, igdbNames }) => + (igdbIds as readonly number[]).includes(targetPlatformId) && + igdbNames.some((name) => normalizePlatformName(name) === normalizedName) + )?.canonical ?? null + ); +} + +export const UNSUPPORTED_TARGET_PLATFORM = "__unsupported_target_platform__"; + +/** Resolve a game's explicit target or preserve the account-wide fallback for legacy rows. */ +export function resolveGamePlatformPreference( + game: { targetPlatformId?: number | null; targetPlatformName?: string | null }, + fallback: string | null +): string | null { + const hasTargetId = game.targetPlatformId != null; + const hasTargetName = game.targetPlatformName != null; + if (!hasTargetId && !hasTargetName) return fallback; + if (!hasTargetId || !hasTargetName) return UNSUPPORTED_TARGET_PLATFORM; + return ( + resolveTargetPlatform(game.targetPlatformId, game.targetPlatformName) ?? + UNSUPPORTED_TARGET_PLATFORM + ); +} /** * Returns true if a release's detected platform matches the preferred platform filter. @@ -292,10 +482,11 @@ export function matchesPlatformFilter( if (preferredPlatform === "PC") { return !releasePlatform || releasePlatform === "PC"; } - // "Xbox" is a superset that covers both Xbox and Xbox Series releases. - // Users who select "Xbox" should not miss Xbox Series titles. + // Preserve the legacy account-wide "Xbox" setting as a cross-generation umbrella. if (preferredPlatform === "Xbox") { - return releasePlatform === "Xbox" || releasePlatform === "Xbox Series"; + return ["Xbox", "Xbox Classic", "Xbox 360", "Xbox One", "Xbox Series"].includes( + releasePlatform ?? "" + ); } return releasePlatform === preferredPlatform; }