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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Auto-grouping split archives → package** (scope `link`, sprint task 31, PRD-v2 §P1.12 / PRD §6.3): new `application/services/split_archive_grouper.rs` clusters resolved Link-Grabber URLs that match split-archive patterns (`*.partNN.rar`, `*.rNN` plus the legacy terminal `.rar` header, `*.7z.NNN`, `*.zip.NNN`, `*.tar.{gz,bz2,xz}.NNN`) by base name + format and creates one `Package` per cluster with `source_type = SplitArchive` and `external_id = "split-archive:{format_tag}:{base}"` (format-namespaced so a RAR set and a ZIP set sharing a base name produce two distinct packages). New `GroupSplitArchivesCommand` handler + `link_group_split_archives` Tauri IPC mirror the playlist grouper flow, capped at `MAX_LINKS = 500` per call to mirror `MAX_URLS` in `resolve_links` and bound the cluster-state allocation; `MAX_PART_INDEX = 10_000` rejects absurd part suffixes so `compute_missing_parts` cannot be coerced into a multi-billion-step iteration. The minimum-parts gate counts distinct `part_num`s rather than raw link count, so duplicate mirrors of one volume cannot satisfy the singleton guard. Gaps in the part numbering emit `DomainEvent::SplitArchiveIncomplete { package_id, base_name, missing_parts }` (forwarded to the frontend as `split-archive-incomplete`) so the UI can warn the user before extraction blocks; legacy RAR completeness now treats the terminal `.rar` header as part 0 so a missing header is reported instead of silently dropped. Frontend `SplitArchiveLinkInput` / `SplitArchiveGroupResult` types added in `src/types/media.ts`. 31 service unit tests (matcher fixtures + grouping integration + DoS caps + legacy-header coverage + distinct-parts gate) + 3 handler tests cover the contract.

- **Shared grouper lock** (scope `core`): new `application/services/group_lock` module factors out the OnceLock + poisoned-mutex-recovery pair that `PlaylistGrouper` and `SplitArchiveGrouper` were each rolling locally. Both groupers now scope the lock to the find-then-save window and release it before publishing `PackageCreated` / `SplitArchiveIncomplete` so synchronous event-bus subscribers cannot block other concurrent grouping calls.

- **CodSpeed performance benchmarks** (CI): new `domain_benchmarks` Criterion harness in `src-tauri/benches/domain_benchmarks.rs` exercising the pure `domain::model::config` helpers (`apply_patch`, `normalize_link_check_parallelism`, `normalize_max_concurrent`). Wired through a new `.github/workflows/codspeed.yml` workflow that runs the benches under CodSpeed on every PR, providing automated perf-regression tracking for the domain layer. `criterion` + `codspeed-criterion-compat` added as dev-dependencies; `[[bench]]` target declared with `harness = false` so Criterion drives the run.

- **CI hardening** (scope `ci`): new GitHub Actions jobs `secrets-scan` (rejects `.env`/`.pem`/`.key`/etc. tracked files plus `AKIA*`/`sk-ant-*`/`ghp_*`/`AIza*` API key patterns in tracked content), `forbidden-tools` (rejects `pnpm-lock.yaml`/`yarn.lock`, `.eslintrc*`/`biome.json*`/`.prettierrc*` configs, and any `#[allow(dead_code|unused|...)]` / `@ts-ignore` / `@ts-expect-error` / `oxlint-disable` comment), and `changelog-check` (PR-only — fails when `*.rs` / `*.ts` / `*.tsx` change without a matching `CHANGELOG.md` edit). Existing `cargo audit` swapped for `cargo deny check` covering advisories + licenses + bans + sources via the new `deny.toml`. Frontend job now runs `oxfmt --check`, `knip --reporter compact`, and uploads `coverage/` as an artifact. New `mutants.yml` workflow runs `cargo mutants --in-diff` on PRs touching `src-tauri/**` and a 4-shard nightly sweep on `main`.
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/adapters/driven/event/tauri_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ fn event_name(event: &DomainEvent) -> &'static str {
DomainEvent::PackageCreated { .. } => "package-created",
DomainEvent::PackageUpdated { .. } => "package-updated",
DomainEvent::PackageDeleted { .. } => "package-deleted",
DomainEvent::SplitArchiveIncomplete { .. } => "split-archive-incomplete",
DomainEvent::ClipboardUrlDetected { .. } => "clipboard-url-detected",
DomainEvent::SettingsUpdated => "settings-updated",
DomainEvent::ChecksumVerified { .. } => "checksum-verified",
Expand Down Expand Up @@ -235,6 +236,17 @@ fn event_payload(event: &DomainEvent) -> serde_json::Value {
DomainEvent::LinkStatusUpdated { url, status } => {
json!({ "url": url, "status": link_status_payload(status) })
}
DomainEvent::SplitArchiveIncomplete {
package_id,
base_name,
missing_parts,
} => {
json!({
"packageId": package_id.to_string(),
"baseName": base_name,
"missingParts": missing_parts,
})
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/adapters/driven/logging/download_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) {
| DomainEvent::NoAccountAvailable { .. }
| DomainEvent::AccountSelected { .. }
| DomainEvent::AccountExhausted { .. }
| DomainEvent::LinkStatusUpdated { .. } => {}
| DomainEvent::LinkStatusUpdated { .. }
| DomainEvent::SplitArchiveIncomplete { .. } => {}
}
}

Expand Down
67 changes: 67 additions & 0 deletions src-tauri/src/adapters/driving/tauri_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,73 @@ pub async fn link_group_playlists(
.collect())
}

/// Inbound IPC payload for [`link_group_split_archives`]. Mirrors
/// [`crate::application::services::SplitArchiveLink`] in camelCase.
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SplitArchiveLinkInputDto {
pub url: String,
pub filename: String,
}

/// IPC return shape for [`link_group_split_archives`]. Mirrors
/// [`crate::application::services::SplitArchiveGroupResult`] in
/// camelCase so the Link Grabber preview can render the "Will create
/// package X with N parts (Y missing)" banner before Start.
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SplitArchiveGroupResultDto {
pub package_id: String,
pub base_name: String,
pub package_name: String,
pub created: bool,
pub urls: Vec<String>,
pub missing_parts: Vec<String>,
}

#[tauri::command]
pub async fn link_group_split_archives(
state: State<'_, AppState>,
links: Vec<SplitArchiveLinkInputDto>,
) -> Result<Vec<SplitArchiveGroupResultDto>, String> {
use crate::application::commands::GroupSplitArchivesCommand;
use crate::application::services::SplitArchiveLink;

let cmd = GroupSplitArchivesCommand {
links: links
.into_iter()
.map(|l| SplitArchiveLink {
url: l.url,
filename: l.filename,
})
.collect(),
};

let results = state
.command_bus
.handle_group_split_archives(cmd)
.await
.map_err(|e| match &e {
AppError::Validation(msg) => msg.clone(),
other => {
tracing::error!(error = %other, "split-archive grouping failed");
"Failed to group split archives".to_string()
}
})?;

Ok(results
.into_iter()
.map(|r| SplitArchiveGroupResultDto {
package_id: r.package_id.as_str().to_string(),
base_name: r.base_name,
package_name: r.package_name,
created: r.created,
urls: r.urls,
missing_parts: r.missing_parts,
})
.collect())
}

// ── Clipboard ────────────────────────────────────────────────────────

#[tauri::command]
Expand Down
128 changes: 128 additions & 0 deletions src-tauri/src/application/commands/group_split_archives.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Handler for [`GroupSplitArchivesCommand`](super::GroupSplitArchivesCommand).
//!
//! Routes the request through [`SplitArchiveGrouper`] so the same
//! idempotent natural-key logic backs both the IPC entry-point and any
//! future internal caller (e.g. the Link Grabber commit flow once it
//! learns to bundle split-archive links). The handler does NOT attach
//! downloads itself — it only ensures one [`Package`](crate::domain::model::package::Package)
//! exists per detected base name. Attaching member downloads is the
//! caller's responsibility once the resolved links have produced
//! [`DownloadId`](crate::domain::model::download::DownloadId)s.

use std::time::{SystemTime, UNIX_EPOCH};

use crate::application::command_bus::CommandBus;
use crate::application::error::AppError;
use crate::application::services::{SplitArchiveGroupResult, SplitArchiveGrouper};

impl CommandBus {
pub async fn handle_group_split_archives(
&self,
cmd: super::GroupSplitArchivesCommand,
) -> Result<Vec<SplitArchiveGroupResult>, AppError> {
let repo = self
.package_repo_arc()
.ok_or_else(|| AppError::Validation("package repository not configured".into()))?;
let grouper = SplitArchiveGrouper::new(repo, self.event_bus_arc());

let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);

grouper.group_all(&cmd.links, now_ms)
}
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use crate::application::commands::GroupSplitArchivesCommand;
use crate::application::commands::tests_support::{
CapturingEventBus, InMemoryCredentialStore, InMemoryDownloadRepo, InMemoryPackageRepo,
build_package_bus, bus_without_account_ports,
};
use crate::application::error::AppError;
use crate::application::services::SplitArchiveLink;
use crate::domain::ports::driven::PackageRepository;

fn link(url: &str, filename: &str) -> SplitArchiveLink {
SplitArchiveLink {
url: url.to_string(),
filename: filename.to_string(),
}
}

fn ten_part_links(base: &str) -> Vec<SplitArchiveLink> {
(1..=10)
.map(|n| {
let name = format!("{base}.part{:02}.rar", n);
let url = format!("https://ex.com/{name}");
link(&url, &name)
})
.collect()
}

#[tokio::test]
async fn test_handle_group_split_archives_creates_one_package_per_base() {
let repo = Arc::new(InMemoryPackageRepo::new());
let creds = Arc::new(InMemoryCredentialStore::new());
let dl_repo = Arc::new(InMemoryDownloadRepo::new());
let events = Arc::new(CapturingEventBus::new());
let bus = build_package_bus(repo.clone(), creds, events, dl_repo);

let mut links = ten_part_links("alpha");
links.extend(ten_part_links("bravo"));

let results = bus
.handle_group_split_archives(GroupSplitArchivesCommand { links })
.await
.expect("group");

assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.created));
assert_eq!(repo.list().unwrap().len(), 2);
}

#[tokio::test]
async fn test_handle_group_split_archives_reuses_existing_package_on_re_resolve() {
let repo = Arc::new(InMemoryPackageRepo::new());
let creds = Arc::new(InMemoryCredentialStore::new());
let dl_repo = Arc::new(InMemoryDownloadRepo::new());
let events = Arc::new(CapturingEventBus::new());
let bus = build_package_bus(repo.clone(), creds, events, dl_repo);

let first = bus
.handle_group_split_archives(GroupSplitArchivesCommand {
links: ten_part_links("movie"),
})
.await
.unwrap();
let second = bus
.handle_group_split_archives(GroupSplitArchivesCommand {
links: ten_part_links("movie"),
})
.await
.unwrap();

assert!(first[0].created);
assert!(!second[0].created);
assert_eq!(first[0].package_id, second[0].package_id);
assert_eq!(repo.list().unwrap().len(), 1, "no duplicate package");
}

#[tokio::test]
async fn test_handle_group_split_archives_returns_validation_when_repo_missing() {
let events = Arc::new(CapturingEventBus::new());
let bus = bus_without_account_ports(events);

let err = bus
.handle_group_split_archives(GroupSplitArchivesCommand {
links: ten_part_links("movie"),
})
.await
.expect_err("missing repo");
assert!(matches!(err, AppError::Validation(_)));
}
}
12 changes: 12 additions & 0 deletions src-tauri/src/application/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod export_accounts;
mod export_history;
mod extract_archive;
mod group_playlists;
mod group_split_archives;
mod import_accounts;
mod install_plugin;
mod move_package_to_folder;
Expand Down Expand Up @@ -211,6 +212,17 @@ pub struct GroupPlaylistsCommand {
}
impl Command for GroupPlaylistsCommand {}

/// Auto-group resolved split-archive parts into one [`Package`] per
/// detected base name. Re-running with the same set reuses the existing
/// package (PRD-v2 §P1.12). The handler also detects gaps in the part
/// numbering and emits [`crate::domain::event::DomainEvent::SplitArchiveIncomplete`]
/// so the UI can warn the user before the extraction step blocks.
#[derive(Debug)]
pub struct GroupSplitArchivesCommand {
pub links: Vec<crate::application::services::SplitArchiveLink>,
}
impl Command for GroupSplitArchivesCommand {}

// Handler: task 23 (settings)
#[derive(Debug)]
pub struct UpdateConfigCommand {
Expand Down
36 changes: 36 additions & 0 deletions src-tauri/src/application/services/group_lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! Process-wide lock shared by the package groupers
//! ([`crate::application::services::PlaylistGrouper`],
//! [`crate::application::services::SplitArchiveGrouper`]) to serialise
//! find-then-save sequences.
//!
//! Without this lock, two concurrent IPC invocations for the same
//! natural key could both observe "not found" in `find_by_external_id`
//! and each insert a new `Package`, breaking the idempotent-reuse
//! guarantee. The lock window covers only the lookup + save, never the
//! downstream event publish, so the contention window stays tiny (a
//! few SQLite writes).
//!
//! A single shared mutex is intentional. The cost of mild cross-grouper
//! serialisation is negligible (groupers run only at Link-Grabber
//! commit time, far from any hot path), and a shared mutex makes
//! reasoning about the SQLite UNIQUE-index contract trivial: at most
//! one writer per process competes for any given external_id at a
//! time.

use std::sync::{Mutex, MutexGuard, OnceLock};

fn lock() -> &'static Mutex<()> {
static GROUP_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
GROUP_LOCK.get_or_init(|| Mutex::new(()))
}

/// Acquire the shared grouper lock, recovering from a poisoned mutex
/// (a previous panic while holding the guard) instead of panicking
/// again. Domain state lives in SQLite, not in the guard, so the next
/// caller can safely proceed.
pub(crate) fn acquire_grouper_lock() -> MutexGuard<'static, ()> {
match lock().lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/application/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ pub mod account_rotator;
pub mod account_selector;
pub mod checksum_validator;
pub mod engine_config_bridge;
pub(crate) mod group_lock;
pub mod history_backfill;
pub mod playlist_grouper;
pub mod queue_config_bridge;
pub mod queue_manager;
pub mod split_archive_grouper;
pub mod startup_recovery;

pub use account_rotator::AccountRotator;
Expand All @@ -16,3 +18,4 @@ pub use history_backfill::backfill_history_for_completed_downloads;
pub use playlist_grouper::{PlaylistGroup, PlaylistGroupResult, PlaylistGrouper};
pub use queue_config_bridge::subscribe_queue_to_config;
pub use queue_manager::QueueManager;
pub use split_archive_grouper::{SplitArchiveGroupResult, SplitArchiveGrouper, SplitArchiveLink};
Loading
Loading