├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── 64x64.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── src │ ├── hitomi │ │ ├── mod.rs │ │ ├── result.rs │ │ ├── gg.rs │ │ └── common.rs │ ├── main.rs │ ├── types │ │ ├── proxy_mode.rs │ │ ├── mod.rs │ │ ├── log_level.rs │ │ ├── download_format.rs │ │ ├── search_result.rs │ │ ├── comic_info.rs │ │ └── comic.rs │ ├── errors.rs │ ├── utils.rs │ ├── extensions.rs │ ├── events.rs │ ├── lib.rs │ ├── config.rs │ ├── logger.rs │ ├── hitomi_client.rs │ └── export.rs ├── .gitignore ├── capabilities │ └── default.json ├── tauri.conf.json └── Cargo.toml ├── .vscode └── extensions.json ├── .prettierrc ├── src ├── utils.ts ├── vite-env.d.ts ├── types.ts ├── locales │ ├── index.ts │ ├── zh-CN.json │ └── en-US.json ├── assets │ └── vue.svg ├── main.ts ├── App.vue ├── components │ ├── CompletedProgresses.vue │ ├── FloatLabelInput.vue │ ├── DownloadButton.vue │ ├── AboutDialog.vue │ ├── ComicCard.vue │ ├── DownloadedComicCard.vue │ ├── SettingsDialog.vue │ ├── LogViewer.vue │ └── UncompletedProgresses.vue ├── store.ts ├── AppContent.vue ├── panes │ ├── DownloadingPane.vue │ ├── DownloadedPane.vue │ ├── ComicPane.vue │ └── SearchPane.vue └── bindings.ts ├── tsconfig.node.json ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.zh-CN.yml │ ├── feature_request.yml │ ├── bug_report.zh-CN.yml │ └── bug_report.yml └── workflows │ └── Publish.yml ├── index.html ├── uno.config.ts ├── eslint.config.js ├── tsconfig.json ├── LICENSE ├── package.json ├── public ├── vite.svg └── tauri.svg ├── vite.config.ts ├── components.d.ts ├── README.zh-CN.md ├── auto-imports.d.ts └── README.md /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lanyeeee/hitomi-downloader/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/src/hitomi/mod.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | mod gg; 3 | mod result; 4 | mod search; 5 | 6 | pub use common::*; 7 | pub use result::*; 8 | pub use search::*; 9 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "bracketSameLine": true, 7 | "endOfLine": "auto", 8 | "htmlWhitespaceSensitivity": "ignore" 9 | } 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as i18n from 'vue-i18n' 2 | import { MessageSchema, SupportedLocales } from './main.ts' 3 | 4 | export function useI18n() { 5 | return i18n.useI18n<{ message: MessageSchema }, SupportedLocales>() 6 | } 7 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | hitomi_downloader_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DownloadTaskEvent } from './bindings.ts' 2 | 3 | export type CurrentTabName = 'search' | 'downloaded' | 'comic' 4 | 5 | export type ProgressData = Extract['data'] & { 6 | percentage: number 7 | indicator: string 8 | } 9 | -------------------------------------------------------------------------------- /src-tauri/src/types/proxy_mode.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)] 5 | pub enum ProxyMode { 6 | #[default] 7 | System, 8 | NoProxy, 9 | Custom, 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | mod comic; 2 | mod comic_info; 3 | mod download_format; 4 | mod log_level; 5 | mod proxy_mode; 6 | mod search_result; 7 | 8 | pub use comic::*; 9 | pub use comic_info::*; 10 | pub use download_format::*; 11 | pub use log_level::*; 12 | pub use proxy_mode::*; 13 | pub use search_result::*; 14 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "opener:default", 11 | "dialog:default" 12 | ] 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import zhCN from './zh-CN.json' 2 | import enUS from './en-US.json' 3 | 4 | 5 | const _locales = { 6 | 'zh-CN': zhCN, 7 | 'en-US': enUS, 8 | } 9 | 10 | // sort locales by key 11 | export const locales = Object.fromEntries( 12 | Object.entries(_locales).sort(([keyA], [keyB]) => { 13 | return keyA.localeCompare(keyB) 14 | }), 15 | ) as typeof _locales 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ❓ Questions & Discussions 4 | url: https://github.com/lanyeeee/hitomi-downloader/discussions 5 | about: Use GitHub discussions for message-board style questions and discussions. 6 | - name: ❓ 提问与讨论 7 | url: https://github.com/lanyeeee/hitomi-downloader/discussions 8 | about: 如果有一般性问题或想发起讨论,请使用 GitHub Discussions -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + Vue + Typescript App 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/src/types/log_level.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)] 5 | pub enum LogLevel { 6 | #[serde(rename = "TRACE")] 7 | Trace, 8 | #[serde(rename = "DEBUG")] 9 | Debug, 10 | #[serde(rename = "INFO")] 11 | Info, 12 | #[serde(rename = "WARN")] 13 | Warn, 14 | #[serde(rename = "ERROR")] 15 | Error, 16 | } 17 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/types/download_format.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | #[derive(Default, Debug, Copy, Clone, PartialEq, Serialize, Deserialize, Type)] 5 | pub enum DownloadFormat { 6 | #[default] 7 | Webp, 8 | Avif, 9 | } 10 | impl DownloadFormat { 11 | // TODO: use `self` instead of `&self` 12 | pub fn to_extension(&self) -> &str { 13 | match self { 14 | DownloadFormat::Webp => "webp", 15 | DownloadFormat::Avif => "avif", 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetTypography, 6 | presetUno, 7 | presetWebFonts, 8 | transformerDirectives, 9 | transformerVariantGroup, 10 | } from 'unocss' 11 | 12 | export default defineConfig({ 13 | shortcuts: [ 14 | // ... 15 | ], 16 | theme: { 17 | colors: { 18 | // ... 19 | }, 20 | }, 21 | presets: [ 22 | presetUno(), 23 | presetAttributify(), 24 | presetIcons(), 25 | presetTypography(), 26 | presetWebFonts({ 27 | fonts: { 28 | // ... 29 | }, 30 | }), 31 | ], 32 | transformers: [transformerDirectives(), transformerVariantGroup()], 33 | }) 34 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createPinia } from 'pinia' 3 | import App from './App.vue' 4 | import 'virtual:uno.css' 5 | import { createI18n } from 'vue-i18n' 6 | import { locales } from './locales' 7 | 8 | export type SupportedLocales = keyof typeof locales 9 | export type MessageSchema = (typeof locales)['zh-CN'] 10 | 11 | export const i18n = createI18n<[MessageSchema], SupportedLocales>({ 12 | locale: navigator.language in locales ? navigator.language : 'en-US', 13 | fallbackLocale: 'en-US', 14 | globalInjection: true, 15 | legacy: false, 16 | messages: locales, 17 | }) 18 | 19 | const pinia = createPinia() 20 | const app = createApp(App) 21 | 22 | app.use(pinia).use(i18n).mount('#app') 23 | -------------------------------------------------------------------------------- /src-tauri/src/errors.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use specta::Type; 3 | 4 | use crate::extensions::AnyhowErrorToStringChain; 5 | 6 | pub type CommandResult = Result; 7 | 8 | #[derive(Debug, Type, Serialize)] 9 | pub struct CommandError { 10 | pub err_title: String, 11 | pub err_message: String, 12 | } 13 | 14 | impl CommandError { 15 | pub fn from(err_title: &str, err: E) -> Self 16 | where 17 | E: Into, 18 | { 19 | let string_chain = err.into().to_string_chain(); 20 | tracing::error!(err_title, message = string_chain); 21 | Self { 22 | err_title: err_title.to_string(), 23 | err_message: string_chain, 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import tseslint from 'typescript-eslint' 4 | import pluginVue from 'eslint-plugin-vue' 5 | import { defineConfig } from 'eslint/config' 6 | 7 | export default defineConfig([ 8 | { files: ['**/*.{js,mjs,cjs,ts,vue}'], plugins: { js }, extends: ['js/recommended'] }, 9 | { files: ['**/*.{js,mjs,cjs,ts,vue}'], languageOptions: { globals: globals.browser } }, 10 | tseslint.configs.recommended, 11 | pluginVue.configs['flat/essential'], 12 | { 13 | files: ['**/*.vue'], 14 | languageOptions: { 15 | parserOptions: { 16 | parser: tseslint.parser, 17 | ecmaFeatures: { 18 | jsx: true, 19 | }, 20 | }, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | "jsxImportSource": "vue", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /src-tauri/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | use tauri::AppHandle; 3 | 4 | pub fn filename_filter(s: &str) -> String { 5 | s.chars() 6 | .map(|c| match c { 7 | '\\' | '/' | '\n' => ' ', 8 | ':' => ':', 9 | '*' => '⭐', 10 | '?' => '?', 11 | '"' => '\'', 12 | '<' => '《', 13 | '>' => '》', 14 | '|' => '丨', 15 | _ => c, 16 | }) 17 | .collect::() 18 | .trim() 19 | .trim_end_matches('.') 20 | .trim() 21 | .to_string() 22 | } 23 | 24 | pub static APP_HANDLE: OnceLock = OnceLock::new(); 25 | 26 | pub fn get_app_handle() -> AppHandle { 27 | APP_HANDLE 28 | .get() 29 | .expect("APP_HANDLE not initialized") 30 | .clone() 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.zh-CN.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: 想要请求添加某个功能 3 | labels: [enhancement] 4 | title: "[功能请求] 修改我!未修改标题的issue将被自动关闭" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 为了使我更好地帮助你,请提供以下信息。以及上方的标题 10 | - type: textarea 11 | id: reason 12 | attributes: 13 | label: 原因 14 | description: 为什么想要这个功能 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: desc 19 | attributes: 20 | label: 功能简述 21 | description: 想要个怎样的功能 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: logic 26 | attributes: 27 | label: 功能逻辑 28 | description: 如何互交、如何使用等 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: ref 33 | attributes: 34 | label: 实现参考 35 | description: 该功能可能的实现方式,或者其他已经实现该功能的应用等 -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/CompletedProgresses.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 31 | -------------------------------------------------------------------------------- /src-tauri/src/extensions.rs: -------------------------------------------------------------------------------- 1 | pub trait AnyhowErrorToStringChain { 2 | /// convert `anyhow::Error` to string chain 3 | /// # Example 4 | /// 0: error message 5 | /// 1: error message 6 | /// 2: error message 7 | fn to_string_chain(&self) -> String; 8 | } 9 | 10 | impl AnyhowErrorToStringChain for anyhow::Error { 11 | fn to_string_chain(&self) -> String { 12 | use std::fmt::Write; 13 | self.chain() 14 | .enumerate() 15 | .fold(String::new(), |mut output, (i, e)| { 16 | let _ = writeln!(output, "{i}: {e}"); 17 | output 18 | }) 19 | } 20 | } 21 | 22 | pub trait PathIsImg { 23 | /// Check if the path is an image file 24 | fn is_img(&self) -> bool; 25 | } 26 | 27 | impl PathIsImg for std::path::Path { 28 | fn is_img(&self) -> bool { 29 | self.extension() 30 | .and_then(|ext| ext.to_str()) 31 | .map(str::to_lowercase) 32 | .is_some_and(|ext| matches!(ext.as_str(), "webp" | "avif")) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { Comic, commands, Config, SearchResult } from './bindings.ts' 3 | import { ref } from 'vue' 4 | import { CurrentTabName, ProgressData } from './types.ts' 5 | 6 | export const useStore = defineStore('store', () => { 7 | const config = ref() 8 | const pickedComic = ref() 9 | const currentTabName = ref('search') 10 | const progresses = ref>(new Map()) 11 | const covers = ref>(new Map()) 12 | const searchResult = ref() 13 | 14 | async function loadCover(id: number, url: string) { 15 | const result = await commands.getCoverData(url) 16 | if (result.status === 'error') { 17 | console.error(result.error) 18 | return 19 | } 20 | const coverData: number[] = result.data 21 | const coverBlob = new Blob([new Uint8Array(coverData)]) 22 | const cover = URL.createObjectURL(coverBlob) 23 | covers.value.set(id, cover) 24 | } 25 | 26 | return { config, pickedComic, currentTabName, progresses, covers, loadCover, searchResult } 27 | }) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 lanyeeee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "hitomi-downloader", 4 | "version": "0.1.0", 5 | "identifier": "com.lanyeeee.hitomi-downloader", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:5005", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "hitomi-downloader", 16 | "width": 800, 17 | "height": 600 18 | } 19 | ], 20 | "security": { 21 | "csp": null 22 | } 23 | }, 24 | "bundle": { 25 | "active": true, 26 | "targets": "all", 27 | "licenseFile": "../LICENSE", 28 | "icon": [ 29 | "icons/32x32.png", 30 | "icons/128x128.png", 31 | "icons/128x128@2x.png", 32 | "icons/icon.icns", 33 | "icons/icon.ico" 34 | ], 35 | "windows": { 36 | "nsis": { 37 | "installMode": "perMachine", 38 | "displayLanguageSelector": true, 39 | "languages": [ 40 | "SimpChinese", 41 | "English" 42 | ] 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hitomi-downloader", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@phosphor-icons/vue": "^2.2.1", 14 | "@tauri-apps/api": "^2", 15 | "@tauri-apps/plugin-dialog": "~2", 16 | "@tauri-apps/plugin-opener": "^2", 17 | "@viselect/vue": "^3.9.0", 18 | "@vitejs/plugin-vue-jsx": "^4.2.0", 19 | "naive-ui": "^2.41.0", 20 | "pinia": "^3.0.2", 21 | "unocss": "^66.1.3", 22 | "unplugin-auto-import": "^19.3.0", 23 | "unplugin-vue-components": "^28.7.0", 24 | "vue": "^3.5.13", 25 | "vue-i18n": "11" 26 | }, 27 | "devDependencies": { 28 | "@eslint/js": "^9.28.0", 29 | "@tauri-apps/cli": "^2", 30 | "@vitejs/plugin-vue": "^5.2.1", 31 | "eslint": "^9.28.0", 32 | "eslint-plugin-vue": "^10.1.0", 33 | "globals": "^16.2.0", 34 | "prettier": "3.5.3", 35 | "typescript": "~5.6.2", 36 | "typescript-eslint": "^8.33.0", 37 | "vite": "^6.0.3", 38 | "vite-plugin-vue-devtools": "^7.7.6", 39 | "vue-tsc": "^2.1.10" 40 | }, 41 | "packageManager": "pnpm@9.5.0" 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request to add a specific feature 3 | labels: [enhancement] 4 | title: "[Feature Request] Edit Me! Issues with unmodified title will be automatically closed" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | To help me understand your idea, please provide the following information and edit the title above. 10 | - type: textarea 11 | id: reason 12 | attributes: 13 | label: Reason 14 | description: Why is this feature needed? What problem does it solve? 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: desc 19 | attributes: 20 | label: Feature Description 21 | description: A clear and concise description of the feature you're proposing 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: logic 26 | attributes: 27 | label: Feature Logic 28 | description: How to interact, how to use, etc. 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: ref 33 | attributes: 34 | label: Implementation Reference 35 | description: Possible ways to implement this feature, or other applications that have already implemented this feature, etc. -------------------------------------------------------------------------------- /src-tauri/src/types/search_result.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use serde::{Deserialize, Serialize}; 3 | use specta::Type; 4 | use tauri::AppHandle; 5 | 6 | use crate::hitomi::GalleryInfo; 7 | 8 | use super::Comic; 9 | 10 | #[derive(Default, Debug, Clone, Serialize, Deserialize, Type)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct SearchResult { 13 | comics: Vec, 14 | current_page: usize, 15 | total_page: usize, 16 | pub ids: Vec, 17 | } 18 | impl SearchResult { 19 | pub async fn from_gallery_infos( 20 | app: &AppHandle, 21 | gallery_infos: Vec, 22 | current_page: usize, 23 | total_page: usize, 24 | ids: Vec, 25 | ) -> anyhow::Result { 26 | let from_comic_tasks = gallery_infos.into_iter().map(|gallery_info| async { 27 | let id = gallery_info.id; 28 | Comic::from_gallery_info(app, gallery_info) 29 | .await 30 | .context(format!("Failed to create Comic from gallery_info `{id}`")) 31 | }); 32 | 33 | let comics = futures::future::try_join_all(from_comic_tasks).await?; 34 | 35 | let search_result = SearchResult { 36 | comics, 37 | current_page, 38 | total_page, 39 | ids, 40 | }; 41 | 42 | Ok(search_result) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import AutoImport from 'unplugin-auto-import/vite' 4 | import Components from 'unplugin-vue-components/vite' 5 | import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' 6 | import UnoCSS from 'unocss/vite' 7 | import vueJsx from '@vitejs/plugin-vue-jsx' 8 | import vueDevTools from 'vite-plugin-vue-devtools' 9 | 10 | // @ts-expect-error process is a nodejs global 11 | const host = process.env.TAURI_DEV_HOST 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig(async () => ({ 15 | plugins: [ 16 | vue(), 17 | UnoCSS(), 18 | vueJsx({}), 19 | vueDevTools(), 20 | AutoImport({ 21 | imports: [ 22 | 'vue', 23 | { 24 | 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], 25 | }, 26 | ], 27 | }), 28 | Components({ 29 | resolvers: [NaiveUiResolver()], 30 | }), 31 | ], 32 | 33 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 34 | // 35 | // 1. prevent vite from obscuring rust errors 36 | clearScreen: false, 37 | // 2. tauri expects a fixed port, fail if that port is not available 38 | server: { 39 | port: 5005, 40 | strictPort: true, 41 | host: host || false, 42 | hmr: host 43 | ? { 44 | protocol: 'ws', 45 | host, 46 | port: 1421, 47 | } 48 | : undefined, 49 | watch: { 50 | // 3. tell vite to ignore watching `src-tauri` 51 | ignored: ['**/src-tauri/**'], 52 | }, 53 | }, 54 | })) 55 | -------------------------------------------------------------------------------- /src/components/FloatLabelInput.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 61 | 62 | 80 | -------------------------------------------------------------------------------- /src-tauri/src/events.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri_specta::Event; 6 | 7 | use crate::{ 8 | download_manager::DownloadTaskState, 9 | types::{Comic, LogLevel}, 10 | }; 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct LogEvent { 15 | pub timestamp: String, 16 | pub level: LogLevel, 17 | pub fields: HashMap, 18 | pub target: String, 19 | pub filename: String, 20 | #[serde(rename = "line_number")] 21 | pub line_number: i64, 22 | } 23 | 24 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 25 | #[serde(tag = "event", content = "data")] 26 | pub enum DownloadTaskEvent { 27 | #[serde(rename_all = "camelCase")] 28 | Create { 29 | state: DownloadTaskState, 30 | comic: Box, 31 | downloaded_img_count: u32, 32 | total_img_count: u32, 33 | }, 34 | 35 | #[serde(rename_all = "camelCase")] 36 | Update { 37 | comic_id: i32, 38 | state: DownloadTaskState, 39 | downloaded_img_count: u32, 40 | total_img_count: u32, 41 | }, 42 | } 43 | 44 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 45 | #[serde(rename_all = "camelCase")] 46 | pub struct DownloadSpeedEvent { 47 | pub speed: String, 48 | } 49 | 50 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 51 | #[serde(tag = "event", content = "data")] 52 | pub enum ExportPdfEvent { 53 | #[serde(rename_all = "camelCase")] 54 | Start { uuid: String, title: String }, 55 | #[serde(rename_all = "camelCase")] 56 | Error { uuid: String }, 57 | #[serde(rename_all = "camelCase")] 58 | End { uuid: String }, 59 | } 60 | 61 | #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] 62 | #[serde(tag = "event", content = "data")] 63 | pub enum ExportCbzEvent { 64 | #[serde(rename_all = "camelCase")] 65 | Start { uuid: String, title: String }, 66 | #[serde(rename_all = "camelCase")] 67 | Error { uuid: String }, 68 | #[serde(rename_all = "camelCase")] 69 | End { uuid: String }, 70 | } 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.zh-CN.yml: -------------------------------------------------------------------------------- 1 | name: 反馈 Bug 2 | description: 反馈遇到的问题 3 | labels: [bug] 4 | title: "[Bug] 修改我!未修改标题的issue将被自动关闭" 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: 在提交BUG之前 9 | options: 10 | - label: 我尝试使用了最新版,我确定这个问题在最新版中依然存在 11 | required: true 12 | - label: 我会以「一起合作来解决问题」的态度编写issue,尽力提供详细信息,而不是仅提供报错信息或截图 13 | required: true 14 | - type: textarea 15 | id: desc 16 | attributes: 17 | label: 问题描述 18 | description: 发生了什么情况?有什么现状?哪部漫画?哪个章节?问题能稳定触发吗? 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: reproduction 23 | attributes: 24 | label: 复现步骤 25 | description: 这是整个issue中**最重要**的部分。请参考[这个issue](https://github.com/lanyeeee/hitomi-downloader/issues/3)编写详细的复现步骤 26 | placeholder: 复现步骤是影响issue处理效率的最大因素,没有详细的复现步骤将导致问题难以被定位,开发者需要花费大量时间来回沟通以定位问题 27 | validations: 28 | required: true 29 | - type: checkboxes 30 | attributes: 31 | label: 复现步骤非常重要 32 | description: 仅提供报错信息或截图而没有复现步骤,问题难以被定位,也就难以被解决。详细的复现步骤也是对项目维护工作的尊重 33 | options: 34 | - label: 我已经参考[这个issue](https://github.com/lanyeeee/hitomi-downloader/issues/3)在**复现步骤**中编写了详细的复现步骤 35 | required: true 36 | - type: textarea 37 | id: expected 38 | attributes: 39 | label: 预期行为 40 | description: 正常情况下应该发生什么 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: actual 45 | attributes: 46 | label: 实际行为 47 | description: 实际上发生了什么 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: logs 52 | attributes: 53 | label: 日志 54 | description: 请提供相关的日志信息 55 | placeholder: | 56 | 如果相关日志比较短可以直接粘贴 57 | 58 | 如果相关日志很长,建议将相关日志保存为txt,然后点击文本框下面小长条上传文件 59 | validations: 60 | required: false 61 | - type: textarea 62 | id: media 63 | attributes: 64 | label: 截图或录屏 65 | description: 问题复现时候的截图或录屏 66 | placeholder: 点击文本框下面小长条可以上传文件 67 | - type: input 68 | id: version 69 | attributes: 70 | label: 工具版本号(点击`关于`按钮查看) 71 | placeholder: v0.1.0 72 | validations: 73 | required: true 74 | - type: textarea 75 | id: other 76 | attributes: 77 | label: 其他 78 | description: 其他要补充的内容 79 | placeholder: 其他要补充的内容 80 | validations: 81 | required: false 82 | -------------------------------------------------------------------------------- /src-tauri/src/types/comic_info.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | use yaserde::{YaDeserialize, YaSerialize}; 4 | 5 | use super::Comic; 6 | 7 | /// https://wiki.kavitareader.com/guides/metadata/comics/ 8 | #[derive( 9 | Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type, YaSerialize, YaDeserialize, 10 | )] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct ComicInfo { 13 | #[yaserde(rename = "Manga")] 14 | pub manga: String, 15 | /// Comic title 16 | #[yaserde(rename = "Series")] 17 | pub series: String, 18 | #[yaserde(rename = "Writer")] 19 | pub writer: String, 20 | #[yaserde(rename = "Publisher")] 21 | pub publisher: String, 22 | #[yaserde(rename = "Genre")] 23 | pub genre: String, 24 | #[yaserde(rename = "Tags")] 25 | pub tags: String, 26 | /// Normal chapter number 27 | #[yaserde(rename = "Number")] 28 | pub number: Option, 29 | /// Volume number 30 | #[yaserde(rename = "Volume")] 31 | pub volume: Option, 32 | /// if the value is `Special`, the chapter will be treated as a special issue by Kavita 33 | #[yaserde(rename = "Format")] 34 | pub format: Option, 35 | /// The number of pages in this chapter 36 | #[yaserde(rename = "PageCount")] 37 | pub page_count: i64, 38 | /// Total number of chapters 39 | /// - `0` => Ongoing 40 | /// - `Non-zero` and consistent with `Number` or `Volume` => Completed 41 | /// - `Other non-zero values` => Ended 42 | #[yaserde(rename = "Count")] 43 | pub count: i64, 44 | } 45 | 46 | impl From for ComicInfo { 47 | fn from(comic: Comic) -> Self { 48 | ComicInfo { 49 | manga: "Yes".to_string(), 50 | series: comic.title, 51 | writer: comic.artists.join(", "), 52 | publisher: "Hitomi".to_string(), 53 | genre: comic.type_field, 54 | tags: comic 55 | .tags 56 | .into_iter() 57 | .map(|tag| tag.tag) 58 | .collect::>() 59 | .join(", "), 60 | number: Some("1".to_string()), 61 | volume: None, 62 | format: Some("Special".to_string()), 63 | #[allow(clippy::cast_possible_wrap)] 64 | page_count: comic.files.len() as i64, 65 | count: 1, 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/DownloadButton.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hitomi-downloader" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "hitomi_downloader_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = [] } 22 | tauri-plugin-opener = "2" 23 | tauri-plugin-dialog = "2" 24 | 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = "1" 27 | yaserde = { version = "0.12.0", features = ["yaserde_derive"] } 28 | 29 | specta = { version = "=2.0.0-rc.20", features = ["serde_json"] } 30 | tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] } 31 | specta-typescript = { version = "=0.0.7" } 32 | 33 | reqwest = { version = "0.12.19", default-features = false, features = ["default-tls", "system-proxy"] } 34 | reqwest-retry = { version = "0.7.0" } 35 | reqwest-middleware = { version = "0.4.2" } 36 | 37 | anyhow = { version = "1.0.98" } 38 | parking_lot = { version = "0.12.4", features = ["send_guard"] } 39 | tracing = { version = "0.1.41" } 40 | tracing-subscriber = { version = "0.3.19", features = ["json", "time", "local-time"] } 41 | tracing-appender = { version = "0.2.3" } 42 | notify = { version = "8.0.0" } 43 | tokio = { version = "1.45.1", features = ["full"] } 44 | chrono = { version = "0.4.41" } 45 | regex-lite = { version = "0.1.6" } 46 | futures = { version = "0.3.31" } 47 | byteorder = { version = "1.5.0" } 48 | sha2 = { version = "0.10.9" } 49 | indexmap = { version = "2.9.0" } 50 | strfmt = { version = "0.2.4" } 51 | bytes = { version = "1.10.1" } 52 | lopdf = { git = "https://github.com/lanyeeee/lopdf", features = ["embed_image_webp"] } 53 | uuid = { version = "1.16.0", features = ["v4"] } 54 | image = { version = "0.25.6", features = ["webp"], default-features = false } 55 | zip = { version = "4.0.0", default-features = false } 56 | walkdir = { version = "2.5.0" } 57 | 58 | [profile.release] 59 | strip = true 60 | lto = true 61 | codegen-units = 1 62 | panic = "abort" 63 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // Generated by unplugin-vue-components 4 | // Read more: https://github.com/vuejs/core/pull/3399 5 | // biome-ignore lint: disable 6 | export {} 7 | 8 | /* prettier-ignore */ 9 | declare module 'vue' { 10 | export interface GlobalComponents { 11 | AboutDialog: typeof import('./src/components/AboutDialog.vue')['default'] 12 | ComicCard: typeof import('./src/components/ComicCard.vue')['default'] 13 | CompletedProgresses: typeof import('./src/components/CompletedProgresses.vue')['default'] 14 | DownloadButton: typeof import('./src/components/DownloadButton.vue')['default'] 15 | DownloadedComicCard: typeof import('./src/components/DownloadedComicCard.vue')['default'] 16 | FloatLabelInput: typeof import('./src/components/FloatLabelInput.vue')['default'] 17 | LogViewer: typeof import('./src/components/LogViewer.vue')['default'] 18 | NA: typeof import('naive-ui')['NA'] 19 | NButton: typeof import('naive-ui')['NButton'] 20 | NCard: typeof import('naive-ui')['NCard'] 21 | NCheckbox: typeof import('naive-ui')['NCheckbox'] 22 | NConfigProvider: typeof import('naive-ui')['NConfigProvider'] 23 | NDialog: typeof import('naive-ui')['NDialog'] 24 | NDropdown: typeof import('naive-ui')['NDropdown'] 25 | NEl: typeof import('naive-ui')['NEl'] 26 | NEmpty: typeof import('naive-ui')['NEmpty'] 27 | NIcon: typeof import('naive-ui')['NIcon'] 28 | NInput: typeof import('naive-ui')['NInput'] 29 | NInputGroup: typeof import('naive-ui')['NInputGroup'] 30 | NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel'] 31 | NInputNumber: typeof import('naive-ui')['NInputNumber'] 32 | NMessageProvider: typeof import('naive-ui')['NMessageProvider'] 33 | NModal: typeof import('naive-ui')['NModal'] 34 | NModalProvider: typeof import('naive-ui')['NModalProvider'] 35 | NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] 36 | NPagination: typeof import('naive-ui')['NPagination'] 37 | NProgress: typeof import('naive-ui')['NProgress'] 38 | NRadio: typeof import('naive-ui')['NRadio'] 39 | NRadioGroup: typeof import('naive-ui')['NRadioGroup'] 40 | NSelect: typeof import('naive-ui')['NSelect'] 41 | NTabPane: typeof import('naive-ui')['NTabPane'] 42 | NTabs: typeof import('naive-ui')['NTabs'] 43 | NTooltip: typeof import('naive-ui')['NTooltip'] 44 | NVirtualList: typeof import('naive-ui')['NVirtualList'] 45 | SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default'] 46 | UncompletedProgresses: typeof import('./src/components/UncompletedProgresses.vue')['default'] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/AboutDialog.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 61 | -------------------------------------------------------------------------------- /src-tauri/src/hitomi/result.rs: -------------------------------------------------------------------------------- 1 | use futures::future::join_all; 2 | use indexmap::IndexSet; 3 | use regex_lite::Regex; 4 | 5 | use super::{get_gallery_ids_for_query, get_gallery_ids_from_nozomi}; 6 | 7 | async fn create_get_results_tasks( 8 | sort_by_popularity: bool, 9 | positive_terms: &[String], 10 | ) -> anyhow::Result> { 11 | if sort_by_popularity { 12 | get_gallery_ids_from_nozomi(None, "popular", "all").await 13 | } else if positive_terms.is_empty() { 14 | get_gallery_ids_from_nozomi(None, "index", "all").await 15 | } else { 16 | Ok(IndexSet::new()) 17 | } 18 | } 19 | 20 | pub async fn do_search(query: String, sort_by_popularity: bool) -> anyhow::Result> { 21 | let terms: Vec = query 22 | .trim() 23 | .strip_prefix('?') 24 | .unwrap_or(&query) 25 | .to_lowercase() 26 | .split_whitespace() 27 | .map(|s| s.replace('_', " ")) 28 | .collect(); 29 | 30 | let mut positive_terms = Vec::new(); 31 | let mut negative_terms = Vec::new(); 32 | let negative_pattern = Regex::new(r"^-")?; 33 | 34 | for term in terms { 35 | if negative_pattern.is_match(&term) { 36 | negative_terms.push(negative_pattern.replace(&term, "").to_string()); 37 | } else if !term.is_empty() { 38 | positive_terms.push(term); 39 | } 40 | } 41 | 42 | let get_results_tasks = create_get_results_tasks(sort_by_popularity, &positive_terms); 43 | 44 | let get_positive_results_tasks: Vec<_> = positive_terms 45 | .iter() 46 | .map(|term| async move { 47 | get_gallery_ids_for_query(term) 48 | .await 49 | .unwrap_or_else(|_| IndexSet::new()) 50 | }) 51 | .collect(); 52 | 53 | let get_negative_results_tasks: Vec<_> = negative_terms 54 | .iter() 55 | .map(|term| async move { 56 | get_gallery_ids_for_query(term) 57 | .await 58 | .unwrap_or_else(|_| IndexSet::new()) 59 | }) 60 | .collect(); 61 | 62 | let (results, positive_results, negative_results) = tokio::join!( 63 | get_results_tasks, 64 | join_all(get_positive_results_tasks), 65 | join_all(get_negative_results_tasks) 66 | ); 67 | let mut results = results?; 68 | 69 | for new_results in positive_results { 70 | if results.is_empty() { 71 | results = new_results; 72 | } else { 73 | results.retain(|id| new_results.contains(id)); 74 | } 75 | } 76 | 77 | for new_results in negative_results { 78 | results.retain(|id| !new_results.contains(id)); 79 | } 80 | 81 | Ok(results) 82 | } 83 | -------------------------------------------------------------------------------- /src-tauri/src/hitomi/gg.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::OnceLock}; 2 | 3 | use chrono::Utc; 4 | use regex_lite::Regex; 5 | 6 | use crate::hitomi_client::HitomiClient; 7 | 8 | pub struct GG { 9 | pub last_retrieval: Option, 10 | pub m_default: i32, 11 | pub m_map: HashMap, 12 | pub b: String, 13 | } 14 | 15 | impl GG { 16 | fn new() -> Self { 17 | Self { 18 | last_retrieval: None, 19 | m_default: 0, 20 | m_map: HashMap::new(), 21 | b: String::new(), 22 | } 23 | } 24 | 25 | pub fn inst() -> &'static tokio::sync::Mutex { 26 | static INSTANCE: OnceLock> = OnceLock::new(); 27 | INSTANCE.get_or_init(|| tokio::sync::Mutex::new(GG::new())) 28 | } 29 | 30 | pub async fn refresh(&mut self) -> anyhow::Result<()> { 31 | if let Some(last_retrieval) = self.last_retrieval { 32 | if last_retrieval + 60000 >= Utc::now().timestamp_millis() { 33 | return Ok(()); 34 | } 35 | } 36 | 37 | let client = HitomiClient::get_api_client(); 38 | 39 | let request = client 40 | .read() 41 | .get("https://ltn.gold-usergeneratedcontent.net/gg.js"); 42 | let body = request.send().await?.text().await?; 43 | 44 | let re_default = Regex::new(r"var o = (\d)")?; 45 | let re_o = Regex::new(r"o = (\d); break;")?; 46 | let re_case = Regex::new(r"case (\d+):")?; 47 | let re_b = Regex::new(r"b: '(.+)'")?; 48 | 49 | if let Some(cap) = re_default.captures(&body) { 50 | self.m_default = cap[1].parse()?; 51 | } 52 | 53 | if let Some(cap) = re_o.captures(&body) { 54 | let o: i32 = cap[1].parse()?; 55 | self.m_map.clear(); 56 | 57 | for cap in re_case.captures_iter(&body) { 58 | let case: i32 = cap[1].parse()?; 59 | self.m_map.insert(case, o); 60 | } 61 | } 62 | 63 | if let Some(cap) = re_b.captures(&body) { 64 | self.b = cap[1].to_string(); 65 | } 66 | 67 | self.last_retrieval = Some(Utc::now().timestamp_millis()); 68 | Ok(()) 69 | } 70 | 71 | pub async fn m(&mut self, g: i32) -> anyhow::Result { 72 | self.refresh().await?; 73 | Ok(self.m_map.get(&g).copied().unwrap_or(self.m_default)) 74 | } 75 | 76 | pub async fn b(&mut self) -> anyhow::Result { 77 | self.refresh().await?; 78 | Ok(self.b.clone()) 79 | } 80 | 81 | #[allow(clippy::unused_self)] 82 | pub fn s(&self, h: &str) -> anyhow::Result { 83 | let re = Regex::new(r"(..)(.)$")?; 84 | if let Some(caps) = re.captures(h) { 85 | let combined = format!("{}{}", &caps[2], &caps[1]); 86 | let num = i32::from_str_radix(&combined, 16)?; 87 | Ok(num.to_string()) 88 | } else { 89 | Err(anyhow::anyhow!("Invalid hash format")) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # 📚Hitomi下载器 7 | 8 | 一个带GUI的用于 hitomi hitomi.la 的多线程下载器 9 | 10 | [English](./README.md) / 简体中文 11 | 12 | ## 📥 快速下载 13 | 14 | [Release页面](https://github.com/lanyeeee/hitomi-downloader/releases)提供了预编译的安装包,直接下载即可使用 15 | 16 | **如果本项目对你有帮助,欢迎点个 Star ⭐ 支持!你的支持是我持续更新维护的动力 🙏** 17 | 18 | ## ✨ 主要特性 19 | 20 | | 特性 | 说明 | 21 | | --------------- | ------------------------------------------------------------ | 22 | | 🖼️图形界面 | 基于 [Tauri](https://www.google.com/url?sa=E&q=https%3A%2F%2Fv2.tauri.app%2Fstart%2F) 构建,轻量、简洁、易用 | 23 | | ⚡多线程下载 | 最大化下载速度 | 24 | | 📂漫画导出 | 一键将下载内容导出为通用的 PDF 或 CBZ 格式 | 25 | | 🌐国际化 | 内置多语言支持系统 (i18n) | 26 | | 🗂️自定义目录结构 | 高度可定制的目录结构和命名规则,支持类型、作者、语言等字段,彻底告别手动整理的烦恼 | 27 | 28 | ## 🖥️图形界面 29 | 30 | ![image](https://github.com/user-attachments/assets/fd93fd2f-db16-43b6-86cf-aa643eb572c8) 31 | ![image](https://github.com/user-attachments/assets/81a859f2-2a06-4eca-b45f-4f6555cc62c0) 32 | 33 | ## 📖 使用方法 34 | 35 | 1. 使用`漫画搜索`搜索关键词 36 | 2. 直接点击卡片上的`一键下载` 或者 点击封面或标题进入`漫画详情`,里面也有`一键下载` 37 | 3. 下载完成后点击`打开目录`按钮查看结果 38 | 39 | **顺带一提,你可以在`本地库存`导出为pdf/cbz(zip)** 40 | 41 | 📹 下面的视频是完整使用流程,**没有H内容,请放心观看** 42 | 43 | https://github.com/user-attachments/assets/d2d0e577-c074-41ca-996f-445d52e2cce5 44 | 45 | 46 | 47 | ## ⚠️关于被杀毒软件误判为病毒 48 | 49 | 对于个人开发的项目来说,这个问题几乎是无解的(~~需要购买数字证书给软件签名,甚至给杀毒软件交保护费~~) 50 | 我能想到的解决办法只有: 51 | 52 | 1. 根据下面的**如何构建(build)**,自行编译 53 | 2. 希望你相信我的承诺,我承诺你在[Release页面](https://github.com/lanyeeee/hitomi-downloader/releases)下载到的所有东西都是安全的 54 | 55 | ## 🛠️如何构建(build) 56 | 57 | 构建非常简单,一共就3条命令 58 | ~~前提是你已经安装了Rust、Node、pnpm~~ 59 | 60 | #### 📋前提 61 | 62 | - [Rust](https://www.rust-lang.org/tools/install) 63 | - [Node](https://nodejs.org/en) 64 | - [pnpm](https://pnpm.io/installation) 65 | 66 | #### 📝步骤 67 | 68 | #### 1. 克隆本仓库 69 | 70 | ``` 71 | git clone https://github.com/lanyeeee/hitomi-downloader.git 72 | ``` 73 | 74 | #### 2.安装依赖 75 | 76 | ``` 77 | cd hitomi-downloader 78 | pnpm install 79 | ``` 80 | 81 | #### 3.构建(build) 82 | 83 | ``` 84 | pnpm tauri build 85 | ``` 86 | 87 | ## 🌐 添加新语言 88 | 89 | 欢迎帮助翻译本项目!如果您想要为项目添加新语言,请参考 [PR #1](https://github.com/lanyeeee/hitomi-downloader/pull/1) 的实现方式。这个PR展示了如何添加`英语(美国)`的本地化文件 90 | 91 | 添加新语言的主要步骤: 92 | 93 | 1. 在 `src/locales` 目录下创建新的语言文件 94 | 2. 参照现有语言文件的格式和键值对进行翻译 95 | 3. 在 `src/locales/index.ts` 中注册新语言 96 | 4. 提交PR 97 | 98 | ## 🤝提交PR 99 | 100 | **PR请提交至`develop`分支** 101 | 102 | **如果想新加一个功能,请先开个`issue`或`discussion`讨论一下,避免无效工作** 103 | 104 | 其他情况的PR欢迎直接提交,比如: 105 | 106 | 1. 🔧 对原有功能的改进 107 | 2. 🐛 修复BUG 108 | 3. 🌐 添加新的语言支持 109 | 4. ⚡ 使用更轻量的库实现原有功能 110 | 5. 📝 修订文档 111 | 6. ⬆️ 升级、更新依赖的PR也会被接受 112 | 113 | ## ⚠️免责声明 114 | 115 | - 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险 116 | - 作者不对使用本工具导致的任何损失、法律纠纷或其他后果负责 117 | - 作者不对用户使用本工具的行为负责,包括但不限于用户违反法律或任何第三方权益的行为 118 | 119 | ## 感谢 120 | 121 | [Pupil](https://github.com/tom5079/Pupil) 122 | 123 | ## 💬其他 124 | 125 | 任何使用中遇到的问题、任何希望添加的功能,都欢迎提交issue或开discussion交流,我会尽力解决 -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | mod config; 3 | mod download_manager; 4 | mod errors; 5 | mod events; 6 | mod export; 7 | mod extensions; 8 | mod hitomi; 9 | mod hitomi_client; 10 | mod logger; 11 | mod types; 12 | mod utils; 13 | 14 | use anyhow::Context; 15 | use config::Config; 16 | use download_manager::DownloadManager; 17 | use events::{DownloadSpeedEvent, DownloadTaskEvent, ExportCbzEvent, ExportPdfEvent, LogEvent}; 18 | use hitomi_client::HitomiClient; 19 | use parking_lot::RwLock; 20 | use tauri::{Manager, Wry}; 21 | 22 | use crate::commands::*; 23 | 24 | fn generate_context() -> tauri::Context { 25 | tauri::generate_context!() 26 | } 27 | 28 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 29 | pub fn run() { 30 | let builder = tauri_specta::Builder::::new() 31 | .commands(tauri_specta::collect_commands![ 32 | greet, 33 | get_config, 34 | save_config, 35 | search, 36 | get_page, 37 | get_comic, 38 | create_download_task, 39 | pause_download_task, 40 | resume_download_task, 41 | cancel_download_task, 42 | get_downloaded_comics, 43 | export_pdf, 44 | export_cbz, 45 | get_search_suggestions, 46 | get_logs_dir_size, 47 | show_path_in_file_manager, 48 | get_cover_data, 49 | get_synced_comic, 50 | ]) 51 | .events(tauri_specta::collect_events![ 52 | LogEvent, 53 | DownloadTaskEvent, 54 | DownloadSpeedEvent, 55 | ExportPdfEvent, 56 | ExportCbzEvent, 57 | ]); 58 | 59 | #[cfg(debug_assertions)] 60 | builder 61 | .export( 62 | specta_typescript::Typescript::default() 63 | .bigint(specta_typescript::BigIntExportBehavior::Number) 64 | .formatter(specta_typescript::formatter::prettier) 65 | .header("// @ts-nocheck"), // disable typescript checks 66 | "../src/bindings.ts", 67 | ) 68 | .expect("Failed to export typescript bindings"); 69 | 70 | tauri::Builder::default() 71 | .plugin(tauri_plugin_dialog::init()) 72 | .plugin(tauri_plugin_opener::init()) 73 | .invoke_handler(builder.invoke_handler()) 74 | .setup(move |app| { 75 | utils::APP_HANDLE.get_or_init(|| app.handle().clone()); 76 | 77 | builder.mount_events(app); 78 | 79 | let app_data_dir = app 80 | .path() 81 | .app_data_dir() 82 | .context("get app_data_dir failed")?; 83 | 84 | std::fs::create_dir_all(&app_data_dir).context(format!( 85 | "create app_data_dir `{}` failed", 86 | app_data_dir.display() 87 | ))?; 88 | 89 | let config = RwLock::new(Config::new(app.handle())?); 90 | app.manage(config); 91 | 92 | let hitomi_client = HitomiClient::new(app.handle().clone()); 93 | app.manage(hitomi_client); 94 | 95 | let download_manager = DownloadManager::new(app.handle()); 96 | app.manage(download_manager); 97 | 98 | logger::init(app.handle())?; 99 | 100 | Ok(()) 101 | }) 102 | .run(generate_context()) 103 | .expect("error while running tauri application"); 104 | } 105 | -------------------------------------------------------------------------------- /src-tauri/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | use tauri::{AppHandle, Manager}; 6 | 7 | use crate::types::{DownloadFormat, ProxyMode}; 8 | 9 | #[derive(Debug, Clone, Serialize, Deserialize, Type)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct Config { 12 | pub download_dir: PathBuf, 13 | pub export_dir: PathBuf, 14 | pub enable_file_logger: bool, 15 | pub download_format: DownloadFormat, 16 | pub dir_fmt: String, 17 | pub proxy_host: String, 18 | pub proxy_mode: ProxyMode, 19 | pub proxy_port: u16, 20 | } 21 | 22 | impl Config { 23 | pub fn new(app: &AppHandle) -> anyhow::Result { 24 | let app_data_dir = app.path().app_data_dir()?; 25 | let config_path = app_data_dir.join("config.json"); 26 | 27 | let config = if config_path.exists() { 28 | let config_string = std::fs::read_to_string(config_path)?; 29 | match serde_json::from_str(&config_string) { 30 | // if deserialization succeeds, use the deserialized Config 31 | Ok(config) => config, 32 | // Otherwise, merge the default configuration with the configuration already in the file 33 | // to avoid resetting all configuration items when new configuration items are added in the new version 34 | // after the user upgrades to the new version 35 | Err(_) => Config::merge_config(&config_string, &app_data_dir), 36 | } 37 | } else { 38 | Config::default(&app_data_dir) 39 | }; 40 | config.save(app)?; 41 | Ok(config) 42 | } 43 | 44 | pub fn save(&self, app: &AppHandle) -> anyhow::Result<()> { 45 | let app_data_dir = app.path().app_data_dir()?; 46 | let config_path = app_data_dir.join("config.json"); 47 | let config_string = serde_json::to_string_pretty(self)?; 48 | std::fs::write(config_path, config_string)?; 49 | Ok(()) 50 | } 51 | 52 | fn merge_config(config_string: &str, app_data_dir: &Path) -> Config { 53 | let Ok(mut json_value) = serde_json::from_str::(config_string) else { 54 | return Config::default(app_data_dir); 55 | }; 56 | let serde_json::Value::Object(ref mut map) = json_value else { 57 | return Config::default(app_data_dir); 58 | }; 59 | let Ok(default_config_value) = serde_json::to_value(Config::default(app_data_dir)) else { 60 | return Config::default(app_data_dir); 61 | }; 62 | let serde_json::Value::Object(default_map) = default_config_value else { 63 | return Config::default(app_data_dir); 64 | }; 65 | for (key, value) in default_map { 66 | map.entry(key).or_insert(value); 67 | } 68 | let Ok(config) = serde_json::from_value(json_value) else { 69 | return Config::default(app_data_dir); 70 | }; 71 | config 72 | } 73 | 74 | fn default(app_data_dir: &Path) -> Config { 75 | Config { 76 | download_dir: app_data_dir.join("download"), 77 | export_dir: app_data_dir.join("export"), 78 | enable_file_logger: true, 79 | download_format: DownloadFormat::Webp, 80 | dir_fmt: "{title} - {id}".to_string(), 81 | proxy_mode: ProxyMode::System, 82 | proxy_host: "127.0.0.1".to_string(), 83 | proxy_port: 7890, 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report issues you encountered 3 | labels: [bug] 4 | title: "[Bug] Edit Me! Issues with unmodified title will be automatically closed" 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Prerequisites 9 | options: 10 | - label: I have tried the latest version and I can confirm that this issue still exists. 11 | required: true 12 | - label: I will write this issue with a collaborative spirit, providing as much detail as possible, not just an error message or a screenshot. 13 | required: true 14 | - type: textarea 15 | id: desc 16 | attributes: 17 | label: Bug Description 18 | description: What happened? What's the current situation? Which Comic? Can the issue be consistently reproduced? 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: reproduction 23 | attributes: 24 | label: Steps to Reproduce 25 | description: This is the **most important** part of the entire issue. Please refer to [this issue](https://github.com/lanyeeee/hitomi-downloader/issues/4) for an example of how to write detailed reproduction steps. 26 | placeholder: Reproduction Steps are the biggest factor affecting issue processing efficiency. Without detailed reproduction steps, the issue will be difficult to locate, and developers will need to spend a lot of time communicating back and forth to pinpoint the issue 27 | validations: 28 | required: true 29 | - type: checkboxes 30 | attributes: 31 | label: Reproduction Steps are Crucial 32 | description: Providing only an error message or a screenshot without reproduction steps makes the issue difficult to locate and, therefore, difficult to solve. Detailed reproduction steps also show respect for the maintainers' work. 33 | options: 34 | - label: I have referred to [this issue](https://github.com/lanyeeee/hitomi-downloader/issues/4) and have written detailed steps in the **Steps to Reproduce** section. 35 | required: true 36 | - type: textarea 37 | id: expected 38 | attributes: 39 | label: Expected Behavior 40 | description: What did you expect to happen? 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: actual 45 | attributes: 46 | label: Actual Behavior 47 | description: What actually happened? 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: logs 52 | attributes: 53 | label: Logs 54 | description: Please provide any relevant logs. 55 | placeholder: | 56 | If the logs are short, you can paste them here directly. 57 | 58 | If the logs are very long, please save them to a .txt file and upload it by dragging it into the text box or clicking the add files button below. 59 | validations: 60 | required: false 61 | - type: textarea 62 | id: media 63 | attributes: 64 | label: Screenshots or Screen Recordings 65 | description: Screenshots or screen recordings that demonstrate the issue. 66 | placeholder: You can drag and drop files here or click the add files button below. 67 | - type: input 68 | id: version 69 | attributes: 70 | label: Version(Check by clicking the "About" button) 71 | placeholder: v0.1.0 72 | validations: 73 | required: true 74 | - type: textarea 75 | id: other 76 | attributes: 77 | label: Additional Context 78 | description: Add any other context about the problem here. 79 | placeholder: Any other supplementary information. 80 | validations: 81 | required: false -------------------------------------------------------------------------------- /src/locales/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "中文(简体)", 3 | "common": { 4 | "artist": "作者", 5 | "group": "团队", 6 | "series": "系列", 7 | "character": "角色", 8 | "type": "类型", 9 | "language": "语言", 10 | "tag": "标签", 11 | "open_directory": "打开目录", 12 | "export_pdf": "导出pdf", 13 | "export_cbz": "导出cbz", 14 | "download_directory": "下载目录", 15 | "export_directory": "导出目录", 16 | "select_all": "全选", 17 | "continue": "继续", 18 | "pause": "暂停", 19 | "cancel": "取消" 20 | }, 21 | "app_content": { 22 | "save_config_success": "保存配置成功", 23 | "logs_directory_size_too_big": "日志目录大小超过50MB", 24 | "log_cleanup_reminder": "请及时清理日志文件", 25 | "click_top_center": "点击中上方的", 26 | "there_is": "里边有", 27 | "you_can_also_uncheck": "你也可以在里边取消勾选", 28 | "this_will_disable_file_logging": "这样将不再产生文件日志" 29 | }, 30 | "log_viewer": { 31 | "name": "日志", 32 | "logs_directory_size": "日志目录总大小", 33 | "search_placeholder": "搜索日志...", 34 | "open_logs_directory": "打开日志目录", 35 | "enable_file_logging": "输出文件日志", 36 | "clear_log_viewer": "清空日志浏览器" 37 | }, 38 | "about_dialog": { 39 | "name": "关于", 40 | "support_message": "如果本项目对你有帮助,欢迎来 {github} 点个Star⭐支持!", 41 | "motivation_message": "你的支持是我持续更新维护的动力🙏", 42 | "software_version": "软件版本", 43 | "source_code_repository": "开源地址", 44 | "feedback": "问题反馈" 45 | }, 46 | "settings_dialog": { 47 | "name": "配置", 48 | "download_format": "下载格式", 49 | "avif_warning": "无法导出为pdf", 50 | "proxy_mode": "代理模式", 51 | "system_proxy": "系统代理", 52 | "no_proxy": "直连", 53 | "custom_proxy": "自定义", 54 | "open_config_directory": "打开配置目录", 55 | "directory_format": { 56 | "name": "文件夹格式", 57 | "directory_level_tips": "可以用斜杠 {slash} 来划分目录层级", 58 | "available_fields": "可用字段", 59 | "id": "漫画ID", 60 | "title": "漫画标题", 61 | "type": "漫画类型(例如doujinshi, manga, imageset)", 62 | "artists": "作者", 63 | "language": "语言名(例如english, chinese)", 64 | "language_localname": "本地化语言名(例如English, 中文)", 65 | "for_example": "例如格式", 66 | "directory_result": "下载漫画2829145会产生三层文件夹,分别是" 67 | } 68 | }, 69 | "search_pane": { 70 | "name": "漫画搜索", 71 | "search_by_query": "关键词", 72 | "search_by_id": "漫画ID (链接也行)", 73 | "searching": "搜索中...", 74 | "searching_warning": "有搜索正在进行,请稍后再试", 75 | "search_success": "搜索成功", 76 | "comic_id_invalid": "漫画ID格式错误", 77 | "enter_comic_id_or_url": "请输入漫画ID或链接" 78 | }, 79 | "downloaded_pane": { 80 | "name": "本地库存", 81 | "empty_description": "请先下载漫画", 82 | "pdf_exporting": "正在导出pdf", 83 | "pdf_export_error": "pdf导出失败", 84 | "pdf_exported": "pdf导出完成", 85 | "cbz_exporting": "正在导出cbz", 86 | "cbz_export_error": "cbz导出失败", 87 | "cbz_exported": "cbz导出完成" 88 | }, 89 | "comic_pane": { 90 | "name": "漫画详情", 91 | "other_language": "其他语言", 92 | "empty_description": "请先选择漫画(@:search_pane.name @:downloaded_pane.name)", 93 | "related": "相关漫画" 94 | }, 95 | "downloading_pane": { 96 | "name": "下载列表", 97 | "pending": "排队中", 98 | "downloading": "下载中", 99 | "paused": "已暂停", 100 | "cancelled": "已取消", 101 | "completed": "下载完成", 102 | "failed": "下载失败", 103 | "download_speed": "下载速度" 104 | }, 105 | "uncompleted_progresses": { 106 | "name": "未完成", 107 | "usage_tips": "左键拖动进行框选,右键打开菜单,双击暂停/继续" 108 | }, 109 | "completed_progresses": { 110 | "name": "已完成" 111 | }, 112 | "download_button": { 113 | "quick_download": "一键下载", 114 | "resume_download": "继续下载", 115 | "download_again": "重新下载" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const EffectScope: typeof import('vue')['EffectScope'] 10 | const computed: typeof import('vue')['computed'] 11 | const createApp: typeof import('vue')['createApp'] 12 | const customRef: typeof import('vue')['customRef'] 13 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 14 | const defineComponent: typeof import('vue')['defineComponent'] 15 | const effectScope: typeof import('vue')['effectScope'] 16 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 17 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 18 | const h: typeof import('vue')['h'] 19 | const inject: typeof import('vue')['inject'] 20 | const isProxy: typeof import('vue')['isProxy'] 21 | const isReactive: typeof import('vue')['isReactive'] 22 | const isReadonly: typeof import('vue')['isReadonly'] 23 | const isRef: typeof import('vue')['isRef'] 24 | const markRaw: typeof import('vue')['markRaw'] 25 | const nextTick: typeof import('vue')['nextTick'] 26 | const onActivated: typeof import('vue')['onActivated'] 27 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 28 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 29 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 30 | const onDeactivated: typeof import('vue')['onDeactivated'] 31 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 32 | const onMounted: typeof import('vue')['onMounted'] 33 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 34 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 35 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 36 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 37 | const onUnmounted: typeof import('vue')['onUnmounted'] 38 | const onUpdated: typeof import('vue')['onUpdated'] 39 | const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] 40 | const provide: typeof import('vue')['provide'] 41 | const reactive: typeof import('vue')['reactive'] 42 | const readonly: typeof import('vue')['readonly'] 43 | const ref: typeof import('vue')['ref'] 44 | const resolveComponent: typeof import('vue')['resolveComponent'] 45 | const shallowReactive: typeof import('vue')['shallowReactive'] 46 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 47 | const shallowRef: typeof import('vue')['shallowRef'] 48 | const toRaw: typeof import('vue')['toRaw'] 49 | const toRef: typeof import('vue')['toRef'] 50 | const toRefs: typeof import('vue')['toRefs'] 51 | const toValue: typeof import('vue')['toValue'] 52 | const triggerRef: typeof import('vue')['triggerRef'] 53 | const unref: typeof import('vue')['unref'] 54 | const useAttrs: typeof import('vue')['useAttrs'] 55 | const useCssModule: typeof import('vue')['useCssModule'] 56 | const useCssVars: typeof import('vue')['useCssVars'] 57 | const useDialog: typeof import('naive-ui')['useDialog'] 58 | const useId: typeof import('vue')['useId'] 59 | const useLoadingBar: typeof import('naive-ui')['useLoadingBar'] 60 | const useMessage: typeof import('naive-ui')['useMessage'] 61 | const useModel: typeof import('vue')['useModel'] 62 | const useNotification: typeof import('naive-ui')['useNotification'] 63 | const useSlots: typeof import('vue')['useSlots'] 64 | const useTemplateRef: typeof import('vue')['useTemplateRef'] 65 | const watch: typeof import('vue')['watch'] 66 | const watchEffect: typeof import('vue')['watchEffect'] 67 | const watchPostEffect: typeof import('vue')['watchPostEffect'] 68 | const watchSyncEffect: typeof import('vue')['watchSyncEffect'] 69 | } 70 | // for type re-export 71 | declare global { 72 | // @ts-ignore 73 | export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' 74 | import('vue') 75 | } 76 | -------------------------------------------------------------------------------- /src/locales/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "English(US)", 3 | "common": { 4 | "artist": "Artist", 5 | "group": "Group", 6 | "series": "Series", 7 | "character": "Character", 8 | "type": "Type", 9 | "language": "Language", 10 | "tag": "Tag", 11 | "open_directory": "Open Folder", 12 | "export_pdf": "Export PDF", 13 | "export_cbz": "Export CBZ", 14 | "download_directory": "DL Folder", 15 | "export_directory": "Export Folder", 16 | "select_all": "Select All", 17 | "continue": "Continue", 18 | "pause": "Pause", 19 | "cancel": "Cancel" 20 | }, 21 | "app_content": { 22 | "save_config_success": "Config saved", 23 | "logs_directory_size_too_big": "Log folder exceeds 50MB", 24 | "log_cleanup_reminder": "Please clean up log files soon", 25 | "click_top_center": "Click the top-center", 26 | "there_is": "There is", 27 | "you_can_also_uncheck": "You can uncheck", 28 | "this_will_disable_file_logging": "This will stop file logging" 29 | }, 30 | "log_viewer": { 31 | "name": "Log", 32 | "logs_directory_size": "Log folder size", 33 | "search_placeholder": "Search logs...", 34 | "open_logs_directory": "Open Log Folder", 35 | "enable_file_logging": "Enable File Logging", 36 | "clear_log_viewer": "Clear Log Viewer" 37 | }, 38 | "about_dialog": { 39 | "name": "About", 40 | "support_message": "Enjoying this project? feel free to support it with a {github} Star⭐!", 41 | "motivation_message": "Your support motivates me to keep updating and maintaining🙏", 42 | "software_version": "Version", 43 | "source_code_repository": "Source Code", 44 | "feedback": "Feedback" 45 | }, 46 | "settings_dialog": { 47 | "name": "Settings", 48 | "download_format": "Download Format", 49 | "avif_warning": "Can not export to PDF", 50 | "proxy_mode": "Proxy Mode", 51 | "system_proxy": "System", 52 | "no_proxy": "No Proxy", 53 | "custom_proxy": "Custom", 54 | "open_config_directory": "Open Config Folder", 55 | "directory_format": { 56 | "name": "Folder Format", 57 | "directory_level_tips": "Use the forward slash {slash} to separate directory levels", 58 | "available_fields": "Available Fields", 59 | "id": "Comic ID", 60 | "title": "Comic Title", 61 | "type": "Comic Type (e.g., doujinshi, manga, imageset)", 62 | "artists": "Comic Authors", 63 | "language": "Language Name", 64 | "language_localname": "Localized Language Name", 65 | "for_example": "For example", 66 | "directory_result": "Download comic 2829145 will create 3 nested folders:" 67 | } 68 | }, 69 | "search_pane": { 70 | "name": "Search", 71 | "search_by_query": "Keywords", 72 | "search_by_id": "Comic ID (Link also works)", 73 | "searching": "Searching...", 74 | "searching_warning": "Search is processing, please try again later", 75 | "search_success": "Search done", 76 | "comic_id_invalid": "Invalid Comic ID format", 77 | "enter_comic_id_or_url": "Please enter Comic ID or URL" 78 | }, 79 | "downloaded_pane": { 80 | "name": "Local", 81 | "empty_description": "Please download comics first", 82 | "pdf_exporting": "Exporting PDF", 83 | "pdf_export_error": "PDF export failed", 84 | "pdf_exported": "PDF exported", 85 | "cbz_exporting": "Exporting CBZ", 86 | "cbz_export_error": "CBZ export failed", 87 | "cbz_exported": "CBZ exported" 88 | }, 89 | "comic_pane": { 90 | "name": "Comic", 91 | "other_language": "Other Languages", 92 | "empty_description": "Please select a comic (@:search_pane.name @:downloaded_pane.name)", 93 | "related": "Related Comics" 94 | }, 95 | "downloading_pane": { 96 | "name": "Downloads", 97 | "pending": "Pending", 98 | "downloading": "Downloading", 99 | "paused": "Paused", 100 | "cancelled": "Cancelled", 101 | "completed": "Completed", 102 | "failed": "Failed", 103 | "download_speed": "Download Speed" 104 | }, 105 | "uncompleted_progresses": { 106 | "name": "Downloading", 107 | "usage_tips": "Drag with left mouse button to select, right click for menu, double click to pause/continue" 108 | }, 109 | "completed_progresses": { 110 | "name": "Completed" 111 | }, 112 | "download_button": { 113 | "quick_download": "Download", 114 | "resume_download": "Resume", 115 | "download_again": "Re-download" 116 | } 117 | } -------------------------------------------------------------------------------- /src/components/ComicCard.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # 📚 Hitomi Downloader 7 | 8 | A GUI-based multi-threaded downloader for hitomi hitomi.la 9 | 10 | English / [简体中文](./README.zh-CN.md) 11 | 12 | ## 📥 Download 13 | 14 | Pre-compiled packages are available on the [Releases page](https://github.com/lanyeeee/hitomi-downloader/releases). Just download and use. 15 | 16 | **Enjoying this project? feel free to support it with a GitHub Star⭐! Your support motivates me to keep updating and maintaining🙏** 17 | 18 | ## ✨ Features 19 | 20 | | Feature | Description | 21 | | ---------------------------------- | ------------------------------------------------------------ | 22 | | 🖼️ GUI | Built with [Tauri](https://v2.tauri.app/start/), lightweight, clean, and easy to use. | 23 | | ⚡ Multi-threaded Downloading | Maximize download speed. | 24 | | 📂 Export | One-click export to universal PDF or CBZ formats. | 25 | | 🌐 Internationalization | Built-in multi-language support system (i18n). | 26 | | 🗂️ Customizable Directory Structure | Highly customizable directory structure and naming rules, supporting fields like type, author, language, etc. Say goodbye to the hassle of manual organization. | 27 | 28 | ## 🖥️ GUI 29 | 30 | ![image](https://github.com/user-attachments/assets/fd93fd2f-db16-43b6-86cf-aa643eb572c8) 31 | ![image](https://github.com/user-attachments/assets/81a859f2-2a06-4eca-b45f-4f6555cc62c0) 32 | 33 | 34 | ## 📖 How to Use 35 | 36 | 1. In `Search` tab search for keywords. 37 | 2. Click the `Download` button directly on the comic card, or click the cover/title to go to the `Comic` tab, where you'll also find a `Download` button. 38 | 3. After downloading, click the `Open Folder` button to check the results. 39 | 40 | **By the way, you can export to PDF/CBZ(ZIP) in the `Local` tab.** 41 | 42 | 📹 The video below demonstrates the full usage process. **It's Safe For Work, so feel free to watch.** 43 | 44 | https://github.com/user-attachments/assets/d2d0e577-c074-41ca-996f-445d52e2cce5 45 | 46 | 47 | 48 | ## ⚠️ About Antivirus False Positives 49 | 50 | For individually developed projects, this issue is almost unavoidable (~~because it requires purchasing a digital certificate for software signing, or even paying protection fees to antivirus companies~~). 51 | The only solutions I can think of are: 52 | 53 | 1. Compile it yourself according to the **How to Build** instructions below. 54 | 2. Trust my promise that everything you download from the [Release page](https://github.com/lanyeeee/hitomi-downloader/releases) is safe. 55 | 56 | ## 🛠️ How to Build 57 | 58 | Building is very simple, just 3 commands. 59 | ~~Prerequisite you have Rust, Node, and pnpm installed.~~ 60 | 61 | #### 📋 Prerequisites 62 | 63 | - [Rust](https://www.rust-lang.org/tools/install) 64 | - [Node](https://nodejs.org/en) 65 | - [pnpm](https://pnpm.io/installation) 66 | 67 | #### 📝 Steps 68 | 69 | #### 1. Clone this repository 70 | 71 | ``` 72 | git clone https://github.com/lanyeeee/hitomi-downloader.git 73 | ``` 74 | 75 | #### 2. Install dependencies 76 | 77 | ``` 78 | cd hitomi-downloader 79 | pnpm install 80 | ``` 81 | 82 | #### 3. Build 83 | 84 | ``` 85 | pnpm tauri build 86 | ``` 87 | 88 | ## 🌐 Adding a New Language 89 | 90 | Help with translating this project is welcome! If you want to add a new language, please refer to the implementation in [PR #1](https://github.com/lanyeeee/hitomi-downloader/pull/1). This PR shows how to add the localization files for `en-us`. 91 | 92 | Main steps for adding a new language: 93 | 94 | 1. Create a new language file in the `src/locales` directory. 95 | 2. Translate the key-value pairs, following the format of the existing language files. 96 | 3. Register the new language in `src/locales/index.ts`. 97 | 4. Submit a PR. 98 | 99 | ## 🤝 Submitting PR 100 | 101 | **Please submit Pull Requests to the `develop` branch.** 102 | 103 | **If you want to add a new feature, please open an `issue` or `discussion` first to talk about it. This helps avoid wasted effort.** 104 | 105 | For other cases, feel free to submit a PR directly, for example: 106 | 107 | 1. 🔧 Improvements to existing features. 108 | 2. 🐛 Bug fixes. 109 | 3. 🌐 Adding new language support. 110 | 4. ⚡ Using a more lightweight library to implement existing features. 111 | 5. 📝 Documentation revisions. 112 | 6. ⬆️ Pull Request for upgrading/updating dependencies will also be accepted. 113 | 114 | ## ⚠️ Disclaimer 115 | 116 | - This tool is intended for learning, research, and communication purposes only. Users should assume all risks associated with its use. 117 | - The author is not responsible for any losses, legal disputes, or other consequences resulting from the use of this tool. 118 | - The author is not responsible for the user's actions while using this tool, including but not limited to actions that violate laws or the rights of any third party. 119 | 120 | ## Thanks 121 | 122 | [Pupil](https://github.com/tom5079/Pupil) 123 | 124 | ## 💬 Other 125 | 126 | Any problems encountered during use or any features you would like to add, welcome to open a `issue` or `discussion`. I will do my best to address them. 127 | -------------------------------------------------------------------------------- /src/AppContent.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 141 | 154 | -------------------------------------------------------------------------------- /src/components/DownloadedComicCard.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 150 | -------------------------------------------------------------------------------- /src/panes/DownloadingPane.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 175 | 176 | 185 | -------------------------------------------------------------------------------- /src/components/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 130 | -------------------------------------------------------------------------------- /src/components/LogViewer.vue: -------------------------------------------------------------------------------- 1 | 139 | 140 | 188 | -------------------------------------------------------------------------------- /src/panes/DownloadedPane.vue: -------------------------------------------------------------------------------- 1 | 162 | 163 | 190 | -------------------------------------------------------------------------------- /.github/workflows/Publish.yml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | REPO_NAME: hitomi-downloader 10 | 11 | jobs: 12 | get-version: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | VERSION: ${{ steps.get_version.outputs.VERSION }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Get version number 20 | id: get_version 21 | run: | 22 | VERSION=$(jq -r '.version' src-tauri/tauri.conf.json) 23 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 24 | 25 | windows-build: 26 | needs: get-version 27 | env: 28 | VERSION: ${{ needs.get-version.outputs.VERSION }} 29 | outputs: 30 | VERSION: ${{ env.VERSION }} 31 | runs-on: windows-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Setup node 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: lts/* 39 | 40 | - name: Install Rust stable 41 | uses: dtolnay/rust-toolchain@stable 42 | 43 | - name: Install pnpm 44 | uses: pnpm/action-setup@v4 45 | with: 46 | run_install: false 47 | 48 | - name: Install frontend dependencies 49 | run: pnpm install 50 | 51 | - name: Build tauri app 52 | uses: tauri-apps/tauri-action@v0 53 | 54 | - name: Create artifacts directory 55 | run: mkdir -p artifacts 56 | 57 | - name: Copy nsis to release assets 58 | run: cp src-tauri/target/release/bundle/nsis/${{ env.REPO_NAME }}_${{ env.VERSION }}_x64-setup.exe artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_windows_x64.exe 59 | 60 | - name: Zip portable to release assets 61 | run: | 62 | cd src-tauri/target/release 63 | 7z a -tzip ../../../artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_windows_x64_portable.zip ${{ env.REPO_NAME }}.exe 64 | 65 | - name: Upload artifacts 66 | uses: actions/upload-artifact@v4 67 | with: 68 | name: windows-assets 69 | path: artifacts/* 70 | 71 | linux-build: 72 | needs: get-version 73 | env: 74 | VERSION: ${{ needs.get-version.outputs.VERSION }} 75 | outputs: 76 | VERSION: ${{ env.VERSION }} 77 | runs-on: ubuntu-24.04 78 | steps: 79 | - name: install dependencies 80 | run: | 81 | sudo apt-get update 82 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 83 | - uses: actions/checkout@v4 84 | 85 | - name: Setup node 86 | uses: actions/setup-node@v4 87 | with: 88 | node-version: lts/* 89 | 90 | - name: Install Rust stable 91 | uses: dtolnay/rust-toolchain@stable 92 | 93 | - name: Install pnpm 94 | uses: pnpm/action-setup@v4 95 | with: 96 | run_install: false 97 | 98 | - name: Install frontend dependencies 99 | run: pnpm install 100 | 101 | - name: Build tauri app 102 | uses: tauri-apps/tauri-action@v0 103 | 104 | - name: Create artifacts directory 105 | run: mkdir -p artifacts 106 | 107 | - name: Copy deb to release assets 108 | run: cp src-tauri/target/release/bundle/deb/${{ env.REPO_NAME }}_${{ env.VERSION }}_amd64.deb artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64.deb 109 | 110 | - name: Copy rpm to release assets 111 | run: cp src-tauri/target/release/bundle/rpm/${{ env.REPO_NAME }}-${{ env.VERSION }}-1.x86_64.rpm artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64.rpm 112 | 113 | - name: Zip portable to release assets 114 | run: | 115 | cd src-tauri/target/release 116 | tar -czf ../../../artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_linux_amd64_portable.tar.gz ${{ env.REPO_NAME }} 117 | 118 | - name: Upload artifacts 119 | uses: actions/upload-artifact@v4 120 | with: 121 | name: linux-assets 122 | path: artifacts/* 123 | 124 | macos-build: 125 | needs: get-version 126 | env: 127 | VERSION: ${{ needs.get-version.outputs.VERSION }} 128 | outputs: 129 | VERSION: ${{ env.VERSION }} 130 | strategy: 131 | fail-fast: false 132 | matrix: 133 | arch: [ aarch64, x86_64 ] 134 | runs-on: macos-latest 135 | steps: 136 | - uses: actions/checkout@v4 137 | 138 | - name: Setup node 139 | uses: actions/setup-node@v4 140 | with: 141 | node-version: lts/* 142 | 143 | - name: Install Rust stable 144 | uses: dtolnay/rust-toolchain@stable 145 | with: 146 | targets: ${{ matrix.arch }}-apple-darwin 147 | 148 | - name: Install pnpm 149 | uses: pnpm/action-setup@v4 150 | with: 151 | run_install: false 152 | 153 | - name: Install frontend dependencies 154 | run: pnpm install 155 | 156 | - name: Build tauri app 157 | uses: tauri-apps/tauri-action@v0 158 | with: 159 | args: --target ${{ matrix.arch }}-apple-darwin 160 | 161 | - name: Create artifacts directory 162 | run: mkdir -p artifacts 163 | 164 | - name: Copy dmg to release assets 165 | env: 166 | ARCH_ALIAS: ${{ matrix.arch == 'x86_64' && 'x64' || matrix.arch }} 167 | run: cp src-tauri/target/${{ matrix.arch }}-apple-darwin/release/bundle/dmg/${{ env.REPO_NAME }}_${{ env.VERSION }}_${{ env.ARCH_ALIAS }}.dmg artifacts/${{ env.REPO_NAME }}_${{ env.VERSION }}_macos_${{ matrix.arch }}.dmg 168 | 169 | - name: Upload artifacts 170 | uses: actions/upload-artifact@v4 171 | with: 172 | name: macos-assets-${{ matrix.arch }} 173 | path: artifacts/* 174 | 175 | create-release: 176 | needs: [ windows-build, linux-build, macos-build ] 177 | runs-on: ubuntu-latest 178 | permissions: 179 | contents: write 180 | steps: 181 | - name: Download Windows assets 182 | uses: actions/download-artifact@v4 183 | with: 184 | name: windows-assets 185 | path: artifacts/windows 186 | 187 | - name: Download Linux assets 188 | uses: actions/download-artifact@v4 189 | with: 190 | name: linux-assets 191 | path: artifacts/linux 192 | 193 | - name: Download macOS aarch64 assets 194 | uses: actions/download-artifact@v4 195 | with: 196 | name: macos-assets-aarch64 197 | path: artifacts/macos-aarch64 198 | 199 | - name: Download macOS x86_64 assets 200 | uses: actions/download-artifact@v4 201 | with: 202 | name: macos-assets-x86_64 203 | path: artifacts/macos-x86_64 204 | 205 | - name: List files in artifacts directory 206 | run: ls -R artifacts 207 | 208 | - name: Create GitHub Release 209 | id: create_release 210 | uses: softprops/action-gh-release@v2 211 | env: 212 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 213 | with: 214 | name: Desktop App v${{ needs.windows-build.outputs.VERSION }} 215 | body: | 216 | Take a look at the assets to download and install this app. 217 | files: | 218 | artifacts/windows/* 219 | artifacts/linux/* 220 | artifacts/macos-aarch64/* 221 | artifacts/macos-x86_64/* 222 | draft: true 223 | prerelease: false -------------------------------------------------------------------------------- /src-tauri/src/hitomi/common.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Context}; 2 | use regex_lite::Regex; 3 | use serde::{Deserialize, Serialize}; 4 | use specta::Type; 5 | 6 | use crate::hitomi_client::HitomiClient; 7 | 8 | use super::gg::GG; 9 | //common.js 10 | pub const PROTOCOL: &str = "https:"; 11 | pub const DOMAIN: &str = "ltn.gold-usergeneratedcontent.net"; 12 | #[allow(dead_code)] 13 | pub const GALLERY_BLOCK_EXTENSION: &str = ".html"; 14 | #[allow(dead_code)] 15 | pub const GALLERY_BLOCK_DIR: &str = "galleryblock"; 16 | pub const NOZOMI_EXTENSION: &str = ".nozomi"; 17 | 18 | #[derive(Debug, Serialize, Deserialize)] 19 | pub struct Artist { 20 | pub artist: String, 21 | pub url: String, 22 | } 23 | 24 | #[derive(Debug, Serialize, Deserialize)] 25 | pub struct Group { 26 | pub group: String, 27 | pub url: String, 28 | } 29 | 30 | #[derive(Debug, Serialize, Deserialize)] 31 | pub struct Parody { 32 | pub parody: String, 33 | pub url: String, 34 | } 35 | 36 | #[derive(Debug, Serialize, Deserialize)] 37 | pub struct Character { 38 | pub character: String, 39 | pub url: String, 40 | } 41 | 42 | #[derive(Debug, Serialize, Deserialize)] 43 | #[allow(clippy::struct_field_names)] 44 | pub struct Tag { 45 | pub tag: String, 46 | pub url: String, 47 | #[serde(default, deserialize_with = "string_to_i32")] 48 | pub female: i32, 49 | #[serde(default, deserialize_with = "string_to_i32")] 50 | pub male: i32, 51 | } 52 | 53 | #[derive(Debug, Clone, Serialize, Deserialize)] 54 | #[allow(clippy::struct_field_names)] 55 | pub struct Language { 56 | #[serde(default, deserialize_with = "string_to_i32")] 57 | pub galleryid: i32, 58 | pub url: String, 59 | pub language_localname: String, 60 | pub name: String, 61 | } 62 | 63 | #[derive(Debug, Clone, Serialize, Deserialize, Type)] 64 | pub struct GalleryFiles { 65 | pub width: i32, 66 | pub hash: String, 67 | #[serde(default)] 68 | pub haswebp: i32, 69 | #[serde(default)] 70 | pub hasavif: i32, 71 | #[serde(default)] 72 | pub hasjxl: i32, 73 | pub name: String, 74 | pub height: i32, 75 | } 76 | 77 | #[derive(Debug, Serialize, Deserialize)] 78 | pub struct GalleryInfo { 79 | #[serde(default, deserialize_with = "string_to_i32")] 80 | pub id: i32, 81 | pub title: String, 82 | pub japanese_title: Option, 83 | pub language: Option, 84 | pub language_localname: Option, 85 | #[serde(rename = "type")] 86 | pub type_field: String, 87 | pub date: String, 88 | pub artists: Option>, 89 | pub groups: Option>, 90 | pub parodys: Option>, 91 | pub tags: Option>, 92 | #[serde(default)] 93 | pub related: Vec, 94 | #[serde(default)] 95 | pub languages: Vec, 96 | pub characters: Option>, 97 | #[serde(default)] 98 | pub scene_indexes: Vec, 99 | #[serde(default)] 100 | pub files: Vec, 101 | } 102 | 103 | fn string_to_i32<'de, D>(d: D) -> Result 104 | where 105 | D: serde::Deserializer<'de>, 106 | { 107 | use serde_json::Value; 108 | let value: Value = serde::Deserialize::deserialize(d)?; 109 | 110 | match value { 111 | #[allow(clippy::cast_possible_truncation)] 112 | Value::Number(n) => Ok(n.as_i64().unwrap_or(0) as i32), 113 | Value::String(s) => Ok(s.parse().unwrap_or(0)), 114 | _ => Err(serde::de::Error::custom( 115 | "`string_to_i32` failed, value type is not `Number` or `String`", 116 | )), 117 | } 118 | } 119 | 120 | #[allow(clippy::cast_sign_loss)] 121 | pub async fn subdomain_from_url( 122 | url: &str, 123 | base: Option<&str>, 124 | dir: Option<&str>, 125 | ) -> anyhow::Result { 126 | let base_is_none_or_empty = match base { 127 | None => true, 128 | Some(base) => base.is_empty(), 129 | }; 130 | 131 | let mut retval = String::new(); 132 | 133 | if base_is_none_or_empty { 134 | match dir { 135 | Some("webp") => retval = "w".to_string(), 136 | Some("avif") => retval = "a".to_string(), 137 | _ => {} 138 | } 139 | } 140 | 141 | let re = Regex::new(r"/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])")?; 142 | 143 | let Some(caps) = re.captures(url) else { 144 | return Ok(String::new()); 145 | }; 146 | 147 | let g = i32::from_str_radix(&format!("{}{}", &caps[2], &caps[1]), 16); 148 | 149 | if let Ok(g) = g { 150 | let mut gg = GG::inst().lock().await; 151 | let m_result = gg.m(g).await?; 152 | 153 | retval = if base_is_none_or_empty { 154 | format!("{retval}{}", 1 + m_result) 155 | } else { 156 | let c = char::from_u32((97 + m_result) as u32).context("Invalid character value")?; 157 | format!("{c}{}", base.unwrap_or("")) 158 | }; 159 | }; 160 | 161 | Ok(retval) 162 | } 163 | 164 | pub async fn url_from_url( 165 | url: &str, 166 | base: Option<&str>, 167 | dir: Option<&str>, 168 | ) -> anyhow::Result { 169 | let re = Regex::new(r"//..?\.(?:gold-usergeneratedcontent\.net|hitomi\.la)/")?; 170 | let subdomain = subdomain_from_url(url, base, dir).await?; 171 | let res_url = re.replace(url, format!("//{subdomain}.gold-usergeneratedcontent.net/")); 172 | Ok(res_url.into_owned()) 173 | } 174 | 175 | pub async fn full_path_from_hash(hash: &str) -> anyhow::Result { 176 | let mut gg = GG::inst().lock().await; 177 | let b = gg.b().await?; 178 | let s = gg.s(hash)?; 179 | Ok(format!("{b}{s}/{hash}")) 180 | } 181 | 182 | pub fn real_full_path_from_hash(hash: &str) -> anyhow::Result { 183 | let re = Regex::new(r"^.*(..)(.)$")?; 184 | let real_full_path = re.replace(hash, format!("$2/$1/{hash}")).to_string(); 185 | Ok(real_full_path) 186 | } 187 | 188 | pub async fn url_from_hash( 189 | _gallery_id: i32, 190 | image: &GalleryFiles, 191 | dir: Option<&str>, 192 | ext: Option<&str>, 193 | ) -> anyhow::Result { 194 | let ext = match (ext, dir) { 195 | (Some(e), _) => e, 196 | (None, Some(d)) => d, 197 | (None, None) => image.name.rsplit('.').next().unwrap_or(""), 198 | }; 199 | 200 | let mut url = String::from("https://a.gold-usergeneratedcontent.net/"); 201 | 202 | if let Some(dir) = dir { 203 | if dir != "webp" && dir != "avif" { 204 | url.push_str(dir); 205 | url.push('/'); 206 | } 207 | } 208 | 209 | url.push_str(&full_path_from_hash(&image.hash).await?); 210 | url.push('.'); 211 | url.push_str(ext); 212 | 213 | Ok(url) 214 | } 215 | 216 | pub async fn url_from_url_from_hash( 217 | gallery_id: i32, 218 | image: &GalleryFiles, 219 | dir: Option<&str>, 220 | ext: Option<&str>, 221 | base: Option<&str>, 222 | ) -> anyhow::Result { 223 | if base == Some("tn") { 224 | let real_path = real_full_path_from_hash(&image.hash)?; 225 | 226 | let Some(dir) = dir else { 227 | return Err(anyhow!(r#"if base is "tn", dir must not be None"#)); 228 | }; 229 | 230 | let Some(ext) = ext else { 231 | return Err(anyhow!(r#"if base is "tn", ext must not be None"#)); 232 | }; 233 | 234 | let url = format!("https://a.gold-usergeneratedcontent.net/{dir}/{real_path}.{ext}"); 235 | url_from_url(&url, base, None).await 236 | } else { 237 | let url = url_from_hash(gallery_id, image, dir, ext).await?; 238 | url_from_url(&url, base, dir).await 239 | } 240 | } 241 | 242 | pub enum Ext { 243 | Webp, 244 | Avif, 245 | } 246 | 247 | pub async fn image_url_from_image( 248 | gallery_id: i32, 249 | image: &GalleryFiles, 250 | ext: Ext, 251 | ) -> anyhow::Result { 252 | match ext { 253 | Ext::Webp => url_from_url_from_hash(gallery_id, image, Some("webp"), None, None).await, 254 | Ext::Avif => url_from_url_from_hash(gallery_id, image, Some("avif"), None, None).await, 255 | } 256 | } 257 | 258 | pub async fn get_gallery_info(gallery_id: i32) -> anyhow::Result { 259 | let client = HitomiClient::get_api_client(); 260 | 261 | let url = format!("{PROTOCOL}//{DOMAIN}/galleries/{gallery_id}.js"); 262 | let request = client.read().get(&url); 263 | let body = request.send().await?.text().await?; 264 | 265 | let json_str = body.replace("var galleryinfo = ", ""); 266 | let gallery_info: GalleryInfo = serde_json::from_str(&json_str) 267 | .context(format!("Failed to parse gallery info: {json_str}"))?; 268 | Ok(gallery_info) 269 | } 270 | -------------------------------------------------------------------------------- /src-tauri/src/logger.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Write, sync::OnceLock}; 2 | 3 | use anyhow::Context; 4 | use notify::{RecommendedWatcher, Watcher}; 5 | use parking_lot::RwLock; 6 | use tauri::{AppHandle, Manager}; 7 | use tauri_specta::Event; 8 | use tracing::{Level, Subscriber}; 9 | use tracing_appender::{ 10 | non_blocking::WorkerGuard, 11 | rolling::{RollingFileAppender, Rotation}, 12 | }; 13 | use tracing_subscriber::{ 14 | filter::{filter_fn, FilterExt, Targets}, 15 | fmt::{layer, time::LocalTime}, 16 | layer::SubscriberExt, 17 | registry::LookupSpan, 18 | util::SubscriberInitExt, 19 | Layer, Registry, 20 | }; 21 | 22 | use crate::{config::Config, events::LogEvent, extensions::AnyhowErrorToStringChain}; 23 | 24 | struct LogEventWriter { 25 | app: AppHandle, 26 | } 27 | 28 | impl Write for LogEventWriter { 29 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 30 | let log_string = String::from_utf8_lossy(buf); 31 | match serde_json::from_str::(&log_string) { 32 | Ok(log_event) => { 33 | let _ = log_event.emit(&self.app); 34 | } 35 | Err(err) => { 36 | let log_string = log_string.to_string(); 37 | let err_msg = err.to_string(); 38 | tracing::error!( 39 | log_string, 40 | err_msg, 41 | "deserialize log_string to LogEvent failed" 42 | ); 43 | } 44 | } 45 | Ok(buf.len()) 46 | } 47 | 48 | fn flush(&mut self) -> std::io::Result<()> { 49 | Ok(()) 50 | } 51 | } 52 | 53 | static RELOAD_FN: OnceLock anyhow::Result<()> + Send + Sync>> = OnceLock::new(); 54 | static GUARD: OnceLock>> = OnceLock::new(); 55 | 56 | pub fn init(app: &AppHandle) -> anyhow::Result<()> { 57 | let lib_module_path = module_path!(); 58 | let lib_target = lib_module_path.split("::").next().context(format!( 59 | "failed to parse lib_target: lib_module_path={lib_module_path}" 60 | ))?; 61 | // filter out logs from other libraries 62 | let target_filter = Targets::new().with_target(lib_target, Level::TRACE); 63 | 64 | let (file_layer, guard) = create_file_layer(app)?; 65 | let (reloadable_file_layer, reload_handle) = tracing_subscriber::reload::Layer::new(file_layer); 66 | 67 | let console_layer = layer() 68 | .with_writer(std::io::stdout) 69 | .with_timer(LocalTime::rfc_3339()) 70 | .with_file(true) 71 | .with_line_number(true); 72 | // send to frontend 73 | let log_event_writer = std::sync::Mutex::new(LogEventWriter { app: app.clone() }); 74 | let log_event_layer = layer() 75 | .with_writer(log_event_writer) 76 | .with_timer(LocalTime::rfc_3339()) 77 | .with_file(true) 78 | .with_line_number(true) 79 | .json() 80 | // filter out logs from this file (logs that failed to parse LogEvent) to avoid infinite recursion 81 | .with_filter(target_filter.clone().and(filter_fn(|metadata| { 82 | metadata.module_path() != Some(lib_module_path) 83 | }))); 84 | 85 | Registry::default() 86 | .with(target_filter) 87 | .with(reloadable_file_layer) 88 | .with(console_layer) 89 | .with(log_event_layer) 90 | .init(); 91 | 92 | GUARD.get_or_init(|| parking_lot::Mutex::new(guard)); 93 | RELOAD_FN.get_or_init(move || { 94 | let app = app.clone(); 95 | Box::new(move || { 96 | let (file_layer, guard) = create_file_layer(&app)?; 97 | reload_handle.reload(file_layer).context("reload failed")?; 98 | *GUARD.get().context("GUARD not initialized")?.lock() = guard; 99 | Ok(()) 100 | }) 101 | }); 102 | tauri::async_runtime::spawn(file_log_watcher(app.clone())); 103 | 104 | Ok(()) 105 | } 106 | 107 | pub fn reload_file_logger() -> anyhow::Result<()> { 108 | RELOAD_FN.get().context("RELOAD_FN not initialized")?() 109 | } 110 | 111 | pub fn disable_file_logger() -> anyhow::Result<()> { 112 | if let Some(guard) = GUARD.get().context("GUARD not initialized")?.lock().take() { 113 | drop(guard); 114 | }; 115 | Ok(()) 116 | } 117 | 118 | fn create_file_layer( 119 | app: &AppHandle, 120 | ) -> anyhow::Result<(Box + Send + Sync>, Option)> 121 | where 122 | S: Subscriber + for<'a> LookupSpan<'a>, 123 | { 124 | let enable_file_logger = app.state::>().read().enable_file_logger; 125 | // If file logging is not enabled, return a placeholder sink layer that is not created or output to a log file 126 | if !enable_file_logger { 127 | let sink_layer = layer() 128 | .with_writer(std::io::sink) 129 | .with_timer(LocalTime::rfc_3339()) 130 | .with_ansi(false) 131 | .with_file(true) 132 | .with_line_number(true); 133 | return Ok((Box::new(sink_layer), None)); 134 | } 135 | let logs_dir = logs_dir(app).context("get logs_dir failed")?; 136 | let file_appender = RollingFileAppender::builder() 137 | .filename_prefix("hitomi-downloader") 138 | .filename_suffix("log") 139 | .rotation(Rotation::DAILY) 140 | .build(&logs_dir) 141 | .context("create RollingFileAppender failed")?; 142 | let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender); 143 | let file_layer = layer() 144 | .with_writer(non_blocking_appender) 145 | .with_timer(LocalTime::rfc_3339()) 146 | .with_ansi(false) 147 | .with_file(true) 148 | .with_line_number(true); 149 | Ok((Box::new(file_layer), Some(guard))) 150 | } 151 | 152 | async fn file_log_watcher(app: AppHandle) { 153 | let (sender, mut receiver) = tokio::sync::mpsc::channel(1); 154 | 155 | let event_handler = move |res| { 156 | tauri::async_runtime::block_on(async { 157 | if let Err(err) = sender.send(res).await.map_err(anyhow::Error::from) { 158 | let err_title = "Failed to send log file watcher event"; 159 | let string_chain = err.to_string_chain(); 160 | tracing::error!(err_title, message = string_chain); 161 | } 162 | }); 163 | }; 164 | 165 | let mut watcher = match RecommendedWatcher::new(event_handler, notify::Config::default()) 166 | .map_err(anyhow::Error::from) 167 | { 168 | Ok(watcher) => watcher, 169 | Err(err) => { 170 | let err_title = "Failed to create log file watcher"; 171 | let string_chain = err.to_string_chain(); 172 | tracing::error!(err_title, message = string_chain); 173 | return; 174 | } 175 | }; 176 | 177 | let logs_dir = match logs_dir(&app) { 178 | Ok(logs_dir) => logs_dir, 179 | Err(err) => { 180 | let err_title = "Failed to get log directory for log file watcher"; 181 | let string_chain = err.to_string_chain(); 182 | tracing::error!(err_title, message = string_chain); 183 | return; 184 | } 185 | }; 186 | 187 | if let Err(err) = watcher 188 | .watch(&logs_dir, notify::RecursiveMode::NonRecursive) 189 | .map_err(anyhow::Error::from) 190 | { 191 | let err_title = "Failed to watch log directory for log file watcher"; 192 | let string_chain = err.to_string_chain(); 193 | tracing::error!(err_title, message = string_chain); 194 | return; 195 | } 196 | 197 | while let Some(res) = receiver.recv().await { 198 | match res.map_err(anyhow::Error::from) { 199 | Ok(event) => { 200 | if let notify::EventKind::Remove(_) = event.kind { 201 | if let Err(err) = reload_file_logger() { 202 | let err_title = "Failed to reload log file"; 203 | let string_chain = err.to_string_chain(); 204 | tracing::error!(err_title, message = string_chain); 205 | } 206 | } 207 | } 208 | Err(err) => { 209 | let err_title = "Failed to receive log file watcher event"; 210 | let string_chain = err.to_string_chain(); 211 | tracing::error!(err_title, message = string_chain); 212 | } 213 | } 214 | } 215 | } 216 | 217 | pub fn logs_dir(app: &AppHandle) -> anyhow::Result { 218 | let app_data_dir = app 219 | .path() 220 | .app_data_dir() 221 | .context("failed to get app_data_dir")?; 222 | Ok(app_data_dir.join("logs")) 223 | } 224 | -------------------------------------------------------------------------------- /src-tauri/src/hitomi_client.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::time::Duration; 3 | 4 | use anyhow::{anyhow, Context}; 5 | use bytes::Bytes; 6 | use parking_lot::RwLock; 7 | use reqwest::{Client, StatusCode}; 8 | use reqwest_middleware::ClientWithMiddleware; 9 | use reqwest_retry::{policies::ExponentialBackoff, Jitter, RetryTransientMiddleware}; 10 | use serde::{Deserialize, Serialize}; 11 | use tauri::{AppHandle, Manager}; 12 | 13 | use crate::{ 14 | config::Config, 15 | extensions::AnyhowErrorToStringChain, 16 | hitomi::{self, Suggestion}, 17 | types::{Comic, ProxyMode, SearchResult}, 18 | utils::get_app_handle, 19 | }; 20 | 21 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct LoginResp { 24 | pub ret: bool, 25 | pub html: String, 26 | } 27 | 28 | #[derive(Clone)] 29 | pub struct HitomiClient { 30 | app: AppHandle, 31 | api_client: Arc>, 32 | img_client: Arc>, 33 | cover_client: Arc>, 34 | } 35 | 36 | impl HitomiClient { 37 | pub fn new(app: AppHandle) -> Self { 38 | let api_client = create_api_client(&app); 39 | let api_client = Arc::new(RwLock::new(api_client)); 40 | 41 | let img_client = create_img_client(&app); 42 | let img_client = Arc::new(RwLock::new(img_client)); 43 | 44 | let cover_client = create_cover_client(&app); 45 | let cover_client = Arc::new(RwLock::new(cover_client)); 46 | 47 | Self { 48 | app, 49 | api_client, 50 | img_client, 51 | cover_client, 52 | } 53 | } 54 | 55 | pub fn get_api_client() -> Arc> { 56 | let app = get_app_handle(); 57 | let hitomi_client = app.state::(); 58 | hitomi_client.api_client.clone() 59 | } 60 | 61 | pub fn reload_client(&self) { 62 | let api_client = create_api_client(&self.app); 63 | *self.api_client.write() = api_client; 64 | 65 | let img_client = create_img_client(&self.app); 66 | *self.img_client.write() = img_client; 67 | 68 | let cover_client = create_cover_client(&self.app); 69 | *self.cover_client.write() = cover_client; 70 | } 71 | 72 | pub async fn search( 73 | &self, 74 | query: &str, 75 | page_num: usize, 76 | sort_by_popularity: bool, 77 | ) -> anyhow::Result { 78 | let ids = hitomi::do_search(query.to_string(), sort_by_popularity).await?; 79 | 80 | let search_result = self.get_page(ids.into_iter().collect(), page_num).await?; 81 | 82 | Ok(search_result) 83 | } 84 | 85 | pub async fn get_page(&self, ids: Vec, page_num: usize) -> anyhow::Result { 86 | const PAGE_SIZE: usize = 25; 87 | 88 | // Calculate total pages by ceiling division 89 | let total_page = ids.len().div_ceil(PAGE_SIZE); 90 | 91 | let get_gallery_info_tasks = ids 92 | .iter() 93 | .skip((page_num - 1) * PAGE_SIZE) 94 | .take(PAGE_SIZE) 95 | .map(|id| async move { 96 | hitomi::get_gallery_info(*id) 97 | .await 98 | .context(format!("Failed to get gallery info for `{id}`")) 99 | }); 100 | let gallery_infos = futures::future::try_join_all(get_gallery_info_tasks).await?; 101 | 102 | let search_result = 103 | SearchResult::from_gallery_infos(&self.app, gallery_infos, page_num, total_page, ids) 104 | .await?; 105 | 106 | Ok(search_result) 107 | } 108 | 109 | pub async fn get_comic(&self, id: i32) -> anyhow::Result { 110 | let gallery = hitomi::get_gallery_info(id) 111 | .await 112 | .context(format!("Failed to get gallery info for `{id}`"))?; 113 | 114 | let comic = Comic::from_gallery_info(&self.app, gallery).await?; 115 | Ok(comic) 116 | } 117 | 118 | pub async fn get_img_data(&self, url: &str) -> anyhow::Result { 119 | let request = self 120 | .img_client 121 | .read() 122 | .get(url) 123 | .header("referer", "https://hitomi.la/"); 124 | let http_resp = request.send().await?; 125 | // check http response status code 126 | let status = http_resp.status(); 127 | if status == StatusCode::SERVICE_UNAVAILABLE { 128 | return Err(anyhow!("Failed after multiple retries, try again later")); 129 | } else if status != StatusCode::OK { 130 | let body = http_resp.text().await?; 131 | return Err(anyhow!("Unexpected status code({status}): {body}")); 132 | } 133 | // get image data 134 | let img_data = http_resp.bytes().await?; 135 | Ok(img_data) 136 | } 137 | 138 | pub async fn get_search_suggestions(&self, query: &str) -> anyhow::Result> { 139 | let suggestion = hitomi::get_suggestions_for_query(query).await?; 140 | Ok(suggestion) 141 | } 142 | 143 | pub async fn get_cover_data(&self, cover_url: &str) -> anyhow::Result { 144 | let request = self 145 | .cover_client 146 | .read() 147 | .get(cover_url) 148 | .header("referer", "https://hitomi.la/"); 149 | let http_resp = request.send().await?; 150 | // check http response status code 151 | let status = http_resp.status(); 152 | if status != StatusCode::OK { 153 | let body = http_resp.text().await?; 154 | return Err(anyhow!("Unexpected status code({status}): {body}")); 155 | } 156 | let cover_data = http_resp.bytes().await?; 157 | Ok(cover_data) 158 | } 159 | } 160 | 161 | fn create_api_client(app: &AppHandle) -> ClientWithMiddleware { 162 | let retry_policy = ExponentialBackoff::builder() 163 | .base(1) 164 | .jitter(Jitter::Bounded) 165 | .build_with_total_retry_duration(Duration::from_secs(5)); 166 | 167 | let client = reqwest::ClientBuilder::new() 168 | .set_proxy(app, "api_client") 169 | .timeout(Duration::from_secs(3)) 170 | .redirect(reqwest::redirect::Policy::none()) 171 | .build() 172 | .unwrap(); 173 | 174 | reqwest_middleware::ClientBuilder::new(client) 175 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 176 | .build() 177 | } 178 | 179 | fn create_img_client(app: &AppHandle) -> ClientWithMiddleware { 180 | let retry_policy = ExponentialBackoff::builder() 181 | .base(1) 182 | .jitter(Jitter::Bounded) 183 | .build_with_max_retries(20); 184 | 185 | let client = reqwest::ClientBuilder::new() 186 | .set_proxy(app, "img_client") 187 | .build() 188 | .unwrap(); 189 | 190 | reqwest_middleware::ClientBuilder::new(client) 191 | .with(RetryTransientMiddleware::new_with_policy(retry_policy)) 192 | .build() 193 | } 194 | 195 | fn create_cover_client(app: &AppHandle) -> Client { 196 | reqwest::ClientBuilder::new() 197 | .set_proxy(app, "cover_client") 198 | .build() 199 | .unwrap() 200 | } 201 | 202 | trait ClientBuilderExt { 203 | fn set_proxy(self, app: &AppHandle, client_name: &str) -> Self; 204 | } 205 | 206 | impl ClientBuilderExt for reqwest::ClientBuilder { 207 | fn set_proxy(self, app: &AppHandle, client_name: &str) -> reqwest::ClientBuilder { 208 | let proxy_mode = app.state::>().read().proxy_mode; 209 | match proxy_mode { 210 | ProxyMode::System => self, 211 | ProxyMode::NoProxy => self.no_proxy(), 212 | ProxyMode::Custom => { 213 | let config = app.state::>(); 214 | let config = config.read(); 215 | let proxy_host = &config.proxy_host; 216 | let proxy_port = &config.proxy_port; 217 | let proxy_url = format!("http://{proxy_host}:{proxy_port}"); 218 | 219 | match reqwest::Proxy::all(&proxy_url).map_err(anyhow::Error::from) { 220 | Ok(proxy) => self.proxy(proxy), 221 | Err(err) => { 222 | let err_title = 223 | format!("{client_name} failed to set proxy `{proxy_url}`, use system proxy instead"); 224 | let string_chain = err.to_string_chain(); 225 | tracing::error!(err_title, message = string_chain); 226 | self 227 | } 228 | } 229 | } 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/panes/ComicPane.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 244 | -------------------------------------------------------------------------------- /src/panes/SearchPane.vue: -------------------------------------------------------------------------------- 1 | 230 | 231 | 296 | -------------------------------------------------------------------------------- /src/components/UncompletedProgresses.vue: -------------------------------------------------------------------------------- 1 | 223 | 224 | 275 | 276 | 289 | -------------------------------------------------------------------------------- /src/bindings.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. 3 | 4 | /** user-defined commands **/ 5 | 6 | 7 | export const commands = { 8 | async greet(name: string) : Promise { 9 | return await TAURI_INVOKE("greet", { name }); 10 | }, 11 | async getConfig() : Promise { 12 | return await TAURI_INVOKE("get_config"); 13 | }, 14 | async saveConfig(config: Config) : Promise> { 15 | try { 16 | return { status: "ok", data: await TAURI_INVOKE("save_config", { config }) }; 17 | } catch (e) { 18 | if(e instanceof Error) throw e; 19 | else return { status: "error", error: e as any }; 20 | } 21 | }, 22 | async search(query: string, pageNum: number, sortByPopularity: boolean) : Promise> { 23 | try { 24 | return { status: "ok", data: await TAURI_INVOKE("search", { query, pageNum, sortByPopularity }) }; 25 | } catch (e) { 26 | if(e instanceof Error) throw e; 27 | else return { status: "error", error: e as any }; 28 | } 29 | }, 30 | async getPage(ids: number[], pageNum: number) : Promise> { 31 | try { 32 | return { status: "ok", data: await TAURI_INVOKE("get_page", { ids, pageNum }) }; 33 | } catch (e) { 34 | if(e instanceof Error) throw e; 35 | else return { status: "error", error: e as any }; 36 | } 37 | }, 38 | async getComic(id: number) : Promise> { 39 | try { 40 | return { status: "ok", data: await TAURI_INVOKE("get_comic", { id }) }; 41 | } catch (e) { 42 | if(e instanceof Error) throw e; 43 | else return { status: "error", error: e as any }; 44 | } 45 | }, 46 | async createDownloadTask(comic: Comic) : Promise> { 47 | try { 48 | return { status: "ok", data: await TAURI_INVOKE("create_download_task", { comic }) }; 49 | } catch (e) { 50 | if(e instanceof Error) throw e; 51 | else return { status: "error", error: e as any }; 52 | } 53 | }, 54 | async pauseDownloadTask(id: number) : Promise> { 55 | try { 56 | return { status: "ok", data: await TAURI_INVOKE("pause_download_task", { id }) }; 57 | } catch (e) { 58 | if(e instanceof Error) throw e; 59 | else return { status: "error", error: e as any }; 60 | } 61 | }, 62 | async resumeDownloadTask(id: number) : Promise> { 63 | try { 64 | return { status: "ok", data: await TAURI_INVOKE("resume_download_task", { id }) }; 65 | } catch (e) { 66 | if(e instanceof Error) throw e; 67 | else return { status: "error", error: e as any }; 68 | } 69 | }, 70 | async cancelDownloadTask(id: number) : Promise> { 71 | try { 72 | return { status: "ok", data: await TAURI_INVOKE("cancel_download_task", { id }) }; 73 | } catch (e) { 74 | if(e instanceof Error) throw e; 75 | else return { status: "error", error: e as any }; 76 | } 77 | }, 78 | async getDownloadedComics() : Promise { 79 | return await TAURI_INVOKE("get_downloaded_comics"); 80 | }, 81 | async exportPdf(comic: Comic) : Promise> { 82 | try { 83 | return { status: "ok", data: await TAURI_INVOKE("export_pdf", { comic }) }; 84 | } catch (e) { 85 | if(e instanceof Error) throw e; 86 | else return { status: "error", error: e as any }; 87 | } 88 | }, 89 | async exportCbz(comic: Comic) : Promise> { 90 | try { 91 | return { status: "ok", data: await TAURI_INVOKE("export_cbz", { comic }) }; 92 | } catch (e) { 93 | if(e instanceof Error) throw e; 94 | else return { status: "error", error: e as any }; 95 | } 96 | }, 97 | async getSearchSuggestions(query: string) : Promise> { 98 | try { 99 | return { status: "ok", data: await TAURI_INVOKE("get_search_suggestions", { query }) }; 100 | } catch (e) { 101 | if(e instanceof Error) throw e; 102 | else return { status: "error", error: e as any }; 103 | } 104 | }, 105 | async getLogsDirSize() : Promise> { 106 | try { 107 | return { status: "ok", data: await TAURI_INVOKE("get_logs_dir_size") }; 108 | } catch (e) { 109 | if(e instanceof Error) throw e; 110 | else return { status: "error", error: e as any }; 111 | } 112 | }, 113 | async showPathInFileManager(path: string) : Promise> { 114 | try { 115 | return { status: "ok", data: await TAURI_INVOKE("show_path_in_file_manager", { path }) }; 116 | } catch (e) { 117 | if(e instanceof Error) throw e; 118 | else return { status: "error", error: e as any }; 119 | } 120 | }, 121 | async getCoverData(coverUrl: string) : Promise> { 122 | try { 123 | return { status: "ok", data: await TAURI_INVOKE("get_cover_data", { coverUrl }) }; 124 | } catch (e) { 125 | if(e instanceof Error) throw e; 126 | else return { status: "error", error: e as any }; 127 | } 128 | }, 129 | async getSyncedComic(comic: Comic) : Promise> { 130 | try { 131 | return { status: "ok", data: await TAURI_INVOKE("get_synced_comic", { comic }) }; 132 | } catch (e) { 133 | if(e instanceof Error) throw e; 134 | else return { status: "error", error: e as any }; 135 | } 136 | } 137 | } 138 | 139 | /** user-defined events **/ 140 | 141 | 142 | export const events = __makeEvents__<{ 143 | downloadSpeedEvent: DownloadSpeedEvent, 144 | downloadTaskEvent: DownloadTaskEvent, 145 | exportCbzEvent: ExportCbzEvent, 146 | exportPdfEvent: ExportPdfEvent, 147 | logEvent: LogEvent 148 | }>({ 149 | downloadSpeedEvent: "download-speed-event", 150 | downloadTaskEvent: "download-task-event", 151 | exportCbzEvent: "export-cbz-event", 152 | exportPdfEvent: "export-pdf-event", 153 | logEvent: "log-event" 154 | }) 155 | 156 | /** user-defined constants **/ 157 | 158 | 159 | 160 | /** user-defined types **/ 161 | 162 | export type Comic = { id: number; title: string; japaneseTitle: string; language: string; languageLocalname: string; type: string; date: string; artists: string[]; groups: string[]; parodys: string[]; tags: Tag[]; related: number[]; languages: Language[]; characters: string[]; sceneIndexes: number[]; files: GalleryFiles[]; coverUrl: string; isDownloaded?: boolean | null; comicDownloadDir?: string | null } 163 | export type CommandError = { err_title: string; err_message: string } 164 | export type Config = { downloadDir: string; exportDir: string; enableFileLogger: boolean; downloadFormat: DownloadFormat; dirFmt: string; proxyHost: string; proxyMode: ProxyMode; proxyPort: number } 165 | export type DownloadFormat = "Webp" | "Avif" 166 | export type DownloadSpeedEvent = { speed: string } 167 | export type DownloadTaskEvent = { event: "Create"; data: { state: DownloadTaskState; comic: Comic; downloadedImgCount: number; totalImgCount: number } } | { event: "Update"; data: { comicId: number; state: DownloadTaskState; downloadedImgCount: number; totalImgCount: number } } 168 | export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Cancelled" | "Completed" | "Failed" 169 | export type ExportCbzEvent = { event: "Start"; data: { uuid: string; title: string } } | { event: "Error"; data: { uuid: string } } | { event: "End"; data: { uuid: string } } 170 | export type ExportPdfEvent = { event: "Start"; data: { uuid: string; title: string } } | { event: "Error"; data: { uuid: string } } | { event: "End"; data: { uuid: string } } 171 | export type GalleryFiles = { width: number; hash: string; haswebp?: number; hasavif?: number; hasjxl?: number; name: string; height: number } 172 | export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue } 173 | export type Language = { galleryid: number; language_localname: string; name: string } 174 | export type LogEvent = { timestamp: string; level: LogLevel; fields: { [key in string]: JsonValue }; target: string; filename: string; line_number: number } 175 | export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" 176 | export type ProxyMode = "System" | "NoProxy" | "Custom" 177 | export type SearchResult = { comics: Comic[]; currentPage: number; totalPage: number; ids: number[] } 178 | export type Suggestion = { s: string; t: number; u: string; n: string } 179 | export type Tag = { tag: string; female: number; male: number } 180 | 181 | /** tauri-specta globals **/ 182 | 183 | import { 184 | invoke as TAURI_INVOKE, 185 | Channel as TAURI_CHANNEL, 186 | } from "@tauri-apps/api/core"; 187 | import * as TAURI_API_EVENT from "@tauri-apps/api/event"; 188 | import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; 189 | 190 | type __EventObj__ = { 191 | listen: ( 192 | cb: TAURI_API_EVENT.EventCallback, 193 | ) => ReturnType>; 194 | once: ( 195 | cb: TAURI_API_EVENT.EventCallback, 196 | ) => ReturnType>; 197 | emit: null extends T 198 | ? (payload?: T) => ReturnType 199 | : (payload: T) => ReturnType; 200 | }; 201 | 202 | export type Result = 203 | | { status: "ok"; data: T } 204 | | { status: "error"; error: E }; 205 | 206 | function __makeEvents__>( 207 | mappings: Record, 208 | ) { 209 | return new Proxy( 210 | {} as unknown as { 211 | [K in keyof T]: __EventObj__ & { 212 | (handle: __WebviewWindow__): __EventObj__; 213 | }; 214 | }, 215 | { 216 | get: (_, event) => { 217 | const name = mappings[event as keyof T]; 218 | 219 | return new Proxy((() => {}) as any, { 220 | apply: (_, __, [window]: [__WebviewWindow__]) => ({ 221 | listen: (arg: any) => window.listen(name, arg), 222 | once: (arg: any) => window.once(name, arg), 223 | emit: (arg: any) => window.emit(name, arg), 224 | }), 225 | get: (_, command: keyof __EventObj__) => { 226 | switch (command) { 227 | case "listen": 228 | return (arg: any) => TAURI_API_EVENT.listen(name, arg); 229 | case "once": 230 | return (arg: any) => TAURI_API_EVENT.once(name, arg); 231 | case "emit": 232 | return (arg: any) => TAURI_API_EVENT.emit(name, arg); 233 | } 234 | }, 235 | }); 236 | }, 237 | }, 238 | ); 239 | } 240 | -------------------------------------------------------------------------------- /src-tauri/src/types/comic.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use anyhow::Context; 4 | use parking_lot::RwLock; 5 | use serde::{Deserialize, Serialize}; 6 | use specta::Type; 7 | use tauri::{AppHandle, Manager}; 8 | use walkdir::WalkDir; 9 | 10 | use crate::{ 11 | config::Config, 12 | hitomi::{url_from_url_from_hash, GalleryFiles, GalleryInfo}, 13 | }; 14 | 15 | #[derive(Default, Debug, Clone, Serialize, Deserialize, Type)] 16 | #[serde(rename_all = "camelCase")] 17 | #[allow(clippy::struct_field_names)] 18 | pub struct Comic { 19 | pub id: i32, 20 | pub title: String, 21 | pub japanese_title: String, 22 | pub language: String, 23 | pub language_localname: String, 24 | #[serde(rename = "type")] 25 | pub type_field: String, 26 | pub date: String, 27 | pub artists: Vec, 28 | pub groups: Vec, 29 | pub parodys: Vec, 30 | pub tags: Vec, 31 | pub related: Vec, 32 | pub languages: Vec, 33 | pub characters: Vec, 34 | pub scene_indexes: Vec, 35 | pub files: Vec, 36 | pub cover_url: String, 37 | #[serde(skip_serializing_if = "Option::is_none")] 38 | pub is_downloaded: Option, 39 | #[serde(skip_serializing_if = "Option::is_none")] 40 | pub comic_download_dir: Option, 41 | } 42 | 43 | impl Comic { 44 | pub async fn from_gallery_info( 45 | app: &AppHandle, 46 | gallery_info: GalleryInfo, 47 | ) -> anyhow::Result { 48 | let first_file = gallery_info 49 | .files 50 | .first() 51 | .context(format!("gallery_info has no files: {gallery_info:?}"))?; 52 | let cover_url = url_from_url_from_hash( 53 | gallery_info.id, 54 | first_file, 55 | Some("webpbigtn"), 56 | Some("webp"), 57 | Some("tn"), 58 | ) 59 | .await 60 | .context("Get cover url failed")?; 61 | 62 | let artists = gallery_info 63 | .artists 64 | .unwrap_or_default() 65 | .into_iter() 66 | .map(|a| a.artist) 67 | .collect(); 68 | 69 | let groups = gallery_info 70 | .groups 71 | .unwrap_or_default() 72 | .into_iter() 73 | .map(|g| g.group) 74 | .collect(); 75 | 76 | let parodys = gallery_info 77 | .parodys 78 | .unwrap_or_default() 79 | .into_iter() 80 | .map(|p| p.parody) 81 | .collect(); 82 | 83 | let tags = gallery_info 84 | .tags 85 | .unwrap_or_default() 86 | .into_iter() 87 | .map(|t| Tag { 88 | tag: t.tag, 89 | female: t.female, 90 | male: t.male, 91 | }) 92 | .collect(); 93 | 94 | let languages = gallery_info 95 | .languages 96 | .into_iter() 97 | .map(|l| Language { 98 | galleryid: l.galleryid, 99 | language_localname: l.language_localname, 100 | name: l.name, 101 | }) 102 | .collect(); 103 | 104 | let characters = gallery_info 105 | .characters 106 | .unwrap_or_default() 107 | .into_iter() 108 | .map(|c| c.character) 109 | .collect(); 110 | 111 | let mut comic = Comic { 112 | id: gallery_info.id, 113 | title: gallery_info.title, 114 | japanese_title: gallery_info.japanese_title.unwrap_or_default(), 115 | language: gallery_info.language.unwrap_or_default(), 116 | language_localname: gallery_info.language_localname.unwrap_or_default(), 117 | type_field: gallery_info.type_field, 118 | date: gallery_info.date, 119 | artists, 120 | groups, 121 | parodys, 122 | tags, 123 | related: gallery_info.related, 124 | languages, 125 | characters, 126 | scene_indexes: gallery_info.scene_indexes, 127 | files: gallery_info.files, 128 | cover_url, 129 | is_downloaded: None, 130 | comic_download_dir: None, 131 | }; 132 | 133 | comic.update_fields(app).context(format!( 134 | "Failed to update fields for comic `{}`", 135 | comic.title 136 | ))?; 137 | 138 | Ok(comic) 139 | } 140 | 141 | pub fn from_metadata(metadata_path: &Path) -> anyhow::Result { 142 | let comic_json = std::fs::read_to_string(metadata_path).context(format!( 143 | "Failed to convert metadata to Comic, failed to read metadata file `{}`", 144 | metadata_path.display() 145 | ))?; 146 | let mut comic: Comic = serde_json::from_str(&comic_json).context(format!( 147 | "Failed to convert metadata to Comic, failed to deserialize `{}` to Comic", 148 | metadata_path.display() 149 | ))?; 150 | // The `is_downloaded` and `comic_download_dir` fields are not serialized in the metadata file 151 | let parent = metadata_path.parent().context(format!( 152 | "Failed to get parent directory of `{}`", 153 | metadata_path.display() 154 | ))?; 155 | comic.comic_download_dir = Some(parent.to_path_buf()); 156 | comic.is_downloaded = Some(true); 157 | Ok(comic) 158 | } 159 | 160 | /// Update fields based on the metadata file in the download directory 161 | /// 162 | /// Update fields and logic: 163 | /// - `comic_download_dir`: Update to the directory where the metadata file is located by matching the current comic id 164 | /// - `is_downloaded`: Set to true if the corresponding comic metadata is found 165 | pub fn update_fields(&mut self, app: &AppHandle) -> anyhow::Result<()> { 166 | let download_dir = app.state::>().read().download_dir.clone(); 167 | if !download_dir.exists() { 168 | return Ok(()); 169 | } 170 | 171 | for entry in WalkDir::new(&download_dir) 172 | .into_iter() 173 | .filter_map(Result::ok) 174 | { 175 | let path = entry.path(); 176 | if path.is_dir() { 177 | continue; 178 | } 179 | if entry.file_name() != "metadata.json" { 180 | continue; 181 | } 182 | // now the entry is the metadata.json file 183 | let metadata_str = std::fs::read_to_string(path) 184 | .context(format!("Failed to read `{}`", path.display()))?; 185 | 186 | let comic_json: serde_json::Value = 187 | serde_json::from_str(&metadata_str).context(format!( 188 | "Failed to deserialize `{}` to serde_json::Value", 189 | path.display() 190 | ))?; 191 | 192 | let id = comic_json 193 | .get("id") 194 | .and_then(|id| id.as_number()) 195 | .context(format!("`id` field not found in `{}`", path.display()))? 196 | .as_i64() 197 | .context(format!( 198 | "`id` field in `{}` is not an integer", 199 | path.display() 200 | ))?; 201 | #[allow(clippy::cast_possible_truncation)] 202 | let id = id as i32; 203 | 204 | if id != self.id { 205 | continue; 206 | } 207 | 208 | let parent = path.parent().context(format!( 209 | "Failed to get parent directory of `{}`", 210 | path.display() 211 | ))?; 212 | 213 | self.comic_download_dir = Some(parent.to_path_buf()); 214 | self.is_downloaded = Some(true); 215 | break; 216 | } 217 | 218 | Ok(()) 219 | } 220 | 221 | pub fn get_comic_download_dir_name(&self) -> anyhow::Result { 222 | let comic_download_dir = self 223 | .comic_download_dir 224 | .as_ref() 225 | .context("`comic_download_dir` field is `None`")?; 226 | 227 | let comic_download_dir_name = comic_download_dir 228 | .file_name() 229 | .context(format!( 230 | "Failed to get directory name of `{}`", 231 | comic_download_dir.display() 232 | ))? 233 | .to_string_lossy() 234 | .to_string(); 235 | 236 | Ok(comic_download_dir_name) 237 | } 238 | 239 | pub fn get_comic_export_dir(&self, app: &AppHandle) -> anyhow::Result { 240 | let (download_dir, export_dir) = { 241 | let config = app.state::>(); 242 | let config = config.read(); 243 | (config.download_dir.clone(), config.export_dir.clone()) 244 | }; 245 | 246 | let comic_download_dir = self 247 | .comic_download_dir 248 | .as_ref() 249 | .context("`comic_download_dir` field is `None`")?; 250 | 251 | let relative_dir = comic_download_dir 252 | .strip_prefix(&download_dir) 253 | .context(format!( 254 | "Failed to strip prefix `{}` from `{}`", 255 | comic_download_dir.display(), 256 | download_dir.display() 257 | ))?; 258 | 259 | let comic_export_dir = export_dir.join(relative_dir); 260 | Ok(comic_export_dir) 261 | } 262 | 263 | pub fn get_temp_download_dir(&self) -> anyhow::Result { 264 | let comic_download_dir = self 265 | .comic_download_dir 266 | .as_ref() 267 | .context("`comic_download_dir` field is `None`")?; 268 | 269 | let comic_download_dir_name = self 270 | .get_comic_download_dir_name() 271 | .context("Failed to get comic download directory name")?; 272 | 273 | let parent = comic_download_dir.parent().context(format!( 274 | "Failed to get parent directory of `{}`", 275 | comic_download_dir.display() 276 | ))?; 277 | 278 | let temp_download_dir = parent.join(format!(".downloading-{comic_download_dir_name}")); 279 | Ok(temp_download_dir) 280 | } 281 | } 282 | 283 | #[derive(Default, Debug, Clone, Serialize, Deserialize, Type)] 284 | #[allow(clippy::struct_field_names)] 285 | pub struct Tag { 286 | pub tag: String, 287 | pub female: i32, 288 | pub male: i32, 289 | } 290 | 291 | #[derive(Default, Debug, Clone, Serialize, Deserialize, Type)] 292 | #[allow(clippy::struct_field_names)] 293 | pub struct Language { 294 | pub galleryid: i32, 295 | pub language_localname: String, 296 | pub name: String, 297 | } 298 | -------------------------------------------------------------------------------- /src-tauri/src/export.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{Read, Write}, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use anyhow::{anyhow, Context}; 7 | use lopdf::{ 8 | content::{Content, Operation}, 9 | dictionary, Document, Object, Stream, 10 | }; 11 | use tauri::AppHandle; 12 | use tauri_specta::Event; 13 | use zip::{write::SimpleFileOptions, ZipWriter}; 14 | 15 | use crate::{ 16 | events::{ExportCbzEvent, ExportPdfEvent}, 17 | extensions::PathIsImg, 18 | types::{Comic, ComicInfo}, 19 | }; 20 | 21 | enum Archive { 22 | Cbz, 23 | Pdf, 24 | } 25 | impl Archive { 26 | pub fn extension(&self) -> &str { 27 | match self { 28 | Archive::Cbz => "cbz", 29 | Archive::Pdf => "pdf", 30 | } 31 | } 32 | } 33 | 34 | struct CbzEventGuard { 35 | uuid: String, 36 | app: AppHandle, 37 | success: bool, 38 | } 39 | 40 | impl Drop for CbzEventGuard { 41 | fn drop(&mut self) { 42 | if self.success { 43 | let _ = ExportCbzEvent::End { 44 | uuid: self.uuid.clone(), 45 | } 46 | .emit(&self.app); 47 | } else { 48 | let _ = ExportCbzEvent::Error { 49 | uuid: self.uuid.clone(), 50 | } 51 | .emit(&self.app); 52 | } 53 | } 54 | } 55 | #[allow(clippy::cast_possible_wrap)] 56 | #[allow(clippy::cast_possible_truncation)] 57 | pub fn cbz(app: &AppHandle, comic: &Comic) -> anyhow::Result<()> { 58 | let comic_title = &comic.title; 59 | // Generate formatted xml 60 | let cfg = yaserde::ser::Config { 61 | perform_indent: true, 62 | ..Default::default() 63 | }; 64 | let event_uuid = uuid::Uuid::new_v4().to_string(); 65 | 66 | let _ = ExportCbzEvent::Start { 67 | uuid: event_uuid.clone(), 68 | title: comic_title.clone(), 69 | } 70 | .emit(app); 71 | // Event guard to ensure that the error event is sent if the function panics 72 | let mut cbz_event_guard = CbzEventGuard { 73 | uuid: event_uuid.clone(), 74 | app: app.clone(), 75 | success: false, 76 | }; 77 | 78 | let download_dir = comic 79 | .comic_download_dir 80 | .as_ref() 81 | .context("`comic_download_dir` field is `None`")?; 82 | let export_dir = comic 83 | .get_comic_export_dir(app) 84 | .context("Failed to get comic export directory")?; 85 | // Generate ComicInfo 86 | let comic_info = ComicInfo::from(comic.clone()); 87 | // Serialize ComicInfo to xml 88 | let comic_info_xml = 89 | yaserde::ser::to_string_with_config(&comic_info, &cfg).map_err(|err_msg| { 90 | anyhow!("`{comic_title}` failed to serialize `ComicInfo.xml`: {err_msg}") 91 | })?; 92 | // Ensure export directory exists 93 | std::fs::create_dir_all(&export_dir).context(format!( 94 | "`{comic_title}` failed to create directory `{}`", 95 | export_dir.display() 96 | ))?; 97 | // Create cbz file 98 | let extension = Archive::Cbz.extension(); 99 | let download_dir_name = &comic 100 | .get_comic_download_dir_name() 101 | .context("Failed to get comic download directory name")?; 102 | let zip_path = export_dir.join(format!("{download_dir_name}.{extension}")); 103 | let zip_file = std::fs::File::create(&zip_path).context(format!( 104 | "`{comic_title}` failed to create file `{}`", 105 | zip_path.display() 106 | ))?; 107 | let mut zip_writer = ZipWriter::new(zip_file); 108 | // Write ComicInfo.xml into cbz 109 | zip_writer 110 | .start_file("ComicInfo.xml", SimpleFileOptions::default()) 111 | .context(format!( 112 | "`{comic_title}` failed to create `ComicInfo.xml` in `{}`", 113 | zip_path.display() 114 | ))?; 115 | zip_writer 116 | .write_all(comic_info_xml.as_bytes()) 117 | .context(format!("`{comic_title}` failed to write `ComicInfo.xml`"))?; 118 | // Iterate through download directory and write files into cbz 119 | let image_paths = std::fs::read_dir(download_dir) 120 | .context(format!( 121 | "`{comic_title}` failed to read directory `{}`", 122 | download_dir.display() 123 | ))? 124 | .filter_map(Result::ok) 125 | .map(|entry| entry.path()) 126 | .filter(|path| path.is_img()); 127 | for image_path in image_paths { 128 | let filename = match image_path.file_name() { 129 | Some(name) => name.to_string_lossy(), 130 | None => continue, 131 | }; 132 | // Write file into cbz 133 | zip_writer 134 | .start_file(&filename, SimpleFileOptions::default()) 135 | .context(format!( 136 | "`{comic_title}` failed to create `{filename}` in `{}`", 137 | zip_path.display() 138 | ))?; 139 | let mut file = std::fs::File::open(&image_path) 140 | .context(format!("Failed to open `{}`", image_path.display()))?; 141 | std::io::copy(&mut file, &mut zip_writer).context(format!( 142 | "`{comic_title}` failed to write `{}` to `{}`", 143 | image_path.display(), 144 | zip_path.display() 145 | ))?; 146 | } 147 | 148 | zip_writer.finish().context(format!( 149 | "`{comic_title}` failed to close `{}`", 150 | zip_path.display() 151 | ))?; 152 | // Set success to true to ensure that the end event is sent 153 | cbz_event_guard.success = true; 154 | 155 | Ok(()) 156 | } 157 | 158 | struct PdfEventGuard { 159 | uuid: String, 160 | app: AppHandle, 161 | success: bool, 162 | } 163 | 164 | impl Drop for PdfEventGuard { 165 | fn drop(&mut self) { 166 | let uuid = self.uuid.clone(); 167 | 168 | let _ = if self.success { 169 | ExportPdfEvent::End { uuid }.emit(&self.app) 170 | } else { 171 | ExportPdfEvent::Error { uuid }.emit(&self.app) 172 | }; 173 | } 174 | } 175 | 176 | pub fn pdf(app: &AppHandle, comic: &Comic) -> anyhow::Result<()> { 177 | let comic_title = &comic.title; 178 | let event_uuid = uuid::Uuid::new_v4().to_string(); 179 | 180 | let _ = ExportPdfEvent::Start { 181 | uuid: event_uuid.clone(), 182 | title: comic_title.clone(), 183 | } 184 | .emit(app); 185 | 186 | // Event guard to ensure that the error event is sent if the function panics 187 | let mut pdf_event_guard = PdfEventGuard { 188 | uuid: event_uuid.clone(), 189 | app: app.clone(), 190 | success: false, 191 | }; 192 | 193 | let download_dir = comic 194 | .comic_download_dir 195 | .as_ref() 196 | .context("`comic_download_dir` field is `None`")?; 197 | let export_dir = comic 198 | .get_comic_export_dir(app) 199 | .context("Failed to get comic export directory")?; 200 | // Ensure export directory exists 201 | std::fs::create_dir_all(&export_dir).context(format!( 202 | "Failed to create directory `{}`", 203 | export_dir.display() 204 | ))?; 205 | // Create PDF 206 | let extension = Archive::Pdf.extension(); 207 | let download_dir_name = &comic 208 | .get_comic_download_dir_name() 209 | .context("Failed to get comic download directory name")?; 210 | let pdf_path = export_dir.join(format!("{download_dir_name}.{extension}")); 211 | create_pdf(download_dir, &pdf_path).context("Failed to create PDF")?; 212 | // Set success to true to ensure that the end event is sent 213 | pdf_event_guard.success = true; 214 | 215 | Ok(()) 216 | } 217 | 218 | /// Create a PDF with images from `comic_download_dir` and save it to `pdf_path` 219 | #[allow(clippy::similar_names)] 220 | #[allow(clippy::cast_possible_truncation)] 221 | fn create_pdf(comic_download_dir: &Path, pdf_path: &Path) -> anyhow::Result<()> { 222 | let mut image_paths: Vec = std::fs::read_dir(comic_download_dir) 223 | .context(format!( 224 | "Failed to read directory `{}`", 225 | comic_download_dir.display() 226 | ))? 227 | .filter_map(Result::ok) 228 | .map(|entry| entry.path()) 229 | .filter(|path| path.is_img()) // Filter out metadata.json files 230 | .collect(); 231 | image_paths.sort_by(|a, b| a.file_name().cmp(&b.file_name())); 232 | 233 | let mut doc = Document::with_version("1.5"); 234 | let pages_id = doc.new_object_id(); 235 | let mut page_ids = vec![]; 236 | 237 | for image_path in image_paths { 238 | if !image_path.is_file() { 239 | continue; 240 | } 241 | 242 | let buffer = read_image_to_buffer(&image_path).context(format!( 243 | "Failed to read `{}` into buffer", 244 | image_path.display() 245 | ))?; 246 | let (width, height) = image::image_dimensions(&image_path).context(format!( 247 | "Failed to get dimensions of `{}`", 248 | image_path.display() 249 | ))?; 250 | let image_stream = lopdf::xobject::image_from(buffer).context(format!( 251 | "Failed to create image stream for `{}`", 252 | image_path.display() 253 | ))?; 254 | // Add image stream to doc 255 | let img_id = doc.add_object(image_stream); 256 | // Image name for the Do operation to display the image on the page 257 | let img_name = format!("X{}", img_id.0); 258 | // Used to set image position and size on the page 259 | let cm_operation = Operation::new( 260 | "cm", 261 | vec![ 262 | width.into(), 263 | 0.into(), 264 | 0.into(), 265 | height.into(), 266 | 0.into(), 267 | 0.into(), 268 | ], 269 | ); 270 | // Used to display the image 271 | let do_operation = Operation::new("Do", vec![Object::Name(img_name.as_bytes().to_vec())]); 272 | // Create a page, set the image position and size, and then display the image 273 | // Since we're creating a PDF from scratch, there's no need to use q and Q operations to save and restore graphics state 274 | let content = Content { 275 | operations: vec![cm_operation, do_operation], 276 | }; 277 | let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode()?)); 278 | let page_id = doc.add_object(dictionary! { 279 | "Type" => "Page", 280 | "Parent" => pages_id, 281 | "Contents" => content_id, 282 | "MediaBox" => vec![0.into(), 0.into(), width.into(), height.into()], 283 | }); 284 | // Add the image as XObject to the document 285 | // The Do operation can only reference XObject (that's why we defined the Do operation with img_name as parameter, not img_id) 286 | doc.add_xobject(page_id, img_name.as_bytes(), img_id)?; 287 | // Record the ID of the newly created page 288 | page_ids.push(page_id); 289 | } 290 | // Add "Pages" to the doc 291 | let pages_dict = dictionary! { 292 | "Type" => "Pages", 293 | "Count" => page_ids.len() as u32, 294 | "Kids" => page_ids.into_iter().map(Object::Reference).collect::>(), 295 | }; 296 | doc.objects.insert(pages_id, Object::Dictionary(pages_dict)); 297 | // Create a new "Catalog" object, add the "Pages" object to the "Catalog" object, then add the "Catalog" object to doc 298 | let catalog_id = doc.add_object(dictionary! { 299 | "Type" => "Catalog", 300 | "Pages" => pages_id, 301 | }); 302 | doc.trailer.set("Root", catalog_id); 303 | 304 | doc.compress(); 305 | 306 | doc.save(pdf_path) 307 | .context(format!("Failed to save `{}`", pdf_path.display()))?; 308 | Ok(()) 309 | } 310 | 311 | /// Read image data from `image_path` into a buffer 312 | fn read_image_to_buffer(image_path: &Path) -> anyhow::Result> { 313 | let file = std::fs::File::open(image_path) 314 | .context(format!("Failed to open `{}`", image_path.display()))?; 315 | let mut reader = std::io::BufReader::new(file); 316 | let mut buffer = vec![]; 317 | reader 318 | .read_to_end(&mut buffer) 319 | .context(format!("Failed to read `{}`", image_path.display()))?; 320 | Ok(buffer) 321 | } 322 | --------------------------------------------------------------------------------