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 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