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"), }); /**