diff --git a/.claude/agents/build-runner/AGENT.md b/.claude/agents/build-runner/AGENT.md new file mode 100644 index 0000000..db5f8c3 --- /dev/null +++ b/.claude/agents/build-runner/AGENT.md @@ -0,0 +1,36 @@ +--- +name: build-runner +description: Runs build checks, formatting, and code generation. Cannot modify files — only reports results. +tools: Read, Bash, Glob, Grep +model: haiku +--- + +You are a build runner for the Comine project. You execute build commands and report results concisely. + +## Available Commands + +Run from project root `/Users/rodolfo/Developer/comine`: + +| Check | Command | What it verifies | +|-------|---------|-----------------| +| TypeScript | `pnpm check` | svelte-kit sync + svelte-check | +| Rust | `cd src-tauri && cargo check` | Rust type checking | +| Format (frontend) | `pnpm format -- --check` | Prettier formatting | +| Format (Rust) | `cd src-tauri && cargo fmt -- --check` | rustfmt formatting | +| Full preflight | `pnpm preflight` | format + generate + check | + +## Workflow + +1. Run the requested check(s) +2. If there are errors, report them clearly with file paths and line numbers +3. If everything passes, report success concisely +4. Never modify files — only report what needs fixing + +## Reporting Format + +``` +✓ TypeScript check: PASS +✗ Rust check: FAIL + → src-tauri/src/lib.rs:42 — unused variable `foo` + → src-tauri/src/proxy.rs:15 — missing lifetime parameter +``` diff --git a/.claude/agents/code-reviewer/AGENT.md b/.claude/agents/code-reviewer/AGENT.md new file mode 100644 index 0000000..8bdca10 --- /dev/null +++ b/.claude/agents/code-reviewer/AGENT.md @@ -0,0 +1,54 @@ +--- +name: code-reviewer +description: Reviews code changes for project convention adherence, correctness, security, and quality. Read-only — cannot modify files. +tools: Read, Glob, Grep +model: sonnet +--- + +You are a code reviewer for the Comine project — a cross-platform media downloader (Tauri 2 + Svelte 5 + Rust). + +## Review Checklist + +### Frontend (Svelte/TypeScript) +- [ ] Uses Svelte 5 runes ($state, $derived, $effect, $props) — NO Svelte 4 syntax +- [ ] Props defined via `interface Props` + `$props()` destructuring +- [ ] Two-way binding uses `$bindable()` +- [ ] Children use `type Snippet`, not slots +- [ ] Imports use `$lib/` alias +- [ ] Types imported from `$lib/bindings` (not redefined) +- [ ] i18n: all user-facing strings use `$t()` or `translate()` +- [ ] CSS uses project variables (--accent, --radius-*, --text-*, --surface-*) +- [ ] Responsive design: mobile breakpoint at 640px, touch via `(pointer: coarse)` +- [ ] Accessibility: semantic HTML, ARIA attributes, keyboard support +- [ ] No direct DOM manipulation — use Svelte actions or runes +- [ ] Stores follow factory function or class-based pattern + +### Backend (Rust) +- [ ] Commands are `async`, return `Result` +- [ ] Shared types have `serde(rename_all = "camelCase")` + `ts_rs::TS` derives +- [ ] Errors mapped to String for commands: `.map_err(|e| e.to_string())` +- [ ] DB operations use `spawn_blocking()` +- [ ] Long tasks use `CancellationToken` +- [ ] No locks held across `.await` points +- [ ] Platform-specific code guarded with `#[cfg(...)]` +- [ ] New commands registered in `lib.rs` +- [ ] Events use kebab-case naming + +### Cross-Cutting +- [ ] No hardcoded strings that should be i18n keys +- [ ] No secrets or credentials in code +- [ ] No `console.log` / `println!` left in (use proper logging) +- [ ] Formatting consistent (2-space TS, 4-space Rust, 100 char width) +- [ ] No unused imports or dead code + +## Output Format + +For each issue found, report: +``` +[SEVERITY] file:line — Description + Suggestion: How to fix +``` + +Severities: `ERROR` (must fix), `WARN` (should fix), `INFO` (consider). + +End with a summary: total issues by severity, overall assessment (approve / request changes). diff --git a/.claude/agents/rust-dev/AGENT.md b/.claude/agents/rust-dev/AGENT.md new file mode 100644 index 0000000..370933d --- /dev/null +++ b/.claude/agents/rust-dev/AGENT.md @@ -0,0 +1,100 @@ +--- +name: rust-dev +description: Backend specialist for Rust + Tauri 2 development in the Comine project. Use for creating/editing Tauri commands, orchestrator logic, dependency specs, database operations, and system integrations. +tools: Read, Write, Edit, Bash, Glob, Grep, Agent +model: sonnet +--- + +You are a backend specialist for the Comine project — a cross-platform media downloader built with Rust + Tauri 2 + tokio + SQLite. + +## Your Responsibilities + +- Create and edit Tauri commands, backend modules, and system integrations +- Implement download orchestration logic (new backends, job management) +- Add dependency management specs (new external tools) +- Maintain cross-boundary types (Rust → TypeScript via ts-rs) +- Handle platform-specific code (#[cfg] guards) +- Ensure proper async patterns and error handling + +## Before Writing Code + +1. **Read `/src-tauri/CLAUDE.md`** — all backend conventions +2. **Read existing similar code** — find the closest module and follow its patterns +3. **Check `lib.rs`** — understand how commands are registered + +## Tauri Command Pattern (Critical) + +```rust +#[tauri::command] +async fn my_command( + app: AppHandle, + state: State<'_, Arc>, + param: String, +) -> Result { + state.do_thing(¶m).await.map_err(|e| e.to_string()) +} +``` + +- Always `async` when using State +- Always return `Result` +- Register in `lib.rs` in the `tauri::generate_handler![]` macro +- Map errors to String: `.map_err(|e| e.to_string())` + +## Type System (Critical) + +All shared types in `orchestrator/types.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct MyType { + pub field_name: String, +} +``` + +After adding/modifying types, tell the user to run `pnpm generate:bindings`. + +## Async Patterns + +- `tokio::spawn()` for fire-and-forget tasks +- `tokio::task::spawn_blocking()` for DB and CPU-heavy work +- `CancellationToken` for long-running tasks — check in progress loops +- `DashMap` for concurrent job storage (never hold locks across .await) +- Clone `AppHandle` (cheap) for spawned tasks + +## Error Handling + +- `BackendError` enum for download errors — each variant marks `is_retryable()` +- `DepsError` for dependency management errors +- Commands: `Result` — use `.map_err(|e| e.to_string())` +- Use `tracing::{info, warn, error, debug}` for logging + +## Adding a New Tauri Command + +1. Write the function with `#[tauri::command]` in the appropriate module +2. If the module has its own `mod.rs` with command re-exports, update it +3. Add to `tauri::generate_handler![]` in `lib.rs` +4. If it needs new types, add to `orchestrator/types.rs` with serde + ts-rs derives +5. Run `cargo check` to verify + +## Adding a New Backend + +1. Create module in `orchestrator/backends/` +2. Implement `Backend` trait (name, capabilities, priority, resolve, spawn) +3. Register in `BackendRegistry` in `orchestrator/backends/mod.rs` +4. Priority: return `Priority::None` for URLs this backend doesn't handle + +## Platform Guards + +```rust +#[cfg(target_os = "android")] // Android-only +#[cfg(not(target_os = "android"))] // Desktop-only +``` + +Desktop features absent on Android: tray, window effects, autostart, Discord RPC. + +## After Writing Code + +Run `cargo check` from `src-tauri/`. If you modified shared types, note that `pnpm generate:bindings` needs to run. diff --git a/.claude/agents/svelte-dev/AGENT.md b/.claude/agents/svelte-dev/AGENT.md new file mode 100644 index 0000000..1d26381 --- /dev/null +++ b/.claude/agents/svelte-dev/AGENT.md @@ -0,0 +1,91 @@ +--- +name: svelte-dev +description: Frontend specialist for Svelte 5 + SvelteKit + TypeScript development in the Comine project. Use for creating/editing components, stores, routes, utilities, actions, and composables. +tools: Read, Write, Edit, Bash, Glob, Grep, Agent +model: sonnet +--- + +You are a frontend specialist for the Comine project — a cross-platform media downloader built with SvelteKit 2 + Svelte 5 + TypeScript + Tauri 2. + +## Your Responsibilities + +- Create and edit Svelte 5 components, stores, utilities, actions, and composables +- Implement UI features following existing patterns +- Connect frontend to backend via Tauri IPC (invoke/listen) +- Maintain type safety with auto-generated bindings +- Ensure accessibility (ARIA attributes, keyboard support, semantic HTML) +- Support responsive design and theming via CSS variables + +## Before Writing Code + +1. **Read the relevant CLAUDE.md** — `/src/CLAUDE.md` has all frontend conventions +2. **Read existing similar code** — find the closest existing component/store/utility and follow its patterns +3. **Check bindings** — if your feature needs backend types, check `src/lib/bindings/index.ts` + +## Svelte 5 Rules (Critical) + +- ALWAYS use runes: `$state()`, `$derived()`, `$effect()`, `$props()`, `$bindable()` +- NEVER use Svelte 4 syntax: no `export let`, no `$:` reactive declarations, no `createEventDispatcher` +- Props: define `interface Props`, destructure with `$props()`, use `$bindable()` for bind:value +- Children: use `type Snippet` from 'svelte', not slots +- Use `$state.raw()` for large objects to avoid deep proxying overhead + +## Component Structure + +```svelte + + + +
+ +
+ + +``` + +## Store Pattern + +```typescript +function createMyStore() { + const { subscribe, set, update } = writable(initial); + return { + subscribe, + async init() { const data = await invoke('command'); set(data); }, + async action() { await invoke('command', { args }); update(s => ({ ...s, changed: true })); }, + }; +} +export const myStore = createMyStore(); +``` + +## IPC Pattern + +```typescript +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import type { ResultType } from '$lib/bindings'; + +const result = await invoke('command_name', { param: value }); +const unlisten = await listen('event-name', (e) => { /* e.payload */ }); +``` + +## After Writing Code + +Run `pnpm check` to verify TypeScript compilation. If you added new i18n keys, note that `pnpm generate:i18n-keys` needs to run. diff --git a/.claude/agents/test-runner/AGENT.md b/.claude/agents/test-runner/AGENT.md new file mode 100644 index 0000000..70e2a2b --- /dev/null +++ b/.claude/agents/test-runner/AGENT.md @@ -0,0 +1,34 @@ +--- +name: test-runner +description: Runs frontend and backend tests and reports results concisely. +tools: Read, Bash, Glob, Grep +model: haiku +--- + +You are a test runner for the Comine project. You execute tests and report results. + +## Available Test Commands + +Run from project root `/Users/rodolfo/Developer/comine`: + +| Suite | Command | Framework | +|-------|---------|-----------| +| Frontend | `pnpm test` or `npx vitest run` | Vitest | +| Backend | `cd src-tauri && cargo test` | cargo test | +| Bindings | `cd src-tauri && cargo test --features ts-export` | ts-rs export | + +## Workflow + +1. Run the requested test suite(s) +2. Parse output for pass/fail counts +3. For failures, report test name, file, and error message +4. For passes, report summary count + +## Reporting Format + +``` +Frontend tests: 12/12 passed +Backend tests: 45/47 passed + FAIL test_proxy_resolution — expected "http://...", got "socks5://..." + FAIL test_url_parsing — index out of bounds at url_utils.rs:156 +``` diff --git a/.claude/skills/check/SKILL.md b/.claude/skills/check/SKILL.md new file mode 100644 index 0000000..71e983a --- /dev/null +++ b/.claude/skills/check/SKILL.md @@ -0,0 +1,13 @@ +--- +name: check +description: Run type checking for frontend (svelte-check) and backend (cargo check) in parallel. +context: fork +agent: build-runner +--- + +Run type checks for the Comine project in parallel: + +1. Frontend: `cd /Users/rodolfo/Developer/comine && pnpm check` +2. Backend: `cd /Users/rodolfo/Developer/comine/src-tauri && cargo check` + +Report results concisely. For failures, show the specific type errors with file paths. diff --git a/.claude/skills/dev/SKILL.md b/.claude/skills/dev/SKILL.md new file mode 100644 index 0000000..fdb36e8 --- /dev/null +++ b/.claude/skills/dev/SKILL.md @@ -0,0 +1,68 @@ +--- +name: dev +description: Main orchestrator for development tasks. Analyzes the task, researches the codebase, plans the approach, and delegates to specialized subagents (svelte-dev, rust-dev) for implementation, then reviews and validates. +argument-hint: +--- + +You are the development orchestrator for the Comine project — a cross-platform media downloader (Tauri 2 + Svelte 5 + Rust). + +## Task + +$ARGUMENTS + +## Workflow + +### Step 1: Classify the Task + +Determine the task type: +- **frontend-only**: UI changes, component work, store updates, styling → delegate to `svelte-dev` +- **backend-only**: Rust logic, new commands, orchestrator changes → delegate to `rust-dev` +- **full-stack**: Feature spanning both layers → delegate to both agents sequentially (backend first for types, then frontend) +- **refactor**: Code restructuring → use appropriate agent(s) +- **bug-fix**: Diagnose first (explore), then fix with appropriate agent + +### Step 2: Research + +Before any implementation: +1. Use the Explore agent to search the codebase for relevant files, existing patterns, and related code +2. Identify ALL files that need modification +3. Understand the current implementation context +4. Check if similar functionality already exists + +### Step 3: Plan + +Create a concise implementation plan: +- List files to create/modify +- Describe changes per file +- Note any cross-boundary impacts (types, bindings, i18n) +- Identify risks or edge cases + +### Step 4: Implement + +Delegate to specialized subagents: +- **svelte-dev** for frontend changes +- **rust-dev** for backend changes +- Run agents in parallel when changes are independent +- Run sequentially when frontend depends on backend types + +### Step 5: Review + +Use the **code-reviewer** agent to validate: +- Convention adherence +- Type safety +- Accessibility +- Platform compatibility + +### Step 6: Validate + +Use the **build-runner** agent to run: +- `cargo check` (if Rust was modified) +- `pnpm check` (if TypeScript/Svelte was modified) + +### Step 7: Report + +Summarize what was done: +- Files created/modified +- Key decisions made +- Any manual steps needed (e.g., `pnpm generate:bindings`, new i18n keys) +- Known limitations or follow-up work diff --git a/.claude/skills/feature/SKILL.md b/.claude/skills/feature/SKILL.md new file mode 100644 index 0000000..3dfaaa2 --- /dev/null +++ b/.claude/skills/feature/SKILL.md @@ -0,0 +1,49 @@ +--- +name: feature +description: Implement a new feature end-to-end. Researches, plans, implements across frontend and backend, reviews, and validates. +argument-hint: +--- + +You are implementing a new feature in the Comine project — a cross-platform media downloader (Tauri 2 + Svelte 5 + Rust). + +## Feature Request + +$ARGUMENTS + +## Process + +### 1. Research Phase + +Use the Explore agent to understand: +- Existing related functionality (search for similar features) +- Files that will be affected +- Patterns used in similar features +- Backend types and commands available +- Frontend components and stores involved + +### 2. Design Phase + +Outline the implementation: +- **Backend changes** (if any): new types, commands, modifications to orchestrator/deps +- **Frontend changes** (if any): new components, store updates, route changes, i18n keys +- **Type bridge**: any new types that need Rust → TypeScript bindings +- **Edge cases**: platform differences, error states, loading states + +### 3. Implementation Phase + +Execute in order: +1. **Backend types** (if new types needed) — add to `orchestrator/types.rs` with serde + ts-rs derives +2. **Backend logic** — new commands, business logic. Use `rust-dev` agent. +3. **Generate bindings** — note that `pnpm generate:bindings` needs to run if types changed +4. **Frontend implementation** — components, stores, i18n. Use `svelte-dev` agent. +5. **Generate i18n keys** — note if `pnpm generate:i18n-keys` is needed + +### 4. Quality Phase + +- Use `code-reviewer` agent to review all changes +- Use `build-runner` agent to verify compilation +- List any manual testing steps needed + +### 5. Summary + +Report: files changed, what was implemented, any follow-up needed. diff --git a/.claude/skills/fix/SKILL.md b/.claude/skills/fix/SKILL.md new file mode 100644 index 0000000..4576aa6 --- /dev/null +++ b/.claude/skills/fix/SKILL.md @@ -0,0 +1,44 @@ +--- +name: fix +description: Diagnose and fix a bug. Investigates the issue, identifies root cause, implements the fix, and validates. +argument-hint: +--- + +You are fixing a bug in the Comine project — a cross-platform media downloader (Tauri 2 + Svelte 5 + Rust). + +## Bug Report + +$ARGUMENTS + +## Process + +### 1. Investigate + +- Search for the error message, relevant function names, or affected UI elements +- Read the relevant source files to understand the current behavior +- Trace the data flow (frontend → IPC → backend or vice versa) +- Check if this is a platform-specific issue + +### 2. Root Cause + +Identify and clearly state: +- What is happening vs. what should happen +- The exact location(s) of the bug +- Why the current code produces the wrong behavior + +### 3. Fix + +- Make the minimal change to fix the bug +- Do NOT refactor surrounding code +- Do NOT add features while fixing +- Preserve existing patterns and conventions + +### 4. Validate + +- Run `cargo check` and/or `pnpm check` as appropriate +- Describe how to verify the fix manually +- Note any edge cases that should be tested + +### 5. Summary + +Report: root cause, what was changed, how to verify. diff --git a/.claude/skills/new-command/SKILL.md b/.claude/skills/new-command/SKILL.md new file mode 100644 index 0000000..9628103 --- /dev/null +++ b/.claude/skills/new-command/SKILL.md @@ -0,0 +1,61 @@ +--- +name: new-command +description: Scaffold a new Tauri command with proper Rust function, command registration, and TypeScript types. +argument-hint: [module] +--- + +Create a new Tauri command in the Comine project. + +## Arguments + +Command: $ARGUMENTS + +Parse the arguments: +- First word = command_name (snake_case) +- Second word (optional) = module to place it in (orchestrator, deps, or a top-level module like lib.rs) + +## Steps + +### 1. Create the Command Function + +In the appropriate module: + +```rust +#[tauri::command] +async fn command_name( + app: AppHandle, + // Add State<'_, Arc> if needed + // Add parameters +) -> Result { + // Implementation + Ok(result) +} +``` + +### 2. Define Types (if needed) + +In `src-tauri/src/orchestrator/types.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct NewType { + pub field: String, +} +``` + +### 3. Register the Command + +Add to `tauri::generate_handler![]` in `src-tauri/src/lib.rs`. + +### 4. Verify + +Run `cargo check` from `src-tauri/`. + +### 5. Report + +- Show the command signature +- Show TypeScript usage: `invoke('command_name', { param: value })` +- Note if `pnpm generate:bindings` needs to run diff --git a/.claude/skills/new-component/SKILL.md b/.claude/skills/new-component/SKILL.md new file mode 100644 index 0000000..4b36eca --- /dev/null +++ b/.claude/skills/new-component/SKILL.md @@ -0,0 +1,53 @@ +--- +name: new-component +description: Scaffold a new Svelte 5 component following project conventions. +argument-hint: [feature-group] +--- + +Create a new Svelte 5 component in the Comine project. + +## Arguments + +Component: $ARGUMENTS + +Parse the arguments: +- First word = ComponentName (PascalCase) +- Second word (optional) = feature group directory (ui, layout, download, settings, resolve, media, builders, providers) +- If no group specified, infer from the component's purpose or ask + +## Template + +Create `src/lib/components/{group}/{ComponentName}.svelte`: + +```svelte + + +
+ {#if children} + {@render children()} + {/if} +
+ + +``` + +## After Scaffolding + +1. Read 2-3 existing components in the same group to match patterns +2. Customize the template based on the component's purpose +3. Add appropriate ARIA attributes and keyboard handling +4. Add responsive styles if needed (`@media (max-width: 640px)`) +5. Explain the component's usage pattern diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md new file mode 100644 index 0000000..4189a4f --- /dev/null +++ b/.claude/skills/preflight/SKILL.md @@ -0,0 +1,17 @@ +--- +name: preflight +description: Run all pre-commit checks — formatting, code generation, and type checking. +context: fork +agent: build-runner +--- + +Run the full preflight check suite for the Comine project: + +1. Check Rust formatting: `cd src-tauri && cargo fmt -- --check` +2. Check frontend formatting: `cd /Users/rodolfo/Developer/comine && npx prettier --check "src/**/*.{ts,svelte,js}"` +3. Run Rust check: `cd src-tauri && cargo check` +4. Run TypeScript/Svelte check: `cd /Users/rodolfo/Developer/comine && pnpm check` + +Run steps 1-2 in parallel (formatting checks), then steps 3-4 in parallel (type checks). + +Report results for each step. For failures, include the specific errors with file paths and line numbers. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md new file mode 100644 index 0000000..b53e48f --- /dev/null +++ b/.claude/skills/review/SKILL.md @@ -0,0 +1,17 @@ +--- +name: review +description: Review recent code changes or specific files for quality, conventions, and correctness. +context: fork +agent: code-reviewer +argument-hint: [file or area to review, or blank for recent changes] +--- + +Review the following in the Comine project: + +$ARGUMENTS + +If no specific files were given, review recent uncommitted changes by running `git diff` and `git diff --cached`. + +Follow your review checklist thoroughly. Check frontend conventions (Svelte 5 runes, accessibility, i18n, CSS variables), backend conventions (command signatures, error handling, type system, async patterns), and cross-cutting concerns (no hardcoded strings, no secrets, proper logging, consistent formatting). + +Report findings with file:line references and severity levels. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..30dcac0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,85 @@ +# Comine + +Cross-platform media downloader. Tauri 2 + Svelte 5 + Rust. Targets Windows, macOS, Linux, Android. + +## Dev Commands + +```bash +pnpm dev # Frontend dev server (port 1420) +pnpm tauri dev # Full app dev (frontend + Rust) +pnpm tauri dev --target android # Android dev + +# iOS build + deploy (MUST use this sequence, NOT `pnpm tauri ios dev`) +cd src-tauri/gen/apple && xcodegen generate # 1. Regenerate Xcode project from project.yml +pnpm tauri ios build --debug # 2. Build debug IPA +ios-deploy --bundle src-tauri/gen/apple/build/arm64/comine.ipa # 3. Deploy to device +pnpm build # Production frontend build +pnpm check # svelte-kit sync + svelte-check +pnpm format # Prettier (frontend) +pnpm format:rust # cargo fmt (backend) +pnpm generate:bindings # Rust types → TypeScript (src/lib/bindings/) +pnpm generate:i18n-keys # en.json → TranslationKeys union type +pnpm preflight # format + generate + check (run before committing) +pnpm version:set # Bump version across all configs +cd src-tauri && cargo check # Rust type checking +cd src-tauri && cargo test # Rust tests +``` + +## Architecture + +``` +src/ # SvelteKit frontend (Svelte 5 + TypeScript) + routes/ # Pages: home, downloads, settings, logs, info, notification + lib/stores/ # State: settings, history, queue, logs, navigation, deps + lib/components/ # UI organized by feature: ui/, layout/, download/, settings/, resolve/, media/ + lib/backend/ # Tauri IPC bridge (invoke + listen) + lib/bindings/ # Auto-generated TypeScript types from Rust (DO NOT edit manually) + lib/i18n/ # Translations (en, ru) + generated key types + lib/utils/ # Pure helpers (format, url, color, platform) + lib/composables/ # Reactive logic (remoteSync, clipboardHandler, extensionBridge) + lib/actions/ # Svelte actions (tooltip, spotlight, portal, edgeMask) + +src-tauri/src/ # Rust backend + lib.rs # App setup, command registration (~50 commands), plugin init + orchestrator/ # Download job management (JobManager, JobStore, HistoryStore) + backends/ # Downloader impls: ytdlp/, aria2, gallery_dl/, direct + deps/ # External tool management (yt-dlp, ffmpeg, aria2, gallery-dl, etc.) + specs/ # Per-dependency install/update/check logic + engine/ # Download, extract, checksum, verify pipeline + database.rs # SQLite (WAL mode): history, jobs, stats tables + clipboard.rs # Clipboard URL watcher (500ms poll) + proxy.rs # System/custom proxy detection + caching + relay.rs # WebSocket relay for remote pairing (AES-256-GCM) + server.rs # Local HTTP server for browser extension + notifications.rs # Positioned notification windows + updater.rs # GitHub releases auto-updater + +scripts/ # Build utilities (version bumping, binding gen, i18n key gen) +``` + +## IPC Contract + +Frontend calls Rust via `invoke('command_name', { args })`. Backend emits events via `app.emit("event-name", data)`. All shared types live in `src-tauri/src/orchestrator/types.rs` and are exported to TypeScript via `ts-rs` with `#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]`. + +**Type flow**: Rust struct → `pnpm generate:bindings` → `src/lib/bindings/index.ts` → frontend imports. + +## Conventions + +- **Formatting**: Prettier (frontend, 100 char width, single quotes, 2-space indent) + rustfmt (backend, 100 char width, 4-space indent) +- **Naming**: PascalCase for types/components, camelCase for functions/variables/stores, snake_case for Rust, kebab-case for CSS vars and events +- **Serde**: All cross-boundary types use `#[serde(rename_all = "camelCase")]` +- **Imports**: Use `$lib/` alias for all frontend library imports +- **Events**: kebab-case naming (`job-progress`, `history-item-added`) +- **Errors**: Tauri commands return `Result`. Backend errors use `BackendError` enum with `is_retryable()`. +- **Platform guards**: `#[cfg(target_os = "android")]` / `#[cfg(not(target_os = "android"))]` for platform-specific code +- **State**: Frontend uses Svelte 5 runes ($state, $derived, $effect) + Svelte stores for global state. Backend uses DashMap, RwLock, Atomic* for concurrency. +- **No manual edits** to `src/lib/bindings/` — always regenerate with `pnpm generate:bindings` +- **No manual edits** to `src/lib/i18n/keys.ts` — always regenerate with `pnpm generate:i18n-keys` + +## Platform Considerations + +- Android: No tray, no window effects, no autostart, no Discord RPC. Uses JNI for file operations. +- Linux: Updater disabled (package manager handles updates). +- Windows: Set `PYTHONIOENCODING=utf-8` for yt-dlp subprocess. Acrylic/Mica window effects. +- macOS: Vibrancy effects. Universal binary (arm64 + x64). +- iOS: Do NOT use `pnpm tauri ios dev` — it produces a black screen. Always use `xcodegen generate` + `pnpm tauri ios build --debug` + `ios-deploy`. Clear DerivedData (`rm -rf ~/Library/Developer/Xcode/DerivedData/comine-*`) if builds behave unexpectedly. XcodeGen project config lives in `src-tauri/gen/apple/project.yml`. diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md new file mode 100644 index 0000000..6142487 --- /dev/null +++ b/scripts/CLAUDE.md @@ -0,0 +1,28 @@ +# Scripts — Build Utilities + +All scripts are Node.js, run via pnpm. + +## Scripts + +| Script | Command | What it does | +|--------|---------|--------------| +| `bump-version.js` | `pnpm version:set ` | Updates version in package.json, Cargo.toml, tauri.conf.json, gradle.properties | +| `generate-bindings.js` | `pnpm generate:bindings` | Runs `cargo test --features ts-export`, collects ts-rs output, replaces `bigint` → `number`, generates barrel `index.ts` in `src/lib/bindings/` | +| `generate-i18n-keys.js` | `pnpm generate:i18n-keys` | Reads `src/lib/i18n/locales/en.json`, generates `TranslationKeys` union type in `src/lib/i18n/keys.ts` | + +## Code Generation Pipeline + +After modifying Rust types with `#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]`: +1. `pnpm generate:bindings` → updates `src/lib/bindings/` +2. `pnpm check` → verifies TypeScript still compiles + +After modifying `src/lib/i18n/locales/en.json`: +1. `pnpm generate:i18n-keys` → updates `src/lib/i18n/keys.ts` + +## Version Format + +Semver: `MAJOR.MINOR.PATCH` or `MAJOR.MINOR.PATCH-prerelease`. Android versionCode = `MAJOR*1000000 + MINOR*1000 + PATCH`. + +## Preflight (Pre-Commit) + +`pnpm preflight` runs: format → generate bindings → generate i18n keys → svelte-check. Always run before committing. diff --git a/src-tauri/CLAUDE.md b/src-tauri/CLAUDE.md new file mode 100644 index 0000000..c848b59 --- /dev/null +++ b/src-tauri/CLAUDE.md @@ -0,0 +1,143 @@ +# Backend — Rust + Tauri 2 + +## Tauri Command Pattern + +All commands follow this signature: + +```rust +#[tauri::command] +async fn command_name( + app: AppHandle, + state: State<'_, Arc>, // or other managed state + param: ParamType, +) -> Result { + // ... + Ok(result) +} +``` + +- Commands return `Result` — map errors with `.map_err(|e| e.to_string())` +- Register in `lib.rs` via `tauri::generate_handler![command_name]` +- Async commands run on a thread pool — never block the main thread +- Access managed state via `State<'_, T>` — Tauri wraps in Arc internally +- For spawned tasks, clone `AppHandle` (cheap) and use `app.state::()` + +## Module Structure + +``` +src/ +├── lib.rs # Setup + all command registrations +├── orchestrator/ +│ ├── mod.rs # Command handlers (resolve_url, start_job, control_job, etc.) +│ ├── manager.rs # JobManager — central download coordinator +│ ├── types.rs # ALL shared types (Job, UrlInfo, DownloadRequest, etc.) +│ ├── store.rs # JobStore — SQLite persistence for active jobs +│ ├── history.rs # HistoryStore — completed download records +│ ├── stats.rs # StatsStore — aggregate download statistics +│ ├── convert.rs # FFmpeg conversion tasks +│ ├── thumbnail.rs # Thumbnail caching +│ └── backends/ +│ ├── mod.rs # Backend trait + BackendRegistry +│ ├── common.rs # Shared utils (URL parsing, MIME, proxy resolution) +│ ├── ytdlp/ # Primary backend (subprocess management) +│ ├── aria2.rs # Parallel download backend +│ ├── gallery_dl/ # Image gallery backend +│ └── direct.rs # Fallback HTTP download +├── deps/ +│ ├── mod.rs, commands.rs # Dependency check/install/uninstall commands +│ ├── error.rs # DepsError, DownloadError, ExtractError +│ ├── updater.rs # Auto-update checker for deps +│ ├── specs/ # Per-dependency: ytdlp, ffmpeg, aria2, gallery_dl, deno, quickjs, lux +│ └── engine/ # Download → extract → checksum → verify pipeline +└── [clipboard, database, proxy, relay, server, notifications, updater, ...] +``` + +## Type System + +All cross-boundary types live in `orchestrator/types.rs`. Requirements: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct MyType { + pub field_name: String, // → camelCase in TS + pub optional: Option, // → number | null in TS +} +``` + +- `#[serde(rename_all = "camelCase")]` on ALL shared types +- `#[cfg_attr(feature = "ts-export", derive(ts_rs::TS))]` for TypeScript generation +- Tagged enums: `#[serde(tag = "type", content = "data")]` for Job status variants +- After modifying types, run `pnpm generate:bindings` to update TypeScript + +## Error Handling + +**Download errors** — `BackendError` enum in `orchestrator/types.rs`: +```rust +pub enum BackendError { + NotFound(String), // 404 + Forbidden(String), // 403 + RateLimited { retry_after: Option }, // 429 + NetworkError(String), + ProcessError { code: Option, stderr: String }, + // ... +} +``` +Each variant has `is_retryable() -> bool`. + +**Dependency errors** — `DepsError` in `deps/error.rs` with nested Download/Extract/Verification variants. + +**Command errors** — always `Result`. Convert with `.map_err(|e| e.to_string())`. + +## Async Patterns + +- `tokio::spawn()` — fire-and-forget async tasks (job execution, event emission) +- `tokio::task::spawn_blocking()` — DB operations, CPU-heavy work (thumbnail processing) +- `CancellationToken` — all long-running tasks check `token.is_cancelled()` in their progress loops +- `DashMap` — lock-free concurrent map for jobs, running tasks +- `RwLock` — backend registry, download settings +- `Atomic*` — simple counters (active_count, max_concurrent, speed_limit) +- `Notify` — signal settings changes, persistence triggers + +## Backend Trait (Adding New Downloaders) + +```rust +#[async_trait] +pub trait Backend: Send + Sync { + fn name(&self) -> &str; + fn capabilities(&self) -> BackendCapabilities; + fn priority(&self, url: &str) -> Priority; // No network — fast URL matching only + async fn resolve(&self, url: &str, settings: &ResolveSettings) -> Result; + async fn spawn(&self, ctx: SpawnContext) -> Result; +} +``` + +Register in `BackendRegistry` in `orchestrator/backends/mod.rs`. + +## Event Emission + +```rust +// Emit to frontend +app.emit("job-progress", &JobEvent::Progress { id, progress, speed, eta })?; + +// Event naming: kebab-case +// Payload: serialize as JSON automatically +``` + +## Database + +SQLite with WAL mode, 5s busy timeout, NORMAL synchronous. Tables: `history`, `jobs`, `stats`. Access via `Mutex` with `lock_or_recover()` for poisoned mutex safety. All DB operations run in `spawn_blocking()`. + +## Platform Conditionals + +```rust +#[cfg(target_os = "android")] // Android-only +#[cfg(not(target_os = "android"))] // Desktop-only +#[cfg(target_os = "windows")] // Windows-only +#[cfg(target_os = "macos")] // macOS-only +#[cfg(target_os = "linux")] // Linux-only +``` + +Desktop features not on Android: tray, window effects, autostart, Discord RPC, file reveal. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 386457a..f3c0df8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -15,7 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -158,6 +158,15 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -170,6 +179,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "assert_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e2651f366b7ee3f97729fded1441539b49d5f39eeb05b842689e11e84501b2" +dependencies = [ + "const_panic", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -296,6 +314,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "async-task" version = "4.7.1" @@ -359,6 +399,40 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.17", + "instant", + "rand 0.8.5", +] + [[package]] name = "base64" version = "0.21.7" @@ -371,6 +445,35 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -404,7 +507,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -727,6 +830,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -755,6 +867,7 @@ dependencies = [ "image", "jni", "libc", + "librqbit", "log", "lru", "opener", @@ -825,6 +938,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -947,7 +1069,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1045,6 +1167,7 @@ dependencies = [ "lock_api", "once_cell", "parking_lot_core", + "serde", ] [[package]] @@ -1110,6 +1233,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs" version = "4.0.0" @@ -1500,6 +1632,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1536,6 +1674,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1636,6 +1780,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -1762,6 +1912,15 @@ dependencies = [ "x11", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1926,6 +2085,29 @@ dependencies = [ "system-deps", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2015,7 +2197,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2023,6 +2205,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2409,7 +2596,25 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "intervaltree" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270bc34e57047cab801a8c871c124d9dc7132f6473c6401f645524f4e6edd111" +dependencies = [ + "smallvec", ] [[package]] @@ -2447,6 +2652,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2569,6 +2783,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leaky-bucket" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a396bb213c2d09ed6c5495fd082c991b6ab39c9daf4fff59e6727f85c73e4c5" +dependencies = [ + "parking_lot", + "pin-project-lite", + "tokio", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2620,6 +2845,222 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "librqbit" +version = "8.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dadca8f521242010a4c846ef5f224c217009c92e272709cdc08ba9cdabe62983" +dependencies = [ + "anyhow", + "arc-swap", + "async-compression", + "async-stream", + "async-trait", + "backoff", + "base64 0.22.1", + "bincode 2.0.1", + "bitvec", + "byteorder", + "bytes", + "dashmap", + "futures", + "governor", + "hex", + "http", + "intervaltree", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "librqbit-dht", + "librqbit-peer-protocol", + "librqbit-sha1-wrapper", + "librqbit-tracker-comms", + "librqbit-upnp", + "memmap2", + "mime_guess", + "parking_lot", + "rand 0.9.2", + "regex", + "reqwest", + "rlimit", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with", + "size_format", + "tokio", + "tokio-socks", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "uuid", + "walkdir", +] + +[[package]] +name = "librqbit-bencode" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606dff526ba81e3eca33e2bb28b53afa2bc0b2c41d252333fa44e6c11abb37da" +dependencies = [ + "anyhow", + "bytes", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-sha1-wrapper", + "serde", +] + +[[package]] +name = "librqbit-buffers" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c78b907d6171a7191c162b2b60db46d254ebde6a95282b77372af556c1463" +dependencies = [ + "bytes", + "librqbit-clone-to-owned", + "serde", +] + +[[package]] +name = "librqbit-clone-to-owned" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd1e66d773ba9c475ff89286dc1d6f9d167cbb898603797467dd0ea6844c445" +dependencies = [ + "bytes", +] + +[[package]] +name = "librqbit-core" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a02cc6fce6743ad38661ccd6fafc6cf1ae5e0106a9922836b0524dbe752378" +dependencies = [ + "anyhow", + "assert_cfg", + "bytes", + "data-encoding", + "directories", + "hex", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "parking_lot", + "rand 0.9.2", + "serde", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "librqbit-dht" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7cc129194337771a86b0399956c4d9bf1cd97c5f24d14a50be38e170f76a54b" +dependencies = [ + "anyhow", + "backoff", + "byteorder", + "bytes", + "chrono", + "dashmap", + "futures", + "hex", + "indexmap 2.13.0", + "leaky-bucket", + "librqbit-bencode", + "librqbit-clone-to-owned", + "librqbit-core", + "parking_lot", + "rand 0.9.2", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "librqbit-peer-protocol" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a73129497b500505f33d1dc0426319b6a6a208f13fdfaae56224ab8c2346a773" +dependencies = [ + "anyhow", + "bincode 1.3.3", + "bitvec", + "byteorder", + "bytes", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "serde", +] + +[[package]] +name = "librqbit-sha1-wrapper" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79373a02db73159e4de7ca5d27b6eeae2d540df66c6801db2b01c5513d087524" +dependencies = [ + "assert_cfg", + "aws-lc-rs", +] + +[[package]] +name = "librqbit-tracker-comms" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08204944c5be677a5de8e1230e0249fce5c14abef23048e26452c6fb03f1b260" +dependencies = [ + "anyhow", + "async-stream", + "byteorder", + "futures", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-core", + "parking_lot", + "rand 0.9.2", + "reqwest", + "serde", + "tokio", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "librqbit-upnp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545aad6124c97201055983137e12a19f34acad565120c3cd30596cbd72e8fa86" +dependencies = [ + "anyhow", + "bstr", + "futures", + "httparse", + "network-interface", + "quick-xml 0.37.5", + "reqwest", + "serde", + "tokio", + "tracing", + "url", +] + [[package]] name = "libsqlite3-sys" version = "0.28.0" @@ -2752,6 +3193,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2767,6 +3217,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minisign-verify" version = "0.2.4" @@ -2855,6 +3315,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "network-interface" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" +dependencies = [ + "cc", + "libc", + "thiserror 2.0.18", + "winapi", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2876,6 +3348,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "normpath" version = "1.5.0" @@ -2899,12 +3377,66 @@ dependencies = [ "zbus", ] +[[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3559,6 +4091,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -3696,6 +4234,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -3709,6 +4262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", + "serde", ] [[package]] @@ -3906,6 +4460,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4085,7 +4648,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4118,6 +4681,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rlimit" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" +dependencies = [ + "libc", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -4208,7 +4780,7 @@ checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4561,6 +5133,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "size_format" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed5f6ab2122c6dec69dca18c72fa4590a27e581ad20d44960fe74c032a0b23b" +dependencies = [ + "generic-array 0.12.4", + "num", +] + [[package]] name = "slab" version = "0.4.11" @@ -4631,6 +5213,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5455,6 +6046,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -5464,6 +6067,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -5767,6 +6371,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + [[package]] name = "uds_windows" version = "1.1.0" @@ -5819,6 +6429,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -5841,12 +6457,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -5932,6 +6560,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2edf776..f714b39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,6 +73,10 @@ thiserror = "1" regex = "1" opener = "0.8.4" shell-words = "1.1.1" +# Torrent/magnet downloads — used by LibrqbitBackend. +# Minimal feature set: rust-tls matches the reqwest rustls-tls already in use, and we +# explicitly disable the heavy http-api / webui features that pull in axum and JS assets. +librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls"] } [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 0000000..c262c34 --- /dev/null +++ b/src-tauri/capabilities/mobile.json @@ -0,0 +1,42 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "platforms": ["android", "iOS"], + "description": "Capability for mobile platforms (Android + iOS)", + "windows": ["main"], + "permissions": [ + "core:default", + { + "identifier": "opener:default", + "allow": [{ "path": "**" }] + }, + "opener:allow-open-path", + "opener:allow-open-url", + "store:default", + "dialog:default", + { + "identifier": "fs:default", + "allow": [{ "path": "**" }] + }, + "fs:allow-exists", + "fs:allow-read", + "fs:allow-write", + "fs:allow-write-text-file", + "fs:allow-create", + "fs:allow-stat", + "fs:read-all", + "clipboard-manager:default", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", + "notification:default", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus" + ] +} diff --git a/src-tauri/gen/apple/.gitignore b/src-tauri/gen/apple/.gitignore new file mode 100644 index 0000000..6726e2f --- /dev/null +++ b/src-tauri/gen/apple/.gitignore @@ -0,0 +1,3 @@ +xcuserdata/ +build/ +Externals/ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 0000000..a6ac2a8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 0000000..cf265a4 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 0000000..29c9746 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 0000000..a4e68c8 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e4adcbc Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 0000000..2869541 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 0000000..a414e65 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000..704c929 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 0000000..a0807e5 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 0000000..2a9fbc2 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 0000000..2cdf184 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 0000000..4723e4b Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..f26fee4 Binary files /dev/null and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90eea7e --- /dev/null +++ b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "AppIcon-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "AppIcon-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "AppIcon-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "AppIcon-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "AppIcon-20x20@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "AppIcon-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "AppIcon-40x40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "AppIcon-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "AppIcon-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "AppIcon-512@2x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/src-tauri/gen/apple/Assets.xcassets/Contents.json b/src-tauri/gen/apple/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/src-tauri/gen/apple/ComineLiveActivity/ComineLiveActivity.entitlements b/src-tauri/gen/apple/ComineLiveActivity/ComineLiveActivity.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/src-tauri/gen/apple/ComineLiveActivity/ComineLiveActivity.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/src-tauri/gen/apple/ComineLiveActivity/DownloadLiveActivity.swift b/src-tauri/gen/apple/ComineLiveActivity/DownloadLiveActivity.swift new file mode 100644 index 0000000..d9a62bd --- /dev/null +++ b/src-tauri/gen/apple/ComineLiveActivity/DownloadLiveActivity.swift @@ -0,0 +1,138 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@available(iOS 16.1, *) +struct DownloadLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: DownloadAttributes.self) { context in + // Lock Screen / Banner view + lockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + // Expanded view (long-press Dynamic Island) + DynamicIslandExpandedRegion(.leading) { + Label(context.attributes.title, systemImage: "arrow.down.circle.fill") + .font(.caption) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + Text(formatSpeed(context.state.speedBps)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + DynamicIslandExpandedRegion(.bottom) { + VStack(spacing: 4) { + ProgressView(value: context.state.progress) + .tint(.blue) + HStack { + Text("\(Int(context.state.progress * 100))%") + .font(.caption2.monospacedDigit()) + Spacer() + if context.state.eta > 0 && !context.state.isFinished { + Text(formatETA(context.state.eta)) + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + } + } + } + } + } compactLeading: { + // Compact leading (left pill) + Image(systemName: "arrow.down.circle.fill") + .foregroundColor(.blue) + } compactTrailing: { + // Compact trailing (right pill) + Text("\(Int(context.state.progress * 100))%") + .font(.caption2.monospacedDigit()) + } minimal: { + // Minimal (single circle when another activity is present) + ProgressView(value: context.state.progress) + .progressViewStyle(.circular) + .tint(.blue) + } + } + } + + @ViewBuilder + func lockScreenView(context: ActivityViewContext) -> some View { + VStack(spacing: 8) { + HStack { + Image(systemName: "arrow.down.circle.fill") + .foregroundColor(.blue) + Text(context.attributes.title) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Spacer() + if context.state.isFinished { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Text(formatSpeed(context.state.speedBps)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + } + ProgressView(value: context.state.progress) + .tint(context.state.isFinished ? .green : .blue) + HStack { + Text(formatBytes(context.state.downloadedBytes)) + .font(.caption2.monospacedDigit()) + if context.state.totalBytes > 0 { + Text("/ \(formatBytes(context.state.totalBytes))") + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + } + Spacer() + if context.state.eta > 0 && !context.state.isFinished { + Text(formatETA(context.state.eta)) + .font(.caption2.monospacedDigit()) + .foregroundColor(.secondary) + } + } + } + .padding() + } +} + +// MARK: - Formatting Helpers + +private func formatSpeed(_ bps: Int64) -> String { + let units = ["B/s", "KB/s", "MB/s", "GB/s"] + var value = Double(bps) + var unitIdx = 0 + while value >= 1024 && unitIdx < units.count - 1 { + value /= 1024 + unitIdx += 1 + } + return String(format: "%.1f %@", value, units[unitIdx]) +} + +private func formatBytes(_ bytes: Int64) -> String { + let units = ["B", "KB", "MB", "GB"] + var value = Double(bytes) + var unitIdx = 0 + while value >= 1024 && unitIdx < units.count - 1 { + value /= 1024 + unitIdx += 1 + } + return unitIdx == 0 + ? String(format: "%.0f %@", value, units[unitIdx]) + : String(format: "%.1f %@", value, units[unitIdx]) +} + +private func formatETA(_ seconds: Int) -> String { + if seconds < 60 { return "\(seconds)s" } + if seconds < 3600 { return "\(seconds / 60)m \(seconds % 60)s" } + return "\(seconds / 3600)h \(seconds % 3600 / 60)m" +} + +// MARK: - Widget Bundle + +@available(iOS 16.1, *) +@main +struct ComineLiveActivityBundle: WidgetBundle { + var body: some Widget { + DownloadLiveActivity() + } +} diff --git a/src-tauri/gen/apple/ComineLiveActivity/Info.plist b/src-tauri/gen/apple/ComineLiveActivity/Info.plist new file mode 100644 index 0000000..636f033 --- /dev/null +++ b/src-tauri/gen/apple/ComineLiveActivity/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Comine Downloads + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.1.0 + CFBundleVersion + 1.1.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/src-tauri/gen/apple/ExportOptions.plist b/src-tauri/gen/apple/ExportOptions.plist new file mode 100644 index 0000000..0428a17 --- /dev/null +++ b/src-tauri/gen/apple/ExportOptions.plist @@ -0,0 +1,8 @@ + + + + + method + debugging + + diff --git a/src-tauri/gen/apple/LaunchScreen.storyboard b/src-tauri/gen/apple/LaunchScreen.storyboard new file mode 100644 index 0000000..81b5f90 --- /dev/null +++ b/src-tauri/gen/apple/LaunchScreen.storyboard @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/apple/Podfile b/src-tauri/gen/apple/Podfile new file mode 100644 index 0000000..e1c9197 --- /dev/null +++ b/src-tauri/gen/apple/Podfile @@ -0,0 +1,21 @@ +# Uncomment the next line to define a global platform for your project + +target 'comine_iOS' do +platform :ios, '14.0' + # Pods for comine_iOS +end + +target 'comine_macOS' do +platform :osx, '11.0' + # Pods for comine_macOS +end + +# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' + config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' + end + end +end diff --git a/src-tauri/gen/apple/Shared/DownloadAttributes.swift b/src-tauri/gen/apple/Shared/DownloadAttributes.swift new file mode 100644 index 0000000..75f1942 --- /dev/null +++ b/src-tauri/gen/apple/Shared/DownloadAttributes.swift @@ -0,0 +1,19 @@ +import ActivityKit +import Foundation + +/// Defines the Live Activity / Dynamic Island content for an active download. +struct DownloadAttributes: ActivityAttributes { + /// Fixed context that doesn't change after the activity starts. + public struct ContentState: Codable, Hashable { + var progress: Double // 0.0 – 1.0 + var speedBps: Int64 // bytes per second + var downloadedBytes: Int64 + var totalBytes: Int64 + var eta: Int // seconds remaining + var isFinished: Bool + } + + /// Static data set when the activity is created. + var title: String + var jobId: String +} diff --git a/src-tauri/gen/apple/Sources/comine/BackgroundAudioService.swift b/src-tauri/gen/apple/Sources/comine/BackgroundAudioService.swift new file mode 100644 index 0000000..5db8ba8 --- /dev/null +++ b/src-tauri/gen/apple/Sources/comine/BackgroundAudioService.swift @@ -0,0 +1,114 @@ +import AVFoundation +import UIKit + +/// Keeps the app alive in the background by playing a silent audio loop +/// and cycling background tasks. Mirrors the iTorrent approach. +class BackgroundAudioService: NSObject { + static let shared = BackgroundAudioService() + + private var audioPlayer: AVAudioPlayer? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + private(set) var isRunning = false + + private override init() { + super.init() + } + + func start() { + guard !isRunning else { return } + isRunning = true + + configureAudioSession() + startSilentAudio() + beginBackgroundTask() + + NSLog("[BackgroundAudio] Started background keep-alive") + } + + func stop() { + guard isRunning else { return } + isRunning = false + + audioPlayer?.stop() + audioPlayer = nil + endBackgroundTask() + + // Deactivate audio session so other apps can use audio normally + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + + NSLog("[BackgroundAudio] Stopped background keep-alive") + } + + private func configureAudioSession() { + let session = AVAudioSession.sharedInstance() + do { + // .playback category + .mixWithOthers so we don't interrupt other audio (e.g. VLC) + try session.setCategory(.playback, options: .mixWithOthers) + try session.setActive(true) + } catch { + NSLog("[BackgroundAudio] Failed to configure audio session: \(error)") + } + } + + private func startSilentAudio() { + guard let url = Bundle.main.url(forResource: "silence", withExtension: "m4a") else { + NSLog("[BackgroundAudio] silence.m4a not found in bundle") + return + } + + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.numberOfLoops = -1 // Loop forever + audioPlayer?.volume = 0.01 // Near-silent + audioPlayer?.prepareToPlay() + audioPlayer?.play() + } catch { + NSLog("[BackgroundAudio] Failed to start audio player: \(error)") + } + } + + /// Cycle UIBackgroundTaskIdentifier to prevent the OS from suspending us. + /// Each task gets ~30 seconds; we renew before expiry. + private func beginBackgroundTask() { + endBackgroundTask() + + backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in + // Expiration handler — renew the task + self?.beginBackgroundTask() + } + + // Proactively renew every 10 seconds (well before the ~30s expiry) + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in + guard let self, self.isRunning else { return } + self.beginBackgroundTask() + } + } + + private func endBackgroundTask() { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + } +} + +// MARK: - FFI exports for Rust + +@_cdecl("start_background_audio") +func startBackgroundAudio() { + DispatchQueue.main.async { + BackgroundAudioService.shared.start() + } +} + +@_cdecl("stop_background_audio") +func stopBackgroundAudio() { + DispatchQueue.main.async { + BackgroundAudioService.shared.stop() + } +} + +@_cdecl("is_background_audio_running") +func isBackgroundAudioRunning() -> Bool { + return BackgroundAudioService.shared.isRunning +} diff --git a/src-tauri/gen/apple/Sources/comine/LiveActivityManager.swift b/src-tauri/gen/apple/Sources/comine/LiveActivityManager.swift new file mode 100644 index 0000000..ea10e2c --- /dev/null +++ b/src-tauri/gen/apple/Sources/comine/LiveActivityManager.swift @@ -0,0 +1,176 @@ +import Foundation + +#if canImport(ActivityKit) +import ActivityKit +#endif + +/// Manages Live Activities for download progress (Dynamic Island + Lock Screen). +/// Requires iOS 16.1+. Gracefully no-ops on older versions. +class LiveActivityManager { + static let shared = LiveActivityManager() + + #if canImport(ActivityKit) + @available(iOS 16.2, *) + private var currentActivity: Activity? { + get { _currentActivity as? Activity } + set { _currentActivity = newValue } + } + #endif + + private var _currentActivity: Any? + private var activeJobId: String? + + private init() {} + + func start(jobId: String, title: String) { + #if canImport(ActivityKit) + guard #available(iOS 16.2, *) else { return } + + // End any existing activity first + stop() + + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + NSLog("[LiveActivity] Activities not authorized") + return + } + + let attributes = DownloadAttributes(title: title, jobId: jobId) + let state = DownloadAttributes.ContentState( + progress: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 0, + eta: 0, + isFinished: false + ) + + do { + let activity = try Activity.request( + attributes: attributes, + content: .init(state: state, staleDate: nil), + pushType: nil + ) + currentActivity = activity + activeJobId = jobId + NSLog("[LiveActivity] Started for job \(jobId)") + } catch { + NSLog("[LiveActivity] Failed to start: \(error)") + } + #endif + } + + func update( + jobId: String, + progress: Double, + speedBps: Int64, + downloadedBytes: Int64, + totalBytes: Int64, + eta: Int + ) { + #if canImport(ActivityKit) + guard #available(iOS 16.2, *) else { return } + guard activeJobId == jobId, let activity = currentActivity else { return } + + let state = DownloadAttributes.ContentState( + progress: min(max(progress, 0), 1), + speedBps: speedBps, + downloadedBytes: downloadedBytes, + totalBytes: totalBytes, + eta: eta, + isFinished: false + ) + + Task { + await activity.update(.init(state: state, staleDate: nil)) + } + #endif + } + + func finish(jobId: String) { + #if canImport(ActivityKit) + guard #available(iOS 16.2, *) else { return } + guard activeJobId == jobId, let activity = currentActivity else { return } + + let state = DownloadAttributes.ContentState( + progress: 1.0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 0, + eta: 0, + isFinished: true + ) + + Task { + let content = ActivityContent(state: state, staleDate: nil) + await activity.end(content, dismissalPolicy: ActivityUIDismissalPolicy.after(Date.now.addingTimeInterval(5))) + NSLog("[LiveActivity] Finished for job \(jobId)") + } + + activeJobId = nil + _currentActivity = nil + #endif + } + + func stop() { + #if canImport(ActivityKit) + guard #available(iOS 16.2, *) else { return } + + if let activity = currentActivity { + Task { + let policy = ActivityUIDismissalPolicy.immediate + await activity.end(nil as ActivityContent?, dismissalPolicy: policy) + } + } + activeJobId = nil + _currentActivity = nil + #endif + } +} + +// MARK: - FFI exports for Rust + +@_cdecl("live_activity_start") +func liveActivityStart(_ jobIdPtr: UnsafePointer, _ titlePtr: UnsafePointer) { + let jobId = String(cString: jobIdPtr) + let title = String(cString: titlePtr) + DispatchQueue.main.async { + LiveActivityManager.shared.start(jobId: jobId, title: title) + } +} + +@_cdecl("live_activity_update") +func liveActivityUpdate( + _ jobIdPtr: UnsafePointer, + _ progress: Double, + _ speedBps: Int64, + _ downloadedBytes: Int64, + _ totalBytes: Int64, + _ eta: Int32 +) { + let jobId = String(cString: jobIdPtr) + DispatchQueue.main.async { + LiveActivityManager.shared.update( + jobId: jobId, + progress: progress, + speedBps: speedBps, + downloadedBytes: downloadedBytes, + totalBytes: totalBytes, + eta: Int(eta) + ) + } +} + +@_cdecl("live_activity_finish") +func liveActivityFinish(_ jobIdPtr: UnsafePointer) { + let jobId = String(cString: jobIdPtr) + DispatchQueue.main.async { + LiveActivityManager.shared.finish(jobId: jobId) + } +} + +@_cdecl("live_activity_stop") +func liveActivityStop() { + DispatchQueue.main.async { + LiveActivityManager.shared.stop() + } +} diff --git a/src-tauri/gen/apple/Sources/comine/ViewportFix.swift b/src-tauri/gen/apple/Sources/comine/ViewportFix.swift new file mode 100644 index 0000000..f7902b8 --- /dev/null +++ b/src-tauri/gen/apple/Sources/comine/ViewportFix.swift @@ -0,0 +1,116 @@ +import UIKit +import WebKit + +private func applyViewportFix() { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }), + let rootVC = window.rootViewController else { + NSLog("[ViewportFix] No root VC found") + return + } + + let screenBounds = UIScreen.main.bounds + NSLog("[ViewportFix] Screen: \(screenBounds), VC view: \(rootVC.view.frame), safe: \(rootVC.view.safeAreaInsets)") + + // Extend layout behind all bars (status bar, home indicator) + rootVC.edgesForExtendedLayout = .all + rootVC.extendedLayoutIncludesOpaqueBars = true + + // Force the VC view to full screen + rootVC.view.frame = screenBounds + + // Negate safe area insets so content fills edge-to-edge + let safe = rootVC.view.safeAreaInsets + rootVC.additionalSafeAreaInsets = UIEdgeInsets( + top: -safe.top, left: -safe.left, + bottom: -safe.bottom, right: -safe.right + ) + + // Find the WKWebView in the view hierarchy + func findWebView(in view: UIView) -> WKWebView? { + if let wv = view as? WKWebView { return wv } + for sub in view.subviews { + if let wv = findWebView(in: sub) { return wv } + } + return nil + } + + if let webview = findWebView(in: rootVC.view) { + webview.frame = screenBounds + webview.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webview.scrollView.contentInsetAdjustmentBehavior = .never + webview.backgroundColor = .clear + webview.scrollView.backgroundColor = .clear + webview.isOpaque = false + + // Force layout recalculation + webview.setNeedsLayout() + webview.layoutIfNeeded() + rootVC.view.setNeedsLayout() + rootVC.view.layoutIfNeeded() + + NSLog("[ViewportFix] Applied — webview frame: \(webview.frame), screen: \(screenBounds)") + } else { + NSLog("[ViewportFix] WKWebView NOT found in hierarchy") + } +} + +// MARK: - Open file via system "Open In…" sheet (UIDocumentInteractionController) + +/// Delegate that retains itself until the interaction is dismissed. +private class DocInteractionDelegate: NSObject, UIDocumentInteractionControllerDelegate { + static var current: DocInteractionDelegate? + var controller: UIDocumentInteractionController? + + func documentInteractionControllerViewControllerForPreview( + _ controller: UIDocumentInteractionController + ) -> UIViewController { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { + return UIViewController() + } + return rootVC + } + + func documentInteractionControllerDidDismissOpenInMenu( + _ controller: UIDocumentInteractionController + ) { + DocInteractionDelegate.current = nil + } +} + +@_cdecl("ios_open_file") +func iosOpenFile(_ pathPtr: UnsafePointer) { + let path = String(cString: pathPtr) + let url = URL(fileURLWithPath: path) + + DispatchQueue.main.async { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { + NSLog("[OpenFile] No root VC") + return + } + + let delegate = DocInteractionDelegate() + let dic = UIDocumentInteractionController(url: url) + dic.delegate = delegate + delegate.controller = dic + DocInteractionDelegate.current = delegate + + // Try "Open In…" menu first; falls back to options menu + if !dic.presentOpenInMenu(from: .zero, in: rootVC.view, animated: true) { + dic.presentOptionsMenu(from: .zero, in: rootVC.view, animated: true) + } + } +} + +@_cdecl("fix_ios_viewport") +func fixIosViewport() { + NSLog("[ViewportFix] Called from Rust") + // Apply with multiple delays — the view hierarchy may not be ready immediately + for delay in [0.0, 0.1, 0.5, 1.0, 2.0] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + applyViewportFix() + } + } +} diff --git a/src-tauri/gen/apple/Sources/comine/ViewportFixLoader.m b/src-tauri/gen/apple/Sources/comine/ViewportFixLoader.m new file mode 100644 index 0000000..611dfbe --- /dev/null +++ b/src-tauri/gen/apple/Sources/comine/ViewportFixLoader.m @@ -0,0 +1,93 @@ +#import +#import +#import + +static WKWebView* findWebView(UIView *view) { + if ([view isKindOfClass:[WKWebView class]]) return (WKWebView *)view; + for (UIView *sub in view.subviews) { + WKWebView *wv = findWebView(sub); + if (wv) return wv; + } + return nil; +} + +static void applyViewportFix(void) { + UIWindowScene *scene = (UIWindowScene *)UIApplication.sharedApplication.connectedScenes.anyObject; + if (!scene) { NSLog(@"[ViewportFix] No scene"); return; } + + UIWindow *window = nil; + for (UIWindow *w in scene.windows) { + if (w.isKeyWindow) { window = w; break; } + } + if (!window) { NSLog(@"[ViewportFix] No key window"); return; } + + UIViewController *rootVC = window.rootViewController; + if (!rootVC) { NSLog(@"[ViewportFix] No root VC"); return; } + + CGRect screenBounds = UIScreen.mainScreen.bounds; + NSLog(@"[ViewportFix] Screen: %@ | VC view: %@ | Safe: %@", + NSStringFromCGRect(screenBounds), + NSStringFromCGRect(rootVC.view.frame), + NSStringFromUIEdgeInsets(rootVC.view.safeAreaInsets)); + + // Extend layout behind status bar and home indicator + rootVC.edgesForExtendedLayout = UIRectEdgeAll; + rootVC.extendedLayoutIncludesOpaqueBars = YES; + + // Force VC view to full screen + rootVC.view.frame = screenBounds; + + // Negate safe area so content fills edge-to-edge + UIEdgeInsets safe = rootVC.view.safeAreaInsets; + rootVC.additionalSafeAreaInsets = UIEdgeInsetsMake( + -safe.top, -safe.left, -safe.bottom, -safe.right + ); + + WKWebView *webview = findWebView(rootVC.view); + if (webview) { + webview.frame = screenBounds; + webview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + webview.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + webview.backgroundColor = UIColor.clearColor; + webview.scrollView.backgroundColor = UIColor.clearColor; + webview.opaque = NO; + + [webview setNeedsLayout]; + [webview layoutIfNeeded]; + [rootVC.view setNeedsLayout]; + [rootVC.view layoutIfNeeded]; + + NSLog(@"[ViewportFix] Applied — webview: %@", NSStringFromCGRect(webview.frame)); + } else { + NSLog(@"[ViewportFix] WKWebView NOT found"); + } +} + +@interface ViewportFixLoader : NSObject +@end + +@implementation ViewportFixLoader + ++ (void)load { + // +load is called by the ObjC runtime before main() — guaranteed to execute + NSLog(@"[ViewportFix] +load called, registering observer"); + + __block id observer = nil; + observer = [[NSNotificationCenter defaultCenter] + addObserverForName:UISceneDidActivateNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + NSLog(@"[ViewportFix] Scene activated, applying fix"); + // Apply multiple times to catch any relayout + applyViewportFix(); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ applyViewportFix(); }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ applyViewportFix(); }); + // Remove observer after first activation + [[NSNotificationCenter defaultCenter] removeObserver:observer]; + }]; +} + +@end diff --git a/src-tauri/gen/apple/Sources/comine/bindings/bindings.h b/src-tauri/gen/apple/Sources/comine/bindings/bindings.h new file mode 100644 index 0000000..5152200 --- /dev/null +++ b/src-tauri/gen/apple/Sources/comine/bindings/bindings.h @@ -0,0 +1,8 @@ +#pragma once + +namespace ffi { + extern "C" { + void start_app(); + } +} + diff --git a/src-tauri/gen/apple/Sources/comine/main.mm b/src-tauri/gen/apple/Sources/comine/main.mm new file mode 100644 index 0000000..7793a9d --- /dev/null +++ b/src-tauri/gen/apple/Sources/comine/main.mm @@ -0,0 +1,6 @@ +#include "bindings/bindings.h" + +int main(int argc, char * argv[]) { + ffi::start_app(); + return 0; +} diff --git a/src-tauri/gen/apple/assets/silence.m4a b/src-tauri/gen/apple/assets/silence.m4a new file mode 100644 index 0000000..9e7bdab Binary files /dev/null and b/src-tauri/gen/apple/assets/silence.m4a differ diff --git a/src-tauri/gen/apple/comine.xcodeproj/project.pbxproj b/src-tauri/gen/apple/comine.xcodeproj/project.pbxproj new file mode 100644 index 0000000..afe0db9 --- /dev/null +++ b/src-tauri/gen/apple/comine.xcodeproj/project.pbxproj @@ -0,0 +1,894 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 084773E991C484AF3D88497E /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D225C27F353C4914DFE5BEB9 /* Security.framework */; }; + 233070012AD6467C47654FE0 /* ViewportFixLoader.m in Sources */ = {isa = PBXBuildFile; fileRef = 16790D3B5E16F2D270D6F978 /* ViewportFixLoader.m */; }; + 26E2DE39AC5C062BEC82B21A /* DownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F40C2D48680E9AB7B4551DA4 /* DownloadAttributes.swift */; }; + 304CBDF1046B77C54115890A /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 88FCB3165D84B18A8E1299AA /* main.mm */; }; + 348C7CFCC4AAC268E0759FA6 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EE670208F47CE0E292A9B8B9 /* MetalKit.framework */; }; + 388993C01E142F77FDECCF8E /* DownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F40C2D48680E9AB7B4551DA4 /* DownloadAttributes.swift */; }; + 426BE6756B1C7C35A56DC261 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 652404EA12AFBD08F18CA25F /* CoreGraphics.framework */; }; + 5B541394DB3FF9BD561846F8 /* BackgroundAudioService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C3B1EB4205545E6B8141F26 /* BackgroundAudioService.swift */; }; + 6ADB4ADEE822FE49DAD455CC /* ComineLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 523B308AB60E89D77237D7B3 /* ComineLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7BAA632733E174AEEE9DF4EC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 613FE5DF3F8DF8FF06FEB72F /* Assets.xcassets */; }; + 7E3EAAA68089E204567F1432 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEA4880A865C245B05D4CF02 /* AVFoundation.framework */; }; + 971991A3C6BD2A158C688B32 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76BD5711C83419D39BB9B723 /* Metal.framework */; }; + 9DDB3903632746A4656A8712 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DB1A13CA9D3F28A87C4AD9C /* WidgetKit.framework */; }; + A2892754AA1291986965C574 /* DownloadLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A2748602A575C09CDA81ED /* DownloadLiveActivity.swift */; }; + BFA4CC4CACB5556062663C72 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1192FA1DEBE015A1F296AA53 /* SwiftUI.framework */; }; + C96557C08B8B4A0EBA732ADE /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F7EC1011DF2149A07B55014 /* WebKit.framework */; }; + CB29CA78C323DB8AE50F0BA1 /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C8E5C31DAA4098AAC5992FD /* libapp.a */; }; + D07B2165DC116F085FF7C436 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 26FDCD6274BAFF0079151066 /* LaunchScreen.storyboard */; }; + D1BEF2000637E073CF764A04 /* ViewportFix.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161A20120ED3944542CC6B30 /* ViewportFix.swift */; }; + D6DA1A3FB13A95DC0508287F /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED786BEF02757C2E91E6E286 /* AudioToolbox.framework */; }; + D93437F1D6C21E32DBD6683B /* assets in Resources */ = {isa = PBXBuildFile; fileRef = CBDDCB0D21B7BBF2CDF2F23E /* assets */; }; + DA49D437020B8E0451925665 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76A9B5E0D05129C8D971FCA9 /* QuartzCore.framework */; }; + ED7884EA88409FB3D12C47BD /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263CDEA16FA91225D1459DA3 /* LiveActivityManager.swift */; }; + F1FED381678818F15F51013E /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFBE588CF6424A6354C1838F /* UIKit.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7DA75587BAF7E1583C420AF5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 103A09618A6B4A481BEBFD17 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6A5BE9C68B1E639C306AC806; + remoteInfo = ComineLiveActivity; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + BF0F5DDB652427E648253B1B /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 6ADB4ADEE822FE49DAD455CC /* ComineLiveActivity.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 02EBF78AD24E2CB742180D23 /* types.rs */ = {isa = PBXFileReference; path = types.rs; sourceTree = ""; }; + 06CA522A3D4778179AE6C432 /* store_utils.rs */ = {isa = PBXFileReference; path = store_utils.rs; sourceTree = ""; }; + 0C8E5C31DAA4098AAC5992FD /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + 0CBC9860C2FD2125C7435A60 /* lux.rs */ = {isa = PBXFileReference; path = lux.rs; sourceTree = ""; }; + 0DB1A13CA9D3F28A87C4AD9C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 10C6EEDF490F9FBEBB956DD5 /* types.rs */ = {isa = PBXFileReference; path = types.rs; sourceTree = ""; }; + 1192FA1DEBE015A1F296AA53 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 119F0709B3E8B73992EDEE82 /* media_info.rs */ = {isa = PBXFileReference; path = media_info.rs; sourceTree = ""; }; + 1238139B249DFE2259AFB971 /* commands.rs */ = {isa = PBXFileReference; path = commands.rs; sourceTree = ""; }; + 139D9A0AC78C337E11E6F18F /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 161A20120ED3944542CC6B30 /* ViewportFix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportFix.swift; sourceTree = ""; }; + 16790D3B5E16F2D270D6F978 /* ViewportFixLoader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewportFixLoader.m; sourceTree = ""; }; + 17F1DC64D3902DBEC1F74406 /* updater.rs */ = {isa = PBXFileReference; path = updater.rs; sourceTree = ""; }; + 18285E2CC85730DEDC92F07C /* store.rs */ = {isa = PBXFileReference; path = store.rs; sourceTree = ""; }; + 1BD12BCC0D8E1257A816C79E /* checksum.rs */ = {isa = PBXFileReference; path = checksum.rs; sourceTree = ""; }; + 240CBE77E9533554D769EE9B /* whisper.rs */ = {isa = PBXFileReference; path = whisper.rs; sourceTree = ""; }; + 2498C207517117161D809721 /* logs.rs */ = {isa = PBXFileReference; path = logs.rs; sourceTree = ""; }; + 263CDEA16FA91225D1459DA3 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 26FDCD6274BAFF0079151066 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 2C3B1EB4205545E6B8141F26 /* BackgroundAudioService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAudioService.swift; sourceTree = ""; }; + 3133BAEF6D00872061A35754 /* server.rs */ = {isa = PBXFileReference; path = server.rs; sourceTree = ""; }; + 33AB0853173EA1CED317037F /* process.rs */ = {isa = PBXFileReference; path = process.rs; sourceTree = ""; }; + 33E1F47E891C595B7350800F /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + 35B595DCF09E8809BB5E5AEB /* tray_icon.rs */ = {isa = PBXFileReference; path = tray_icon.rs; sourceTree = ""; }; + 3650A2B2CC22C4EE05FD7C92 /* relay.rs */ = {isa = PBXFileReference; path = relay.rs; sourceTree = ""; }; + 39494F60CA566D50B89257F3 /* clipboard.rs */ = {isa = PBXFileReference; path = clipboard.rs; sourceTree = ""; }; + 3A3C787D9832118BA09E0E98 /* manager.rs */ = {isa = PBXFileReference; path = manager.rs; sourceTree = ""; }; + 3A9A68FE5257BAEDAFC22751 /* tray.rs */ = {isa = PBXFileReference; path = tray.rs; sourceTree = ""; }; + 3BD9F4FC4AB06D9D996F929B /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = ""; }; + 3F94807E038EE03F6400FF04 /* convert.rs */ = {isa = PBXFileReference; path = convert.rs; sourceTree = ""; }; + 400FC02B8A204C71C172D60C /* notifications.rs */ = {isa = PBXFileReference; path = notifications.rs; sourceTree = ""; }; + 423FFDFB8C6367C29A6877D3 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 47A2748602A575C09CDA81ED /* DownloadLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivity.swift; sourceTree = ""; }; + 480D139D700F3DBAD5DB9514 /* utils.rs */ = {isa = PBXFileReference; path = utils.rs; sourceTree = ""; }; + 4AA852A462CF03D3B278A8F0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4CE655D388436D35CF3C000A /* window_manager.rs */ = {isa = PBXFileReference; path = window_manager.rs; sourceTree = ""; }; + 4E22AF42FCC0CCC754230833 /* gallery_dl.rs */ = {isa = PBXFileReference; path = gallery_dl.rs; sourceTree = ""; }; + 523B308AB60E89D77237D7B3 /* ComineLiveActivity.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ComineLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 540EC26CAA62FA0876B2E097 /* progress.rs */ = {isa = PBXFileReference; path = progress.rs; sourceTree = ""; }; + 548E22D8EE2C121D9F292899 /* deno.rs */ = {isa = PBXFileReference; path = deno.rs; sourceTree = ""; }; + 5DB5DC0AC3861FBCECBC7FD3 /* js_runtime.rs */ = {isa = PBXFileReference; path = js_runtime.rs; sourceTree = ""; }; + 5DC36797D2FA389885350B35 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 60A6105C9BDE5FA4423641B2 /* fs.rs */ = {isa = PBXFileReference; path = fs.rs; sourceTree = ""; }; + 613FE5DF3F8DF8FF06FEB72F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 64617FD428A0E3370A37C286 /* stats.rs */ = {isa = PBXFileReference; path = stats.rs; sourceTree = ""; }; + 652404EA12AFBD08F18CA25F /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 6546728CDC9C0A3EACF5A079 /* database.rs */ = {isa = PBXFileReference; path = database.rs; sourceTree = ""; }; + 654BCC9DC159017BA2A31A3D /* proxy.rs */ = {isa = PBXFileReference; path = proxy.rs; sourceTree = ""; }; + 664374AA96206676463DEB89 /* direct.rs */ = {isa = PBXFileReference; path = direct.rs; sourceTree = ""; }; + 670517EEE6AF5722103D566E /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 6B0752CF97149934DA242F02 /* thumbnail_color.rs */ = {isa = PBXFileReference; path = thumbnail_color.rs; sourceTree = ""; }; + 6D1371C2E99645A3FF854D99 /* ComineLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ComineLiveActivity.entitlements; sourceTree = ""; }; + 7126F13ED85EA8E158C9FDEE /* podcast.rs */ = {isa = PBXFileReference; path = podcast.rs; sourceTree = ""; }; + 7397AB966429B922CB6956E3 /* shared.rs */ = {isa = PBXFileReference; path = shared.rs; sourceTree = ""; }; + 73DA6AA4C5C195D2E5ACAD39 /* verify.rs */ = {isa = PBXFileReference; path = verify.rs; sourceTree = ""; }; + 76A9B5E0D05129C8D971FCA9 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + 76BD5711C83419D39BB9B723 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + 806450D665BD7DC572AE2C41 /* ytdlp.rs */ = {isa = PBXFileReference; path = ytdlp.rs; sourceTree = ""; }; + 86046325181D7E94605B31FE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 88FCB3165D84B18A8E1299AA /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + 8ADCAF540C2666EA1BDF7407 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + 8B7B1FC202C7FAEDBF7EA804 /* comine_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = comine_iOS.entitlements; sourceTree = ""; }; + 8F7EC1011DF2149A07B55014 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + 9070F630F0ECC98FEBC58734 /* window_effects.rs */ = {isa = PBXFileReference; path = window_effects.rs; sourceTree = ""; }; + 90A76DEBD0AB19D5C8F1FC25 /* progress.rs */ = {isa = PBXFileReference; path = progress.rs; sourceTree = ""; }; + 912F05474954277FEB481887 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + 934F2887825FAFD7416BD7D7 /* aria2.rs */ = {isa = PBXFileReference; path = aria2.rs; sourceTree = ""; }; + 9729922CF510CF7C67D82D0E /* edge_tts.rs */ = {isa = PBXFileReference; path = edge_tts.rs; sourceTree = ""; }; + 99EA0556D03EDD66545DA63A /* discord.rs */ = {isa = PBXFileReference; path = discord.rs; sourceTree = ""; }; + 9C5F8F83C8FEC778DBE5B497 /* installer.rs */ = {isa = PBXFileReference; path = installer.rs; sourceTree = ""; }; + 9DC161AFC5726B16A012D1CF /* args.rs */ = {isa = PBXFileReference; path = args.rs; sourceTree = ""; }; + A0F82EB86AF255BD25975B9B /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = ""; }; + A4E72385F3FA4F782754A6F8 /* quickjs.rs */ = {isa = PBXFileReference; path = quickjs.rs; sourceTree = ""; }; + AFBDE2546C2482CC0DEF81EF /* thumbnail.rs */ = {isa = PBXFileReference; path = thumbnail.rs; sourceTree = ""; }; + B31CB0D4639AFA8AFCEB142F /* torrent_search.rs */ = {isa = PBXFileReference; path = torrent_search.rs; sourceTree = ""; }; + B71E82B799B26AF1FC643856 /* error.rs */ = {isa = PBXFileReference; path = error.rs; sourceTree = ""; }; + B8919E2367C9990BF5AEA841 /* media_stream.rs */ = {isa = PBXFileReference; path = media_stream.rs; sourceTree = ""; }; + BAC193B045293362C99D364B /* json.rs */ = {isa = PBXFileReference; path = json.rs; sourceTree = ""; }; + BD2F2A92902B2FB26420B41A /* updater.rs */ = {isa = PBXFileReference; path = updater.rs; sourceTree = ""; }; + C6E0C75D630F949AF9F27BAF /* librqbit.rs */ = {isa = PBXFileReference; path = librqbit.rs; sourceTree = ""; }; + CBB0620BD41FCC335FDEF607 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; + CBDDCB0D21B7BBF2CDF2F23E /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + CD032B398E5B9C9436081AAD /* ffmpeg.rs */ = {isa = PBXFileReference; path = ffmpeg.rs; sourceTree = ""; }; + CEA4880A865C245B05D4CF02 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + D055DDA730071E9272162D6A /* download.rs */ = {isa = PBXFileReference; path = download.rs; sourceTree = ""; }; + D225C27F353C4914DFE5BEB9 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + D5D7F2EB7A4CA8EF3D5B0CCE /* common.rs */ = {isa = PBXFileReference; path = common.rs; sourceTree = ""; }; + D72E7597F03483FC19097281 /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + DD0C9037588D6F82A1983094 /* aria2.rs */ = {isa = PBXFileReference; path = aria2.rs; sourceTree = ""; }; + E15621166248F7C01C5A020C /* comine_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = comine_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E17FC347DA3ECED9D7B66708 /* cancel.rs */ = {isa = PBXFileReference; path = cancel.rs; sourceTree = ""; }; + E6E490972881AEDB1509F370 /* android_jni.rs */ = {isa = PBXFileReference; path = android_jni.rs; sourceTree = ""; }; + ED786BEF02757C2E91E6E286 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; }; + EDCED786175ABD43D6A3416D /* mod.rs */ = {isa = PBXFileReference; path = mod.rs; sourceTree = ""; }; + EE670208F47CE0E292A9B8B9 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + F102C44A7E5D68A1450E72D4 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = ""; }; + F214F1FBEE1C83D5E75A9923 /* process.rs */ = {isa = PBXFileReference; path = process.rs; sourceTree = ""; }; + F3363EE1ACE394265815D045 /* extract.rs */ = {isa = PBXFileReference; path = extract.rs; sourceTree = ""; }; + F40C2D48680E9AB7B4551DA4 /* DownloadAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAttributes.swift; sourceTree = ""; }; + F431A337C04C1F28E98424D5 /* history.rs */ = {isa = PBXFileReference; path = history.rs; sourceTree = ""; }; + F8F3B111AA6A680C6D74ED83 /* common.rs */ = {isa = PBXFileReference; path = common.rs; sourceTree = ""; }; + FB33F64320749D16B048DBB2 /* url_utils.rs */ = {isa = PBXFileReference; path = url_utils.rs; sourceTree = ""; }; + FFBE588CF6424A6354C1838F /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 47E3266392EF8C30F97C14D7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BFA4CC4CACB5556062663C72 /* SwiftUI.framework in Frameworks */, + 9DDB3903632746A4656A8712 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C17BC2BE330389C656FAE924 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CB29CA78C323DB8AE50F0BA1 /* libapp.a in Frameworks */, + 426BE6756B1C7C35A56DC261 /* CoreGraphics.framework in Frameworks */, + 971991A3C6BD2A158C688B32 /* Metal.framework in Frameworks */, + 348C7CFCC4AAC268E0759FA6 /* MetalKit.framework in Frameworks */, + DA49D437020B8E0451925665 /* QuartzCore.framework in Frameworks */, + 084773E991C484AF3D88497E /* Security.framework in Frameworks */, + F1FED381678818F15F51013E /* UIKit.framework in Frameworks */, + C96557C08B8B4A0EBA732ADE /* WebKit.framework in Frameworks */, + 7E3EAAA68089E204567F1432 /* AVFoundation.framework in Frameworks */, + D6DA1A3FB13A95DC0508287F /* AudioToolbox.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 008FF62A3BEDE692EE11993B /* ytdlp */ = { + isa = PBXGroup; + children = ( + F102C44A7E5D68A1450E72D4 /* android.rs */, + 9DC161AFC5726B16A012D1CF /* args.rs */, + F8F3B111AA6A680C6D74ED83 /* common.rs */, + BAC193B045293362C99D364B /* json.rs */, + 423FFDFB8C6367C29A6877D3 /* mod.rs */, + 33AB0853173EA1CED317037F /* process.rs */, + 90A76DEBD0AB19D5C8F1FC25 /* progress.rs */, + 7397AB966429B922CB6956E3 /* shared.rs */, + ); + path = ytdlp; + sourceTree = ""; + }; + 0233E6DAA467A716795E7D6F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED786BEF02757C2E91E6E286 /* AudioToolbox.framework */, + CEA4880A865C245B05D4CF02 /* AVFoundation.framework */, + 652404EA12AFBD08F18CA25F /* CoreGraphics.framework */, + 0C8E5C31DAA4098AAC5992FD /* libapp.a */, + 76BD5711C83419D39BB9B723 /* Metal.framework */, + EE670208F47CE0E292A9B8B9 /* MetalKit.framework */, + 76A9B5E0D05129C8D971FCA9 /* QuartzCore.framework */, + D225C27F353C4914DFE5BEB9 /* Security.framework */, + 1192FA1DEBE015A1F296AA53 /* SwiftUI.framework */, + FFBE588CF6424A6354C1838F /* UIKit.framework */, + 8F7EC1011DF2149A07B55014 /* WebKit.framework */, + 0DB1A13CA9D3F28A87C4AD9C /* WidgetKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 045CFB77D82F49336C6A7563 /* ComineLiveActivity */ = { + isa = PBXGroup; + children = ( + 6D1371C2E99645A3FF854D99 /* ComineLiveActivity.entitlements */, + 47A2748602A575C09CDA81ED /* DownloadLiveActivity.swift */, + 4AA852A462CF03D3B278A8F0 /* Info.plist */, + ); + path = ComineLiveActivity; + sourceTree = ""; + }; + 14056DBA29772FA0D40F6D3D /* release */ = { + isa = PBXGroup; + children = ( + CBB0620BD41FCC335FDEF607 /* libapp.a */, + ); + path = release; + sourceTree = ""; + }; + 2E2C5460911E020FBAF759A2 /* arm64 */ = { + isa = PBXGroup; + children = ( + F69C16A9C122A5DD7AD2F928 /* debug */, + 14056DBA29772FA0D40F6D3D /* release */, + ); + path = arm64; + sourceTree = ""; + }; + 3B11E4DF769039363FD2EC1F /* Shared */ = { + isa = PBXGroup; + children = ( + F40C2D48680E9AB7B4551DA4 /* DownloadAttributes.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 496F2E0B6A656E4CD65B6112 /* deps */ = { + isa = PBXGroup; + children = ( + 1238139B249DFE2259AFB971 /* commands.rs */, + B71E82B799B26AF1FC643856 /* error.rs */, + D72E7597F03483FC19097281 /* mod.rs */, + BD2F2A92902B2FB26420B41A /* updater.rs */, + 7869289569DD67B1C68F24DA /* engine */, + F3493816AC8981A0281E95BA /* specs */, + ); + path = deps; + sourceTree = ""; + }; + 505BAC054C287E715435A328 /* Sources */ = { + isa = PBXGroup; + children = ( + 645D35136E78C01B18BC0043 /* comine */, + ); + path = Sources; + sourceTree = ""; + }; + 645D35136E78C01B18BC0043 /* comine */ = { + isa = PBXGroup; + children = ( + 2C3B1EB4205545E6B8141F26 /* BackgroundAudioService.swift */, + 263CDEA16FA91225D1459DA3 /* LiveActivityManager.swift */, + 88FCB3165D84B18A8E1299AA /* main.mm */, + 161A20120ED3944542CC6B30 /* ViewportFix.swift */, + 16790D3B5E16F2D270D6F978 /* ViewportFixLoader.m */, + B34982C98F0F3D4AD2F0E912 /* bindings */, + ); + path = comine; + sourceTree = ""; + }; + 6AC901204EF2FF18475BCC76 = { + isa = PBXGroup; + children = ( + CBDDCB0D21B7BBF2CDF2F23E /* assets */, + 613FE5DF3F8DF8FF06FEB72F /* Assets.xcassets */, + 26FDCD6274BAFF0079151066 /* LaunchScreen.storyboard */, + 91E51CE9ECD4CEB4CFB5BED3 /* comine_iOS */, + 045CFB77D82F49336C6A7563 /* ComineLiveActivity */, + CD01FB3B9F32882BF13177F1 /* Externals */, + 3B11E4DF769039363FD2EC1F /* Shared */, + 505BAC054C287E715435A328 /* Sources */, + 8723285716C38667863216DB /* src */, + 0233E6DAA467A716795E7D6F /* Frameworks */, + 6CD847DE18E6D825BC33900B /* Products */, + ); + sourceTree = ""; + }; + 6CD847DE18E6D825BC33900B /* Products */ = { + isa = PBXGroup; + children = ( + E15621166248F7C01C5A020C /* comine_iOS.app */, + 523B308AB60E89D77237D7B3 /* ComineLiveActivity.appex */, + ); + name = Products; + sourceTree = ""; + }; + 7869289569DD67B1C68F24DA /* engine */ = { + isa = PBXGroup; + children = ( + E17FC347DA3ECED9D7B66708 /* cancel.rs */, + 1BD12BCC0D8E1257A816C79E /* checksum.rs */, + D055DDA730071E9272162D6A /* download.rs */, + F3363EE1ACE394265815D045 /* extract.rs */, + 60A6105C9BDE5FA4423641B2 /* fs.rs */, + 9C5F8F83C8FEC778DBE5B497 /* installer.rs */, + 8ADCAF540C2666EA1BDF7407 /* mod.rs */, + 540EC26CAA62FA0876B2E097 /* progress.rs */, + 73DA6AA4C5C195D2E5ACAD39 /* verify.rs */, + ); + path = engine; + sourceTree = ""; + }; + 79419C88FDD76A5C811A2B85 /* gallery_dl */ = { + isa = PBXGroup; + children = ( + EDCED786175ABD43D6A3416D /* mod.rs */, + F214F1FBEE1C83D5E75A9923 /* process.rs */, + ); + path = gallery_dl; + sourceTree = ""; + }; + 8233F98A9BA97B53155AA2A9 /* orchestrator */ = { + isa = PBXGroup; + children = ( + 3F94807E038EE03F6400FF04 /* convert.rs */, + F431A337C04C1F28E98424D5 /* history.rs */, + 3A3C787D9832118BA09E0E98 /* manager.rs */, + 5DC36797D2FA389885350B35 /* mod.rs */, + 7126F13ED85EA8E158C9FDEE /* podcast.rs */, + 64617FD428A0E3370A37C286 /* stats.rs */, + 18285E2CC85730DEDC92F07C /* store.rs */, + AFBDE2546C2482CC0DEF81EF /* thumbnail.rs */, + 02EBF78AD24E2CB742180D23 /* types.rs */, + 9AC2B0A74E92B920D5CA23C2 /* backends */, + ); + path = orchestrator; + sourceTree = ""; + }; + 8723285716C38667863216DB /* src */ = { + isa = PBXGroup; + children = ( + 39494F60CA566D50B89257F3 /* clipboard.rs */, + 6546728CDC9C0A3EACF5A079 /* database.rs */, + 99EA0556D03EDD66545DA63A /* discord.rs */, + 3BD9F4FC4AB06D9D996F929B /* lib.rs */, + 2498C207517117161D809721 /* logs.rs */, + A0F82EB86AF255BD25975B9B /* main.rs */, + 119F0709B3E8B73992EDEE82 /* media_info.rs */, + B8919E2367C9990BF5AEA841 /* media_stream.rs */, + 400FC02B8A204C71C172D60C /* notifications.rs */, + 654BCC9DC159017BA2A31A3D /* proxy.rs */, + 3650A2B2CC22C4EE05FD7C92 /* relay.rs */, + 3133BAEF6D00872061A35754 /* server.rs */, + 06CA522A3D4778179AE6C432 /* store_utils.rs */, + 6B0752CF97149934DA242F02 /* thumbnail_color.rs */, + B31CB0D4639AFA8AFCEB142F /* torrent_search.rs */, + 35B595DCF09E8809BB5E5AEB /* tray_icon.rs */, + 3A9A68FE5257BAEDAFC22751 /* tray.rs */, + 10C6EEDF490F9FBEBB956DD5 /* types.rs */, + 17F1DC64D3902DBEC1F74406 /* updater.rs */, + FB33F64320749D16B048DBB2 /* url_utils.rs */, + 480D139D700F3DBAD5DB9514 /* utils.rs */, + 9070F630F0ECC98FEBC58734 /* window_effects.rs */, + 4CE655D388436D35CF3C000A /* window_manager.rs */, + 496F2E0B6A656E4CD65B6112 /* deps */, + 8233F98A9BA97B53155AA2A9 /* orchestrator */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; + 91E51CE9ECD4CEB4CFB5BED3 /* comine_iOS */ = { + isa = PBXGroup; + children = ( + 8B7B1FC202C7FAEDBF7EA804 /* comine_iOS.entitlements */, + 86046325181D7E94605B31FE /* Info.plist */, + ); + path = comine_iOS; + sourceTree = ""; + }; + 9AC2B0A74E92B920D5CA23C2 /* backends */ = { + isa = PBXGroup; + children = ( + E6E490972881AEDB1509F370 /* android_jni.rs */, + 934F2887825FAFD7416BD7D7 /* aria2.rs */, + D5D7F2EB7A4CA8EF3D5B0CCE /* common.rs */, + 664374AA96206676463DEB89 /* direct.rs */, + C6E0C75D630F949AF9F27BAF /* librqbit.rs */, + 670517EEE6AF5722103D566E /* mod.rs */, + 79419C88FDD76A5C811A2B85 /* gallery_dl */, + 008FF62A3BEDE692EE11993B /* ytdlp */, + ); + path = backends; + sourceTree = ""; + }; + B34982C98F0F3D4AD2F0E912 /* bindings */ = { + isa = PBXGroup; + children = ( + 912F05474954277FEB481887 /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + CD01FB3B9F32882BF13177F1 /* Externals */ = { + isa = PBXGroup; + children = ( + 2E2C5460911E020FBAF759A2 /* arm64 */, + ); + path = Externals; + sourceTree = ""; + }; + F3493816AC8981A0281E95BA /* specs */ = { + isa = PBXGroup; + children = ( + DD0C9037588D6F82A1983094 /* aria2.rs */, + 548E22D8EE2C121D9F292899 /* deno.rs */, + 9729922CF510CF7C67D82D0E /* edge_tts.rs */, + CD032B398E5B9C9436081AAD /* ffmpeg.rs */, + 4E22AF42FCC0CCC754230833 /* gallery_dl.rs */, + 5DB5DC0AC3861FBCECBC7FD3 /* js_runtime.rs */, + 0CBC9860C2FD2125C7435A60 /* lux.rs */, + 139D9A0AC78C337E11E6F18F /* mod.rs */, + A4E72385F3FA4F782754A6F8 /* quickjs.rs */, + 240CBE77E9533554D769EE9B /* whisper.rs */, + 806450D665BD7DC572AE2C41 /* ytdlp.rs */, + ); + path = specs; + sourceTree = ""; + }; + F69C16A9C122A5DD7AD2F928 /* debug */ = { + isa = PBXGroup; + children = ( + 33E1F47E891C595B7350800F /* libapp.a */, + ); + path = debug; + sourceTree = ""; + }; + "TEMP_FE87D359-D044-42AA-BE90-2F9B4930A9BD" /* x86_64 */ = { + isa = PBXGroup; + children = ( + ); + path = x86_64; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 6A5BE9C68B1E639C306AC806 /* ComineLiveActivity */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA3171912A92C0958389E387 /* Build configuration list for PBXNativeTarget "ComineLiveActivity" */; + buildPhases = ( + 1297B1FDF0D1ACB9EB188627 /* Sources */, + 47E3266392EF8C30F97C14D7 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ComineLiveActivity; + packageProductDependencies = ( + ); + productName = ComineLiveActivity; + productReference = 523B308AB60E89D77237D7B3 /* ComineLiveActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 7FCEA8E61DEF86CDC7717A78 /* comine_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29E8A986D2B852E5C392E963 /* Build configuration list for PBXNativeTarget "comine_iOS" */; + buildPhases = ( + 23C43D9386B969BDF23823F7 /* Build Rust Code */, + B1A5B5EA3371A92878DA84DF /* Sources */, + 15DCF37A91EE6E1B4FF23CE5 /* Resources */, + C17BC2BE330389C656FAE924 /* Frameworks */, + BF0F5DDB652427E648253B1B /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 469ABA94A5798DA1477FA0B7 /* PBXTargetDependency */, + ); + name = comine_iOS; + packageProductDependencies = ( + ); + productName = comine_iOS; + productReference = E15621166248F7C01C5A020C /* comine_iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 103A09618A6B4A481BEBFD17 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 6A5BE9C68B1E639C306AC806 = { + DevelopmentTeam = 365KJHJCW7; + ProvisioningStyle = Automatic; + }; + 7FCEA8E61DEF86CDC7717A78 = { + DevelopmentTeam = 365KJHJCW7; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 5268B9C24D94E8E5D9ADB6DC /* Build configuration list for PBXProject "comine" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 6AC901204EF2FF18475BCC76; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 6CD847DE18E6D825BC33900B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 6A5BE9C68B1E639C306AC806 /* ComineLiveActivity */, + 7FCEA8E61DEF86CDC7717A78 /* comine_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 15DCF37A91EE6E1B4FF23CE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7BAA632733E174AEEE9DF4EC /* Assets.xcassets in Resources */, + D07B2165DC116F085FF7C436 /* LaunchScreen.storyboard in Resources */, + D93437F1D6C21E32DBD6683B /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 23C43D9386B969BDF23823F7 /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1297B1FDF0D1ACB9EB188627 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 388993C01E142F77FDECCF8E /* DownloadAttributes.swift in Sources */, + A2892754AA1291986965C574 /* DownloadLiveActivity.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B1A5B5EA3371A92878DA84DF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5B541394DB3FF9BD561846F8 /* BackgroundAudioService.swift in Sources */, + 26E2DE39AC5C062BEC82B21A /* DownloadAttributes.swift in Sources */, + ED7884EA88409FB3D12C47BD /* LiveActivityManager.swift in Sources */, + D1BEF2000637E073CF764A04 /* ViewportFix.swift in Sources */, + 233070012AD6467C47654FE0 /* ViewportFixLoader.m in Sources */, + 304CBDF1046B77C54115890A /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 469ABA94A5798DA1477FA0B7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6A5BE9C68B1E639C306AC806 /* ComineLiveActivity */; + targetProxy = 7DA75587BAF7E1583C420AF5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1E205C129BA330D3E19BAAEC /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + arm64, + ); + CODE_SIGN_ENTITLEMENTS = ComineLiveActivity/ComineLiveActivity.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 365KJHJCW7; + INFOPLIST_FILE = ComineLiveActivity/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.nichind.comine.live-activity"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = debug; + }; + 272100977D080CE3A8813910 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = ( + arm64, + ); + CODE_SIGN_ENTITLEMENTS = ComineLiveActivity/ComineLiveActivity.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 365KJHJCW7; + INFOPLIST_FILE = ComineLiveActivity/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.nichind.comine.live-activity"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = release; + }; + 7583D0503B1DC84AC919D6D0 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + 9417DA8901F1AF57818E8D64 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = comine_iOS/comine_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "365KJHJCW7"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = comine_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + OTHER_LDFLAGS = "$(inherited) -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos -lswiftCompatibility56"; + PRODUCT_BUNDLE_IDENTIFIER = com.nichind.comine; + PRODUCT_NAME = "comine"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = release; + }; + E42EB27B2484AF613FBA505E /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + F24E203A860C4A17E8726DA1 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = comine_iOS/comine_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "365KJHJCW7"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = comine_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + OTHER_LDFLAGS = "$(inherited) -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos -lswiftCompatibility56"; + PRODUCT_BUNDLE_IDENTIFIER = com.nichind.comine; + PRODUCT_NAME = "comine"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = arm64; + }; + name = debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 29E8A986D2B852E5C392E963 /* Build configuration list for PBXNativeTarget "comine_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F24E203A860C4A17E8726DA1 /* debug */, + 9417DA8901F1AF57818E8D64 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + 5268B9C24D94E8E5D9ADB6DC /* Build configuration list for PBXProject "comine" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E42EB27B2484AF613FBA505E /* debug */, + 7583D0503B1DC84AC919D6D0 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + DA3171912A92C0958389E387 /* Build configuration list for PBXNativeTarget "ComineLiveActivity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1E205C129BA330D3E19BAAEC /* debug */, + 272100977D080CE3A8813910 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 103A09618A6B4A481BEBFD17 /* Project object */; +} diff --git a/src-tauri/gen/apple/comine.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/src-tauri/gen/apple/comine.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/src-tauri/gen/apple/comine.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/src-tauri/gen/apple/comine.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/src-tauri/gen/apple/comine.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..ac90d5a --- /dev/null +++ b/src-tauri/gen/apple/comine.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,10 @@ + + + + + BuildSystemType + Original + DisableBuildSystemDeprecationDiagnostic + + + diff --git a/src-tauri/gen/apple/comine.xcodeproj/xcshareddata/xcschemes/comine_iOS.xcscheme b/src-tauri/gen/apple/comine.xcodeproj/xcshareddata/xcschemes/comine_iOS.xcscheme new file mode 100644 index 0000000..07120fb --- /dev/null +++ b/src-tauri/gen/apple/comine.xcodeproj/xcshareddata/xcschemes/comine_iOS.xcscheme @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/apple/comine_iOS/Info.plist b/src-tauri/gen/apple/comine_iOS/Info.plist new file mode 100644 index 0000000..2f18f43 --- /dev/null +++ b/src-tauri/gen/apple/comine_iOS/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.1.0 + CFBundleVersion + 1.1.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSSupportsLiveActivities + + UIBackgroundModes + + audio + fetch + + UIFileSharingEnabled + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + metal + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + \ No newline at end of file diff --git a/src-tauri/gen/apple/comine_iOS/comine_iOS.entitlements b/src-tauri/gen/apple/comine_iOS/comine_iOS.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/src-tauri/gen/apple/comine_iOS/comine_iOS.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml new file mode 100644 index 0000000..84df689 --- /dev/null +++ b/src-tauri/gen/apple/project.yml @@ -0,0 +1,132 @@ +name: comine +options: + bundleIdPrefix: com.nichind.comine + deploymentTarget: + iOS: 15.0 +fileGroups: [../../src] +configs: + debug: debug + release: release +settingGroups: + app: + base: + PRODUCT_NAME: comine + PRODUCT_BUNDLE_IDENTIFIER: com.nichind.comine + DEVELOPMENT_TEAM: 365KJHJCW7 + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: Automatic +targetTemplates: + app: + type: application + sources: + - path: Sources + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + groups: [app] +targets: + comine_iOS: + type: application + platform: iOS + sources: + - path: Sources + - path: Shared + - path: Assets.xcassets + - path: Externals + buildPhase: none + - path: comine_iOS + - path: assets + buildPhase: resources + type: folder + - path: LaunchScreen.storyboard + info: + path: comine_iOS/Info.plist + properties: + LSRequiresIPhoneOS: true + UILaunchStoryboardName: LaunchScreen + UIRequiredDeviceCapabilities: [arm64, metal] + UIFileSharingEnabled: true + LSSupportsOpeningDocumentsInPlace: true + NSSupportsLiveActivities: true + UIBackgroundModes: [audio, fetch] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + CFBundleShortVersionString: 1.1.0 + CFBundleVersion: "1.1.0" + entitlements: + path: comine_iOS/comine_iOS.entitlements + scheme: + environmentVariables: + RUST_BACKTRACE: full + RUST_LOG: info + settings: + base: + ENABLE_BITCODE: false + ARCHS: [arm64] + VALID_ARCHS: arm64 + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true + OTHER_LDFLAGS: $(inherited) -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos -lswiftCompatibility56 + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + groups: [app] + dependencies: + - framework: libapp.a + embed: false + - sdk: CoreGraphics.framework + - sdk: Metal.framework + - sdk: MetalKit.framework + - sdk: QuartzCore.framework + - sdk: Security.framework + - sdk: UIKit.framework + - sdk: WebKit.framework + - sdk: AVFoundation.framework + - sdk: AudioToolbox.framework + - target: ComineLiveActivity + embed: true + preBuildScripts: + - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + name: Build Rust Code + basedOnDependencyAnalysis: false + outputFiles: + - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a + ComineLiveActivity: + type: app-extension + platform: iOS + deploymentTarget: + iOS: 16.2 + sources: + - path: ComineLiveActivity + - path: Shared + entitlements: + path: ComineLiveActivity/ComineLiveActivity.entitlements + info: + path: ComineLiveActivity/Info.plist + properties: + CFBundleDisplayName: Comine Downloads + CFBundleShortVersionString: 1.1.0 + CFBundleVersion: "1.1.0" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.nichind.comine.live-activity + DEVELOPMENT_TEAM: 365KJHJCW7 + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: Automatic + ARCHS: [arm64] + SKIP_INSTALL: true + SWIFT_EMIT_LOC_STRINGS: true + dependencies: + - sdk: SwiftUI.framework + - sdk: WidgetKit.framework diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 8409ac0..2c3c9e5 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -1,5 +1,5 @@ -use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; use rusqlite::{params, Connection}; use tracing::{error, info, warn}; @@ -7,10 +7,16 @@ use tracing::{error, info, warn}; use crate::orchestrator::types::HistoryItem; pub struct Database { - conn: Mutex, + db_path: PathBuf, + data_dir: PathBuf, + conn: OnceLock>, } impl Database { + /// Create a Database handle without opening the SQLite file. + /// The actual connection, schema creation, and migrations are deferred + /// to the first `conn()` call. This keeps app startup non-blocking on + /// iOS where a watchdog kills the process if the main thread stalls. pub fn new(data_dir: &Path) -> Result, String> { let db_path = data_dir.join("comine.db"); @@ -18,8 +24,18 @@ impl Database { let _ = std::fs::create_dir_all(parent); } - let conn = Connection::open(&db_path) - .map_err(|e| format!("Failed to open database at {:?}: {}", db_path, e))?; + Ok(Arc::new(Self { + db_path, + data_dir: data_dir.to_path_buf(), + conn: OnceLock::new(), + })) + } + + /// Open the connection and run all one-time setup. + /// Called exactly once via `OnceLock::get_or_init`. + fn init_connection(&self) -> Mutex { + let conn = Connection::open(&self.db_path) + .unwrap_or_else(|e| panic!("Failed to open database at {:?}: {}", self.db_path, e)); conn.execute_batch( "PRAGMA journal_mode=WAL; @@ -27,28 +43,25 @@ impl Database { PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", ) - .map_err(|e| format!("Failed to set database pragmas: {}", e))?; - - let db = Arc::new(Self { - conn: Mutex::new(conn), - }); + .unwrap_or_else(|e| panic!("Failed to set database pragmas: {}", e)); - db.init_schema()?; - db.run_migrations(); - db.migrate_from_json(data_dir); + Self::init_schema_on(&conn) + .unwrap_or_else(|e| panic!("Failed to initialize schema: {}", e)); + Self::run_migrations_on(&conn); + Self::migrate_from_json_on(&conn, &self.data_dir); - info!("Database initialized at {:?}", db_path); - Ok(db) + info!("Database initialized at {:?}", self.db_path); + Mutex::new(conn) } pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> { self.conn + .get_or_init(|| self.init_connection()) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } - fn init_schema(&self) -> Result<(), String> { - let conn = self.conn(); + fn init_schema_on(conn: &Connection) -> Result<(), String> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS history ( id TEXT PRIMARY KEY, @@ -112,9 +125,7 @@ impl Database { Ok(()) } - fn run_migrations(&self) { - let conn = self.conn(); - + fn run_migrations_on(conn: &Connection) { // Add is_directory and file_count columns for multi-file download support let has_is_directory: bool = conn .prepare("SELECT is_directory FROM history LIMIT 0") @@ -133,36 +144,52 @@ impl Database { info!("Migration: added is_directory and file_count columns to history"); } } + + // Add podcast columns for auto-podcast generation pipeline + let has_podcast_path: bool = conn + .prepare("SELECT podcast_path FROM history LIMIT 0") + .is_ok(); + + if !has_podcast_path { + if let Err(e) = conn.execute_batch( + "ALTER TABLE history ADD COLUMN podcast_path TEXT; + ALTER TABLE history ADD COLUMN podcast_subtitle_path TEXT; + ALTER TABLE history ADD COLUMN podcast_status TEXT;", + ) { + warn!( + "Migration: adding podcast columns failed (may already exist): {}", + e + ); + } else { + info!("Migration: added podcast_path, podcast_subtitle_path, podcast_status columns to history"); + } + } } - fn migrate_from_json(&self, data_dir: &Path) { - self.migrate_history_json(data_dir); - self.migrate_jobs_json(data_dir); - self.migrate_stats_json(data_dir); + fn migrate_from_json_on(conn: &Connection, data_dir: &Path) { + Self::migrate_history_json_on(conn, data_dir); + Self::migrate_jobs_json_on(conn, data_dir); + Self::migrate_stats_json_on(conn, data_dir); } - fn migrate_history_json(&self, data_dir: &Path) { + fn migrate_history_json_on(conn: &Connection, data_dir: &Path) { let json_path = data_dir.join("history_backend.json"); if !json_path.exists() { return; } // Check if we already have data (avoid re-migration) - { - let conn = self.conn(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0)) - .unwrap_or(0); - if count > 0 { - // DB already has data, rename JSON as backup - let backup = data_dir.join("history_backend.json.bak"); - let _ = std::fs::rename(&json_path, &backup); - info!( - "History DB already populated ({} items), backed up JSON", - count - ); - return; - } + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0)) + .unwrap_or(0); + if count > 0 { + let backup = data_dir.join("history_backend.json.bak"); + let _ = std::fs::rename(&json_path, &backup); + info!( + "History DB already populated ({} items), backed up JSON", + count + ); + return; } let json_str = match std::fs::read_to_string(&json_path) { @@ -192,7 +219,6 @@ impl Database { items.len() ); - let conn = self.conn(); let tx = match conn.unchecked_transaction() { Ok(tx) => tx, Err(e) => { @@ -234,24 +260,21 @@ impl Database { } } - fn migrate_jobs_json(&self, data_dir: &Path) { + fn migrate_jobs_json_on(conn: &Connection, data_dir: &Path) { let json_path = data_dir.join("jobs.json"); if !json_path.exists() { return; } // Check if we already have data - { - let conn = self.conn(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM jobs", [], |row| row.get(0)) - .unwrap_or(0); - if count > 0 { - let backup = data_dir.join("jobs.json.bak"); - let _ = std::fs::rename(&json_path, &backup); - info!("Jobs DB already populated ({} jobs), backed up JSON", count); - return; - } + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM jobs", [], |row| row.get(0)) + .unwrap_or(0); + if count > 0 { + let backup = data_dir.join("jobs.json.bak"); + let _ = std::fs::rename(&json_path, &backup); + info!("Jobs DB already populated ({} jobs), backed up JSON", count); + return; } let json_str = match std::fs::read_to_string(&json_path) { @@ -279,7 +302,6 @@ impl Database { info!("Migrating {} jobs from JSON to SQLite", jobs.len()); - let conn = self.conn(); let tx = match conn.unchecked_transaction() { Ok(tx) => tx, Err(e) => { @@ -346,28 +368,25 @@ impl Database { } } - fn migrate_stats_json(&self, data_dir: &Path) { + fn migrate_stats_json_on(conn: &Connection, data_dir: &Path) { let json_path = data_dir.join("stats.json"); if !json_path.exists() { return; } // Check if stats have been populated (total_downloads > 0 or installation_id set) - { - let conn = self.conn(); - let has_data: bool = conn - .query_row( - "SELECT total_downloads > 0 OR installation_id != '' FROM stats WHERE id = 1", - [], - |row| row.get(0), - ) - .unwrap_or(false); - if has_data { - let backup = data_dir.join("stats.json.bak"); - let _ = std::fs::rename(&json_path, &backup); - info!("Stats DB already populated, backed up JSON"); - return; - } + let has_data: bool = conn + .query_row( + "SELECT total_downloads > 0 OR installation_id != '' FROM stats WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap_or(false); + if has_data { + let backup = data_dir.join("stats.json.bak"); + let _ = std::fs::rename(&json_path, &backup); + info!("Stats DB already populated, backed up JSON"); + return; } let json_str = match std::fs::read_to_string(&json_path) { @@ -412,7 +431,6 @@ impl Database { .unwrap_or_default(); let last_sync_time = stats.get("last_sync_time").and_then(|v| v.as_str()); - let conn = self.conn(); if let Err(e) = conn.execute( "UPDATE stats SET total_downloads = ?1, successful_downloads = ?2, failed_downloads = ?3, total_size_mb = ?4, first_launch = ?5, installation_id = ?6, last_sync_time = ?7 WHERE id = 1", params![ @@ -429,8 +447,6 @@ impl Database { return; } - drop(conn); - let backup = data_dir.join("stats.json.bak"); if let Err(e) = std::fs::rename(&json_path, &backup) { warn!("Failed to rename stats JSON to backup: {}", e); diff --git a/src-tauri/src/deps/commands.rs b/src-tauri/src/deps/commands.rs index c4dafe7..f1ba929 100644 --- a/src-tauri/src/deps/commands.rs +++ b/src-tauri/src/deps/commands.rs @@ -4,7 +4,7 @@ use crate::proxy::ProxyConfig; use crate::types::{DependencyStatus, ReleaseInfo}; use super::engine::cancel; -use super::specs::{aria2, deno, ffmpeg, gallery_dl, lux, quickjs, ytdlp}; +use super::specs::{aria2, deno, edge_tts, ffmpeg, gallery_dl, lux, quickjs, whisper, ytdlp}; #[allow(unused_imports)] pub use aria2::get_aria2_path; @@ -201,7 +201,7 @@ pub async fn install_gallery_dl( // Dynamically register gallery-dl backend now that it's installed. // This avoids requiring an app restart after install. - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { use crate::orchestrator::manager::JobManager; use std::sync::Arc; @@ -225,7 +225,7 @@ pub async fn install_gallery_dl( pub async fn uninstall_gallery_dl(app: AppHandle) -> Result<(), String> { gallery_dl::uninstall_gallery_dl(app.clone()).await?; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { use crate::orchestrator::manager::JobManager; use std::sync::Arc; @@ -246,6 +246,42 @@ pub async fn get_gallery_dl_releases( gallery_dl::get_gallery_dl_releases(proxy_config).await } +#[tauri::command] +pub async fn check_edge_tts( + app: AppHandle, + check_updates: Option, +) -> Result { + edge_tts::check_edge_tts(app, check_updates).await +} + +#[tauri::command] +pub async fn install_edge_tts(app: AppHandle) -> Result { + edge_tts::install_edge_tts(app).await +} + +#[tauri::command] +pub async fn uninstall_edge_tts(app: AppHandle) -> Result<(), String> { + edge_tts::uninstall_edge_tts(app).await +} + +#[tauri::command] +pub async fn check_whisper( + app: AppHandle, + check_updates: Option, +) -> Result { + whisper::check_whisper(app, check_updates).await +} + +#[tauri::command] +pub async fn install_whisper(app: AppHandle) -> Result { + whisper::install_whisper(app).await +} + +#[tauri::command] +pub async fn uninstall_whisper(app: AppHandle) -> Result<(), String> { + whisper::uninstall_whisper(app).await +} + #[tauri::command] pub async fn cancel_dep_install(dep: String) -> Result<(), String> { const KNOWN_DEPS: &[&str] = &[ @@ -256,6 +292,8 @@ pub async fn cancel_dep_install(dep: String) -> Result<(), String> { "quickjs", "lux", "gallery-dl", + "edge-tts", + "whisper", ]; if KNOWN_DEPS.contains(&dep.as_str()) { cancel::cancel(&dep); diff --git a/src-tauri/src/deps/engine/download.rs b/src-tauri/src/deps/engine/download.rs index d59b2c9..32f6017 100644 --- a/src-tauri/src/deps/engine/download.rs +++ b/src-tauri/src/deps/engine/download.rs @@ -1,48 +1,48 @@ use std::path::Path; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use futures_util::StreamExt; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tracing::{debug, error, info, warn}; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tokio::io::AsyncWriteExt; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tokio_util::sync::CancellationToken; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use crate::proxy::{proxy_strategies, ProxyConfig}; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use crate::types::InstallProgress; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use super::progress::ProgressEmitter; use crate::deps::error::{DepsError, DepsResult, DownloadError}; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const HTTP_TIMEOUT_SECS: u64 = 600; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const HTTP_CONNECT_TIMEOUT_SECS: u64 = 60; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const TCP_KEEPALIVE_SECS: u64 = 30; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const MAX_REDIRECTS: usize = 10; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const MAX_RETRY_ATTEMPTS: u32 = 5; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const PROGRESS_EMIT_INTERVAL_MS: u128 = 100; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use crate::orchestrator::types::constants::USER_AGENT; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const RATELIMIT_DELAY_SECS: u64 = 10; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] const RATELIMIT_MAX_DELAY_SECS: u64 = 60; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] fn http_client(proxy_url: Option<&str>) -> Result { let mut builder = reqwest::Client::builder() .user_agent(USER_AGENT) @@ -74,7 +74,7 @@ fn http_client(proxy_url: Option<&str>) -> Result( url: &str, proxy_config: &ProxyConfig, @@ -307,7 +307,7 @@ pub async fn fetch_json( .map_err(|e| DepsError::other(format!("Failed to parse JSON: {}", e))) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub async fn fetch_text(url: &str, proxy_config: &ProxyConfig) -> DepsResult { let response = fetch(url, proxy_config, None).await?; response @@ -316,27 +316,27 @@ pub async fn fetch_text(url: &str, proxy_config: &ProxyConfig) -> DepsResult( _url: &str, _proxy_config: &crate::proxy::ProxyConfig, ) -> DepsResult { Err(DepsError::UnsupportedPlatform( - "fetch_json not supported on Android", + "fetch_json not supported on mobile", )) } -#[cfg(target_os = "android")] +#[cfg(mobile)] pub async fn fetch_text( _url: &str, _proxy_config: &crate::proxy::ProxyConfig, ) -> DepsResult { Err(DepsError::UnsupportedPlatform( - "fetch_text not supported on Android", + "fetch_text not supported on mobile", )) } -#[cfg(target_os = "android")] +#[cfg(mobile)] pub async fn download_file_with_checksum( _url: &str, _dest: &std::path::Path, @@ -348,6 +348,6 @@ pub async fn download_file_with_checksum( _cancel: Option<&tokio_util::sync::CancellationToken>, ) -> DepsResult<()> { Err(DepsError::UnsupportedPlatform( - "Downloading dependencies is not supported on Android", + "Downloading dependencies is not supported on mobile", )) } diff --git a/src-tauri/src/deps/engine/installer.rs b/src-tauri/src/deps/engine/installer.rs index e0a1792..3397f49 100644 --- a/src-tauri/src/deps/engine/installer.rs +++ b/src-tauri/src/deps/engine/installer.rs @@ -225,7 +225,10 @@ pub async fn run_install( ); if let Some(custom_verify) = plan.custom_verify { - custom_verify(&plan.binary_path).await?; + if let Err(e) = custom_verify(&plan.binary_path).await { + delete_corrupt_binary(&plan.binary_path, plan.display_name).await; + return Err(e); + } } else { let args: Vec<&str> = plan.verify_args.to_vec(); match super::verify::run_capture_async(&plan.binary_path, &args).await { @@ -233,12 +236,29 @@ pub async fn run_install( info!("{} installed successfully", plan.display_name); } Ok(output) => { + let reason = if output.stderr.is_empty() { + format!("exited with code {:?}", output.status_code) + } else { + output.stderr.clone() + }; + tracing::error!( + "{} verification failed after install: {}", + plan.display_name, + reason + ); + delete_corrupt_binary(&plan.binary_path, plan.display_name).await; return Err(format!( "{} verification failed: {}", - plan.display_name, output.stderr + plan.display_name, reason )); } Err(e) => { + tracing::error!( + "{} verification error (binary may be incompatible): {}", + plan.display_name, + e + ); + delete_corrupt_binary(&plan.binary_path, plan.display_name).await; return Err(format!("{} verification failed: {}", plan.display_name, e)); } } @@ -253,6 +273,35 @@ pub async fn run_install( Ok(plan.binary_path.to_string_lossy().to_string()) } +/// Delete a binary that failed verification so future install attempts re-download it fresh. +/// +/// Verification failures often indicate the wrong architecture was downloaded (e.g., x86_64 +/// binary on an ARM Mac). Leaving the corrupt file on disk causes a loop: the startup check +/// finds it, fails to run it, and subsequent installs fail at verification again. +async fn delete_corrupt_binary(binary_path: &Path, display_name: &str) { + if !binary_path.exists() { + return; + } + tracing::warn!( + "Deleting corrupt/incompatible {} binary at {:?} so next install re-downloads it", + display_name, + binary_path + ); + if let Err(e) = tokio::fs::remove_file(binary_path).await { + tracing::warn!( + "Failed to delete corrupt {} binary at {:?}: {}", + display_name, + binary_path, + e + ); + } else { + tracing::info!( + "Deleted corrupt {} binary — next install will fetch a fresh copy", + display_name + ); + } +} + async fn check_cancelled(token: &CancellationToken, temp_archive: &Path) -> Result<(), String> { if token.is_cancelled() { let _ = tokio::fs::remove_file(temp_archive).await; diff --git a/src-tauri/src/deps/specs/aria2.rs b/src-tauri/src/deps/specs/aria2.rs index bb635b9..f13d34d 100644 --- a/src-tauri/src/deps/specs/aria2.rs +++ b/src-tauri/src/deps/specs/aria2.rs @@ -61,13 +61,13 @@ pub async fn check_aria2( app: AppHandle, check_updates: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = check_updates; return Ok(DependencyStatus::embedded("youtubedl-android library")); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let aria2_path = match resolve_aria2_path(&app) { Some(path) => path, @@ -119,11 +119,11 @@ pub async fn install_aria2( ); } - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = proxy_config; return Err( - "aria2 installation on Android is not supported. Please install via Termux." + "aria2 installation on mobile is not supported. Please install via Termux." .to_string(), ); } diff --git a/src-tauri/src/deps/specs/deno.rs b/src-tauri/src/deps/specs/deno.rs index 7ee5135..87b3da1 100644 --- a/src-tauri/src/deps/specs/deno.rs +++ b/src-tauri/src/deps/specs/deno.rs @@ -120,13 +120,13 @@ pub async fn install_deno( app: AppHandle, proxy_config: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = proxy_config; - return Err("Deno installation on Android is not supported.".to_string()); + return Err("Deno installation on mobile is not supported.".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let download_url = get_download_url(); if download_url.is_empty() { diff --git a/src-tauri/src/deps/specs/edge_tts.rs b/src-tauri/src/deps/specs/edge_tts.rs new file mode 100644 index 0000000..26bc964 --- /dev/null +++ b/src-tauri/src/deps/specs/edge_tts.rs @@ -0,0 +1,291 @@ +use std::path::PathBuf; + +use tauri::{AppHandle, Manager}; +use tracing::{error, info, warn}; + +use crate::deps::engine::progress::ProgressEmitter; +use crate::deps::engine::verify::{find_in_system_path, run_capture_async}; +use crate::types::{DependencyStatus, InstallProgress}; + +const EVENT_PROGRESS: &str = "edge-tts-install-progress"; + +#[cfg(target_os = "windows")] +const BINARY_NAME: &str = "edge-tts.exe"; +#[cfg(not(target_os = "windows"))] +const BINARY_NAME: &str = "edge-tts"; + +fn get_venv_dir(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|d| d.join("podcast-venv")) + .map_err(|e| format!("Failed to get app data dir: {}", e)) +} + +fn get_venv_bin(app: &AppHandle, name: &str) -> Result { + let venv = get_venv_dir(app)?; + #[cfg(target_os = "windows")] + { + Ok(venv.join("Scripts").join(format!("{}.exe", name))) + } + #[cfg(not(target_os = "windows"))] + { + Ok(venv.join("bin").join(name)) + } +} + +/// Resolve edge-tts binary path: check venv first, then system PATH. +pub fn resolve_edge_tts_path(app: &AppHandle) -> Option { + // Check venv-local binary first + if let Ok(venv_bin) = get_venv_bin(app, "edge-tts") { + if venv_bin.exists() { + return Some(venv_bin); + } + } + // Fall back to system PATH + find_in_system_path(BINARY_NAME) +} + +/// Find a usable Python 3 executable on the system. +async fn find_python3() -> Option { + // Try `python3` first (preferred on Unix/macOS), then `python` (Windows fallback) + for candidate in &["python3", "python"] { + if let Some(path) = find_in_system_path(candidate) { + // Verify it's actually Python 3 by checking --version output + if let Ok(out) = run_capture_async(&path, &["--version"]).await { + let combined = format!("{} {}", out.stdout, out.stderr); + if combined.contains("Python 3") { + return Some(path); + } + } + } + } + None +} + +pub async fn check_edge_tts( + app: AppHandle, + check_updates: Option, +) -> Result { + #[cfg(mobile)] + { + let _ = check_updates; + return Ok(DependencyStatus::not_installed()); + } + + #[cfg(desktop)] + { + let _ = check_updates; // edge-tts has no structured GitHub releases to compare against + + let edge_tts_path = match resolve_edge_tts_path(&app) { + Some(path) => path, + None => return Ok(DependencyStatus::not_installed()), + }; + + // Try `edge-tts --version`; fall back to `pip show edge-tts` for the version string. + match run_capture_async(&edge_tts_path, &["--version"]).await { + Ok(output) if output.status_code == Some(0) => { + let version = output.stdout.trim().to_string(); + info!("edge-tts version (--version): {}", version); + Ok(DependencyStatus::installed( + version, + edge_tts_path.to_string_lossy().to_string(), + )) + } + Ok(_) | Err(_) => { + // edge-tts binary found but --version failed or returned non-zero. + // Try pip show to get the installed package version. + let version = get_pip_version(&app).await.unwrap_or_default(); + if version.is_empty() { + warn!("edge-tts binary found at {:?} but version query failed", edge_tts_path); + Ok(DependencyStatus::not_installed()) + } else { + info!("edge-tts version (pip show): {}", version); + Ok(DependencyStatus::installed( + version, + edge_tts_path.to_string_lossy().to_string(), + )) + } + } + } + } +} + +/// Query the installed version via `pip show edge-tts`, checking the venv pip first. +async fn get_pip_version(app: &AppHandle) -> Option { + // Prefer venv pip + let pip_path = get_venv_bin(app, "pip").ok().filter(|p| p.exists()).or_else(|| { + find_in_system_path("pip3").or_else(|| find_in_system_path("pip")) + })?; + + let out = run_capture_async(&pip_path, &["show", "edge-tts"]).await.ok()?; + for line in out.stdout.lines() { + if let Some(rest) = line.strip_prefix("Version:") { + return Some(rest.trim().to_string()); + } + } + None +} + +pub async fn install_edge_tts(app: AppHandle) -> Result { + #[cfg(mobile)] + { + return Err("edge-tts installation on mobile is not supported.".to_string()); + } + + #[cfg(desktop)] + { + let progress = ProgressEmitter::new(&app, EVENT_PROGRESS); + + progress.emit(InstallProgress { + stage: "checking".to_string(), + progress: 5, + message: "Checking for Python 3...".to_string(), + ..Default::default() + }); + + // Ensure Python 3 is available + let python_path = find_python3().await.ok_or_else(|| { + "Python 3 is not installed or not in PATH. \ + Please install Python 3 and try again." + .to_string() + })?; + + info!("Using Python 3 at: {:?}", python_path); + + let venv_dir = get_venv_dir(&app)?; + + // Create venv if it doesn't already exist + if !venv_dir.exists() { + progress.emit(InstallProgress { + stage: "creating venv".to_string(), + progress: 20, + message: "Creating podcast virtual environment...".to_string(), + ..Default::default() + }); + + info!("Creating podcast-venv at {:?}", venv_dir); + let out = crate::utils::new_command(&python_path) + .args(["-m", "venv", &venv_dir.to_string_lossy()]) + .output() + .await + .map_err(|e| format!("Failed to spawn python -m venv: {}", e))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + error!("Failed to create venv: {}", stderr); + return Err(format!("Failed to create podcast-venv: {}", stderr)); + } + + info!("podcast-venv created successfully"); + } else { + info!("podcast-venv already exists at {:?}, skipping creation", venv_dir); + } + + // Resolve venv pip + let venv_pip = get_venv_bin(&app, "pip")?; + if !venv_pip.exists() { + return Err(format!( + "Expected pip in venv but not found at {:?}. \ + The venv may be broken — try deleting it and reinstalling.", + venv_pip + )); + } + + progress.emit(InstallProgress { + stage: "installing".to_string(), + progress: 40, + message: "Installing edge-tts via pip...".to_string(), + ..Default::default() + }); + + info!("Installing edge-tts into {:?}", venv_dir); + let install_out = crate::utils::new_command(&venv_pip) + .args(["install", "--upgrade", "edge-tts"]) + .output() + .await + .map_err(|e| format!("Failed to spawn pip install: {}", e))?; + + if !install_out.status.success() { + let stderr = String::from_utf8_lossy(&install_out.stderr).trim().to_string(); + error!("pip install edge-tts failed: {}", stderr); + return Err(format!("Failed to install edge-tts: {}", stderr)); + } + + info!("edge-tts pip install succeeded"); + + progress.emit(InstallProgress { + stage: "verifying".to_string(), + progress: 80, + message: "Verifying edge-tts installation...".to_string(), + ..Default::default() + }); + + // Verify the binary is now resolvable and get version + let edge_tts_path = resolve_edge_tts_path(&app) + .ok_or_else(|| "edge-tts was installed but the binary was not found.".to_string())?; + + let version = get_pip_version(&app).await.unwrap_or_else(|| "unknown".to_string()); + + progress.emit(InstallProgress { + stage: "done".to_string(), + progress: 100, + message: format!("edge-tts {} installed", version), + ..Default::default() + }); + + info!("edge-tts installed at {:?}, version: {}", edge_tts_path, version); + Ok(version) + } +} + +pub async fn uninstall_edge_tts(app: AppHandle) -> Result<(), String> { + #[cfg(mobile)] + { + return Err("edge-tts is not supported on mobile.".to_string()); + } + + #[cfg(desktop)] + { + // Prefer uninstalling from the venv pip to avoid touching system packages + let venv_pip = get_venv_bin(&app, "pip").ok().filter(|p| p.exists()); + + if let Some(pip) = venv_pip { + info!("Uninstalling edge-tts from podcast-venv via {:?}", pip); + let out = crate::utils::new_command(&pip) + .args(["uninstall", "-y", "edge-tts"]) + .output() + .await + .map_err(|e| format!("Failed to spawn pip uninstall: {}", e))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + warn!("pip uninstall edge-tts returned error: {}", stderr); + // Non-fatal: the package may simply not have been installed in this venv + } + return Ok(()); + } + + // Fall back to system pip3 / pip + let system_pip = find_in_system_path("pip3") + .or_else(|| find_in_system_path("pip")) + .ok_or_else(|| { + "Could not find pip to uninstall edge-tts. \ + Please run `pip uninstall -y edge-tts` manually." + .to_string() + })?; + + info!("Uninstalling edge-tts via system pip at {:?}", system_pip); + let out = crate::utils::new_command(&system_pip) + .args(["uninstall", "-y", "edge-tts"]) + .output() + .await + .map_err(|e| format!("Failed to spawn pip uninstall: {}", e))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(format!("Failed to uninstall edge-tts: {}", stderr)); + } + + Ok(()) + } +} diff --git a/src-tauri/src/deps/specs/ffmpeg.rs b/src-tauri/src/deps/specs/ffmpeg.rs index 1ee897d..85b5133 100644 --- a/src-tauri/src/deps/specs/ffmpeg.rs +++ b/src-tauri/src/deps/specs/ffmpeg.rs @@ -1,14 +1,18 @@ use std::path::PathBuf; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tauri::AppHandle; #[cfg(target_os = "android")] use tauri::{AppHandle, Manager}; +#[cfg(target_os = "ios")] +use tauri::{AppHandle, Manager}; use crate::proxy::ProxyConfig; use crate::types::DependencyStatus; +#[cfg(not(target_os = "ios"))] use crate::deps::engine::installer::{self, get_bin_dir, ExtractStrategy, InstallPlan}; +#[cfg(not(target_os = "ios"))] use crate::deps::engine::verify::run_capture_async; const EVENT_PROGRESS: &str = "ffmpeg-install-progress"; @@ -47,7 +51,17 @@ fn get_binary_path(app: &AppHandle, name: &str) -> Result { Ok(custom_path) } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let bin_dir = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to get app cache dir: {}", e))? + .join("bin"); + Ok(bin_dir.join(name)) + } + + #[cfg(desktop)] Ok(get_bin_dir(app)?.join(name)) } @@ -101,21 +115,30 @@ pub async fn check_ffmpeg( app: AppHandle, check_updates: Option, ) -> Result { + #[cfg(target_os = "ios")] + { + let _ = (app, check_updates); + return Ok(DependencyStatus::not_installed()); + } + + #[cfg(not(target_os = "ios"))] let ffmpeg_path = match resolve_ffmpeg_path(&app) { Some(path) => path, None => { #[cfg(target_os = "android")] return Ok(DependencyStatus::embedded("youtubedl-android library")); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] return Ok(DependencyStatus::not_installed()); } }; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let ffprobe_path = resolve_ffprobe_path(&app); + #[cfg(not(target_os = "ios"))] let _ = check_updates; + #[cfg(not(target_os = "ios"))] match run_capture_async(&ffmpeg_path, &["-version"]).await { Ok(output) if output.status_code == Some(0) => { let version = output @@ -127,7 +150,7 @@ pub async fn check_ffmpeg( .unwrap_or("unknown") .to_string(); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let disk_size = { let ffmpeg_size = tokio::fs::metadata(&ffmpeg_path) .await @@ -158,7 +181,7 @@ pub async fn check_ffmpeg( return Ok(DependencyStatus::embedded( "youtubedl-android library (not found in path)", )); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] Ok(DependencyStatus::not_installed()) } } @@ -202,11 +225,10 @@ pub async fn install_ffmpeg( ) -> Result { #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { - let _ = proxy_config; + let _ = (app, proxy_config); return Err( - "ffmpeg installation is not supported on this platform. On Android, install via Termux." - .to_string(), - ); + "ffmpeg installation is not supported on this platform.".to_string(), + ); } #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] @@ -262,18 +284,27 @@ pub async fn install_ffmpeg( } pub async fn uninstall_ffmpeg(app: AppHandle) -> Result<(), String> { - let ffmpeg_path = get_ffmpeg_path(&app)?; - let ffprobe_path = get_ffprobe_path(&app)?; - - if ffmpeg_path.exists() { - tokio::fs::remove_file(&ffmpeg_path) - .await - .map_err(|e| format!("Failed to remove ffmpeg: {}", e))?; + #[cfg(mobile)] + { + let _ = app; + return Ok(()); } - if ffprobe_path.exists() { - let _ = tokio::fs::remove_file(&ffprobe_path).await; - } + #[cfg(desktop)] + { + let ffmpeg_path = get_ffmpeg_path(&app)?; + let ffprobe_path = get_ffprobe_path(&app)?; - Ok(()) + if ffmpeg_path.exists() { + tokio::fs::remove_file(&ffmpeg_path) + .await + .map_err(|e| format!("Failed to remove ffmpeg: {}", e))?; + } + + if ffprobe_path.exists() { + let _ = tokio::fs::remove_file(&ffprobe_path).await; + } + + Ok(()) + } } diff --git a/src-tauri/src/deps/specs/gallery_dl.rs b/src-tauri/src/deps/specs/gallery_dl.rs index aa71164..049aade 100644 --- a/src-tauri/src/deps/specs/gallery_dl.rs +++ b/src-tauri/src/deps/specs/gallery_dl.rs @@ -7,9 +7,9 @@ use crate::proxy::ProxyConfig; use crate::types::{DependencyStatus, ReleaseInfo}; use crate::deps::engine::download::fetch_json; -use crate::deps::engine::installer::{ - self, get_bin_dir, ExtractStrategy, GitHubRelease, InstallPlan, -}; +use crate::deps::engine::installer::{self, get_bin_dir, GitHubRelease}; +#[cfg(all(desktop, not(target_os = "macos")))] +use crate::deps::engine::installer::{ExtractStrategy, InstallPlan}; use crate::deps::engine::verify::run_capture_async; const EVENT_PROGRESS: &str = "gallery-dl-install-progress"; @@ -19,12 +19,13 @@ const BINARY_NAME: &str = "gallery-dl.exe"; #[cfg(not(target_os = "windows"))] const BINARY_NAME: &str = "gallery-dl"; +// gallery-dl only ships a standalone binary for Windows and Linux. +// The "gallery-dl.bin" release asset is a Linux ELF binary — it cannot run on macOS. +// On macOS, users must install gallery-dl via pip or Homebrew; we detect it in the system PATH. #[cfg(target_os = "windows")] const RELEASE_ASSET: &str = "gallery-dl.exe"; -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "linux")] const RELEASE_ASSET: &str = "gallery-dl.bin"; -// gallery-dl doesn't provide a standalone macOS binary; users must install via pip/brew. -// We'll still attempt the GitHub release but fall back gracefully. pub fn get_gallery_dl_path(app: &AppHandle) -> Result { Ok(get_bin_dir(app)?.join(BINARY_NAME)) @@ -43,23 +44,54 @@ async fn fetch_latest_release(proxy_config: &ProxyConfig) -> Result, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = check_updates; return Ok(DependencyStatus::not_installed()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let gdl_path = match resolve_gallery_dl_path(&app) { Some(path) => path, None => return Ok(DependencyStatus::not_installed()), }; + // Determine whether this is the managed binary (in our bin dir) so we know + // whether it is safe to delete on failure. + let managed_path = get_gallery_dl_path(&app).ok(); + let is_managed = managed_path + .as_ref() + .is_some_and(|m| m == &gdl_path); + match run_capture_async(&gdl_path, &["--version"]).await { Ok(output) if output.status_code == Some(0) => { let version = output.stdout.trim().to_string(); @@ -89,15 +121,30 @@ pub async fn check_gallery_dl( ) } Ok(output) => { - warn!("gallery-dl exists but failed to run: {}", output.stderr); + // Binary exists but could not be executed — most commonly caused by an + // architecture mismatch (e.g., Linux ELF downloaded on macOS, or wrong CPU + // arch). Delete the managed copy so the next install re-downloads correctly. + warn!( + "gallery-dl exists but failed to run: {}", + output.stderr + ); + if is_managed { + if let Some(ref mp) = managed_path { + delete_managed_binary_if_corrupt(mp, &output.stderr).await; + } + } Ok(DependencyStatus::not_installed()) } Err(e) => { error!("Failed to execute gallery-dl: {}", e); - Ok(DependencyStatus { - path: Some(gdl_path.to_string_lossy().to_string()), - ..DependencyStatus::not_installed() - }) + // An OS-level execution error (e.g., ENOEXEC) also indicates a corrupt or + // incompatible binary — clean it up if it is our managed copy. + if is_managed { + if let Some(ref mp) = managed_path { + delete_managed_binary_if_corrupt(mp, &e).await; + } + } + Ok(DependencyStatus::not_installed()) } } } @@ -108,13 +155,27 @@ pub async fn install_gallery_dl( version: Option, proxy_config: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = (version, proxy_config); - return Err("gallery-dl installation on Android is not supported.".to_string()); + return Err("gallery-dl installation on mobile is not supported.".to_string()); + } + + // gallery-dl does not publish a standalone macOS binary in its GitHub releases. + // The "gallery-dl.bin" asset is a Linux ELF and cannot run on macOS regardless of + // architecture. Users must install via `pip install gallery-dl` or `brew install gallery-dl`. + #[cfg(target_os = "macos")] + { + let _ = (version, proxy_config, app); + return Err( + "gallery-dl does not provide a standalone macOS binary. \ + Please install it via Homebrew (`brew install gallery-dl`) \ + or pip (`pip install gallery-dl`)." + .to_string(), + ); } - #[cfg(not(target_os = "android"))] + #[cfg(all(desktop, not(target_os = "macos")))] { let config = proxy_config.unwrap_or_default(); diff --git a/src-tauri/src/deps/specs/lux.rs b/src-tauri/src/deps/specs/lux.rs index 5d2fe4a..20ad7b5 100644 --- a/src-tauri/src/deps/specs/lux.rs +++ b/src-tauri/src/deps/specs/lux.rs @@ -68,13 +68,13 @@ pub async fn check_lux( app: AppHandle, check_updates: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = check_updates; return Ok(DependencyStatus::not_installed()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let lux_path = match resolve_lux_path(&app) { Some(path) => path, @@ -132,7 +132,7 @@ pub async fn check_lux( } } -#[cfg(all(not(target_os = "android"), not(target_os = "windows")))] +#[cfg(all(desktop, not(target_os = "windows")))] async fn extract_tar_gz_lux( archive_path: &std::path::Path, bin_dir: &std::path::Path, @@ -181,13 +181,13 @@ pub async fn install_lux( app: AppHandle, proxy_config: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = proxy_config; - return Err("Lux installation on Android is not supported.".to_string()); + return Err("Lux installation on mobile is not supported.".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let config = proxy_config.unwrap_or_default(); let release = installer::fetch_github_latest_release("iawia002/lux", &config).await?; diff --git a/src-tauri/src/deps/specs/mod.rs b/src-tauri/src/deps/specs/mod.rs index 9d9ec89..8984375 100644 --- a/src-tauri/src/deps/specs/mod.rs +++ b/src-tauri/src/deps/specs/mod.rs @@ -1,8 +1,10 @@ pub mod aria2; pub mod deno; +pub mod edge_tts; pub mod ffmpeg; pub mod gallery_dl; pub mod js_runtime; pub mod lux; pub mod quickjs; +pub mod whisper; pub mod ytdlp; diff --git a/src-tauri/src/deps/specs/quickjs.rs b/src-tauri/src/deps/specs/quickjs.rs index b3491d2..6fc3964 100644 --- a/src-tauri/src/deps/specs/quickjs.rs +++ b/src-tauri/src/deps/specs/quickjs.rs @@ -189,7 +189,7 @@ pub async fn check_quickjs( app: AppHandle, check_updates: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = check_updates; if resolve_quickjs_path(&app).is_some() { @@ -198,7 +198,7 @@ pub async fn check_quickjs( return Ok(DependencyStatus::not_installed()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let quickjs_path = match resolve_quickjs_path(&app) { Some(path) => path, @@ -255,13 +255,13 @@ pub async fn install_quickjs( app: AppHandle, proxy_config: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = (app, proxy_config); return Err("libqjs.so".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let config = proxy_config.unwrap_or_default(); let version = fetch_latest_version(&config).await; @@ -309,13 +309,13 @@ pub async fn install_quickjs( } pub async fn uninstall_quickjs(app: AppHandle) -> Result<(), String> { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = app; return Err("Cannot uninstall bundled quickjs".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let quickjs_path = get_quickjs_path(&app)?; diff --git a/src-tauri/src/deps/specs/whisper.rs b/src-tauri/src/deps/specs/whisper.rs new file mode 100644 index 0000000..73c209c --- /dev/null +++ b/src-tauri/src/deps/specs/whisper.rs @@ -0,0 +1,284 @@ +use std::path::PathBuf; + +use tauri::{AppHandle, Manager}; +use tracing::{error, info, warn}; + +use crate::deps::engine::progress::ProgressEmitter; +use crate::deps::engine::verify::{find_in_system_path, run_capture_async}; +use crate::types::{DependencyStatus, InstallProgress}; + +const EVENT_PROGRESS: &str = "whisper-install-progress"; + +#[cfg(target_os = "windows")] +const BINARY_NAME: &str = "whisper.exe"; +#[cfg(not(target_os = "windows"))] +const BINARY_NAME: &str = "whisper"; + +fn get_venv_dir(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|d| d.join("podcast-venv")) + .map_err(|e| format!("Failed to get app data dir: {}", e)) +} + +fn get_venv_bin(app: &AppHandle, name: &str) -> Result { + let venv = get_venv_dir(app)?; + #[cfg(target_os = "windows")] + { + Ok(venv.join("Scripts").join(format!("{}.exe", name))) + } + #[cfg(not(target_os = "windows"))] + { + Ok(venv.join("bin").join(name)) + } +} + +/// Resolve whisper binary path: check venv first, then system PATH. +pub fn resolve_whisper_path(app: &AppHandle) -> Option { + // Check venv-local binary first + if let Ok(venv_bin) = get_venv_bin(app, "whisper") { + if venv_bin.exists() { + return Some(venv_bin); + } + } + // Fall back to system PATH + find_in_system_path(BINARY_NAME) +} + +/// Find a usable Python 3 executable on the system. +async fn find_python3() -> Option { + // Try `python3` first (preferred on Unix/macOS), then `python` (Windows fallback) + for candidate in &["python3", "python"] { + if let Some(path) = find_in_system_path(candidate) { + // Verify it's actually Python 3 by checking --version output + if let Ok(out) = run_capture_async(&path, &["--version"]).await { + let combined = format!("{} {}", out.stdout, out.stderr); + if combined.contains("Python 3") { + return Some(path); + } + } + } + } + None +} + +pub async fn check_whisper( + app: AppHandle, + check_updates: Option, +) -> Result { + #[cfg(mobile)] + { + let _ = check_updates; + return Ok(DependencyStatus::not_installed()); + } + + #[cfg(desktop)] + { + let _ = check_updates; // openai-whisper has no structured GitHub releases to compare against + + let whisper_path = match resolve_whisper_path(&app) { + Some(path) => path, + None => return Ok(DependencyStatus::not_installed()), + }; + + // whisper has no --version flag; use `pip show openai-whisper` for the version string. + let version = get_pip_version(&app).await; + match version { + Some(v) if !v.is_empty() => { + info!("whisper version (pip show): {}", v); + Ok(DependencyStatus::installed( + v, + whisper_path.to_string_lossy().to_string(), + )) + } + _ => { + // Binary found but version query failed — treat as installed with unknown version. + warn!("whisper binary found at {:?} but pip show version query failed", whisper_path); + Ok(DependencyStatus::installed( + "unknown".to_string(), + whisper_path.to_string_lossy().to_string(), + )) + } + } + } +} + +/// Query the installed version via `pip show openai-whisper`, checking the venv pip first. +async fn get_pip_version(app: &AppHandle) -> Option { + // Prefer venv pip + let pip_path = get_venv_bin(app, "pip").ok().filter(|p| p.exists()).or_else(|| { + find_in_system_path("pip3").or_else(|| find_in_system_path("pip")) + })?; + + let out = run_capture_async(&pip_path, &["show", "openai-whisper"]).await.ok()?; + for line in out.stdout.lines() { + if let Some(rest) = line.strip_prefix("Version:") { + return Some(rest.trim().to_string()); + } + } + None +} + +pub async fn install_whisper(app: AppHandle) -> Result { + #[cfg(mobile)] + { + return Err("whisper installation on mobile is not supported.".to_string()); + } + + #[cfg(desktop)] + { + let progress = ProgressEmitter::new(&app, EVENT_PROGRESS); + + progress.emit(InstallProgress { + stage: "checking".to_string(), + progress: 5, + message: "Checking for Python 3...".to_string(), + ..Default::default() + }); + + // Ensure Python 3 is available + let python_path = find_python3().await.ok_or_else(|| { + "Python 3 is not installed or not in PATH. \ + Please install Python 3 and try again." + .to_string() + })?; + + info!("Using Python 3 at: {:?}", python_path); + + let venv_dir = get_venv_dir(&app)?; + + // Create venv if it doesn't already exist + if !venv_dir.exists() { + progress.emit(InstallProgress { + stage: "creating venv".to_string(), + progress: 20, + message: "Creating podcast virtual environment...".to_string(), + ..Default::default() + }); + + info!("Creating podcast-venv at {:?}", venv_dir); + let out = crate::utils::new_command(&python_path) + .args(["-m", "venv", &venv_dir.to_string_lossy()]) + .output() + .await + .map_err(|e| format!("Failed to spawn python -m venv: {}", e))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + error!("Failed to create venv: {}", stderr); + return Err(format!("Failed to create podcast-venv: {}", stderr)); + } + + info!("podcast-venv created successfully"); + } else { + info!("podcast-venv already exists at {:?}, skipping creation", venv_dir); + } + + // Resolve venv pip + let venv_pip = get_venv_bin(&app, "pip")?; + if !venv_pip.exists() { + return Err(format!( + "Expected pip in venv but not found at {:?}. \ + The venv may be broken — try deleting it and reinstalling.", + venv_pip + )); + } + + progress.emit(InstallProgress { + stage: "installing".to_string(), + progress: 40, + message: "Installing openai-whisper via pip...".to_string(), + ..Default::default() + }); + + info!("Installing openai-whisper into {:?}", venv_dir); + let install_out = crate::utils::new_command(&venv_pip) + .args(["install", "--upgrade", "openai-whisper"]) + .output() + .await + .map_err(|e| format!("Failed to spawn pip install: {}", e))?; + + if !install_out.status.success() { + let stderr = String::from_utf8_lossy(&install_out.stderr).trim().to_string(); + error!("pip install openai-whisper failed: {}", stderr); + return Err(format!("Failed to install openai-whisper: {}", stderr)); + } + + info!("openai-whisper pip install succeeded"); + + progress.emit(InstallProgress { + stage: "verifying".to_string(), + progress: 80, + message: "Verifying whisper installation...".to_string(), + ..Default::default() + }); + + // Verify the binary is now resolvable and get version + let whisper_path = resolve_whisper_path(&app) + .ok_or_else(|| "whisper was installed but the binary was not found.".to_string())?; + + let version = get_pip_version(&app).await.unwrap_or_else(|| "unknown".to_string()); + + progress.emit(InstallProgress { + stage: "done".to_string(), + progress: 100, + message: format!("whisper {} installed", version), + ..Default::default() + }); + + info!("whisper installed at {:?}, version: {}", whisper_path, version); + Ok(version) + } +} + +pub async fn uninstall_whisper(app: AppHandle) -> Result<(), String> { + #[cfg(mobile)] + { + return Err("whisper is not supported on mobile.".to_string()); + } + + #[cfg(desktop)] + { + // Prefer uninstalling from the venv pip to avoid touching system packages + let venv_pip = get_venv_bin(&app, "pip").ok().filter(|p| p.exists()); + + if let Some(pip) = venv_pip { + info!("Uninstalling openai-whisper from podcast-venv via {:?}", pip); + let out = crate::utils::new_command(&pip) + .args(["uninstall", "-y", "openai-whisper"]) + .output() + .await + .map_err(|e| format!("Failed to spawn pip uninstall: {}", e))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + warn!("pip uninstall openai-whisper returned error: {}", stderr); + // Non-fatal: the package may simply not have been installed in this venv + } + return Ok(()); + } + + // Fall back to system pip3 / pip + let system_pip = find_in_system_path("pip3") + .or_else(|| find_in_system_path("pip")) + .ok_or_else(|| { + "Could not find pip to uninstall openai-whisper. \ + Please run `pip uninstall -y openai-whisper` manually." + .to_string() + })?; + + info!("Uninstalling openai-whisper via system pip at {:?}", system_pip); + let out = crate::utils::new_command(&system_pip) + .args(["uninstall", "-y", "openai-whisper"]) + .output() + .await + .map_err(|e| format!("Failed to spawn pip uninstall: {}", e))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + return Err(format!("Failed to uninstall openai-whisper: {}", stderr)); + } + + Ok(()) + } +} diff --git a/src-tauri/src/deps/specs/ytdlp.rs b/src-tauri/src/deps/specs/ytdlp.rs index 0be9985..dcb692d 100644 --- a/src-tauri/src/deps/specs/ytdlp.rs +++ b/src-tauri/src/deps/specs/ytdlp.rs @@ -1,18 +1,23 @@ use std::path::PathBuf; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tauri::AppHandle; #[cfg(target_os = "android")] use tauri::{AppHandle, Manager}; +#[cfg(target_os = "ios")] +use tauri::{AppHandle, Manager}; use tracing::{error, info, warn}; use crate::proxy::ProxyConfig; use crate::types::{DependencyStatus, ReleaseInfo}; +#[cfg(desktop)] use crate::deps::engine::download::fetch_json; +#[cfg(desktop)] use crate::deps::engine::installer::{ self, get_bin_dir as get_bin_dir_default, ExtractStrategy, GitHubRelease, InstallPlan, }; +#[cfg(desktop)] use crate::deps::engine::verify::run_capture_async; const EVENT_PROGRESS: &str = "ytdlp-install-progress"; @@ -21,7 +26,9 @@ const EVENT_PROGRESS: &str = "ytdlp-install-progress"; const BINARY_NAME: &str = "yt-dlp.exe"; #[cfg(target_os = "android")] const BINARY_NAME: &str = "libytdlp.so"; -#[cfg(all(not(target_os = "windows"), not(target_os = "android")))] +#[cfg(target_os = "ios")] +const BINARY_NAME: &str = ""; +#[cfg(all(not(target_os = "windows"), not(target_os = "android"), not(target_os = "ios")))] const BINARY_NAME: &str = "yt-dlp"; #[cfg(target_os = "windows")] @@ -32,6 +39,8 @@ const RELEASE_ASSET: &str = "yt-dlp_macos"; const RELEASE_ASSET: &str = "yt-dlp_linux"; #[cfg(target_os = "android")] const RELEASE_ASSET: &str = ""; +#[cfg(target_os = "ios")] +const RELEASE_ASSET: &str = ""; fn get_bin_dir(app: &AppHandle) -> Result { #[cfg(target_os = "android")] @@ -44,7 +53,17 @@ fn get_bin_dir(app: &AppHandle) -> Result { Ok(cache_dir.join("bin")) } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to get app cache dir: {}", e))?; + info!("Using iOS cache dir: {:?}", cache_dir); + Ok(cache_dir.join("bin")) + } + + #[cfg(desktop)] { get_bin_dir_default(app) } @@ -63,6 +82,7 @@ pub fn resolve_ytdlp_path(app: &AppHandle) -> Option { crate::deps::engine::verify::find_in_system_path(BINARY_NAME) } +#[cfg(desktop)] async fn fetch_latest_release(proxy_config: &ProxyConfig) -> Result { crate::deps::engine::installer::fetch_github_latest_release("yt-dlp/yt-dlp", proxy_config).await } @@ -77,7 +97,13 @@ pub async fn check_ytdlp( return Ok(DependencyStatus::embedded("youtubedl-android library")); } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let _ = (app, check_updates); + return Ok(DependencyStatus::not_installed()); + } + + #[cfg(desktop)] { let ytdlp_path = match resolve_ytdlp_path(&app) { Some(path) => path, @@ -131,13 +157,13 @@ pub async fn install_ytdlp( version: Option, proxy_config: Option, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - let _ = (version, proxy_config); + let _ = (app, version, proxy_config); return Ok("embedded".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let config = proxy_config.unwrap_or_default(); @@ -185,31 +211,36 @@ pub async fn install_ytdlp( } pub async fn uninstall_ytdlp(app: AppHandle) -> Result<(), String> { - let ytdlp_path = get_ytdlp_path(&app)?; - - if ytdlp_path.exists() { - tokio::fs::remove_file(&ytdlp_path) - .await - .map_err(|e| format!("Failed to remove yt-dlp: {}", e))?; + #[cfg(mobile)] + { + let _ = app; + return Ok(()); } - Ok(()) + #[cfg(desktop)] + { + let ytdlp_path = get_ytdlp_path(&app)?; + + if ytdlp_path.exists() { + tokio::fs::remove_file(&ytdlp_path) + .await + .map_err(|e| format!("Failed to remove yt-dlp: {}", e))?; + } + + Ok(()) + } } pub async fn get_ytdlp_releases( proxy_config: Option, ) -> Result, String> { - #[cfg(target_os = "android")] + #[cfg(mobile)] { let _ = proxy_config; - return Ok(vec![ReleaseInfo { - tag: "embedded".to_string(), - name: "youtubedl-android (embedded)".to_string(), - published_at: "".to_string(), - }]); + return Ok(vec![]); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let config = proxy_config.unwrap_or_default(); let releases: Vec = fetch_json( @@ -243,7 +274,13 @@ pub async fn self_update_ytdlp(app: AppHandle) -> Result { .map_err(|e| format!("Task panicked: {}", e))? } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let _ = app; + return Err("yt-dlp self-update is not supported on iOS".to_string()); + } + + #[cfg(desktop)] { let ytdlp_path = match resolve_ytdlp_path(&app) { Some(path) => path, @@ -286,7 +323,13 @@ pub async fn update_ytdlp_channel(app: AppHandle, channel: String) -> Result path, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c241b0..5003b71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,8 @@ mod clipboard; mod database; mod deps; -#[cfg(not(target_os = "android"))] +mod media_stream; +#[cfg(desktop)] mod discord; mod logs; mod media_info; @@ -11,16 +12,17 @@ mod proxy; mod server; mod store_utils; mod thumbnail_color; -#[cfg(not(target_os = "android"))] +mod torrent_search; +#[cfg(desktop)] mod tray; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] mod tray_icon; mod types; mod updater; mod url_utils; mod utils; mod window_effects; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub(crate) mod window_manager; use tracing::info; @@ -52,7 +54,7 @@ async fn detect_system_proxy() -> Result { #[tauri::command] async fn get_disk_space(path: String) -> Result { let actual_path = if path.is_empty() { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { dirs::download_dir() .ok_or("Could not find Downloads folder")? @@ -63,6 +65,13 @@ async fn get_disk_space(path: String) -> Result { { "/storage/emulated/0/Download/Comine".to_string() } + #[cfg(target_os = "ios")] + { + dirs::document_dir() + .ok_or("Could not find Documents folder")? + .to_string_lossy() + .to_string() + } } else { path }; @@ -73,7 +82,7 @@ async fn get_disk_space(path: String) -> Result { #[tauri::command] async fn get_default_download_dir() -> Result { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { dirs::download_dir() .map(|p| p.to_string_lossy().to_string()) @@ -84,11 +93,18 @@ async fn get_default_download_dir() -> Result { // On Android, use Downloads/Comine to avoid permission issues Ok("/storage/emulated/0/Download/Comine".to_string()) } + #[cfg(target_os = "ios")] + { + // On iOS, use the app's Documents directory + dirs::document_dir() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| "Could not determine Documents folder".to_string()) + } } #[tauri::command] #[cfg(target_os = "android")] -async fn open_file(path: String) -> Result { +async fn open_file(path: String, _app: Option) -> Result { use jni::objects::JValue; tracing::info!("open_file called with path: {}", path); @@ -125,14 +141,43 @@ async fn open_file(path: String) -> Result { } #[tauri::command] -#[cfg(not(target_os = "android"))] -async fn open_file(path: String) -> Result { - tracing::info!("open_file called with path: {}", path); +#[cfg(desktop)] +async fn open_file(path: String, app: Option) -> Result { + tracing::info!("open_file called with path: {}, app: {:?}", path, app); + + if let Some(ref app_name) = app { + #[cfg(target_os = "macos")] + { + return std::process::Command::new("open") + .args(["-a", app_name, &path]) + .spawn() + .map(|_| true) + .map_err(|e| format!("Failed to open with {}: {}", app_name, e)); + } + #[cfg(target_os = "linux")] + { + return std::process::Command::new(app_name.to_lowercase()) + .arg(&path) + .spawn() + .map(|_| true) + .map_err(|e| format!("Failed to open with {}: {}", app_name, e)); + } + // Windows / other: fall through to default opener + } + opener::open(std::path::Path::new(&path)) .map(|_| true) .map_err(|e| format!("Failed to open file: {}", e)) } +#[tauri::command] +#[cfg(target_os = "ios")] +async fn open_file(path: String, _app: Option) -> Result { + tracing::info!("open_file (iOS): presenting Open In… sheet for {}", path); + ios_ffi::call_open_file(&path); + Ok(true) +} + #[tauri::command] #[cfg(target_os = "android")] async fn open_folder(path: String) -> Result { @@ -166,7 +211,7 @@ async fn open_folder(path: String) -> Result { } #[tauri::command] -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] async fn open_folder(path: String) -> Result { tracing::info!("open_folder called with path: {}", path); opener::open(std::path::Path::new(&path)) @@ -175,7 +220,13 @@ async fn open_folder(path: String) -> Result { } #[tauri::command] -#[cfg(not(target_os = "android"))] +#[cfg(target_os = "ios")] +async fn open_folder(_path: String) -> Result { + Ok(false) +} + +#[tauri::command] +#[cfg(desktop)] async fn pick_folder(app: AppHandle) -> Result, String> { use tauri_plugin_dialog::DialogExt; let (tx, rx) = tokio::sync::oneshot::channel(); @@ -185,6 +236,12 @@ async fn pick_folder(app: AppHandle) -> Result, String> { rx.await.map_err(|_| "Dialog channel closed".to_string()) } +#[tauri::command] +#[cfg(target_os = "ios")] +async fn pick_folder() -> Result, String> { + Ok(None) +} + #[tauri::command] #[cfg(target_os = "android")] async fn pick_folder() -> Result, String> { @@ -217,7 +274,7 @@ async fn pick_folder() -> Result, String> { } #[tauri::command] -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] async fn reveal_file(path: String) -> Result { tracing::info!("reveal_file called with path: {}", path); let p = std::path::PathBuf::from(&path); @@ -256,12 +313,12 @@ async fn reveal_file(path: String) -> Result { } #[tauri::command] -#[cfg(target_os = "android")] +#[cfg(mobile)] async fn reveal_file(_path: String) -> Result { Ok(false) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub(crate) async fn reveal_file_internal(path: String) -> Result { reveal_file(path).await } @@ -489,7 +546,7 @@ struct IpCheckResult { proxy_source: String, } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] async fn autostart_enable(app: AppHandle) -> Result<(), String> { use tauri_plugin_autostart::ManagerExt; @@ -498,7 +555,7 @@ async fn autostart_enable(app: AppHandle) -> Result<(), String> { .map_err(|e| format!("Failed to enable autostart: {}", e)) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] async fn autostart_disable(app: AppHandle) -> Result<(), String> { use tauri_plugin_autostart::ManagerExt; @@ -507,7 +564,7 @@ async fn autostart_disable(app: AppHandle) -> Result<(), String> { .map_err(|e| format!("Failed to disable autostart: {}", e)) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] async fn autostart_is_enabled(app: AppHandle) -> Result { use tauri_plugin_autostart::ManagerExt; @@ -516,25 +573,25 @@ async fn autostart_is_enabled(app: AppHandle) -> Result { .map_err(|e| format!("Failed to check autostart status: {}", e)) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] async fn autostart_enable(_app: AppHandle) -> Result<(), String> { - Err("Autostart not supported on Android".to_string()) + Err("Autostart not supported on mobile".to_string()) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] async fn autostart_disable(_app: AppHandle) -> Result<(), String> { - Err("Autostart not supported on Android".to_string()) + Err("Autostart not supported on mobile".to_string()) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] async fn autostart_is_enabled(_app: AppHandle) -> Result { Ok(false) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] async fn discord_rpc_set_enabled( enabled: bool, @@ -548,7 +605,7 @@ async fn discord_rpc_set_enabled( Ok(()) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] async fn discord_rpc_set_enabled(_enabled: bool) -> Result<(), String> { Ok(()) @@ -574,6 +631,175 @@ fn server_is_running() -> bool { server::is_running() } +// iOS Swift FFI — uses dlsym for runtime symbol resolution because the Swift +// symbols are compiled by Xcode AFTER the Rust static lib is built. +#[cfg(target_os = "ios")] +mod ios_ffi { + use std::ffi::{c_char, c_void, CString}; + + unsafe fn dlsym_fn(name: &str) -> *mut c_void { + let c_name = CString::new(name).unwrap(); + unsafe { libc::dlsym(libc::RTLD_DEFAULT, c_name.as_ptr()) } + } + + pub fn call_start_background_audio() { + unsafe { + let ptr = dlsym_fn("start_background_audio"); + if !ptr.is_null() { + let f: extern "C" fn() = std::mem::transmute(ptr); + f(); + } + } + } + + pub fn call_stop_background_audio() { + unsafe { + let ptr = dlsym_fn("stop_background_audio"); + if !ptr.is_null() { + let f: extern "C" fn() = std::mem::transmute(ptr); + f(); + } + } + } + + pub fn call_is_background_audio_running() -> bool { + unsafe { + let ptr = dlsym_fn("is_background_audio_running"); + if !ptr.is_null() { + let f: extern "C" fn() -> bool = std::mem::transmute(ptr); + f() + } else { + false + } + } + } + + pub fn call_live_activity_start(job_id: &str, title: &str) { + unsafe { + let ptr = dlsym_fn("live_activity_start"); + if !ptr.is_null() { + let c_job = CString::new(job_id).unwrap_or_default(); + let c_title = CString::new(title).unwrap_or_default(); + let f: extern "C" fn(*const c_char, *const c_char) = std::mem::transmute(ptr); + f(c_job.as_ptr(), c_title.as_ptr()); + } + } + } + + pub fn call_live_activity_update( + job_id: &str, + progress: f64, + speed_bps: i64, + downloaded_bytes: i64, + total_bytes: i64, + eta: i32, + ) { + unsafe { + let ptr = dlsym_fn("live_activity_update"); + if !ptr.is_null() { + let c_job = CString::new(job_id).unwrap_or_default(); + let f: extern "C" fn(*const c_char, f64, i64, i64, i64, i32) = + std::mem::transmute(ptr); + f(c_job.as_ptr(), progress, speed_bps, downloaded_bytes, total_bytes, eta); + } + } + } + + pub fn call_live_activity_finish(job_id: &str) { + unsafe { + let ptr = dlsym_fn("live_activity_finish"); + if !ptr.is_null() { + let c_job = CString::new(job_id).unwrap_or_default(); + let f: extern "C" fn(*const c_char) = std::mem::transmute(ptr); + f(c_job.as_ptr()); + } + } + } + + pub fn call_live_activity_stop() { + unsafe { + let ptr = dlsym_fn("live_activity_stop"); + if !ptr.is_null() { + let f: extern "C" fn() = std::mem::transmute(ptr); + f(); + } + } + } + + pub fn call_open_file(path: &str) { + unsafe { + let ptr = dlsym_fn("ios_open_file"); + if !ptr.is_null() { + let c_path = CString::new(path).unwrap_or_default(); + let f: extern "C" fn(*const c_char) = std::mem::transmute(ptr); + f(c_path.as_ptr()); + } + } + } + + pub fn call_fix_viewport() { + unsafe { + let ptr = dlsym_fn("fix_ios_viewport"); + if !ptr.is_null() { + let f: extern "C" fn() = std::mem::transmute(ptr); + f(); + } + } + } +} + +#[tauri::command] +fn ios_start_background_audio() { + #[cfg(target_os = "ios")] + ios_ffi::call_start_background_audio(); +} + +#[tauri::command] +fn ios_stop_background_audio() { + #[cfg(target_os = "ios")] + ios_ffi::call_stop_background_audio(); +} + +#[tauri::command] +fn ios_is_background_audio_running() -> bool { + #[cfg(target_os = "ios")] + { ios_ffi::call_is_background_audio_running() } + #[cfg(not(target_os = "ios"))] + { false } +} + +#[tauri::command] +fn ios_live_activity_start(#[allow(unused)] job_id: String, #[allow(unused)] title: String) { + #[cfg(target_os = "ios")] + ios_ffi::call_live_activity_start(&job_id, &title); +} + +#[tauri::command] +#[allow(unused_variables)] +fn ios_live_activity_update( + job_id: String, + progress: f64, + speed_bps: i64, + downloaded_bytes: i64, + total_bytes: i64, + eta: i32, +) { + #[cfg(target_os = "ios")] + ios_ffi::call_live_activity_update(&job_id, progress, speed_bps, downloaded_bytes, total_bytes, eta); +} + +#[tauri::command] +fn ios_live_activity_finish(#[allow(unused)] job_id: String) { + #[cfg(target_os = "ios")] + ios_ffi::call_live_activity_finish(&job_id); +} + +#[tauri::command] +fn ios_live_activity_stop() { + #[cfg(target_os = "ios")] + ios_ffi::call_live_activity_stop(); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() @@ -582,6 +808,9 @@ pub fn run() { .targets([ tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { + file_name: Some("comine".into()), + }), ]) .level(log::LevelFilter::Debug) .build(), @@ -595,7 +824,7 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let builder = builder.plugin( tauri_plugin_autostart::Builder::new() .args(["--minimized"]) @@ -606,6 +835,9 @@ pub fn run() { media_info::get_media_duration, media_info::get_media_technical_info, media_info::generate_local_thumbnail, + media_info::find_subtitles, + media_info::read_subtitle_file, + media_info::remux_for_playback, orchestrator::thumbnail::get_or_cache_thumbnail, thumbnail_color::get_cached_thumbnail_color, thumbnail_color::extract_thumbnail_color, @@ -661,6 +893,12 @@ pub fn run() { deps::install_gallery_dl, deps::uninstall_gallery_dl, deps::get_gallery_dl_releases, + deps::check_edge_tts, + deps::install_edge_tts, + deps::uninstall_edge_tts, + deps::check_whisper, + deps::install_whisper, + deps::uninstall_whisper, deps::cancel_dep_install, autostart_enable, autostart_disable, @@ -706,11 +944,29 @@ pub fn run() { orchestrator::fetch_broadcasts, orchestrator::convert::convert_local_file, orchestrator::convert::cancel_conversion, + #[cfg(desktop)] + orchestrator::podcast::generate_podcast, + #[cfg(desktop)] + orchestrator::podcast::get_podcast_settings, + #[cfg(desktop)] + orchestrator::podcast::set_podcast_settings, clipboard::start_clipboard_watcher, clipboard::stop_clipboard_watcher, clipboard::set_url_input_focused, - #[cfg(not(target_os = "android"))] - tray::rebuild_tray_menu + #[cfg(desktop)] + tray::rebuild_tray_menu, + torrent_search::torrent_search, + torrent_search::torrent_autocomplete, + torrent_search::torrent_list_files, + media_stream::start_media_stream, + media_stream::stop_media_stream, + ios_start_background_audio, + ios_stop_background_audio, + ios_is_background_audio_running, + ios_live_activity_start, + ios_live_activity_update, + ios_live_activity_finish, + ios_live_activity_stop, ]); let builder = builder @@ -720,7 +976,7 @@ pub fn run() { app.manage(std::sync::Arc::new(clipboard::ClipboardWatcherState::new())); app.manage(std::sync::Arc::new(clipboard::InputFocusState::new())); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] app.manage(std::sync::Arc::new(window_manager::WindowState::new())); { @@ -732,7 +988,12 @@ pub fn run() { deps::updater::start(app.handle()); - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + ios_ffi::call_fix_viewport(); + } + + #[cfg(desktop)] { tray::setup(app.handle())?; tray_icon::start_polling(app.handle()); diff --git a/src-tauri/src/logs.rs b/src-tauri/src/logs.rs index 9867e99..fdc4d06 100644 --- a/src-tauri/src/logs.rs +++ b/src-tauri/src/logs.rs @@ -21,12 +21,12 @@ fn get_logs_dir(app: &AppHandle) -> Result { #[tauri::command] pub async fn get_log_file_path(app: AppHandle) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - return Err("Log files not supported on Android".to_string()); + return Err("Log files not supported on mobile".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let logs_dir = get_logs_dir(&app)?; let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); @@ -41,12 +41,12 @@ pub async fn append_log( session_file: String, entry: String, ) -> Result<(), String> { - #[cfg(target_os = "android")] + #[cfg(mobile)] { return Ok(()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let path = std::path::Path::new(&session_file); let mut file = std::fs::OpenOptions::new() @@ -62,12 +62,12 @@ pub async fn append_log( #[tauri::command] pub async fn cleanup_old_logs(app: AppHandle, keep_sessions: usize) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { return Ok(0); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let logs_dir = get_logs_dir(&app)?; @@ -103,12 +103,12 @@ pub async fn cleanup_old_logs(app: AppHandle, keep_sessions: usize) -> Result Result<(), String> { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - return Err("Not supported on Android".to_string()); + return Err("Not supported on mobile".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let logs_dir = get_logs_dir(&app)?; @@ -142,12 +142,12 @@ pub async fn open_logs_folder(app: AppHandle) -> Result<(), String> { #[tauri::command] pub async fn get_logs_folder_path(app: AppHandle) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - return Err("Not supported on Android".to_string()); + return Err("Not supported on mobile".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let logs_dir = get_logs_dir(&app)?; Ok(logs_dir.to_string_lossy().to_string()) @@ -160,12 +160,12 @@ pub async fn read_session_logs( offset: Option, limit: Option, ) -> Result, String> { - #[cfg(target_os = "android")] + #[cfg(mobile)] { return Ok(vec![]); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let path = std::path::Path::new(&session_file); if !path.exists() { @@ -191,12 +191,12 @@ pub async fn read_session_logs( #[tauri::command] pub async fn get_session_log_count(session_file: String) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { return Ok(0); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let path = std::path::Path::new(&session_file); if !path.exists() { diff --git a/src-tauri/src/media_info.rs b/src-tauri/src/media_info.rs index 042f658..4c64c45 100644 --- a/src-tauri/src/media_info.rs +++ b/src-tauri/src/media_info.rs @@ -1,9 +1,10 @@ use tauri::AppHandle; +use tracing::{debug, info, warn}; #[cfg(feature = "ts-export")] use ts_rs::TS; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] fn resolve_ffprobe_cmd(app: &AppHandle) -> Result { match crate::deps::resolve_ffprobe_path(app) { Some(path) => Ok(path.to_string_lossy().to_string()), @@ -11,7 +12,7 @@ fn resolve_ffprobe_cmd(app: &AppHandle) -> Result { } } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] async fn run_ffprobe(app: &AppHandle, args: &[&str]) -> Result { use std::process::Stdio; @@ -35,12 +36,12 @@ async fn run_ffprobe(app: &AppHandle, args: &[&str]) -> Result { #[tauri::command] #[allow(unused_variables)] pub async fn get_media_duration(app: AppHandle, file_path: String) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - return Err("get_media_duration not supported on Android".to_string()); + return Err("get_media_duration not supported on mobile".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { if !std::path::Path::new(&file_path).exists() { return Err(format!("File not found: {}", file_path)); @@ -103,12 +104,12 @@ pub async fn get_media_technical_info( app: AppHandle, file_path: String, ) -> Result { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - return Err("get_media_technical_info not supported on Android".to_string()); + return Err("get_media_technical_info not supported on mobile".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { if !std::path::Path::new(&file_path).exists() { return Err(format!("File not found: {}", file_path)); @@ -177,3 +178,304 @@ pub async fn generate_local_thumbnail( ) -> Result { crate::orchestrator::thumbnail::generate_local_thumbnail(&app, &file_path, &item_id).await } + +#[derive(serde::Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct SubtitleFile { + pub path: String, + pub label: String, + pub format: String, +} + +#[tauri::command] +pub async fn find_subtitles(file_path: String) -> Result, String> { + let path = std::path::Path::new(&file_path); + + let parent = path + .parent() + .ok_or_else(|| format!("Could not determine parent directory of: {}", file_path))?; + + let stem = path + .file_stem() + .ok_or_else(|| format!("Could not determine file stem of: {}", file_path))? + .to_string_lossy() + .to_string(); + + const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "vtt"]; + + let parent_buf = parent.to_path_buf(); + let subtitles = tokio::task::spawn_blocking(move || -> Result, String> { + let read_dir = std::fs::read_dir(&parent_buf) + .map_err(|e| format!("Failed to read directory: {}", e))?; + + let mut subtitles: Vec = Vec::new(); + + for entry in read_dir.flatten() { + let entry_path = entry.path(); + + let ext = match entry_path.extension() { + Some(e) => e.to_string_lossy().to_lowercase(), + None => continue, + }; + + if !SUBTITLE_EXTENSIONS.contains(&ext.as_str()) { + continue; + } + + let entry_stem = match entry_path.file_stem() { + Some(s) => s.to_string_lossy().to_string(), + None => continue, + }; + + if entry_stem != stem && !entry_stem.starts_with(&format!("{}.", stem)) { + continue; + } + + let label = if entry_stem == stem { + "Default".to_string() + } else { + // Extract the suffix between the media stem and the subtitle extension. + // e.g. stem="video", entry_stem="video.en" → suffix=".en" → label="en" + let suffix = &entry_stem[stem.len()..]; + suffix.trim_start_matches('.').to_string() + }; + + subtitles.push(SubtitleFile { + path: entry_path.to_string_lossy().to_string(), + label, + format: ext.to_string(), + }); + } + + subtitles.sort_by(|a, b| a.label.cmp(&b.label)); + + Ok(subtitles) + }) + .await + .map_err(|e| format!("spawn_blocking panicked: {}", e))??; + + Ok(subtitles) +} + +#[tauri::command] +#[allow(unused_variables)] +pub async fn remux_for_playback(app: AppHandle, file_path: String) -> Result { + #[cfg(mobile)] + { + return Err("remux_for_playback not supported on mobile".to_string()); + } + + #[cfg(desktop)] + { + use std::path::Path; + use std::process::Stdio; + use tauri::Manager; + + const HTML5_EXTS: &[&str] = &["mp4", "webm", "ogg"]; + + let input_path = Path::new(&file_path); + + if !input_path.exists() { + return Err(format!("File not found: {}", file_path)); + } + + // If the file is already HTML5-compatible, return it as-is. + let ext = input_path + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + + if HTML5_EXTS.contains(&ext.as_str()) { + debug!(path = %file_path, "File already HTML5-compatible, skipping remux"); + return Ok(file_path); + } + + // Resolve the ffmpeg binary. + let ffmpeg_path = match crate::deps::resolve_ffmpeg_path(&app) { + Some(p) => p, + None => { + return Err( + "FFmpeg not installed. Please install it from Settings → Dependencies." + .to_string(), + ) + } + }; + + // Determine the cache directory: /playback_remux/ + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to resolve app cache dir: {}", e))? + .join("playback_remux"); + + tokio::fs::create_dir_all(&cache_dir) + .await + .map_err(|e| format!("Failed to create remux cache dir: {}", e))?; + + let stem = input_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "remuxed".to_string()); + + // Try MP4 first, then WebM as a fallback. + let mp4_output = cache_dir.join(format!("{}.mp4", stem)); + let webm_output = cache_dir.join(format!("{}.webm", stem)); + + // Return a cached file if it already exists and is non-empty. + for cached in [&mp4_output, &webm_output] { + if cached.exists() { + if let Ok(meta) = tokio::fs::metadata(cached).await { + if meta.len() > 0 { + info!( + input = %file_path, + output = %cached.display(), + "Returning cached remux" + ); + return Ok(cached.to_string_lossy().to_string()); + } + } + } + } + + let t_start = std::time::Instant::now(); + + // Attempt 1: remux to MP4 with -c copy + faststart. + info!(input = %file_path, output = %mp4_output.display(), "Remuxing to MP4 for playback"); + + let mp4_result = tokio::time::timeout( + std::time::Duration::from_secs(30), + async { + let mut cmd = crate::utils::new_command(&ffmpeg_path); + cmd.arg("-i") + .arg(&file_path) + .args(["-c", "copy", "-movflags", "+faststart", "-y"]) + .arg(&mp4_output) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to spawn FFmpeg: {}", e))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Err(stderr) + } + }, + ) + .await; + + match mp4_result { + Ok(Ok(())) => { + let elapsed = t_start.elapsed(); + info!( + input = %file_path, + output = %mp4_output.display(), + elapsed_ms = elapsed.as_millis(), + "Remux to MP4 completed" + ); + return Ok(mp4_output.to_string_lossy().to_string()); + } + Ok(Err(stderr)) => { + warn!( + input = %file_path, + stderr = %stderr, + "MP4 remux failed (incompatible codecs?), trying WebM" + ); + // Clean up any partial output. + let _ = tokio::fs::remove_file(&mp4_output).await; + } + Err(_) => { + warn!(input = %file_path, "MP4 remux timed out, trying WebM"); + let _ = tokio::fs::remove_file(&mp4_output).await; + } + } + + // Attempt 2: fallback — remux to WebM with -c copy. + info!(input = %file_path, output = %webm_output.display(), "Remuxing to WebM as fallback"); + + let webm_result = tokio::time::timeout( + std::time::Duration::from_secs(30), + async { + let mut cmd = crate::utils::new_command(&ffmpeg_path); + cmd.arg("-i") + .arg(&file_path) + .args(["-c", "copy", "-y"]) + .arg(&webm_output) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to spawn FFmpeg: {}", e))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Err(stderr) + } + }, + ) + .await; + + match webm_result { + Ok(Ok(())) => { + let elapsed = t_start.elapsed(); + info!( + input = %file_path, + output = %webm_output.display(), + elapsed_ms = elapsed.as_millis(), + "Remux to WebM completed" + ); + Ok(webm_output.to_string_lossy().to_string()) + } + Ok(Err(stderr)) => { + let _ = tokio::fs::remove_file(&webm_output).await; + Err(format!( + "Both MP4 and WebM remux failed. Last error: {}", + stderr.lines().last().unwrap_or("unknown") + )) + } + Err(_) => { + let _ = tokio::fs::remove_file(&webm_output).await; + Err("Remux timed out (30s limit exceeded)".to_string()) + } + } + } +} + +#[tauri::command] +pub async fn read_subtitle_file(file_path: String) -> Result { + let path = std::path::Path::new(&file_path); + + if !path.exists() { + return Err(format!("File not found: {}", file_path)); + } + + let meta = tokio::fs::metadata(path) + .await + .map_err(|e| format!("Cannot stat subtitle file: {}", e))?; + if meta.len() > 10 * 1024 * 1024 { + return Err(format!("Subtitle file too large: {} bytes", meta.len())); + } + + let bytes = tokio::fs::read(path) + .await + .map_err(|e| format!("Failed to read subtitle file: {}", e))?; + + // Try strict UTF-8 first; fall back to lossy decoding so the frontend always + // receives a valid string rather than an error on non-UTF-8 encoded files. + let content = match String::from_utf8(bytes.clone()) { + Ok(s) => s, + Err(_) => String::from_utf8_lossy(&bytes).into_owned(), + }; + + Ok(content) +} diff --git a/src-tauri/src/media_stream.rs b/src-tauri/src/media_stream.rs new file mode 100644 index 0000000..b24ab1e --- /dev/null +++ b/src-tauri/src/media_stream.rs @@ -0,0 +1,268 @@ +use std::sync::{LazyLock, Mutex}; +use tauri::AppHandle; +use tokio::io::AsyncBufReadExt; +use tracing::{debug, info, warn}; + +struct ActiveStream { + temp_file: std::path::PathBuf, + child: Option, +} + +static ACTIVE_STREAM: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +/// Tear down the current active stream (kill FFmpeg, delete temp file). +fn kill_active_stream() { + let old = { + let mut guard = crate::utils::lock_or_recover(&ACTIVE_STREAM); + guard.take() + }; + + if let Some(mut stream) = old { + if let Some(ref mut child) = stream.child { + let _ = child.start_kill(); + } + if let Err(e) = std::fs::remove_file(&stream.temp_file) { + debug!("Temp file cleanup: {}", e); + } + info!("Media stream torn down"); + } +} + +/// Probe the video codec of the first video stream using ffprobe. +#[cfg(desktop)] +async fn probe_video_codec(app: &AppHandle, file_path: &str) -> Option { + use std::process::Stdio; + + let ffprobe_path = crate::deps::resolve_ffprobe_path(app)?; + let mut cmd = crate::utils::new_command(&ffprobe_path); + cmd.args([ + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=codec_name", + "-of", "csv=p=0", + ]) + .arg(file_path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let output = cmd.output().await.ok()?; + let codec = String::from_utf8_lossy(&output.stdout).trim().to_lowercase().to_string(); + if codec.is_empty() { None } else { Some(codec) } +} + +/// Remux a media file to a fragmented MP4 temp file using FFmpeg. +/// +/// Returns the path to the temp file. The frontend should use `convertFileSrc()` +/// on this path to get an asset:// URL that WebKit can play with full Range +/// request support. +#[cfg(desktop)] +#[tauri::command] +pub async fn start_media_stream(app: AppHandle, file_path: String) -> Result { + use std::process::Stdio; + use std::time::Duration; + + info!(path = %file_path, "start_media_stream called"); + + let ffmpeg_path = crate::deps::resolve_ffmpeg_path(&app).ok_or_else(|| { + "FFmpeg not installed. Please install it from Settings \u{2192} Dependencies.".to_string() + })?; + + if !std::path::Path::new(&file_path).exists() { + return Err(format!("File not found: {}", file_path)); + } + + // Probe video codec to determine if transcoding is needed. + let video_codec = probe_video_codec(&app, &file_path).await; + let needs_transcode = matches!(video_codec.as_deref(), Some("hevc" | "h265")); + if needs_transcode { + info!(codec = ?video_codec, "HEVC detected — will transcode to H.264"); + } + // Transcoding needs more time than remuxing. + let ffmpeg_timeout = if needs_transcode { + Duration::from_secs(600) + } else { + Duration::from_secs(120) + }; + + // Tear down any previous stream first. + kill_active_stream(); + + // Temp file for FFmpeg's fragmented MP4 output. + let temp_file = std::env::temp_dir().join(format!( + "comine_stream_{}.mp4", + std::process::id() + )); + + // Retry loop: if FFmpeg fails quickly (e.g. file still downloading, headers + // incomplete), wait for more data and retry. + const MAX_ATTEMPTS: u32 = 6; + const RETRY_DELAY: Duration = Duration::from_secs(3); + let mut last_error = String::new(); + let mut prev_file_size: u64 = std::fs::metadata(&file_path) + .map(|m| m.len()) + .unwrap_or(0); + + for attempt in 0..MAX_ATTEMPTS { + if attempt > 0 { + let current_size = std::fs::metadata(&file_path) + .map(|m| m.len()) + .unwrap_or(0); + if current_size == prev_file_size { + info!(attempt, "Source file not growing, stopping retries"); + break; + } + prev_file_size = current_size; + info!(attempt, size = current_size, "Retrying FFmpeg (file still growing)"); + tokio::time::sleep(RETRY_DELAY).await; + } + + let _ = std::fs::remove_file(&temp_file); + + // Spawn FFmpeg: remux (or transcode) into fragmented MP4. + let mut cmd = crate::utils::new_command(&ffmpeg_path); + cmd.arg("-i").arg(&file_path); + + if needs_transcode { + // HEVC → transcode video to H.264 for WebKit compatibility. + // ultrafast preset minimizes encoding time; CRF 23 balances quality/size. + cmd.args(["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]); + // Copy audio if possible (AAC/MP3/Opus work in MP4). + cmd.args(["-c:a", "copy"]); + } else { + cmd.args(["-c", "copy"]); + } + + cmd.args([ + "-movflags", + "frag_keyframe+empty_moov+default_base_moof", + "-f", + "mp4", + "-y", + ]) + .arg(&temp_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + last_error = format!("Failed to spawn FFmpeg: {}", e); + continue; + } + }; + + if let Some(stderr) = child.stderr.take() { + tokio::spawn(log_ffmpeg_stderr(stderr)); + } + + // Wait for FFmpeg to finish. Timeout varies: 120s for remux, 600s for transcode. + let result = tokio::time::timeout(ffmpeg_timeout, child.wait()).await; + + match result { + Ok(Ok(status)) if status.success() => { + // Verify the temp file has content. + match std::fs::metadata(&temp_file) { + Ok(m) if m.len() > 0 => { + let path_str = temp_file.to_string_lossy().to_string(); + info!(path = %path_str, size = m.len(), "Remux complete"); + + // Store for cleanup on close. + { + let mut guard = crate::utils::lock_or_recover(&ACTIVE_STREAM); + *guard = Some(ActiveStream { + temp_file, + child: None, + }); + } + + return Ok(path_str); + } + _ => { + last_error = "FFmpeg produced empty output.".to_string(); + } + } + } + Ok(Ok(status)) => { + last_error = format!("FFmpeg exited with status: {}", status); + } + Ok(Err(e)) => { + last_error = format!("FFmpeg wait error: {}", e); + } + Err(_) => { + // Timeout — kill the child. This may happen for in-progress downloads + // where FFmpeg keeps reading as the source file grows. + // Return what we have so far if the temp file has content. + let _ = child.start_kill(); + let _ = child.wait().await; + + match std::fs::metadata(&temp_file) { + Ok(m) if m.len() > 0 => { + let path_str = temp_file.to_string_lossy().to_string(); + info!( + path = %path_str, + size = m.len(), + "FFmpeg timed out but produced partial output, using it" + ); + + { + let mut guard = crate::utils::lock_or_recover(&ACTIVE_STREAM); + *guard = Some(ActiveStream { + temp_file, + child: None, + }); + } + + return Ok(path_str); + } + _ => { + last_error = + "FFmpeg timed out without producing output.".to_string(); + } + } + } + } + } + + // All retries exhausted. + let _ = std::fs::remove_file(&temp_file); + Err(last_error) +} + +/// Log FFmpeg stderr output for debugging. +#[cfg(desktop)] +async fn log_ffmpeg_stderr(stderr: tokio::process::ChildStderr) { + let reader = tokio::io::BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.starts_with("frame=") || line.starts_with("size=") || line.starts_with("speed=") { + debug!(target: "ffmpeg", "{}", line); + } else { + warn!(target: "ffmpeg", "{}", line); + } + } +} + +/// Stop the currently active media stream, killing FFmpeg and freeing resources. +#[cfg(desktop)] +#[tauri::command] +pub async fn stop_media_stream() -> Result<(), String> { + info!("stop_media_stream called"); + kill_active_stream(); + Ok(()) +} + +// Mobile stubs — FFmpeg is not available on mobile targets. + +#[cfg(mobile)] +#[tauri::command] +pub async fn start_media_stream(_app: AppHandle, _file_path: String) -> Result { + Err("Media streaming is not supported on mobile".to_string()) +} + +#[cfg(mobile)] +#[tauri::command] +pub async fn stop_media_stream() -> Result<(), String> { + Err("Media streaming is not supported on mobile".to_string()) +} diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index d66dea0..09514a4 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -136,12 +136,12 @@ pub async fn show_notification_window( monitor: Option, offset: Option, ) -> Result<(), String> { - #[cfg(target_os = "android")] + #[cfg(mobile)] { - return Err("Notification windows not supported on Android".to_string()); + return Err("Notification windows not supported on mobile".to_string()); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { use std::sync::atomic::{AtomicU32, Ordering}; static NOTIFICATION_COUNTER: AtomicU32 = AtomicU32::new(0); @@ -419,7 +419,7 @@ pub async fn show_notification_window( #[tauri::command] pub async fn reveal_notification_window(app: AppHandle, window_id: String) -> Result<(), String> { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { info!("Revealing notification window: {}", window_id); if let Some(window) = app.get_webview_window(&window_id) { @@ -471,7 +471,7 @@ pub async fn reveal_notification_window(app: AppHandle, window_id: String) -> Re #[tauri::command] pub async fn close_notification_window(app: AppHandle, window_id: String) -> Result<(), String> { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { info!("Closing notification: {}", window_id); @@ -512,7 +512,7 @@ pub async fn close_notification_window(app: AppHandle, window_id: String) -> Res #[tauri::command] pub async fn close_all_notifications(app: AppHandle) -> Result<(), String> { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { info!("Closing all notification windows"); @@ -540,7 +540,7 @@ pub async fn notification_action( metadata: Option, keep_open: Option, ) -> Result<(), String> { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { info!( "Notification action triggered: window_id={}, url={:?}, has_metadata={}, keep_open={:?}", @@ -585,7 +585,7 @@ pub async fn notification_action( let _ = main_window.emit("notification-start-download", payload); info!("Emitted notification-start-download to main window for playlist/channel/track-builder"); } else { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { if let Err(e) = crate::window_manager::recreate_main_window(&app) { tracing::error!("Failed to recreate window: {}", e); diff --git a/src-tauri/src/orchestrator/backends/aria2.rs b/src-tauri/src/orchestrator/backends/aria2.rs index 9e1bc5b..9e1ad83 100644 --- a/src-tauri/src/orchestrator/backends/aria2.rs +++ b/src-tauri/src/orchestrator/backends/aria2.rs @@ -6,7 +6,7 @@ use tracing::info; use crate::orchestrator::backends::{ extract_filename_from_url, extract_magnet_name, guess_mime_type, has_file_extension, - is_torrent_url, parse_size_str, Backend, BackendCapabilities, SpawnContext, + is_torrent_url, parse_size_str, Backend, BackendCapabilities, MetadataEvent, SpawnContext, DIRECT_FILE_EXTENSIONS, }; use crate::orchestrator::types::*; @@ -110,6 +110,26 @@ fn build_aria2_args(req: &DownloadRequest, config: &Aria2Config) -> Vec>() + .join(","); + args.push(vec![format!("--select-file={}", indices)]); + } + } } args.push(vec![req.url.clone()]); @@ -210,7 +230,7 @@ fn parse_eta_str(s: &str) -> Option { } pub struct Aria2Backend { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] binary_path: PathBuf, } @@ -314,14 +334,14 @@ pub fn cancel_aria2(job_id: &str) -> Result<(), String> { mod exec { use super::*; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] pub fn create_backend(app: &AppHandle) -> Option { let binary_path = crate::deps::resolve_aria2_path(app)?; info!("aria2 backend using binary: {:?}", binary_path); Some(Aria2Backend { binary_path }) } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] pub async fn run_aria2( backend: &Aria2Backend, ctx: SpawnContext, @@ -367,6 +387,10 @@ mod exec { let mut output_path_count: u32 = 0; let mut last_update = std::time::Instant::now(); let job_id = ctx.job.id.clone(); + let is_torrent = is_torrent_url(&ctx.job.request.url); + let mut early_path_sent = false; + // Track the actual file path detected from aria2's FILE: lines (for torrents). + let mut detected_file_path: Option = None; loop { tokio::select! { @@ -393,6 +417,24 @@ mod exec { output_path = Some(path); output_path_count += 1; } + + // For torrent downloads, detect the file path early + // from aria2's "FILE:" lines and emit it so the + // frontend can show a play button while downloading. + if is_torrent { + if let Some(path) = extract_aria2_file_line(&line) { + if detected_file_path.is_none() { + detected_file_path = Some(path.clone()); + } + if !early_path_sent && is_media_extension(&path) { + info!(target: "aria2", "Early file path detected: {}", path); + let _ = ctx.metadata_tx.send( + MetadataEvent::FilePath(path), + ); + early_path_sent = true; + } + } + } } Ok(None) => break, Err(e) => { @@ -416,7 +458,25 @@ mod exec { ))); } - let final_path = if output_path_count > 1 { + // For torrent single-file downloads, use the detected FILE: path + // directly — this is the actual file, not the torrent directory. + let has_single_selected = ctx + .job + .request + .options + .torrent_selected_files + .as_ref() + .map(|f| f.len() == 1) + .unwrap_or(false); + + let final_path = if has_single_selected { + if let Some(ref path) = detected_file_path { + info!(target: "aria2", "Using detected file path for single-file torrent: {}", path); + path.clone() + } else { + output_path.unwrap_or_else(|| ctx.job.request.output.directory.clone()) + } + } else if output_path_count > 1 { // Multi-file download (torrent): use parent directory output_path .as_ref() @@ -441,18 +501,55 @@ mod exec { Ok(final_path) } - #[cfg(not(target_os = "android"))] - pub(super) fn extract_output_path(line: &str) -> Option { - if line.contains("Download complete:") { - let path = line.split("Download complete:").nth(1)?.trim(); - if !path.is_empty() { - return Some(path.to_string()); + /// Extract a real file path from aria2's `FILE: /path/to/file` output lines. + /// Ignores `[MEMORY]` metadata lines. Strips trailing ` (Nmore)` suffixes. + #[cfg(desktop)] + pub(super) fn extract_aria2_file_line(line: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.starts_with("FILE:") { + return None; + } + let path = trimmed.strip_prefix("FILE:")?.trim(); + if path.is_empty() || path.starts_with("[MEMORY]") { + return None; + } + // Strip trailing " (1more)", " (2more)", etc. + let clean = if let Some(idx) = path.rfind(" (") { + if path[idx..].ends_with("more)") { + path[..idx].trim() + } else { + path } + } else { + path + }; + if clean.is_empty() { + return None; } + Some(clean.to_string()) + } + + /// Check if a file path has a media (video/audio) extension. + /// Used to filter out subtitle/text files when detecting early file paths from torrents. + #[cfg(desktop)] + fn is_media_extension(path: &str) -> bool { + const MEDIA_EXTS: &[&str] = &[ + "mkv", "mp4", "avi", "wmv", "flv", "mov", "webm", "m4v", "mpg", "mpeg", "ts", + "vob", "ogv", "3gp", "m2ts", "mts", "divx", "rmvb", "asf", + "mp3", "flac", "wav", "aac", "ogg", "opus", "m4a", "wma", "alac", "ape", "aiff", + ]; + path.rsplit('.') + .next() + .map(|ext| MEDIA_EXTS.contains(&ext.to_lowercase().as_str())) + .unwrap_or(false) + } - if line.contains("[NOTICE]") && line.contains("Download complete:") { + #[cfg(desktop)] + pub(super) fn extract_output_path(line: &str) -> Option { + if line.contains("Download complete:") { let path = line.split("Download complete:").nth(1)?.trim(); - if !path.is_empty() { + // Skip [MEMORY] metadata lines — these are torrent metadata, not real files + if !path.is_empty() && !path.starts_with("[MEMORY]") { return Some(path.to_string()); } } @@ -460,17 +557,28 @@ mod exec { None } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] async fn graceful_shutdown(child: &mut tokio::process::Child) { crate::orchestrator::backends::graceful_shutdown(child, "aria2").await; } - #[cfg(target_os = "android")] + #[cfg(mobile)] pub fn create_backend(_app: &AppHandle) -> Option { - info!("aria2 backend initialized for Android"); + info!("aria2 backend initialized for mobile"); Some(Aria2Backend {}) } + #[cfg(target_os = "ios")] + pub async fn run_aria2( + _backend: &Aria2Backend, + _ctx: SpawnContext, + _args: Vec>, + ) -> Result { + Err(BackendError::Other( + "aria2 downloads are not supported on iOS".to_string(), + )) + } + #[cfg(target_os = "android")] pub async fn run_aria2( _backend: &Aria2Backend, @@ -908,7 +1016,7 @@ mod tests { assert_eq!(config.speed_limit, Some(512_000)); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] #[test] fn test_extract_output_path_basic() { assert_eq!( @@ -917,7 +1025,7 @@ mod tests { ); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] #[test] fn test_extract_output_path_with_notice() { assert_eq!( @@ -926,7 +1034,7 @@ mod tests { ); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] #[test] fn test_extract_output_path_no_match() { assert_eq!(exec::extract_output_path("Some random output line"), None); diff --git a/src-tauri/src/orchestrator/backends/common.rs b/src-tauri/src/orchestrator/backends/common.rs index b530182..e1b7738 100644 --- a/src-tauri/src/orchestrator/backends/common.rs +++ b/src-tauri/src/orchestrator/backends/common.rs @@ -216,7 +216,7 @@ pub fn resolve_effective_proxy(proxy: &Option) -> Option { return Some(url.clone()); } } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let system_proxy = crate::proxy::detect_system_proxy(); if !system_proxy.url.is_empty() { @@ -267,7 +267,7 @@ pub fn parse_size_str(s: &str) -> Option { Some((num * multiplier as f64) as u64) } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub async fn graceful_shutdown(child: &mut tokio::process::Child, label: &str) { use std::time::Duration; @@ -351,7 +351,7 @@ pub async fn graceful_shutdown(child: &mut tokio::process::Child, label: &str) { } } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub fn apply_args_to_command(cmd: &mut tokio::process::Command, option_groups: Vec>) { for group in option_groups { if group.len() == 1 { diff --git a/src-tauri/src/orchestrator/backends/gallery_dl/mod.rs b/src-tauri/src/orchestrator/backends/gallery_dl/mod.rs index 862e216..aae90d5 100644 --- a/src-tauri/src/orchestrator/backends/gallery_dl/mod.rs +++ b/src-tauri/src/orchestrator/backends/gallery_dl/mod.rs @@ -4,8 +4,8 @@ //! collections from several image hosting sites. This backend wraps it //! following the same subprocess pattern as the yt-dlp backend. -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] mod process; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub use process::GalleryDlBackend; diff --git a/src-tauri/src/orchestrator/backends/librqbit.rs b/src-tauri/src/orchestrator/backends/librqbit.rs new file mode 100644 index 0000000..9555d05 --- /dev/null +++ b/src-tauri/src/orchestrator/backends/librqbit.rs @@ -0,0 +1,348 @@ +//! librqbit-based torrent/magnet download backend. +//! +//! On iOS, aria2 subprocess is unavailable, so this backend takes Priority::Absolute for +//! magnet links and .torrent URLs. On desktop, aria2 is preferred (richer feature set), so +//! this backend returns Priority::None and acts only as a fallback that the caller can force +//! by explicitly naming it. +//! +//! The `librqbit::Session` is created lazily on first download to avoid noisy DHT bootstrap +//! when the backend is never actually used (i.e. on desktop where aria2 handles torrents). +//! The session is shared via `SharedLibrqbitSession` so that both the download backend and +//! the `torrent_list_files` command can reuse the same DHT connections. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::sync::OnceCell; +use tracing::{error, info, warn}; + +use crate::orchestrator::backends::{ + extract_magnet_name, is_torrent_url, Backend, BackendCapabilities, MetadataEvent, SpawnContext, +}; +use crate::orchestrator::types::*; + +/// Shared, lazily-initialised librqbit session. +/// Registered as Tauri managed state so that both `LibrqbitBackend` (downloads) and +/// `torrent_list_files` (file listing on mobile) share the same DHT connections. +pub struct SharedLibrqbitSession { + session: OnceCell>, + download_dir: String, +} + +impl SharedLibrqbitSession { + pub fn new(download_dir: &str) -> Self { + info!(target: "librqbit", download_dir = %download_dir, "SharedLibrqbitSession created"); + Self { + session: OnceCell::new(), + download_dir: download_dir.to_string(), + } + } + + /// Get or create the librqbit session. + pub async fn get(&self) -> Result<&Arc, BackendError> { + self.session + .get_or_try_init(|| async { + let path = PathBuf::from(&self.download_dir); + info!(target: "librqbit", path = %path.display(), "Creating download directory"); + + tokio::fs::create_dir_all(&path) + .await + .map_err(|e| { + error!(target: "librqbit", error = %e, path = %path.display(), "Failed to create download dir"); + BackendError::Other(format!("Failed to create download dir: {}", e)) + })?; + + info!(target: "librqbit", "Initialising librqbit session..."); + + let session = librqbit::Session::new_with_opts( + path, + librqbit::SessionOptions { + disable_dht: false, + enable_upnp_port_forwarding: false, + ..Default::default() + }, + ) + .await + .map_err(|e| { + error!(target: "librqbit", error = %e, "Failed to create librqbit session"); + BackendError::Other(format!("Failed to create librqbit session: {}", e)) + })?; + + info!(target: "librqbit", "librqbit session initialised successfully"); + Ok(session) + }) + .await + } +} + +pub struct LibrqbitBackend { + shared: Arc, +} + +impl LibrqbitBackend { + pub fn new(shared: Arc) -> Self { + Self { shared } + } + + async fn session(&self) -> Result<&Arc, BackendError> { + self.shared.get().await + } +} + +#[async_trait] +impl Backend for LibrqbitBackend { + fn name(&self) -> &str { + "librqbit" + } + + fn capabilities(&self) -> BackendCapabilities { + BackendCapabilities { + name: "librqbit".into(), + streaming_resolve: false, + playlists: false, + pause_resume: false, + multi_connection: true, + format_selection: false, + subtitles: false, + speed_limit: false, + proxy: false, + cookies: false, + torrent_magnet: true, + post_processing: false, + } + } + + fn priority(&self, url: &str) -> Priority { + if !is_torrent_url(url) { + return Priority::None; + } + + // On iOS, aria2 subprocess is unavailable, so take absolute priority for torrents. + #[cfg(target_os = "ios")] + { + Priority::Absolute + } + + // On all other platforms, aria2 is preferred because it has broader feature support. + #[cfg(not(target_os = "ios"))] + { + Priority::None + } + } + + async fn resolve( + &self, + url: &str, + _settings: &ResolveSettings, + ) -> Result { + info!(target: "librqbit", url = %url, "resolve called"); + if !is_torrent_url(url) { + return Err(BackendError::UnsupportedUrl(url.to_string())); + } + + let mut info = UrlInfo::simple(url, extract_magnet_name(url), self.name()); + info.content_type = ContentType::Torrent; + Ok(info) + } + + async fn spawn(&self, ctx: SpawnContext) -> Result { + let url = ctx.job.request.url.clone(); + let job_id = ctx.job.id.clone(); + let output_dir = ctx.job.request.output.directory.clone(); + + info!( + target: "librqbit", + job_id = %job_id, + url = %url, + output_dir = %output_dir, + "Starting torrent download" + ); + + info!(target: "librqbit", job_id = %job_id, "Getting/creating session..."); + let session = self.session().await?; + info!(target: "librqbit", job_id = %job_id, "Session ready"); + + // Convert 1-based torrent_selected_files to the 0-based indices librqbit expects. + let only_files: Option> = + ctx.job.request.options.torrent_selected_files.as_ref().map(|selected| { + let files: Vec = selected.iter().map(|&i| i.saturating_sub(1) as usize).collect(); + info!(target: "librqbit", job_id = %job_id, ?files, "Selected files (0-based)"); + files + }); + + let add_opts = librqbit::AddTorrentOptions { + output_folder: Some(output_dir.clone()), + only_files, + ..Default::default() + }; + + info!(target: "librqbit", job_id = %job_id, "Adding torrent to session..."); + let response = session + .add_torrent(librqbit::AddTorrent::from_url(&url), Some(add_opts)) + .await + .map_err(|e| { + error!(target: "librqbit", job_id = %job_id, error = %e, "Failed to add torrent"); + BackendError::Other(format!("Failed to add torrent: {}", e)) + })?; + + info!(target: "librqbit", job_id = %job_id, "Torrent added, getting handle..."); + let handle = response + .into_handle() + .ok_or_else(|| { + error!(target: "librqbit", job_id = %job_id, "No handle — torrent was already managed or list-only"); + BackendError::Other("Torrent was already managed or list-only".to_string()) + })?; + + let torrent_id = handle.id(); + info!(target: "librqbit", job_id = %job_id, torrent_id = torrent_id, "Got torrent handle"); + + // Wait for metadata to arrive so we know the total size. + info!(target: "librqbit", job_id = %job_id, "Waiting for metadata/initialization..."); + handle + .wait_until_initialized() + .await + .map_err(|e| { + error!(target: "librqbit", job_id = %job_id, error = %e, "Torrent init/metadata failed"); + BackendError::Other(format!("Torrent init failed: {}", e)) + })?; + + info!(target: "librqbit", job_id = %job_id, name = ?handle.name(), "Metadata received, torrent initialized"); + + // Send output path early so the frontend can enable play-while-downloading. + let early_output_path = resolve_output_path(&handle, &output_dir); + info!(target: "librqbit", job_id = %job_id, path = %early_output_path, "Early output path"); + let _ = ctx.metadata_tx.send(MetadataEvent::FilePath(early_output_path)); + + // Progress loop: poll stats every second, forward ProgressUpdate, honour cancellation. + info!(target: "librqbit", job_id = %job_id, "Starting progress loop"); + let poll_result = run_progress_loop(&ctx, &handle).await; + + match poll_result { + Ok(()) => { + let output_path = resolve_output_path(&handle, &output_dir); + info!( + target: "librqbit", + job_id = %job_id, + output_path = %output_path, + "Torrent download complete" + ); + Ok(output_path) + } + Err(BackendError::Cancelled) => { + if let Err(e) = session + .delete(librqbit::api::TorrentIdOrHash::Id(torrent_id), false) + .await + { + warn!( + target: "librqbit", + job_id = %job_id, + error = %e, + "Failed to remove cancelled torrent from session" + ); + } + Err(BackendError::Cancelled) + } + Err(e) => { + error!(target: "librqbit", job_id = %job_id, error = %e, "Download failed"); + Err(e) + } + } + } +} + +/// Poll the torrent stats every second, emit ProgressUpdates, and wait for completion. +async fn run_progress_loop( + ctx: &SpawnContext, + handle: &Arc, +) -> Result<(), BackendError> { + let job_id = ctx.job.id.clone(); + let mut last_progress_bytes: u64 = 0; + let mut last_tick = tokio::time::Instant::now(); + let mut log_counter: u32 = 0; + // Exponential moving average for speed smoothing (α = 0.3). + // Prevents the jumpy 0 → burst → 0 pattern caused by iOS network buffering. + let mut smoothed_speed: f64 = 0.0; + const EMA_ALPHA: f64 = 0.3; + + loop { + tokio::select! { + _ = ctx.cancel_token.cancelled() => { + info!(target: "librqbit", job_id = %job_id, "Download cancelled"); + return Err(BackendError::Cancelled); + } + _ = tokio::time::sleep(Duration::from_secs(1)) => { + let stats = handle.stats(); + + let elapsed_secs = last_tick.elapsed().as_secs_f64().max(0.001); + let speed_bytes = stats.progress_bytes.saturating_sub(last_progress_bytes); + let instant_speed = speed_bytes as f64 / elapsed_secs; + + // EMA smoothing: new = α * instant + (1 - α) * previous + smoothed_speed = EMA_ALPHA * instant_speed + (1.0 - EMA_ALPHA) * smoothed_speed; + let speed = Some(smoothed_speed as u64); + + let total_bytes = if stats.total_bytes > 0 { + Some(stats.total_bytes) + } else { + None + }; + + let eta = total_bytes.and_then(|total| { + speed.filter(|&s| s > 0).map(|s| { + total.saturating_sub(stats.progress_bytes) / s + }) + }); + + // Log every 5 seconds for diagnostics + log_counter += 1; + if log_counter % 5 == 1 { + info!( + target: "librqbit", + job_id = %job_id, + progress_bytes = stats.progress_bytes, + total_bytes = stats.total_bytes, + finished = stats.finished, + speed = ?speed, + "Progress tick" + ); + } + + let _ = ctx.progress_tx.send(ProgressUpdate { + job_id: job_id.clone(), + downloaded_bytes: stats.progress_bytes, + total_bytes, + speed, + eta, + }); + + last_progress_bytes = stats.progress_bytes; + last_tick = tokio::time::Instant::now(); + + if stats.finished { + info!(target: "librqbit", job_id = %job_id, "Torrent finished"); + break; + } + } + } + } + + handle + .wait_until_completed() + .await + .map_err(|e| BackendError::Other(format!("Torrent completion wait failed: {}", e)))?; + + Ok(()) +} + +fn resolve_output_path(handle: &Arc, fallback_dir: &str) -> String { + let torrent_name = handle.name(); + match torrent_name { + Some(name) if !name.is_empty() => { + let candidate = PathBuf::from(fallback_dir).join(&name); + candidate.to_string_lossy().to_string() + } + _ => fallback_dir.to_string(), + } +} diff --git a/src-tauri/src/orchestrator/backends/mod.rs b/src-tauri/src/orchestrator/backends/mod.rs index 81325c5..5c266b0 100644 --- a/src-tauri/src/orchestrator/backends/mod.rs +++ b/src-tauri/src/orchestrator/backends/mod.rs @@ -32,6 +32,7 @@ pub mod aria2; pub mod common; pub mod direct; pub mod gallery_dl; +pub mod librqbit; pub mod ytdlp; #[cfg(target_os = "android")] @@ -43,7 +44,7 @@ pub use common::{ resolve_http_file, DIRECT_FILE_EXTENSIONS, }; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub use common::{apply_args_to_command, graceful_shutdown}; #[cfg(target_os = "android")] @@ -59,6 +60,7 @@ pub use aria2::cancel_aria2; pub enum MetadataEvent { Patch(UrlInfoPatch), PostProcessing, + FilePath(String), } #[derive(Clone)] diff --git a/src-tauri/src/orchestrator/backends/ytdlp/args.rs b/src-tauri/src/orchestrator/backends/ytdlp/args.rs index 364658a..9a2eace 100644 --- a/src-tauri/src/orchestrator/backends/ytdlp/args.rs +++ b/src-tauri/src/orchestrator/backends/ytdlp/args.rs @@ -355,6 +355,12 @@ impl YtDlpArgsBuilder { if let Some(height) = req.quality.max_height { self = self.add_pair("-S", format!("res:{}", height)); } + // Force MP4 container for video downloads so the built-in HTML5 player can play + // the output. yt-dlp defaults to MKV when merging separate video+audio streams. + // --merge-output-format handles the merge path; --remux-video is a safety net for + // cases where yt-dlp downloads a single stream that is already muxed but not MP4. + self = self.add_pair("--merge-output-format", "mp4".to_string()); + self = self.add_pair("--remux-video", "mp4".to_string()); } if req.options.embed_metadata { @@ -397,7 +403,9 @@ impl YtDlpArgsBuilder { if req.options.use_aria2 { #[cfg(target_os = "android")] let aria2_bin = "libaria2c.so"; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] + let aria2_bin = "aria2c"; + #[cfg(target_os = "ios")] let aria2_bin = "aria2c"; self = self.add_pair("--external-downloader", aria2_bin.to_string()); diff --git a/src-tauri/src/orchestrator/backends/ytdlp/common.rs b/src-tauri/src/orchestrator/backends/ytdlp/common.rs index 9b2f973..0131dcd 100644 --- a/src-tauri/src/orchestrator/backends/ytdlp/common.rs +++ b/src-tauri/src/orchestrator/backends/ytdlp/common.rs @@ -141,6 +141,16 @@ pub fn parse_is_playlist_line(line: &str) -> Option { Some(n > 0) } +/// Returns true when the stderr text indicates a proxy connection failure. +pub fn stderr_is_proxy_error(stderr: &str) -> bool { + let lower = stderr.to_lowercase(); + lower.contains("unable to connect to proxy") + || lower.contains("tunnel connection failed") + || lower.contains("proxyerror") + || lower.contains("proxy connection") + || (lower.contains("proxy") && lower.contains("403 forbidden")) +} + pub fn parse_ytdlp_error(stderr: &str) -> BackendError { let lower = stderr.to_lowercase(); @@ -152,7 +162,9 @@ pub fn parse_ytdlp_error(stderr: &str) -> BackendError { } } - if lower.contains("video unavailable") || lower.contains("not available") { + if stderr_is_proxy_error(stderr) { + BackendError::ProxyError(truncate(stderr, 200)) + } else if lower.contains("video unavailable") || lower.contains("not available") { BackendError::NotFound(truncate(stderr, 200)) } else if lower.contains("private video") || lower.contains("sign in") { BackendError::Unauthorized(truncate(stderr, 200)) diff --git a/src-tauri/src/orchestrator/backends/ytdlp/mod.rs b/src-tauri/src/orchestrator/backends/ytdlp/mod.rs index 63892a6..9a53f75 100644 --- a/src-tauri/src/orchestrator/backends/ytdlp/mod.rs +++ b/src-tauri/src/orchestrator/backends/ytdlp/mod.rs @@ -4,9 +4,9 @@ pub mod json; pub mod progress; pub mod shared; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub mod process; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub use process::YtdlpBackend; #[cfg(target_os = "android")] diff --git a/src-tauri/src/orchestrator/backends/ytdlp/process.rs b/src-tauri/src/orchestrator/backends/ytdlp/process.rs index e0d0ff6..6c19db0 100644 --- a/src-tauri/src/orchestrator/backends/ytdlp/process.rs +++ b/src-tauri/src/orchestrator/backends/ytdlp/process.rs @@ -13,7 +13,7 @@ use tracing::{debug, info, warn}; use super::common::{ apply_metadata_event, normalize_url_for_ytdlp, parse_ytdlp_error, parse_ytdlp_line_event, - PreparedCookieProxy, ProgressTracker, YtDlpArgsBuilder, YtdlpEvent, + stderr_is_proxy_error, PreparedCookieProxy, ProgressTracker, YtDlpArgsBuilder, YtdlpEvent, }; use super::shared; use crate::orchestrator::backends::apply_args_to_command; @@ -240,8 +240,20 @@ impl YtdlpBackend { .unwrap_or_else(|| p.to_string_lossy().to_string()) }); + // If skip_proxy is set (e.g. after a ProxyError retry), omit the proxy arg entirely. + let proxy_url = if ctx.job.skip_proxy { + info!(target: "ytdlp", "skip_proxy=true for job {}: omitting --proxy argument", ctx.job.id); + None + } else { + prepared.proxy_url + }; + + // Keep a copy of the proxy URL string for the circuit breaker — with_proxy() + // takes ownership so we save it before the builder consumes the value. + let proxy_url_for_cb = proxy_url.clone(); + let option_groups = YtDlpArgsBuilder::new(&req.url) - .with_proxy(prepared.proxy_url) + .with_proxy(proxy_url) .with_cookies(prepared.cookies_from_browser, prepared.cookie_arg) .with_js_runtimes(self.js_runtimes()) .build_download(req, ctx.effective_speed_limit, ffmpeg_location); @@ -507,6 +519,25 @@ impl YtdlpBackend { }; guard.iter().cloned().collect::>().join("\n") }; + + // Check for proxy errors before any other handling. Return a ProxyError so + // the manager can retry without proxy instead of falling back to a wrong backend. + if stderr_is_proxy_error(&tail) { + warn!(target: "ytdlp", + "Proxy error detected in yt-dlp stderr for job {}. Signalling ProxyError for retry without proxy.", + ctx.job.id + ); + // Feed the circuit breaker — use the proxy URL that was actually passed to + // yt-dlp so the circuit breaker tracks the right entry. + #[cfg(desktop)] + if let Some(ref purl) = proxy_url_for_cb { + crate::proxy::record_proxy_failure(purl); + } + return Err(BackendError::ProxyError( + "yt-dlp proxy connection failed. Will retry without proxy.".to_string(), + )); + } + let tail_msg = if tail.trim().is_empty() { String::new() } else { diff --git a/src-tauri/src/orchestrator/convert.rs b/src-tauri/src/orchestrator/convert.rs index f0b01d7..5849707 100644 --- a/src-tauri/src/orchestrator/convert.rs +++ b/src-tauri/src/orchestrator/convert.rs @@ -317,7 +317,7 @@ pub async fn convert_local_file( final_path }; - let force_software = cfg!(target_os = "android"); + let force_software = cfg!(mobile); let (pre_args, post_args) = build_ffmpeg_components(&request, force_software); let result = exec::run_ffmpeg_convert( @@ -394,7 +394,12 @@ mod exec { { run_ffmpeg_concat_android(concat_list_path, output_path).await } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let _ = (app, concat_list_path, output_path); + Err("FFmpeg concat is not supported on iOS".to_string()) + } + #[cfg(desktop)] { run_ffmpeg_concat_desktop(app, concat_list_path, output_path).await } @@ -412,17 +417,22 @@ mod exec { { run_ffmpeg_convert_android(app, job_id, source_path, output_path, post_args).await } - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "ios")] + { + let _ = (app, job_id, source_path, output_path, pre_args, post_args); + Err("FFmpeg conversion is not supported on iOS".to_string()) + } + #[cfg(desktop)] { run_ffmpeg_convert_desktop(app, job_id, source_path, output_path, pre_args, post_args) .await } } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] use crate::utils::get_media_duration; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] async fn run_ffmpeg_concat_desktop( app: &AppHandle, concat_list_path: &Path, @@ -454,7 +464,7 @@ mod exec { Ok(()) } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] async fn run_ffmpeg_convert_desktop( app: &AppHandle, job_id: &str, diff --git a/src-tauri/src/orchestrator/history.rs b/src-tauri/src/orchestrator/history.rs index 572cb0b..5c14a7e 100644 --- a/src-tauri/src/orchestrator/history.rs +++ b/src-tauri/src/orchestrator/history.rs @@ -21,8 +21,8 @@ impl HistoryStore { tokio::task::spawn_blocking(move || { let conn = db.conn(); if let Err(e) = conn.execute( - "INSERT OR REPLACE INTO history (id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + "INSERT OR REPLACE INTO history (id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count, podcast_path, podcast_subtitle_path, podcast_status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)", params![ item_clone.id, item_clone.url, item_clone.title, item_clone.author, item_clone.author_url, item_clone.thumbnail, item_clone.extension, @@ -34,6 +34,9 @@ impl HistoryStore { item_clone.is_favourite as i32, item_clone.is_directory as i32, item_clone.file_count.map(|v| v as i64), + item_clone.podcast_path, + item_clone.podcast_subtitle_path, + item_clone.podcast_status, ], ) { error!("Failed to insert history item: {}", e); @@ -58,7 +61,7 @@ impl HistoryStore { fn get_all_sync(db: &Database) -> Vec { let conn = db.conn(); let mut stmt = match conn.prepare( - "SELECT id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count + "SELECT id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count, podcast_path, podcast_subtitle_path, podcast_status FROM history ORDER BY downloaded_at DESC", ) { Ok(s) => s, @@ -90,6 +93,9 @@ impl HistoryStore { is_favourite: row.get::<_, i32>(17)? != 0, is_directory: row.get::<_, i32>(18)? != 0, file_count: row.get::<_, Option>(19)?.map(|v| v as u32), + podcast_path: row.get(20)?, + podcast_subtitle_path: row.get(21)?, + podcast_status: row.get(22)?, }) }); @@ -167,6 +173,75 @@ impl HistoryStore { .ok(); } + pub async fn update_podcast_status( + &self, + id: &str, + status: &str, + podcast_path: Option<&str>, + podcast_subtitle_path: Option<&str>, + ) { + let db = self.db.clone(); + let id = id.to_string(); + let status = status.to_string(); + let podcast_path = podcast_path.map(|s| s.to_string()); + let podcast_subtitle_path = podcast_subtitle_path.map(|s| s.to_string()); + tokio::task::spawn_blocking(move || { + let conn = db.conn(); + if let Err(e) = conn.execute( + "UPDATE history SET podcast_status = ?1, podcast_path = ?2, podcast_subtitle_path = ?3 WHERE id = ?4", + params![status, podcast_path, podcast_subtitle_path, id], + ) { + warn!("Failed to update podcast status for {}: {}", id, e); + } + }) + .await + .ok(); + } + + pub async fn get_by_id(&self, id: &str) -> Option { + let db = self.db.clone(); + let id = id.to_string(); + tokio::task::spawn_blocking(move || { + let conn = db.conn(); + conn.query_row( + "SELECT id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count, podcast_path, podcast_subtitle_path, podcast_status + FROM history WHERE id = ?1", + params![id], + |row| { + Ok(crate::orchestrator::types::HistoryItem { + id: row.get(0)?, + url: row.get(1)?, + title: row.get(2)?, + author: row.get(3)?, + author_url: row.get(4)?, + thumbnail: row.get(5)?, + extension: row.get(6)?, + size: row.get::<_, i64>(7)? as u64, + duration: row.get(8)?, + file_path: row.get(9)?, + downloaded_at: row.get::<_, i64>(10)? as u64, + item_type: row.get(11)?, + playlist_id: row.get(12)?, + playlist_title: row.get(13)?, + playlist_index: row.get::<_, Option>(14)?.map(|v| v as u32), + converted_format: row.get(15)?, + download_source: row.get(16)?, + is_favourite: row.get::<_, i32>(17)? != 0, + is_directory: row.get::<_, i32>(18)? != 0, + file_count: row.get::<_, Option>(19)?.map(|v| v as u32), + podcast_path: row.get(20)?, + podcast_subtitle_path: row.get(21)?, + podcast_status: row.get(22)?, + }) + }, + ) + .ok() + }) + .await + .ok() + .flatten() + } + pub async fn update_duration(&self, id: &str, duration: f64) { let db = self.db.clone(); let id = id.to_string(); @@ -190,8 +265,8 @@ impl HistoryStore { let mut added = 0usize; for item in &new_items { match conn.execute( - "INSERT OR IGNORE INTO history (id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + "INSERT OR IGNORE INTO history (id, url, title, author, author_url, thumbnail, extension, size, duration, file_path, downloaded_at, item_type, playlist_id, playlist_title, playlist_index, converted_format, download_source, is_favourite, is_directory, file_count, podcast_path, podcast_subtitle_path, podcast_status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)", params![ item.id, item.url, item.title, item.author, item.author_url, item.thumbnail, item.extension, @@ -203,6 +278,9 @@ impl HistoryStore { item.is_favourite as i32, item.is_directory as i32, item.file_count.map(|v| v as i64), + item.podcast_path, + item.podcast_subtitle_path, + item.podcast_status, ], ) { Ok(n) => added += n, diff --git a/src-tauri/src/orchestrator/manager.rs b/src-tauri/src/orchestrator/manager.rs index 4fe517e..83a7851 100644 --- a/src-tauri/src/orchestrator/manager.rs +++ b/src-tauri/src/orchestrator/manager.rs @@ -9,7 +9,7 @@ use tokio_util::sync::CancellationToken; #[allow(unused_imports)] use tracing::{debug, error, info, warn}; -use crate::orchestrator::backends::{Backend, BackendRegistry, SpawnContext}; +use crate::orchestrator::backends::{is_torrent_url, Backend, BackendRegistry, SpawnContext}; use crate::orchestrator::store::JobStore; use crate::orchestrator::types::*; @@ -391,6 +391,12 @@ impl JobManager { .unwrap_or_else(|| "ytdlp".to_string()) }; + let content_type = if is_torrent_url(&request.url) { + Some(ContentType::Torrent) + } else { + None + }; + let job = Job { id: job_id.clone(), request, @@ -415,7 +421,8 @@ impl JobManager { playlist_id: None, playlist_title: None, playlist_index: None, - content_type: None, + content_type, + skip_proxy: false, }; self.jobs.insert(job_id.clone(), job.clone()); @@ -683,6 +690,7 @@ impl JobManager { clip_ranges: o.clip_ranges.clone(), use_aria2, force_keyframes_at_cuts: false, + torrent_selected_files: o.torrent_selected_files.clone(), }, post_process: Vec::new(), }; @@ -697,15 +705,23 @@ impl JobManager { let playlist_id = req.playlist_id.clone(); let playlist_title = req.playlist_title.clone(); let playlist_index = req.playlist_index; + let prefetched_title = req.overrides.title.clone(); + let prefetched_thumbnail = req.overrides.thumbnail.clone(); let request = self.build_request_from_enqueue(&req).await?; let job_id = self.start_job(request).await?; - if playlist_id.is_some() || playlist_title.is_some() || playlist_index.is_some() { - if let Some(mut job) = self.jobs.get_mut(&job_id) { + if let Some(mut job) = self.jobs.get_mut(&job_id) { + if playlist_id.is_some() || playlist_title.is_some() || playlist_index.is_some() { job.playlist_id = playlist_id; job.playlist_title = playlist_title; job.playlist_index = playlist_index; } + if let Some(title) = prefetched_title { + job.title = Some(title); + } + if let Some(thumb) = prefetched_thumbnail { + job.thumbnail = Some(thumb); + } } Ok(job_id) @@ -1030,6 +1046,14 @@ impl JobManager { MetadataEvent::PostProcessing => { self.set_post_processing(&job_id); } + MetadataEvent::FilePath(path) => { + info!("FilePathResolved for {}: {}", job_id, path); + // Emit to frontend so play-while-downloading can work. + self.emit_event(JobEvent::FilePathResolved { + job_id: job_id.clone(), + output_path: path, + }); + } } } } @@ -1113,6 +1137,9 @@ impl JobManager { is_favourite: false, is_directory, file_count, + podcast_path: None, + podcast_subtitle_path: None, + podcast_status: None, }; ( @@ -1136,11 +1163,18 @@ impl JobManager { let _ = app.emit("history-item-added", &added); let history_stats = history.compute_stats().await; let _ = app.emit("history-stats-changed", &history_stats); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { if let Err(e) = crate::tray::rebuild_menu_async(&app).await { tracing::warn!("[Tray] Failed to rebuild after download: {}", e); } + // Auto-trigger podcast generation for YouTube downloads if enabled + crate::orchestrator::podcast::maybe_auto_generate_podcast( + app.clone(), + history.clone(), + added, + ) + .await; } }); } @@ -1233,10 +1267,19 @@ impl JobManager { }; if retryable && retry_count < max_retries { + let is_proxy_error = error.is_proxy_error(); if let Some(mut job) = self.jobs.get_mut(job_id) { job.retry_count += 1; job.last_error = Some(error.to_string()); job.status = JobStatus::Queued; + // On a proxy error, set skip_proxy so the next attempt omits --proxy. + if is_proxy_error && !job.skip_proxy { + warn!( + "Proxy error for job {} — next retry will skip proxy", + job_id + ); + job.skip_proxy = true; + } } let delay_secs = 3 * 2u64.pow(retry_count); @@ -1251,6 +1294,7 @@ impl JobManager { let manager = Arc::clone(self); let job_id = job_id.to_string(); let current_backend = current_backend.clone(); + let is_proxy_error = error.is_proxy_error(); let error_str = error.to_string(); tokio::spawn(async move { @@ -1271,10 +1315,20 @@ impl JobManager { } manager.schedule_try_start_next(); } else { + // Build a user-facing error message. Proxy errors get a more helpful hint. + let final_error = if is_proxy_error { + format!( + "All backends failed — proxy may be blocking the connection. {}", + error_str + ) + } else { + format!("All backends failed: {}", error_str) + }; + manager.update_job_status( &job_id, JobStatus::Failed { - error: error_str.clone(), + error: final_error.clone(), retryable, }, ); @@ -1287,7 +1341,7 @@ impl JobManager { manager.emit_event(JobEvent::Failed { job_id: job_id.clone(), - error: error_str, + error: final_error, retryable, }); @@ -1306,30 +1360,50 @@ impl JobManager { } async fn find_fallback_backend(&self, job_id: &str, current: &str) -> Option { + use crate::orchestrator::backends::{has_file_extension, DIRECT_FILE_EXTENSIONS}; + let job = self.jobs.get(job_id)?; let url = job.request.url.clone(); let request = job.request.clone(); drop(job); + // Only allow the "direct" backend as a fallback when the URL looks like a real + // file download. Without this guard, platform URLs (YouTube, etc.) would fall + // back to direct HTTP GET and download the HTML page instead of the video. + let url_has_file_ext = has_file_extension(&url, DIRECT_FILE_EXTENSIONS); + let registry = self.registry.read().await; let candidates = registry.candidates_for(&url); + // First pass: prefer a backend that is capability-compatible with the request. for (backend, _priority) in &candidates { let name = backend.name(); if name == current { continue; } + if name == "direct" && !url_has_file_ext { + debug!( + "Skipping 'direct' fallback for job {} — URL has no recognized file extension", + job_id + ); + continue; + } let caps = backend.capabilities(); if request_compatible_with(&request, &caps) { return Some(name.to_string()); } } + // Second pass: accept any compatible backend. for (backend, _priority) in &candidates { let name = backend.name(); - if name != current { - return Some(name.to_string()); + if name == current { + continue; + } + if name == "direct" && !url_has_file_ext { + continue; } + return Some(name.to_string()); } None diff --git a/src-tauri/src/orchestrator/mod.rs b/src-tauri/src/orchestrator/mod.rs index 541ba12..8fe2a28 100644 --- a/src-tauri/src/orchestrator/mod.rs +++ b/src-tauri/src/orchestrator/mod.rs @@ -2,6 +2,7 @@ pub mod backends; pub mod convert; pub mod history; pub mod manager; +pub mod podcast; pub mod stats; pub mod store; pub mod thumbnail; @@ -264,6 +265,9 @@ pub fn init(app: &AppHandle) -> Arc { .app_data_dir() .unwrap_or_else(|_| std::path::PathBuf::from(".")); + // Database uses lazy initialization — the SQLite connection is opened on + // first `conn()` call, not here. This prevents blocking the main thread + // on iOS where a watchdog kills apps that stall during startup. let db = crate::database::Database::new(&app_data_dir) .unwrap_or_else(|e| panic!("Failed to initialize database: {}", e)); @@ -281,6 +285,10 @@ pub fn init(app: &AppHandle) -> Arc { let manager_clone = Arc::clone(&manager); let app_clone = app.clone(); tauri::async_runtime::spawn(async move { + // Seed stats (first_launch, installation_id) — this triggers the lazy + // DB connection for the first time, safely off the main thread. + manager_clone.stats.ensure_seeded(); + manager_clone .stats .backfill_from_history(&manager_clone.history) @@ -300,7 +308,42 @@ pub fn init(app: &AppHandle) -> Arc { )) .await; - #[cfg(not(target_os = "android"))] + // librqbit backend — handles torrents/magnets natively (no subprocess). + // On iOS it takes Priority::Absolute for torrent URLs (aria2 is unavailable there). + // On other platforms it returns Priority::None so aria2 wins; it can still be forced + // explicitly by name if needed. + // Session is lazy — DHT only starts on first download or file listing. + { + // On iOS, download_dir() resolves to a path the sandbox doesn't permit. + // Use document_dir() first since that's the writable Documents folder. + #[cfg(target_os = "ios")] + let default_dir = app_clone + .path() + .document_dir() + .or_else(|_| app_clone.path().app_data_dir()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + + #[cfg(not(target_os = "ios"))] + let default_dir = app_clone + .path() + .download_dir() + .or_else(|_| app_clone.path().document_dir()) + .or_else(|_| app_clone.path().app_data_dir()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + + let shared_session = Arc::new( + crate::orchestrator::backends::librqbit::SharedLibrqbitSession::new(&default_dir), + ); + // Register the shared session as Tauri managed state so torrent_list_files can use it. + app_clone.manage(shared_session.clone()); + let backend = + crate::orchestrator::backends::librqbit::LibrqbitBackend::new(shared_session); + manager_clone.register_backend(Arc::new(backend)).await; + } + + #[cfg(desktop)] { match crate::deps::resolve_ytdlp_path(&app_clone) { Some(path) => { @@ -319,7 +362,7 @@ pub fn init(app: &AppHandle) -> Arc { } } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { match crate::deps::resolve_gallery_dl_path(&app_clone) { Some(path) => { diff --git a/src-tauri/src/orchestrator/podcast.rs b/src-tauri/src/orchestrator/podcast.rs new file mode 100644 index 0000000..8f7c893 --- /dev/null +++ b/src-tauri/src/orchestrator/podcast.rs @@ -0,0 +1,1070 @@ +/// Auto-podcast generation pipeline (desktop-only). +/// +/// Triggered after a YouTube download completes (if auto_generate is enabled) or manually +/// via the `generate_podcast` Tauri command. +/// +/// Pipeline steps: +/// 1. fetch_transcript — yt-dlp: download YouTube VTT → plain text +/// 2. generate_script — claude CLI: transcript → podcast script (graceful fallback) +/// 3. narrate — edge-tts: script → raw_podcast.mp3 + podcast.vtt +/// 4. master_audio — ffmpeg: raw_podcast.mp3 → mastered podcast.mp3 +/// 5. save artifacts alongside the original download, update history, emit events + +#[cfg(desktop)] +use std::path::{Path, PathBuf}; +#[cfg(desktop)] +use std::process::Stdio; +#[cfg(desktop)] +use std::sync::Arc; +#[cfg(desktop)] +use std::time::{Duration, Instant}; + +#[cfg(desktop)] +use tauri::{AppHandle, Emitter, Manager}; +#[cfg(desktop)] +use tokio::time::timeout; +#[cfg(desktop)] +use tracing::{debug, error, info, warn}; + +#[cfg(desktop)] +use crate::orchestrator::history::HistoryStore; +#[cfg(desktop)] +use crate::orchestrator::types::{HistoryItem, PodcastProgress, PodcastResult, PodcastSettings}; + +// ── Tool discovery ──────────────────────────────────────────────────────────── + +#[cfg(desktop)] +fn find_tool(name: &str, app: &AppHandle) -> Option { + // 1. Check the podcast venv managed by Comine + if let Ok(app_data) = app.path().app_data_dir() { + #[cfg(target_os = "windows")] + let venv_bin = app_data.join("podcast-venv").join("Scripts").join(name); + #[cfg(not(target_os = "windows"))] + let venv_bin = app_data.join("podcast-venv").join("bin").join(name); + + if venv_bin.exists() { + return Some(venv_bin); + } + } + + // 2. Fallback to system PATH + crate::deps::engine::verify::find_in_system_path(name) +} + +#[cfg(desktop)] +fn find_ffmpeg(app: &AppHandle) -> Option { + crate::deps::resolve_ffmpeg_path(app) +} + +// ── VTT → plain text ────────────────────────────────────────────────────────── + +#[cfg(desktop)] +fn vtt_to_plain_text(vtt_content: &str) -> String { + let mut lines: Vec = Vec::new(); + let mut prev_line: Option = None; + + let mut in_style_block = false; + + for raw_line in vtt_content.lines() { + let line = raw_line.trim(); + + // Skip WEBVTT header + if line.starts_with("WEBVTT") { + continue; + } + + // Track style blocks + if line == "STYLE" { + in_style_block = true; + continue; + } + if in_style_block { + if line.is_empty() { + in_style_block = false; + } + continue; + } + + // Skip empty lines + if line.is_empty() { + continue; + } + + // Skip timestamp lines (00:00:00.000 --> 00:00:05.000 or 00:00.000 --> 00:05.000) + if line.contains("-->") { + continue; + } + + // Skip pure numeric cue identifiers + if line.chars().all(|c| c.is_ascii_digit()) { + continue; + } + + // Strip HTML-like tags: , , <00:00:00.000>, etc. + let cleaned = strip_vtt_tags(line); + + if cleaned.is_empty() { + continue; + } + + // Deduplicate consecutive identical lines (yt-dlp auto-subs repeat lines) + if prev_line.as_deref() == Some(&cleaned) { + continue; + } + + prev_line = Some(cleaned.clone()); + lines.push(cleaned); + } + + lines.join(" ") +} + +#[cfg(desktop)] +fn strip_vtt_tags(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut inside_tag = false; + + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '<' { + inside_tag = true; + } else if chars[i] == '>' { + inside_tag = false; + } else if !inside_tag { + out.push(chars[i]); + } + i += 1; + } + + out.trim().to_string() +} + +// ── PodcastPipeline ─────────────────────────────────────────────────────────── + +#[cfg(desktop)] +pub struct PodcastPipeline { + app: AppHandle, + history_id: String, + video_url: String, + title: String, + author: String, + output_dir: PathBuf, + settings: PodcastSettings, + workdir: PathBuf, +} + +#[cfg(desktop)] +impl PodcastPipeline { + pub fn new( + app: AppHandle, + history_id: String, + video_url: String, + title: String, + author: String, + output_dir: PathBuf, + settings: PodcastSettings, + ) -> Result { + // Create a unique temp workdir in the system cache directory + let cache_base = dirs::cache_dir() + .map(|d| d.join("comine").join("podcast")) + .unwrap_or_else(|| std::env::temp_dir().join("comine").join("podcast")); + + let workdir = cache_base.join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&workdir) + .map_err(|e| format!("Failed to create podcast workdir: {}", e))?; + + Ok(Self { + app, + history_id, + video_url, + title, + author, + output_dir, + settings, + workdir, + }) + } + + fn emit_progress(&self, step: &str, progress: f64, error: Option) { + let _ = self.app.emit( + "podcast-generation-progress", + PodcastProgress { + history_id: self.history_id.clone(), + step: step.to_string(), + progress, + error, + }, + ); + } + + // ── Step 1: Fetch YouTube transcript via yt-dlp ──────────────────────────── + + async fn fetch_transcript(&self) -> Result { + let ytdlp = crate::deps::resolve_ytdlp_path(&self.app) + .ok_or_else(|| "yt-dlp not installed. Install it from Settings → Dependencies.".to_string())?; + + let subs_template = self.workdir.join("subs"); + + info!( + history_id = %self.history_id, + url = %self.video_url, + lang = %self.settings.subtitle_lang, + "Podcast step 1/4: fetching YouTube transcript with yt-dlp" + ); + let t = Instant::now(); + + // Try configured language first, then fall back to any available language. + let lang_attempts = [self.settings.subtitle_lang.as_str(), "all"]; + + for (i, lang) in lang_attempts.iter().enumerate() { + if i > 0 { + // Clean up previous attempt's files + let _ = self.clean_vtt_files(); + info!( + history_id = %self.history_id, + fallback_lang = %lang, + "No subtitles found for '{}', retrying with any available language", + self.settings.subtitle_lang + ); + } + + let mut cmd = crate::utils::new_command(&ytdlp); + cmd.arg("--write-subs") + .arg("--write-auto-subs") + .arg("--skip-download") + .arg("--sub-lang") + .arg(lang) + .arg("--sub-format") + .arg("vtt") + .arg("-o") + .arg(&subs_template) + .arg(&self.video_url) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let result = timeout(Duration::from_secs(30), cmd.output()) + .await + .map_err(|_| "yt-dlp subtitle download timed out (30s limit)".to_string())? + .map_err(|e| format!("yt-dlp failed to start: {}", e))?; + + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + debug!( + history_id = %self.history_id, + lang = %lang, + "yt-dlp subtitle attempt failed: {}", + stderr.lines().last().unwrap_or("unknown error") + ); + continue; + } + + if let Ok(vtt_file) = self.find_vtt_file() { + let vtt_content = tokio::fs::read_to_string(&vtt_file) + .await + .map_err(|e| format!("Failed to read VTT subtitle file: {}", e))?; + + let plain_text = vtt_to_plain_text(&vtt_content); + + if plain_text.trim().is_empty() { + debug!(history_id = %self.history_id, lang = %lang, "VTT file found but empty after parsing"); + continue; + } + + let transcript_path = self.workdir.join("transcript.txt"); + tokio::fs::write(&transcript_path, plain_text.as_bytes()) + .await + .map_err(|e| format!("Failed to write transcript.txt: {}", e))?; + + info!( + history_id = %self.history_id, + lang = %lang, + elapsed_ms = %t.elapsed().as_millis(), + "Podcast step 1/4: transcript fetched successfully" + ); + return Ok(transcript_path); + } + } + + // Last resort: download audio and transcribe with Whisper. + info!( + history_id = %self.history_id, + "No YouTube subtitles available, falling back to Whisper transcription" + ); + self.emit_progress("transcribing_audio", 0.15, None); + self.whisper_transcribe(&ytdlp).await + } + + fn find_vtt_file(&self) -> Result { + let entries = std::fs::read_dir(&self.workdir) + .map_err(|e| format!("Failed to read workdir: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("vtt") { + return Ok(path); + } + } + + Err("No YouTube transcript available for this video".to_string()) + } + + fn clean_vtt_files(&self) -> Result<(), String> { + let entries = std::fs::read_dir(&self.workdir) + .map_err(|e| format!("Failed to read workdir: {}", e))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("vtt") { + let _ = std::fs::remove_file(&path); + } + } + Ok(()) + } + + // ── Step 1b: Whisper fallback — download audio and transcribe ────────────── + + async fn whisper_transcribe(&self, ytdlp: &Path) -> Result { + let whisper = find_tool("whisper", &self.app).ok_or_else(|| { + "No subtitles available and Whisper not found.\n\ + Install with: pip install openai-whisper" + .to_string() + })?; + + // Download audio only via yt-dlp. + let audio_path = self.workdir.join("audio.mp3"); + info!(history_id = %self.history_id, "Downloading audio for Whisper transcription"); + + let mut dl_cmd = crate::utils::new_command(ytdlp); + dl_cmd + .arg("-x") + .arg("--audio-format") + .arg("mp3") + .arg("-o") + .arg(&audio_path) + .arg(&self.video_url) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let dl_result = timeout(Duration::from_secs(120), dl_cmd.output()) + .await + .map_err(|_| "yt-dlp audio download timed out (120s limit)".to_string())? + .map_err(|e| format!("yt-dlp audio download failed: {}", e))?; + + if !dl_result.status.success() { + let stderr = String::from_utf8_lossy(&dl_result.stderr); + return Err(format!( + "yt-dlp audio download failed: {}", + stderr.lines().last().unwrap_or("unknown error") + )); + } + + // yt-dlp may append codec suffixes — find the actual audio file. + let actual_audio = self.find_audio_file()?.unwrap_or(audio_path); + if !actual_audio.exists() { + return Err("yt-dlp did not produce an audio file".to_string()); + } + + // Run Whisper on the audio. Use "base" model for speed. + info!(history_id = %self.history_id, "Running Whisper transcription"); + + let mut w_cmd = crate::utils::new_command(&whisper); + w_cmd + .arg(&actual_audio) + .arg("--model") + .arg("base") + .arg("--output_format") + .arg("txt") + .arg("--output_dir") + .arg(&self.workdir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + // Whisper can take a while, especially without GPU — 5 min timeout. + let w_result = timeout(Duration::from_secs(300), w_cmd.output()) + .await + .map_err(|_| "Whisper transcription timed out (5 min limit)".to_string())? + .map_err(|e| format!("Whisper failed to start: {}", e))?; + + if !w_result.status.success() { + let stderr = String::from_utf8_lossy(&w_result.stderr); + return Err(format!( + "Whisper transcription failed: {}", + stderr.lines().last().unwrap_or("unknown error") + )); + } + + // Whisper outputs {stem}.txt in the output dir. + let stem = actual_audio + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "audio".to_string()); + let whisper_txt = self.workdir.join(format!("{}.txt", stem)); + + if !whisper_txt.exists() { + return Err("Whisper did not produce a transcript file".to_string()); + } + + let content = tokio::fs::read_to_string(&whisper_txt) + .await + .map_err(|e| format!("Failed to read Whisper transcript: {}", e))?; + + if content.trim().is_empty() { + return Err("Whisper produced an empty transcript".to_string()); + } + + // Write to the standard transcript.txt location. + let transcript_path = self.workdir.join("transcript.txt"); + tokio::fs::write(&transcript_path, content.as_bytes()) + .await + .map_err(|e| format!("Failed to write transcript.txt: {}", e))?; + + info!(history_id = %self.history_id, "Whisper transcription complete"); + Ok(transcript_path) + } + + fn find_audio_file(&self) -> Result, String> { + let entries = std::fs::read_dir(&self.workdir) + .map_err(|e| format!("Failed to read workdir: {}", e))?; + + const AUDIO_EXTS: &[&str] = &["mp3", "m4a", "wav", "opus", "ogg", "webm"]; + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTS.contains(&ext) { + return Ok(Some(path)); + } + } + } + Ok(None) + } + + // ── Step 2: Generate podcast script via Claude CLI ──────────────────────── + + async fn generate_script(&self, transcript_path: &Path) -> Result { + let script_path = self.workdir.join("script.txt"); + + let claude_path = find_tool("claude", &self.app); + if claude_path.is_none() { + warn!( + history_id = %self.history_id, + "claude CLI not found — using raw transcript as podcast script. \ + Install Claude CLI from https://claude.ai/cli for better results." + ); + // Graceful fallback: copy transcript directly as the script + std::fs::copy(transcript_path, &script_path) + .map_err(|e| format!("Failed to copy transcript as fallback script: {}", e))?; + return Ok(script_path); + } + + let claude = claude_path.unwrap(); + + let prompt = self.settings.claude_prompt.as_deref().unwrap_or( + "You are a podcast scriptwriter. Given this transcript from a YouTube video, \ + write a concise podcast-style script (2-4 minutes spoken) in English. \ + If the transcript is in another language, translate and adapt it to English. \ + Focus on the real takeaways and insights, not just paraphrasing. Write it optimized \ + for spoken delivery — short sentences, natural rhythm, clear transitions. \ + Start directly with a hook about the content — do NOT begin with meta-commentary \ + like 'Here is a podcast script' or 'Here's a summary'. Just start the script. \ + Cover the key points and end with a takeaway.", + ); + + let full_prompt = format!( + "{} The video is titled '{}' by {}.", + prompt, self.title, self.author + ); + + info!( + history_id = %self.history_id, + "Podcast step 2/4: generating podcast script via Claude CLI" + ); + let t = Instant::now(); + + let transcript_content = tokio::fs::read_to_string(transcript_path) + .await + .map_err(|e| format!("Failed to read transcript: {}", e))?; + + let mut cmd = crate::utils::new_command(&claude); + // Remove CLAUDECODE env var to avoid "nested session" detection + cmd.env_remove("CLAUDECODE") + .arg("--print") + .arg(&full_prompt) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to start claude CLI: {}", e))?; + + // Write transcript to stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + if let Err(e) = stdin.write_all(transcript_content.as_bytes()).await { + warn!(history_id = %self.history_id, "Failed to pipe transcript to claude: {}", e); + } + // stdin drops here → EOF sent to claude + } + + // Claude CLI: 2 minute timeout + let output = timeout(Duration::from_secs(120), child.wait_with_output()) + .await + .map_err(|_| "Claude CLI timed out (2 min limit)".to_string())? + .map_err(|e| format!("Claude CLI error: {}", e))?; + + if !output.status.success() { + warn!( + history_id = %self.history_id, + stderr = %String::from_utf8_lossy(&output.stderr), + "Claude CLI failed — falling back to raw transcript" + ); + std::fs::copy(transcript_path, &script_path) + .map_err(|e| format!("Failed to copy transcript as fallback script: {}", e))?; + return Ok(script_path); + } + + let script_text = String::from_utf8_lossy(&output.stdout); + tokio::fs::write(&script_path, script_text.as_bytes()) + .await + .map_err(|e| format!("Failed to write script file: {}", e))?; + + info!( + history_id = %self.history_id, + elapsed_ms = %t.elapsed().as_millis(), + "Podcast step 2/4: script generation complete" + ); + Ok(script_path) + } + + // ── Step 3: TTS narration with edge-tts ────────────────────────────────── + + async fn narrate(&self, script_path: &Path) -> Result<(PathBuf, Option), String> { + let edge_tts = find_tool("edge-tts", &self.app).ok_or_else(|| { + "edge-tts not found — install with: pip install edge-tts\n\ + Or enable the podcast venv in Settings → Podcast." + .to_string() + })?; + + let raw_mp3_path = self.workdir.join("raw_podcast.mp3"); + let vtt_path = self.workdir.join("podcast.vtt"); + + info!( + history_id = %self.history_id, + voice = %self.settings.tts_voice, + rate = %self.settings.tts_rate, + "Podcast step 3/4: narrating with edge-tts" + ); + let t = Instant::now(); + + let script_content = tokio::fs::read_to_string(script_path) + .await + .map_err(|e| format!("Failed to read script: {}", e))?; + + let mut cmd = crate::utils::new_command(&edge_tts); + cmd.arg("--voice") + .arg(&self.settings.tts_voice) + .arg("--rate") + .arg(&self.settings.tts_rate) + .arg("--text") + .arg(&script_content) + .arg("--write-media") + .arg(&raw_mp3_path) + .arg("--write-subtitles") + .arg(&vtt_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + // edge-tts: 2 minute timeout + let result = timeout(Duration::from_secs(120), cmd.output()) + .await + .map_err(|_| "edge-tts timed out (2 min limit)".to_string())? + .map_err(|e| format!("edge-tts failed to start: {}", e))?; + + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + return Err(format!( + "edge-tts failed: {}", + stderr.lines().last().unwrap_or("unknown error") + )); + } + + if !raw_mp3_path.exists() { + return Err("edge-tts did not produce the expected MP3 file".to_string()); + } + + let vtt = if vtt_path.exists() { + Some(vtt_path) + } else { + None + }; + + info!( + history_id = %self.history_id, + elapsed_ms = %t.elapsed().as_millis(), + "Podcast step 3/4: narration complete" + ); + Ok((raw_mp3_path, vtt)) + } + + // ── Step 4: FFmpeg audio mastering ──────────────────────────────────────── + + async fn master_audio(&self, raw_mp3: &Path, output_path: &Path) -> Result<(), String> { + let ffmpeg = find_ffmpeg(&self.app).ok_or_else(|| { + "FFmpeg not installed. Install it from Settings → Dependencies.".to_string() + })?; + + info!( + history_id = %self.history_id, + output = %output_path.display(), + "Podcast step 4/4: mastering audio with FFmpeg" + ); + let t = Instant::now(); + + let mut cmd = crate::utils::new_command(&ffmpeg); + cmd.arg("-y") + .arg("-i") + .arg(raw_mp3) + .args([ + "-af", + "highpass=f=80:poles=1,lowpass=f=12000:poles=1,acompressor=threshold=-18dB:ratio=3:attack=10:release=100,loudnorm=I=-16:TP=-1.5:LRA=11", + "-ar", + "44100", + "-b:a", + "192k", + ]) + .arg(output_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + // FFmpeg mastering: 1 minute timeout + let result = timeout(Duration::from_secs(60), cmd.output()) + .await + .map_err(|_| "FFmpeg mastering timed out (1 min limit)".to_string())? + .map_err(|e| format!("FFmpeg mastering failed to start: {}", e))?; + + if !result.status.success() || !output_path.exists() { + warn!( + history_id = %self.history_id, + "FFmpeg mastering failed — using raw TTS output as fallback" + ); + // Graceful fallback: copy unmastered version + tokio::fs::copy(raw_mp3, output_path) + .await + .map_err(|e| format!("Failed to copy raw podcast as fallback: {}", e))?; + } + + info!( + history_id = %self.history_id, + elapsed_ms = %t.elapsed().as_millis(), + "Podcast step 4/4: mastering complete" + ); + Ok(()) + } + + // ── Orchestrator ────────────────────────────────────────────────────────── + + pub async fn run(self) -> Result { + let history_id = self.history_id.clone(); + + // Sanitize title for filesystem use + let safe_title: String = self + .title + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { + c + } else { + '_' + } + }) + .collect::() + .trim() + .chars() + .take(80) + .collect(); + let safe_title = if safe_title.is_empty() { + "podcast".to_string() + } else { + safe_title + }; + + // Destinations in output_dir (alongside the original download) + let podcast_mp3 = self.output_dir.join(format!("{}_podcast.mp3", safe_title)); + let podcast_vtt = self.output_dir.join(format!("{}_podcast.vtt", safe_title)); + let transcript_dest = self.output_dir.join(format!("{}_transcript.txt", safe_title)); + let script_dest = self.output_dir.join(format!("{}_script.txt", safe_title)); + + // ── 1. Fetch YouTube transcript ─────────────────────────────────────── + self.emit_progress("fetching_transcript", 0.1, None); + let transcript_path = match self.fetch_transcript().await { + Ok(p) => p, + Err(e) => { + self.emit_progress("failed", 0.0, Some(e.clone())); + self.cleanup(); + return Err(e); + } + }; + + // ── 2. Generate script ──────────────────────────────────────────────── + self.emit_progress("generating_script", 0.35, None); + let script_path = match self.generate_script(&transcript_path).await { + Ok(p) => p, + Err(e) => { + self.emit_progress("failed", 0.0, Some(e.clone())); + self.cleanup(); + return Err(e); + } + }; + + // ── 3. Narrate ──────────────────────────────────────────────────────── + self.emit_progress("narrating", 0.55, None); + let (raw_mp3, vtt_path) = match self.narrate(&script_path).await { + Ok(r) => r, + Err(e) => { + self.emit_progress("failed", 0.0, Some(e.clone())); + self.cleanup(); + return Err(e); + } + }; + + // ── 4. Master audio ─────────────────────────────────────────────────── + self.emit_progress("mastering", 0.8, None); + if self.settings.mastering_enabled { + if let Err(e) = self.master_audio(&raw_mp3, &podcast_mp3).await { + self.emit_progress("failed", 0.0, Some(e.clone())); + self.cleanup(); + return Err(e); + } + } else { + // Skip mastering: copy raw directly to destination + if let Err(e) = tokio::fs::copy(&raw_mp3, &podcast_mp3).await { + let msg = format!("Failed to copy raw podcast: {}", e); + self.emit_progress("failed", 0.0, Some(msg.clone())); + self.cleanup(); + return Err(msg); + } + } + + // ── 5. Copy artifacts to output dir ────────────────────────────────── + let _ = tokio::fs::copy(&transcript_path, &transcript_dest).await; + let _ = tokio::fs::copy(&script_path, &script_dest).await; + + // VTT subtitle (if produced) + let final_vtt = if let Some(ref vp) = vtt_path { + match tokio::fs::copy(vp, &podcast_vtt).await { + Ok(_) => Some(podcast_vtt.to_string_lossy().to_string()), + Err(e) => { + warn!(history_id = %history_id, "Failed to copy VTT: {}", e); + None + } + } + } else { + None + }; + + self.cleanup(); + + self.emit_progress("complete", 1.0, None); + let _ = self.app.emit( + "podcast-generation-completed", + serde_json::json!({ + "historyId": history_id, + "podcastPath": podcast_mp3.to_string_lossy(), + "subtitlePath": final_vtt, + }), + ); + + info!( + history_id = %history_id, + podcast = %podcast_mp3.display(), + "Podcast generation complete" + ); + + Ok(PodcastResult { + podcast_path: podcast_mp3.to_string_lossy().to_string(), + subtitle_path: final_vtt, + transcript_path: transcript_dest.to_string_lossy().to_string(), + script_path: script_dest.to_string_lossy().to_string(), + }) + } + + fn cleanup(&self) { + if let Err(e) = std::fs::remove_dir_all(&self.workdir) { + debug!("Failed to clean up podcast workdir: {}", e); + } + } +} + +// ── Settings I/O ────────────────────────────────────────────────────────────── + +#[cfg(desktop)] +pub fn load_podcast_settings(app: &AppHandle) -> PodcastSettings { + use tauri_plugin_store::StoreExt; + let store = match app.store("settings.json") { + Ok(s) => s, + Err(_) => return PodcastSettings::default(), + }; + + let raw = match store.get("podcastSettings") { + Some(v) => v, + None => return PodcastSettings::default(), + }; + + match serde_json::from_value::(raw.clone()) { + Ok(s) => { + debug!( + enabled = s.enabled, + auto_generate = s.auto_generate, + "Loaded podcast settings" + ); + s + } + Err(e) => { + warn!("Failed to deserialize podcast settings (using defaults): {}", e); + debug!("Raw podcast settings value: {:?}", raw); + PodcastSettings::default() + } + } +} + +#[cfg(desktop)] +pub fn save_podcast_settings(app: &AppHandle, settings: &PodcastSettings) -> Result<(), String> { + use tauri_plugin_store::StoreExt; + let store = app + .store("settings.json") + .map_err(|e| format!("Failed to open settings store: {}", e))?; + let value = serde_json::to_value(settings) + .map_err(|e| format!("Failed to serialize podcast settings: {}", e))?; + store.set("podcastSettings", value); + Ok(()) +} + +// ── Auto-trigger helper ─────────────────────────────────────────────────────── + +/// Called from `manager.rs` `complete_job()` to optionally auto-start the podcast pipeline. +/// This is a no-op on mobile. +#[cfg(desktop)] +pub async fn maybe_auto_generate_podcast( + app: AppHandle, + history: Arc, + item: HistoryItem, +) { + let settings = load_podcast_settings(&app); + + if !settings.enabled || !settings.auto_generate { + debug!( + url = %item.url, + enabled = settings.enabled, + auto_generate = settings.auto_generate, + "Podcast auto-generate skipped: feature disabled" + ); + return; + } + + // Only for YouTube URLs + let is_youtube = item.url.contains("youtube.com") || item.url.contains("youtu.be"); + if !is_youtube { + debug!(url = %item.url, "Podcast auto-generate skipped: not a YouTube URL"); + return; + } + + // Don't generate for directories or galleries + if item.is_directory { + debug!(url = %item.url, "Podcast auto-generate skipped: directory download"); + return; + } + + info!( + history_id = %item.id, + url = %item.url, + title = %item.title, + "Podcast auto-generate: starting pipeline for YouTube download" + ); + + let history_id = item.id.clone(); + let video_url = item.url.clone(); + let title = item.title.clone(); + let author = item.author.clone(); + + let output_dir = PathBuf::from(&item.file_path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Mark as generating in DB + history + .update_podcast_status(&history_id, "generating", None, None) + .await; + + let _ = app.emit( + "podcast-generation-started", + serde_json::json!({ "historyId": history_id }), + ); + + let app_clone = app.clone(); + let history_clone = history.clone(); + tokio::spawn(async move { + let pipeline = match PodcastPipeline::new( + app_clone.clone(), + history_id.clone(), + video_url, + title, + author, + output_dir, + settings, + ) { + Ok(p) => p, + Err(e) => { + error!(history_id = %history_id, "Failed to create podcast pipeline: {}", e); + history_clone + .update_podcast_status(&history_id, "failed", None, None) + .await; + let _ = app_clone.emit( + "podcast-generation-failed", + serde_json::json!({ "historyId": history_id, "error": e }), + ); + return; + } + }; + + match pipeline.run().await { + Ok(result) => { + history_clone + .update_podcast_status( + &history_id, + "completed", + Some(&result.podcast_path), + result.subtitle_path.as_deref(), + ) + .await; + } + Err(e) => { + error!(history_id = %history_id, "Podcast generation failed: {}", e); + history_clone + .update_podcast_status(&history_id, "failed", None, None) + .await; + let _ = app_clone.emit( + "podcast-generation-failed", + serde_json::json!({ "historyId": history_id, "error": e }), + ); + } + } + }); +} + +/// No-op on mobile — podcast pipeline requires desktop tooling. +#[cfg(mobile)] +pub async fn maybe_auto_generate_podcast( + _app: tauri::AppHandle, + _history: std::sync::Arc, + _item: crate::orchestrator::types::HistoryItem, +) { +} + +// ── Tauri Commands ──────────────────────────────────────────────────────────── + +/// Manually trigger podcast generation for a history item. +/// +/// The command returns immediately; track progress via `podcast-generation-progress`, +/// `podcast-generation-completed`, and `podcast-generation-failed` events. +#[cfg(desktop)] +#[tauri::command] +pub async fn generate_podcast( + app: AppHandle, + state: tauri::State<'_, Arc>, + history_id: String, +) -> Result<(), String> { + let settings = load_podcast_settings(&app); + + if !settings.enabled { + return Err( + "Podcast generation is disabled. Enable it in Settings → Podcast.".to_string(), + ); + } + + let item = state + .history + .get_by_id(&history_id) + .await + .ok_or_else(|| format!("History item not found: {}", history_id))?; + + let source_path = PathBuf::from(&item.file_path); + if !source_path.exists() { + return Err(format!( + "Source file not found: {}. The file may have been moved or deleted.", + source_path.display() + )); + } + + let output_dir = source_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Mark as generating + state + .history + .update_podcast_status(&history_id, "generating", None, None) + .await; + + let _ = app.emit( + "podcast-generation-started", + serde_json::json!({ "historyId": history_id }), + ); + + let pipeline = PodcastPipeline::new( + app.clone(), + history_id.clone(), + item.url.clone(), + item.title.clone(), + item.author.clone(), + output_dir, + settings, + )?; + + let history_clone = state.history.clone(); + let app_clone = app.clone(); + let history_id_clone = history_id.clone(); + + tokio::spawn(async move { + match pipeline.run().await { + Ok(result) => { + history_clone + .update_podcast_status( + &history_id_clone, + "completed", + Some(&result.podcast_path), + result.subtitle_path.as_deref(), + ) + .await; + } + Err(e) => { + error!(history_id = %history_id_clone, "Podcast generation failed: {}", e); + history_clone + .update_podcast_status(&history_id_clone, "failed", None, None) + .await; + let _ = app_clone.emit( + "podcast-generation-failed", + serde_json::json!({ "historyId": history_id_clone, "error": e }), + ); + } + } + }); + + Ok(()) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn get_podcast_settings(app: AppHandle) -> Result { + Ok(load_podcast_settings(&app)) +} + +#[cfg(desktop)] +#[tauri::command] +pub async fn set_podcast_settings( + app: AppHandle, + settings: PodcastSettings, +) -> Result<(), String> { + save_podcast_settings(&app, &settings) +} diff --git a/src-tauri/src/orchestrator/stats.rs b/src-tauri/src/orchestrator/stats.rs index 2e41d18..64b67da 100644 --- a/src-tauri/src/orchestrator/stats.rs +++ b/src-tauri/src/orchestrator/stats.rs @@ -17,41 +17,44 @@ pub struct StatsStore { impl StatsStore { pub fn new(db: Arc) -> Arc { - // Ensure first_launch and installation_id are set - { - let conn = db.conn(); - let (first_launch, installation_id): (String, String) = conn - .query_row( - "SELECT first_launch, installation_id FROM stats WHERE id = 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap_or_default(); - - let mut needs_update = false; - let new_first_launch = if first_launch.is_empty() { - needs_update = true; - chrono::Utc::now().to_rfc3339() - } else { - first_launch - }; - let new_installation_id = if installation_id.is_empty() { - needs_update = true; - uuid::Uuid::new_v4().to_string() - } else { - installation_id - }; - - if needs_update { - let _ = conn.execute( - "UPDATE stats SET first_launch = ?1, installation_id = ?2 WHERE id = 1", - params![new_first_launch, new_installation_id], - ); - } + Arc::new(Self { db }) + } + + /// Seed first_launch and installation_id if not yet set. + /// Called from the async init block (off the main thread) so that + /// the lazy Database connection is not triggered on iOS's main thread. + pub fn ensure_seeded(&self) { + let conn = self.db.conn(); + let (first_launch, installation_id): (String, String) = conn + .query_row( + "SELECT first_launch, installation_id FROM stats WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap_or_default(); + + let mut needs_update = false; + let new_first_launch = if first_launch.is_empty() { + needs_update = true; + chrono::Utc::now().to_rfc3339() + } else { + first_launch + }; + let new_installation_id = if installation_id.is_empty() { + needs_update = true; + uuid::Uuid::new_v4().to_string() + } else { + installation_id + }; + + if needs_update { + let _ = conn.execute( + "UPDATE stats SET first_launch = ?1, installation_id = ?2 WHERE id = 1", + params![new_first_launch, new_installation_id], + ); } info!("Stats store initialized from database"); - Arc::new(Self { db }) } pub fn start(self: &Arc, app: AppHandle) { diff --git a/src-tauri/src/orchestrator/store.rs b/src-tauri/src/orchestrator/store.rs index b05abf8..4251cd9 100644 --- a/src-tauri/src/orchestrator/store.rs +++ b/src-tauri/src/orchestrator/store.rs @@ -188,6 +188,7 @@ impl JobRow { playlist_title: None, playlist_index: None, content_type: None, + skip_proxy: false, }) } } diff --git a/src-tauri/src/orchestrator/thumbnail.rs b/src-tauri/src/orchestrator/thumbnail.rs index dfa047d..6e4c6da 100644 --- a/src-tauri/src/orchestrator/thumbnail.rs +++ b/src-tauri/src/orchestrator/thumbnail.rs @@ -217,14 +217,14 @@ async fn embed_thumbnail_from_url( return Err("Empty thumbnail URL".to_string()); } - // Android doesn't support thumbnail embedding into media files via ffmpeg. - #[cfg(target_os = "android")] + // Mobile platforms don't support thumbnail embedding into media files via ffmpeg. + #[cfg(mobile)] { let _ = app; return Ok((media_path.to_string(), None)); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { // Skip formats that don't support embedded thumbnails let ext = std::path::Path::new(media_path) @@ -258,7 +258,7 @@ async fn embed_thumbnail_from_url( } } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] async fn embed_thumbnail_jpeg_bytes( app: &AppHandle, media_path: &str, @@ -502,7 +502,7 @@ pub async fn generate_local_thumbnail( Ok(format!("file://{}", output_path.to_string_lossy())) } else if is_video { - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { use std::process::Stdio; @@ -546,10 +546,10 @@ pub async fn generate_local_thumbnail( Ok(format!("file://{}", output_path.to_string_lossy())) } - #[cfg(target_os = "android")] + #[cfg(mobile)] { Err(format!( - "Video thumbnail generation not supported on Android" + "Video thumbnail generation not supported on mobile" )) } } else { diff --git a/src-tauri/src/orchestrator/types.rs b/src-tauri/src/orchestrator/types.rs index bd05ba2..b03f4ed 100644 --- a/src-tauri/src/orchestrator/types.rs +++ b/src-tauri/src/orchestrator/types.rs @@ -594,6 +594,10 @@ pub struct DownloadOptions { pub use_aria2: bool, #[serde(default)] pub force_keyframes_at_cuts: bool, + /// Torrent file indices to download (1-based). When set, only the selected + /// files are downloaded. Passed to aria2 as `--select-file=`. + #[serde(default)] + pub torrent_selected_files: Option>, } fn default_true() -> bool { @@ -622,6 +626,7 @@ impl Default for DownloadOptions { clip_ranges: None, use_aria2: false, force_keyframes_at_cuts: false, + torrent_selected_files: None, } } } @@ -673,6 +678,10 @@ pub struct Job { pub playlist_index: Option, #[serde(default)] pub content_type: Option, + /// When true, the yt-dlp backend should omit the --proxy argument on the next attempt. + /// Set after a ProxyError to retry the download over a direct connection. + #[serde(default)] + pub skip_proxy: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -771,6 +780,10 @@ pub enum JobEvent { job_id: String, color: (u8, u8, u8), }, + FilePathResolved { + job_id: String, + output_path: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -792,6 +805,8 @@ pub enum BackendError { Paused, /// A non-terminal job with the same URL already exists. DuplicateUrl(String), + /// Proxy connection failed — retrying without proxy is appropriate. + ProxyError(String), Other(String), } @@ -803,8 +818,13 @@ impl BackendError { | BackendError::ServerError(_) | BackendError::RateLimited(_) | BackendError::ProcessError(_) + | BackendError::ProxyError(_) ) } + + pub fn is_proxy_error(&self) -> bool { + matches!(self, BackendError::ProxyError(_)) + } } impl std::fmt::Display for BackendError { @@ -823,6 +843,7 @@ impl std::fmt::Display for BackendError { BackendError::Cancelled => write!(f, "Cancelled"), BackendError::Paused => write!(f, "Paused"), BackendError::DuplicateUrl(id) => write!(f, "Duplicate URL (active job: {})", id), + BackendError::ProxyError(s) => write!(f, "Proxy error: {}", s), BackendError::Other(s) => write!(f, "{}", s), } } @@ -894,15 +915,22 @@ pub struct DownloadSettings { impl DownloadSettings { pub fn default_download_path() -> String { - #[cfg(not(target_os = "android"))] + #[cfg(target_os = "android")] { - dirs::download_dir() + "/storage/emulated/0/Download/Comine".to_string() + } + #[cfg(target_os = "ios")] + { + // iOS sandbox: download_dir() is unavailable, use Documents instead. + dirs::document_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default() } - #[cfg(target_os = "android")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] { - "/storage/emulated/0/Download/Comine".to_string() + dirs::download_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default() } } } @@ -973,6 +1001,14 @@ pub struct EnqueueOverrides { pub output_directory: Option, pub dont_show_in_history: Option, + + /// Torrent file indices to download (1-based). + pub torrent_selected_files: Option>, + + /// Prefetched title (e.g. individual torrent file name). + pub title: Option, + /// Prefetched thumbnail URL. + pub thumbnail: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1029,6 +1065,61 @@ pub struct HistoryItem { pub is_directory: bool, #[serde(default)] pub file_count: Option, + pub podcast_path: Option, + pub podcast_subtitle_path: Option, + /// "generating" | "completed" | "failed" + pub podcast_status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase", default)] +pub struct PodcastSettings { + pub enabled: bool, + pub subtitle_lang: String, + pub tts_voice: String, + pub tts_rate: String, + pub claude_prompt: Option, + pub mastering_enabled: bool, + pub auto_generate: bool, +} + +impl Default for PodcastSettings { + fn default() -> Self { + Self { + enabled: false, + subtitle_lang: "en".into(), + tts_voice: "en-US-AndrewMultilingualNeural".into(), + tts_rate: "+0%".into(), + claude_prompt: None, + mastering_enabled: true, + auto_generate: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] +pub struct PodcastResult { + pub podcast_path: String, + pub subtitle_path: Option, + pub transcript_path: String, + pub script_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +#[serde(rename_all = "camelCase")] +pub struct PodcastProgress { + pub history_id: String, + /// "fetching_transcript" | "generating_script" | "narrating" | "mastering" | "complete" | "failed" + pub step: String, + pub progress: f64, + pub error: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs index e1b6245..65ff793 100644 --- a/src-tauri/src/proxy.rs +++ b/src-tauri/src/proxy.rs @@ -1,7 +1,12 @@ +use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; +// --------------------------------------------------------------------------- +// Proxy cache +// --------------------------------------------------------------------------- + struct ProxyCache { result: Option, last_check: Option, @@ -19,6 +24,116 @@ impl ProxyCache { static PROXY_CACHE: Mutex = Mutex::new(ProxyCache::new()); const PROXY_CACHE_TTL: Duration = Duration::from_secs(300); +// --------------------------------------------------------------------------- +// Circuit breaker +// --------------------------------------------------------------------------- + +/// Per-proxy health state tracked for the current session. +struct ProxyHealthState { + consecutive_failures: u32, + broken: bool, +} + +impl ProxyHealthState { + fn new() -> Self { + Self { + consecutive_failures: 0, + broken: false, + } + } +} + +/// The circuit breaker trips after this many consecutive failures for a proxy URL. +const CIRCUIT_BREAKER_THRESHOLD: u32 = 3; + +static PROXY_HEALTH: Mutex>> = Mutex::new(None); + +fn with_health(f: F) -> R +where + F: FnOnce(&mut HashMap) -> R, +{ + let mut guard = PROXY_HEALTH.lock().unwrap_or_else(|e| e.into_inner()); + let map = guard.get_or_insert_with(HashMap::new); + f(map) +} + +/// Record a failure for a proxy URL. Returns `true` if this call tripped the +/// circuit breaker (i.e. the proxy just transitioned to "broken"). +fn record_failure_inner(url: &str) -> bool { + with_health(|map| { + let entry = map.entry(url.to_string()).or_insert_with(ProxyHealthState::new); + if entry.broken { + return false; // already broken, nothing new + } + entry.consecutive_failures += 1; + if entry.consecutive_failures >= CIRCUIT_BREAKER_THRESHOLD { + entry.broken = true; + warn!( + "proxy: Circuit breaker tripped for {} after {} consecutive failures — \ + switching to direct for this session", + url, entry.consecutive_failures + ); + true + } else { + debug!( + "proxy: Failure {}/{} for {}", + entry.consecutive_failures, CIRCUIT_BREAKER_THRESHOLD, url + ); + false + } + }) +} + +fn is_broken(url: &str) -> bool { + with_health(|map| map.get(url).is_some_and(|s| s.broken)) +} + +/// Report a successful request through the given proxy URL. +/// Resets the consecutive failure counter (but does not un-break an already-broken proxy). +pub fn record_proxy_success(url: &str) { + with_health(|map| { + if let Some(entry) = map.get_mut(url) { + if !entry.broken { + entry.consecutive_failures = 0; + } + } + }); +} + +/// Report a failed request through the given proxy URL. +/// After `CIRCUIT_BREAKER_THRESHOLD` consecutive failures the proxy is marked +/// broken and the cache is invalidated so the next resolution skips it. +pub fn record_proxy_failure(url: &str) { + let tripped = record_failure_inner(url); + if tripped { + invalidate_proxy_cache(); + } +} + +/// Reset all proxy health state (useful for UI "reset proxy" action). +pub fn reset_proxy_health() { + with_health(|map| map.clear()); + info!("proxy: Health state reset"); +} + +// --------------------------------------------------------------------------- +// Cache invalidation +// --------------------------------------------------------------------------- + +/// Clear the proxy detection cache so the next call to `detect_system_proxy()` +/// performs a fresh detection pass. +pub fn invalidate_proxy_cache() { + if let Ok(mut cache) = PROXY_CACHE.lock() { + cache.result = None; + cache.last_check = None; + info!("proxy: Cache invalidated"); + } +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ProxyConfig { @@ -57,6 +172,10 @@ impl Default for ResolvedProxy { } } +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + pub fn validate_proxy_url(url: &str) -> Result<(), String> { if url.is_empty() { return Err("Proxy URL is empty".to_string()); @@ -83,6 +202,10 @@ pub fn validate_proxy_url(url: &str) -> Result<(), String> { Ok(()) } +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + pub fn resolve_proxy(config: &ProxyConfig) -> ResolvedProxy { match config.mode.as_str() { "none" => { @@ -160,6 +283,12 @@ fn detect_system_proxy_uncached() -> ResolvedProxy { return proxy; } + // Port scanning is a last resort and generates false positives — log a warning. + warn!( + "proxy: No system proxy configured, falling back to port scanning \ + (may produce false positives)" + ); + if let Some(proxy) = detect_common_proxy_ports() { return proxy; } @@ -448,40 +577,159 @@ fn detect_linux_proxy() -> Option { None } +// --------------------------------------------------------------------------- +// Port scanning with HTTP CONNECT probe validation +// --------------------------------------------------------------------------- + +/// Send an HTTP CONNECT request through `proxy_addr` and verify the response +/// looks like a functioning proxy. Returns `true` only when the proxy replies +/// with `HTTP/1.x 200`. +/// +/// We probe `connectivitycheck.gstatic.com:443` — a lightweight Google +/// endpoint that is globally reachable and rarely blocked, making it a good +/// canary. The entire operation runs within `timeout`. +fn validate_http_proxy(proxy_addr: std::net::SocketAddr, timeout: Duration) -> bool { + use std::io::{Read, Write}; + use std::net::TcpStream; + + // The target we ask the proxy to CONNECT to. We don't actually complete + // a TLS handshake — we only need the proxy's `200 Connection established` + // response to confirm it speaks proxy protocol. + const PROBE_HOST: &str = "connectivitycheck.gstatic.com"; + const PROBE_PORT: u16 = 443; + + let stream = match TcpStream::connect_timeout(&proxy_addr, timeout) { + Ok(s) => s, + Err(_) => return false, + }; + + if stream.set_read_timeout(Some(timeout)).is_err() { + return false; + } + if stream.set_write_timeout(Some(timeout)).is_err() { + return false; + } + + let request = format!( + "CONNECT {}:{} HTTP/1.0\r\nHost: {}:{}\r\n\r\n", + PROBE_HOST, PROBE_PORT, PROBE_HOST, PROBE_PORT + ); + + let mut stream = stream; + + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + + // Read just enough to check the status line — we don't need the full + // response headers, and we don't want to wait for a body that won't come. + let mut buf = [0u8; 64]; + let n = match stream.read(&mut buf) { + Ok(n) if n > 0 => n, + _ => return false, + }; + + let response = std::str::from_utf8(&buf[..n]).unwrap_or(""); + + // A well-behaved HTTP proxy responds with: + // HTTP/1.0 200 Connection established + // HTTP/1.1 200 Connection established + // Non-proxy services (dev servers, databases, etc.) respond with HTML + // error pages, 400 Bad Request, or nothing at all. + let valid = response.starts_with("HTTP/1.") && response.contains(" 200 "); + + if valid { + debug!("proxy: CONNECT probe to {}:{} accepted", PROBE_HOST, PROBE_PORT); + } else { + debug!( + "proxy: CONNECT probe rejected (response prefix: {:?})", + &response.chars().take(40).collect::() + ); + } + + valid +} + +/// Scan a fixed set of well-known proxy ports on localhost, but only accept +/// a port as a proxy if it passes an HTTP CONNECT validation probe. +/// +/// Port 8080 is intentionally excluded because it is used by far too many +/// non-proxy services (dev servers, web UIs, Jupyter, etc.) to be useful as +/// a proxy heuristic. fn detect_common_proxy_ports() -> Option { use std::net::TcpStream; - let common_ports = [ - (7890, "http"), - (7891, "http"), - (8080, "http"), - (8118, "http"), - (3128, "http"), - (1080, "socks5"), - (10809, "http"), - (10808, "socks5"), + // Ports ordered by likelihood of being a real proxy. + // 8080 is deliberately omitted — too many false positives. + let common_ports: &[(u16, &str)] = &[ + (7890, "http"), // Clash + (7891, "http"), // Clash (HTTP) + (8118, "http"), // Privoxy + (3128, "http"), // Squid + (1080, "socks5"), // SOCKS5 generic + (10809, "http"), // v2rayN HTTP + (10808, "socks5"), // v2rayN SOCKS (2080, "http"), (2081, "socks5"), - (9050, "socks5"), - (9150, "socks5"), + (9050, "socks5"), // Tor + (9150, "socks5"), // Tor Browser ]; - for (port, scheme) in common_ports { + // Quick TCP connect timeout — we only want to know if something is + // listening before paying for the full CONNECT probe round-trip. + let tcp_timeout = Duration::from_millis(50); + + // Budget for the full CONNECT probe (connect + write + read). + let probe_timeout = Duration::from_millis(500); + + for &(port, scheme) in common_ports { let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); - if TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_ok() { + + // Fast path: skip if nothing is listening. + if TcpStream::connect_timeout(&addr, tcp_timeout).is_err() { + continue; + } + + // Something is listening — but is it actually a proxy? + // Only HTTP(S) proxies support CONNECT; skip the probe for SOCKS ports + // because SOCKS uses a binary protocol and we can't validate it the + // same way. For SOCKS we accept the TCP connect as sufficient evidence + // (SOCKS proxies on those ports are almost exclusively real proxies). + let is_validated = if scheme == "http" || scheme == "https" { + validate_http_proxy(addr, probe_timeout) + } else { + // For SOCKS ports we trust the well-known port numbers. + true + }; + + if is_validated { let proxy_url = format!("{}://127.0.0.1:{}", scheme, port); - info!("Found open proxy port: {}", port); + info!("proxy: Validated proxy on port {} ({})", port, scheme); return Some(ResolvedProxy { - url: proxy_url.clone(), + url: proxy_url, source: "detected".to_string(), description: format!("Detected local proxy on port {}", port), }); + } else { + debug!( + "proxy: Port {} has a listener but failed CONNECT probe — not treating as proxy", + port + ); } } None } +// --------------------------------------------------------------------------- +// Strategy list (used by download engine) +// --------------------------------------------------------------------------- + +/// Return an ordered list of proxy strategies to try for a given config. +/// +/// When the resolved proxy URL is marked as broken by the circuit breaker, +/// it is skipped and we go straight to "no proxy" — no point hammering a +/// known-broken proxy. pub fn proxy_strategies(config: &ProxyConfig) -> Vec<(&'static str, Option)> { let resolved = resolve_proxy(config); @@ -491,10 +739,22 @@ pub fn proxy_strategies(config: &ProxyConfig) -> Vec<(&'static str, Option { if !resolved.url.is_empty() { + // Skip a broken proxy immediately. + if is_broken(&resolved.url) { + warn!( + "proxy: Custom proxy {} is broken (circuit breaker open), using direct", + resolved.url + ); + return vec![("no proxy", None)]; + } + if config.retry_without_proxy { let system_proxy = detect_system_proxy(); let mut strategies = vec![("custom proxy", Some(resolved.url.clone()))]; - if !system_proxy.url.is_empty() && system_proxy.url != resolved.url { + if !system_proxy.url.is_empty() + && system_proxy.url != resolved.url + && !is_broken(&system_proxy.url) + { strategies.push(("system proxy", Some(system_proxy.url))); } strategies.push(("no proxy", None)); @@ -504,7 +764,7 @@ pub fn proxy_strategies(config: &ProxyConfig) -> Vec<(&'static str, Option Vec<(&'static str, Option { if !resolved.url.is_empty() { + // Skip a broken detected/system proxy. + if is_broken(&resolved.url) { + warn!( + "proxy: System proxy {} is broken (circuit breaker open), using direct", + resolved.url + ); + return vec![("no proxy", None)]; + } vec![("system proxy", Some(resolved.url)), ("no proxy", None)] } else { vec![("no proxy", None)] diff --git a/src-tauri/src/torrent_search.rs b/src-tauri/src/torrent_search.rs new file mode 100644 index 0000000..a94bff0 --- /dev/null +++ b/src-tauri/src/torrent_search.rs @@ -0,0 +1,496 @@ +use tauri::AppHandle; +use tracing::{debug, warn}; + +#[cfg(feature = "ts-export")] +use ts_rs::TS; + +use serde::{Deserialize, Serialize}; + +// ── Types ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct TorrentSearchFilters { + pub query: String, + /// "movie" | "tv" + pub content_type: Option, + /// "4K" | "1080p" | "720p" + pub quality: Option, + pub genre: Option, + pub year: Option, + pub min_rating: Option, + pub language: Option, + /// "relevance" | "seeders" | "rating" | "date" + pub sort: Option, + pub page: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct TorrentOption { + pub quality: Option, + pub codec: Option, + pub seeders: Option, + /// Size in bytes as returned by the API (integer). + pub size_bytes: Option, + pub quality_score: Option, + pub magnet_url: Option, + pub torrent_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct TorrentSearchResult { + pub title: String, + pub year: Option, + /// API field name is "contentType" (camelCase rename handled by serde). + pub content_type: Option, + pub rating_imdb: Option, + pub poster_url: Option, + pub torrents: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct TorrentSearchResponse { + pub total: u32, + pub page: u32, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-export", derive(TS))] +#[cfg_attr(feature = "ts-export", ts(export))] +pub struct TorrentFileEntry { + pub index: u32, + pub path: String, + pub size: u64, +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const BASE_URL: &str = "https://torrentclaw.com/api/v1"; + +fn load_api_key(app: &AppHandle) -> Option { + use tauri_plugin_store::StoreExt; + let store = app.store("settings.json").ok()?; + store + .get("torrentSearchApiKey") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .filter(|s| !s.is_empty()) +} + +fn build_client(timeout_secs: u64) -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| format!("HTTP client error: {}", e)) +} + +fn apply_api_key( + request: reqwest::RequestBuilder, + api_key: Option<&str>, +) -> reqwest::RequestBuilder { + match api_key { + Some(key) => request.header("Authorization", format!("Bearer {}", key)), + None => request, + } +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn torrent_search( + app: AppHandle, + filters: TorrentSearchFilters, +) -> Result { + debug!( + "torrent_search: query={:?} type={:?} quality={:?} page={:?}", + filters.query, filters.content_type, filters.quality, filters.page + ); + + let api_key = load_api_key(&app); + let client = build_client(15)?; + + let mut params: Vec<(&str, String)> = vec![("q", filters.query.clone())]; + + if let Some(ref ct) = filters.content_type { + params.push(("type", ct.clone())); + } + if let Some(ref q) = filters.quality { + params.push(("quality", q.clone())); + } + if let Some(ref g) = filters.genre { + params.push(("genre", g.clone())); + } + if let Some(y) = filters.year { + params.push(("year", y.to_string())); + } + if let Some(r) = filters.min_rating { + params.push(("rating", r.to_string())); + } + if let Some(ref lang) = filters.language { + params.push(("language", lang.clone())); + } + if let Some(ref sort) = filters.sort { + params.push(("sort", sort.clone())); + } + if let Some(page) = filters.page { + params.push(("page", page.to_string())); + } + + let url = format!("{}/search", BASE_URL); + let request = client.get(&url).query(¶ms); + let request = apply_api_key(request, api_key.as_deref()); + + let response = request + .send() + .await + .map_err(|e| format!("Search request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("TorrentClaw API error: {}", response.status())); + } + + let text = response + .text() + .await + .map_err(|e| format!("Failed to read search response: {}", e))?; + + serde_json::from_str::(&text).map_err(|e| { + let preview = if text.len() > 300 { + format!("{}...", &text[..300]) + } else { + text.clone() + }; + warn!("TorrentClaw parse error: {}. Response preview: {}", e, preview); + format!("Failed to parse search response: {}", e) + }) +} + +#[tauri::command] +pub async fn torrent_autocomplete( + app: AppHandle, + query: String, +) -> Result, String> { + debug!("torrent_autocomplete: query={:?}", query); + + let api_key = load_api_key(&app); + let client = build_client(5)?; + + let url = format!("{}/autocomplete", BASE_URL); + let request = client.get(&url).query(&[("q", &query)]); + let request = apply_api_key(request, api_key.as_deref()); + + let response = request + .send() + .await + .map_err(|e| format!("Autocomplete request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("TorrentClaw API error: {}", response.status())); + } + + response + .json::>() + .await + .map_err(|e| format!("Failed to parse autocomplete response: {}", e)) +} + +/// List files inside a torrent given its magnet URL. +/// +/// Because `aria2c --show-files` only works on `.torrent` files, we: +/// 1. Fetch the metadata via `--bt-metadata-only=true --bt-save-metadata=true` into a temp dir. +/// 2. Locate the resulting `.torrent` file. +/// 3. Run `--show-files` on it and parse the output. +/// 4. Clean up the temp dir. +/// +/// This command is desktop-only because aria2 is not available on mobile. +#[cfg(desktop)] +#[tauri::command] +pub async fn torrent_list_files( + app: AppHandle, + magnet_url: String, +) -> Result, String> { + debug!("torrent_list_files: magnet_url={:?}", magnet_url); + + let aria2_path = crate::deps::resolve_aria2_path(&app) + .ok_or_else(|| "aria2 is not installed. Install it from the Dependencies settings page.".to_string())?; + + // Create a temporary directory for metadata download. + let temp_dir = std::env::temp_dir().join(format!("comine_torrent_{}", uuid::Uuid::new_v4())); + tokio::fs::create_dir_all(&temp_dir) + .await + .map_err(|e| format!("Failed to create temp dir: {}", e))?; + + let cleanup = || { + let dir = temp_dir.clone(); + tokio::spawn(async move { + if let Err(e) = tokio::fs::remove_dir_all(&dir).await { + warn!("Failed to clean up temp dir {:?}: {}", dir, e); + } + }); + }; + + // Step 1: Download torrent metadata only. + let torrent_file = match fetch_torrent_metadata(&aria2_path, &temp_dir, &magnet_url).await { + Ok(path) => path, + Err(e) => { + cleanup(); + return Err(e); + } + }; + + // Step 2: List files from the .torrent file. + let entries = match list_files_from_torrent(&aria2_path, &torrent_file).await { + Ok(entries) => entries, + Err(e) => { + cleanup(); + return Err(e); + } + }; + + cleanup(); + Ok(entries) +} + +/// Mobile: list files via librqbit's list_only mode (no aria2 subprocess available). +#[cfg(mobile)] +#[tauri::command] +pub async fn torrent_list_files( + app: AppHandle, + magnet_url: String, +) -> Result, String> { + use std::sync::Arc; + use tauri::Manager; + use crate::orchestrator::backends::librqbit::SharedLibrqbitSession; + + debug!("torrent_list_files (mobile): magnet_url={:?}", magnet_url); + + let shared = app.state::>(); + let session = shared.get().await.map_err(|e| e.to_string())?; + + let add_opts = librqbit::AddTorrentOptions { + list_only: true, + ..Default::default() + }; + + let response = session + .add_torrent(librqbit::AddTorrent::from_url(&magnet_url), Some(add_opts)) + .await + .map_err(|e| format!("Failed to fetch torrent metadata: {}", e))?; + + match response { + librqbit::AddTorrentResponse::ListOnly(list) => { + let entries: Vec = list + .info + .iter_file_details() + .map_err(|e| format!("Failed to parse file details: {}", e))? + .enumerate() + .map(|(i, details)| TorrentFileEntry { + index: (i + 1) as u32, // 1-based to match aria2 convention + path: details + .filename + .to_string() + .unwrap_or_else(|_| format!("file_{}", i)), + size: details.len, + }) + .collect(); + Ok(entries) + } + _ => Err("Unexpected response from librqbit (expected ListOnly)".to_string()), + } +} + +// ── aria2 subprocess helpers (desktop only) ────────────────────────────────── + +#[cfg(desktop)] +async fn fetch_torrent_metadata( + aria2_path: &std::path::Path, + temp_dir: &std::path::Path, + magnet_url: &str, +) -> Result { + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, BufReader}; + + let mut cmd = crate::utils::new_command(aria2_path); + cmd.args([ + "--bt-metadata-only=true", + "--bt-save-metadata=true", + "--enable-dht=true", + "--bt-enable-lpd=true", + "--listen-port=6881-6999", + "--dht-listen-port=6881-6999", + "--seed-ratio=0.0", + "--summary-interval=5", + "-d", + ]) + .arg(temp_dir) + .arg(magnet_url) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn aria2 for metadata fetch: {}", e))?; + + let stderr = child.stderr.take(); + if let Some(stderr) = stderr { + tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if !line.trim().is_empty() { + tracing::debug!(target: "aria2_meta", "{}", line); + } + } + }); + } + + // Wait up to 30 seconds for metadata. + let timeout = tokio::time::timeout( + std::time::Duration::from_secs(30), + child.wait(), + ) + .await + .map_err(|_| "Timed out waiting for torrent metadata (30s). The swarm may be inactive.".to_string())? + .map_err(|e| format!("aria2 metadata process error: {}", e))?; + + // aria2 exits with code 0 on success, but also exits 0 when it saves the + // metadata before seeding. Any non-zero code is a failure. + if !timeout.success() { + return Err(format!( + "aria2 failed to fetch torrent metadata (exit code {:?})", + timeout.code() + )); + } + + // Find the .torrent file aria2 saved. + find_torrent_file(temp_dir).await +} + +#[cfg(desktop)] +async fn find_torrent_file(dir: &std::path::Path) -> Result { + let mut read_dir = tokio::fs::read_dir(dir) + .await + .map_err(|e| format!("Failed to read temp dir: {}", e))?; + + while let Ok(Some(entry)) = read_dir.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("torrent") { + return Ok(path); + } + } + + Err("aria2 did not produce a .torrent metadata file. The magnet link may be invalid or the torrent has no active peers.".to_string()) +} + +#[cfg(desktop)] +async fn list_files_from_torrent( + aria2_path: &std::path::Path, + torrent_file: &std::path::Path, +) -> Result, String> { + use std::process::Stdio; + + let mut cmd = crate::utils::new_command(aria2_path); + cmd.arg("--show-files") + .arg(torrent_file) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let output = tokio::time::timeout( + std::time::Duration::from_secs(10), + cmd.output(), + ) + .await + .map_err(|_| "Timed out running aria2 --show-files".to_string())? + .map_err(|e| format!("Failed to run aria2 --show-files: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_show_files_output(&stdout) +} + +/// Parse the tabular output of `aria2c --show-files`. +/// +/// Format produced by aria2: +/// ```text +/// idx|path/length +/// ===+=========== +/// 1|path/to/movie.mkv +/// | 4.2GiB (4500000000) +/// 2|path/to/subs.srt +/// | 50KiB (51200) +/// ``` +/// +/// We read index lines (those starting with a digit after optional whitespace), +/// capture their path, then on the very next line capture the byte count from +/// the parenthesised number. +#[cfg(desktop)] +fn parse_show_files_output(output: &str) -> Result, String> { + let mut entries: Vec = Vec::new(); + + // Skip the header lines (idx|... and ===+...) + let data_lines: Vec<&str> = output + .lines() + .skip_while(|l| !l.trim_start().starts_with(|c: char| c.is_ascii_digit())) + .collect(); + + let mut i = 0; + while i < data_lines.len() { + let line = data_lines[i]; + + // Index lines: optionally indented digits followed by '|' + let trimmed = line.trim_start(); + if let Some(pipe_pos) = trimmed.find('|') { + let index_part = trimmed[..pipe_pos].trim(); + if let Ok(index) = index_part.parse::() { + let path = trimmed[pipe_pos + 1..].trim().to_string(); + + // The next line should contain the size. + let size = if i + 1 < data_lines.len() { + let size_line = data_lines[i + 1]; + parse_size_from_line(size_line) + } else { + 0 + }; + + entries.push(TorrentFileEntry { index, path, size }); + i += 2; // consume both the index line and the size line + continue; + } + } + + i += 1; + } + + if entries.is_empty() { + return Err("aria2 --show-files produced no file entries. The .torrent file may be malformed.".to_string()); + } + + Ok(entries) +} + +/// Extract the byte count from a size line like ` 4.2GiB (4500000000)`. +/// Returns 0 if the parenthesised value cannot be found or parsed. +#[cfg(desktop)] +fn parse_size_from_line(line: &str) -> u64 { + if let (Some(open), Some(close)) = (line.rfind('('), line.rfind(')')) { + if open < close { + let inner = &line[open + 1..close]; + // Strip commas (e.g. "4,500,000,000") before parsing. + let clean: String = inner.chars().filter(|c| c.is_ascii_digit()).collect(); + return clean.parse::().unwrap_or(0); + } + } + 0 +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 1169ea5..afab7a1 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,4 +1,4 @@ -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use std::path::Path; use std::sync::{Mutex, MutexGuard}; @@ -27,7 +27,7 @@ pub struct DiskSpaceInfo { pub used_percent: f64, } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] pub fn get_disk_space_for_path(path: &str) -> Option { use sysinfo::Disks; @@ -66,7 +66,7 @@ pub fn get_disk_space_for_path(path: &str) -> Option { }) } -#[cfg(target_os = "android")] +#[cfg(mobile)] pub fn get_disk_space_for_path(path: &str) -> Option { use std::ffi::CString; use std::mem::MaybeUninit; @@ -181,7 +181,7 @@ pub fn http_client_with_proxy(proxy_url: Option<&str>) -> Result Option { let mut cmd = new_command(ffprobe_path); cmd.args([ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ee535a7..c7be7e4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -65,6 +65,10 @@ "appimage": { "bundleMediaFramework": true } + }, + "iOS": { + "developmentTeam": "365KJHJCW7", + "minimumSystemVersion": "15.0" } } } diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 100644 index 0000000..abb45e0 --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1,140 @@ +# Frontend — SvelteKit + Svelte 5 + TypeScript + +## Svelte 5 Patterns + +This project uses **Svelte 5 runes** throughout. Never use Svelte 4 syntax (`export let`, `$:` reactive declarations). + +```svelte + +``` + +**Key rules:** +- `$state()` for reactive local state. Use `$state.raw()` for large objects to avoid deep proxying. +- `$derived()` / `$derived.by()` for computed values — prefer over `$effect` when possible. +- `$effect()` for side effects only. Use `$effect.root()` in class-based stores for cleanup. +- `$props()` with destructuring and interface. Use `$bindable()` for two-way bound props. +- `type Snippet` for slot-like child content. + +## Component Conventions + +- **Location**: `lib/components/{feature}/ComponentName.svelte` — group by feature (ui, layout, download, settings, resolve, media, providers, builders) +- **Props**: Define `interface Props` in script block, destructure with defaults +- **Events**: Callback props (`onclick`, `onchange`) — not `createEventDispatcher` +- **Actions**: Apply with `use:actionName` — existing actions: tooltip, spotlight, portal, edgeMask, skeleton, preserveScroll +- **Accessibility**: Use semantic HTML, ARIA attributes (`role`, `aria-checked`, `aria-label`, `aria-modal`), keyboard support (Enter, Space, Escape) +- **Styling**: Scoped ` - +
diff --git a/src/lib/bindings/BackendError.ts b/src/lib/bindings/BackendError.ts index af66edc..6c0d600 100644 --- a/src/lib/bindings/BackendError.ts +++ b/src/lib/bindings/BackendError.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type BackendError = { "type": "unsupportedUrl", "message": string } | { "type": "networkError", "message": string } | { "type": "notFound", "message": string } | { "type": "forbidden", "message": string } | { "type": "unauthorized", "message": string } | { "type": "serverError", "message": string } | { "type": "rateLimited", "message": string } | { "type": "processError", "message": string } | { "type": "parseError", "message": string } | { "type": "ioError", "message": string } | { "type": "cancelled" } | { "type": "paused" } | { "type": "duplicateUrl", "message": string } | { "type": "other", "message": string }; +export type BackendError = { "type": "unsupportedUrl", "message": string } | { "type": "networkError", "message": string } | { "type": "notFound", "message": string } | { "type": "forbidden", "message": string } | { "type": "unauthorized", "message": string } | { "type": "serverError", "message": string } | { "type": "rateLimited", "message": string } | { "type": "processError", "message": string } | { "type": "parseError", "message": string } | { "type": "ioError", "message": string } | { "type": "cancelled" } | { "type": "paused" } | { "type": "duplicateUrl", "message": string } | { "type": "proxyError", "message": string } | { "type": "other", "message": string }; diff --git a/src/lib/bindings/DownloadOptions.ts b/src/lib/bindings/DownloadOptions.ts index 2d34a81..4cbe6b4 100644 --- a/src/lib/bindings/DownloadOptions.ts +++ b/src/lib/bindings/DownloadOptions.ts @@ -2,4 +2,9 @@ import type { ClipRange } from "./ClipRange"; import type { ProxyConfig } from "./ProxyConfig"; -export type DownloadOptions = { cookiesFromBrowser: string | null, customCookies: string | null, proxy: ProxyConfig | null, speedLimit: number | null, embedThumbnail: boolean, embedMetadata: boolean, embedSubtitles: boolean, subtitleLangs: string | null, sponsorblockRemove: string | null, youtubePlayerClient: string | null, aria2Connections: number | null, aria2Splits: number | null, aria2MinSplitSize: string | null, aria2DisableIpv6: boolean | null, aria2CustomArgs: string | null, maxRetries: number | null, clipRanges: Array | null, useAria2: boolean, forceKeyframesAtCuts: boolean, }; +export type DownloadOptions = { cookiesFromBrowser: string | null, customCookies: string | null, proxy: ProxyConfig | null, speedLimit: number | null, embedThumbnail: boolean, embedMetadata: boolean, embedSubtitles: boolean, subtitleLangs: string | null, sponsorblockRemove: string | null, youtubePlayerClient: string | null, aria2Connections: number | null, aria2Splits: number | null, aria2MinSplitSize: string | null, aria2DisableIpv6: boolean | null, aria2CustomArgs: string | null, maxRetries: number | null, clipRanges: Array | null, useAria2: boolean, forceKeyframesAtCuts: boolean, +/** + * Torrent file indices to download (1-based). When set, only the selected + * files are downloaded. Passed to aria2 as `--select-file=`. + */ +torrentSelectedFiles: Array | null, }; diff --git a/src/lib/bindings/EnqueueOverrides.ts b/src/lib/bindings/EnqueueOverrides.ts index 3875646..ba5f301 100644 --- a/src/lib/bindings/EnqueueOverrides.ts +++ b/src/lib/bindings/EnqueueOverrides.ts @@ -1,4 +1,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ClipRange } from "./ClipRange"; -export type EnqueueOverrides = { downloadMode: string | null, videoQuality: string | null, audioQuality: string | null, cookiesFromBrowser: string | null, customCookies: string | null, embedThumbnail: boolean | null, clearMetadata: boolean | null, embedSubtitles: boolean | null, subtitleLanguages: string | null, sponsorBlock: boolean | null, sponsorBlockCategories: Array | null, useAria2: boolean | null, audioFormat: string | null, outputTemplate: string | null, clipRanges: Array | null, filename: string | null, outputDirectory: string | null, dontShowInHistory: boolean | null, }; +export type EnqueueOverrides = { downloadMode: string | null, videoQuality: string | null, audioQuality: string | null, cookiesFromBrowser: string | null, customCookies: string | null, embedThumbnail: boolean | null, clearMetadata: boolean | null, embedSubtitles: boolean | null, subtitleLanguages: string | null, sponsorBlock: boolean | null, sponsorBlockCategories: Array | null, useAria2: boolean | null, audioFormat: string | null, outputTemplate: string | null, clipRanges: Array | null, filename: string | null, outputDirectory: string | null, dontShowInHistory: boolean | null, +/** + * Torrent file indices to download (1-based). + */ +torrentSelectedFiles: Array | null, +/** + * Prefetched title (e.g. individual torrent file name). + */ +title: string | null, +/** + * Prefetched thumbnail URL. + */ +thumbnail: string | null, }; diff --git a/src/lib/bindings/HistoryItem.ts b/src/lib/bindings/HistoryItem.ts index 3900ed5..4a7922c 100644 --- a/src/lib/bindings/HistoryItem.ts +++ b/src/lib/bindings/HistoryItem.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HistoryItem = { id: string, url: string, title: string, author: string, authorUrl: string | null, thumbnail: string, extension: string, size: number, duration: number, filePath: string, downloadedAt: number, type: string, playlistId: string | null, playlistTitle: string | null, playlistIndex: number | null, convertedFormat: string | null, downloadSource: string | null, isFavourite: boolean, isDirectory: boolean, fileCount: number | null, }; +export type HistoryItem = { id: string, url: string, title: string, author: string, authorUrl: string | null, thumbnail: string, extension: string, size: number, duration: number, filePath: string, downloadedAt: number, type: string, playlistId: string | null, playlistTitle: string | null, playlistIndex: number | null, convertedFormat: string | null, downloadSource: string | null, isFavourite: boolean, isDirectory: boolean, fileCount: number | null, podcastPath: string | null, podcastSubtitlePath: string | null, +/** + * "generating" | "completed" | "failed" + */ +podcastStatus: string | null, }; diff --git a/src/lib/bindings/Job.ts b/src/lib/bindings/Job.ts index 98eecdb..48720eb 100644 --- a/src/lib/bindings/Job.ts +++ b/src/lib/bindings/Job.ts @@ -3,4 +3,9 @@ import type { ContentType } from "./ContentType"; import type { DownloadRequest } from "./DownloadRequest"; import type { JobStatus } from "./JobStatus"; -export type Job = { id: string, request: DownloadRequest, status: JobStatus, backend: string, createdAt: number, startedAt: number | null, completedAt: number | null, progress: number, downloadedBytes: number, totalBytes: number | null, speed: number | null, eta: number | null, tempFiles: Array, retryCount: number, lastError: string | null, title: string | null, thumbnail: string | null, author: string | null, authorUrl: string | null, duration: number | null, playlistId: string | null, playlistTitle: string | null, playlistIndex: number | null, contentType: ContentType | null, }; +export type Job = { id: string, request: DownloadRequest, status: JobStatus, backend: string, createdAt: number, startedAt: number | null, completedAt: number | null, progress: number, downloadedBytes: number, totalBytes: number | null, speed: number | null, eta: number | null, tempFiles: Array, retryCount: number, lastError: string | null, title: string | null, thumbnail: string | null, author: string | null, authorUrl: string | null, duration: number | null, playlistId: string | null, playlistTitle: string | null, playlistIndex: number | null, contentType: ContentType | null, +/** + * When true, the yt-dlp backend should omit the --proxy argument on the next attempt. + * Set after a ProxyError to retry the download over a direct connection. + */ +skipProxy: boolean, }; diff --git a/src/lib/bindings/JobEvent.ts b/src/lib/bindings/JobEvent.ts index 33cd002..44e2bda 100644 --- a/src/lib/bindings/JobEvent.ts +++ b/src/lib/bindings/JobEvent.ts @@ -3,4 +3,4 @@ import type { Job } from "./Job"; import type { JobStatus } from "./JobStatus"; import type { UrlInfoPatch } from "./UrlInfoPatch"; -export type JobEvent = { "type": "added", "data": { job: Job, } } | { "type": "started", "data": { job_id: string, backend: string, } } | { "type": "urlInfoPatched", "data": { job_id: string, patch: UrlInfoPatch, } } | { "type": "progress", "data": { job_id: string, progress: number, downloaded_bytes: number, total_bytes: number | null, speed: number | null, eta: number | null, } } | { "type": "statusChanged", "data": { job_id: string, status: JobStatus, } } | { "type": "completed", "data": { job_id: string, output_path: string, title: string | null, thumbnail: string | null, filesize: number | null, } } | { "type": "failed", "data": { job_id: string, error: string, retryable: boolean, } } | { "type": "cancelled", "data": { job_id: string, } } | { "type": "paused", "data": { job_id: string, } } | { "type": "resumed", "data": { job_id: string, } } | { "type": "thumbnailEmbedFailed", "data": { job_id: string, error: string, } } | { "type": "thumbnailColorExtracted", "data": { job_id: string, color: [number, number, number], } }; +export type JobEvent = { "type": "added", "data": { job: Job, } } | { "type": "started", "data": { job_id: string, backend: string, } } | { "type": "urlInfoPatched", "data": { job_id: string, patch: UrlInfoPatch, } } | { "type": "progress", "data": { job_id: string, progress: number, downloaded_bytes: number, total_bytes: number | null, speed: number | null, eta: number | null, } } | { "type": "statusChanged", "data": { job_id: string, status: JobStatus, } } | { "type": "completed", "data": { job_id: string, output_path: string, title: string | null, thumbnail: string | null, filesize: number | null, } } | { "type": "failed", "data": { job_id: string, error: string, retryable: boolean, } } | { "type": "cancelled", "data": { job_id: string, } } | { "type": "paused", "data": { job_id: string, } } | { "type": "resumed", "data": { job_id: string, } } | { "type": "thumbnailEmbedFailed", "data": { job_id: string, error: string, } } | { "type": "thumbnailColorExtracted", "data": { job_id: string, color: [number, number, number], } } | { "type": "filePathResolved", "data": { job_id: string, output_path: string, } }; diff --git a/src/lib/bindings/PodcastProgress.ts b/src/lib/bindings/PodcastProgress.ts new file mode 100644 index 0000000..e618a81 --- /dev/null +++ b/src/lib/bindings/PodcastProgress.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PodcastProgress = { historyId: string, +/** + * "fetching_transcript" | "generating_script" | "narrating" | "mastering" | "complete" | "failed" + */ +step: string, progress: number, error: string | null, }; diff --git a/src/lib/bindings/PodcastResult.ts b/src/lib/bindings/PodcastResult.ts new file mode 100644 index 0000000..dcffbe7 --- /dev/null +++ b/src/lib/bindings/PodcastResult.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PodcastResult = { podcastPath: string, subtitlePath: string | null, transcriptPath: string, scriptPath: string, }; diff --git a/src/lib/bindings/PodcastSettings.ts b/src/lib/bindings/PodcastSettings.ts new file mode 100644 index 0000000..05490c2 --- /dev/null +++ b/src/lib/bindings/PodcastSettings.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PodcastSettings = { enabled: boolean, subtitleLang: string, ttsVoice: string, ttsRate: string, claudePrompt: string | null, masteringEnabled: boolean, autoGenerate: boolean, }; diff --git a/src/lib/bindings/SubtitleFile.ts b/src/lib/bindings/SubtitleFile.ts new file mode 100644 index 0000000..cf3dea1 --- /dev/null +++ b/src/lib/bindings/SubtitleFile.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SubtitleFile = { path: string, label: string, format: string, }; diff --git a/src/lib/bindings/TorrentFileEntry.ts b/src/lib/bindings/TorrentFileEntry.ts new file mode 100644 index 0000000..b3c6bae --- /dev/null +++ b/src/lib/bindings/TorrentFileEntry.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentFileEntry = { index: number, path: string, size: number, }; diff --git a/src/lib/bindings/TorrentOption.ts b/src/lib/bindings/TorrentOption.ts new file mode 100644 index 0000000..e8ca9e6 --- /dev/null +++ b/src/lib/bindings/TorrentOption.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentOption = { quality: string | null, codec: string | null, seeders: number | null, +/** + * Size in bytes as returned by the API (integer). + */ +sizeBytes: number | null, qualityScore: number | null, magnetUrl: string | null, torrentUrl: string | null, }; diff --git a/src/lib/bindings/TorrentSearchFilters.ts b/src/lib/bindings/TorrentSearchFilters.ts new file mode 100644 index 0000000..90a4cf6 --- /dev/null +++ b/src/lib/bindings/TorrentSearchFilters.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentSearchFilters = { query: string, +/** + * "movie" | "tv" + */ +contentType: string | null, +/** + * "4K" | "1080p" | "720p" + */ +quality: string | null, genre: string | null, year: number | null, minRating: number | null, language: string | null, +/** + * "relevance" | "seeders" | "rating" | "date" + */ +sort: string | null, page: number | null, }; diff --git a/src/lib/bindings/TorrentSearchResponse.ts b/src/lib/bindings/TorrentSearchResponse.ts new file mode 100644 index 0000000..b2372c5 --- /dev/null +++ b/src/lib/bindings/TorrentSearchResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TorrentSearchResult } from "./TorrentSearchResult"; + +export type TorrentSearchResponse = { total: number, page: number, results: Array, }; diff --git a/src/lib/bindings/TorrentSearchResult.ts b/src/lib/bindings/TorrentSearchResult.ts new file mode 100644 index 0000000..13fa028 --- /dev/null +++ b/src/lib/bindings/TorrentSearchResult.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TorrentOption } from "./TorrentOption"; + +export type TorrentSearchResult = { title: string, year: number | null, +/** + * API field name is "contentType" (camelCase rename handled by serde). + */ +contentType: string | null, ratingImdb: string | null, posterUrl: string | null, torrents: Array, }; diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index 5578134..a2d66a6 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -46,6 +46,9 @@ export type { MusicInfo } from './MusicInfo'; export type { OutputSettings } from './OutputSettings'; export type { Pagination } from './Pagination'; export type { PlaylistEntry } from './PlaylistEntry'; +export type { PodcastProgress } from './PodcastProgress'; +export type { PodcastResult } from './PodcastResult'; +export type { PodcastSettings } from './PodcastSettings'; export type { PostProcessStep } from './PostProcessStep'; export type { Priority } from './Priority'; export type { ProxyConfig } from './ProxyConfig'; @@ -57,9 +60,15 @@ export type { ResolveResult } from './ResolveResult'; export type { ResolvedProxy } from './ResolvedProxy'; export type { SeriesInfo } from './SeriesInfo'; export type { Storyboard } from './Storyboard'; +export type { SubtitleFile } from './SubtitleFile'; export type { SubtitleTrack } from './SubtitleTrack'; export type { Thumbnail } from './Thumbnail'; +export type { TorrentFileEntry } from './TorrentFileEntry'; export type { TorrentInfo } from './TorrentInfo'; +export type { TorrentOption } from './TorrentOption'; +export type { TorrentSearchFilters } from './TorrentSearchFilters'; +export type { TorrentSearchResponse } from './TorrentSearchResponse'; +export type { TorrentSearchResult } from './TorrentSearchResult'; export type { UpdateCheckResult } from './UpdateCheckResult'; export type { UrlInfo } from './UrlInfo'; export type { UrlInfoPatch } from './UrlInfoPatch'; diff --git a/src/lib/components/download/DownloadItem.svelte b/src/lib/components/download/DownloadItem.svelte index d35016c..210aef2 100644 --- a/src/lib/components/download/DownloadItem.svelte +++ b/src/lib/components/download/DownloadItem.svelte @@ -9,6 +9,9 @@ import { t } from '$lib/i18n'; import { formatDuration } from '$lib/utils/format'; import { useDownloadItem } from './useDownloadItem.svelte'; + import { isMobile } from '$lib/utils/android'; + import { openFile } from '$lib/utils/platform'; + import { player } from '$lib/stores/player.svelte'; export interface Props { item: UnifiedDownloadItem; @@ -31,6 +34,49 @@ let thumbWidth = $derived(Math.round(thumbHeight * (16 / 9))); let subtitle = $derived(dState.getItemSubtitle(item)); + let canPlayWhileDownloading = $derived( + item.type === 'torrent' && dl.isDownloading && !!item.filePath + ); + + let isTorrentOnMobile = $derived(item.type === 'torrent' && isMobile()); + + function playTorrentFile() { + if (!item.filePath) return; + player.openMedia({ + filePath: item.filePath, + mediaType: 'video', + title: item.title, + thumbnail: item.thumbnail, + useSystemPlayer: true, + }); + } + + let hasPodcast = $derived(item.podcastStatus === 'completed' && !!item.podcastPath); + let isPodcastGenerating = $derived(item.podcastStatus === 'generating'); + let podcastLabel = $derived.by(() => { + if (!isPodcastGenerating) return ''; + const stepNames: Record = { + starting: 'Starting', + fetching_transcript: 'Transcript', + generating_script: 'Script', + narrating: 'Narrating', + mastering: 'Mastering', + }; + const step = stepNames[item.podcastStep ?? ''] ?? 'Generating'; + const pct = item.podcastProgress != null ? ` ${item.podcastProgress}%` : ''; + return `${step}${pct}`; + }); + + function playPodcast() { + if (!item.podcastPath) return; + player.openMedia({ + filePath: item.podcastPath, + mediaType: 'audio', + title: `${item.title} (Podcast)`, + thumbnail: item.thumbnail, + }); + } + let progressLabel = $derived.by(() => { if (!item.isActive) return ''; if (item.status === 'pending') return $t('downloads.queue.waiting'); @@ -239,6 +285,18 @@ : `${dl.displayProgress}%`}
+ {#if canPlayWhileDownloading} + + {/if} {#if item.source !== 'convert'} + {:else if isPodcastGenerating} +
+ +
+ {/if} + {#if isPodcastGenerating} + + + {podcastLabel} + + {:else if hasPodcast} + + {/if} {#if item.convertedFormat} {@render thumbnailContent(getTypeIcon(item.type), 20, 'thumbnail-placeholder')} - {#if dl.isDownloading} + {#if dl.isDownloading && canPlayWhileDownloading && isTorrentOnMobile} + + {:else if dl.isDownloading}
@@ -570,6 +664,21 @@
{/if} + {#if isPodcastGenerating} + + + {podcastLabel} + + {:else if hasPodcast} + + {/if} {#if item.convertedFormat} {:else if item.source !== 'convert'} + {#if canPlayWhileDownloading} + + {/if} {#if dl.isPaused} {:else} + {#if hasPodcast} + + {:else if isPodcastGenerating} +
+ +
+ {/if}
{/if} - {#if item.filePath && !isAndroid()} + {#if item.filePath && !isMobile()}
{$t('downloads.details.media.title')}
{#if technicalLoading} @@ -434,7 +434,7 @@ +
+ + +
player.lockControls()} + onmouseleave={() => player.unlockControls()} + role="toolbar" + aria-label="Playback controls" + tabindex="0" + > + +
+ +
+ +
+ +
+ + + + {player.formattedTime} +
+ + +
+ + {#if player.subtitleTracks.length > 0} +
+ + + {#if subtitleMenuOpen} + + {/if} +
+ {/if} + + +
+ + player.setVolume(Number((e.target as HTMLInputElement).value))} + aria-label="Volume" + style:--fill-percent="{(player.muted ? 0 : player.volume) * 100}%" + /> +
+ + +
+ + + {#if speedMenuOpen} + + {/if} +
+ + + {#if player.mediaType === 'video'} + + {/if} +
+
+
+ + +{/if} + + diff --git a/src/lib/components/providers/BackgroundProvider.svelte b/src/lib/components/providers/BackgroundProvider.svelte index 43afdc4..21d7ec1 100644 --- a/src/lib/components/providers/BackgroundProvider.svelte +++ b/src/lib/components/providers/BackgroundProvider.svelte @@ -1,6 +1,6 @@ + +{#snippet torrentRow(torrent: EnrichedTorrent, showName: boolean)} + {@const idx = getGlobalIndex(torrent)} + {@const sizeNum = torrent.sizeBytes ?? 0} + {@const scoreColor = getQualityScoreColor(torrent.qualityScore)} +
+ {#if showName} + + {#if torrent.episodeLabel} + {torrent.episodeLabel} + {/if} + {torrent.displayName} + + {/if} + + + {#if torrent.quality} + {torrent.quality} + {:else} + + {/if} + + + + {torrent.codec ?? '—'} + + + + {#if torrent.seeders !== null && torrent.seeders > 0} + + {torrent.seeders} + {:else} + + {/if} + + + + {sizeNum > 0 ? formatSize(sizeNum) : '—'} + + + + {#if torrent.qualityScore !== null} + + {torrent.qualityScore} + + {:else} + + {/if} + + + + + + +
+{/snippet} + +
+
+ {#if onBack} + + {/if} +
+ +
+
+
+ {#if result.posterUrl && !posterError} + {result.title} (posterError = true)} + /> + {:else} +
+ +
+ {/if} +
+ +
+

{result.title}

+ +
+ {#if result.year} + + + {result.year} + + {/if} + {#if result.contentType} + {result.contentType} + {/if} + {#if result.ratingImdb} + + + {result.ratingImdb} + + {/if} +
+ +

+ {enrichedTorrents.length} + {$t('torrentSearch.availableTorrents')} +

+
+
+ +
+ {#if enrichedTorrents.length === 0} +
+ + {$t('torrentSearch.noResults')} +
+ {:else if isTvContent && groupedBySeason.length > 0} + {#each groupedBySeason as group} +
+ + + {#if !collapsedSeasons.has(group.key)} +
+
+ Name + {$t('torrentSearch.filters.quality')} + Codec + {$t('torrentSearch.seeders')} + Size + {$t('torrentSearch.qualityScore')} + +
+ {#each group.torrents as torrent} + {@render torrentRow(torrent, true)} + {/each} +
+ {/if} +
+ {/each} + {:else} +

{$t('torrentSearch.availableTorrents')}

+
+
+ {$t('torrentSearch.filters.quality')} + Codec + {$t('torrentSearch.seeders')} + Size + {$t('torrentSearch.qualityScore')} + +
+ {#each flatSorted as torrent} + {@render torrentRow(torrent, false)} + {/each} +
+ {/if} +
+
+ + {#if filePickerMagnet} +
+ handleFilePickerConfirm(selectedFiles, selectedEntries)} + onBack={() => (filePickerMagnet = null)} + /> +
+ {/if} +
+ + diff --git a/src/lib/components/resolve/TorrentFilePicker.svelte b/src/lib/components/resolve/TorrentFilePicker.svelte new file mode 100644 index 0000000..16ec11c --- /dev/null +++ b/src/lib/components/resolve/TorrentFilePicker.svelte @@ -0,0 +1,500 @@ + + +
+
+ {#if onBack} + + {/if} + +
+ + {$t('torrentSearch.fileSelection.title')} +
+ +
+ + {title} +
+ +
+ {#if loading} +
+
+ +
+

{$t('torrentSearch.fileSelection.loading')}

+
+ {:else if error} +
+ +

{$t('torrentSearch.error')}

+

{error}

+
+ {:else if files.length === 0} +
+ +

{$t('torrentSearch.fileSelection.noFiles')}

+
+ {:else} +
+ + + +
+ +
+ {#each files as file} + {@const fname = getFileName(file.path)} + {@const fdir = getFileDirname(file.path)} + {@const fileIcon = getFileIcon(file.path)} + {@const isSelected = selected.has(file.index)} + + {/each} +
+ {/if} +
+ + {#if !loading && !error && files.length > 0} + + {/if} +
+ + diff --git a/src/lib/components/resolve/TorrentSearch.svelte b/src/lib/components/resolve/TorrentSearch.svelte new file mode 100644 index 0000000..94b1f37 --- /dev/null +++ b/src/lib/components/resolve/TorrentSearch.svelte @@ -0,0 +1,879 @@ + + + + + diff --git a/src/lib/components/settings/Dependencies.svelte b/src/lib/components/settings/Dependencies.svelte index 67b4b75..4e8ec70 100644 --- a/src/lib/components/settings/Dependencies.svelte +++ b/src/lib/components/settings/Dependencies.svelte @@ -18,7 +18,7 @@ name: DependencyName; label: string; descriptionKey: string; - badge: 'required' | 'optional'; + badge: 'required' | 'optional' | 'podcast'; installer: () => Promise; uninstaller: () => Promise; } @@ -29,7 +29,11 @@ name, label: cfg.label, descriptionKey: `settings.deps.${name === 'gallery_dl' ? 'galleryDl' : name}Description`, - badge: cfg.required ? ('required' as const) : ('optional' as const), + badge: cfg.required + ? ('required' as const) + : name === 'edge_tts' || name === 'whisper' + ? ('podcast' as const) + : ('optional' as const), installer: name === 'ytdlp' ? () => deps.installYtdlp() : () => deps.install(name), uninstaller: () => deps.uninstall(name), }; @@ -234,6 +238,11 @@ color: rgba(255, 255, 255, 0.5); } + .dep-badge.podcast { + background: rgba(168, 85, 247, 0.18); + color: #c084fc; + } + .dep-version { font-size: var(--text-xs, 11px); font-weight: 500; diff --git a/src/lib/components/settings/PodcastSettings.svelte b/src/lib/components/settings/PodcastSettings.svelte new file mode 100644 index 0000000..33b466c --- /dev/null +++ b/src/lib/components/settings/PodcastSettings.svelte @@ -0,0 +1,271 @@ + + + + {#snippet title()} + + {/snippet} + {#snippet description()} + + {/snippet} + {#snippet headerAction()} + updateField('enabled', v)} + disabled={loading} + /> + {/snippet} + + {#if podcastSettings.enabled} +
+
+
+ + + + + + +
+ updateField('autoGenerate', v)} + /> +
+ +
+
+ + + + + + +
+ updateField('subtitleLang', e.currentTarget.value)} + placeholder="en" + /> +
+ +
+
+ + + + + + +
+ updateField('ttsVoice', e.currentTarget.value)} + placeholder="en-US-AndrewMultilingualNeural" + /> +
+ +
+
+ + + + + + +
+ updateField('ttsRate', e.currentTarget.value)} + placeholder="+0%" + /> +
+ +
+
+ + + + + + +
+ updateField('masteringEnabled', v)} + /> +
+
+ {/if} +
+ + diff --git a/src/lib/components/settings/ProxyStatus.svelte b/src/lib/components/settings/ProxyStatus.svelte new file mode 100644 index 0000000..811c87e --- /dev/null +++ b/src/lib/components/settings/ProxyStatus.svelte @@ -0,0 +1,348 @@ + + +{#if isDesktop() && $settings.proxyMode !== 'none'} + + {#snippet title()} + + {/snippet} + {#snippet description()} + + {/snippet} + {#snippet headerAction()} +
+ {#if proxyStatus} + {@const badge = getBadgeState(proxyStatus)} + + + {#if badge === 'connected'} + {$t('settings.proxy.connected')} + {:else if badge === 'detected'} + {$t('settings.proxy.detected')} + {:else} + {$t('settings.proxy.failed')} + {/if} + + {/if} + +
+ {/snippet} + +
+ + + {#if proxyStatus?.status === 'failed'} + + {/if} +
+ + {#if testResult || testError} +
+ {#if testError} + + {$t('settings.proxy.testFailed')}: {testError} + {:else if testResult?.success} + + + {$t('settings.proxy.testSuccess')} + {#if testResult.latencyMs !== null} + ({testResult.latencyMs}ms) + {/if} + + {:else} + + {$t('settings.proxy.testFailed')}: {testResult?.message} + {/if} +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/settings/SetupBanner.svelte b/src/lib/components/settings/SetupBanner.svelte index e19b81e..7eee71a 100644 --- a/src/lib/components/settings/SetupBanner.svelte +++ b/src/lib/components/settings/SetupBanner.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/Icon.svelte b/src/lib/components/ui/Icon.svelte index b8c1e40..226145e 100644 --- a/src/lib/components/ui/Icon.svelte +++ b/src/lib/components/ui/Icon.svelte @@ -57,6 +57,8 @@ | 'filter' | 'fog_line_duotone' | 'folder_search' + | 'fullscreen' + | 'fullscreen_exit' | 'gallery' | 'ghost' | 'globe' @@ -96,11 +98,14 @@ | 'save' | 'select_folder' | 'server' + | 'skip_backward' + | 'skip_forward' | 'sort' | 'sort_down' | 'sort_up' | 'sort_vertical' | 'sound' + | 'speed' | 'spinner' | 'square_circle' | 'star' @@ -108,6 +113,7 @@ | 'starry' | 'stats' | 'stop' + | 'subtitle' | 'telegram' | 'text' | 'three_squares' @@ -119,6 +125,9 @@ | 'video' | 'video_replace' | 'video2' + | 'volume_high' + | 'volume_low' + | 'volume_off' | 'warning' | 'weight' | 'widget' @@ -130,6 +139,7 @@ | 'maximize' | 'minimize' | 'close' + | 'close_large' | 'queue' | 'settings' | 'logs' diff --git a/src/lib/components/ui/Select.svelte b/src/lib/components/ui/Select.svelte index b173f25..538d9af 100644 --- a/src/lib/components/ui/Select.svelte +++ b/src/lib/components/ui/Select.svelte @@ -37,7 +37,7 @@ let dropdownEl = $state(); const isRich = $derived(options.some((o) => !!o.description)); - const rowHeight = $derived(isRich ? 60 : 40); + const rowHeight = $derived(isRich ? 60 : 36); let layout = $state({ top: 0, diff --git a/src/lib/components/ui/Toast.svelte b/src/lib/components/ui/Toast.svelte index 2b44045..418fd30 100644 --- a/src/lib/components/ui/Toast.svelte +++ b/src/lib/components/ui/Toast.svelte @@ -94,7 +94,7 @@ import { browser } from '$app/environment'; import Icon, { type IconName } from '$lib/components/ui/Icon.svelte'; import { settings, type ToastPosition } from '$lib/stores/settings'; - import { isAndroid } from '$lib/utils/android'; + import { isMobile } from '$lib/utils/android'; const iconMap: Record = { success: 'check', @@ -106,7 +106,7 @@ }; let position = $derived( - $settings.toastPosition || (browser && isAndroid() ? 'top-right' : 'bottom-right') + $settings.toastPosition || (browser && isMobile() ? 'top-right' : 'bottom-right') ); let flyY = $derived(position.startsWith('top') ? -20 : 20); diff --git a/src/lib/components/ui/iconPaths.ts b/src/lib/components/ui/iconPaths.ts index c2fd2de..fc09ea2 100644 --- a/src/lib/components/ui/iconPaths.ts +++ b/src/lib/components/ui/iconPaths.ts @@ -1751,4 +1751,14 @@ export const iconPaths: Record = { `, ruler: ` `, + close_large: ``, + fullscreen: ``, + fullscreen_exit: ``, + skip_backward: ``, + skip_forward: ``, + speed: ``, + subtitle: ``, + volume_high: ``, + volume_low: ``, + volume_off: ``, }; diff --git a/src/lib/composables/extensionBridge.ts b/src/lib/composables/extensionBridge.ts index 22f8983..d69a55d 100644 --- a/src/lib/composables/extensionBridge.ts +++ b/src/lib/composables/extensionBridge.ts @@ -13,7 +13,7 @@ import { toast } from '$lib/components/ui/Toast.svelte'; import { translate } from '$lib/i18n'; import { cleanUrl } from '$lib/utils/urlUtils'; import { openFile } from '$lib/utils/platform'; -import { isAndroid } from '$lib/utils/android'; +import { isMobile } from '$lib/utils/android'; interface ExtensionBridgeOptions { getAppWindow: () => TauriWindow | null; @@ -168,7 +168,7 @@ export async function setupExtensionBridge(opts: ExtensionBridgeOptions): Promis const filePath = event.payload; logs.info('extension', `Server open request: ${filePath}`); try { - if (isAndroid()) { + if (isMobile()) { await openFile(filePath); } else { await openPath(filePath); diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 0245df5..faf5363 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -344,10 +344,11 @@ export type TranslationKeys = | 'downloads.openError' | 'downloads.openFile' | 'downloads.openFolder' - | 'downloads.openInApp' | 'downloads.openLink' | 'downloads.openSelected' | 'downloads.play' + | 'downloads.playWhileDownloading' + | 'downloads.playWithSystemPlayer' | 'downloads.queue.cancelAll' | 'downloads.queue.cancelAllConfirm' | 'downloads.queue.clearCompleted' @@ -574,6 +575,11 @@ export type TranslationKeys = | 'onboarding.welcome.subtitle' | 'onboarding.welcome.title' + | 'player.playbackError' + | 'player.subtitleDelay' + | 'player.subtitlesOff' + | 'player.unsupportedFormat' + | 'playlist.createFolder' | 'playlist.deselectAll' | 'playlist.downloadOrder' @@ -806,6 +812,7 @@ export type TranslationKeys = | 'settings.deps.components' | 'settings.deps.denoDescription' | 'settings.deps.description' + | 'settings.deps.edge_ttsDescription' | 'settings.deps.ffmpegDescription' | 'settings.deps.galleryDlDescription' | 'settings.deps.goToSettings' @@ -819,6 +826,7 @@ export type TranslationKeys = | 'settings.deps.missingYtdlp' | 'settings.deps.notInstalled' | 'settings.deps.optional' + | 'settings.deps.podcast' | 'settings.deps.quickjsDescription' | 'settings.deps.ready' | 'settings.deps.recommended' @@ -830,6 +838,7 @@ export type TranslationKeys = | 'settings.deps.switchingChannel' | 'settings.deps.title' | 'settings.deps.uninstall' + | 'settings.deps.whisperDescription' | 'settings.deps.ytdlpDescription' | 'settings.downloads.aria2Connections' | 'settings.downloads.aria2ConnectionsDescription' @@ -987,6 +996,32 @@ export type TranslationKeys = | 'settings.notifications.title' | 'settings.notifications.toastPosition' | 'settings.notifications.toastPositionDescription' + | 'settings.podcast.autoGenerate' + | 'settings.podcast.autoGenerateDescription' + | 'settings.podcast.enabled' + | 'settings.podcast.enabledDescription' + | 'settings.podcast.mastering' + | 'settings.podcast.masteringDescription' + | 'settings.podcast.subtitleLang' + | 'settings.podcast.subtitleLangDescription' + | 'settings.podcast.title' + | 'settings.podcast.ttsRate' + | 'settings.podcast.ttsRateDescription' + | 'settings.podcast.ttsVoice' + | 'settings.podcast.ttsVoiceDescription' + | 'settings.proxy.connected' + | 'settings.proxy.customProxy' + | 'settings.proxy.detected' + | 'settings.proxy.directFallback' + | 'settings.proxy.failed' + | 'settings.proxy.noProxy' + | 'settings.proxy.reset' + | 'settings.proxy.status' + | 'settings.proxy.systemProxy' + | 'settings.proxy.test' + | 'settings.proxy.testFailed' + | 'settings.proxy.testSuccess' + | 'settings.proxy.testing' | 'settings.resetSection' | 'settings.resetSectionTooltip' | 'settings.search.placeholder' @@ -1022,6 +1057,38 @@ export type TranslationKeys = | 'settings.sync.verifyCode' | 'settings.title' + | 'torrentSearch.availableTorrents' + | 'torrentSearch.download' + | 'torrentSearch.error' + | 'torrentSearch.fileSelection.confirm' + | 'torrentSearch.fileSelection.loading' + | 'torrentSearch.fileSelection.noFiles' + | 'torrentSearch.fileSelection.selectAll' + | 'torrentSearch.fileSelection.selectNone' + | 'torrentSearch.fileSelection.title' + | 'torrentSearch.fileSelection.totalSelected' + | 'torrentSearch.fileSelection.videosOnly' + | 'torrentSearch.filters.all' + | 'torrentSearch.filters.anyQuality' + | 'torrentSearch.filters.date' + | 'torrentSearch.filters.movies' + | 'torrentSearch.filters.quality' + | 'torrentSearch.filters.rating' + | 'torrentSearch.filters.relevance' + | 'torrentSearch.filters.seeders' + | 'torrentSearch.filters.sort' + | 'torrentSearch.filters.tvShows' + | 'torrentSearch.loadMore' + | 'torrentSearch.loading' + | 'torrentSearch.noResults' + | 'torrentSearch.noResultsHint' + | 'torrentSearch.qualityScore' + | 'torrentSearch.retry' + | 'torrentSearch.searchPlaceholder' + | 'torrentSearch.seeders' + | 'torrentSearch.selectFiles' + | 'torrentSearch.title' + | 'tray.downloadClipboard' | 'tray.hiddenToTray' | 'tray.noRecentDownloads' diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 7f3c768..20370f8 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -330,6 +330,7 @@ }, "moreOptions": "More options", "play": "Play file", + "playWhileDownloading": "Watch while downloading", "retry": "Retry", "retryFailed": "Retry failed", "retryAll": "Retry all failed", @@ -413,7 +414,7 @@ "ascending": "Ascending", "descending": "Descending" }, - "openInApp": "Open in app", + "playWithSystemPlayer": "Play with system player", "openAuthor": "View author", "viewChannel": "View channel", "queue": { @@ -750,6 +751,7 @@ "description": "Manage required and optional components for downloading", "required": "Required", "optional": "Optional", + "podcast": "podcast", "recommended": "Recommended", "checking": "Checking...", "installed": "Installed", @@ -769,6 +771,8 @@ "quickjsDescription": "Lightweight JavaScript runtime for yt-dlp PO tokens. Alternative to Deno for YouTube bot detection bypass", "luxDescription": "Alternative downloader for Bilibili, Douyin, TikTok, and 40+ Chinese platforms", "galleryDlDescription": "Downloads images, galleries, and files from Pixiv, Danbooru, Kemono, DeviantArt, and 100+ sites", + "edge_ttsDescription": "Microsoft Edge text-to-speech engine for AI podcast narration", + "whisperDescription": "AI speech-to-text transcription (used when YouTube subtitles are unavailable)", "missingRequired": "Missing required dependencies", "missingYtdlp": "yt-dlp is not installed. Please install it from Settings → Dependencies.", "missingFfmpeg": "ffmpeg is not installed. Please install it from Settings → Dependencies.", @@ -785,6 +789,21 @@ "autoUpdateDeps": "Auto-update dependencies", "autoUpdateDepsDescription": "Automatically update dependencies when updates are available" }, + "podcast": { + "title": "Podcast", + "enabled": "Enable podcast generation", + "enabledDescription": "Generate AI-narrated podcasts from YouTube downloads", + "autoGenerate": "Auto-generate after download", + "autoGenerateDescription": "Automatically start podcast generation when a YouTube video finishes downloading", + "subtitleLang": "Transcript language", + "subtitleLangDescription": "Preferred language for YouTube transcript (e.g. en, es, fr)", + "ttsVoice": "Narration voice", + "ttsVoiceDescription": "Edge TTS voice for podcast narration", + "ttsRate": "Speech rate", + "ttsRateDescription": "Narration speed adjustment (e.g. +0%, +20%, -10%)", + "mastering": "Audio mastering", + "masteringDescription": "Apply loudness normalization and compression to the final podcast" + }, "data": { "title": "Data", "description": "Manage your app data, history, and settings", @@ -880,6 +899,21 @@ "proxyActive": "Via proxy", "directConnection": "Direct connection" }, + "proxy": { + "status": "Proxy Status", + "noProxy": "No proxy", + "systemProxy": "System proxy", + "customProxy": "Custom proxy", + "directFallback": "Proxy failed — using direct connection", + "connected": "Connected", + "detected": "Detected", + "failed": "Failed", + "test": "Test Proxy", + "testing": "Testing...", + "reset": "Reset", + "testSuccess": "Proxy is working", + "testFailed": "Proxy test failed" + }, "localServer": { "title": "Local Extension Server", "enabled": "Enable local server", @@ -1242,5 +1276,48 @@ "windowsFilenamesHint": "Replace characters not allowed on Windows" } } + }, + "player": { + "playbackError": "Failed to play this media file", + "subtitlesOff": "Off", + "subtitleDelay": "Delay", + "unsupportedFormat": "This format may not be supported by your system. Try the system player instead." + }, + "torrentSearch": { + "title": "Search Torrents", + "searchPlaceholder": "Search movies, TV shows...", + "filters": { + "all": "All", + "movies": "Movies", + "tvShows": "TV Shows", + "quality": "Quality", + "sort": "Sort by", + "anyQuality": "Any", + "relevance": "Relevance", + "seeders": "Seeders", + "rating": "Rating", + "date": "Date" + }, + "noResults": "No results found", + "noResultsHint": "Try different keywords or adjust filters", + "loading": "Searching...", + "error": "Search failed", + "retry": "Retry", + "loadMore": "Load more", + "seeders": "seeders", + "download": "Download", + "selectFiles": "Select Files", + "fileSelection": { + "title": "Select Files", + "selectAll": "Select All", + "selectNone": "Select None", + "videosOnly": "Videos Only", + "totalSelected": "Selected: {count} files ({size})", + "confirm": "Download Selected", + "loading": "Fetching file list...", + "noFiles": "No files found in torrent" + }, + "qualityScore": "Quality Score", + "availableTorrents": "Available Torrents" } } diff --git a/src/lib/i18n/locales/ru.json b/src/lib/i18n/locales/ru.json index 6b7c90c..f68157f 100644 --- a/src/lib/i18n/locales/ru.json +++ b/src/lib/i18n/locales/ru.json @@ -331,6 +331,7 @@ }, "moreOptions": "Ещё", "play": "Воспроизвести", + "playWhileDownloading": "Смотреть во время загрузки", "retry": "Повторить", "retryFailed": "Повторить неудачную", "retryAll": "Повторить все неудачные", @@ -414,7 +415,7 @@ "ascending": "По возрастанию", "descending": "По убыванию" }, - "openInApp": "Открыть в приложении", + "playWithSystemPlayer": "Воспроизвести системным плеером", "openAuthor": "Посмотреть автора", "queue": { "pause": "Приостановить очередь", @@ -776,6 +777,21 @@ "proxyActive": "Через прокси", "directConnection": "Прямое подключение" }, + "proxy": { + "status": "Статус прокси", + "noProxy": "Без прокси", + "systemProxy": "Системный прокси", + "customProxy": "Свой прокси", + "directFallback": "Прокси не работает — прямое подключение", + "connected": "Подключён", + "detected": "Обнаружен", + "failed": "Ошибка", + "test": "Проверить прокси", + "testing": "Проверяется...", + "reset": "Сбросить", + "testSuccess": "Прокси работает", + "testFailed": "Проверка прокси не удалась" + }, "localServer": { "title": "Локальный сервер расширения", "enabled": "Включить локальный сервер", @@ -822,6 +838,7 @@ "description": "Управление обязательными и дополнительными компонентами для загрузки", "required": "Обязательно", "optional": "Опционально", + "podcast": "podcast", "recommended": "Рекомендуется", "checking": "Проверка...", "installed": "Установлено", @@ -841,6 +858,8 @@ "quickjsDescription": "Легковесный JavaScript-рантайм для PO токенов yt-dlp. Альтернатива Deno для обхода защиты YouTube от ботов", "luxDescription": "Альтернативный загрузчик для Bilibili, Douyin, TikTok и 40+ китайских платформ", "galleryDlDescription": "Загружает изображения, галереи и файлы с Pixiv, Danbooru, Kemono, DeviantArt и 100+ сайтов", + "edge_ttsDescription": "Движок синтеза речи Microsoft Edge для AI-нарративов подкастов", + "whisperDescription": "AI-транскрипция речи в текст (используется когда субтитры YouTube недоступны)", "missingRequired": "Отсутствуют необходимые компоненты", "missingYtdlp": "yt-dlp не установлен. Установите его в Настройки → Зависимости.", "missingFfmpeg": "ffmpeg не установлен. Установите его в Настройки → Зависимости.", @@ -857,6 +876,21 @@ "autoUpdateDeps": "Автоматическое обновление зависимостей", "autoUpdateDepsDescription": "Автоматически обновлять зависимости при наличии обновлений" }, + "podcast": { + "title": "Подкаст", + "enabled": "Включить генерацию подкастов", + "enabledDescription": "Создавать AI-озвученные подкасты из загрузок YouTube", + "autoGenerate": "Автогенерация после загрузки", + "autoGenerateDescription": "Автоматически начинать генерацию подкаста после завершения загрузки видео с YouTube", + "subtitleLang": "Язык транскрипции", + "subtitleLangDescription": "Предпочтительный язык субтитров YouTube (напр. en, es, ru)", + "ttsVoice": "Голос озвучки", + "ttsVoiceDescription": "Голос Edge TTS для озвучки подкаста", + "ttsRate": "Скорость речи", + "ttsRateDescription": "Настройка скорости озвучки (напр. +0%, +20%, -10%)", + "mastering": "Мастеринг аудио", + "masteringDescription": "Применить нормализацию громкости и компрессию к финальному подкасту" + }, "data": { "title": "Данные", "description": "Управление данными приложения, историей и настройками", @@ -1242,5 +1276,48 @@ "windowsFilenamesHint": "Заменить символы, недопустимые в Windows" } } + }, + "player": { + "playbackError": "Не удалось воспроизвести медиафайл", + "subtitlesOff": "Выкл", + "subtitleDelay": "Задержка", + "unsupportedFormat": "Этот формат может не поддерживаться. Попробуйте системный плеер." + }, + "torrentSearch": { + "title": "Поиск торрентов", + "searchPlaceholder": "Поиск фильмов, сериалов...", + "filters": { + "all": "Все", + "movies": "Фильмы", + "tvShows": "Сериалы", + "quality": "Качество", + "sort": "Сортировка", + "anyQuality": "Любое", + "relevance": "По релевантности", + "seeders": "По сидерам", + "rating": "По рейтингу", + "date": "По дате" + }, + "noResults": "Ничего не найдено", + "noResultsHint": "Попробуйте другие ключевые слова или измените фильтры", + "loading": "Поиск...", + "error": "Ошибка поиска", + "retry": "Повторить", + "loadMore": "Загрузить ещё", + "seeders": "сидеров", + "download": "Скачать", + "selectFiles": "Выбрать файлы", + "fileSelection": { + "title": "Выбор файлов", + "selectAll": "Выбрать все", + "selectNone": "Снять выбор", + "videosOnly": "Только видео", + "totalSelected": "Выбрано: {count} файлов ({size})", + "confirm": "Скачать выбранное", + "loading": "Получение списка файлов...", + "noFiles": "В торренте не найдено файлов" + }, + "qualityScore": "Оценка качества", + "availableTorrents": "Доступные раздачи" } } diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index 1050ed6..ccde297 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -132,6 +132,7 @@ export const SECTIONS = [ { id: 'appearance', titleKey: 'settings.appearance.title', icon: 'pen_new' }, { id: 'app', titleKey: 'settings.app.title', icon: 'widgets' }, { id: 'deps', titleKey: 'settings.deps.title', icon: 'package' }, + { id: 'podcast', titleKey: 'settings.podcast.title', icon: 'headphones', platforms: ['desktop'] }, { id: 'data', titleKey: 'settings.data.title', icon: 'folder' }, ] as const; @@ -849,6 +850,14 @@ export const SETTINGS: SettingDef[] = [ platforms: ['desktop'], visible: (s) => s.proxyMode !== 'none', }, + { + type: 'custom', + key: 'proxy-status', + section: 'network', + titleKey: 'settings.proxy.status', + platforms: ['desktop'], + visible: (s) => s.proxyMode !== 'none', + }, { type: 'toggle', @@ -1367,6 +1376,16 @@ export const SETTINGS: SettingDef[] = [ visible: (s) => s.checkDepUpdates, }, + // ── Podcast ───────────────────────────────────────────────────────────────── + { + type: 'custom', + key: 'podcast-settings', + section: 'podcast', + titleKey: 'settings.podcast.title', + platforms: ['desktop'], + keywords: ['podcast', 'tts', 'narration', 'transcript', 'voice', 'claude', 'edge-tts'], + }, + { type: 'custom', key: 'data-actions', diff --git a/src/lib/stores/deps.ts b/src/lib/stores/deps.ts index f8fd5a7..2c6163e 100644 --- a/src/lib/stores/deps.ts +++ b/src/lib/stores/deps.ts @@ -15,7 +15,9 @@ export type DependencyName = | 'deno' | 'quickjs' | 'lux' - | 'gallery_dl'; + | 'gallery_dl' + | 'edge_tts' + | 'whisper'; export interface DepsState { ytdlp: DependencyStatus | null; @@ -25,6 +27,8 @@ export interface DepsState { quickjs: DependencyStatus | null; lux: DependencyStatus | null; gallery_dl: DependencyStatus | null; + edge_tts: DependencyStatus | null; + whisper: DependencyStatus | null; checking: DependencyName | null; installingDeps: Set; installProgressMap: Map; @@ -42,6 +46,8 @@ const initialState: DepsState = { quickjs: null, lux: null, gallery_dl: null, + edge_tts: null, + whisper: null, checking: null, installingDeps: new Set(), installProgressMap: new Map(), @@ -117,6 +123,22 @@ export const DEP_CONFIG: Record = { uninstallCommand: 'uninstall_gallery_dl', progressEvent: 'gallery-dl-install-progress', }, + edge_tts: { + label: 'edge-tts', + required: false, + checkCommand: 'check_edge_tts', + installCommand: 'install_edge_tts', + uninstallCommand: 'uninstall_edge_tts', + progressEvent: 'edge-tts-install-progress', + }, + whisper: { + label: 'whisper', + required: false, + checkCommand: 'check_whisper', + installCommand: 'install_whisper', + uninstallCommand: 'uninstall_whisper', + progressEvent: 'whisper-install-progress', + }, }; const installToastIds = new Map(); @@ -365,6 +387,8 @@ function createDepsStore() { checkDep('quickjs'), checkDep('lux'), checkDep('gallery_dl'), + checkDep('edge_tts'), + checkDep('whisper'), ]); update((s) => ({ ...s, hasCheckedAll: true })); logs.debug('deps', 'All dependency checks complete'); diff --git a/src/lib/stores/downloadsState.svelte.ts b/src/lib/stores/downloadsState.svelte.ts index 2ca3751..b877c9b 100644 --- a/src/lib/stores/downloadsState.svelte.ts +++ b/src/lib/stores/downloadsState.svelte.ts @@ -63,6 +63,10 @@ export interface UnifiedDownloadItem { convertedFormat?: string; source?: 'ytdlp' | 'file' | 'convert'; downloadSource?: string; + podcastPath?: string; + podcastStatus?: string; + podcastProgress?: number; + podcastStep?: string; } export type VirtualListItem = @@ -144,6 +148,10 @@ function historyToUnified(item: HistoryItem): UnifiedDownloadItem { progress: 100, convertedFormat: item.convertedFormat, downloadSource: item.downloadSource, + podcastPath: item.podcastPath ?? undefined, + podcastStatus: item.podcastStatus ?? undefined, + podcastProgress: item.podcastProgress, + podcastStep: item.podcastStep, }; } diff --git a/src/lib/stores/history.ts b/src/lib/stores/history.ts index a163749..31b8500 100644 --- a/src/lib/stores/history.ts +++ b/src/lib/stores/history.ts @@ -18,6 +18,9 @@ export type HistoryItem = Omit< | 'isFavourite' | 'isDirectory' | 'fileCount' + | 'podcastPath' + | 'podcastSubtitlePath' + | 'podcastStatus' > & { type: 'video' | 'audio' | 'image' | 'file' | 'gallery' | 'torrent'; authorUrl?: string; @@ -29,6 +32,11 @@ export type HistoryItem = Omit< isFavourite?: boolean; isDirectory?: boolean; fileCount?: number; + podcastPath?: string; + podcastSubtitlePath?: string; + podcastStatus?: string; + podcastProgress?: number; + podcastStep?: string; }; export type FilterType = @@ -76,6 +84,9 @@ function normalizeItem(raw: BindingHistoryItem): HistoryItem { isFavourite: raw.isFavourite ?? false, isDirectory: raw.isDirectory ?? false, fileCount: raw.fileCount ?? undefined, + podcastPath: raw.podcastPath ?? undefined, + podcastSubtitlePath: raw.podcastSubtitlePath ?? undefined, + podcastStatus: raw.podcastStatus ?? undefined, }; } @@ -101,6 +112,9 @@ function toBackendItem(item: HistoryItem): BindingHistoryItem { isFavourite: item.isFavourite ?? false, isDirectory: item.isDirectory ?? false, fileCount: item.fileCount ?? null, + podcastPath: item.podcastPath ?? null, + podcastSubtitlePath: item.podcastSubtitlePath ?? null, + podcastStatus: item.podcastStatus ?? null, }; } @@ -158,6 +172,65 @@ function createHistoryStore() { })); }); unlistenHistoryItem = unlisten; + + // Update history items when podcast status changes + await listen<{ historyId: string; podcastPath?: string; subtitlePath?: string }>( + 'podcast-generation-completed', + (event) => { + const { historyId, podcastPath } = event.payload; + update((state) => ({ + ...state, + items: state.items.map((item) => + item.id === historyId + ? { ...item, podcastStatus: 'completed', podcastPath: podcastPath ?? item.podcastPath, podcastProgress: undefined, podcastStep: undefined } + : item + ), + })); + } + ); + + await listen<{ historyId: string }>( + 'podcast-generation-started', + (event) => { + update((state) => ({ + ...state, + items: state.items.map((item) => + item.id === event.payload.historyId + ? { ...item, podcastStatus: 'generating', podcastProgress: 0, podcastStep: 'starting' } + : item + ), + })); + } + ); + + await listen<{ historyId: string; step: string; progress: number; error: string | null }>( + 'podcast-generation-progress', + (event) => { + const { historyId, step, progress } = event.payload; + update((state) => ({ + ...state, + items: state.items.map((item) => + item.id === historyId + ? { ...item, podcastProgress: Math.round(progress * 100), podcastStep: step } + : item + ), + })); + } + ); + + await listen<{ historyId: string; error: string }>( + 'podcast-generation-failed', + (event) => { + update((state) => ({ + ...state, + items: state.items.map((item) => + item.id === event.payload.historyId + ? { ...item, podcastStatus: 'failed', podcastProgress: undefined, podcastStep: undefined } + : item + ), + })); + } + ); }, async add(item: Omit) { diff --git a/src/lib/stores/logs.ts b/src/lib/stores/logs.ts index 90274bb..fb9467e 100644 --- a/src/lib/stores/logs.ts +++ b/src/lib/stores/logs.ts @@ -1,7 +1,7 @@ import { writable, derived, get } from 'svelte/store'; import { invoke } from '@tauri-apps/api/core'; import { browser } from '$app/environment'; -import { isAndroid } from '$lib/utils/android'; +import { isMobile } from '$lib/utils/android'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -78,14 +78,14 @@ function createLogsStore() { const { subscribe, set, update } = writable(initialState); let idCounter = 0; - const onAndroid = browser && isAndroid(); + const onMobile = browser && isMobile(); if (browser) { - update((s) => ({ ...s, isAndroidPlatform: onAndroid })); + update((s) => ({ ...s, isAndroidPlatform: onMobile })); } async function initLogging() { - if (!browser || isInitializing || onAndroid) return; + if (!browser || isInitializing || onMobile) return; const state = get({ subscribe }); if (state.sessionFile) return; @@ -108,7 +108,7 @@ function createLogsStore() { } async function writeToFile(entry: LogEntry) { - if (onAndroid) return; + if (onMobile) return; const state = get({ subscribe }); if (!state.sessionFile) return; diff --git a/src/lib/stores/navigation.ts b/src/lib/stores/navigation.ts index dc5a330..c80e92f 100644 --- a/src/lib/stores/navigation.ts +++ b/src/lib/stores/navigation.ts @@ -2,12 +2,28 @@ import { writable, derived } from 'svelte/store'; import { mediaCache, type MediaPreview } from './mediaCache'; import { getQuickThumbnail } from '$lib/utils/thumbnailUtils'; -type ViewType = 'home' | 'video' | 'playlist' | 'channel'; +type ViewType = 'home' | 'video' | 'playlist' | 'channel' | 'torrent-search' | 'torrent-detail'; + +export interface TorrentSearchSnapshot { + query: string; + contentType: string; + quality: string; + sort: string; + page: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + results: any[]; + total: number; + scrollTop: number; +} export interface ViewState { type: ViewType; url?: string; cachedData?: MediaPreview; + torrentQuery?: string; + torrentSearchState?: TorrentSearchSnapshot; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + torrentResult?: any; } interface NavigationState { @@ -71,6 +87,16 @@ function createNavigationStore() { })); }, + /** Update the current (top) view state in-place without changing the stack structure. */ + updateCurrent(patch: Partial) { + update((state) => { + const stack = [...state.stack]; + const top = { ...stack[stack.length - 1], ...patch }; + stack[stack.length - 1] = top; + return { ...state, stack }; + }); + }, + reset() { set({ stack: [{ type: 'home' }] }); }, @@ -87,6 +113,30 @@ function createNavigationStore() { this._openView('channel', url, false, previewData); }, + openTorrentSearch(query?: string) { + update((state) => { + let newStack = [...state.stack, { type: 'torrent-search' as ViewType, torrentQuery: query }]; + if (newStack.length > MAX_STACK_DEPTH) { + newStack = [newStack[0], ...newStack.slice(-(MAX_STACK_DEPTH - 1))]; + } + return { ...state, stack: newStack }; + }); + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + openTorrentDetail(result: any) { + update((state) => { + let newStack = [ + ...state.stack, + { type: 'torrent-detail' as ViewType, torrentResult: result }, + ]; + if (newStack.length > MAX_STACK_DEPTH) { + newStack = [newStack[0], ...newStack.slice(-(MAX_STACK_DEPTH - 1))]; + } + return { ...state, stack: newStack }; + }); + }, + _openView(type: ViewType, url: string, isPlaylist: boolean, previewData?: MediaPreview) { if (previewData) { mediaCache.setPreview(url, { ...previewData, isPlaylist }); diff --git a/src/lib/stores/player.svelte.ts b/src/lib/stores/player.svelte.ts new file mode 100644 index 0000000..dc5c650 --- /dev/null +++ b/src/lib/stores/player.svelte.ts @@ -0,0 +1,249 @@ +import { invoke, convertFileSrc } from '@tauri-apps/api/core'; +import { formatPlayerTime } from '$lib/utils/subtitles'; +import type { SubtitleCue } from '$lib/utils/subtitles'; +import { openFile } from '$lib/utils/platform'; +import { isMobile } from '$lib/utils/android'; + +export type { SubtitleCue }; + +export interface SubtitleTrack { + path: string; + label: string; + format: string; + cues?: SubtitleCue[]; // lazy-loaded +} + +export interface PlayerOpenOptions { + filePath: string; + mediaType: 'video' | 'audio'; + title?: string; + thumbnail?: string; + /** Always open in the system player (VLC, etc.) instead of in-app. */ + useSystemPlayer?: boolean; +} + +export class PlayerState { + // Core state + open = $state(false); + filePath = $state(null); + mediaSrc = $state(null); // convertFileSrc result + mediaType = $state<'video' | 'audio'>('video'); + title = $state(''); + thumbnail = $state(null); + + // Playback state + playing = $state(false); + currentTime = $state(0); + duration = $state(0); + buffered = $state(0); // buffered time in seconds + volume = $state(1); + muted = $state(false); + playbackRate = $state(1); + error = $state(null); + + // Subtitles + subtitleTracks = $state([]); + activeSubtitleIndex = $state(-1); // -1 = off + subtitleDelay = $state(0); // seconds + + // UI state + controlsVisible = $state(true); + + // Internal + private _hideTimer: ReturnType | null = null; + private _controlsLocked = false; // when hovering over controls or menu open + + // Computed + progress = $derived(this.duration > 0 ? this.currentTime / this.duration : 0); + formattedTime = $derived.by(() => { + return `${formatPlayerTime(this.currentTime)} / ${formatPlayerTime(this.duration)}`; + }); + + // Extensions natively supported by HTML5