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

+ 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", {