diff --git a/client/__tests__/MobileBottomNav.test.tsx b/client/__tests__/MobileBottomNav.test.tsx index 951b598bb..dbc21de4a 100644 --- a/client/__tests__/MobileBottomNav.test.tsx +++ b/client/__tests__/MobileBottomNav.test.tsx @@ -9,7 +9,10 @@ import MobileBottomNav from "../src/components/MobileBottomNav"; vi.mock("lucide-react", () => { const Icon = () => ; return { + Ban: Icon, ClipboardList: Icon, + History: Icon, + Timer: Icon, MoreHorizontal: Icon, Calendar: Icon, Compass: Icon, diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a887c39c..61ccedae6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -41,6 +41,9 @@ const SetupPage = lazy(() => import("@/pages/auth/setup")); const StatsPage = lazy(() => import("@/pages/stats")); const LogsPage = lazy(() => import("@/pages/logs")); const ImportHistoryPage = lazy(() => import("@/pages/import-history")); +const ActivityHistoryPage = lazy(() => import("@/pages/activity-history")); +const ActivityBlocklistPage = lazy(() => import("@/pages/activity-blocklist")); +const SystemTasksPage = lazy(() => import("@/pages/system-tasks")); const PlayPage = lazy(() => import("@/pages/play")); const GameDetailsPage = lazy(() => import("@/pages/game-details")); @@ -142,6 +145,9 @@ function Router() { + + + diff --git a/client/src/components/navigation-items.ts b/client/src/components/navigation-items.ts index c4544490b..f446aa1fb 100644 --- a/client/src/components/navigation-items.ts +++ b/client/src/components/navigation-items.ts @@ -1,6 +1,9 @@ import { Calendar, ClipboardList, + Ban, + History, + Timer, Compass, Database, Download, @@ -65,7 +68,10 @@ export const managementNavigation: AppNavItem[] = [ export const activityNavigation: AppNavItem[] = [ { title: "Logs", url: "/logs", icon: ScrollText }, + { title: "History", url: "/activity/history", icon: History }, + { title: "Blocklist", url: "/activity/blocklist", icon: Ban }, { title: "Import History", url: "/activity/imports", icon: ClipboardList }, + { title: "System Tasks", url: "/system/tasks", icon: Timer }, ]; export const mobileBottomNavigation: AppNavItem[] = [ diff --git a/client/src/lib/routes.ts b/client/src/lib/routes.ts index 32fef2518..da13a9fb6 100644 --- a/client/src/lib/routes.ts +++ b/client/src/lib/routes.ts @@ -18,6 +18,9 @@ export const routePaths = { stats: "/stats", logs: "/logs", importHistory: "/activity/imports", + activityHistory: "/activity/history", + activityBlocklist: "/activity/blocklist", + systemTasks: "/system/tasks", } as const; /** Wouter pattern for the per-game details page. */ @@ -47,4 +50,7 @@ export const appRoutePaths = [ routePaths.stats, routePaths.logs, routePaths.importHistory, + routePaths.activityHistory, + routePaths.activityBlocklist, + routePaths.systemTasks, ] as const; diff --git a/client/src/pages/activity-blocklist.tsx b/client/src/pages/activity-blocklist.tsx new file mode 100644 index 000000000..14a1c557b --- /dev/null +++ b/client/src/pages/activity-blocklist.tsx @@ -0,0 +1,223 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Ban, Search, Trash2 } from "lucide-react"; +import { Link } from "wouter"; +import PageHeader from "@/components/PageHeader"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import { routePaths } from "@/lib/routes"; +import type { ReleaseBlacklist } from "@shared/schema"; + +type BlocklistEntry = ReleaseBlacklist & { gameTitle: string }; + +function formatDate(value: Date | string | null): string { + return value ? new Date(value).toLocaleDateString() : "—"; +} + +export default function ActivityBlocklistPage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [filter, setFilter] = useState(""); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["/api/blacklist"], + }); + + const removeMutation = useMutation({ + mutationFn: async ({ gameId, id }: { gameId: string; id: string }) => { + await apiRequest("DELETE", `/api/games/${gameId}/blacklist/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/blacklist"] }); + toast({ description: "Release removed from the blocklist" }); + }, + onError: () => toast({ variant: "destructive", description: "Failed to remove the release" }), + }); + + const clearMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("DELETE", "/api/activity/blocklist"); + return (await res.json()) as { removed: number }; + }, + onSuccess: ({ removed }) => { + queryClient.invalidateQueries({ queryKey: ["/api/blacklist"] }); + toast({ description: `${removed} release${removed === 1 ? "" : "s"} unblocked` }); + }, + onError: () => toast({ variant: "destructive", description: "Failed to clear the blocklist" }), + }); + + const entries = useMemo(() => { + const needle = filter.trim().toLowerCase(); + const all = data ?? []; + if (!needle) return all; + return all.filter( + (e) => + e.releaseTitle.toLowerCase().includes(needle) || + e.gameTitle.toLowerCase().includes(needle) || + (e.indexerName ?? "").toLowerCase().includes(needle) + ); + }, [data, filter]); + + const remove = (entry: BlocklistEntry) => + removeMutation.mutate({ gameId: entry.gameId, id: entry.id }); + + let body; + if (isLoading) { + body =

Loading...

; + } else if (isError) { + body = ( +
+ Could not load the blocklist. + +
+ ); + } else if (entries.length === 0) { + body = ( +
+
+ ); + } else { + body = ( + <> +
    + {entries.map((entry) => ( +
  • +

    + {entry.releaseTitle} +

    +

    + {entry.gameTitle} · {entry.indexerName ?? "unknown indexer"} ·{" "} + {formatDate(entry.createdAt)} +

    + +
  • + ))} +
+
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
ReleaseGameIndexerBlocked on + Actions +
{entry.releaseTitle}{entry.gameTitle}{entry.indexerName ?? "—"}{formatDate(entry.createdAt)} + +
+
+ + ); + } + + return ( +
+ + + + + + + Unblock all releases? + + {data?.length ?? 0} blocked release{data?.length === 1 ? "" : "s"} will show up in + search results and can be auto-downloaded again. + + + + Cancel + clearMutation.mutate()}> + Unblock all + + + + + } + /> +

+ Release name terms that hide matching releases everywhere live in{" "} + + Settings + {" "} + under Discovery & Downloads. +

+
+
+ {body} +
+ ); +} diff --git a/client/src/pages/activity-history.tsx b/client/src/pages/activity-history.tsx new file mode 100644 index 000000000..385bc125b --- /dev/null +++ b/client/src/pages/activity-history.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { History, Search } from "lucide-react"; +import PageHeader from "@/components/PageHeader"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { DownloadEvent, DownloadEventType } from "@shared/schema"; + +const PAGE_SIZE = 50; + +const EVENT_LABELS: Record = { + grabbed: { label: "Grabbed", className: "border-blue-700/50 bg-blue-900/40 text-blue-400" }, + completed: { + label: "Completed", + className: "border-emerald-700/50 bg-emerald-900/40 text-emerald-400", + }, + imported: { label: "Imported", className: "border-teal-700/50 bg-teal-900/40 text-teal-400" }, + failed: { label: "Failed", className: "border-red-700/50 bg-red-900/40 text-red-400" }, + needs_review: { + label: "Needs review", + className: "border-amber-700/50 bg-amber-900/40 text-amber-400", + }, + removed: { label: "Removed", className: "border-slate-600/50 bg-slate-800/60 text-slate-300" }, + blocklisted: { + label: "Blocklisted", + className: "border-purple-700/50 bg-purple-900/40 text-purple-400", + }, +}; + +function EventBadge({ type }: Readonly<{ type: DownloadEventType }>) { + const meta = EVENT_LABELS[type] ?? { label: type, className: "" }; + return {meta.label}; +} + +function formatDateTime(value: Date | string | null): string { + return value ? new Date(value).toLocaleString() : "—"; +} + +export default function ActivityHistoryPage() { + const [eventType, setEventType] = useState("all"); + const [page, setPage] = useState(0); + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); + + // Query the server once the user pauses typing, always from the first page. + useEffect(() => { + const timer = setTimeout(() => { + setSearch(searchInput.trim()); + setPage(0); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + offset: String(page * PAGE_SIZE), + }); + if (eventType !== "all") params.set("eventType", eventType); + if (search) params.set("search", search); + const url = `/api/activity/history?${params.toString()}`; + + const { data, isLoading, isError, refetch } = useQuery<{ + events: DownloadEvent[]; + total: number; + }>({ + queryKey: [url], + placeholderData: keepPreviousData, + }); + + const events = data?.events ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + let body; + if (isLoading) { + body =

Loading...

; + } else if (isError) { + body = ( +
+ Could not load the history. + +
+ ); + } else if (events.length === 0) { + body = ( +
+
+ ); + } else { + body = ( + <> +
    + {events.map((event) => ( +
  • +
    +

    + {event.releaseTitle} +

    + +
    +

    + {event.gameTitle} + {event.downloaderName ? ` · ${event.downloaderName}` : ""} ·{" "} + {formatDateTime(event.createdAt)} +

    + {event.message && ( +

    {event.message}

    + )} +
  • + ))} +
+
+ + + + + + + + + + + + {events.map((event) => ( + + + + + + + + ))} + +
ReleaseGameEventClient / indexerWhen
+

{event.releaseTitle}

+ {event.message && ( +

{event.message}

+ )} +
{event.gameTitle} + + {event.downloaderName ?? "—"} + {formatDateTime(event.createdAt)} +
+
+ {pageCount > 1 && ( +
+ + + Page {page + 1} of {pageCount} + + +
+ )} + + ); + } + + return ( +
+ { + setEventType(value as DownloadEventType | "all"); + setPage(0); + }} + > + + + + + All events + {Object.entries(EVENT_LABELS).map(([value, meta]) => ( + + {meta.label} + + ))} + + + } + /> +
+
+ {body} +
+ ); +} diff --git a/client/src/pages/system-tasks.tsx b/client/src/pages/system-tasks.tsx new file mode 100644 index 000000000..2fb95282f --- /dev/null +++ b/client/src/pages/system-tasks.tsx @@ -0,0 +1,279 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, CheckCircle, Clock, Loader2, Play, Search, Timer } from "lucide-react"; +import PageHeader from "@/components/PageHeader"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import { getSocket } from "@/lib/socket"; + +interface SystemTask { + name: string; + title: string; + description: string; + intervalMs: number; + running: boolean; + lastStartedAt: number | null; + lastFinishedAt: number | null; + lastDurationMs: number | null; + lastResult: "success" | "error" | null; + lastError: string | null; + nextRunAt: number | null; +} + +function formatInterval(ms: number): string { + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return `${Math.round(ms / 1000)} s`; + if (minutes < 60) return `${minutes} min`; + const hours = minutes / 60; + return Number.isInteger(hours) ? `${hours} h` : `${hours.toFixed(1)} h`; +} + +function formatDuration(ms: number | null): string { + if (ms === null) return "—"; + if (ms < 1000) return `${ms} ms`; + const secs = Math.round(ms / 1000); + return secs < 60 ? `${secs} s` : `${Math.floor(secs / 60)} min ${secs % 60} s`; +} + +function formatRelative(ts: number | null, now: number): string { + if (ts === null) return "—"; + const diffSec = Math.round((ts - now) / 1000); + const abs = Math.abs(diffSec); + let text: string; + if (abs < 60) text = `${abs} s`; + else if (abs < 3600) text = `${Math.round(abs / 60)} min`; + else if (abs < 86_400) text = `${Math.round(abs / 3600)} h`; + else text = `${Math.round(abs / 86_400)} d`; + return diffSec >= 0 ? `in ${text}` : `${text} ago`; +} + +function ResultBadge({ task }: Readonly<{ task: SystemTask }>) { + if (task.running) { + return ( + + + ); + } + if (task.lastResult === "success") { + return ( + + + ); + } + if (task.lastResult === "error") { + return ( + + + ); + } + return ( + + + ); +} + +export default function SystemTasksPage() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + const [now, setNow] = useState(() => Date.now()); + + const { data, isError, refetch } = useQuery<{ tasks: SystemTask[] }>({ + queryKey: ["/api/system/tasks"], + refetchInterval: (query) => (query.state.data?.tasks.some((t) => t.running) ? 3000 : false), + }); + const [filter, setFilter] = useState(""); + const tasks = useMemo(() => { + const needle = filter.trim().toLowerCase(); + const all = data?.tasks ?? []; + if (!needle) return all; + return all.filter( + (t) => t.title.toLowerCase().includes(needle) || t.description.toLowerCase().includes(needle) + ); + }, [data, filter]); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 10_000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + const socket = getSocket(); + const refresh = () => queryClient.invalidateQueries({ queryKey: ["/api/system/tasks"] }); + socket.on("task:started", refresh); + socket.on("task:finished", refresh); + socket.on("connect", refresh); + return () => { + socket.off("task:started", refresh); + socket.off("task:finished", refresh); + socket.off("connect", refresh); + }; + }, [queryClient]); + + const runTask = useMutation({ + mutationKey: ["system-task-run"], + mutationFn: async (name: string) => { + await apiRequest("POST", `/api/system/tasks/${encodeURIComponent(name)}/run`); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["/api/system/tasks"] }), + onError: (error: Error) => + toast({ title: "Could not start task", description: error.message, variant: "destructive" }), + }); + + const pendingTaskNames = useMutationState({ + filters: { mutationKey: ["system-task-run"], status: "pending" }, + select: (mutation) => mutation.state.variables as string, + }); + + return ( +
+ + +
+
+ + {isError && !data ? ( +
+ Could not load the scheduled tasks. + +
+ ) : tasks.length === 0 ? ( +
+
+ ) : ( + <> + {/* Phones: one card per task */} +
    + {tasks.map((task) => ( +
  • +
    +
    +

    {task.title}

    +

    {task.description}

    +
    + +
    +
    +
    Every
    +
    {formatInterval(task.intervalMs)}
    +
    Last run
    +
    {formatRelative(task.lastFinishedAt, now)}
    +
    Duration
    +
    {formatDuration(task.lastDurationMs)}
    +
    Next run
    +
    {task.running ? "—" : formatRelative(task.nextRunAt, now)}
    +
    + {task.lastError && ( +

    {task.lastError}

    + )} + +
  • + ))} +
+ + {/* Tablet and desktop: table */} +
+ + + + + + + + + + + + + + {tasks.map((task) => ( + + + + + + + + + + ))} + +
TaskIntervalLast runDurationResultNext run + Actions +
+

{task.title}

+

{task.description}

+ {task.lastError && ( +

{task.lastError}

+ )} +
{formatInterval(task.intervalMs)} + {formatRelative(task.lastFinishedAt, now)} + + {formatDuration(task.lastDurationMs)} + + + + {task.running ? "—" : formatRelative(task.nextRunAt, now)} + + +
+
+ + )} +
+ ); +} diff --git a/migrations-pg/0010_next_pepper_potts.sql b/migrations-pg/0010_next_pepper_potts.sql new file mode 100644 index 000000000..9ea9e57d4 --- /dev/null +++ b/migrations-pg/0010_next_pepper_potts.sql @@ -0,0 +1,15 @@ +CREATE TABLE "download_events" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "game_id" text, + "game_title" text NOT NULL, + "event_type" text NOT NULL, + "release_title" text NOT NULL, + "downloader_name" text, + "message" text, + "created_at" bigint DEFAULT (EXTRACT(EPOCH FROM now()) * 1000)::bigint +); +--> statement-breakpoint +ALTER TABLE "download_events" ADD CONSTRAINT "download_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "download_events" ADD CONSTRAINT "download_events_game_id_games_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "download_events_user_created_idx" ON "download_events" USING btree ("user_id","created_at"); \ No newline at end of file diff --git a/migrations-pg/meta/0010_snapshot.json b/migrations-pg/meta/0010_snapshot.json new file mode 100644 index 000000000..68fa46ce7 --- /dev/null +++ b/migrations-pg/meta/0010_snapshot.json @@ -0,0 +1,2428 @@ +{ + "id": "9199bca5-2e0b-425b-8420-f240a59f3b8d", + "prevId": "419419c1-f0e2-433d-921f-08747b8a151b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_auto_download_holds": { + "name": "ai_auto_download_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "ai_auto_download_holds_game_title_idx": { + "name": "ai_auto_download_holds_game_title_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "release_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_auto_download_holds_game_id_games_id_fk": { + "name": "ai_auto_download_holds_game_id_games_id_fk", + "tableFrom": "ai_auto_download_holds", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "last_used_at": { + "name": "last_used_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.download_events": { + "name": "download_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "game_title": { + "name": "game_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "downloader_name": { + "name": "downloader_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "download_events_user_created_idx": { + "name": "download_events_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "download_events_user_id_users_id_fk": { + "name": "download_events_user_id_users_id_fk", + "tableFrom": "download_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "download_events_game_id_games_id_fk": { + "name": "download_events_game_id_games_id_fk", + "tableFrom": "download_events", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.downloaders": { + "name": "downloaders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_ssl": { + "name": "use_ssl", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "url_path": { + "name": "url_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_self_signed_certificate": { + "name": "allow_self_signed_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_insecure_lan": { + "name": "allow_insecure_lan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "download_path": { + "name": "download_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'games'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Questarr'" + }, + "add_stopped": { + "name": "add_stopped", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "remove_completed": { + "name": "remove_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "post_import_category": { + "name": "post_import_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_downloads": { + "name": "game_downloads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "downloader_id": { + "name": "downloader_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "download_type": { + "name": "download_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'torrent'" + }, + "download_hash": { + "name": "download_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "download_title": { + "name": "download_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'downloading'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "completed_at": { + "name": "completed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_downloads_downloader_hash_idx": { + "name": "game_downloads_downloader_hash_idx", + "columns": [ + { + "expression": "downloader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "download_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_downloads_game_id_games_id_fk": { + "name": "game_downloads_game_id_games_id_fk", + "tableFrom": "game_downloads", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_downloads_downloader_id_downloaders_id_fk": { + "name": "game_downloads_downloader_id_downloaders_id_fk", + "tableFrom": "game_downloads", + "tableTo": "downloaders", + "columnsFrom": ["downloader_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_files": { + "name": "game_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "download_id": { + "name": "download_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category_overridden": { + "name": "category_overridden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "game_files_game_id_idx": { + "name": "game_files_game_id_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_files_download_id_idx": { + "name": "game_files_download_id_idx", + "columns": [ + { + "expression": "download_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_files_game_id_games_id_fk": { + "name": "game_files_game_id_games_id_fk", + "tableFrom": "game_files", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_files_download_id_game_downloads_id_fk": { + "name": "game_files_download_id_game_downloads_id_fk", + "tableFrom": "game_files", + "tableTo": "game_downloads", + "columnsFrom": ["download_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_journal_entries": { + "name": "game_journal_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "game_journal_entries_game_user_idx": { + "name": "game_journal_entries_game_user_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_journal_entries_game_id_games_id_fk": { + "name": "game_journal_entries_game_id_games_id_fk", + "tableFrom": "game_journal_entries", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_journal_entries_user_id_users_id_fk": { + "name": "game_journal_entries_user_id_users_id_fk", + "tableFrom": "game_journal_entries", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_milestones": { + "name": "game_milestones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "game_milestones_game_user_idx": { + "name": "game_milestones_game_user_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_milestones_game_id_games_id_fk": { + "name": "game_milestones_game_id_games_id_fk", + "tableFrom": "game_milestones", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_milestones_user_id_users_id_fk": { + "name": "game_milestones_user_id_users_id_fk", + "tableFrom": "game_milestones", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_screenshots": { + "name": "game_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "game_screenshots_game_user_idx": { + "name": "game_screenshots_game_user_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_screenshots_game_id_games_id_fk": { + "name": "game_screenshots_game_id_games_id_fk", + "tableFrom": "game_screenshots", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_screenshots_user_id_users_id_fk": { + "name": "game_screenshots_user_id_users_id_fk", + "tableFrom": "game_screenshots", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.games": { + "name": "games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steam_appid": { + "name": "steam_appid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_platform_id": { + "name": "target_platform_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_platform_name": { + "name": "target_platform_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "themes": { + "name": "themes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "screenshots": { + "name": "screenshots", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "videos": { + "name": "videos", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'manual'" + }, + "igdb_websites": { + "name": "igdb_websites", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expansions": { + "name": "expansions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "aggregated_rating": { + "name": "aggregated_rating", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "time_to_beat_hastily": { + "name": "time_to_beat_hastily", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "time_to_beat_normally": { + "name": "time_to_beat_normally", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "time_to_beat_completely": { + "name": "time_to_beat_completely", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'wanted'" + }, + "original_release_date": { + "name": "original_release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_status": { + "name": "release_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'upcoming'" + }, + "early_access": { + "name": "early_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_adult_content": { + "name": "is_adult_content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_age_restricted": { + "name": "is_age_restricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_rating": { + "name": "user_rating", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "library_path": { + "name": "library_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_version": { + "name": "installed_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_results_available": { + "name": "search_results_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "search_results_available_at": { + "name": "search_results_available_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "update_search_results_available": { + "name": "update_search_results_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "packs_search_results_available": { + "name": "packs_search_results_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "added_at": { + "name": "added_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "completed_at": { + "name": "completed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "games_user_id_users_id_fk": { + "name": "games_user_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_task_items": { + "name": "import_task_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "game_title": { + "name": "game_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "import_task_items_task_id_idx": { + "name": "import_task_items_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_task_items_task_id_import_tasks_id_fk": { + "name": "import_task_items_task_id_import_tasks_id_fk", + "tableFrom": "import_task_items", + "tableTo": "import_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_tasks": { + "name": "import_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "started_at": { + "name": "started_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added_items": { + "name": "added_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_items": { + "name": "skipped_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "import_tasks_user_id_users_id_fk": { + "name": "import_tasks_user_id_users_id_fk", + "tableFrom": "import_tasks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexers": { + "name": "indexers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'torznab'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "rss_enabled": { + "name": "rss_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_search_enabled": { + "name": "auto_search_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_insecure_lan": { + "name": "allow_insecure_lan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.path_mappings": { + "name": "path_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "remote_path": { + "name": "remote_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_path": { + "name": "local_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_host": { + "name": "remote_host", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_mappings": { + "name": "platform_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "igdb_platform_id": { + "name": "igdb_platform_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_platform_name": { + "name": "source_platform_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_blacklist": { + "name": "release_blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "indexer_name": { + "name": "indexer_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": { + "release_blacklist_game_title_idx": { + "name": "release_blacklist_game_title_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "release_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_blacklist_game_id_games_id_fk": { + "name": "release_blacklist_game_id_games_id_fk", + "tableFrom": "release_blacklist", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.root_folders": { + "name": "root_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_delete": { + "name": "allow_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "accessible": { + "name": "accessible", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disk_free_bytes": { + "name": "disk_free_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "disk_total_bytes": { + "name": "disk_total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "root_folders_path_unique": { + "name": "root_folders_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rss_feed_items": { + "name": "rss_feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pub_date": { + "name": "pub_date", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_name": { + "name": "source_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "igdb_game_id": { + "name": "igdb_game_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "igdb_game_name": { + "name": "igdb_game_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "rss_feed_items_feed_id_rss_feeds_id_fk": { + "name": "rss_feed_items_feed_id_rss_feeds_id_fk", + "tableFrom": "rss_feed_items", + "tableTo": "rss_feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rss_feeds": { + "name": "rss_feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mapping": { + "name": "mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_check": { + "name": "last_check", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ok'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_config": { + "name": "system_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_search_enabled": { + "name": "auto_search_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_download_enabled": { + "name": "auto_download_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notification_preferences": { + "name": "notification_preferences", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_interval_hours": { + "name": "search_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "igdb_rate_limit_per_second": { + "name": "igdb_rate_limit_per_second", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "download_rules": { + "name": "download_rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_auto_search": { + "name": "last_auto_search", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "xrel_scene_releases": { + "name": "xrel_scene_releases", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "xrel_p2p_releases": { + "name": "xrel_p2p_releases", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_search_unreleased": { + "name": "auto_search_unreleased", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "steam_sync_failures": { + "name": "steam_sync_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "steam_sync_enabled": { + "name": "steam_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "steam_sync_interval_hours": { + "name": "steam_sync_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "last_steam_sync": { + "name": "last_steam_sync", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "preferred_release_groups": { + "name": "preferred_release_groups", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filter_by_preferred_groups": { + "name": "filter_by_preferred_groups", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "release_name_blacklist": { + "name": "release_name_blacklist", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_platform": { + "name": "preferred_platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hide_adult_content": { + "name": "hide_adult_content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "hide_age_restricted_content": { + "name": "hide_age_restricted_content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "hide_shelved_by_default": { + "name": "hide_shelved_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "hide_owned_in_has_results": { + "name": "hide_owned_in_has_results", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_post_processing": { + "name": "enable_post_processing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_unpack": { + "name": "auto_unpack", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rename_pattern": { + "name": "rename_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{Title} ({Region})'" + }, + "overwrite_existing": { + "name": "overwrite_existing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transfer_mode": { + "name": "transfer_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hardlink'" + }, + "import_platform_ids": { + "name": "import_platform_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "ignored_extensions": { + "name": "ignored_extensions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "min_file_size": { + "name": "min_file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "library_root": { + "name": "library_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/data'" + }, + "auto_delete_after_import": { + "name": "auto_delete_after_import", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_extras": { + "name": "sort_extras", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steam_id_64": { + "name": "steam_id_64", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.xrel_notified_releases": { + "name": "xrel_notified_releases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "xrel_release_id": { + "name": "xrel_release_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(EPOCH FROM now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "xrel_notified_releases_game_id_games_id_fk": { + "name": "xrel_notified_releases_game_id_games_id_fk", + "tableFrom": "xrel_notified_releases", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations-pg/meta/_journal.json b/migrations-pg/meta/_journal.json index 986495e19..30da228fe 100644 --- a/migrations-pg/meta/_journal.json +++ b/migrations-pg/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1791870870205, "tag": "0009_goofy_corsair", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1792406180560, + "tag": "0010_next_pepper_potts", + "breakpoints": true } ] } diff --git a/migrations/0046_unknown_moonstone.sql b/migrations/0046_unknown_moonstone.sql new file mode 100644 index 000000000..bf08ce876 --- /dev/null +++ b/migrations/0046_unknown_moonstone.sql @@ -0,0 +1,15 @@ +CREATE TABLE `download_events` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `game_id` text, + `game_title` text NOT NULL, + `event_type` text NOT NULL, + `release_title` text NOT NULL, + `downloader_name` text, + `message` text, + `created_at` integer DEFAULT (strftime('%s', 'now') * 1000), + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `download_events_user_created_idx` ON `download_events` (`user_id`,`created_at`); \ No newline at end of file diff --git a/migrations/meta/0046_snapshot.json b/migrations/meta/0046_snapshot.json new file mode 100644 index 000000000..265acae18 --- /dev/null +++ b/migrations/meta/0046_snapshot.json @@ -0,0 +1,2450 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "41ce72ab-0263-4d75-8639-47bcf0401194", + "prevId": "001e5cb4-05bf-4371-bda1-81a7ae5b05f2", + "tables": { + "ai_auto_download_holds": { + "name": "ai_auto_download_holds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "ai_auto_download_holds_game_title_idx": { + "name": "ai_auto_download_holds_game_title_idx", + "columns": ["game_id", "release_title"], + "isUnique": true + } + }, + "foreignKeys": { + "ai_auto_download_holds_game_id_games_id_fk": { + "name": "ai_auto_download_holds_game_id_games_id_fk", + "tableFrom": "ai_auto_download_holds", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": ["key_hash"], + "isUnique": true + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_events": { + "name": "download_events", + "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 + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "game_title": { + "name": "game_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloader_name": { + "name": "downloader_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "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": { + "download_events_user_created_idx": { + "name": "download_events_user_created_idx", + "columns": ["user_id", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "download_events_user_id_users_id_fk": { + "name": "download_events_user_id_users_id_fk", + "tableFrom": "download_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "download_events_game_id_games_id_fk": { + "name": "download_events_game_id_games_id_fk", + "tableFrom": "download_events", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloaders": { + "name": "downloaders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_ssl": { + "name": "use_ssl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "url_path": { + "name": "url_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_self_signed_certificate": { + "name": "allow_self_signed_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_insecure_lan": { + "name": "allow_insecure_lan", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "download_path": { + "name": "download_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'games'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'Questarr'" + }, + "add_stopped": { + "name": "add_stopped", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "remove_completed": { + "name": "remove_completed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "post_import_category": { + "name": "post_import_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "game_downloads": { + "name": "game_downloads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloader_id": { + "name": "downloader_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "download_type": { + "name": "download_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'torrent'" + }, + "download_hash": { + "name": "download_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "download_title": { + "name": "download_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'downloading'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "game_downloads_downloader_hash_idx": { + "name": "game_downloads_downloader_hash_idx", + "columns": ["downloader_id", "download_hash"], + "isUnique": true + } + }, + "foreignKeys": { + "game_downloads_game_id_games_id_fk": { + "name": "game_downloads_game_id_games_id_fk", + "tableFrom": "game_downloads", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_downloads_downloader_id_downloaders_id_fk": { + "name": "game_downloads_downloader_id_downloaders_id_fk", + "tableFrom": "game_downloads", + "tableTo": "downloaders", + "columnsFrom": ["downloader_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "game_files": { + "name": "game_files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "download_id": { + "name": "download_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_overridden": { + "name": "category_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "game_files_game_id_idx": { + "name": "game_files_game_id_idx", + "columns": ["game_id"], + "isUnique": false + }, + "game_files_download_id_idx": { + "name": "game_files_download_id_idx", + "columns": ["download_id"], + "isUnique": false + } + }, + "foreignKeys": { + "game_files_game_id_games_id_fk": { + "name": "game_files_game_id_games_id_fk", + "tableFrom": "game_files", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_files_download_id_game_downloads_id_fk": { + "name": "game_files_download_id_game_downloads_id_fk", + "tableFrom": "game_files", + "tableTo": "game_downloads", + "columnsFrom": ["download_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "game_journal_entries": { + "name": "game_journal_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "game_journal_entries_game_user_idx": { + "name": "game_journal_entries_game_user_idx", + "columns": ["game_id", "user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "game_journal_entries_game_id_games_id_fk": { + "name": "game_journal_entries_game_id_games_id_fk", + "tableFrom": "game_journal_entries", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_journal_entries_user_id_users_id_fk": { + "name": "game_journal_entries_user_id_users_id_fk", + "tableFrom": "game_journal_entries", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "game_milestones": { + "name": "game_milestones", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "game_milestones_game_user_idx": { + "name": "game_milestones_game_user_idx", + "columns": ["game_id", "user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "game_milestones_game_id_games_id_fk": { + "name": "game_milestones_game_id_games_id_fk", + "tableFrom": "game_milestones", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_milestones_user_id_users_id_fk": { + "name": "game_milestones_user_id_users_id_fk", + "tableFrom": "game_milestones", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "game_screenshots": { + "name": "game_screenshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "game_screenshots_game_user_idx": { + "name": "game_screenshots_game_user_idx", + "columns": ["game_id", "user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "game_screenshots_game_id_games_id_fk": { + "name": "game_screenshots_game_id_games_id_fk", + "tableFrom": "game_screenshots", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_screenshots_user_id_users_id_fk": { + "name": "game_screenshots_user_id_users_id_fk", + "tableFrom": "game_screenshots", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "games": { + "name": "games", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steam_appid": { + "name": "steam_appid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platforms": { + "name": "platforms", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_platform_id": { + "name": "target_platform_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_platform_name": { + "name": "target_platform_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "genres": { + "name": "genres", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "themes": { + "name": "themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publishers": { + "name": "publishers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "developers": { + "name": "developers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "screenshots": { + "name": "screenshots", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videos": { + "name": "videos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "igdb_websites": { + "name": "igdb_websites", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expansions": { + "name": "expansions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aggregated_rating": { + "name": "aggregated_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_to_beat_hastily": { + "name": "time_to_beat_hastily", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_to_beat_normally": { + "name": "time_to_beat_normally", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_to_beat_completely": { + "name": "time_to_beat_completely", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'wanted'" + }, + "original_release_date": { + "name": "original_release_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_status": { + "name": "release_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'upcoming'" + }, + "early_access": { + "name": "early_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_adult_content": { + "name": "is_adult_content", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_age_restricted": { + "name": "is_age_restricted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "user_rating": { + "name": "user_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_path": { + "name": "library_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_version": { + "name": "installed_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_results_available": { + "name": "search_results_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "search_results_available_at": { + "name": "search_results_available_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_search_results_available": { + "name": "update_search_results_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "packs_search_results_available": { + "name": "packs_search_results_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "games_user_id_users_id_fk": { + "name": "games_user_id_users_id_fk", + "tableFrom": "games", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_task_items": { + "name": "import_task_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "game_title": { + "name": "game_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "import_task_items_task_id_idx": { + "name": "import_task_items_task_id_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "import_task_items_task_id_import_tasks_id_fk": { + "name": "import_task_items_task_id_import_tasks_id_fk", + "tableFrom": "import_task_items", + "tableTo": "import_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_tasks": { + "name": "import_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "added_items": { + "name": "added_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "skipped_items": { + "name": "skipped_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "import_tasks_user_id_users_id_fk": { + "name": "import_tasks_user_id_users_id_fk", + "tableFrom": "import_tasks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "indexers": { + "name": "indexers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'torznab'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "rss_enabled": { + "name": "rss_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "auto_search_enabled": { + "name": "auto_search_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_insecure_lan": { + "name": "allow_insecure_lan", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "path_mappings": { + "name": "path_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "remote_path": { + "name": "remote_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_path": { + "name": "local_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_host": { + "name": "remote_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platform_mappings": { + "name": "platform_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "igdb_platform_id": { + "name": "igdb_platform_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_platform_name": { + "name": "source_platform_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_blacklist": { + "name": "release_blacklist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_title": { + "name": "release_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "indexer_name": { + "name": "indexer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "release_blacklist_game_title_idx": { + "name": "release_blacklist_game_title_idx", + "columns": ["game_id", "release_title"], + "isUnique": true + } + }, + "foreignKeys": { + "release_blacklist_game_id_games_id_fk": { + "name": "release_blacklist_game_id_games_id_fk", + "tableFrom": "release_blacklist", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "root_folders": { + "name": "root_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_delete": { + "name": "allow_delete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "accessible": { + "name": "accessible", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_free_bytes": { + "name": "disk_free_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_total_bytes": { + "name": "disk_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "root_folders_path_unique": { + "name": "root_folders_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rss_feed_items": { + "name": "rss_feed_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pub_date": { + "name": "pub_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_name": { + "name": "source_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_game_id": { + "name": "igdb_game_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_game_name": { + "name": "igdb_game_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "rss_feed_items_feed_id_rss_feeds_id_fk": { + "name": "rss_feed_items_feed_id_rss_feeds_id_fk", + "tableFrom": "rss_feed_items", + "tableTo": "rss_feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rss_feeds": { + "name": "rss_feeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "mapping": { + "name": "mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check": { + "name": "last_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'ok'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_config": { + "name": "system_config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_settings": { + "name": "user_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_search_enabled": { + "name": "auto_search_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "auto_download_enabled": { + "name": "auto_download_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notification_preferences": { + "name": "notification_preferences", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_interval_hours": { + "name": "search_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "igdb_rate_limit_per_second": { + "name": "igdb_rate_limit_per_second", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "download_rules": { + "name": "download_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_auto_search": { + "name": "last_auto_search", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xrel_scene_releases": { + "name": "xrel_scene_releases", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "xrel_p2p_releases": { + "name": "xrel_p2p_releases", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_search_unreleased": { + "name": "auto_search_unreleased", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steam_sync_failures": { + "name": "steam_sync_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steam_sync_enabled": { + "name": "steam_sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steam_sync_interval_hours": { + "name": "steam_sync_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 24 + }, + "last_steam_sync": { + "name": "last_steam_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferred_release_groups": { + "name": "preferred_release_groups", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filter_by_preferred_groups": { + "name": "filter_by_preferred_groups", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "release_name_blacklist": { + "name": "release_name_blacklist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferred_platform": { + "name": "preferred_platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_adult_content": { + "name": "hide_adult_content", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hide_age_restricted_content": { + "name": "hide_age_restricted_content", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hide_shelved_by_default": { + "name": "hide_shelved_by_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hide_owned_in_has_results": { + "name": "hide_owned_in_has_results", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_post_processing": { + "name": "enable_post_processing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_unpack": { + "name": "auto_unpack", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rename_pattern": { + "name": "rename_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{Title} ({Region})'" + }, + "overwrite_existing": { + "name": "overwrite_existing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transfer_mode": { + "name": "transfer_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hardlink'" + }, + "import_platform_ids": { + "name": "import_platform_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "ignored_extensions": { + "name": "ignored_extensions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "min_file_size": { + "name": "min_file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "library_root": { + "name": "library_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/data'" + }, + "auto_delete_after_import": { + "name": "auto_delete_after_import", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_extras": { + "name": "sort_extras", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": { + "user_settings_user_id_unique": { + "name": "user_settings_user_id_unique", + "columns": ["user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "steam_id_64": { + "name": "steam_id_64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": ["username"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "xrel_notified_releases": { + "name": "xrel_notified_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "xrel_release_id": { + "name": "xrel_release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "xrel_notified_releases_game_id_games_id_fk": { + "name": "xrel_notified_releases_game_id_games_id_fk", + "tableFrom": "xrel_notified_releases", + "tableTo": "games", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 926d8bbe5..95e1e3f9d 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -330,6 +330,13 @@ "when": 1791870869466, "tag": "0045_dazzling_namorita", "breakpoints": true + }, + { + "idx": 46, + "version": "6", + "when": 1792406179382, + "tag": "0046_unknown_moonstone", + "breakpoints": true } ] } diff --git a/scripts/sqlite-to-pg.ts b/scripts/sqlite-to-pg.ts index 24e66f2f1..921d98b66 100644 --- a/scripts/sqlite-to-pg.ts +++ b/scripts/sqlite-to-pg.ts @@ -55,6 +55,7 @@ const TABLE_ORDER = [ "gameDownloads", "xrelNotifiedReleases", "releaseBlacklist", + "downloadEvents", "aiAutoDownloadHolds", "importTaskItems", "gameJournalEntries", diff --git a/server/__tests__/activity_system_routes.test.ts b/server/__tests__/activity_system_routes.test.ts new file mode 100644 index 000000000..05e538255 --- /dev/null +++ b/server/__tests__/activity_system_routes.test.ts @@ -0,0 +1,102 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../storage.js", () => ({ + storage: { + getDownloadEvents: vi.fn(), + removeAllReleaseBlacklists: vi.fn(), + getSystemConfig: vi.fn().mockResolvedValue(undefined), + setSystemConfig: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock("../socket.js", () => ({ notifyUser: vi.fn() })); +vi.mock("../logger.js", () => { + const l = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { logger: l, igdbLogger: l, routesLogger: l }; +}); + +import { activityRouter } from "../routes/activity.js"; +import { systemRouter } from "../routes/system.js"; +import { registerTask, resetTaskRegistry } from "../task-registry.js"; +import { storage } from "../storage.js"; + +function makeApp(authenticated = true) { + const app = express(); + app.use((req, _res, next) => { + if (authenticated) req.user = { id: "u1" } as never; + next(); + }); + app.use("/api/activity", activityRouter); + app.use("/api/system", systemRouter); + return app; +} + +describe("activity routes", () => { + beforeEach(() => vi.clearAllMocks()); + + it("requires authentication", async () => { + await request(makeApp(false)).get("/api/activity/history").expect(401); + }); + + it("passes filters and paging to the storage layer", async () => { + vi.mocked(storage.getDownloadEvents).mockResolvedValue({ events: [], total: 0 }); + await request(makeApp()) + .get("/api/activity/history?eventType=failed&search=zelda&limit=10&offset=20") + .expect(200, { events: [], total: 0 }); + expect(storage.getDownloadEvents).toHaveBeenCalledWith("u1", { + eventType: "failed", + search: "zelda", + limit: 10, + offset: 20, + }); + }); + + it("rejects an unknown event type and an oversized page", async () => { + await request(makeApp()).get("/api/activity/history?eventType=nope").expect(400); + await request(makeApp()).get("/api/activity/history?limit=9999").expect(400); + }); + + it("reports a storage failure as 500", async () => { + vi.mocked(storage.getDownloadEvents).mockRejectedValue(new Error("db")); + await request(makeApp()).get("/api/activity/history").expect(500); + }); + + it("unblocks everything for the user", async () => { + vi.mocked(storage.removeAllReleaseBlacklists).mockResolvedValue(3); + await request(makeApp()).delete("/api/activity/blocklist").expect(200, { removed: 3 }); + expect(storage.removeAllReleaseBlacklists).toHaveBeenCalledWith("u1"); + }); + + it("reports a failed unblock-all as 500", async () => { + vi.mocked(storage.removeAllReleaseBlacklists).mockRejectedValue(new Error("db")); + await request(makeApp()).delete("/api/activity/blocklist").expect(500); + }); +}); + +describe("system task routes", () => { + beforeEach(() => { + resetTaskRegistry(); + }); + + it("lists tasks, runs one with 202, and refuses a second run while it is running", async () => { + let release!: () => void; + registerTask({ + name: "slow", + title: "Slow", + description: "", + intervalMs: 60_000, + run: () => new Promise((r) => (release = r)), + }); + const app = makeApp(); + + const list = await request(app).get("/api/system/tasks").expect(200); + expect(list.body.tasks.map((t: { name: string }) => t.name)).toEqual(["slow"]); + + await request(app).post("/api/system/tasks/slow/run").expect(202); + await request(app).post("/api/system/tasks/slow/run").expect(409); + release(); + await request(app).post("/api/system/tasks/missing/run").expect(404); + resetTaskRegistry(); + }); +}); diff --git a/server/__tests__/archive_service_sets.test.ts b/server/__tests__/archive_service_sets.test.ts new file mode 100644 index 000000000..53ea496b0 --- /dev/null +++ b/server/__tests__/archive_service_sets.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { randomBytes } from "node:crypto"; +import fs from "fs-extra"; +import os from "node:os"; +import path from "node:path"; +import { ArchiveService } from "../services/ArchiveService.js"; + +describe("ArchiveService.findArchiveSets", () => { + let root: string; + const service = new ArchiveService(); + + const touch = async (rel: string) => { + const full = path.join(root, rel); + await fs.ensureDir(path.dirname(full)); + await fs.writeFile(full, "x"); + }; + const primaries = async (options?: Parameters[1]) => + (await service.findArchiveSets(root, options)) + .map((s) => path.relative(root, s.primary)) + .sort(); + + beforeEach(() => { + root = path.join(os.tmpdir(), `questarr-sets-${Date.now()}-${randomBytes(6).toString("hex")}`); + }); + afterEach(async () => { + await fs.remove(root); + }); + + it("returns one set per multi-volume release, entered through its plain .rar", async () => { + await touch("Game.rar"); + await touch("Game.r00"); + await touch("Game.r01"); + expect(await primaries()).toEqual(["Game.rar"]); + const [set] = await service.findArchiveSets(root); + expect(set?.volumes).toHaveLength(3); + }); + + it("keeps independent archive sets in the same folder apart", async () => { + await touch("Game.7z.001"); + await touch("Game.7z.002"); + await touch("Extras.zip"); + expect(await primaries()).toEqual(["Extras.zip", "Game.7z.001"]); + }); + + it("finds archives in sub-folders and reports their folder", async () => { + await touch("Disc1/data.zip"); + const [set] = await service.findArchiveSets(root); + expect(set?.relDir).toBe("Disc1"); + }); + + it("stops at the depth limit and ignores plain files and disc images", async () => { + await touch("readme.txt"); + await touch("game.iso"); + await touch("a/b/c/d/deep.zip"); + expect(await primaries({ maxDepth: 3 })).toEqual([]); + expect(await primaries({ maxDepth: 4 })).toEqual([path.join("a", "b", "c", "d", "deep.zip")]); + expect(await primaries({ includeDiscImages: true, maxDepth: 0 })).toEqual(["game.iso"]); + }); + + it("does not follow symlinks and skips extraction staging folders", async () => { + const outside = `${root}-outside`; + await fs.ensureDir(outside); + await fs.writeFile(path.join(outside, "hidden.zip"), "x"); + await fs.ensureDir(root); + await fs.symlink(outside, path.join(root, "link")); + await touch(".questarr-extract-abc/inner.zip"); + try { + expect(await primaries()).toEqual([]); + } finally { + await fs.remove(outside); + } + }); + + it("never looks at more entries than the budget in a single large folder", async () => { + await touch("a.zip"); + await touch("b.zip"); + await touch("c.zip"); + expect((await primaries({ maxEntries: 2 })).length).toBe(2); + }); + + it("stops after the entry budget is spent", async () => { + await touch("a.zip"); + await touch("b/c.zip"); + // Only one entry of the root directory is read, so at most one of its names is looked at. + expect((await primaries({ maxEntries: 1 })).length).toBeLessThanOrEqual(1); + }); + + it("reads inside a huge directory only up to the budget", async () => { + for (let i = 0; i < 20; i++) await touch(`f${i}.zip`); + expect((await primaries({ maxEntries: 5 })).length).toBe(5); + }); + + it("does not follow a symlinked root and handles a single archive file", async () => { + await touch("real/a.zip"); + await fs.symlink(path.join(root, "real"), path.join(root, "link")); + const svc = new ArchiveService(); + expect(await svc.findArchiveSets(path.join(root, "link"))).toEqual([]); + const single = await svc.findArchiveSets(path.join(root, "real", "a.zip")); + expect(single.map((s) => path.basename(s.primary))).toEqual(["a.zip"]); + }); +}); diff --git a/server/__tests__/dialect-parity.test.ts b/server/__tests__/dialect-parity.test.ts index 6167cdea2..8a24616c6 100644 --- a/server/__tests__/dialect-parity.test.ts +++ b/server/__tests__/dialect-parity.test.ts @@ -255,4 +255,159 @@ describe.each(activeTestDialects())("storage behaviour on %s", (dialect) => { expect(keys.has(`${downloader!.id}:${realHash}`)).toBe(true); }); }); + + describe("download event log", () => { + it("still saves the error message when the status does not change", async () => { + const [user] = await storage().getAllUsers(); + const [game] = await storage().getUserGames(user.id); + const downloader = await storage().addDownloader({ + name: "same-status-qb", + type: "qbittorrent", + url: "http://same-status", + } as never); + const download = await storage().addGameDownload({ + gameId: game.id, + downloaderId: downloader!.id, + downloadType: "game", + downloadHash: "same-status-hash", + downloadTitle: "Same.Status-GRP", + status: "manual_review_required", + } as never); + const before = (await storage().getDownloadEvents(user.id, { eventType: "needs_review" })) + .total; + await storage().updateGameDownloadStatus(download!.id, "manual_review_required", "scan gap"); + const row = await storage().getGameDownload(download!.id); + expect(row?.errorMessage).toBe("scan gap"); + expect( + (await storage().getDownloadEvents(user.id, { eventType: "needs_review" })).total + ).toBe(before); + }); + + it("records a completed event for rows inserted already completed", async () => { + const [user] = await storage().getAllUsers(); + const [game] = await storage().getUserGames(user.id); + const downloader = await storage().addDownloader({ + name: "claimed-qb", + type: "qbittorrent", + url: "http://claimed", + } as never); + const before = (await storage().getDownloadEvents(user.id, { eventType: "completed" })).total; + await storage().addGameDownload({ + gameId: game.id, + downloaderId: downloader!.id, + downloadType: "game", + downloadHash: "claimed-hash", + downloadTitle: "Claimed.Game-GRP", + status: "completed", + } as never); + const after = (await storage().getDownloadEvents(user.id, { eventType: "completed" })).total; + expect(after).toBe(before + 1); + }); + + it("records a needs_review event when an orphaned download is relinked", async () => { + const [user] = await storage().getAllUsers(); + const [game] = await storage().getUserGames(user.id); + const downloader = await storage().addDownloader({ + name: "relink-qb", + type: "qbittorrent", + url: "http://relink", + } as never); + const download = await storage().addGameDownload({ + gameId: game.id, + downloaderId: downloader!.id, + downloadType: "game", + downloadHash: "relink-hash", + downloadTitle: "Relink.Game-GRP", + status: "game_link_required", + } as never); + const before = (await storage().getDownloadEvents(user.id, { eventType: "needs_review" })) + .total; + const relinked = await storage().relinkGameDownload(download!.id, game.id); + expect(relinked?.status).toBe("manual_review_required"); + const after = await storage().getDownloadEvents(user.id, { eventType: "needs_review" }); + expect(after.total).toBe(before + 1); + // A second relink no longer matches game_link_required and logs nothing. + expect(await storage().relinkGameDownload(download!.id, game.id)).toBeUndefined(); + expect( + (await storage().getDownloadEvents(user.id, { eventType: "needs_review" })).total + ).toBe(before + 1); + }); + + it("records grabbed, completed, blocklisted and removed events, newest first", async () => { + const [user] = await storage().getAllUsers(); + const [game] = await storage().getUserGames(user.id); + const downloader = await storage().addDownloader({ + name: "events-qb", + type: "qbittorrent", + url: "http://events", + } as never); + const before = (await storage().getDownloadEvents(user.id)).total; + + const download = await storage().addGameDownload({ + gameId: game.id, + downloaderId: downloader!.id, + downloadType: "game", + downloadHash: "events-hash", + downloadTitle: "Events.Game-GRP", + status: "downloading", + } as never); + await storage().updateGameDownloadStatus(download!.id, "completed"); + // Same status twice must not log a second event. + await storage().updateGameDownloadStatus(download!.id, "completed"); + await storage().addReleaseBlacklist({ + gameId: game.id, + releaseTitle: "Events.Game-BAD", + indexerName: "idx", + } as never); + await storage().removeGameDownload(download!.id, game.id); + + const { events, total } = await storage().getDownloadEvents(user.id); + expect(total).toBe(before + 4); + expect( + events + .filter((e) => e.releaseTitle.startsWith("Events.Game-")) + .map((e) => e.eventType) + .sort() + ).toEqual(["blocklisted", "completed", "grabbed", "removed"]); + expect(events.find((e) => e.eventType === "grabbed")).toMatchObject({ + gameTitle: game.title, + releaseTitle: "Events.Game-GRP", + downloaderName: "events-qb", + }); + + // Search matches the release or the game title, whatever the case. + const bySearch = await storage().getDownloadEvents(user.id, { search: "EVENTS.game-bad" }); + expect(bySearch.total).toBe(1); + expect(bySearch.events[0]?.eventType).toBe("blocklisted"); + expect((await storage().getDownloadEvents(user.id, { search: "no-such-thing" })).total).toBe( + 0 + ); + + // Unblock all removes every blocked release of the user's games, and only theirs. + const other = await storage().createUser({ + username: "bob-unblock", + passwordHash: "h", + } as never); + const otherGame = await storage().addGame({ + userId: other.id, + igdbId: 9001, + title: "Other Game", + status: "wanted", + } as never); + await storage().addReleaseBlacklist({ + gameId: otherGame.id, + releaseTitle: "Other.Game-BAD", + indexerName: "idx", + } as never); + expect(await storage().removeAllReleaseBlacklists(user.id)).toBeGreaterThanOrEqual(1); + expect(await storage().getAllReleaseBlacklists(user.id)).toEqual([]); + expect( + (await storage().getAllReleaseBlacklists(other.id)).map((e) => e.releaseTitle) + ).toEqual(["Other.Game-BAD"]); + + const completedOnly = await storage().getDownloadEvents(user.id, { eventType: "completed" }); + expect(completedOnly.total).toBeGreaterThanOrEqual(1); + expect(completedOnly.events.every((e) => e.eventType === "completed")).toBe(true); + }); + }); }); diff --git a/server/__tests__/downloaders_nzbget_remaining.test.ts b/server/__tests__/downloaders_nzbget_remaining.test.ts index cfd41edec..1736f3ecf 100644 --- a/server/__tests__/downloaders_nzbget_remaining.test.ts +++ b/server/__tests__/downloaders_nzbget_remaining.test.ts @@ -252,6 +252,37 @@ describe("NZBGet remaining coverage", () => { } ); + it.each([ + ["NONE", undefined], + ["SUCCESS", "completed"], + ["FAILURE", "failed"], + ])("maps history UnpackStatus %s to %s", async (unpack, expected) => { + const client = new NZBGetClient(createDownloader()); + const privateClient = client as unknown as { + makeXMLRPCRequest: (method: string, params?: unknown[]) => Promise; + getFromHistory: (id: string) => Promise<{ unpackStatus?: string }>; + }; + vi.spyOn(privateClient, "makeXMLRPCRequest").mockResolvedValueOnce([ + { + NZBID: 8, + Name: "Finished Game", + Status: "SUCCESS/ALL", + FileSizeMB: 20, + Category: "games", + DownloadTimeSec: 90, + ParStatus: "SUCCESS", + UnpackStatus: unpack, + FailedArticles: 0, + DeleteStatus: "NONE", + DestDir: "/downloads", + }, + ]); + + // NONE means NZBGet never unpacked, so no unpack state is claimed. + const result = await privateClient.getFromHistory("8"); + expect(result.unpackStatus).toBe(expected); + }); + it("does not treat an unrelated overall status as completed", async () => { const client = new NZBGetClient(createDownloader()); const privateClient = client as unknown as { diff --git a/server/__tests__/import_manager_archive_extraction.test.ts b/server/__tests__/import_manager_archive_extraction.test.ts index 74c694637..173f665c9 100644 --- a/server/__tests__/import_manager_archive_extraction.test.ts +++ b/server/__tests__/import_manager_archive_extraction.test.ts @@ -455,3 +455,63 @@ describe("ImportManager archive extraction (library-side)", () => { ); }); }); + +describe("ImportManager.reportLeftoverArchives", () => { + it("warns once about archives nothing extracted, naming them", async () => { + const dir = tempDir(); + await fs.ensureDir(path.join(dir, "Disc2")); + await fs.writeFile(path.join(dir, "Game.rar"), "x"); + await fs.writeFile(path.join(dir, "Game.r00"), "x"); + await fs.writeFile(path.join(dir, "Disc2", "data.zip"), "x"); + await fs.writeFile(path.join(dir, "setup.exe"), "x"); + + const storage = makeStorage(); + const { archiveService } = makeArchiveService({}); + const manager = createManager(archiveService, storage, {}); + await manager.reportLeftoverArchives( + makeGame({ id: "g1", userId: "u1", title: "My Game" }), + dir + ); + + expect(storage.addNotification).toHaveBeenCalledTimes(1); + const notification = storage.addNotification.mock.calls[0]![0] as { + type: string; + message: string; + }; + expect(notification.type).toBe("warning"); + expect(notification.message).toContain("2 archives"); + expect(notification.message).toContain("Game.rar"); + }); + + it("stays quiet when the contents already sit next to the archive", async () => { + const dir = tempDir(); + await fs.ensureDir(dir); + await fs.writeFile(path.join(dir, "Game.rar"), "x"); + + const storage = makeStorage(); + const { archiveService } = makeArchiveService({ alreadyExtracted: true }); + const manager = createManager(archiveService, storage, {}); + await manager.reportLeftoverArchives( + makeGame({ id: "g1", userId: "u1", title: "My Game" }), + dir + ); + + expect(storage.addNotification).not.toHaveBeenCalled(); + }); + + it("stays quiet when there is no archive", async () => { + const dir = tempDir(); + await fs.ensureDir(dir); + await fs.writeFile(path.join(dir, "setup.exe"), "x"); + + const storage = makeStorage(); + const { archiveService } = makeArchiveService({}); + const manager = createManager(archiveService, storage, {}); + await manager.reportLeftoverArchives( + makeGame({ id: "g1", userId: "u1", title: "My Game" }), + dir + ); + + expect(storage.addNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/server/__tests__/steam_wishlist.test.ts b/server/__tests__/steam_wishlist.test.ts index e8ef40403..7e4389aca 100644 --- a/server/__tests__/steam_wishlist.test.ts +++ b/server/__tests__/steam_wishlist.test.ts @@ -520,10 +520,10 @@ describe("checkSteamWishlist", () => { await firstRun; }); - it("does not throw and clears the in-progress guard when getAllUsers fails", async () => { + it("rejects so the task registry records a failure, and clears the in-progress guard, when getAllUsers fails", async () => { vi.mocked(storage.getAllUsers).mockRejectedValueOnce(new Error("DB unavailable")); - await expect(checkSteamWishlist()).resolves.toBeUndefined(); + await expect(checkSteamWishlist()).rejects.toThrow("DB unavailable"); // The in-progress guard must have been cleared, so a subsequent run proceeds normally. vi.mocked(storage.getAllUsers).mockResolvedValueOnce([]); diff --git a/server/__tests__/task_registry.test.ts b/server/__tests__/task_registry.test.ts new file mode 100644 index 000000000..066b19717 --- /dev/null +++ b/server/__tests__/task_registry.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../storage.js", () => ({ + storage: { + getSystemConfig: vi.fn().mockResolvedValue(undefined), + setSystemConfig: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock("../socket.js", () => ({ notifyUser: vi.fn() })); +vi.mock("../logger.js", () => { + const l = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { igdbLogger: l }; +}); + +import { + executeTask, + getTaskStatus, + registerTask, + resetTaskRegistry, + startTaskScheduler, + STARTUP_DELAY_MS, +} from "../task-registry.js"; +import { storage } from "../storage.js"; +import { notifyUser } from "../socket.js"; + +const HOUR = 60 * 60 * 1000; + +describe("task registry", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + resetTaskRegistry(); + }); + afterEach(() => { + resetTaskRegistry(); + vi.useRealTimers(); + }); + + it("records last run, duration and the next run after a success", async () => { + const run = vi.fn().mockResolvedValue(undefined); + registerTask({ name: "t", title: "T", description: "", intervalMs: HOUR, run }); + await executeTask("t", "manual"); + + const status = getTaskStatus("t")!; + expect(status.lastResult).toBe("success"); + expect(status.lastTrigger).toBe("manual"); + expect(status.running).toBe(false); + expect(status.nextRunAt).toBe(status.lastFinishedAt! + HOUR); + expect(storage.setSystemConfig).toHaveBeenCalledWith("task_state:t", expect.any(String)); + expect(notifyUser).toHaveBeenCalledWith("task:started", expect.anything()); + expect(notifyUser).toHaveBeenCalledWith("task:finished", expect.anything()); + }); + + it("keeps the error and stays scheduled when a run fails", async () => { + registerTask({ + name: "t", + title: "T", + description: "", + intervalMs: HOUR, + run: () => Promise.reject(new Error("boom")), + }); + await executeTask("t", "schedule"); + const status = getTaskStatus("t")!; + expect(status.lastResult).toBe("error"); + expect(status.lastError).toBe("boom"); + expect(status.nextRunAt).not.toBeNull(); + }); + + it("refuses to run a task that is already running", async () => { + let release!: () => void; + registerTask({ + name: "t", + title: "T", + description: "", + intervalMs: HOUR, + run: () => new Promise((r) => (release = r)), + }); + const first = executeTask("t", "manual"); + expect(getTaskStatus("t")!.running).toBe(true); + expect(await executeTask("t", "manual")).toBe("already_running"); + release(); + await first; + expect(getTaskStatus("t")!.running).toBe(false); + }); + + it("reports unknown tasks", async () => { + expect(await executeTask("nope", "manual")).toBe("unknown_task"); + }); + + it("runs startup tasks after the startup delay and then on the interval", async () => { + const run = vi.fn().mockResolvedValue(undefined); + registerTask({ + name: "t", + title: "T", + description: "", + intervalMs: HOUR, + runOnStartup: true, + run, + }); + await startTaskScheduler(); + expect(run).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(STARTUP_DELAY_MS); + expect(run).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(HOUR); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("restores the persisted last run on startup", async () => { + vi.mocked(storage.getSystemConfig).mockResolvedValueOnce( + JSON.stringify({ + lastStartedAt: 1, + lastFinishedAt: 5, + lastDurationMs: 4, + lastResult: "success", + lastError: null, + lastTrigger: "schedule", + }) + ); + registerTask({ name: "t", title: "T", description: "", intervalMs: HOUR, run: async () => {} }); + await startTaskScheduler(); + expect(getTaskStatus("t")!.lastDurationMs).toBe(4); + }); +}); diff --git a/server/cron.ts b/server/cron.ts index bc2585c59..c844039f2 100644 --- a/server/cron.ts +++ b/server/cron.ts @@ -36,6 +36,7 @@ import { categorizeDownload } from "../shared/download-categorizer.js"; import { isReleasePossiblyNewer } from "../shared/version-utils.js"; import { recordVersionFromCompletedDownload } from "./game-version.js"; import { rssService } from "./rss.js"; +import { registerTask, startTaskScheduler, type TaskDefinition } from "./task-registry.js"; import { releaseMatchesGame, normalizeTitle, @@ -431,71 +432,92 @@ async function searchAndCategorizeItemsForGame( export function startCronJobs() { igdbLogger.info("Starting cron jobs..."); - igdbLogger.info( + registerCronTasks(); + startTaskScheduler().catch((err) => igdbLogger.error({ err }, "Failed to start task scheduler")); +} + +const IMPORT_TASK_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const DOWNLOAD_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +const IMPORT_TASK_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; + +function registerCronTasks(): void { + const tasks: TaskDefinition[] = [ { - gameUpdates: `every ${CHECK_INTERVAL_MS / 1000 / 60 / 60} hours`, - downloadStatus: `every ${DOWNLOAD_CHECK_INTERVAL_MS / 1000} seconds`, - autoSearch: `every ${AUTO_SEARCH_CHECK_INTERVAL_MS / 1000 / 60} minutes`, - steamSync: `every ${STEAM_SYNC_CHECK_INTERVAL_MS / 1000 / 60} minutes (per-user interval gated)`, - rssFeeds: `every ${RSS_REFRESH_INTERVAL_MS / 1000 / 60} minutes`, + name: "game-updates", + title: "Game updates", + description: "Refresh release dates and metadata of tracked games from IGDB.", + intervalMs: CHECK_INTERVAL_MS, + runOnStartup: true, + run: checkGameUpdates, }, - "Cron job intervals configured" - ); - - // Run immediately on startup (or after a slight delay to ensure DB is ready) - setTimeout(() => { - igdbLogger.info("Running initial cron job checks..."); - checkGameUpdates().catch((err) => igdbLogger.error({ err }, "Error in checkGameUpdates")); - checkDownloadStatus().catch((err) => igdbLogger.error({ err }, "Error in checkDownloadStatus")); - checkAutoSearch().catch((err) => igdbLogger.error({ err }, "Error in checkAutoSearch")); - checkXrelReleases().catch((err) => igdbLogger.error({ err }, "Error in checkXrelReleases")); - checkSteamWishlist().catch((err) => igdbLogger.error({ err }, "Error in checkSteamWishlist")); - runScheduledRommSync().catch((err) => igdbLogger.error({ err }, "Error in RomM sync")); - logClientVersions().catch((err) => igdbLogger.warn({ err }, "Error in logClientVersions")); - rssService.refreshFeeds().catch((err) => igdbLogger.error({ err }, "Error in refreshFeeds")); - }, 10000); - - // Schedule periodic checks - setInterval(() => { - checkGameUpdates().catch((err) => igdbLogger.error({ err }, "Error in checkGameUpdates")); - }, CHECK_INTERVAL_MS); - - setInterval(() => { - checkDownloadStatus().catch((err) => igdbLogger.error({ err }, "Error in checkDownloadStatus")); - }, DOWNLOAD_CHECK_INTERVAL_MS); - - setInterval(() => { - checkAutoSearch().catch((err) => igdbLogger.error({ err }, "Error in checkAutoSearch")); - }, AUTO_SEARCH_CHECK_INTERVAL_MS); - - setInterval(() => { - checkXrelReleases().catch((err) => igdbLogger.error({ err }, "Error in checkXrelReleases")); - }, XREL_CHECK_INTERVAL_MS); - - setInterval(() => { - checkSteamWishlist().catch((err) => igdbLogger.error({ err }, "Error in checkSteamWishlist")); - }, STEAM_SYNC_CHECK_INTERVAL_MS); - - setInterval(() => { - runScheduledRommSync().catch((err) => igdbLogger.error({ err }, "Error in RomM sync")); - }, ROMM_SYNC_INTERVAL_MS); - - setInterval(() => { - logClientVersions().catch((err) => igdbLogger.warn({ err }, "Error in logClientVersions")); - }, CLIENT_VERSION_LOG_INTERVAL_MS); - - setInterval(() => { - rssService.refreshFeeds().catch((err) => igdbLogger.error({ err }, "Error in refreshFeeds")); - }, RSS_REFRESH_INTERVAL_MS); - - const IMPORT_TASK_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; - const runImportTaskCleanup = () => { - const cutoff = Date.now() - IMPORT_TASK_RETENTION_MS; - storage - .deleteImportTasksOlderThan(cutoff) - .catch((err) => igdbLogger.warn({ err }, "Import task cleanup failed")); - }; - setInterval(runImportTaskCleanup, 24 * 60 * 60 * 1000); + { + name: "download-status", + title: "Download status", + description: "Poll download clients, update progress and trigger imports.", + intervalMs: DOWNLOAD_CHECK_INTERVAL_MS, + runOnStartup: true, + run: checkDownloadStatus, + }, + { + name: "auto-search", + title: "Auto search", + description: "Search indexers for wanted games and grab matching releases.", + intervalMs: AUTO_SEARCH_CHECK_INTERVAL_MS, + runOnStartup: true, + run: checkAutoSearch, + }, + { + name: "xrel-releases", + title: "xREL.to releases", + description: "Check xREL.to for new scene releases of tracked games.", + intervalMs: XREL_CHECK_INTERVAL_MS, + runOnStartup: true, + run: checkXrelReleases, + }, + { + name: "steam-wishlist", + title: "Steam wishlist sync", + description: "Import wishlists of users with Steam sync enabled (per-user interval applies).", + intervalMs: STEAM_SYNC_CHECK_INTERVAL_MS, + runOnStartup: true, + run: checkSteamWishlist, + }, + { + name: "romm-sync", + title: "RomM sync", + description: "Synchronise the library with the configured RomM server.", + intervalMs: ROMM_SYNC_INTERVAL_MS, + runOnStartup: true, + run: runScheduledRommSync, + }, + { + name: "client-versions", + title: "Client version check", + description: "Record the versions of the connected download clients and indexers.", + intervalMs: CLIENT_VERSION_LOG_INTERVAL_MS, + runOnStartup: true, + run: logClientVersions, + }, + { + name: "rss-refresh", + title: "RSS feeds", + description: "Fetch every enabled RSS feed.", + intervalMs: RSS_REFRESH_INTERVAL_MS, + runOnStartup: true, + run: () => rssService.refreshFeeds(), + }, + { + name: "import-task-cleanup", + title: "History cleanup", + description: "Delete import history older than 30 days and download events older than 90.", + intervalMs: IMPORT_TASK_CLEANUP_INTERVAL_MS, + run: async () => { + await storage.deleteImportTasksOlderThan(Date.now() - IMPORT_TASK_RETENTION_MS); + await storage.deleteDownloadEventsOlderThan(Date.now() - DOWNLOAD_EVENT_RETENTION_MS); + }, + }, + ]; + for (const task of tasks) registerTask(task); } async function logClientVersions(): Promise { @@ -1878,6 +1900,8 @@ export async function checkAutoSearch() { } } catch (error) { igdbLogger.error({ error }, "Error in checkAutoSearch"); + // Let the task registry record the run as failed. + throw error; } } @@ -1993,6 +2017,8 @@ export async function checkXrelReleases() { } } catch (error) { igdbLogger.error({ error }, "Error in checkXrelReleases"); + // Let the task registry record the run as failed. + throw error; } } @@ -2031,6 +2057,8 @@ export async function checkSteamWishlist() { } } catch (error) { igdbLogger.error({ error }, "Failed scheduled Steam Wishlist auto-sync check"); + // Let the task registry record the run as failed. + throw error; } finally { steamWishlistCheckInProgress = false; } diff --git a/server/db/tables.ts b/server/db/tables.ts index 5389fc286..93036f56f 100644 --- a/server/db/tables.ts +++ b/server/db/tables.ts @@ -35,6 +35,7 @@ export const { pathMappings, platformMappings, releaseBlacklist, + downloadEvents, rootFolders, rssFeedItems, rssFeeds, diff --git a/server/downloaders/nzbget.ts b/server/downloaders/nzbget.ts index 7f56f19f3..5a19eaf7e 100644 --- a/server/downloaders/nzbget.ts +++ b/server/downloaders/nzbget.ts @@ -480,8 +480,10 @@ export class NZBGetClient implements DownloaderClient { status = "completed"; repairStatus = item.ParStatus === "SUCCESS" || item.ParStatus === "NONE" ? "good" : "failed"; - unpackStatus = - item.UnpackStatus === "SUCCESS" || item.UnpackStatus === "NONE" ? "completed" : "failed"; + // NONE means NZBGet did not unpack at all (unpacking disabled for the category), so + // the job can still hold archives: report no unpack state rather than "completed". + if (item.UnpackStatus === "SUCCESS") unpackStatus = "completed"; + else if (item.UnpackStatus !== "NONE") unpackStatus = "failed"; } else { status = "error"; repairStatus = item.ParStatus === "FAILURE" ? "failed" : "good"; diff --git a/server/routes.ts b/server/routes.ts index 1901b1a37..3d698a088 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -304,6 +304,7 @@ import { importRouter } from "./routes/import.js"; import { rommRouter } from "./routes/romm.js"; import { importTasksRouter } from "./routes/import-tasks.js"; import { systemRouter } from "./routes/system.js"; +import { activityRouter } from "./routes/activity.js"; import { pcgamingwikiRouter } from "./pcgamingwiki-router.js"; import { probeRootFolder, isWithinDeletableRootFolder, isStrictlyInside } from "./root-folders.js"; import { @@ -1620,6 +1621,7 @@ export async function registerRoutes(app: Express): Promise { app.use("/api/settings/romm", rommRouter); app.use("/api/import-tasks", importTasksRouter); app.use("/api/system", systemRouter); + app.use("/api/activity", activityRouter); // Authenticated by the /api gate above (JWT or integration API key); same // reasoning as the mounts above. app.use("/api/integration", integrationRouter); diff --git a/server/routes/activity.ts b/server/routes/activity.ts new file mode 100644 index 000000000..3f6cb6747 --- /dev/null +++ b/server/routes/activity.ts @@ -0,0 +1,55 @@ +import { Router } from "express"; +import { z } from "zod"; +import { storage } from "../storage.js"; +import { routesLogger as logger } from "../logger.js"; +import { DOWNLOAD_EVENT_TYPES } from "../../shared/schema.js"; + +export const activityRouter = Router(); + +activityRouter.use((req, res, next) => { + if (!req.user?.id) { + return res.status(401).json({ error: "Unauthorized" }); + } + + res.locals.userId = req.user.id; + return next(); +}); + +const historyQuerySchema = z.object({ + eventType: z.enum(DOWNLOAD_EVENT_TYPES).optional(), + search: z.string().trim().max(200).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +// GET /api/activity/history?eventType=&limit=&offset= - what happened to downloads, newest first +activityRouter.get("/history", async (req, res) => { + const parsed = historyQuerySchema.safeParse(req.query); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid query parameters" }); + } + try { + const { eventType, search, limit, offset } = parsed.data; + const result = await storage.getDownloadEvents(res.locals.userId as string, { + ...(eventType ? { eventType } : {}), + ...(search ? { search } : {}), + limit, + offset, + }); + return res.json(result); + } catch (error) { + logger.error({ error }, "error listing download history"); + return res.status(500).json({ error: "Failed to load history" }); + } +}); + +// DELETE /api/activity/blocklist - unblock every release of the user's games in one go +activityRouter.delete("/blocklist", async (_req, res) => { + try { + const removed = await storage.removeAllReleaseBlacklists(res.locals.userId as string); + return res.json({ removed }); + } catch (error) { + logger.error({ error }, "error clearing the blocklist"); + return res.status(500).json({ error: "Failed to clear the blocklist" }); + } +}); diff --git a/server/routes/system.ts b/server/routes/system.ts index f517c4b13..77edf80ea 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { storage } from "../storage.js"; import { routesLogger as logger } from "../logger.js"; import { isSensitivePath } from "../path-security.js"; +import { executeTask, getTaskStatus, getTaskStatuses } from "../task-registry.js"; // Allow browsing the entire container filesystem // Security is maintained by container isolation and explicit volume mounts @@ -124,3 +125,21 @@ systemRouter.get("/browse", async (req, res) => { return res.status(500).json({ error: "Internal server error" }); } }); + +// GET /api/system/tasks - scheduled background jobs with their last/next run +systemRouter.get("/tasks", (_req, res) => { + res.json({ tasks: getTaskStatuses() }); +}); + +// POST /api/system/tasks/:name/run - start a job now; returns immediately (progress over Socket.IO) +systemRouter.post("/tasks/:name/run", (req, res) => { + const status = getTaskStatus(req.params.name ?? ""); + if (!status) { + return res.status(404).json({ error: "Unknown task" }); + } + if (status.running) { + return res.status(409).json({ error: "Task is already running", task: status }); + } + void executeTask(status.name, "manual"); + return res.status(202).json({ task: { ...status, running: true } }); +}); diff --git a/server/services/ArchiveService.ts b/server/services/ArchiveService.ts index 17021dfc2..e7b801d58 100644 --- a/server/services/ArchiveService.ts +++ b/server/services/ArchiveService.ts @@ -1,10 +1,14 @@ import { execFile } from "node:child_process"; -import { accessSync, constants as fsConstants } from "node:fs"; +import { accessSync, constants as fsConstants, type Dir } from "node:fs"; +import { opendir } from "node:fs/promises"; import path from "node:path"; import fs from "fs-extra"; import { logger } from "../logger.js"; import { EXTRACT_STAGING_PREFIX } from "./extract-staging.js"; +/** Archive sets reported per directory: the post-import scan is a best-effort warning. */ +const MAX_SETS_PER_DIR = 50; + type ArchiveTool = "7zip" | "unrar"; type ExecFileResult = { stdout: string; stderr: string }; @@ -775,6 +779,110 @@ export class ArchiveService { return true; } + private groupArchiveSets( + files: string[], + archiveOptions: { includeDiscImages: boolean }, + maxSets: number + ): Array<{ primary: string; volumes: string[] }> { + const result: Array<{ primary: string; volumes: string[] }> = []; + let remaining = files.filter((f) => this.isArchive(f, archiveOptions)); + // Each set costs one pass over the directory's files, so the number of sets is capped. + while (remaining.length > 0 && result.length < maxSets) { + let primary = remaining[0]!; + // ".r00" sorts before ".rar" but is a continuation, not the entry point. + const continuation = /^(.*)\.r\d{2,3}$/i.exec(path.basename(primary)); + if (continuation?.[1]) { + const stem = continuation[1].toLowerCase(); + const rar = remaining.find( + (f) => + /\.rar$/i.test(f) && path.basename(f).slice(0, -".rar".length).toLowerCase() === stem + ); + if (rar) primary = rar; + } + const volumes = this.findVolumeSiblings(primary, files).map((f) => path.resolve(f)); + if (!volumes.includes(path.resolve(primary))) volumes.push(path.resolve(primary)); + result.push({ primary, volumes }); + const taken = new Set(volumes); + remaining = remaining.filter((f) => !taken.has(path.resolve(f))); + } + return result; + } + + /** + * Finds every archive set (one primary volume plus its continuations) below `root`, without + * touching anything: `lstat` only, symlinks are never followed, and the walk is bounded by + * depth and by the number of directory entries it looks at. Staging directories left by a + * running extraction are skipped. Only the primary volume of each set is returned, so a + * release split in ten parts counts once. + */ + async findArchiveSets( + root: string, + options: { maxDepth?: number; maxEntries?: number; includeDiscImages?: boolean } = {} + ): Promise> { + const maxDepth = options.maxDepth ?? 3; + let budget = options.maxEntries ?? 5000; + const sets: Array<{ primary: string; volumes: string[]; relDir: string }> = []; + const archiveOptions = { includeDiscImages: options.includeDiscImages === true }; + + // Reads at most `limit` names (sorted) so a huge directory is never fully enumerated. + const readNames = async (dir: string, limit: number): Promise => { + const names: string[] = []; + let handle: Dir | undefined; + try { + handle = await opendir(dir); + for await (const entry of handle) { + names.push(entry.name); + if (names.length >= limit) break; + } + } catch { + // Unreadable or vanished directory: report what was read so far. + } finally { + await handle?.close().catch(() => undefined); + } + return names.sort((a, b) => a.localeCompare(b)); + }; + + const walk = async (dir: string, depth: number): Promise => { + if (budget <= 0) return; + const names = await readNames(dir, budget); + budget -= names.length; + + const files: string[] = []; + const subDirs: string[] = []; + for (const name of names) { + if (name.startsWith(EXTRACT_STAGING_PREFIX)) continue; + const full = path.join(dir, name); + const stats = await fs.lstat(full).catch(() => null); + if (!stats || stats.isSymbolicLink()) continue; + if (stats.isDirectory()) subDirs.push(full); + else if (stats.isFile()) files.push(full); + } + + for (const set of this.groupArchiveSets(files, archiveOptions, MAX_SETS_PER_DIR)) { + sets.push({ ...set, relDir: path.relative(root, dir) }); + } + + if (depth >= maxDepth) return; + for (const sub of subDirs) { + // Re-check right before descending: a directory swapped for a symlink is not followed. + const current = await fs.lstat(sub).catch(() => null); + if (current?.isDirectory() && !current.isSymbolicLink()) await walk(sub, depth + 1); + } + }; + + const rootStats = await fs.lstat(root).catch(() => null); + if (!rootStats || rootStats.isSymbolicLink()) return sets; + if (rootStats.isFile()) { + // A single-file import points at the file itself. + if (this.isArchive(root, archiveOptions)) { + sets.push({ primary: root, volumes: [path.resolve(root)], relDir: "" }); + } + return sets; + } + await walk(root, 0); + return sets; + } + /** * Given a main archive path and the absolute paths of its siblings, * returns the subset that belongs to the same archive: the main archive diff --git a/server/services/ImportManager.ts b/server/services/ImportManager.ts index ef9fe7e92..edf24d1ea 100644 --- a/server/services/ImportManager.ts +++ b/server/services/ImportManager.ts @@ -89,6 +89,9 @@ interface ArchiveResolution { hasRemainingFiles: boolean; } +// Upper bound on archive sets inspected when looking for leftovers after an import. +const MAX_LEFTOVER_SETS_CHECKED = 25; + export class ImportManager { private readonly pathRetryCount = new Map(); // Downloads whose processImport() is running in this process. The @@ -465,6 +468,58 @@ export class ImportManager { return { archiveInDest, siblingsInDest }; } + /** + * Archive sets below `dir` that still hold packed content. A set whose contents are already + * sitting next to it (the client unpacked but kept the archive) does not count. + */ + private async findUnextractedArchiveSets(dir: string): Promise { + const sets = await this.archiveService.findArchiveSets(dir); + const unextracted: string[] = []; + // Each check may spawn 7z/unrar; this is a best-effort warning, so bound the work. + for (const set of sets.slice(0, MAX_LEFTOVER_SETS_CHECKED)) { + if (!(await this.archiveService.isAlreadyExtracted(set.primary, path.dirname(set.primary)))) { + unextracted.push(path.relative(dir, set.primary)); + } + } + return unextracted; + } + + /** + * Tells the user when a finished download still contains archives nothing unpacked: the + * download client marks a job complete even when it left archives behind, and Questarr's + * own import only unpacks when auto-unpack is on. Informational and best effort: it never + * changes the download or the import. + */ + async reportLeftoverArchives( + game: NonNullable>>, + dir: string, + options: { checkRoots?: boolean; extractionAttempted?: boolean } = {} + ): Promise { + try { + if (isSensitivePath(dir)) return; + if (options.checkRoots) { + await assertWithinRoots(dir, await this.pathService.getConfiguredRoots(), "outside roots"); + } + const leftovers = await this.findUnextractedArchiveSets(dir); + if (leftovers.length === 0) return; + const shown = leftovers.slice(0, 3).join(", "); + const more = leftovers.length > 3 ? ` and ${leftovers.length - 3} more` : ""; + const hint = options.extractionAttempted + ? "Questarr already tried to extract this import; check for nested archives or extract them yourself." + : "Turn on Auto-unpack in the import settings, or extract them yourself."; + const notification = await this.storage.addNotification({ + userId: game.userId ?? "", + type: "warning", + title: "Archives were not extracted", + message: `"${game.title}" still contains ${leftovers.length} archive${leftovers.length === 1 ? "" : "s"} that nothing unpacked (${shown}${more}). ${hint}`, + link: "/downloads", + }); + notifyUser("notification", notification, notification.userId); + } catch (err) { + logger.debug({ err, dir }, "[ImportManager] Leftover archive check skipped"); + } + } + private async notifyStrandedImport( game: NonNullable>>, archiveInDest: string, @@ -1021,6 +1076,9 @@ export class ImportManager { ); await this.finalizeImport(downloadId, game, result.destDir); + await this.reportLeftoverArchives(game, result.destDir, { + extractionAttempted: needsExtraction, + }); await this.autoDeleteIfConfigured(downloadId, download, game, config); } catch (err) { logger.error({ err, downloadId }, "[ImportManager] Import failed"); @@ -1334,6 +1392,9 @@ export class ImportManager { ); await this.finalizeImport(downloadId, game, result.destDir); + await this.reportLeftoverArchives(game, result.destDir, { + extractionAttempted: needsExtraction, + }); } catch (err) { logger.error({ err, downloadId }, "[ImportManager] confirmImport failed"); try { diff --git a/server/storage.ts b/server/storage.ts index 6a3682ef6..c17c78f33 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -25,6 +25,9 @@ import { type InsertRssFeedItem, type ReleaseBlacklist, type InsertReleaseBlacklist, + type DownloadEvent, + type InsertDownloadEvent, + type DownloadEventType, type AiAutoDownloadHold, type InsertAiAutoDownloadHold, type ImportTask, @@ -73,6 +76,7 @@ import { importTasks, importTaskItems, releaseBlacklist, + downloadEvents, aiAutoDownloadHolds, gameFiles, apiKeys, @@ -83,9 +87,11 @@ import { } from "./db/tables.js"; import { randomUUID } from "crypto"; import { db } from "./db.js"; +import { logger } from "./logger.js"; import { normalizeDownloadHash } from "./download-hash.js"; import { eq, + ne, like, or, sql, @@ -423,8 +429,17 @@ export interface IStorage { getReleaseBlacklist(gameId: string): Promise; getAllReleaseBlacklists(userId: string): Promise<(ReleaseBlacklist & { gameTitle: string })[]>; removeReleaseBlacklist(id: string, gameId: string): Promise; + removeAllReleaseBlacklists(userId: string): Promise; getReleaseBlacklistSet(gameId: string): Promise>; + // Download event log (Activity > History) + addDownloadEvent(event: InsertDownloadEvent): Promise; + getDownloadEvents( + userId: string, + options?: { eventType?: DownloadEventType; search?: string; limit?: number; offset?: number } + ): Promise<{ events: DownloadEvent[]; total: number }>; + deleteDownloadEventsOlderThan(cutoffMs: number): Promise; + // AI auto-download hold methods (TypeSafe review holds -- see aiAutoDownloadHolds in schema) // Returns true when a new hold row was created, false when one already existed -- // callers use this to send the review notification only once, at the moment of the hold. @@ -499,6 +514,7 @@ export class MemStorage implements IStorage { private readonly pathMappings: Map; private readonly platformMappings: Map; private releaseBlacklists: Map; + private downloadEventLog: DownloadEvent[]; private aiAutoDownloadHolds: Map; private gameFiles: Map; private rootFolders: Map; @@ -522,6 +538,7 @@ export class MemStorage implements IStorage { this.pathMappings = new Map(); this.platformMappings = new Map(); this.releaseBlacklists = new Map(); + this.downloadEventLog = []; this.aiAutoDownloadHolds = new Map(); this.gameFiles = new Map(); this.rootFolders = new Map(); @@ -1782,6 +1799,22 @@ export class MemStorage implements IStorage { return true; } + async removeAllReleaseBlacklists(userId: string): Promise { + const userGameIds = new Set( + Array.from(this.games.values()) + .filter((g) => g.userId === userId) + .map((g) => g.id) + ); + let removed = 0; + for (const [id, entry] of Array.from(this.releaseBlacklists.entries())) { + if (userGameIds.has(entry.gameId)) { + this.releaseBlacklists.delete(id); + removed++; + } + } + return removed; + } + async getReleaseBlacklistSet(gameId: string): Promise> { const titles = Array.from(this.releaseBlacklists.values()) .filter((r) => r.gameId === gameId) @@ -1789,6 +1822,50 @@ export class MemStorage implements IStorage { return new Set(titles); } + async addDownloadEvent(event: InsertDownloadEvent): Promise { + this.downloadEventLog.push({ + ...event, + id: randomUUID(), + gameId: event.gameId ?? null, + downloaderName: event.downloaderName ?? null, + message: event.message ?? null, + createdAt: new Date(), + }); + } + + async getDownloadEvents( + userId: string, + options: { + eventType?: DownloadEventType; + search?: string; + limit?: number; + offset?: number; + } = {} + ): Promise<{ events: DownloadEvent[]; total: number }> { + const matching = this.downloadEventLog + .filter( + (e) => + e.userId === userId && + (!options.eventType || e.eventType === options.eventType) && + (!options.search || + `${e.releaseTitle} ${e.gameTitle}`.toLowerCase().includes(options.search.toLowerCase())) + ) + .sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0)); + const offset = options.offset ?? 0; + return { + events: matching.slice(offset, offset + (options.limit ?? 50)), + total: matching.length, + }; + } + + async deleteDownloadEventsOlderThan(cutoffMs: number): Promise { + const before = this.downloadEventLog.length; + this.downloadEventLog = this.downloadEventLog.filter( + (e) => (e.createdAt?.getTime() ?? 0) >= cutoffMs + ); + return before - this.downloadEventLog.length; + } + // AI auto-download hold methods async recordAiAutoDownloadHold(entry: InsertAiAutoDownloadHold): Promise { const cutoff = Date.now() - AI_AUTO_DOWNLOAD_HOLD_TTL_MS; @@ -2037,6 +2114,14 @@ export class MemStorage implements IStorage { } } +// Download status changes worth a line in Activity > History. +const DOWNLOAD_STATUS_EVENTS: Partial> = { + completed: "completed", + imported: "imported", + failed: "failed", + manual_review_required: "needs_review", +}; + export class DatabaseStorage implements IStorage { // System Config methods async getSystemConfig(key: string): Promise { @@ -2787,6 +2872,21 @@ export class DatabaseStorage implements IStorage { .set({ gameId, status: "manual_review_required", errorMessage: null }) .where(and(eq(gameDownloads.id, id), eq(gameDownloads.status, GAME_LINK_REQUIRED_STATUS))) .returning(); + const eventType = DOWNLOAD_STATUS_EVENTS["manual_review_required"]; + if (updated && eventType) { + // Read after the write so the event carries the newly linked game. + const context = await this.getDownloadEventContext(id); + if (context) { + await this.recordDownloadEvent({ + userId: context.userId, + gameId: context.gameId, + gameTitle: context.gameTitle, + eventType, + releaseTitle: context.releaseTitle, + downloaderName: context.downloaderName, + }); + } + } return updated; } @@ -2853,7 +2953,62 @@ export class DatabaseStorage implements IStorage { if (errorMessage !== undefined) { updates.errorMessage = errorMessage; } - await db.update(gameDownloads).set(updates).where(eq(gameDownloads.id, id)); + const eventType = DOWNLOAD_STATUS_EVENTS[status]; + const previous = eventType ? await this.getDownloadEventContext(id) : undefined; + // One conditional write carries the status and its metadata, and only the call that really + // changes the status gets a row back and logs the event, even under concurrency. + const changed = await db + .update(gameDownloads) + .set(updates) + .where(and(eq(gameDownloads.id, id), ne(gameDownloads.status, status as never))) + .returning({ id: gameDownloads.id }); + if (changed.length === 0) { + // Same status as before: only the metadata (error message, completion time) moves, and + // only while the status still matches, so a newer transition is never overwritten. + const { status: _status, ...metadata } = updates; + await db + .update(gameDownloads) + .set(metadata) + .where(and(eq(gameDownloads.id, id), eq(gameDownloads.status, status as never))); + } + if (eventType && previous && changed.length > 0) { + await this.recordDownloadEvent({ + userId: previous.userId, + gameId: previous.gameId, + gameTitle: previous.gameTitle, + eventType, + releaseTitle: previous.releaseTitle, + downloaderName: previous.downloaderName, + message: errorMessage ?? null, + }); + } + } + + private async getDownloadEventContext(id: string) { + try { + return await this.loadDownloadEventContext(id); + } catch (error) { + logger.warn({ error, downloadId: id }, "Failed to load download event context"); + return undefined; + } + } + + private async loadDownloadEventContext(id: string) { + const [row] = await db + .select({ + status: gameDownloads.status, + gameId: gameDownloads.gameId, + releaseTitle: gameDownloads.downloadTitle, + gameTitle: games.title, + userId: games.userId, + downloaderName: downloaders.name, + }) + .from(gameDownloads) + .innerJoin(games, eq(gameDownloads.gameId, games.id)) + .leftJoin(downloaders, eq(gameDownloads.downloaderId, downloaders.id)) + .where(eq(gameDownloads.id, id)); + // Rows without an owner cannot be shown in anyone's history. + return row?.userId ? { ...row, userId: row.userId } : undefined; } async updateGameDownloadHash( @@ -2943,14 +3098,50 @@ export class DatabaseStorage implements IStorage { }) .onConflictDoNothing() .returning(); + if (gameDownload) { + const context = await this.getDownloadEventContext(gameDownload.id); + if (context) { + await this.recordDownloadEvent({ + userId: context.userId, + gameId: context.gameId, + gameTitle: context.gameTitle, + eventType: "grabbed", + releaseTitle: context.releaseTitle, + downloaderName: context.downloaderName, + }); + // Rows inserted already finished (claimed completed items) never go through a status update. + const insertedEvent = DOWNLOAD_STATUS_EVENTS[gameDownload.status]; + if (insertedEvent) { + await this.recordDownloadEvent({ + userId: context.userId, + gameId: context.gameId, + gameTitle: context.gameTitle, + eventType: insertedEvent, + releaseTitle: context.releaseTitle, + downloaderName: context.downloaderName, + }); + } + } + } return gameDownload; } async removeGameDownload(id: string, gameId: string): Promise { + const context = await this.getDownloadEventContext(id); const result = await db .delete(gameDownloads) .where(and(eq(gameDownloads.id, id), eq(gameDownloads.gameId, gameId))) .returning(); + if (result.length > 0 && context && context.gameId === gameId) { + await this.recordDownloadEvent({ + userId: context.userId, + gameId: context.gameId, + gameTitle: context.gameTitle, + eventType: "removed", + releaseTitle: context.releaseTitle, + downloaderName: context.downloaderName, + }); + } return result.length > 0; } @@ -3326,6 +3517,21 @@ export class DatabaseStorage implements IStorage { ); return firstOrThrow(existingRows); } + try { + const [game] = await db.select().from(games).where(eq(games.id, entry.gameId)); + if (game?.userId) { + await this.recordDownloadEvent({ + userId: game.userId, + gameId: game.id, + gameTitle: game.title, + eventType: "blocklisted", + releaseTitle: entry.releaseTitle, + downloaderName: entry.indexerName ?? null, + }); + } + } catch (error) { + logger.warn({ error, gameId: entry.gameId }, "Failed to record blocklist event"); + } return row; } @@ -3362,6 +3568,18 @@ export class DatabaseStorage implements IStorage { return affectedRows(result) > 0; } + async removeAllReleaseBlacklists(userId: string): Promise { + const result = await db + .delete(releaseBlacklist) + .where( + inArray( + releaseBlacklist.gameId, + db.select({ id: games.id }).from(games).where(eq(games.userId, userId)) + ) + ); + return affectedRows(result); + } + async getReleaseBlacklistSet(gameId: string): Promise> { const rows = await db .select({ releaseTitle: releaseBlacklist.releaseTitle }) @@ -3370,6 +3588,66 @@ export class DatabaseStorage implements IStorage { return new Set(rows.map((r) => r.releaseTitle)); } + /** Event logging must never break the operation that triggered it. */ + private lastDownloadEventMs = 0; + + private async recordDownloadEvent(event: InsertDownloadEvent): Promise { + try { + await this.addDownloadEvent(event); + } catch (error) { + logger.warn({ error, eventType: event.eventType }, "Failed to record download event"); + } + } + + async addDownloadEvent(event: InsertDownloadEvent): Promise { + // Strictly increasing timestamps keep events recorded back to back in insertion order. + const nowMs = Math.max(Date.now(), this.lastDownloadEventMs + 1); + this.lastDownloadEventMs = nowMs; + await db + .insert(downloadEvents) + .values({ ...event, id: randomUUID(), createdAt: new Date(nowMs) }); + } + + async getDownloadEvents( + userId: string, + options: { + eventType?: DownloadEventType; + search?: string; + limit?: number; + offset?: number; + } = {} + ): Promise<{ events: DownloadEvent[]; total: number }> { + const search = options.search?.trim(); + const where = and( + eq(downloadEvents.userId, userId), + options.eventType ? eq(downloadEvents.eventType, options.eventType) : undefined, + search + ? or( + containsCI(downloadEvents.releaseTitle, search), + containsCI(downloadEvents.gameTitle, search) + ) + : undefined + ); + const [events, [totalRow]] = await Promise.all([ + db + .select() + .from(downloadEvents) + .where(where) + .orderBy(desc(downloadEvents.createdAt), desc(downloadEvents.id)) + .limit(options.limit ?? 50) + .offset(options.offset ?? 0), + db.select({ total: count() }).from(downloadEvents).where(where), + ]); + return { events, total: totalRow?.total ?? 0 }; + } + + async deleteDownloadEventsOlderThan(cutoffMs: number): Promise { + const result = await db + .delete(downloadEvents) + .where(lt(downloadEvents.createdAt, new Date(cutoffMs))); + return affectedRows(result); + } + // AI auto-download hold methods async recordAiAutoDownloadHold(entry: InsertAiAutoDownloadHold): Promise { const id = randomUUID(); diff --git a/server/task-registry.ts b/server/task-registry.ts new file mode 100644 index 000000000..fe4379845 --- /dev/null +++ b/server/task-registry.ts @@ -0,0 +1,193 @@ +import { storage } from "./storage.js"; +import { notifyUser } from "./socket.js"; +import { igdbLogger } from "./logger.js"; + +export type TaskTrigger = "schedule" | "manual" | "startup"; +export type TaskResult = "success" | "error"; + +export interface TaskDefinition { + name: string; + title: string; + description: string; + intervalMs: number; + /** Run once shortly after startup, before the first interval elapses. */ + runOnStartup?: boolean; + run: () => Promise; +} + +export interface TaskStatus { + name: string; + title: string; + description: string; + intervalMs: number; + running: boolean; + lastStartedAt: number | null; + lastFinishedAt: number | null; + lastDurationMs: number | null; + lastResult: TaskResult | null; + lastError: string | null; + lastTrigger: TaskTrigger | null; + nextRunAt: number | null; +} + +type PersistedState = Pick< + TaskStatus, + "lastStartedAt" | "lastFinishedAt" | "lastDurationMs" | "lastResult" | "lastError" | "lastTrigger" +>; + +interface Entry { + def: TaskDefinition; + state: TaskStatus; + timer: NodeJS.Timeout | null; +} + +export const STARTUP_DELAY_MS = 10_000; +const CONFIG_KEY_PREFIX = "task_state:"; +const MAX_ERROR_LENGTH = 500; + +const entries = new Map(); + +function emptyState(def: TaskDefinition): TaskStatus { + return { + name: def.name, + title: def.title, + description: def.description, + intervalMs: def.intervalMs, + running: false, + lastStartedAt: null, + lastFinishedAt: null, + lastDurationMs: null, + lastResult: null, + lastError: null, + lastTrigger: null, + nextRunAt: null, + }; +} + +export function registerTask(def: TaskDefinition): void { + const existing = entries.get(def.name); + if (existing?.timer) clearTimeout(existing.timer); + entries.set(def.name, { def, state: emptyState(def), timer: null }); +} + +export function getTaskStatuses(): TaskStatus[] { + return [...entries.values()].map((e) => ({ ...e.state })); +} + +export function getTaskStatus(name: string): TaskStatus | undefined { + const entry = entries.get(name); + return entry ? { ...entry.state } : undefined; +} + +function schedule(entry: Entry, delayMs: number): void { + if (entry.timer) clearTimeout(entry.timer); + entry.state.nextRunAt = Date.now() + delayMs; + entry.timer = setTimeout(() => { + void executeTask(entry.def.name, "schedule"); + }, delayMs); + // Never keep the process alive just for a task timer. + entry.timer.unref?.(); +} + +async function persist(entry: Entry): Promise { + const { lastStartedAt, lastFinishedAt, lastDurationMs, lastResult, lastError, lastTrigger } = + entry.state; + const payload: PersistedState = { + lastStartedAt, + lastFinishedAt, + lastDurationMs, + lastResult, + lastError, + lastTrigger, + }; + try { + await storage.setSystemConfig(CONFIG_KEY_PREFIX + entry.def.name, JSON.stringify(payload)); + } catch (err) { + igdbLogger.warn({ err, task: entry.def.name }, "Failed to persist task state"); + } +} + +async function restore(entry: Entry): Promise { + try { + const raw = await storage.getSystemConfig(CONFIG_KEY_PREFIX + entry.def.name); + if (!raw) return; + Object.assign(entry.state, JSON.parse(raw) as Partial); + } catch (err) { + igdbLogger.warn({ err, task: entry.def.name }, "Failed to restore task state"); + } +} + +/** + * Runs a task unless it is already running. Resolves once the run is over; the next run is + * scheduled interval-after-finish, so a slow run can never overlap itself. + */ +export async function executeTask( + name: string, + trigger: TaskTrigger +): Promise<"started" | "already_running" | "unknown_task"> { + const entry = entries.get(name); + if (!entry) return "unknown_task"; + if (entry.state.running) return "already_running"; + + if (entry.timer) clearTimeout(entry.timer); + entry.timer = null; + entry.state.running = true; + entry.state.nextRunAt = null; + entry.state.lastStartedAt = Date.now(); + entry.state.lastTrigger = trigger; + try { + notifyUser("task:started", { name, startedAt: entry.state.lastStartedAt, trigger }); + } catch (err) { + igdbLogger.warn({ err, task: name }, "Failed to notify task start"); + } + + const startedAt = entry.state.lastStartedAt; + try { + await entry.def.run(); + entry.state.lastResult = "success"; + entry.state.lastError = null; + } catch (err) { + entry.state.lastResult = "error"; + entry.state.lastError = (err instanceof Error ? err.message : String(err)).slice( + 0, + MAX_ERROR_LENGTH + ); + igdbLogger.error({ err, task: name }, "Scheduled task failed"); + } + entry.state.running = false; + entry.state.lastFinishedAt = Date.now(); + entry.state.lastDurationMs = entry.state.lastFinishedAt - startedAt; + schedule(entry, entry.def.intervalMs); + await persist(entry); + try { + notifyUser("task:finished", { ...entry.state }); + } catch (err) { + igdbLogger.warn({ err, task: name }, "Failed to notify task end"); + } + return "started"; +} + +/** Starts every registered task: startup tasks after a short delay, the rest on their interval. */ +export async function startTaskScheduler(): Promise { + for (const entry of entries.values()) { + if (entry.timer) clearTimeout(entry.timer); + await restore(entry); + if (entry.def.runOnStartup) { + entry.state.nextRunAt = Date.now() + STARTUP_DELAY_MS; + entry.timer = setTimeout(() => { + void executeTask(entry.def.name, "startup"); + }, STARTUP_DELAY_MS); + entry.timer.unref?.(); + } else { + schedule(entry, entry.def.intervalMs); + } + } +} + +/** Test helper: forget every task and cancel their timers. */ +export function resetTaskRegistry(): void { + for (const entry of entries.values()) { + if (entry.timer) clearTimeout(entry.timer); + } + entries.clear(); +} diff --git a/shared/__tests__/schema-parity.test.ts b/shared/__tests__/schema-parity.test.ts index 6f112ea5b..e1c794d11 100644 --- a/shared/__tests__/schema-parity.test.ts +++ b/shared/__tests__/schema-parity.test.ts @@ -21,6 +21,7 @@ import * as pgSchema from "../schema.pg.js"; const EXPECTED_TABLES = [ "aiAutoDownloadHolds", "apiKeys", + "downloadEvents", "downloaders", "gameDownloads", "gameFiles", diff --git a/shared/schema-parity.ts b/shared/schema-parity.ts index 13e126d93..2ddfbc141 100644 --- a/shared/schema-parity.ts +++ b/shared/schema-parity.ts @@ -215,3 +215,16 @@ export type _SelectApiKeys = Expect< export type _InsertApiKeys = Expect< Equal >; + +export type _SelectDownloadEvents = Expect< + Equal< + typeof sqliteSchema.downloadEvents.$inferSelect, + typeof pgSchema.downloadEvents.$inferSelect + > +>; +export type _InsertDownloadEvents = Expect< + Equal< + typeof sqliteSchema.downloadEvents.$inferInsert, + typeof pgSchema.downloadEvents.$inferInsert + > +>; diff --git a/shared/schema.pg.ts b/shared/schema.pg.ts index 81e7a328f..9bea0f19e 100644 --- a/shared/schema.pg.ts +++ b/shared/schema.pg.ts @@ -12,6 +12,7 @@ import { index, } from "drizzle-orm/pg-core"; import type { + DownloadEventType, GameExpansion, GameFileCategory, ImportTaskItemResult, @@ -285,6 +286,24 @@ export const releaseBlacklist = pgTable( (t) => [uniqueIndex("release_blacklist_game_title_idx").on(t.gameId, t.releaseTitle)] ); +export const downloadEvents = pgTable( + "download_events", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + gameId: text("game_id").references(() => games.id, { onDelete: "set null" }), + gameTitle: text("game_title").notNull(), + eventType: text("event_type").notNull().$type(), + releaseTitle: text("release_title").notNull(), + downloaderName: text("downloader_name"), + message: text("message"), + createdAt: timestampMs("created_at").default(sql`(EXTRACT(EPOCH FROM now()) * 1000)::bigint`), + }, + (t) => [index("download_events_user_created_idx").on(t.userId, t.createdAt)] +); + export const aiAutoDownloadHolds = pgTable( "ai_auto_download_holds", { diff --git a/shared/schema.ts b/shared/schema.ts index 90e60acb6..21801255e 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1199,6 +1199,42 @@ export type InsertImportTask = (typeof insertImportTaskSchema)["_output"]; export type ImportTaskItem = typeof importTaskItems.$inferSelect; export type InsertImportTaskItem = (typeof insertImportTaskItemSchema)["_output"]; +export const DOWNLOAD_EVENT_TYPES = [ + "grabbed", + "completed", + "imported", + "failed", + "needs_review", + "removed", + "blocklisted", +] as const; +export type DownloadEventType = (typeof DOWNLOAD_EVENT_TYPES)[number]; + +// Append-only log of what happened to a game's downloads. gameId survives the game being +// deleted as null; gameTitle is a snapshot so the entry still reads sensibly afterwards. +export const downloadEvents = sqliteTable( + "download_events", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + gameId: text("game_id").references(() => games.id, { onDelete: "set null" }), + gameTitle: text("game_title").notNull(), + eventType: text("event_type").notNull().$type(), + releaseTitle: text("release_title").notNull(), + downloaderName: text("downloader_name"), + message: text("message"), + createdAt: integer("created_at", { mode: "timestamp_ms" }).default( + sql`(strftime('%s', 'now') * 1000)` + ), + }, + (t) => [index("download_events_user_created_idx").on(t.userId, t.createdAt)] +); + +export type DownloadEvent = typeof downloadEvents.$inferSelect; +export type InsertDownloadEvent = Omit; + export const gameFileCategorySchema = z.enum(["main", "dlc", "update", "extra"]); export type GameFileCategory = z.infer;