Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 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).
- **Accounts view** (PRD §6.4, PRD-v2 §P1.4, task 23): full Accounts management UI replacing the previous `PlaceholderView`. Header tabs (`All` / `Debrid` / `Premium` / `Free`) drive a category filter on top of the SQLite-backed `account_list` query, with the `(filter, all)` count rendered next to each label. Each row exposes the service, username, account type, derived status badge (`Active` / `Expired` / `Disabled` / `Unverified`), an aria-labelled traffic progress bar (used / total formatted via `formatBytes`), `valid_until` and `last_validated` columns, an enable/disable `Switch`, an inline `Validate` button, and a kebab menu with `Edit` / `Delete`. The new `AddAccountDialog` validates non-empty service / username / password before submission. `EditAccountDialog` posts a partial `AccountPatch` (skips fields that did not change so the keyring rotation only fires when the password field is filled). The `Delete` action honours the existing `settings.confirm_delete` toggle: when enabled it pops the new `DeleteAccountDialog` (translated description naming the row), otherwise it deletes immediately. `ImportAccountsDialog` calls `tauri-plugin-dialog`'s file-pick to anchor the encrypted bundle path, prompts for the passphrase, then calls `account_import` and invalidates the list cache so freshly-imported rows appear without a manual refresh; `ExportAccountsDialog` requires the user to confirm the passphrase, opens the native `save` dialog for the destination, and reports the row count via toast. Nine new Tauri IPC commands wire the existing `CommandBus` / `QueryBus` handlers (tasks 21, 22) to the frontend: `account_add`, `account_update`, `account_delete`, `account_validate`, `account_export`, `account_import`, `account_list`, `account_get`, `account_traffic_get`, all registered in `invoke_handler!` and re-exported from `lib.rs`. The runtime now wires `SqliteAccountRepo` to both buses and provides the `KeyringAccountStore` + `AesGcmPbkdf2Codec` adapters to the `CommandBus`. Adds `useAccountsQuery` (TanStack Query, 30 s `staleTime`) and `accountQueries` cache key factory. New i18n namespace `accounts.*` covers titles, status badges, dialog copy and toast messages in `en.json` + `fr.json`. 13 Vitest tests cover render, empty state, category filter, add → IPC → toast flow, delete → confirm → IPC, export trigger disabled when no accounts, export with passphrase, import with file picker. `AccountValidator` is intentionally not wired in this commit — `account_validate` returns the configured `Validation` error until the first hoster plugin lands (task 38), letting the UI render the failure toast without crashing. The "volume per account" stat from the requirements list is deferred until `history` gains an `account_id` column.
Expand Down
15 changes: 8 additions & 7 deletions src-tauri/src/adapters/driven/event/tauri_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,14 @@ mod tests {
event_name(&DomainEvent::PluginUnloaded { name: "p".into() }),
"plugin-unloaded"
);
assert_eq!(
event_name(&DomainEvent::PackageCreated {
id: 1,
name: "pkg".into()
}),
"package-created"
);
let evt = DomainEvent::PackageCreated {
id: crate::domain::model::package::PackageId::new("pkg-1"),
name: "pkg".into(),
};
assert_eq!(event_name(&evt), "package-created");
let (_, payload) = to_tauri_event(&evt);
assert_eq!(payload["id"], "pkg-1");
assert_eq!(payload["name"], "pkg");
}

#[test]
Expand Down
104 changes: 104 additions & 0 deletions src-tauri/src/adapters/driven/sqlite/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,110 @@ mod tests {
assert!(other.is_ok(), "different service must be allowed");
}

#[tokio::test]
async fn test_packages_migration_applies_cleanly_on_existing_db() {
// Stand up a DB at the schema state immediately before the
// packages migration (6 migrations applied), seed prior tables,
// then run the remaining migrations and verify the new schema
// exists and existing data is preserved.
let sqlite_opts = sea_orm::sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
.unwrap()
.pragma("foreign_keys", "ON");
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(sqlite_opts)
.await
.unwrap();
let db = sea_orm::SqlxSqliteConnector::from_sqlx_sqlite_pool(pool);

Migrator::up(&db, Some(6))
.await
.expect("first 6 migrations");

// Seed a download row that must survive the migration.
db.execute(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"INSERT INTO downloads (id, url, file_name, state, priority, queue_position, downloaded_bytes, speed_bytes_per_sec, retry_count, max_retries, segments_count, source_hostname, protocol, resume_supported, destination_path, created_at, updated_at) VALUES (1, 'https://example.com/f.zip', 'f.zip', 'Queued', 5, 0, 0, 0, 0, 5, 1, 'example.com', 'https', 0, '/tmp', 1, 1)"
.to_string(),
))
.await
.expect("seed download");

Migrator::up(&db, None).await.expect("remaining migrations");

// packages table replaced with the new schema.
let cols = db
.query_all(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"PRAGMA table_info(packages)".to_string(),
))
.await
.unwrap();
let names: Vec<String> = cols
.iter()
.map(|r| r.try_get_by_index::<String>(1).unwrap())
.collect();
for required in [
"id",
"name",
"source_type",
"folder_path",
"password",
"auto_extract",
"priority",
"created_at",
] {
assert!(
names.iter().any(|n| n == required),
"packages must have column '{required}', got: {names:?}"
);
}

// downloads gained the package_id FK column and its index.
let dl_cols = db
.query_all(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"PRAGMA table_info(downloads)".to_string(),
))
.await
.unwrap();
let dl_names: Vec<String> = dl_cols
.iter()
.map(|r| r.try_get_by_index::<String>(1).unwrap())
.collect();
assert!(
dl_names.iter().any(|n| n == "package_id"),
"downloads must expose 'package_id', got: {dl_names:?}"
);

let indexes = db
.query_all(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='downloads'"
.to_string(),
))
.await
.unwrap();
let idx_names: Vec<String> = indexes
.iter()
.map(|r| r.try_get_by_index::<String>(0).unwrap())
.collect();
assert!(
idx_names.iter().any(|n| n == "idx_downloads_package"),
"expected idx_downloads_package, got: {idx_names:?}"
);

// Existing data preserved.
let downloads = db
.query_all(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"SELECT id FROM downloads".to_string(),
))
.await
.unwrap();
assert_eq!(downloads.len(), 1, "existing download row preserved");
}

#[tokio::test]
async fn test_wal_mode_enabled() {
let test_id = std::process::id();
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/adapters/driven/sqlite/entities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ pub mod account;
pub mod download;
pub mod download_segment;
pub mod history;
pub mod package;
pub mod plugin_config;
83 changes: 83 additions & 0 deletions src-tauri/src/adapters/driven/sqlite/entities/package.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use sea_orm::entity::prelude::*;

use crate::domain::error::DomainError;
use crate::domain::model::package::{Package, PackageId, PackageSourceType};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "packages")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub source_type: String,
pub folder_path: Option<String>,
pub password: Option<String>,
pub auto_extract: i32,
pub priority: i32,
pub created_at: i64,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

impl Model {
pub fn into_domain(self) -> Result<Package, DomainError> {
let source_type: PackageSourceType = self.source_type.parse()?;
let auto_extract = match self.auto_extract {
0 => false,
1 => true,
other => {
return Err(DomainError::ValidationError(format!(
"package {}: auto_extract {other} out of bool range",
self.id
)));
}
};
let priority = u8::try_from(self.priority).map_err(|_| {
DomainError::ValidationError(format!(
"package {}: priority {} out of u8 range",
self.id, self.priority
))
})?;
let created_at = u64::try_from(self.created_at).map_err(|_| {
DomainError::ValidationError(format!(
"package {}: created_at {} out of u64 range",
self.id, self.created_at
))
})?;
Package::reconstruct(
PackageId::new(self.id),
self.name,
source_type,
self.folder_path,
self.password,
auto_extract,
priority,
created_at,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

impl ActiveModel {
pub fn from_domain(package: &Package) -> Result<Self, DomainError> {
use sea_orm::ActiveValue::Set;

let id_str = package.id().as_str().to_string();
let created_at = i64::try_from(package.created_at()).map_err(|_| {
DomainError::ValidationError(format!("package {id_str}: created_at exceeds i64::MAX"))
})?;

Ok(Self {
id: Set(id_str),
name: Set(package.name().to_string()),
source_type: Set(package.source_type().to_string()),
folder_path: Set(package.folder_path().map(str::to_string)),
password: Set(package.password().map(str::to_string)),
auto_extract: Set(if package.auto_extract() { 1 } else { 0 }),
priority: Set(i32::from(package.priority())),
created_at: Set(created_at),
})
}
}
Loading
Loading