diff --git a/client/__tests__/ImportSettings.test.tsx b/client/__tests__/ImportSettings.test.tsx
index 9cb761ed..a2df2864 100644
--- a/client/__tests__/ImportSettings.test.tsx
+++ b/client/__tests__/ImportSettings.test.tsx
@@ -29,6 +29,7 @@ const baseConfig: ImportConfig = {
libraryRoot: "/data/library",
transferMode: "hardlink",
autoDeleteAfterImport: false,
+ sortExtras: false,
importPlatformIds: [],
renamePattern: "{Title} ({Year})",
} as unknown as ImportConfig;
@@ -107,6 +108,23 @@ describe("ImportSettings", () => {
});
});
+ it("allows category subfolders to be enabled", async () => {
+ const { apiRequest } = await import("@/lib/queryClient");
+ renderComponent();
+ await screen.findByText("Sort add-on files into subfolders");
+
+ fireEvent.click(screen.getAllByRole("switch")[4]);
+ fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(apiRequest).toHaveBeenCalledWith(
+ "PATCH",
+ "/api/imports/config",
+ expect.objectContaining({ sortExtras: true })
+ );
+ });
+ });
+
it("filters platforms by search text", async () => {
renderComponent();
await screen.findByText("PC (Microsoft Windows)");
diff --git a/client/src/components/ImportSettings.tsx b/client/src/components/ImportSettings.tsx
index 8060f91c..03868adb 100644
--- a/client/src/components/ImportSettings.tsx
+++ b/client/src/components/ImportSettings.tsx
@@ -291,6 +291,23 @@ export default function ImportSettings() {
}
/>
+
+
+
Sort add-on files into subfolders
+
+ For directory imports, place detected DLC, updates, and extras in
+ dlc/, update/, and extra/{" "}
+ subfolders inside the game folder. Single-file imports keep their
+ existing layout.
+
+
+
+ setLocalConfig({ ...localConfig, sortExtras: checked })
+ }
+ />
+
diff --git a/migrations/0026_chemical_nightmare.sql b/migrations/0026_chemical_nightmare.sql
new file mode 100644
index 00000000..b954a6fd
--- /dev/null
+++ b/migrations/0026_chemical_nightmare.sql
@@ -0,0 +1 @@
+ALTER TABLE `user_settings` ADD `sort_extras` integer DEFAULT false NOT NULL;
\ No newline at end of file
diff --git a/migrations/meta/0026_snapshot.json b/migrations/meta/0026_snapshot.json
new file mode 100644
index 00000000..679cb4ec
--- /dev/null
+++ b/migrations/meta/0026_snapshot.json
@@ -0,0 +1,1646 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "91c5518a-54e4-410e-8e25-d525a57bcc16",
+ "prevId": "b8506e7a-cf45-4086-9f0f-135a115f9f8e",
+ "tables": {
+ "downloaders": {
+ "name": "downloaders",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "use_ssl": {
+ "name": "use_ssl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "url_path": {
+ "name": "url_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "download_path": {
+ "name": "download_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'games'"
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'Questarr'"
+ },
+ "add_stopped": {
+ "name": "add_stopped",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "remove_completed": {
+ "name": "remove_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "post_import_category": {
+ "name": "post_import_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "game_downloads": {
+ "name": "game_downloads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "downloader_id": {
+ "name": "downloader_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "download_type": {
+ "name": "download_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'torrent'"
+ },
+ "download_hash": {
+ "name": "download_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "download_title": {
+ "name": "download_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'downloading'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "game_downloads_downloader_hash_idx": {
+ "name": "game_downloads_downloader_hash_idx",
+ "columns": ["downloader_id", "download_hash"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "game_downloads_game_id_games_id_fk": {
+ "name": "game_downloads_game_id_games_id_fk",
+ "tableFrom": "game_downloads",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_downloads_downloader_id_downloaders_id_fk": {
+ "name": "game_downloads_downloader_id_downloaders_id_fk",
+ "tableFrom": "game_downloads",
+ "tableTo": "downloaders",
+ "columnsFrom": ["downloader_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "games": {
+ "name": "games",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "igdb_id": {
+ "name": "igdb_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "steam_appid": {
+ "name": "steam_appid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "release_date": {
+ "name": "release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platforms": {
+ "name": "platforms",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "themes": {
+ "name": "themes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publishers": {
+ "name": "publishers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "developers": {
+ "name": "developers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "screenshots": {
+ "name": "screenshots",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'manual'"
+ },
+ "igdb_websites": {
+ "name": "igdb_websites",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "aggregated_rating": {
+ "name": "aggregated_rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'wanted'"
+ },
+ "original_release_date": {
+ "name": "original_release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "release_status": {
+ "name": "release_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'upcoming'"
+ },
+ "early_access": {
+ "name": "early_access",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "hidden": {
+ "name": "hidden",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_adult_content": {
+ "name": "is_adult_content",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_age_restricted": {
+ "name": "is_age_restricted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "user_rating": {
+ "name": "user_rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "library_path": {
+ "name": "library_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_results_available": {
+ "name": "search_results_available",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "games_user_id_users_id_fk": {
+ "name": "games_user_id_users_id_fk",
+ "tableFrom": "games",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "import_task_items": {
+ "name": "import_task_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "task_id": {
+ "name": "task_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_name": {
+ "name": "item_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "game_title": {
+ "name": "game_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "import_task_items_task_id_idx": {
+ "name": "import_task_items_task_id_idx",
+ "columns": ["task_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "import_task_items_task_id_import_tasks_id_fk": {
+ "name": "import_task_items_task_id_import_tasks_id_fk",
+ "tableFrom": "import_task_items",
+ "tableTo": "import_tasks",
+ "columnsFrom": ["task_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "import_tasks": {
+ "name": "import_tasks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "task_type": {
+ "name": "task_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_items": {
+ "name": "total_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "added_items": {
+ "name": "added_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "skipped_items": {
+ "name": "skipped_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "failed_items": {
+ "name": "failed_items",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "import_tasks_user_id_users_id_fk": {
+ "name": "import_tasks_user_id_users_id_fk",
+ "tableFrom": "import_tasks",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "indexers": {
+ "name": "indexers",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol": {
+ "name": "protocol",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'torznab'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "categories": {
+ "name": "categories",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "rss_enabled": {
+ "name": "rss_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "auto_search_enabled": {
+ "name": "auto_search_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read": {
+ "name": "read",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "path_mappings": {
+ "name": "path_mappings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_path": {
+ "name": "remote_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "local_path": {
+ "name": "local_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remote_host": {
+ "name": "remote_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "platform_mappings": {
+ "name": "platform_mappings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "igdb_platform_id": {
+ "name": "igdb_platform_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_platform_name": {
+ "name": "source_platform_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "release_blacklist": {
+ "name": "release_blacklist",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "release_title": {
+ "name": "release_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "indexer_name": {
+ "name": "indexer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {
+ "release_blacklist_game_title_idx": {
+ "name": "release_blacklist_game_title_idx",
+ "columns": ["game_id", "release_title"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "release_blacklist_game_id_games_id_fk": {
+ "name": "release_blacklist_game_id_games_id_fk",
+ "tableFrom": "release_blacklist",
+ "tableTo": "games",
+ "columnsFrom": ["game_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rss_feed_items": {
+ "name": "rss_feed_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feed_id": {
+ "name": "feed_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "guid": {
+ "name": "guid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "link": {
+ "name": "link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pub_date": {
+ "name": "pub_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_name": {
+ "name": "source_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "igdb_game_id": {
+ "name": "igdb_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "igdb_game_name": {
+ "name": "igdb_game_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "rss_feed_items_feed_id_rss_feeds_id_fk": {
+ "name": "rss_feed_items_feed_id_rss_feeds_id_fk",
+ "tableFrom": "rss_feed_items",
+ "tableTo": "rss_feeds",
+ "columnsFrom": ["feed_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rss_feeds": {
+ "name": "rss_feeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "mapping": {
+ "name": "mapping",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_check": {
+ "name": "last_check",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'ok'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "system_config": {
+ "name": "system_config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "(strftime('%s', 'now') * 1000)"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_settings": {
+ "name": "user_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auto_search_enabled": {
+ "name": "auto_search_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "auto_download_enabled": {
+ "name": "auto_download_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "search_interval_hours": {
+ "name": "search_interval_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 6
+ },
+ "igdb_rate_limit_per_second": {
+ "name": "igdb_rate_limit_per_second",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 3
+ },
+ "download_rules": {
+ "name": "download_rules",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_auto_search": {
+ "name": "last_auto_search",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "xrel_scene_releases": {
+ "name": "xrel_scene_releases",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "xrel_p2p_releases": {
+ "name": "xrel_p2p_releases",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "auto_search_unreleased": {
+ "name": "auto_search_unreleased",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "steam_sync_failures": {
+ "name": "steam_sync_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "steam_sync_enabled": {
+ "name": "steam_sync_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "steam_sync_interval_hours": {
+ "name": "steam_sync_interval_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 24
+ },
+ "last_steam_sync": {
+ "name": "last_steam_sync",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "preferred_release_groups": {
+ "name": "preferred_release_groups",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "filter_by_preferred_groups": {
+ "name": "filter_by_preferred_groups",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "preferred_platform": {
+ "name": "preferred_platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hide_adult_content": {
+ "name": "hide_adult_content",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "hide_age_restricted_content": {
+ "name": "hide_age_restricted_content",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "enable_post_processing": {
+ "name": "enable_post_processing",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "auto_unpack": {
+ "name": "auto_unpack",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "rename_pattern": {
+ "name": "rename_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{Title} ({Region})'"
+ },
+ "overwrite_existing": {
+ "name": "overwrite_existing",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "transfer_mode": {
+ "name": "transfer_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'hardlink'"
+ },
+ "import_platform_ids": {
+ "name": "import_platform_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "ignored_extensions": {
+ "name": "ignored_extensions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "min_file_size": {
+ "name": "min_file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "library_root": {
+ "name": "library_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'/data'"
+ },
+ "auto_delete_after_import": {
+ "name": "auto_delete_after_import",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "sort_extras": {
+ "name": "sort_extras",
+ "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 fbc6a2c2..89b66add 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -183,6 +183,13 @@
"when": 1784581013215,
"tag": "0025_damp_storm",
"breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "6",
+ "when": 1785770232220,
+ "tag": "0026_chemical_nightmare",
+ "breakpoints": true
}
]
}
diff --git a/server/__tests__/helpers/import-test-helpers.ts b/server/__tests__/helpers/import-test-helpers.ts
index 32f9e1b0..9b0b69c2 100644
--- a/server/__tests__/helpers/import-test-helpers.ts
+++ b/server/__tests__/helpers/import-test-helpers.ts
@@ -51,6 +51,7 @@ export function makeImportConfig(overrides: Partial = {}): ImportC
minFileSize: 0,
libraryRoot: "/data",
autoDeleteAfterImport: false,
+ sortExtras: false,
...overrides,
};
}
diff --git a/server/__tests__/import_manager.test.ts b/server/__tests__/import_manager.test.ts
index 41a887c6..09406e5f 100644
--- a/server/__tests__/import_manager.test.ts
+++ b/server/__tests__/import_manager.test.ts
@@ -543,6 +543,68 @@ describe("ImportManager", () => {
execSpy.mockRestore();
});
+ it("confirmImport: recomputes category placement server-side when sorting is enabled", async () => {
+ storage.getGameDownload.mockResolvedValue({
+ id: "dl-1",
+ gameId: "g1",
+ downloaderId: "d1",
+ downloadTitle: "My Game",
+ });
+ storage.getGame.mockResolvedValue({
+ id: "g1",
+ title: "My Game",
+ userId: "u1",
+ status: "wanted",
+ platforms: [6],
+ });
+ storage.getImportConfig.mockResolvedValue(
+ makeImportConfig({ libraryRoot: "/safe/root", sortExtras: true })
+ );
+
+ const { PCImportStrategy } = await import("../services/ImportStrategies.js");
+ const planSpy = vi.spyOn(PCImportStrategy.prototype, "planImport").mockResolvedValue({
+ needsReview: false,
+ originalPath: "/downloads/game",
+ proposedPath: "/safe/root/PC/My Game",
+ strategy: "pc",
+ fileCategories: [{ name: "Game Update v1.nsp", category: "update" }],
+ });
+ const execSpy = vi.spyOn(PCImportStrategy.prototype, "executeImport").mockResolvedValue({
+ destDir: "/safe/root/PC/My Game",
+ filesPlaced: ["/safe/root/PC/My Game/update/Game Update v1.nsp"],
+ modeUsed: "move",
+ conflictsResolved: [],
+ });
+
+ const manager = new ImportManager(
+ storage as never, // NOSONAR
+ pathService as never, // NOSONAR
+ platformService as never, // NOSONAR
+ archiveService as never // NOSONAR
+ );
+
+ try {
+ await manager.confirmImport("dl-1", {
+ strategy: "pc",
+ originalPath: "/downloads/game",
+ proposedPath: "/safe/root/PC/My Game",
+ needsReview: false,
+ transferMode: "move",
+ });
+
+ expect(execSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ proposedPath: "/safe/root/PC/My Game",
+ fileCategories: [{ name: "Game Update v1.nsp", category: "update" }],
+ }),
+ "move"
+ );
+ } finally {
+ planSpy.mockRestore();
+ execSpy.mockRestore();
+ }
+ });
+
it("confirmImport: overridePlan.unpack = true → archiveService.extract is called", async () => {
storage.getGameDownload.mockResolvedValue({
id: "dl-1",
diff --git a/server/__tests__/import_strategies.test.ts b/server/__tests__/import_strategies.test.ts
index 8904e63b..ce8d6565 100644
--- a/server/__tests__/import_strategies.test.ts
+++ b/server/__tests__/import_strategies.test.ts
@@ -105,6 +105,38 @@ describe("ImportStrategies", () => {
expect(result.filesPlaced.some((p) => p.endsWith("game.exe"))).toBe(true);
expect(result.filesPlaced.some((p) => p.endsWith("data.pak"))).toBe(true);
});
+
+ it("sorts detected add-on files while preserving main and existing category paths", async () => {
+ const root = tempDir();
+ const sourceDir = path.join(root, "downloads", "game-folder");
+ const destination = path.join(root, "library", "PC", "My Game");
+ await fs.ensureDir(path.join(sourceDir, "dlc"));
+ await fs.writeFile(path.join(sourceDir, "base.nsp"), "base");
+ await fs.writeFile(path.join(sourceDir, "Game Update v1.nsp"), "update");
+ await fs.writeFile(path.join(sourceDir, "Game Expansion Pack.nsp"), "dlc");
+ await fs.writeFile(path.join(sourceDir, "Game OST.zip"), "extra");
+ await fs.writeFile(path.join(sourceDir, "dlc", "Game DLC Pack.nsp"), "nested-dlc");
+
+ const strategy = new PCImportStrategy();
+ const plan = await strategy.planImport(
+ sourceDir,
+ makeGame({ title: "My Game" }),
+ path.join(root, "library"),
+ makeImportConfig({ sortExtras: true, overwriteExisting: true })
+ );
+ const result = await strategy.executeImport(plan, "copy");
+
+ expect(result.filesPlaced).toEqual(
+ expect.arrayContaining([
+ path.join(destination, "base.nsp"),
+ path.join(destination, "update", "Game Update v1.nsp"),
+ path.join(destination, "dlc", "Game Expansion Pack.nsp"),
+ path.join(destination, "extra", "Game OST.zip"),
+ path.join(destination, "dlc", "Game DLC Pack.nsp"),
+ ])
+ );
+ expect(await fs.pathExists(path.join(destination, "dlc", "dlc"))).toBe(false);
+ });
});
// ---------------------------------------------------------------------------
@@ -182,5 +214,23 @@ describe("ImportStrategies", () => {
expect(plan.needsReview).toBe(true);
expect(plan.reviewReason).toMatch(/Destination already exists/);
});
+
+ it("keeps the existing flat destination for a single file when sorting is enabled", async () => {
+ const root = tempDir();
+ const source = path.join(root, "downloads", "game.exe");
+ await fs.ensureDir(path.dirname(source));
+ await fs.writeFile(source, "exe-bytes");
+
+ const strategy = new PCImportStrategy();
+ const plan = await strategy.planImport(
+ source,
+ makeGame({ title: "My Game" }),
+ path.join(root, "library"),
+ makeImportConfig({ sortExtras: true })
+ );
+
+ expect(plan.proposedPath).toMatch(/My Game\.exe$/);
+ expect(plan.fileCategories).toBeUndefined();
+ });
});
});
diff --git a/server/__tests__/storage.test.ts b/server/__tests__/storage.test.ts
index d07aa15f..de09cd7f 100644
--- a/server/__tests__/storage.test.ts
+++ b/server/__tests__/storage.test.ts
@@ -758,6 +758,7 @@ describe("Import And Mapping Helpers", () => {
minFileSize: 0,
libraryRoot: "/data",
autoDeleteAfterImport: false,
+ sortExtras: false,
});
});
diff --git a/server/routes/import.ts b/server/routes/import.ts
index d01bc5dd..92ea5ac2 100644
--- a/server/routes/import.ts
+++ b/server/routes/import.ts
@@ -44,6 +44,7 @@ const importConfigPatchSchema = z
minFileSize: z.number().int().min(0).optional(),
libraryRoot: z.string().min(1).max(1024).optional(),
autoDeleteAfterImport: z.boolean().optional(),
+ sortExtras: z.boolean().optional(),
})
.strict();
@@ -328,6 +329,7 @@ importRouter.patch("/config", async (req, res) => {
ignoredExtensions: newConfig.ignoredExtensions,
minFileSize: newConfig.minFileSize,
libraryRoot: newConfig.libraryRoot,
+ sortExtras: newConfig.sortExtras,
};
const existing = await storage.getUserSettings(userId);
diff --git a/server/services/ImportManager.ts b/server/services/ImportManager.ts
index af64ae1c..a0788966 100644
--- a/server/services/ImportManager.ts
+++ b/server/services/ImportManager.ts
@@ -594,6 +594,15 @@ export class ImportManager {
try {
const strategy = new PCImportStrategy();
+ if (config.sortExtras) {
+ const categorizedPlan = await strategy.planImport(
+ processPath,
+ game,
+ config.libraryRoot,
+ config
+ );
+ planToExecute.fileCategories = categorizedPlan.fileCategories;
+ }
const result = await strategy.executeImport(planToExecute, transferMode);
await this.finalizeImport(downloadId, game, result.destDir);
diff --git a/server/services/ImportStrategies.ts b/server/services/ImportStrategies.ts
index 1f60af9f..8f2f2723 100644
--- a/server/services/ImportStrategies.ts
+++ b/server/services/ImportStrategies.ts
@@ -1,4 +1,5 @@
import { Game, ImportConfig } from "../../shared/schema.js";
+import { categorizeDownload, type DownloadCategory } from "../../shared/download-categorizer.js";
import fs from "fs-extra";
import path from "node:path";
import { logger } from "../logger.js";
@@ -26,9 +27,15 @@ export interface ImportReview {
proposedPath: string;
strategy: "pc";
ignoredExtensions?: string[];
+ fileCategories?: FileCategoryEntry[];
importResult?: ImportResult;
}
+export interface FileCategoryEntry {
+ name: string;
+ category: DownloadCategory;
+}
+
export interface ImportStrategy {
planImport(
sourcePath: string,
@@ -49,6 +56,10 @@ async function transferFile(
destination: string,
mode: "move" | "copy" | "hardlink" | "symlink"
): Promise<"move" | "copy" | "hardlink" | "symlink"> {
+ if (path.resolve(source) === path.resolve(destination)) {
+ return mode;
+ }
+
await ensureParentDir(destination);
if (mode === "move") {
@@ -111,6 +122,31 @@ async function gatherFiles(rootPath: string): Promise {
return collected;
}
+const CATEGORY_DIR_MAP: Record = {
+ main: "",
+ dlc: "dlc",
+ update: "update",
+ extra: "extra",
+};
+
+async function categorizeSourceFiles(sourcePath: string): Promise {
+ const files = await gatherFiles(sourcePath);
+ return files.map((filePath) => {
+ const name = path.relative(sourcePath, filePath);
+ return { name, category: categorizeDownload(name).category };
+ });
+}
+
+function destinationForFile(gameDir: string, entry: FileCategoryEntry): string {
+ const subdir = CATEGORY_DIR_MAP[entry.category];
+ if (!subdir) return path.join(gameDir, entry.name);
+
+ const firstSegment = entry.name.split(path.sep)[0]?.toLowerCase();
+ return firstSegment === subdir
+ ? path.join(gameDir, entry.name)
+ : path.join(gameDir, subdir, entry.name);
+}
+
export class PCImportStrategy implements ImportStrategy {
async planImport(
sourcePath: string,
@@ -127,6 +163,10 @@ export class PCImportStrategy implements ImportStrategy {
const cleanTitle = sanitizeFsName(game.title);
const ext = stats.isDirectory() ? "" : path.extname(sourcePath);
const destination = path.join(targetRoot, platformDir ?? "PC", cleanTitle + ext);
+ const fileCategories =
+ stats.isDirectory() && config.sortExtras
+ ? await categorizeSourceFiles(sourcePath)
+ : undefined;
const destinationExists = await fs.pathExists(destination);
const needsReview = destinationExists && !config.overwriteExisting;
@@ -137,6 +177,7 @@ export class PCImportStrategy implements ImportStrategy {
originalPath: sourcePath,
proposedPath: destination,
strategy: "pc",
+ fileCategories,
};
}
@@ -144,6 +185,41 @@ export class PCImportStrategy implements ImportStrategy {
review: ImportReview,
transferMode: "move" | "copy" | "hardlink" | "symlink"
): Promise {
+ if (review.fileCategories && review.fileCategories.length > 0) {
+ const filesPlaced: string[] = [];
+ const conflictsResolved: string[] = [];
+ let modeUsed: TransferMode = transferMode;
+
+ for (const entry of review.fileCategories) {
+ const sourceFile = path.join(review.originalPath, entry.name);
+ const destinationFile = destinationForFile(review.proposedPath, entry);
+ const entryMode = await transferFile(sourceFile, destinationFile, transferMode);
+ filesPlaced.push(destinationFile);
+ if (entryMode !== transferMode) {
+ modeUsed = entryMode;
+ conflictsResolved.push(`${entry.name} (mode fallback: ${entryMode})`);
+ }
+ }
+
+ const resolvedSource = path.resolve(review.originalPath);
+ const resolvedDestination = path.resolve(review.proposedPath);
+ const destinationInsideSource = resolvedDestination.startsWith(resolvedSource + path.sep);
+ if (
+ transferMode === "move" &&
+ resolvedSource !== resolvedDestination &&
+ !destinationInsideSource
+ ) {
+ await fs.remove(review.originalPath);
+ }
+
+ return {
+ destDir: review.proposedPath,
+ filesPlaced,
+ modeUsed,
+ conflictsResolved,
+ };
+ }
+
await fs.ensureDir(path.dirname(review.proposedPath));
const modeUsed = await transferFile(review.originalPath, review.proposedPath, transferMode);
const filesPlaced = await gatherFiles(review.proposedPath);
diff --git a/server/storage.ts b/server/storage.ts
index c781eb24..beaa7d19 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -93,6 +93,7 @@ function buildImportConfigFromSettings(
| "minFileSize"
| "libraryRoot"
| "autoDeleteAfterImport"
+ | "sortExtras"
>
): ImportConfig {
const parsed = importConfigSchema.safeParse({
@@ -106,6 +107,7 @@ function buildImportConfigFromSettings(
minFileSize: settings?.minFileSize ?? 0,
libraryRoot: settings?.libraryRoot ?? "/data",
autoDeleteAfterImport: settings?.autoDeleteAfterImport ?? false,
+ sortExtras: settings?.sortExtras ?? false,
});
if (parsed.success) return parsed.data;
@@ -121,6 +123,7 @@ function buildImportConfigFromSettings(
minFileSize: settings?.minFileSize ?? 0,
libraryRoot: settings?.libraryRoot ?? "/data",
autoDeleteAfterImport: settings?.autoDeleteAfterImport ?? false,
+ sortExtras: settings?.sortExtras ?? false,
};
}
@@ -1081,6 +1084,7 @@ export class MemStorage implements IStorage {
minFileSize: insertSettings.minFileSize ?? 0,
libraryRoot: insertSettings.libraryRoot ?? "/data",
autoDeleteAfterImport: insertSettings.autoDeleteAfterImport ?? false,
+ sortExtras: insertSettings.sortExtras ?? false,
preferredReleaseGroups: insertSettings.preferredReleaseGroups ?? null,
filterByPreferredGroups: insertSettings.filterByPreferredGroups ?? false,
diff --git a/shared/schema.ts b/shared/schema.ts
index a7162737..e898deab 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -71,6 +71,7 @@ export const userSettings = sqliteTable("user_settings", {
autoDeleteAfterImport: integer("auto_delete_after_import", { mode: "boolean" })
.notNull()
.default(false),
+ sortExtras: integer("sort_extras", { mode: "boolean" }).notNull().default(false),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(
sql`(strftime('%s', 'now') * 1000)`
),
@@ -115,6 +116,7 @@ export interface ImportConfig {
minFileSize: number;
libraryRoot: string;
autoDeleteAfterImport: boolean;
+ sortExtras: boolean;
}
export const IMPORT_TRANSFER_MODES = ["move", "copy", "hardlink", "symlink"] as const;
@@ -134,6 +136,7 @@ export const importConfigSchema = z.object({
minFileSize: z.number().int().min(0),
libraryRoot: z.string().min(1),
autoDeleteAfterImport: z.boolean(),
+ sortExtras: z.boolean(),
});
export const systemConfig = sqliteTable("system_config", {