Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
416 changes: 0 additions & 416 deletions docs/contributing.md

This file was deleted.

4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
3 changes: 3 additions & 0 deletions drizzle/0012_add_download_folder.sql
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions drizzle/0013_add_download_settings.sql
Original file line number Diff line number Diff line change
@@ -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';
14 changes: 14 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
1 change: 1 addition & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 77 additions & 3 deletions src/main/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface IpcBridge {
// Settings
getSettings: () => Promise<IpcSettings | null>;
saveSettings: (creds: { userId: string; apiKey: string }) => Promise<boolean>;
saveDownloadFolder: (path: string | null) => Promise<boolean>;
confirmLegal: () => Promise<IpcSettings>;
logout: () => Promise<void>;

Expand All @@ -73,6 +74,8 @@ export interface IpcBridge {
// Posts
getArtistPosts: (params: GetPostsRequest) => Promise<Post[]>;
getArtistPostsCount: (artistId?: number) => Promise<number>;
getDownloadItems: (params: GetPostsRequest & { limit?: number }) => Promise<{ items: Array<{ url: string; filename: string }> }>;
getPostsCountWithFilters: (params: Pick<GetPostsRequest, "artistId" | "filters">) => Promise<number>;

togglePostViewed: (postId: number) => Promise<boolean>;

Expand Down Expand Up @@ -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<boolean>;
pauseDownloadAll: () => Promise<void>;
resumeDownloadAll: () => Promise<void>;
getPendingDownload: () => Promise<{
hasPending: boolean;
total: number;
done: number;
folder: string;
} | null>;
resumePendingDownload: () => Promise<{ success: boolean; error?: string }>;
dismissPendingDownload: () => Promise<void>;
saveDownloadSettings: (data: {
duplicateFileBehavior?: "skip" | "overwrite";
downloadFolderStructure?: "flat" | "{artist_id}";
}) => Promise<boolean>;
openFileInFolder: (path: string) => Promise<boolean>;
selectDownloadFolder: () => Promise<string | null>;

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<SearchResults[]>;

Expand All @@ -142,7 +174,7 @@ export interface IpcBridge {
removePostsFromPlaylist: (data: RemovePostsFromPlaylistRequest) => Promise<number>;
getPlaylistPosts: (params: GetPlaylistPostsRequest) => Promise<Post[]>;
resolvePlaylistPosts: (params: ResolvePlaylistPostsRequest) => Promise<Post[]>;
getPlaylistsContainingPost: (postId: number) => Promise<number[]>;
getPlaylistsContainingPost: (postId: number, rule34PostId?: number) => Promise<number[]>;
}

const ipcBridge: IpcBridge = {
Expand Down Expand Up @@ -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),
Expand All @@ -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<GetPostsRequest, "artistId" | "filters">) =>
ipcRenderer.invoke(IPC_CHANNELS.DB.GET_POSTS_COUNT_WITH_FILTERS, params),

openExternal: (url) => ipcRenderer.invoke("app:open-external", url),

Expand All @@ -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) =>
Expand All @@ -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),

Expand Down Expand Up @@ -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);
54 changes: 25 additions & 29 deletions src/main/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export async function initializeDatabase(): Promise<AppDatabase> {
);
`);

// 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`);

Expand All @@ -188,32 +188,24 @@ export async function initializeDatabase(): Promise<AppDatabase> {
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
Expand Down Expand Up @@ -377,10 +369,14 @@ export async function initializeDatabase(): Promise<AppDatabase> {
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 {
Expand Down
3 changes: 3 additions & 0 deletions src/main/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand 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",
Expand All @@ -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;
Loading