Skip to content
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 queries** (PRD §6.3, PRD-v2 §P1.9, task 28): three CQRS query handlers (`list_packages`, `get_package`, `list_package_downloads`) wired through the `QueryBus` builder via a new `with_package_read_repo` setter. New driven port `PackageReadRepository` (`find_packages` / `find_package_by_id` / `find_package_downloads`) and `SqlitePackageReadRepo` adapter compute every package statistic (`downloads_count`, `total_bytes`, `downloaded_bytes`, `progress_percent`, `all_completed`) in a single `LEFT JOIN packages → downloads` with `GROUP BY p.id` so listing N packages costs one round-trip instead of `N+1`. `PackageFilter { source_type?, name_q? }` AND-combines filters: `source_type` is an exact match against the lowercase wire form (`container` / `playlist` / `manual` / `split_archive`) and is delegated to the SQL `WHERE` clause, while `name_q` is a case-insensitive substring (`LOWER(p.name) LIKE %?%`) so the UI can fuzzy-search package titles. Blank / whitespace-only `name_q` is treated as "no filter" so the UI can blindly forward an empty input. Aggregate progress mirrors the per-download formula (`Completed` always reports 100 even when `downloaded < total`, unknown total reports 0, otherwise `downloaded / total * 100` rounded to 1 dp); `all_completed` flips to `true` only when the package has at least one member and every member is in the `Completed` state. New read model `PackageViewDto` (`#[serde(rename_all = "camelCase")]`) re-exposes the aggregated `PackageView` to the frontend with no password / credential reference field, by construction. `list_package_downloads(id)` reuses the existing `DownloadView` so the React layer can render member rows with the same component as the main downloads list. Three Tauri IPC commands (`package_list`, `package_get`, `package_list_downloads`) registered in `invoke_handler!` and re-exported from `lib.rs`; `package_list` validates an unknown `source_type` argument up-front so callers see "invalid package source type" instead of an empty result. The runtime now wires `SqlitePackageReadRepo` to the `QueryBus` via `with_package_read_repo`. Twenty-three new unit + integration tests cover the three acceptance criteria (SQL-side stats with no N+1, fuzzy `name_q`, in-memory SQLite fixtures): aggregate vs empty package, mixed-state aggregation, all-completed flip, unknown total treated as zero, deterministic ordering by `(created_at, id)`, exact `source_type` filter, case-insensitive substring `name_q`, AND combination, blank `name_q` ignored, missing-id `None`, member ordering by `queue_position` then `id`, no leak across packages, validation errors when the read repo is missing, and DTO camelCase + no-password serialization assertions. Unblocks task 29 (Vue Packages React).
- **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.
Expand Down
19 changes: 4 additions & 15 deletions src-tauri/src/adapters/driven/sqlite/download_read_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ use crate::domain::ports::driven::download_read_repository::DownloadReadReposito

use super::entities::{download, download_segment};
use super::util::{
MIN_PLAUSIBLE_UNIX_MS, block_on, infer_timestamp_ms_from_download_id,
inferred_download_created_at_order_expr, map_db_err, safe_u32, safe_u64,
block_on, inferred_download_created_at_order_expr, map_db_err, resolve_download_created_at,
safe_u32, safe_u64,
};

pub struct SqliteDownloadReadRepo {
Expand All @@ -27,19 +27,7 @@ impl SqliteDownloadReadRepo {
}

fn read_created_at(model: &download::Model) -> u64 {
let created_at = safe_u64(model.created_at);
if created_at > 0 {
created_at
} else if let Some(inferred) = infer_timestamp_ms_from_download_id(model.id) {
inferred
} else {
let updated_at = safe_u64(model.updated_at);
if updated_at > 0 {
updated_at
} else {
MIN_PLAUSIBLE_UNIX_MS
}
}
resolve_download_created_at(model.created_at, model.id, model.updated_at)
}

/// Compute progress percent rounded to one decimal place.
Expand Down Expand Up @@ -354,6 +342,7 @@ impl DownloadReadRepository for SqliteDownloadReadRepo {
mod tests {
use super::*;
use crate::adapters::driven::sqlite::download_repo::SqliteDownloadRepo;
use crate::adapters::driven::sqlite::util::MIN_PLAUSIBLE_UNIX_MS;
use crate::domain::model::download::{Download, Url};
use crate::domain::model::segment::SegmentState;
use crate::domain::ports::driven::download_repository::DownloadRepository;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/adapters/driven/sqlite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod download_repo;
pub mod entities;
pub mod history_repo;
pub mod migrations;
pub mod package_read_repo;
pub mod package_repo;
pub mod plugin_config_repo;
pub mod progress_bridge;
Expand Down
Loading
Loading