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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Packages commands** (PRD §6.3, PRD-v2 §P1.8, task 27): nine command handlers wired to the new `PackageRepository` and the existing `CredentialStore` via a `with_package_repo` builder on the `CommandBus`. `create_package(name, source_type, folder_path?)` generates a UUID v4 id, validates the trimmed name is non-empty, persists the aggregate and emits `DomainEvent::PackageCreated`. `update_package(id, PackagePatch)` applies a partial mutation (rename / folder / priority / auto_extract) — `folder_path` accepts `Some(Some(path))` to set, `Some(None)` to clear, `None` to leave untouched, so the frontend can distinguish "set to empty" from "unchanged". `delete_package(id, delete_downloads)` runs in two cascade modes: `false` (default) detaches every member via `PackageRepository::detach_download` so the downloads survive as standalone rows, `true` removes each member through the existing `RemoveDownloadCommand` (deletes engine state, files, and the SQLite row) before dropping the package row; the keyring entry under `vortex.package.<id>` is best-effort cleaned in both cases. `set_package_password(id, Option<String>)` stores the secret in the OS keyring via `CredentialStore::store("vortex.package.<id>", …)` and only writes the keyring service key (never the plaintext) onto the `packages.password` SQLite column as a marker; passing `None` clears both the keyring entry and the marker idempotently, and an explicit empty string is rejected as a validation error so callers cannot ambiguously "clear by emptying". `set_package_priority(id, priority)` validates the value through the domain `Priority` aggregate up-front (so a bad input never produces partial cascade state), persists the new value on the package row and then loops through every member returned by `list_downloads` to update each download's `priority` and emit a `DownloadPrioritySet` event per child — dangling FK members (download row missing) are skipped with a debug log instead of aborting the cascade. `move_package_to_folder(id, new_folder)` updates the package row's `folder_path` and re-uses task 13's `ChangeDirectoryCommand` for each member; per-child failures are collected into a `PackageMoveOutcome { moved, failed }` and surfaced to the frontend so partial failures don't roll back the package update. `toggle_package_auto_extract(id)` flips the flag and returns the new state. `add_download_to_package(package_id, download_id)` and `remove_download_from_package(package_id, download_id)` set / clear the FK on `downloads.package_id` via the new `attach_download` / `detach_download` trait methods; both validate the package exists first so the IPC layer surfaces a clean `NotFound` for stale callers, and `attach_download` also requires the download to exist (re-attaching is idempotent). The `PackageRepository` trait gains `attach_download(&PackageId, DownloadId) -> Result<(), DomainError>` (returns `NotFound` when the download row is missing) and `detach_download(DownloadId) -> Result<(), DomainError>` (idempotent, no-op on missing row); the `SqlitePackageRepo` adapter implements both via raw `UPDATE downloads SET package_id = ? WHERE id = ?` so the FK singleton semantics match the existing `ON DELETE SET NULL` migration. Two new `DomainEvent` variants — `PackageUpdated { id }` (rename / folder / priority / password / auto_extract / membership change) and `PackageDeleted { id, delete_downloads }` (the flag mirrors the command so subscribers distinguish "package detached, downloads kept" from "everything gone" without re-reading the repo) — are forwarded by the Tauri bridge as `package-updated` and `package-deleted` (camelCase `deleteDownloads`). Nine Tauri IPC commands (`package_create`, `package_update`, `package_delete`, `package_set_password`, `package_set_priority`, `package_move_to_folder`, `package_toggle_auto_extract`, `package_add_download`, `package_remove_download`) registered in `invoke_handler!` and re-exported from `lib.rs`, with a new `PackagePatchDto` deserialiser whose `folder_path: Option<Option<String>>` round-trips the three-state semantics from the frontend. The runtime now wires `SqlitePackageRepo` into the `CommandBus` via `with_package_repo`. Forty-three new unit tests against `InMemoryPackageRepo` / `InMemoryDownloadRepo` / `InMemoryCredentialStore` mocks cover every acceptance criterion: CRUD round-trip, cascade-delete vs detach, keyring-only password storage (the `packages.password` column never holds the plaintext), per-child `DownloadPrioritySet` cascade with an explicit count assertion, partial-failure outcome shape on bulk move, idempotent attach/detach, dangling-FK skip on the priority cascade, and validation paths for blank names, empty-string passwords, invalid priorities, missing repos, and unknown ids. Adapter coverage hovers at 95-99 % per file (well above the 85 % threshold). Five SQLite-level tests pin the new attach/detach semantics on a real in-memory DB. Unblocks task 29 (Vue Packages React).
- **Packages persistence** (PRD §6.3, PRD-v2 §P1.7, task 26): SQLite `packages` table (migration `m20260429_000007`) with the schema mandated by PRD-v2 §8 P1 — `id TEXT PRIMARY KEY`, `name`, `source_type` (`container` / `playlist` / `manual` / `split_archive`), nullable `folder_path`, nullable `password` (keyring ref), `auto_extract` (default `1`), `priority` (default `5`), `created_at`. The legacy stub `packages` table from migration 1 (BIGINT id, name only, never wired) is dropped and recreated. The migration also adds `downloads.package_id TEXT REFERENCES packages(id) ON DELETE SET NULL` plus the `idx_downloads_package` index, so deleting a package detaches its members without losing the rows. New `PackageRepository` driven port (`save` / `find_by_id` / `list` / `delete` / `list_downloads`) and `SqlitePackageRepo` adapter with sea-orm entity + `from_domain` / `into_domain` converters. Upserts preserve the original `created_at` so list ordering stays stable across re-saves; `list` orders by `(created_at asc, id asc)`; `list_downloads` orders by `queue_position asc, id asc` so the caller surfaces members in scheduling order. Domain `Package` aggregate gained the new persisted fields plus a `PackageId(String)` typed wrapper and a `PackageSourceType` enum (round-trips via `Display` / `FromStr`); `download_ids` stays in-memory (the FK on `downloads.package_id` is the source of truth on disk). `DomainEvent::PackageCreated.id` switches from `u64` to `PackageId` to match. Twenty-one new unit tests cover the four acceptance criteria (fresh + existing-DB migration, FK `ON DELETE SET NULL` semantics, full-field round-trip, ≥85 % adapter coverage), plus error paths (unknown `source_type`, priority overflow, `created_at` overflow), source-type round-trip per variant, optional fields persisting as `NULL`, `list_downloads` filtering and ordering, and the `InMemoryPackageRepository` mock used by future command / query handlers. Unblocks tasks 27 (Commands Packages), 28 (Queries Packages), 30 (auto-grouping playlist) and 31 (auto-grouping split archives).
- **Account rotation on quota** (PRD §6.4, PRD-v2 §P1.6, task 25): new `AccountRotator` application service detects quota exhaustion (HTTP `429` or `traffic_left` below a caller-supplied threshold via `is_quota_signal`), pulls the offending account out of rotation for a hoster-specific cooldown via `mark_exhausted(account_id, service_name, ttl_secs)`, and asks the existing `AccountSelector` for the next best candidate via `next_account(service, strategy) -> NextAccountOutcome`. The outcome enum distinguishes three caller-actionable states: `Picked(Account)` (use the credential), `NoneAvailable` (no enabled / non-expired account configured — fall back to the free path or surface a UI hint), and `AllExhausted { next_eligible_at_ms }` (every eligible account is on cooldown — stall the download in `Waiting` until the earliest deadline so the scheduler can retry without busy-looping). `NextAccountOutcome::error_message(service_name)` returns the PRD §6.4 standard wording (`"All accounts exhausted for {service}"` / `"No account available for {service}"`) so callers attaching the error to `Download.error` stay uniform across hosters. Cooldown lifecycle: `record_traffic_refresh(account_id, traffic_left, threshold)` clears the marker only when the upstream confirms `traffic_left >= threshold` (a `None` observation or below-threshold value leaves the marker in place so a hoster without a traffic counter cannot silently undo every `mark_exhausted`); `clear_exhausted(account_id)` is the explicit reset path, idempotent for unknown ids; expired entries are pruned lazily on the next `next_account` call so no background sweeper is needed. The exhaustion map sits behind a `std::sync::Mutex` in `AccountRotator` (intentionally NOT persisted in SQLite — a process restart wipes the cooldown, which is the desired behaviour for the 5-to-15-minute hoster reset window); a poisoned mutex surfaces as `AppError::Validation("exhausted accounts mutex poisoned")` so callers can distinguish "no candidate" from "internal state corrupted", matching `AccountSelector::pick_round_robin`'s contract. The `AllExhausted` deadline restricts its scan to accounts that actually belong to the queried service so a parallel-service entry cannot leak its cooldown into an unrelated answer. New `AccountSelector::select_best_excluding(service, strategy, exclude_ids)` extends the existing `select_best` with an exclude list (no caching, no behaviour change for empty `exclude`); the prior signature is now a thin wrapper. New `DomainEvent::AccountExhausted { id, service_name, exhausted_until_ms }` forwarded by the Tauri bridge as `account-exhausted` (camelCase `exhaustedUntilMs`). New transient `Account::exhausted_until: Option<u64>` field with `mark_exhausted` / `clear_exhausted` / `is_exhausted(now_ms)` / `exhausted_until()` methods — the field is reset to `None` by `Account::reconstruct` so the rotator's in-memory map remains the single source of truth even though SQLite roundtrips drop the marker. New `CommandBus::with_account_rotator` / `account_rotator()` builder & accessor wires the rotator alongside the existing `AccountSelector`. Twenty-two new unit tests cover the four acceptance criteria (`429 → next account`, `all exhausted → AllExhausted with earliest deadline`, `traffic-refresh clears cooldown when above threshold`, full rotator + selector-exclude integration), plus edge cases: zero-TTL no-op, deadline-exclusive equality, cross-service deadline isolation, `None`-traffic refresh keeps cooldown, `404` / `500` ignored by `is_quota_signal`, threshold-equality below-but-not-above, idempotent `clear_exhausted`, lazy cooldown expiry surfaces an account back into rotation. Unblocks task 38 (vortex-mod-1fichier free + premium) which is the first hoster to wire the rotation flow.
- **Account auto-selection** (PRD §6.4, PRD-v2 §P1.5, task 24): new `AccountSelector` application service picks the best `Account` per service for the live `AppConfig::account_selection_strategy`. Three strategies: `BestTraffic` (default, ranks `enabled → not expired → most traffic_left → most recent last_validated → smallest id` with `Unlimited` traffic ranking above any finite value), `RoundRobin` (per-service cursor over enabled non-expired candidates ordered by id; a poisoned cursor mutex now surfaces as `AppError::Validation("round-robin cursor mutex poisoned")` so it stays distinguishable from "no eligible account"), and `Manual` (fallback alias of `BestTraffic` until pinning UI lands). The selector reads `AccountRepository::list_by_service` on every call instead of caching: the previous event-driven invalidation could read stale rows when `select_best` landed between `bus.publish(AccountUpdated)` and the spawned `TokioEventBus` subscriber firing. New `CommandBus::resolve_account_for(service_name)` exposes the selector to download / link-grabber flows; failures from `ConfigStore::get_config()` propagate via `?` instead of being swallowed by a default-strategy fallback. New `DomainEvent::NoAccountAvailable { service_name }` (emitted when no candidate passes the filter) and `DomainEvent::AccountSelected { id, service_name, strategy }` (emitted whenever a pick is made), both forwarded by the Tauri bridge as `no-account-available` / `account-selected`. New `account_selection_strategy` field on `AppConfig` / `ConfigPatch` / `apply_patch` plus the matching IPC and TOML serialisation paths (snake_case `"best_traffic" | "round_robin" | "manual"`). The IPC layer rejects unknown strategy values: `ConfigPatchDto` → `ConfigPatch` is `TryFrom` and `settings_update` surfaces `invalid account selection strategy: …` instead of silently ignoring a typo. The TOML store mirrors the rule: `ConfigDto` → `AppConfig` is also `TryFrom`, so a hand-edited `config.toml` carrying an unknown strategy value now fails fast with `StorageError("invalid config: …")` instead of silently coercing to `best_traffic`. Backward compat is preserved: a legacy `config.toml` written before this field existed deserializes the missing key as the empty string via `#[serde(default)]`, and that empty case is treated as `BestTraffic` so an upgrade does not break startup. Eighteen unit tests cover the four acceptance criteria (3-account scenario, all-expired surface, comparative ranking table, round-robin alternance), repo-fresh selection, poisoned-cursor surfacing, IPC rejection of unknown strategies, TOML-store rejection of unknown persisted strategies, legacy-config (missing strategy field) backward compat, and config-error propagation. Unblocks task 25 (rotation auto sur quota).
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/adapters/driven/event/tauri_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ fn event_name(event: &DomainEvent) -> &'static str {
DomainEvent::PluginLoaded { .. } => "plugin-loaded",
DomainEvent::PluginUnloaded { .. } => "plugin-unloaded",
DomainEvent::PackageCreated { .. } => "package-created",
DomainEvent::PackageUpdated { .. } => "package-updated",
DomainEvent::PackageDeleted { .. } => "package-deleted",
DomainEvent::ClipboardUrlDetected { .. } => "clipboard-url-detected",
DomainEvent::SettingsUpdated => "settings-updated",
DomainEvent::ChecksumVerified { .. } => "checksum-verified",
Expand Down Expand Up @@ -146,6 +148,11 @@ fn event_payload(event: &DomainEvent) -> serde_json::Value {
}
DomainEvent::PluginUnloaded { name } => json!({ "name": name }),
DomainEvent::PackageCreated { id, name } => json!({ "id": id.to_string(), "name": name }),
DomainEvent::PackageUpdated { id } => json!({ "id": id.to_string() }),
DomainEvent::PackageDeleted {
id,
delete_downloads,
} => json!({ "id": id.to_string(), "deleteDownloads": delete_downloads }),
DomainEvent::ClipboardUrlDetected { urls } => json!({ "urls": urls }),
DomainEvent::SettingsUpdated => json!({}),
DomainEvent::ChecksumVerified {
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/adapters/driven/logging/download_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
| DomainEvent::PluginLoaded { .. }
| DomainEvent::PluginUnloaded { .. }
| DomainEvent::PackageCreated { .. }
| DomainEvent::PackageUpdated { .. }
| DomainEvent::PackageDeleted { .. }
| DomainEvent::ClipboardUrlDetected { .. }
| DomainEvent::SettingsUpdated
| DomainEvent::AccountAdded { .. }
Expand Down
Loading
Loading