diff --git a/docs/contributing.md b/docs/contributing.md
deleted file mode 100644
index cd72da7..0000000
--- a/docs/contributing.md
+++ /dev/null
@@ -1,416 +0,0 @@
-# Contributing Guide
-
-## 📑 Table of Contents
-
-- [Code of Conduct](#code-of-conduct)
-- [Development Principles](#development-principles)
-- [Getting Started](#getting-started)
-- [Development Workflow](#development-workflow)
-- [Code Standards](#code-standards)
-- [Database Changes](#database-changes)
-- [Testing](#testing)
-- [Pull Request Process](#pull-request-process)
-- [Project Structure](#project-structure)
-- [Common Tasks](#common-tasks)
-
----
-
-Thank you for your interest in contributing to RuleDesk! This document provides guidelines and instructions for contributing.
-
-**📖 Related Documentation:**
-
-- [Development Guide](./development.md) - Development setup and workflows
-- [Architecture Documentation](./architecture.md) - System architecture and design patterns
-- [API Documentation](./api.md) - IPC API reference
-- [Glossary](./glossary.md) - Key terms and concepts
-
-## Code of Conduct
-
-- Be respectful and professional
-- Follow the project's coding standards
-- Write clear, maintainable code
-- Test your changes before submitting
-
-## Development Principles
-
-This project adheres to strict development principles. Please review these before contributing:
-
-### KISS & YAGNI
-
-- **KISS (Keep It Simple, Stupid):** Prefer simple, readable solutions
-- **YAGNI (You Aren't Gonna Need It):** Implement only what's required now
-
-### SOLID & DRY
-
-- **Single Responsibility:** One component/function = one job
-- **DRY (Don't Repeat Yourself):** Refactor duplicated code
-- **Composition over Inheritance:** Prefer composition in React
-
-### Code Quality
-
-- **TypeScript:** Strict typing, no `any` or unsafe casts
-- **Explicit over Implicit:** No magic numbers or strings
-- **Fail Fast:** Validate inputs at boundaries
-- **Error Handling:** Proper error handling, no bare `catch (e)`
-
-## Getting Started
-
-### Prerequisites
-
-- Node.js v18 or higher
-- npm or yarn
-- Git
-
-### Setup
-
-1. **Fork and Clone**
-
- ```bash
- git clone https://github.com/KazeKaze93/ruledesk.git
- cd ruledesk
- ```
-
-2. **Install Dependencies**
-
- ```bash
- npm install
- ```
-
-3. **Run Development Mode**
-
- ```bash
- npm run dev
- ```
-
-4. **Run Type Checking**
-
- ```bash
- npm run typecheck
- ```
-
-5. **Run Linter**
- ```bash
- npm run lint
- ```
-
-## Development Workflow
-
-### Branch Strategy
-
-- Create a feature branch from `master`
-- Use descriptive branch names: `feature/add-download-manager`, `fix/artist-validation`
-
-### Making Changes
-
-1. **Create a Branch**
-
- ```bash
- git checkout -b feature/your-feature-name
- ```
-
-2. **Make Your Changes**
-
- - Follow the coding standards
- - Write clear, self-documenting code
- - Add comments where necessary
-
-3. **Test Your Changes**
-
- - Run the application: `npm run dev`
- - Check for TypeScript errors: `npm run typecheck`
- - Run the linter: `npm run lint`
-
-4. **Commit Your Changes**
- ```bash
- git add .
- git commit -m "feat: add download manager"
- ```
-
-### Commit Message Format
-
-Follow conventional commits:
-
-- `feat:` - New feature
-- `fix:` - Bug fix
-- `docs:` - Documentation changes
-- `style:` - Code style changes (formatting)
-- `refactor:` - Code refactoring
-- `test:` - Adding tests
-- `chore:` - Maintenance tasks
-
-**Example:**
-
-```
-feat: add artist deletion functionality
-
-- Add deleteArtist method to DbService
-- Add delete button to artist list UI
-- Add confirmation dialog before deletion
-```
-
-## Code Standards
-
-### TypeScript
-
-- **No `any` types:** Use proper types or `unknown`
-- **No unsafe casts:** Avoid `as` unless absolutely necessary
-- **Strict mode:** All code must pass `tsc --noEmit`
-- **Type inference:** Prefer inference where possible
-
-**Good:**
-
-```typescript
-const artists: Artist[] = await dbService.getTrackedArtists();
-```
-
-**Bad:**
-
-```typescript
-const artists: any = await dbService.getTrackedArtists();
-```
-
-### React
-
-- **Functional Components:** Use function components, not classes
-- **Hooks:** Prefer hooks over lifecycle methods
-- **Props Types:** Always type component props
-- **No Inline Styles:** Use Tailwind CSS classes
-
-**Good:**
-
-```typescript
-interface ArtistCardProps {
- artist: Artist;
- onDelete: (id: number) => void;
-}
-
-export const ArtistCard: React.FC = ({ artist, onDelete }) => {
- return {artist.name}
;
-};
-```
-
-**Bad:**
-
-```typescript
-export const ArtistCard = ({ artist, onDelete }: any) => {
- return {artist.name}
;
-};
-```
-
-### Error Handling
-
-- **Never use bare catch:** Always handle errors properly
-- **Descriptive errors:** Provide meaningful error messages
-- **Log errors:** Use the logger for error tracking
-
-**Good:**
-
-```typescript
-try {
- const result = await dbService.addArtist(data);
- return result;
-} catch (error) {
- logger.error("Failed to add artist:", error);
- if (error instanceof Error) {
- throw new Error(`Failed to add artist: ${error.message}`);
- }
- throw error;
-}
-```
-
-**Bad:**
-
-```typescript
-try {
- return await dbService.addArtist(data);
-} catch (e) {
- // ...
-}
-```
-
-### Database
-
-- **Use Drizzle ORM:** Never write raw SQL unless necessary
-- **Type Safety:** Use inferred types from schema
-- **Migrations:** Always create migrations for schema changes
-
-**Good:**
-
-```typescript
-const artists = await db.query.artists.findMany({
- where: eq(schema.artists.id, artistId),
-});
-```
-
-**Bad:**
-
-```typescript
-const artists = db.prepare("SELECT * FROM artists WHERE id = ?").all(artistId);
-```
-
-## Database Changes
-
-### Creating Migrations
-
-1. **Modify Schema** (`src/main/db/schema.ts`)
-
-2. **Generate Migration**
-
- ```bash
- npm run db:generate
- ```
-
-3. **Review Migration** (in `drizzle/` folder)
-
- - SQL migration files (`drizzle/*.sql`) are tracked in git
- - Meta files (`drizzle/meta/`) are ignored by git and generated locally
-
-4. **Test Migration**
- ```bash
- npm run db:migrate
- ```
-
-## Testing
-
-### Manual Testing
-
-- Test all new features manually
-- Verify error handling
-- Check UI responsiveness
-- Test on different screen sizes
-
-### Automated Testing
-
-(To be implemented)
-
-## Pull Request Process
-
-1. **Update Documentation**
-
- - Update relevant documentation files
- - Add examples if introducing new features
-
-2. **Update README**
-
- - If adding new features, update the README
- - Keep the README concise
-
-3. **Create Pull Request**
-
- - Provide a clear description
- - Reference any related issues
- - Include screenshots for UI changes
-
-4. **Review Process**
- - Address review comments
- - Keep commits atomic (one logical change per commit)
- - Squash commits if requested
-
-## Project Structure
-
-### Key Directories
-
-- `src/main/` - Electron Main Process code
-- `src/renderer/` - React Renderer Process code
-- `src/main/db/` - Database schema and services
-- `docs/` - Documentation files
-- `drizzle/` - Database migrations
-
-### File Naming
-
-- **Components:** PascalCase (`ArtistCard.tsx`)
-- **Utilities:** camelCase (`utils.ts`)
-- **Types:** PascalCase (`types.ts`)
-- **Services:** PascalCase (`DbService.ts`)
-
-## Common Tasks
-
-### Adding a New IPC Method
-
-1. **Add Channel Constant** (`src/main/ipc/channels.ts`)
-
- ```typescript
- export const IPC_CHANNELS = {
- APP: {
- // ... existing channels
- NEW_METHOD: "app:new-method",
- },
- } as const;
- ```
-
-2. **Define in Bridge** (`src/main/bridge.ts`)
-
- ```typescript
- export interface IpcBridge {
- // ... existing methods
- newMethod: () => Promise;
- }
- ```
-
-3. **Implement in Bridge**
-
- ```typescript
- const ipcBridge: IpcBridge = {
- // ... existing methods
- newMethod: () => ipcRenderer.invoke(IPC_CHANNELS.APP.NEW_METHOD),
- };
- ```
-
-4. **Add Handler in Controller** (`src/main/ipc/controllers/` - add to appropriate controller or create new)
-
- ```typescript
- export class MyController extends BaseController {
- setup() {
- this.handle(
- IPC_CHANNELS.APP.NEW_METHOD,
- NewMethodSchema, // Zod schema
- this.newMethod.bind(this)
- );
- }
-
- private async newMethod(
- _event: IpcMainInvokeEvent,
- data: NewMethodRequest
- ) {
- const db = container.resolve(DI_TOKENS.DB);
- // Implementation
- }
- }
- ```
-
-5. **Register Controller** (`src/main/ipc/index.ts` - in `setupIpc()` function)
-
- ```typescript
- const myController = new MyController();
- myController.setup();
- ```
-
-6. **Update Types** (`src/renderer.d.ts`)
- ```typescript
- export interface IpcApi {
- // ... existing methods
- newMethod: () => Promise;
- }
- ```
-
-### Adding a New Database Table
-
-1. **Add Schema** (`src/main/db/schema.ts`)
-2. **Generate Migration** (`npm run db:generate`)
-3. **Review Migration** (check generated SQL in `drizzle/*.sql` files - these are tracked in git)
-4. **Test Migration** (`npm run db:migrate`)
-5. **Update Documentation** (`docs/database.md` - add table documentation)
-
-**Note:** Only SQL migration files (`drizzle/*.sql`) should be committed. Meta files in `drizzle/meta/` are automatically ignored by git.
-
-## Questions?
-
-If you have questions about contributing:
-
-1. Check existing documentation
-2. Review similar code in the codebase
-3. Open an issue for discussion
-
-## License
-
-By contributing, you agree that your contributions will be licensed under the MIT License.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 4eecdf3..d912a82 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -328,8 +328,8 @@ Advanced features for future releases:
- ✅ **Queue System:** Handle downloads in the background/main process with progress tracking
- ✅ **Progress Events:** Real-time download progress via IPC events (`onDownloadProgress`)
- ✅ **File Management:** Open downloaded file in folder (`openFileInFolder`)
-- ⏳ "Download All" for current filter/artist (planned)
-- ⏳ **Settings:** Allow choosing a default download directory (planned)
+- ✅ **Download All:** Batch download for Artist Gallery, Favorites, Updates (rate-limited, max 500 files)
+- ✅ **Settings:** Default download folder configurable in Settings
**Implementation Notes:**
diff --git a/drizzle/0012_add_download_folder.sql b/drizzle/0012_add_download_folder.sql
new file mode 100644
index 0000000..874e1d9
--- /dev/null
+++ b/drizzle/0012_add_download_folder.sql
@@ -0,0 +1,3 @@
+-- Add default download folder setting
+-- Allows users to choose a custom folder for batch downloads
+ALTER TABLE settings ADD COLUMN download_folder text;
diff --git a/drizzle/0013_add_download_settings.sql b/drizzle/0013_add_download_settings.sql
new file mode 100644
index 0000000..ed9f603
--- /dev/null
+++ b/drizzle/0013_add_download_settings.sql
@@ -0,0 +1,5 @@
+-- Add download behavior settings
+-- duplicate_file_behavior: 'skip' | 'overwrite' - what to do when file exists
+-- download_folder_structure: 'flat' | '{artist_id}' - subfolder structure
+ALTER TABLE settings ADD COLUMN duplicate_file_behavior text DEFAULT 'skip';
+ALTER TABLE settings ADD COLUMN download_folder_structure text DEFAULT 'flat';
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 385c408..83671ff 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -78,6 +78,20 @@
"when": 1769269364000,
"tag": "0011_add_fts5_count_meta",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "5",
+ "when": 1769269365000,
+ "tag": "0012_add_download_folder",
+ "breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "5",
+ "when": 1769269366000,
+ "tag": "0013_add_download_settings",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 3f63d0c..dab54b8 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -15,6 +15,7 @@ export default defineConfig({
rollupOptions: {
input: {
main: resolve(__dirname, "src/main/main.ts"),
+ "workers/downloadWorker": resolve(__dirname, "src/main/workers/downloadWorker.ts"),
},
output: {
dir: "out/main",
diff --git a/src/main/bridge.ts b/src/main/bridge.ts
index 0ffe6b2..1c12889 100644
--- a/src/main/bridge.ts
+++ b/src/main/bridge.ts
@@ -59,6 +59,7 @@ export interface IpcBridge {
// Settings
getSettings: () => Promise;
saveSettings: (creds: { userId: string; apiKey: string }) => Promise;
+ saveDownloadFolder: (path: string | null) => Promise;
confirmLegal: () => Promise;
logout: () => Promise;
@@ -73,6 +74,8 @@ export interface IpcBridge {
// Posts
getArtistPosts: (params: GetPostsRequest) => Promise;
getArtistPostsCount: (artistId?: number) => Promise;
+ getDownloadItems: (params: GetPostsRequest & { limit?: number }) => Promise<{ items: Array<{ url: string; filename: string }> }>;
+ getPostsCountWithFilters: (params: Pick) => Promise;
togglePostViewed: (postId: number) => Promise;
@@ -114,9 +117,38 @@ export interface IpcBridge {
error?: string;
canceled?: boolean;
}>;
+ downloadAll: (
+ items: Array<{ url: string; filename: string }>
+ ) => Promise<{
+ success: boolean;
+ downloaded: number;
+ failed: number;
+ canceled: boolean;
+ error?: string;
+ }>;
+ cancelDownloadAll: () => Promise;
+ pauseDownloadAll: () => Promise;
+ resumeDownloadAll: () => Promise;
+ getPendingDownload: () => Promise<{
+ hasPending: boolean;
+ total: number;
+ done: number;
+ folder: string;
+ } | null>;
+ resumePendingDownload: () => Promise<{ success: boolean; error?: string }>;
+ dismissPendingDownload: () => Promise;
+ saveDownloadSettings: (data: {
+ duplicateFileBehavior?: "skip" | "overwrite";
+ downloadFolderStructure?: "flat" | "{artist_id}";
+ }) => Promise;
openFileInFolder: (path: string) => Promise;
+ selectDownloadFolder: () => Promise;
onDownloadProgress: (callback: DownloadProgressCallback) => () => void;
+ onDownloadAllProgress: (
+ callback: (data: { id: string; percent: number; done: number; total: number }) => void
+ ) => () => void;
+ onPendingDownloadStateChanged: (callback: () => void) => () => void;
searchRemoteTags: (query: string, provider?: ProviderId) => Promise;
@@ -142,7 +174,7 @@ export interface IpcBridge {
removePostsFromPlaylist: (data: RemovePostsFromPlaylistRequest) => Promise;
getPlaylistPosts: (params: GetPlaylistPostsRequest) => Promise;
resolvePlaylistPosts: (params: ResolvePlaylistPostsRequest) => Promise;
- getPlaylistsContainingPost: (postId: number) => Promise;
+ getPlaylistsContainingPost: (postId: number, rule34PostId?: number) => Promise;
}
const ipcBridge: IpcBridge = {
@@ -176,6 +208,8 @@ const ipcBridge: IpcBridge = {
verifyCredentials: () => ipcRenderer.invoke("app:verify-creds"),
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS.GET),
+ saveDownloadFolder: (path) =>
+ ipcRenderer.invoke(IPC_CHANNELS.SETTINGS.SAVE_DOWNLOAD_FOLDER, path),
saveSettings: (creds) =>
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS.SAVE, creds),
confirmLegal: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS.CONFIRM_LEGAL),
@@ -191,6 +225,10 @@ const ipcBridge: IpcBridge = {
ipcRenderer.invoke("db:get-posts", params),
getArtistPostsCount: (artistId?: number) =>
ipcRenderer.invoke("db:get-posts-count", artistId),
+ getDownloadItems: (params: GetPostsRequest & { limit?: number }) =>
+ ipcRenderer.invoke(IPC_CHANNELS.DB.GET_DOWNLOAD_ITEMS, params),
+ getPostsCountWithFilters: (params: Pick) =>
+ ipcRenderer.invoke(IPC_CHANNELS.DB.GET_POSTS_COUNT_WITH_FILTERS, params),
openExternal: (url) => ipcRenderer.invoke("app:open-external", url),
@@ -214,9 +252,28 @@ const ipcBridge: IpcBridge = {
return ipcRenderer.invoke("files:download", url, filename);
},
+ downloadAll: (items: Array<{ url: string; filename: string }>) =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.DOWNLOAD_ALL, items),
+ cancelDownloadAll: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.CANCEL_DOWNLOAD_ALL),
+ pauseDownloadAll: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.PAUSE_DOWNLOAD_ALL),
+ resumeDownloadAll: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.RESUME_DOWNLOAD_ALL),
+ getPendingDownload: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.GET_PENDING_DOWNLOAD),
+ resumePendingDownload: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.RESUME_PENDING_DOWNLOAD),
+ dismissPendingDownload: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.DISMISS_PENDING_DOWNLOAD),
+ saveDownloadSettings: (data) =>
+ ipcRenderer.invoke(IPC_CHANNELS.SETTINGS.SAVE_DOWNLOAD_SETTINGS, data),
openFileInFolder: (path: string) =>
ipcRenderer.invoke("files:open-folder", path),
+ selectDownloadFolder: () =>
+ ipcRenderer.invoke(IPC_CHANNELS.FILES.SELECT_DOWNLOAD_FOLDER),
+
onDownloadProgress: (callback) => {
const channel = "files:download-progress";
const subscription = (_: IpcRendererEvent, data: DownloadProgressData) =>
@@ -228,6 +285,23 @@ const ipcBridge: IpcBridge = {
};
},
+ onDownloadAllProgress: (callback) => {
+ const channel = IPC_CHANNELS.FILES.DOWNLOAD_ALL_PROGRESS;
+ const subscription = (
+ _: IpcRendererEvent,
+ data: { id: string; percent: number; done: number; total: number }
+ ) => callback(data);
+ ipcRenderer.on(channel, subscription);
+ return () => ipcRenderer.removeListener(channel, subscription);
+ },
+
+ onPendingDownloadStateChanged: (callback) => {
+ const channel = IPC_CHANNELS.FILES.PENDING_DOWNLOAD_STATE_CHANGED;
+ const subscription = () => callback();
+ ipcRenderer.on(channel, subscription);
+ return () => ipcRenderer.removeListener(channel, subscription);
+ },
+
repairArtist: (artistId) =>
ipcRenderer.invoke("sync:repair-artist", artistId),
@@ -301,8 +375,8 @@ const ipcBridge: IpcBridge = {
ipcRenderer.invoke(IPC_CHANNELS.DB.GET_PLAYLIST_POSTS, params),
resolvePlaylistPosts: (params: ResolvePlaylistPostsRequest) =>
ipcRenderer.invoke(IPC_CHANNELS.DB.RESOLVE_PLAYLIST_POSTS, params),
- getPlaylistsContainingPost: (postId: number) =>
- ipcRenderer.invoke(IPC_CHANNELS.DB.GET_PLAYLISTS_CONTAINING_POST, postId),
+ getPlaylistsContainingPost: (postId: number, rule34PostId?: number) =>
+ ipcRenderer.invoke(IPC_CHANNELS.DB.GET_PLAYLISTS_CONTAINING_POST, postId, rule34PostId),
};
contextBridge.exposeInMainWorld("api", ipcBridge);
diff --git a/src/main/db/client.ts b/src/main/db/client.ts
index 5dcae86..d166a62 100644
--- a/src/main/db/client.ts
+++ b/src/main/db/client.ts
@@ -167,7 +167,7 @@ export async function initializeDatabase(): Promise {
);
`);
- // Execute migrations manually, handling duplicate column errors
+ // Execute migrations manually, handling duplicate column/table errors
for (const entry of migrationEntries) {
const migrationFile = path.join(migrationsFolder, `${entry.tag}.sql`);
@@ -188,32 +188,24 @@ export async function initializeDatabase(): Promise {
try {
const migrationSQL = fs.readFileSync(migrationFile, "utf-8");
- // Handle migration 0004 specially - check if columns exist before adding
- if (entry.tag === "0004_exotic_misty_knight") {
- const tableInfo = sqliteInstance
- .prepare("PRAGMA table_info(settings)")
- .all() as Array<{ name: string }>;
- const columnNames = tableInfo.map((col) => col.name);
-
- // Only execute ALTER TABLE if columns don't exist
- const needsIsAdultVerified = !columnNames.includes("is_adult_verified");
- const needsTosAcceptedAt = !columnNames.includes("tos_accepted_at");
-
- if (needsIsAdultVerified) {
- sqliteInstance.exec(
- "ALTER TABLE settings ADD COLUMN is_adult_verified integer DEFAULT 0 NOT NULL;"
- );
- logger.debug("[DB] Added is_adult_verified column");
- }
- if (needsTosAcceptedAt) {
- sqliteInstance.exec("ALTER TABLE settings ADD COLUMN tos_accepted_at integer;");
- logger.debug("[DB] Added tos_accepted_at column");
+ // Handle migration 0000: if artists table exists, skip (DB was created without migrations)
+ if (entry.tag === "0000_blue_lorna_dane") {
+ const artistsTableExists = sqliteInstance
+ .prepare(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='artists'"
+ )
+ .get();
+ if (artistsTableExists) {
+ logger.debug("[DB] Migration 0000: artists exists, skipping");
+ sqliteInstance
+ .prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)")
+ .run(entry.tag, Date.now());
+ } else {
+ sqliteInstance.exec(migrationSQL);
+ sqliteInstance
+ .prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)")
+ .run(entry.tag, Date.now());
}
-
- // Mark migration as executed
- sqliteInstance
- .prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)")
- .run(entry.tag, Date.now());
} else if (entry.tag === "0010_add_fts5_cache_invalidation") {
// Handle migration 0010 specially - it tries to create triggers on FTS5 virtual table
// SQLite doesn't allow triggers on virtual tables in some configurations
@@ -377,10 +369,14 @@ export async function initializeDatabase(): Promise {
const errorMessage = error.message || String(error);
const errorCode = error.code || "";
- // If it's a duplicate column error, log and mark as executed
- if (errorCode === "SQLITE_ERROR" && errorMessage.includes("duplicate column")) {
+ // If it's a duplicate column/table/index error, log and mark as executed
+ // (DB was created without migrations or from different schema)
+ const isAlreadyExists =
+ (errorCode === "SQLITE_ERROR" && errorMessage.includes("duplicate column")) ||
+ (errorCode === "SQLITE_ERROR" && errorMessage.includes("already exists"));
+ if (isAlreadyExists) {
logger.warn(
- `[DB] Migration ${entry.tag} attempted to add duplicate column. Skipping...`
+ `[DB] Migration ${entry.tag}: object already exists. Skipping...`
);
// Mark as executed anyway to prevent retry
try {
diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts
index 9ae7f1a..b48d5f7 100644
--- a/src/main/db/schema.ts
+++ b/src/main/db/schema.ts
@@ -125,6 +125,9 @@ export const settings = sqliteTable("settings", {
.default(false)
.notNull(),
tosAcceptedAt: integer("tos_accepted_at", { mode: "timestamp" }),
+ downloadFolder: text("download_folder"),
+ duplicateFileBehavior: text("duplicate_file_behavior").default("skip"),
+ downloadFolderStructure: text("download_folder_structure").default("flat"),
});
export const tagMetadata = sqliteTable(
diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts
index bb43885..dac669b 100644
--- a/src/main/ipc/channels.ts
+++ b/src/main/ipc/channels.ts
@@ -11,14 +11,18 @@ export const IPC_CHANNELS = {
GET: "app:get-settings-status",
SAVE: "app:save-settings",
CONFIRM_LEGAL: "settings:confirm-legal",
+ SAVE_DOWNLOAD_FOLDER: "settings:save-download-folder",
+ SAVE_DOWNLOAD_SETTINGS: "settings:save-download-settings",
},
DB: {
+ GET_DOWNLOAD_ITEMS: "db:get-download-items",
GET_ARTISTS: "db:get-artists",
ADD_ARTIST: "db:add-artist",
DELETE_ARTIST: "db:delete-artist",
SEARCH_TAGS: "db:search-tags",
GET_POSTS: "db:get-posts",
GET_POSTS_COUNT: "db:get-posts-count",
+ GET_POSTS_COUNT_WITH_FILTERS: "db:get-posts-count-with-filters",
MARK_VIEWED: "db:mark-post-viewed",
TOGGLE_FAVORITE: "db:toggle-post-favorite",
SYNC_ALL: "db:sync-all",
@@ -36,6 +40,7 @@ export const IPC_CHANNELS = {
GET_PLAYLIST_POSTS: "db:get-playlist-posts",
RESOLVE_PLAYLIST_POSTS: "db:resolve-playlist-posts",
GET_PLAYLISTS_CONTAINING_POST: "db:get-playlists-containing-post",
+ GET_PLAYLIST_DOWNLOAD_ITEMS: "db:get-playlist-download-items",
},
API: {
SEARCH_REMOTE: "api:search-remote-tags",
@@ -55,7 +60,17 @@ export const IPC_CHANNELS = {
FILES: {
DOWNLOAD: "files:download",
+ DOWNLOAD_ALL: "files:download-all",
+ CANCEL_DOWNLOAD_ALL: "files:cancel-download-all",
+ PAUSE_DOWNLOAD_ALL: "files:pause-download-all",
+ RESUME_DOWNLOAD_ALL: "files:resume-download-all",
+ GET_PENDING_DOWNLOAD: "files:get-pending-download",
+ RESUME_PENDING_DOWNLOAD: "files:resume-pending-download",
+ DISMISS_PENDING_DOWNLOAD: "files:dismiss-pending-download",
OPEN_FOLDER: "files:open-folder",
DOWNLOAD_PROGRESS: "files:download-progress",
+ DOWNLOAD_ALL_PROGRESS: "files:download-all-progress",
+ PENDING_DOWNLOAD_STATE_CHANGED: "files:pending-download-state-changed",
+ SELECT_DOWNLOAD_FOLDER: "files:select-download-folder",
},
} as const;
diff --git a/src/main/ipc/controllers/FileController.ts b/src/main/ipc/controllers/FileController.ts
index a7bf96f..7471d42 100644
--- a/src/main/ipc/controllers/FileController.ts
+++ b/src/main/ipc/controllers/FileController.ts
@@ -2,14 +2,18 @@ import { type IpcMainInvokeEvent } from "electron";
import { app, shell, dialog, BrowserWindow, type BrowserWindow as BrowserWindowType } from "electron";
import path from "path";
import fs from "fs";
-import axios, { type AxiosProgressEvent } from "axios";
-import { pipeline } from "stream/promises";
+import { Worker } from "worker_threads";
+import { access, mkdir, readFile, realpath, unlink, writeFile } from "fs/promises";
import log from "electron-log";
import { z } from "zod";
+import { eq } from "drizzle-orm";
import { BaseController } from "../../core/ipc/BaseController";
+import { container, DI_TOKENS } from "../../core/di/Container";
+import { settings, SETTINGS_ID } from "../../db/schema";
import { IPC_CHANNELS } from "../channels";
-const DOWNLOAD_ROOT = path.join(app.getPath("downloads"), "BooruClient");
+const DEFAULT_DOWNLOAD_ROOT = path.join(app.getPath("downloads"), "BooruClient");
+const DOWNLOAD_QUEUE_FILE = "download-queue.json";
// Maximum filename length to prevent filesystem errors
// Most filesystems (Windows, Linux, macOS) limit filenames to 255 characters
@@ -32,6 +36,18 @@ const DownloadFileSchema = z.object({
const OpenFolderSchema = z.string().min(1);
+// Batch download limits (see docs/download-batch-risks.md)
+const BATCH_DOWNLOAD_CONCURRENCY = 3;
+const BATCH_DOWNLOAD_DELAY_MS = 500;
+const BATCH_DOWNLOAD_MAX_FILES = 500;
+
+const DownloadAllItemSchema = z.object({
+ url: DownloadFileSchema.shape.url,
+ filename: DownloadFileSchema.shape.filename,
+});
+
+const DownloadAllSchema = z.array(DownloadAllItemSchema).max(BATCH_DOWNLOAD_MAX_FILES);
+
/**
* File Controller
*
@@ -44,6 +60,7 @@ export class FileController extends BaseController {
private totalBytes = 0;
// Track active downloads to cancel them on window close
private activeDownloads = new Map();
+ private downloadWorker: Worker | null = null;
/**
* Set main window reference (needed for download dialogs and progress events)
@@ -59,6 +76,38 @@ export class FileController extends BaseController {
});
}
+ /**
+ * Cancel batch download (sends message to Worker Thread)
+ */
+ public cancelDownloadAll(): boolean {
+ if (this.downloadWorker) {
+ this.downloadWorker.postMessage({ type: "cancel" });
+ log.info("[FileController] Batch download cancel requested");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Pause batch download
+ */
+ public pauseDownloadAll(): void {
+ if (this.downloadWorker) {
+ this.downloadWorker.postMessage({ type: "pause" });
+ log.info("[FileController] Batch download paused");
+ }
+ }
+
+ /**
+ * Resume batch download
+ */
+ public resumeDownloadAll(): void {
+ if (this.downloadWorker) {
+ this.downloadWorker.postMessage({ type: "resume" });
+ log.info("[FileController] Batch download resumed");
+ }
+ }
+
/**
* Cancel all active downloads (called on window close)
*/
@@ -69,6 +118,190 @@ export class FileController extends BaseController {
log.debug(`[FileController] Canceled download: ${filename}`);
}
this.activeDownloads.clear();
+ if (this.downloadWorker) {
+ this.downloadWorker.postMessage({ type: "cancel" });
+ this.downloadWorker.terminate().catch(() => {});
+ this.downloadWorker = null;
+ }
+ }
+
+ private getQueueFilePath(): string {
+ return path.join(app.getPath("userData"), DOWNLOAD_QUEUE_FILE);
+ }
+
+ private async writeQueueFile(data: {
+ items: Array<{ url: string; filename: string }>;
+ doneCount: number;
+ total: number;
+ folder: string;
+ timestamp: number;
+ }): Promise {
+ try {
+ await writeFile(this.getQueueFilePath(), JSON.stringify(data), "utf-8");
+ } catch (e) {
+ log.warn("[FileController] Failed to write queue file:", e);
+ }
+ }
+
+ private async readQueueFile(): Promise<{
+ items: Array<{ url: string; filename: string }>;
+ doneCount: number;
+ total: number;
+ folder: string;
+ timestamp: number;
+ } | null> {
+ try {
+ const p = this.getQueueFilePath();
+ await access(p);
+ const raw = await readFile(p, "utf-8");
+ const data = JSON.parse(raw);
+ if (!Array.isArray(data.items) || typeof data.doneCount !== "number") return null;
+ return data;
+ } catch {
+ return null;
+ }
+ }
+
+ private async deleteQueueFile(): Promise {
+ try {
+ const p = this.getQueueFilePath();
+ await access(p);
+ await unlink(p);
+ this.notifyPendingDownloadStateChanged();
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
+ log.warn("[FileController] Failed to delete queue file:", e);
+ }
+ }
+ }
+
+ private notifyPendingDownloadStateChanged(): void {
+ const win = this.getMainWindow();
+ if (win && !win.isDestroyed()) {
+ win.webContents.send(IPC_CHANNELS.FILES.PENDING_DOWNLOAD_STATE_CHANGED);
+ }
+ }
+
+ private async getPendingDownload(): Promise<{
+ hasPending: boolean;
+ total: number;
+ done: number;
+ folder: string;
+ } | null> {
+ const data = await this.readQueueFile();
+ if (!data || data.doneCount >= data.items.length) return null;
+ const maxAgeMs = 7 * 24 * 60 * 60 * 1000;
+ if (Date.now() - data.timestamp > maxAgeMs) {
+ await this.deleteQueueFile();
+ return null;
+ }
+ return {
+ hasPending: true,
+ total: data.total,
+ done: data.doneCount,
+ folder: data.folder,
+ };
+ }
+
+ private async resumePendingDownload(
+ event: IpcMainInvokeEvent
+ ): Promise<{ success: boolean; error?: string }> {
+ const data = await this.readQueueFile();
+ if (!data || data.doneCount >= data.items.length) {
+ await this.deleteQueueFile();
+ return { success: false, error: "No pending download" };
+ }
+ const remaining = data.items.slice(data.doneCount);
+ await this.deleteQueueFile();
+ const result = await this.downloadAll(event, remaining);
+ return {
+ success: result.success,
+ error: result.error,
+ };
+ }
+
+ /**
+ * Get download settings (duplicate behavior, folder structure)
+ */
+ private getDownloadSettings(): {
+ duplicateFileBehavior: "skip" | "overwrite";
+ downloadFolderStructure: "flat" | "{artist_id}";
+ } {
+ try {
+ const db = container.resolve(DI_TOKENS.DB);
+ const row = db
+ .select({
+ duplicateFileBehavior: settings.duplicateFileBehavior,
+ downloadFolderStructure: settings.downloadFolderStructure,
+ })
+ .from(settings)
+ .where(eq(settings.id, SETTINGS_ID))
+ .limit(1)
+ .get();
+ return {
+ duplicateFileBehavior:
+ (row?.duplicateFileBehavior as "skip" | "overwrite") || "skip",
+ downloadFolderStructure:
+ (row?.downloadFolderStructure as "flat" | "{artist_id}") || "flat",
+ };
+ } catch (e) {
+ log.warn("[FileController] Failed to get download settings:", e);
+ return { duplicateFileBehavior: "skip", downloadFolderStructure: "flat" };
+ }
+ }
+
+ /**
+ * Build full file path from root, structure template, and filename.
+ * Filename format: artistId_postId.ext - we extract artistId for {artist_id} structure.
+ * Sanitizes path to prevent traversal outside downloadRoot.
+ */
+ private getFilePath(
+ root: string,
+ filename: string,
+ structure: "flat" | "{artist_id}"
+ ): string {
+ const resolvedRoot = path.resolve(root);
+ let fullPath: string;
+ if (structure === "flat") {
+ fullPath = path.resolve(root, filename);
+ } else {
+ const match = filename.match(/^(\d+)_/);
+ const artistId = match ? match[1] : "unknown";
+ fullPath = path.resolve(root, artistId, filename);
+ }
+ const relative = path.relative(resolvedRoot, fullPath);
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ log.error(`[FileController] Path traversal blocked: ${fullPath} outside ${resolvedRoot}`);
+ throw new Error("Path traversal attempted");
+ }
+ return fullPath;
+ }
+
+ /**
+ * Get download root folder from settings (or default)
+ */
+ private async getDownloadRoot(): Promise {
+ try {
+ const db = container.resolve(DI_TOKENS.DB);
+ const row = db
+ .select({ downloadFolder: settings.downloadFolder })
+ .from(settings)
+ .where(eq(settings.id, SETTINGS_ID))
+ .limit(1)
+ .get();
+ const folder = row?.downloadFolder?.trim();
+ if (folder) {
+ try {
+ await access(folder);
+ return folder;
+ } catch {
+ /* folder doesn't exist or inaccessible */
+ }
+ }
+ } catch (e) {
+ log.warn("[FileController] Failed to get download folder from settings:", e);
+ }
+ return DEFAULT_DOWNLOAD_ROOT;
}
/**
@@ -104,10 +337,205 @@ export class FileController extends BaseController {
// Type assertion is safe: BaseController validates args with Zod schema before calling handler
this.openFolder.bind(this) as (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise
);
+ this.handle(
+ IPC_CHANNELS.FILES.SELECT_DOWNLOAD_FOLDER,
+ z.tuple([]),
+ this.selectDownloadFolder.bind(this) as (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.DOWNLOAD_ALL,
+ z.tuple([DownloadAllSchema]),
+ this.downloadAll.bind(this) as (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.CANCEL_DOWNLOAD_ALL,
+ z.tuple([]),
+ () => Promise.resolve(this.cancelDownloadAll())
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.PAUSE_DOWNLOAD_ALL,
+ z.tuple([]),
+ () => {
+ this.pauseDownloadAll();
+ return Promise.resolve();
+ }
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.RESUME_DOWNLOAD_ALL,
+ z.tuple([]),
+ () => {
+ this.resumeDownloadAll();
+ return Promise.resolve();
+ }
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.GET_PENDING_DOWNLOAD,
+ z.tuple([]),
+ this.getPendingDownload.bind(this),
+ { isIdempotent: true }
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.RESUME_PENDING_DOWNLOAD,
+ z.tuple([]),
+ this.resumePendingDownload.bind(this) as (
+ event: IpcMainInvokeEvent,
+ ...args: unknown[]
+ ) => Promise
+ );
+ this.handle(
+ IPC_CHANNELS.FILES.DISMISS_PENDING_DOWNLOAD,
+ z.tuple([]),
+ async () => {
+ await this.deleteQueueFile();
+ this.notifyPendingDownloadStateChanged();
+ }
+ );
log.info("[FileController] All handlers registered");
}
+ /**
+ * Open folder picker for selecting default download directory
+ * @returns Selected folder path or null if canceled
+ */
+ private async selectDownloadFolder(
+ _event: IpcMainInvokeEvent
+ ): Promise {
+ const mainWindow = this.getMainWindow();
+ if (!mainWindow) return null;
+ const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
+ title: "Select Download Folder",
+ defaultPath: await this.getDownloadRoot(),
+ properties: ["openDirectory"],
+ });
+ if (canceled || !filePaths?.length) return null;
+ return filePaths[0] ?? null;
+ }
+
+ /**
+ * Download multiple files via Worker Thread.
+ * Heavy I/O (network, disk) runs off Main process to avoid blocking UI.
+ * Main only orchestrates: spawn Worker, forward progress, handle cancel/pause/resume.
+ */
+ private async downloadAll(
+ _event: IpcMainInvokeEvent,
+ items: Array<{ url: string; filename: string }>
+ ): Promise<{
+ success: boolean;
+ downloaded: number;
+ failed: number;
+ canceled: boolean;
+ error?: string;
+ }> {
+ const mainWindow = this.getMainWindow();
+ if (!mainWindow) {
+ return { success: false, downloaded: 0, failed: 0, canceled: false, error: "Main window not available" };
+ }
+
+ const validation = DownloadAllSchema.safeParse(items);
+ if (!validation.success) {
+ log.error("[FileController] DownloadAll validation failed", validation.error);
+ return {
+ success: false,
+ downloaded: 0,
+ failed: 0,
+ canceled: false,
+ error: `Invalid input. Max ${BATCH_DOWNLOAD_MAX_FILES} files allowed.`,
+ };
+ }
+
+ const validItems = validation.data;
+ if (validItems.length === 0) {
+ return { success: true, downloaded: 0, failed: 0, canceled: false };
+ }
+
+ const folder = await this.getDownloadRoot();
+ const { duplicateFileBehavior, downloadFolderStructure } = this.getDownloadSettings();
+ try {
+ await access(folder);
+ } catch {
+ try {
+ await mkdir(folder, { recursive: true });
+ } catch (e) {
+ log.error("[FileController] Failed to create download directory", e);
+ return {
+ success: false,
+ downloaded: 0,
+ failed: validItems.length,
+ canceled: false,
+ error: "Failed to create download directory",
+ };
+ }
+ }
+
+ const workerPath = path.join(__dirname, "workers", "downloadWorker.cjs");
+ return new Promise((resolve) => {
+ const fail = (error: string) =>
+ resolve({
+ success: false,
+ downloaded: 0,
+ failed: validItems.length,
+ canceled: false,
+ error,
+ });
+
+ try {
+ const worker = new Worker(workerPath, {
+ workerData: {
+ items: validItems,
+ folder,
+ duplicateFileBehavior,
+ downloadFolderStructure,
+ queueFilePath: this.getQueueFilePath(),
+ },
+ });
+ this.downloadWorker = worker;
+
+ worker.on("message", (msg: { type: string; id?: string; percent?: number; done?: number; total?: number; success?: boolean; downloaded?: number; failed?: number; canceled?: boolean; error?: string }) => {
+ if (msg.type === "progress" && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(IPC_CHANNELS.FILES.DOWNLOAD_ALL_PROGRESS, {
+ id: msg.id,
+ percent: msg.percent ?? 0,
+ done: msg.done ?? 0,
+ total: msg.total ?? validItems.length,
+ });
+ } else if (msg.type === "complete") {
+ this.downloadWorker = null;
+ if (msg.success && !msg.canceled) {
+ this.notifyPendingDownloadStateChanged();
+ }
+ resolve({
+ success: msg.success ?? false,
+ downloaded: msg.downloaded ?? 0,
+ failed: msg.failed ?? 0,
+ canceled: msg.canceled ?? false,
+ });
+ } else if (msg.type === "error") {
+ this.downloadWorker = null;
+ fail(msg.error ?? "Worker error");
+ }
+ });
+
+ worker.on("error", (err) => {
+ this.downloadWorker = null;
+ log.error("[FileController] Download worker error:", err);
+ fail(err.message);
+ });
+
+ worker.on("exit", (code) => {
+ if (code !== 0 && this.downloadWorker) {
+ this.downloadWorker = null;
+ fail(`Worker exited with code ${code}`);
+ }
+ });
+ } catch (err) {
+ this.downloadWorker = null;
+ log.error("[FileController] Failed to spawn download worker:", err);
+ fail(err instanceof Error ? err.message : String(err));
+ }
+ });
+ }
+
/**
* Download file with "Save As" dialog and progress tracking
*
@@ -138,12 +566,14 @@ export class FileController extends BaseController {
const { url: validUrl, filename: validFilename } = validation.data;
try {
- const defaultDir = DOWNLOAD_ROOT;
+ const defaultDir = await this.getDownloadRoot();
// Safely create directory
- if (!fs.existsSync(defaultDir)) {
+ try {
+ await access(defaultDir);
+ } catch {
try {
- fs.mkdirSync(defaultDir, { recursive: true });
+ await mkdir(defaultDir, { recursive: true });
} catch (e) {
log.error("[FileController] Failed to create download directory", e);
// Don't fail, dialog will just open in OS default folder
@@ -249,11 +679,12 @@ export class FileController extends BaseController {
log.info(`[FileController] Download canceled: ${validFilename}`);
// Clean up partial file if it exists
try {
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- }
+ await access(filePath);
+ await unlink(filePath);
} catch (unlinkError) {
- log.warn("[FileController] Failed to clean up partial file:", unlinkError);
+ if ((unlinkError as NodeJS.ErrnoException).code !== "ENOENT") {
+ log.warn("[FileController] Failed to clean up partial file:", unlinkError);
+ }
}
return { success: false, canceled: true };
}
@@ -291,61 +722,67 @@ export class FileController extends BaseController {
filePathOrName: string
): Promise {
try {
+ const downloadRoot = await this.getDownloadRoot();
let fullPath = filePathOrName;
if (!path.isAbsolute(filePathOrName)) {
- fullPath = path.join(DOWNLOAD_ROOT, filePathOrName);
+ fullPath = path.join(downloadRoot, filePathOrName);
}
const normalizedPath = path.normalize(fullPath);
// Security check: ensure path is within safe directory (before resolving symlinks)
- if (!normalizedPath.startsWith(DOWNLOAD_ROOT)) {
+ if (!normalizedPath.startsWith(downloadRoot)) {
log.error(
`[FileController] SECURITY VIOLATION: Attempt to open path outside safe directory: ${normalizedPath}`
);
- shell.openPath(DOWNLOAD_ROOT);
+ shell.openPath(downloadRoot);
return false;
}
// Critical security: resolve symlinks to get real path on disk
// This prevents path traversal via symbolic links
- let realPath: string;
+ let resolvedPath: string;
try {
- // Use realpathSync to resolve all symlinks and get canonical path
- realPath = fs.realpathSync(normalizedPath);
- } catch (error) {
- // Path doesn't exist or is inaccessible, fallback to DOWNLOAD_ROOT
+ resolvedPath = await realpath(normalizedPath);
+ } catch (error: unknown) {
+ // Path doesn't exist or is inaccessible, fallback to download root
log.warn(`[FileController] Failed to resolve real path: ${normalizedPath}`, error);
- if (fs.existsSync(DOWNLOAD_ROOT)) {
- await shell.openPath(DOWNLOAD_ROOT);
+ try {
+ await access(downloadRoot);
+ await shell.openPath(downloadRoot);
return true;
+ } catch {
+ return false;
}
- return false;
}
// Security check: ensure real path (after symlink resolution) is still within safe directory
- const normalizedRealPath = path.normalize(realPath);
- if (!normalizedRealPath.startsWith(DOWNLOAD_ROOT)) {
+ const normalizedRealPath = path.normalize(resolvedPath);
+ if (!normalizedRealPath.startsWith(downloadRoot)) {
log.error(
`[FileController] SECURITY VIOLATION: Real path outside safe directory: ${normalizedRealPath} (original: ${normalizedPath})`
);
- shell.openPath(DOWNLOAD_ROOT);
+ shell.openPath(downloadRoot);
return false;
}
- if (fs.existsSync(realPath)) {
- shell.showItemInFolder(realPath);
+ try {
+ await access(resolvedPath);
+ shell.showItemInFolder(resolvedPath);
return true;
+ } catch {
+ /* path doesn't exist */
}
- if (fs.existsSync(DOWNLOAD_ROOT)) {
- await shell.openPath(DOWNLOAD_ROOT);
+ try {
+ await access(downloadRoot);
+ await shell.openPath(downloadRoot);
return true;
+ } catch {
+ return false;
}
-
- return false;
- } catch (error) {
+ } catch (error: unknown) {
log.error("[FileController] Failed to open folder:", error);
return false;
}
diff --git a/src/main/ipc/controllers/PlaylistController.ts b/src/main/ipc/controllers/PlaylistController.ts
index 0dddd6e..4e64c3b 100644
--- a/src/main/ipc/controllers/PlaylistController.ts
+++ b/src/main/ipc/controllers/PlaylistController.ts
@@ -237,7 +237,7 @@ export class PlaylistController extends BaseController {
this.handle(
IPC_CHANNELS.DB.GET_PLAYLISTS_CONTAINING_POST,
- z.tuple([z.number().int().positive()]),
+ z.tuple([z.number().int(), z.number().int().positive().optional()]),
this.getPlaylistsContainingPost.bind(this) as (
event: IpcMainInvokeEvent,
...args: unknown[]
@@ -1079,19 +1079,33 @@ export class PlaylistController extends BaseController {
*/
private async getPlaylistsContainingPost(
_event: IpcMainInvokeEvent,
- postId: number
+ postId: number,
+ rule34PostId?: number
): Promise {
try {
const db = this.getDb();
- // Use Drizzle Query API for cleaner code and automatic type inference
- // Efficient single query: SELECT playlist_id FROM playlist_entries WHERE post_id = ?
- const result = await db.query.playlistEntries.findMany({
- where: (playlistEntries, { eq }) => eq(playlistEntries.postId, postId),
- columns: {
- playlistId: true,
- },
- });
+ let result: { playlistId: number }[];
+ if (postId <= 0 && rule34PostId != null && rule34PostId > 0) {
+ // External post from Browse: look up by posts.postId and artistId=EXTERNAL_ARTIST_ID
+ const rows = db
+ .select({ playlistId: playlistEntries.playlistId })
+ .from(playlistEntries)
+ .innerJoin(posts, eq(playlistEntries.postId, posts.id))
+ .where(
+ and(
+ eq(posts.postId, rule34PostId),
+ eq(posts.artistId, EXTERNAL_ARTIST_ID)
+ )
+ )
+ .all();
+ result = rows;
+ } else {
+ result = await db.query.playlistEntries.findMany({
+ where: (entries, { eq }) => eq(entries.postId, postId),
+ columns: { playlistId: true },
+ });
+ }
const playlistIds = result.map((r) => r.playlistId);
diff --git a/src/main/ipc/controllers/PostsController.ts b/src/main/ipc/controllers/PostsController.ts
index aa127ca..e9c36ee 100644
--- a/src/main/ipc/controllers/PostsController.ts
+++ b/src/main/ipc/controllers/PostsController.ts
@@ -135,6 +135,18 @@ export class PostsController extends BaseController {
...args: unknown[]
) => Promise
);
+ this.handle(
+ IPC_CHANNELS.DB.GET_DOWNLOAD_ITEMS,
+ z.tuple([
+ GetPostsSchema.extend({
+ limit: z.number().int().min(1).max(500).default(500),
+ }),
+ ]),
+ this.getDownloadItems.bind(this) as (
+ event: IpcMainInvokeEvent,
+ ...args: unknown[]
+ ) => Promise
+ );
this.handle(
IPC_CHANNELS.DB.GET_POSTS_COUNT,
z.tuple([z.number().int().positive().optional()]),
@@ -144,6 +156,15 @@ export class PostsController extends BaseController {
...args: unknown[]
) => Promise
);
+ this.handle(
+ IPC_CHANNELS.DB.GET_POSTS_COUNT_WITH_FILTERS,
+ z.tuple([GetPostsSchema.pick({ artistId: true, filters: true })]),
+ this.getPostsCountWithFilters.bind(this) as (
+ event: IpcMainInvokeEvent,
+ ...args: unknown[]
+ ) => Promise,
+ { isIdempotent: true }
+ );
this.handle(
IPC_CHANNELS.DB.MARK_VIEWED,
z.tuple([
@@ -559,6 +580,33 @@ export class PostsController extends BaseController {
return conditions;
}
+ /**
+ * Get download items for batch download (all posts matching filters, up to 500)
+ * Returns { items } for use with Download All. Total for display comes from getArtistPostsCount.
+ */
+ private async getDownloadItems(
+ _event: IpcMainInvokeEvent,
+ params: GetPostsParams & { limit?: number }
+ ): Promise<{ items: Array<{ url: string; filename: string }> }> {
+ const posts = await this.getPosts(_event, {
+ ...params,
+ page: 1,
+ limit: Math.min(params.limit ?? 500, 500),
+ });
+ const items = posts
+ .filter((p) => p.fileUrl?.trim())
+ .map((p) => {
+ const pathMatch = (p.fileUrl || "").match(/^[^?#]+/);
+ const pathname = pathMatch ? pathMatch[0] : p.fileUrl || "";
+ const ext = pathname.split(".").pop()?.toLowerCase() || "jpg";
+ return {
+ url: p.fileUrl!,
+ filename: `${p.artistId}_${p.postId}.${ext}`,
+ };
+ });
+ return { items };
+ }
+
/**
* Get posts for an artist (or globally) with pagination and filters
*
@@ -729,6 +777,60 @@ export class PostsController extends BaseController {
}
}
+ /**
+ * Get posts count with filters (for Updates, Favorites - matches getPosts logic)
+ */
+ private async getPostsCountWithFilters(
+ _event: IpcMainInvokeEvent,
+ params: Pick
+ ): Promise {
+ try {
+ const db = this.getDb();
+ const { artistId, filters } = params;
+
+ if (filters?.sinceTracking === true) {
+ const baseConditions = this.buildPostFilterConditions(artistId, filters);
+ const whereClause =
+ baseConditions.length > 0 ? and(...baseConditions) : undefined;
+ const joinConditions = and(
+ eq(posts.artistId, artists.id),
+ gte(posts.publishedAt, artists.createdAt),
+ not(eq(posts.artistId, EXTERNAL_ARTIST_ID)),
+ notLike(artists.tag, `${EXTERNAL_ARTIST_TAG_PREFIX}%`)
+ );
+ const finalWhereClause = whereClause
+ ? and(whereClause, not(eq(posts.artistId, EXTERNAL_ARTIST_ID)))
+ : not(eq(posts.artistId, EXTERNAL_ARTIST_ID));
+
+ const result = await db
+ .select({ value: count() })
+ .from(posts)
+ .innerJoin(artists, joinConditions)
+ .where(finalWhereClause);
+
+ const total = result[0]?.value ?? 0;
+ log.debug(`[PostsController] Posts count with filters: ${total}`);
+ return total;
+ }
+
+ const baseConditions = this.buildPostFilterConditions(artistId, filters);
+ const whereClause =
+ baseConditions.length > 0 ? and(...baseConditions) : undefined;
+
+ const result = await db
+ .select({ value: count() })
+ .from(posts)
+ .where(whereClause);
+
+ const total = result[0]?.value ?? 0;
+ log.debug(`[PostsController] Posts count with filters: ${total}`);
+ return total;
+ } catch (error) {
+ log.error("[PostsController] Failed to get posts count with filters:", error);
+ return 0;
+ }
+ }
+
/**
* Mark post as viewed
*
diff --git a/src/main/ipc/controllers/SettingsController.ts b/src/main/ipc/controllers/SettingsController.ts
index 6b04870..5e41093 100644
--- a/src/main/ipc/controllers/SettingsController.ts
+++ b/src/main/ipc/controllers/SettingsController.ts
@@ -28,6 +28,9 @@ const DEFAULT_IPC_SETTINGS: IpcSettings = {
isAdultConfirmed: false,
isAdultVerified: false,
tosAcceptedAt: null,
+ downloadFolder: null,
+ duplicateFileBehavior: "skip",
+ downloadFolderStructure: "flat",
};
/**
@@ -73,6 +76,11 @@ function mapSettingsToIpc(
}
return null;
})(),
+ downloadFolder: dbSettings.downloadFolder ?? null,
+ duplicateFileBehavior:
+ (dbSettings.duplicateFileBehavior as "skip" | "overwrite") || "skip",
+ downloadFolderStructure:
+ (dbSettings.downloadFolderStructure as "flat" | "{artist_id}") || "flat",
};
}
@@ -127,6 +135,29 @@ export class SettingsController extends BaseController {
z.tuple([]),
this.confirmLegal.bind(this)
);
+ // settings:save-download-folder - saves custom download folder path
+ this.handle(
+ IPC_CHANNELS.SETTINGS.SAVE_DOWNLOAD_FOLDER,
+ z.tuple([z.string().max(4096).nullable()]),
+ this.saveDownloadFolder.bind(this) as (
+ event: IpcMainInvokeEvent,
+ ...args: unknown[]
+ ) => Promise
+ );
+ // settings:save-download-settings - saves duplicate/folder structure
+ this.handle(
+ IPC_CHANNELS.SETTINGS.SAVE_DOWNLOAD_SETTINGS,
+ z.tuple([
+ z.object({
+ duplicateFileBehavior: z.enum(["skip", "overwrite"]).optional(),
+ downloadFolderStructure: z.enum(["flat", "{artist_id}"]).optional(),
+ }),
+ ]),
+ this.saveDownloadSettings.bind(this) as (
+ event: IpcMainInvokeEvent,
+ ...args: unknown[]
+ ) => Promise
+ );
log.info("[SettingsController] All handlers registered");
}
@@ -310,6 +341,71 @@ export class SettingsController extends BaseController {
}
}
+ /**
+ * Save download folder path (null = use default)
+ */
+ private async saveDownloadFolder(
+ _event: IpcMainInvokeEvent,
+ folderPath: string | null
+ ): Promise {
+ try {
+ const db = this.getDb();
+ const existing = await db.query.settings.findFirst({
+ where: eq(settings.id, SETTINGS_ID),
+ });
+ if (!existing) {
+ log.warn("[SettingsController] No settings record for download folder, skipping");
+ return false;
+ }
+ await db
+ .update(settings)
+ .set({ downloadFolder: folderPath || null })
+ .where(eq(settings.id, SETTINGS_ID))
+ .run();
+ this.settingsCache = null;
+ log.debug(`[SettingsController] Download folder saved: ${folderPath ?? "default"}`);
+ return true;
+ } catch (error) {
+ log.error("[SettingsController] Failed to save download folder:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Save download behavior settings (duplicate handling, folder structure)
+ */
+ private async saveDownloadSettings(
+ _event: IpcMainInvokeEvent,
+ data: { duplicateFileBehavior?: "skip" | "overwrite"; downloadFolderStructure?: "flat" | "{artist_id}" }
+ ): Promise {
+ try {
+ const db = this.getDb();
+ const existing = await db.query.settings.findFirst({
+ where: eq(settings.id, SETTINGS_ID),
+ });
+ if (!existing) {
+ log.warn("[SettingsController] No settings record for download settings, skipping");
+ return false;
+ }
+ const updates: Partial> = {};
+ if (data.duplicateFileBehavior !== undefined) {
+ updates.duplicateFileBehavior = data.duplicateFileBehavior;
+ }
+ if (data.downloadFolderStructure !== undefined) {
+ updates.downloadFolderStructure = data.downloadFolderStructure;
+ }
+ if (Object.keys(updates).length > 0) {
+ await db.update(settings).set(updates).where(eq(settings.id, SETTINGS_ID)).run();
+ this.settingsCache = null;
+ log.debug(`[SettingsController] Download settings saved:`, updates);
+ }
+ return true;
+ } catch (error) {
+ log.error("[SettingsController] Failed to save download settings:", error);
+ throw error;
+ }
+ }
+
/**
* Confirm legal (Age Gate & ToS acceptance)
*
diff --git a/src/main/main.ts b/src/main/main.ts
index c05cf51..e8d5ce2 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -671,6 +671,9 @@ function createTray(_window: BrowserWindow): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
mainWindow.focus();
+ } else {
+ // Window was closed - recreate it
+ initializeAppAndWindow();
}
},
},
@@ -712,14 +715,9 @@ function createTray(_window: BrowserWindow): void {
return;
}
- // Check if window exists and is not destroyed
- if (!mainWindow) {
- logger.warn("[Tray] Main window is null");
- return;
- }
-
- if (mainWindow.isDestroyed()) {
- logger.warn("[Tray] Main window is destroyed");
+ // Window was closed - recreate it
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ initializeAppAndWindow();
return;
}
@@ -741,13 +739,8 @@ function createTray(_window: BrowserWindow): void {
if (process.platform !== "darwin") {
tray.on("double-click", () => {
try {
- if (!mainWindow) {
- logger.warn("[Tray] Main window is null");
- return;
- }
-
- if (mainWindow.isDestroyed()) {
- logger.warn("[Tray] Main window is destroyed");
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ initializeAppAndWindow();
return;
}
diff --git a/src/main/workers/downloadWorker.ts b/src/main/workers/downloadWorker.ts
new file mode 100644
index 0000000..9fe3939
--- /dev/null
+++ b/src/main/workers/downloadWorker.ts
@@ -0,0 +1,258 @@
+/**
+ * Download Worker Thread
+ *
+ * Runs batch downloads off the Main process to avoid blocking the UI.
+ * Receives config via workerData, listens for cancel/pause/resume via parentPort,
+ * sends progress back to Main via parentPort.postMessage.
+ */
+import { parentPort, workerData } from "worker_threads";
+import path from "path";
+import fs from "fs";
+import { access, mkdir, readFile, unlink, writeFile } from "fs/promises";
+import axios, { type AxiosProgressEvent } from "axios";
+import { pipeline } from "stream/promises";
+
+const BATCH_DOWNLOAD_CONCURRENCY = 3;
+const BATCH_DOWNLOAD_DELAY_MS = 500;
+
+interface WorkerData {
+ items: Array<{ url: string; filename: string }>;
+ folder: string;
+ duplicateFileBehavior: "skip" | "overwrite";
+ downloadFolderStructure: "flat" | "{artist_id}";
+ queueFilePath: string;
+}
+
+interface WorkerMessage {
+ type: "progress" | "complete" | "error";
+ id?: string;
+ percent?: number;
+ done?: number;
+ total?: number;
+ success?: boolean;
+ downloaded?: number;
+ failed?: number;
+ canceled?: boolean;
+ error?: string;
+}
+
+function getFilePath(
+ root: string,
+ filename: string,
+ structure: "flat" | "{artist_id}"
+): string {
+ const resolvedRoot = path.resolve(root);
+ let fullPath: string;
+ if (structure === "flat") {
+ fullPath = path.resolve(root, filename);
+ } else {
+ const match = filename.match(/^(\d+)_/);
+ const artistId = match ? match[1] : "unknown";
+ fullPath = path.resolve(root, artistId, filename);
+ }
+ const relative = path.relative(resolvedRoot, fullPath);
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ throw new Error("Path traversal attempted");
+ }
+ return fullPath;
+}
+
+async function runWorker(): Promise {
+ const {
+ items,
+ folder,
+ duplicateFileBehavior,
+ downloadFolderStructure,
+ queueFilePath,
+ } = workerData as WorkerData;
+
+ let aborted = false;
+ let paused = false;
+
+ parentPort?.on("message", (msg: { type: string }) => {
+ if (msg.type === "cancel") aborted = true;
+ if (msg.type === "pause") paused = true;
+ if (msg.type === "resume") paused = false;
+ });
+
+ const post = (m: WorkerMessage) => parentPort?.postMessage(m);
+
+ const writeQueueFile = async (data: {
+ items: Array<{ url: string; filename: string }>;
+ doneCount: number;
+ total: number;
+ folder: string;
+ timestamp: number;
+ }) => {
+ try {
+ await writeFile(queueFilePath, JSON.stringify(data), "utf-8");
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const deleteQueueFile = async () => {
+ try {
+ await access(queueFilePath);
+ await unlink(queueFilePath);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ let downloaded = 0;
+ let failed = 0;
+
+ const updateQueueProgress = async () => {
+ await writeQueueFile({
+ items,
+ doneCount: downloaded,
+ total: items.length,
+ folder,
+ timestamp: Date.now(),
+ });
+ };
+
+ const runOne = async (item: { url: string; filename: string }): Promise => {
+ if (aborted) return;
+ while (paused && !aborted) {
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ if (aborted) return;
+
+ const filePath = getFilePath(folder, item.filename, downloadFolderStructure);
+ const dir = path.dirname(filePath);
+ try {
+ await access(dir);
+ } catch {
+ try {
+ await mkdir(dir, { recursive: true });
+ } catch {
+ failed++;
+ return;
+ }
+ }
+
+ let fileExists = false;
+ try {
+ await access(filePath);
+ fileExists = true;
+ } catch {
+ /* file doesn't exist */
+ }
+ if (fileExists && duplicateFileBehavior === "skip") {
+ downloaded++;
+ await updateQueueProgress();
+ post({
+ type: "progress",
+ id: item.filename,
+ percent: 100,
+ done: downloaded,
+ total: items.length,
+ });
+ return;
+ }
+
+ const abortController = new AbortController();
+ try {
+ const response = await axios({
+ method: "GET",
+ url: item.url,
+ responseType: "stream",
+ signal: abortController.signal,
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ },
+ onDownloadProgress: (ev: AxiosProgressEvent) => {
+ if (aborted) {
+ abortController.abort();
+ return;
+ }
+ if (ev.total) {
+ const pct = Math.round((ev.loaded * 100) / ev.total);
+ post({
+ type: "progress",
+ id: item.filename,
+ percent: pct,
+ done: downloaded + (pct >= 100 ? 1 : 0),
+ total: items.length,
+ });
+ }
+ },
+ });
+ const writer = fs.createWriteStream(filePath);
+ await pipeline(response.data, writer, {
+ signal: abortController.signal,
+ });
+ downloaded++;
+ await updateQueueProgress();
+ post({
+ type: "progress",
+ id: item.filename,
+ percent: 100,
+ done: downloaded,
+ total: items.length,
+ });
+ } catch (err) {
+ if (aborted) return;
+ failed++;
+ const isAborted =
+ (err instanceof Error && err.name === "AbortError") ||
+ (axios.isAxiosError(err) && err.code === "ERR_CANCELED");
+ if (isAborted) return;
+ try {
+ await access(filePath);
+ await unlink(filePath);
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+ await writeQueueFile({
+ items,
+ doneCount: 0,
+ total: items.length,
+ folder,
+ timestamp: Date.now(),
+ });
+
+ const queue = [...items];
+ const workers: Promise[] = [];
+ for (let i = 0; i < BATCH_DOWNLOAD_CONCURRENCY; i++) {
+ workers.push(
+ (async () => {
+ while (queue.length > 0 && !aborted) {
+ const item = queue.shift();
+ if (!item) break;
+ await runOne(item);
+ await delay(BATCH_DOWNLOAD_DELAY_MS);
+ }
+ })()
+ );
+ }
+ await Promise.all(workers);
+
+ const canceled = aborted;
+ if (!canceled && failed === 0) {
+ await deleteQueueFile();
+ }
+
+ post({
+ type: "complete",
+ success: failed === 0 && !canceled,
+ downloaded,
+ failed,
+ canceled,
+ });
+}
+
+runWorker().catch((err: unknown) => {
+ parentPort?.postMessage({
+ type: "error",
+ error: err instanceof Error ? err.message : String(err),
+ });
+});
diff --git a/src/renderer.d.ts b/src/renderer.d.ts
index 179020e..6da36eb 100644
--- a/src/renderer.d.ts
+++ b/src/renderer.d.ts
@@ -42,6 +42,9 @@ export interface IpcSettings {
isAdultConfirmed: boolean;
isAdultVerified: boolean;
tosAcceptedAt: number | null; // Timestamp in milliseconds (Date.getTime())
+ downloadFolder: string | null; // Custom folder for downloads
+ duplicateFileBehavior: "skip" | "overwrite";
+ downloadFolderStructure: "flat" | "{artist_id}";
}
export interface IpcApi extends IpcBridge {
@@ -51,6 +54,7 @@ export interface IpcApi extends IpcBridge {
// Settings
getSettings: () => Promise;
saveSettings: (creds: { userId: string; apiKey: string }) => Promise;
+ saveDownloadFolder: (path: string | null) => Promise;
confirmLegal: () => Promise;
logout: () => Promise;
openExternal: (url: string) => Promise;
diff --git a/src/renderer/components/downloads/DownloadAllButton.tsx b/src/renderer/components/downloads/DownloadAllButton.tsx
new file mode 100644
index 0000000..8dd2da1
--- /dev/null
+++ b/src/renderer/components/downloads/DownloadAllButton.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import { Button } from "../ui/button";
+import { Download, Loader2, Square, Pause, Play } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useDownloadStore } from "../../store/downloadStore";
+
+export interface DownloadAllButtonProps {
+ onClick: () => void;
+ onCancel?: () => void;
+ onPause?: () => void;
+ onResume?: () => void;
+ isDownloading: boolean;
+ isPaused?: boolean;
+ progress: { done: number; total: number };
+ canDownload: boolean;
+ totalLabel: string | number;
+ size?: "default" | "sm";
+ className?: string;
+}
+
+export const DownloadAllButton: React.FC = ({
+ onClick,
+ onCancel,
+ onPause,
+ onResume,
+ isDownloading,
+ isPaused = false,
+ progress,
+ canDownload,
+ totalLabel,
+ size = "sm",
+ className,
+}) => {
+ const isAnyDownloadActive = useDownloadStore((s) => s.isDownloading);
+ const pct = progress.total > 0 ? Math.round((progress.done * 100) / progress.total) : 0;
+ const disabled = !canDownload || (isAnyDownloadActive && !isDownloading);
+
+ return (
+
+
+ {!isDownloading ? (
+
+ ) : (
+ <>
+
+
+
+ {onPause && onResume && (
+
+ )}
+ {onCancel && (
+
+ )}
+ >
+ )}
+
+ {isDownloading && progress.total > 0 && (
+
+ )}
+
+ );
+};
diff --git a/src/renderer/components/downloads/PendingDownloadBanner.tsx b/src/renderer/components/downloads/PendingDownloadBanner.tsx
new file mode 100644
index 0000000..b5c03ff
--- /dev/null
+++ b/src/renderer/components/downloads/PendingDownloadBanner.tsx
@@ -0,0 +1,89 @@
+import React, { useEffect, useState } from "react";
+import { Button } from "../ui/button";
+import { Download, X } from "lucide-react";
+import { useDownloadStore } from "../../store/downloadStore";
+
+const checkPending = async () => {
+ try {
+ const p = await window.api.getPendingDownload();
+ if (p?.hasPending) {
+ return { total: p.total, done: p.done, folder: p.folder };
+ }
+ } catch {
+ /* ignore */
+ }
+ return null;
+};
+
+export const PendingDownloadBanner: React.FC = () => {
+ const isDownloading = useDownloadStore((s) => s.isDownloading);
+ const setDownloading = useDownloadStore((s) => s.setDownloading);
+ const [pending, setPending] = useState<{
+ total: number;
+ done: number;
+ folder: string;
+ } | null>(null);
+
+ useEffect(() => {
+ void checkPending().then(setPending);
+ const unsub = window.api.onPendingDownloadStateChanged(() => {
+ void checkPending().then(setPending);
+ });
+ return unsub;
+ }, []);
+
+ const handleResume = async () => {
+ setDownloading(true);
+ setPending(null);
+ try {
+ const result = await window.api.resumePendingDownload();
+ if (result.success) {
+ let unsub: () => void;
+ const timeout = setTimeout(() => {
+ setDownloading(false);
+ unsub?.();
+ }, 600_000);
+ unsub = window.api.onDownloadAllProgress((data) => {
+ if (data.total > 0 && data.done >= data.total) {
+ clearTimeout(timeout);
+ setDownloading(false);
+ unsub();
+ }
+ });
+ } else {
+ setDownloading(false);
+ }
+ } catch {
+ setDownloading(false);
+ } finally {
+ setDownloading(false);
+ }
+ };
+
+ const handleDismiss = async () => {
+ await window.api.dismissPendingDownload();
+ setPending(null);
+ };
+
+ if (!pending || isDownloading) return null;
+
+ const remaining = pending.total - pending.done;
+ return (
+
+
+
+
+ Interrupted download: {pending.done}/{pending.total} done. {remaining} remaining.
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/renderer/components/layout/AppLayout.tsx b/src/renderer/components/layout/AppLayout.tsx
index cfb6945..1ff776a 100644
--- a/src/renderer/components/layout/AppLayout.tsx
+++ b/src/renderer/components/layout/AppLayout.tsx
@@ -3,6 +3,7 @@ import { Sidebar } from "./Sidebar";
import { GlobalTopBar } from "./GlobalTopBar";
import { PanicButton } from "./PanicButton";
import { ViewerDialog } from "@/features/viewer/ViewerDialog";
+import { PendingDownloadBanner } from "../downloads/PendingDownloadBanner";
export const AppLayout = () => {
return (
@@ -12,6 +13,7 @@ export const AppLayout = () => {
{/* Main Content Area */}
+
{/* Scrollable Content */}
diff --git a/src/renderer/components/pages/Browse.tsx b/src/renderer/components/pages/Browse.tsx
index 7736870..84757dc 100644
--- a/src/renderer/components/pages/Browse.tsx
+++ b/src/renderer/components/pages/Browse.tsx
@@ -10,6 +10,8 @@ import { PostCard } from "../../features/artists/components/PostCard";
import { Button } from "../ui/button";
import { ExternalLink } from "lucide-react";
import { useGalleryInfiniteScroll } from "../../hooks/useGalleryInfiniteScroll";
+import { useDownloadAll } from "../../hooks/useDownloadAll";
+import { DownloadAllButton } from "../downloads/DownloadAllButton";
import { useWorkerFilteredPosts } from "../../hooks/useWorkerFilteredPosts";
import type { WorkerFilterConfig } from "../../hooks/useWorkerProcessor";
@@ -177,7 +179,7 @@ export const Browse = () => {
tags,
}), [aiFilter, mediaType, source, sortOrder, trackedTagsArray, tags]);
- const { data: allPosts, isLoading: workerLoading } = useWorkerFilteredPosts(
+ const { data: allPosts = [], isLoading: workerLoading } = useWorkerFilteredPosts(
rawPosts,
filterConfig,
250 // Debounce delay
@@ -229,6 +231,17 @@ export const Browse = () => {
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rawPosts, appendQueueIds]);
+ const {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading: isDownloadingAll,
+ isPaused,
+ progress: downloadAllProgress,
+ canDownload,
+ } = useDownloadAll(allPosts);
+
const handlePostClick = (index: number) => {
const postIds = allPosts.map((p) => p.id);
const post = allPosts[index];
@@ -254,11 +267,22 @@ export const Browse = () => {
{/* Header */}
-
+
Browse
+ 0}
+ totalLabel={allPosts.length}
+ />
diff --git a/src/renderer/components/pages/Favorites.tsx b/src/renderer/components/pages/Favorites.tsx
index b9fa5e4..3d1bfa9 100644
--- a/src/renderer/components/pages/Favorites.tsx
+++ b/src/renderer/components/pages/Favorites.tsx
@@ -9,6 +9,7 @@ import {
import { Heart, Loader2 } from "lucide-react";
import { VirtuosoGrid } from "react-virtuoso";
import log from "electron-log/renderer";
+import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils";
import { hasAiGeneratedTag, isVideoPost } from "../../lib/filter-utils";
import { useViewerStore } from "../../store/viewerStore";
@@ -16,6 +17,8 @@ import { useSearchStore } from "../../store/searchStore";
import { PostCard } from "../../features/artists/components/PostCard";
import type { Post } from "../../../main/db/schema";
import { EXTERNAL_ARTIST_ID } from "../../../shared/constants";
+import { useDownloadAllWithFilters } from "../../hooks/useDownloadAll";
+import { DownloadAllButton } from "../downloads/DownloadAllButton";
// Helper function to parse tags from query string
const parseTags = (query: string): string[] => {
@@ -182,6 +185,29 @@ export const Favorites = () => {
});
}, [data, sortOrder, aiFilter, mediaType, source, trackedArtists]);
+ const fetchParams = useMemo(
+ () => ({
+ filters: {
+ isFavorited: true,
+ tags: tags.length > 0 ? tags.join(" ") : undefined,
+ aiFilter: aiFilter === "all" ? undefined : aiFilter,
+ mediaType: mediaType === "all" ? undefined : mediaType,
+ },
+ }),
+ [tags, aiFilter, mediaType]
+ );
+ const {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading: isDownloadingAll,
+ isPaused,
+ progress: downloadAllProgress,
+ canDownload,
+ totalCount: downloadTotalCount,
+ } = useDownloadAllWithFilters(fetchParams);
+
// Create stable List and Item components with forwardRef and aria-busy
// Must be memoized to prevent Virtuoso from remounting on every render
const { ListComponent, ItemComponent } = useMemo(() => {
@@ -294,13 +320,25 @@ export const Favorites = () => {
{allPosts.length > 0 && (
- {allPosts.length} {allPosts.length === 1 ? "post" : "posts"}
- {hasNextPage && " +"}
+ {downloadTotalCount || allPosts.length}{" "}
+ {(downloadTotalCount || allPosts.length) === 1 ? "post" : "posts"}
+ {!downloadTotalCount && hasNextPage ? " +" : ""}
)}
+
0}
+ totalLabel={downloadTotalCount || allPosts.length}
+ />
{/* Grid Content */}
diff --git a/src/renderer/components/pages/PlaylistsPage.tsx b/src/renderer/components/pages/PlaylistsPage.tsx
index 6fdf7d4..e1226b8 100644
--- a/src/renderer/components/pages/PlaylistsPage.tsx
+++ b/src/renderer/components/pages/PlaylistsPage.tsx
@@ -22,6 +22,8 @@ import { AsyncAutocomplete } from "../../components/inputs/AsyncAutocomplete";
import type { SmartPlaylistQuery, SmartPlaylistTag } from "../../../shared/schemas/playlist";
import { parsePlaylistQuery } from "../../../shared/schemas/playlist";
import { usePlaylists } from "../../lib/hooks/usePlaylists";
+import { useDownloadAll } from "../../hooks/useDownloadAll";
+import { DownloadAllButton } from "../downloads/DownloadAllButton";
import type { SearchResults } from "../../../main/providers";
interface PlaylistsPageProps {
@@ -138,6 +140,17 @@ const PlaylistGallery: React.FC = ({ playlist, onBack }) =
return data?.pages.flat() ?? [];
}, [data]);
+ const {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading: isDownloadingAll,
+ isPaused,
+ progress: downloadAllProgress,
+ canDownload,
+ } = useDownloadAll(allPosts);
+
const handleLoadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
@@ -190,9 +203,11 @@ const PlaylistGallery: React.FC = ({ playlist, onBack }) =
postIds: [postId],
});
- // Invalidate queries to refresh the list
+ // Invalidate playlist posts list
queryClient.invalidateQueries({ queryKey: ["playlist-posts", playlist.id] });
+ // Invalidate playlist-entries so PostCard/QuickAddToPlaylistMenu on other tabs show correct status
queryClient.invalidateQueries({ queryKey: ["playlist-entries"] });
+ queryClient.invalidateQueries({ queryKey: ["playlist-entries", postId] });
} catch (error) {
log.error("[PlaylistGallery] Failed to remove post from playlist:", error);
}
@@ -222,9 +237,27 @@ const PlaylistGallery: React.FC = ({ playlist, onBack }) =
Smart Collection
)}
+ {allPosts.length > 0 && (
+
+ Total: {allPosts.length}
+
+ )}
+
+
+
{/* Grid Content */}
diff --git a/src/renderer/components/pages/Updates.tsx b/src/renderer/components/pages/Updates.tsx
index a60bd4f..9591869 100644
--- a/src/renderer/components/pages/Updates.tsx
+++ b/src/renderer/components/pages/Updates.tsx
@@ -14,6 +14,9 @@ import { useViewerStore } from "../../store/viewerStore";
import { useSearchStore } from "../../store/searchStore";
import { PostCard } from "../../features/artists/components/PostCard";
import type { Post } from "../../../main/db/schema";
+import { useDownloadAllWithFilters } from "../../hooks/useDownloadAll";
+import { DownloadAllButton } from "../downloads/DownloadAllButton";
+import { Button } from "../../components/ui/button";
// --- Constants ---
const POSTS_PER_PAGE = 50;
@@ -203,6 +206,30 @@ export const Updates = () => {
});
}, [data, sortOrder, aiFilter, mediaType, source]);
+ const fetchParams = useMemo(
+ () => ({
+ filters: {
+ sinceTracking: true,
+ tags: tags.length > 0 ? tags.join(" ") : undefined,
+ aiFilter: aiFilter === "all" ? undefined : aiFilter,
+ mediaType: mediaType === "all" ? undefined : mediaType,
+ isFavorited: source === "favorites" ? true : undefined,
+ },
+ }),
+ [tags, aiFilter, mediaType, source]
+ );
+ const {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading: isDownloadingAll,
+ isPaused,
+ progress: downloadAllProgress,
+ canDownload,
+ totalCount: downloadTotalCount,
+ } = useDownloadAllWithFilters(fetchParams);
+
// Create stable List and Item components with forwardRef and aria-busy
// Must be memoized to prevent Virtuoso from remounting on every render
const { ListComponent, ItemComponent } = useMemo(() => {
@@ -324,13 +351,25 @@ export const Updates = () => {
{allPosts.length > 0 && (
- {allPosts.length} {allPosts.length === 1 ? "post" : "posts"}
- {hasNextPage && " +"}
+ {downloadTotalCount || allPosts.length}{" "}
+ {(downloadTotalCount || allPosts.length) === 1 ? "post" : "posts"}
+ {!downloadTotalCount && hasNextPage ? " +" : ""}
)}
+ 0}
+ totalLabel={downloadTotalCount || allPosts.length}
+ />
{/* Grid Content */}
diff --git a/src/renderer/components/playlists/QuickAddToPlaylistMenu.tsx b/src/renderer/components/playlists/QuickAddToPlaylistMenu.tsx
index 0a6cf8e..6179829 100644
--- a/src/renderer/components/playlists/QuickAddToPlaylistMenu.tsx
+++ b/src/renderer/components/playlists/QuickAddToPlaylistMenu.tsx
@@ -18,16 +18,17 @@ import { usePlaylists } from "../../lib/hooks/usePlaylists";
import type { Playlist } from "../../../main/db/schema";
interface QuickAddToPlaylistMenuProps {
- postId: number;
+ post: { id: number; postId: number };
trigger?: React.ReactNode;
onSuccess?: () => void;
}
export const QuickAddToPlaylistMenu: React.FC = ({
- postId,
+ post,
trigger,
onSuccess,
}) => {
+ const postId = post.id;
const [selectedPlaylistIds, setSelectedPlaylistIds] = useState>(new Set());
const [isCreating, setIsCreating] = useState(false);
const [newPlaylistName, setNewPlaylistName] = useState("");
@@ -42,50 +43,79 @@ export const QuickAddToPlaylistMenu: React.FC = ({
const playlists = allPlaylists.filter((p: Playlist) => !p.isSmart);
// Fetch which playlists this post is already in - single query instead of N queries
- const { data: existingPlaylistIds = [] } = useQuery({
- queryKey: ["playlist-entries", postId],
+ const { data: existingPlaylistIds = [], isFetching } = useQuery({
+ queryKey: ["playlist-entries", postId, post.postId],
queryFn: async () => {
- // Use optimized single-query method instead of looping through playlists
- return await window.api.getPlaylistsContainingPost(postId);
+ return await window.api.getPlaylistsContainingPost(
+ postId,
+ postId <= 0 ? post.postId : undefined
+ );
},
enabled: isMenuOpen && playlists.length > 0,
});
- // Initialize selected playlists with existing ones
+ // Sync selectedPlaylistIds with server data when it changes. Only sync when not fetching
+ // to avoid overwriting optimistic updates during refetch after toggle.
useEffect(() => {
- if (existingPlaylistIds.length > 0) {
+ if (isMenuOpen && !isFetching) {
setSelectedPlaylistIds(new Set(existingPlaylistIds));
}
- }, [existingPlaylistIds]);
+ }, [isMenuOpen, isFetching, existingPlaylistIds]);
- const handleTogglePlaylist = (playlistId: number) => {
- const newSet = new Set(selectedPlaylistIds);
- if (newSet.has(playlistId)) {
- newSet.delete(playlistId);
- } else {
- newSet.add(playlistId);
+ const invalidatePostPlaylists = (effectivePostId: number, playlistIds: number[]) => {
+ queryClient.invalidateQueries({ queryKey: ["playlist-entries", effectivePostId] });
+ queryClient.invalidateQueries({ queryKey: ["playlist-entries", postId] });
+ queryClient.invalidateQueries({ queryKey: ["playlists"] });
+ for (const pid of playlistIds) {
+ queryClient.invalidateQueries({ queryKey: ["playlist-posts", pid] });
}
- setSelectedPlaylistIds(newSet);
};
- const handleSave = async () => {
- if (selectedPlaylistIds.size === 0) {
- return;
+ const handleTogglePlaylist = async (playlistId: number) => {
+ const isAdding = !selectedPlaylistIds.has(playlistId);
+ const prevSet = new Set(selectedPlaylistIds);
+
+ // Optimistic update
+ const newSet = new Set(selectedPlaylistIds);
+ if (isAdding) {
+ newSet.add(playlistId);
+ } else {
+ newSet.delete(playlistId);
}
+ setSelectedPlaylistIds(newSet);
try {
- await window.api.addPostsToPlaylist({
- playlistIds: Array.from(selectedPlaylistIds),
- postIds: [postId],
- });
+ let effectivePostId = postId;
+ if (postId <= 0 && post.postId > 0) {
+ const inserted = await window.api.shadowInsertPost({
+ postId: post.postId,
+ provider: "rule34",
+ });
+ effectivePostId = inserted.id;
+ }
+ if (effectivePostId <= 0) {
+ log.error("[QuickAddToPlaylistMenu] Cannot add/remove: post has no valid DB id");
+ setSelectedPlaylistIds(prevSet);
+ return;
+ }
- // Invalidate queries to refresh data
- queryClient.invalidateQueries({ queryKey: ["playlist-entries", postId] });
- queryClient.invalidateQueries({ queryKey: ["playlists"] });
+ if (isAdding) {
+ await window.api.addPostsToPlaylist({
+ playlistIds: [playlistId],
+ postIds: [effectivePostId],
+ });
+ } else {
+ await window.api.removePostsFromPlaylist({
+ playlistId,
+ postIds: [effectivePostId],
+ });
+ }
+ invalidatePostPlaylists(effectivePostId, [playlistId]);
onSuccess?.();
- } catch (error) {
- log.error("[QuickAddToPlaylistMenu] Failed to add post to playlists:", error);
+ } catch (error: unknown) {
+ log.error("[QuickAddToPlaylistMenu] Failed to toggle playlist:", error);
+ setSelectedPlaylistIds(prevSet);
}
};
@@ -108,15 +138,26 @@ export const QuickAddToPlaylistMenu: React.FC = ({
newSet.add(newPlaylist.id);
setSelectedPlaylistIds(newSet);
- // Add post to the new playlist
- await window.api.addPostsToPlaylist({
- playlistIds: [newPlaylist.id],
- postIds: [postId],
- });
+ // Add post to the new playlist (shadow insert if external post)
+ let effectivePostId = postId;
+ if (postId <= 0 && post.postId > 0) {
+ const inserted = await window.api.shadowInsertPost({
+ postId: post.postId,
+ provider: "rule34",
+ });
+ effectivePostId = inserted.id;
+ }
+ if (effectivePostId > 0) {
+ await window.api.addPostsToPlaylist({
+ playlistIds: [newPlaylist.id],
+ postIds: [effectivePostId],
+ });
+ }
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["playlists"] });
queryClient.invalidateQueries({ queryKey: ["playlist-entries", postId] });
+ queryClient.invalidateQueries({ queryKey: ["playlist-posts", newPlaylist.id] });
setNewPlaylistName("");
setIsDialogOpen(false);
@@ -136,7 +177,8 @@ export const QuickAddToPlaylistMenu: React.FC = ({
return (
<>
-
+ {/* modal={false} allows menu to stay open when clicking outside; verify A11y with screen reader */}
+
{trigger || defaultTrigger}
@@ -160,6 +202,7 @@ export const QuickAddToPlaylistMenu: React.FC = ({
key={playlist.id}
checked={selectedPlaylistIds.has(playlist.id)}
onCheckedChange={() => handleTogglePlaylist(playlist.id)}
+ onSelect={(e) => e.preventDefault()}
>
{playlist.name}
@@ -171,14 +214,6 @@ export const QuickAddToPlaylistMenu: React.FC = ({
Create New Playlist
- {selectedPlaylistIds.size > 0 && (
- <>
-
-
- Save ({selectedPlaylistIds.size})
-
- >
- )}
>
)}
diff --git a/src/renderer/features/artists/ArtistGallery.tsx b/src/renderer/features/artists/ArtistGallery.tsx
index e99ca5c..636531f 100644
--- a/src/renderer/features/artists/ArtistGallery.tsx
+++ b/src/renderer/features/artists/ArtistGallery.tsx
@@ -17,6 +17,8 @@ import { useViewerStore } from "../../store/viewerStore";
import { useSearchStore } from "../../store/searchStore";
import { PostCard } from "./components/PostCard";
import { useGalleryInfiniteScroll } from "../../hooks/useGalleryInfiniteScroll";
+import { useDownloadAllFromBackend } from "../../hooks/useDownloadAll";
+import { DownloadAllButton } from "../../components/downloads/DownloadAllButton";
interface ArtistGalleryProps {
artist: Artist;
@@ -265,6 +267,29 @@ export const ArtistGallery: React.FC = ({
});
};
+ const fetchParams = useMemo(
+ () => ({
+ artistId: artist.id,
+ page: 1,
+ filters: {
+ aiFilter: aiFilter === "all" ? undefined : aiFilter,
+ mediaType: mediaType === "all" ? undefined : mediaType,
+ isFavorited: source === "favorites" ? true : undefined,
+ },
+ }),
+ [artist.id, aiFilter, mediaType, source]
+ );
+ const {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading: isDownloadingAll,
+ isPaused,
+ progress: downloadAllProgress,
+ canDownload,
+ } = useDownloadAllFromBackend(fetchParams, totalPosts);
+
const handleRepairSync = async () => {
if (isLoading) return;
if (
@@ -309,6 +334,17 @@ export const ArtistGallery: React.FC = ({
+
+
+
+ Downloads
+
+ Choose a default folder for saving downloaded files. If not set,
+ files are saved to your system Downloads folder.
+
+
+
+
+
+ {downloadFolder ?? "Default (Downloads/BooruClient)"}
+
+
+
+ {downloadFolder && (
+
+ )}
+
+ {downloadFolderStatus === "success" && (
+
+ Download folder updated.
+
+ )}
+ {downloadFolderStatus === "error" && (
+
+ Failed to update. Please try again.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
Database Management
diff --git a/src/renderer/features/viewer/ViewerDialog.tsx b/src/renderer/features/viewer/ViewerDialog.tsx
index 4ee8922..16fa473 100644
--- a/src/renderer/features/viewer/ViewerDialog.tsx
+++ b/src/renderer/features/viewer/ViewerDialog.tsx
@@ -1332,7 +1332,7 @@ const ViewerContent = ({
setShowPlaylistDialog(false)}>
e.stopPropagation()}>
diff --git a/src/renderer/hooks/useDownloadAll.ts b/src/renderer/hooks/useDownloadAll.ts
new file mode 100644
index 0000000..796fac3
--- /dev/null
+++ b/src/renderer/hooks/useDownloadAll.ts
@@ -0,0 +1,186 @@
+import { useState, useEffect } from "react";
+import log from "electron-log/renderer";
+import type { Post } from "../../main/db/schema";
+import type { GetPostsRequest } from "../../main/types/ipc";
+import { useDownloadStore } from "../store/downloadStore";
+
+function postToDownloadItem(p: Post): { url: string; filename: string } | null {
+ if (!p.fileUrl?.trim()) return null;
+ const pathMatch = p.fileUrl.match(/^[^?#]+/);
+ const pathname = pathMatch ? pathMatch[0] : p.fileUrl;
+ const ext = pathname.split(".").pop()?.toLowerCase() || "jpg";
+ return {
+ url: p.fileUrl,
+ filename: `${p.artistId}_${p.postId}.${ext}`,
+ };
+}
+
+/** Download from loaded posts (Favorites, Updates, Browse, Playlists) */
+export function useDownloadAll(posts: Post[]) {
+ const [isDownloading, setIsDownloading] = useState(false);
+ const setGlobalDownloading = useDownloadStore((s) => s.setDownloading);
+ const [isPaused, setIsPaused] = useState(false);
+ const [progress, setProgress] = useState({ done: 0, total: 0 });
+
+ useEffect(() => {
+ if (!isDownloading) return;
+ const unsub = window.api.onDownloadAllProgress((data) => {
+ setProgress({ done: data.done, total: data.total });
+ });
+ return () => unsub();
+ }, [isDownloading]);
+
+ const downloadAll = async () => {
+ const items = posts
+ .map(postToDownloadItem)
+ .filter((x): x is { url: string; filename: string } => x !== null);
+ if (items.length === 0) return;
+ setIsDownloading(true);
+ setGlobalDownloading(true);
+ setIsPaused(false);
+ setProgress({ done: 0, total: items.length });
+ try {
+ const result = await window.api.downloadAll(items);
+ log.info(
+ `[useDownloadAll] Done: ${result.downloaded} ok, ${result.failed} failed, canceled=${result.canceled}`
+ );
+ } catch (e) {
+ log.error("[useDownloadAll] Failed:", e);
+ } finally {
+ setIsDownloading(false);
+ setGlobalDownloading(false);
+ setIsPaused(false);
+ setProgress({ done: 0, total: 0 });
+ }
+ };
+
+ const cancel = () => {
+ window.api.cancelDownloadAll();
+ };
+
+ const pause = () => {
+ window.api.pauseDownloadAll();
+ setIsPaused(true);
+ };
+
+ const resume = () => {
+ window.api.resumeDownloadAll();
+ setIsPaused(false);
+ };
+
+ return {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading,
+ isPaused,
+ progress,
+ canDownload: posts.length > 0,
+ };
+}
+
+/** Download from backend with filters (Updates, Favorites - fetches count + items from DB) */
+export function useDownloadAllWithFilters(
+ fetchParams: Pick | null
+) {
+ const [totalCount, setTotalCount] = useState(0);
+
+ useEffect(() => {
+ if (!fetchParams) {
+ setTotalCount(0);
+ return;
+ }
+ window.api
+ .getPostsCountWithFilters(fetchParams)
+ .then(setTotalCount)
+ .catch((e) => {
+ if ((e as { code?: string })?.code !== "RATE_LIMIT") {
+ setTotalCount(0);
+ }
+ });
+ }, [
+ fetchParams?.artistId,
+ fetchParams?.filters?.tags,
+ fetchParams?.filters?.rating,
+ fetchParams?.filters?.isFavorited,
+ fetchParams?.filters?.isViewed,
+ fetchParams?.filters?.sinceTracking,
+ fetchParams?.filters?.aiFilter,
+ fetchParams?.filters?.mediaType,
+ ]);
+
+ const backendResult = useDownloadAllFromBackend(
+ fetchParams ? { ...fetchParams, page: 1, limit: 50 } : null,
+ totalCount
+ );
+
+ return { ...backendResult, totalCount };
+}
+
+/** Download from backend (ArtistGallery - fetches all from DB, uses totalCount for display) */
+export function useDownloadAllFromBackend(
+ fetchParams: GetPostsRequest | null,
+ totalCount: number
+) {
+ const [isDownloading, setIsDownloading] = useState(false);
+ const setGlobalDownloading = useDownloadStore((s) => s.setDownloading);
+ const [isPaused, setIsPaused] = useState(false);
+ const [progress, setProgress] = useState({ done: 0, total: 0 });
+
+ useEffect(() => {
+ if (!isDownloading) return;
+ const unsub = window.api.onDownloadAllProgress((data) => {
+ setProgress({ done: data.done, total: data.total });
+ });
+ return () => unsub();
+ }, [isDownloading]);
+
+ const downloadAll = async () => {
+ if (!fetchParams) return;
+ setIsDownloading(true);
+ setGlobalDownloading(true);
+ setIsPaused(false);
+ setProgress({ done: 0, total: 0 });
+ try {
+ const { items } = await window.api.getDownloadItems({
+ ...fetchParams,
+ limit: 500,
+ });
+ if (items.length === 0) return;
+ setProgress({ done: 0, total: items.length });
+ const result = await window.api.downloadAll(items);
+ log.info(
+ `[useDownloadAllFromBackend] Done: ${result.downloaded} ok, ${result.failed} failed, canceled=${result.canceled}`
+ );
+ } catch (e) {
+ log.error("[useDownloadAllFromBackend] Failed:", e);
+ } finally {
+ setIsDownloading(false);
+ setGlobalDownloading(false);
+ setIsPaused(false);
+ setProgress({ done: 0, total: 0 });
+ }
+ };
+
+ const cancel = () => window.api.cancelDownloadAll();
+ const pause = () => {
+ window.api.pauseDownloadAll();
+ setIsPaused(true);
+ };
+ const resume = () => {
+ window.api.resumeDownloadAll();
+ setIsPaused(false);
+ };
+
+ return {
+ downloadAll,
+ cancel,
+ pause,
+ resume,
+ isDownloading,
+ isPaused,
+ progress,
+ canDownload: totalCount > 0,
+ };
+}
diff --git a/src/renderer/store/downloadStore.ts b/src/renderer/store/downloadStore.ts
new file mode 100644
index 0000000..1283caa
--- /dev/null
+++ b/src/renderer/store/downloadStore.ts
@@ -0,0 +1,12 @@
+import { create } from "zustand";
+
+/** Global download state - used to hide PendingDownloadBanner and prevent double-download during active batch */
+interface DownloadState {
+ isDownloading: boolean;
+ setDownloading: (value: boolean) => void;
+}
+
+export const useDownloadStore = create((set) => ({
+ isDownloading: false,
+ setDownloading: (value) => set({ isDownloading: value }),
+}));
diff --git a/src/shared/schemas/settings.ts b/src/shared/schemas/settings.ts
index cfdb32f..e215045 100644
--- a/src/shared/schemas/settings.ts
+++ b/src/shared/schemas/settings.ts
@@ -89,6 +89,9 @@ export const IpcSettingsSchema = z.object({
isAdultConfirmed: z.boolean(),
isAdultVerified: z.boolean(),
tosAcceptedAt: z.number().nullable(), // Timestamp in milliseconds
+ downloadFolder: z.string().nullable(), // Custom folder for downloads (null = use default)
+ duplicateFileBehavior: z.enum(["skip", "overwrite"]).default("skip"),
+ downloadFolderStructure: z.enum(["flat", "{artist_id}"]).default("flat"),
});
/**