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
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. 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 cap + legacy-header coverage) + 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