({
+ 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(() => {
@@ -75,12 +100,14 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
setSearchQuery("");
setDebouncedQuery("");
setShowUndatedGames(false);
+ setSelectedPlatform("all");
+ setReleaseYear("");
}
}, [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 +115,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,6 +174,14 @@ 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 handleAddGame = (searchResult: SearchResult) => {
// Map to InsertGame to filter out client-only fields before sending to server
const gameData = mapGameToInsertGame(searchResult);
@@ -156,7 +198,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,11 +254,37 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
aria-label="Search games"
/>
+
+
+
+
+
+
+ All platforms
+ {platforms.map((platform) => (
+
+ {platform.name}
+
+ ))}
+
+
+ handleReleaseYearChange(event.target.value)}
+ aria-label="Release year filter"
+ />
+
Show undated games first
@@ -378,6 +446,32 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
+
+
+
+
+
+
+ All platforms
+ {platforms.map((platform) => (
+
+ {platform.name}
+
+ ))}
+
+
+ handleReleaseYearChange(event.target.value)}
+ aria-label="Release year filter"
+ />
+
+
Show undated games first
@@ -385,7 +479,11 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
Include titles without a release date and place them before dated results.
-
+
diff --git a/server/__tests__/api_routes.test.ts b/server/__tests__/api_routes.test.ts
index 3a7677081..fdf5eacc5 100644
--- a/server/__tests__/api_routes.test.ts
+++ b/server/__tests__/api_routes.test.ts
@@ -892,6 +892,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__/igdb.test.ts b/server/__tests__/igdb.test.ts
index 89c64de26..5bbd0c146 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 = {
diff --git a/server/igdb.ts b/server/igdb.ts
index d6d66ef9c..ccef752ba 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) {
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..5664ea3b4 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -1734,7 +1734,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 +1747,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)
);
From 990f183c3b215fe0f1597e86b0cf49d1583d89cc Mon Sep 17 00:00:00 2001
From: noahbare <50649628+noahbare@users.noreply.github.com>
Date: Mon, 10 Aug 2026 01:36:16 +0000
Subject: [PATCH 2/4] fix: deduplicate game discovery filters
---
client/src/components/AddGameModal.tsx | 129 +++++++++++--------------
1 file changed, 56 insertions(+), 73 deletions(-)
diff --git a/client/src/components/AddGameModal.tsx b/client/src/components/AddGameModal.tsx
index db3756028..4e9d77681 100644
--- a/client/src/components/AddGameModal.tsx
+++ b/client/src/components/AddGameModal.tsx
@@ -182,6 +182,60 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
}
};
+ const renderDiscoveryFilters = (compact = false) => (
+
+
+
+
+
+
+
+ All platforms
+ {platforms.map((platform) => (
+
+ {platform.name}
+
+ ))}
+
+
+ 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 handleAddGame = (searchResult: SearchResult) => {
// Map to InsertGame to filter out client-only fields before sending to server
const gameData = mapGameToInsertGame(searchResult);
@@ -254,40 +308,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
aria-label="Search games"
/>
-
-
-
-
-
-
- All platforms
- {platforms.map((platform) => (
-
- {platform.name}
-
- ))}
-
-
- handleReleaseYearChange(event.target.value)}
- aria-label="Release year filter"
- />
-
-
- Show undated games first
-
-
+ {renderDiscoveryFilters(true)}
{/* Scrollable results */}
@@ -446,45 +467,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
-
-
-
-
-
-
- All platforms
- {platforms.map((platform) => (
-
- {platform.name}
-
- ))}
-
-
- handleReleaseYearChange(event.target.value)}
- aria-label="Release year filter"
- />
-
-
-
-
-
Show undated games first
-
- Include titles without a release date and place them before dated results.
-
-
-
-
+ {renderDiscoveryFilters()}
{isSearching && (
From b6b80bb302f7743f5e67137f3db6921121ede878 Mon Sep 17 00:00:00 2001
From: noahbare <50649628+noahbare@users.noreply.github.com>
Date: Mon, 10 Aug 2026 01:30:19 +0000
Subject: [PATCH 3/4] feat: add per-game target platforms
---
client/__tests__/AddGameModal.test.tsx | 30 +
client/__tests__/GameDetailsModal.test.tsx | 40 +
client/src/components/AddGameModal.tsx | 68 +-
client/src/components/GameDetailsModal.tsx | 94 +-
client/src/lib/utils.ts | 2 +
migrations/0026_simple_speed_demon.sql | 2 +
migrations/meta/0026_snapshot.json | 1704 +++++++++++++++++
migrations/meta/_journal.json | 9 +-
server/__tests__/api_routes.test.ts | 45 +
.../__tests__/fixtures/common-route-mocks.ts | 1 +
.../__tests__/helpers/import-test-helpers.ts | 2 +
server/__tests__/igdb.test.ts | 17 +
server/cron.ts | 7 +-
server/igdb.ts | 1 +
server/routes.ts | 31 +
server/storage.ts | 2 +
shared/__tests__/schema.test.ts | 79 +-
shared/__tests__/title_utils.test.ts | 43 +-
shared/schema.ts | 62 +-
shared/title-utils.ts | 284 ++-
20 files changed, 2460 insertions(+), 63 deletions(-)
create mode 100644 migrations/0026_simple_speed_demon.sql
create mode 100644 migrations/meta/0026_snapshot.json
diff --git a/client/__tests__/AddGameModal.test.tsx b/client/__tests__/AddGameModal.test.tsx
index 44ee639ec..bdcdb10de 100644
--- a/client/__tests__/AddGameModal.test.tsx
+++ b/client/__tests__/AddGameModal.test.tsx
@@ -251,6 +251,36 @@ describe("AddGameModal", () => {
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 4e9d77681..0d1d12aa2 100644
--- a/client/src/components/AddGameModal.tsx
+++ b/client/src/components/AddGameModal.tsx
@@ -36,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 {
@@ -58,6 +60,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
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();
@@ -86,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) {
@@ -102,6 +110,7 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
setShowUndatedGames(false);
setSelectedPlatform("all");
setReleaseYear("");
+ setTargetPlatforms({});
}
}, [open, initialQuery]);
@@ -235,10 +244,61 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
);
+ 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 (
+ setTargetPlatforms((current) => ({ ...current, [key]: value }))}
+ >
+
+
+
+
+ Account default
+ {supportedOptions.map((platform) => (
+
+ {platform.name}
+
+ ))}
+
+
+ );
+ };
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);
};
@@ -407,6 +467,8 @@ export default function AddGameModal({ children, initialQuery }: AddGameModalPro
{game.summary}
)}
+
+ {renderTargetPlatformSelect(game)}
))}
@@ -537,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) && (
+
+
+
+ Automatic download target
+
+
{
+ const value = event.target.value;
+ setTargetPlatformValue(value);
+ const selected = supportedTargetPlatformOptions.find(
+ ({ id }) => String(id) === value
+ );
+ targetPlatformMutation.mutate(
+ selected
+ ? {
+ targetPlatformId: selected.id,
+ targetPlatformName: selected.name,
+ }
+ : { targetPlatformId: null, targetPlatformName: null }
+ );
+ }}
+ className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ Use account default
+ {supportedTargetPlatformOptions.map((platform) => (
+
+ {platform.name}
+
+ ))}
+
+
+ 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 fdf5eacc5..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";
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 5bbd0c146..f117c0d4e 100644
--- a/server/__tests__/igdb.test.ts
+++ b/server/__tests__/igdb.test.ts
@@ -536,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 ccef752ba..b3e275447 100644
--- a/server/igdb.ts
+++ b/server/igdb.ts
@@ -1096,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/routes.ts b/server/routes.ts
index 5664ea3b4..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 {
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..d4b4f664a 100644
--- a/shared/__tests__/title_utils.test.ts
+++ b/shared/__tests__/title_utils.test.ts
@@ -7,6 +7,9 @@ import {
parseReleaseMetadata,
parseJsonStringArray,
matchesPlatformFilter,
+ resolveGamePlatformPreference,
+ resolveTargetPlatform,
+ UNSUPPORTED_TARGET_PLATFORM,
} from "../title-utils.js";
describe("title-utils", () => {
@@ -109,9 +112,16 @@ 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.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 +131,37 @@ describe("title-utils", () => {
});
});
+ describe("target platform resolution", () => {
+ 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..eb4f3587c 100644
--- a/shared/title-utils.ts
+++ b/shared/title-utils.ts
@@ -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,235 @@ 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"];
+export const CANONICAL_PLATFORMS = 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 +477,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;
}
From 57a356e8899d15e9a41d8d2a9163083c63c99ef6 Mon Sep 17 00:00:00 2001
From: noahbare <50649628+noahbare@users.noreply.github.com>
Date: Mon, 10 Aug 2026 01:53:32 +0000
Subject: [PATCH 4/4] fix: preserve Xbox targeting compatibility
---
server/__tests__/cron_autosearch.test.ts | 103 +++++++++++++++++++++++
shared/__tests__/title_utils.test.ts | 7 ++
shared/title-utils.ts | 9 +-
3 files changed, 117 insertions(+), 2 deletions(-)
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/shared/__tests__/title_utils.test.ts b/shared/__tests__/title_utils.test.ts
index d4b4f664a..163a84ca9 100644
--- a/shared/__tests__/title_utils.test.ts
+++ b/shared/__tests__/title_utils.test.ts
@@ -10,6 +10,7 @@ import {
resolveGamePlatformPreference,
resolveTargetPlatform,
UNSUPPORTED_TARGET_PLATFORM,
+ CANONICAL_PLATFORMS,
} from "../title-utils.js";
describe("title-utils", () => {
@@ -121,6 +122,8 @@ describe("title-utils", () => {
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", () => {
@@ -132,6 +135,10 @@ 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");
diff --git a/shared/title-utils.ts b/shared/title-utils.ts
index eb4f3587c..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
@@ -426,7 +426,12 @@ export const PLATFORM_CATALOG = [
] as const;
export type CanonicalPlatform = (typeof PLATFORM_CATALOG)[number]["canonical"];
-export const CANONICAL_PLATFORMS = PLATFORM_CATALOG.map(({ canonical }) => 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, "");