Skip to content

feat(link): wire willReuseExisting in MediaGrabberDialog (#136)#138

Merged
mpiton merged 1 commit into
mainfrom
feat/issue-136-package-find-by-external-id
May 1, 2026
Merged

feat(link): wire willReuseExisting in MediaGrabberDialog (#136)#138
mpiton merged 1 commit into
mainfrom
feat/issue-136-package-find-by-external-id

Conversation

@mpiton
Copy link
Copy Markdown
Owner

@mpiton mpiton commented May 1, 2026

Summary

When a playlist URL is re-resolved in the Link Grabber, the MediaGrabberDialog now predicts whether the app will reuse an existing package or create a new one. This makes the PlaylistPackageBanner show "Reusing playlist package {name}" instead of always falling back to "Will create …". Closes #136.

Why

The backend link_group_playlists command already implements the reuse logic (via PackageRepository::find_by_external_id), but the frontend dialog has no way to know the outcome upfront. Adding a read-only query surfaces the prediction before the dialog is confirmed, making the UI more accurate and less surprising to users.

Changes

Backend (CQRS read side):

  • Extend PackageReadRepository trait: add find_package_by_external_id(external_id) -> Result<Option<PackageView>, DomainError>
  • SQLite impl: filter on existing idx_packages_external_id UNIQUE index, mirrors find_package_by_id SQL pattern
  • New FindPackageByExternalIdQuery handler in application/queries/ maps result to minimal PackageSummaryDto { packageId, packageName } (camelCase for IPC)
  • Tauri IPC command package_find_by_external_id registered in lib.rs

Frontend:

  • New type PackageSummary { packageId: string; packageName: string } in src/types/package.ts
  • New typed hook usePackageByExternalId(externalId?) wraps useTauriQuery with enabled: !!externalId so no IPC call is made for non-playlist URLs
  • MediaGrabberDialog calls the hook with canonicalPlaylistKey(link.originalUrl) when metadata.isPlaylist, derives willReuseExisting = !!existingPackage, and passes both the flag and the real package name to PlaylistPackageBanner
  • Loading state (pending IPC) defaults to "Will create" so the banner is never blocked by the round-trip (acceptance criterion feat(domain): implement domain models with state machine #3)

Testing

  • Rust: 7 new tests covering DTO round-trip + camelCase serialization, handler logic vs InMemoryPackageReadRepo mock, and SQLite adapter round-tripping external_id matches and misses
  • TypeScript: 6 new tests covering hook invoke args / response / skip-when-undefined / null-match paths, and dialog branches for will-create vs will-reuse banner states
  • All tests passing:
    cd src-tauri && cargo test --workspace  # 1337 passing
    npm test                                 # 650 passing
    cargo clippy --workspace -- -D warnings # clean
    npm run typecheck                        # clean
    npm run lint                             # clean

Related Issues

Notes for Reviewer

The PackageReadRepository::find_package_by_external_id method reuses the same SQL column and index already wired for the write-side PackageRepository::find_by_external_id, so there is no new schema burden. The hook's enabled guard means non-playlist dialogs incur zero IPC overhead — the query only fires when there is actually a playlist key to look up.

Checklist

  • Tests added and passing locally
  • Docs updated (CHANGELOG.md)
  • No secrets, debug prints, or commented-out code
  • Self-reviewed the diff
  • CI expected to be green

Summary by CodeRabbit

  • New Features
    • The media grabber dialog now detects when a playlist has been previously grabbed and displays "Reusing [existing package name]" instead of defaulting to "Will create new package."
    • Improved loading state handling with safe fallback behavior during package lookups.

Add read-only IPC query `package_find_by_external_id` so the playlist
banner accurately reports "Reusing playlist package {name}" when a
playlist URL is re-resolved, instead of always falling back to "Will
create …".

Backend (CQRS read side):
- `PackageReadRepository::find_package_by_external_id` (port)
- SQLite impl filters on existing `idx_packages_external_id` UNIQUE
  index, mirrors `find_package_by_id` SQL pattern
- New `FindPackageByExternalIdQuery` handler maps result to a minimal
  `PackageSummaryDto { packageId, packageName }` (camelCase)
- Tauri IPC: `package_find_by_external_id` registered in lib.rs

Frontend:
- `PackageSummary` type + `usePackageByExternalId(externalId?)` hook
  with `enabled: !!externalId` short-circuit
- `MediaGrabberDialog` calls the hook with `canonicalPlaylistKey(...)`
  when `metadata.isPlaylist`, derives `willReuseExisting`, and prefers
  the real `existingPackage.packageName` for the banner title
- Loading state defaults to "Will create" so the banner is never
  blocked by the round-trip (AC #3)

Tests: 7 new Rust (DTO + handler + SQLite), 6 new TypeScript (hook +
dialog branches). cargo test 1337 passing, vitest 650 passing,
clippy + tsc + oxlint clean.

Closes #136
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust frontend labels May 1, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

📝 Walkthrough

Walkthrough

This PR implements a read-side query system to detect whether a playlist package already exists by external ID, enabling the MediaGrabberDialog to display accurate reuse status upfront. It spans a complete vertical slice: SQLite lookup, CQRS query handler, Tauri IPC command, React hook, and component integration with corresponding tests.

Changes

Cohort / File(s) Summary
Changelog
CHANGELOG.md
Documents the new unreleased feature for playlist package reuse detection via package_find_by_external_id query, including backend CQRS infra and frontend hook integration.
Backend SQLite Adapter
src-tauri/src/adapters/driven/sqlite/package_read_repo.rs
Adds find_package_by_external_id() method performing parameterized query on packages.external_id index, returning Option<PackageView> with two async tests covering match and no-match cases.
Backend CQRS Query Infrastructure
src-tauri/src/application/queries/find_package_by_external_id.rs, src-tauri/src/application/queries/mod.rs, src-tauri/src/domain/ports/driven/package_read_repository.rs
Introduces FindPackageByExternalIdQuery, corresponding handler validating repository exists, delegating to SQLite adapter, and mapping result to PackageSummaryDto; adds trait method to read-port; three tests validate positive match, None result, and exact-match semantics.
Backend Read Models & DTOs
src-tauri/src/application/read_models/package_summary.rs, src-tauri/src/application/read_models/mod.rs
Adds minimal PackageSummaryDto with camelCase serialization for packageId/packageName, conversion from PackageView, and tests verifying DTO fields and JSON output.
Backend Tauri IPC & Wiring
src-tauri/src/adapters/driving/tauri_ipc.rs, src-tauri/src/lib.rs
Exposes package_find_by_external_id Tauri command accepting external_id string, delegating to query bus, and registering in invoke handler for frontend access.
Backend Test Support
src-tauri/src/application/test_support.rs
Extends in-memory PackageReadRepository test double with external_id_index map, insert_with_external_id() helper, and find_package_by_external_id() implementation for test scenarios.
Frontend Types & Hooks
src/types/package.ts, src/hooks/usePackageByExternalId.ts, src/hooks/__tests__/usePackageByExternalId.test.tsx
Adds PackageSummary type, new TanStack hook usePackageByExternalId() with enabled: !!externalId gate to skip queries for undefined IDs, and comprehensive tests mocking Tauri invoke covering defined/undefined/null cases.
Frontend Component Integration
src/views/LinkGrabberView/MediaGrabberDialog/MediaGrabberDialog.tsx, src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx
Wires hook into dialog to derive canonicalPlaylistKey for playlists, queries existingPackage, computes willReuseExisting flag, passes flag and preferred package name to PlaylistPackageBanner; tests verify banner renders "will create" vs. "reusing" states based on lookup result.

Sequence Diagram

sequenceDiagram
    participant React as Frontend<br/>(React Component)
    participant Hook as usePackageByExternalId<br/>(Hook)
    participant TauriAPI as Tauri IPC<br/>(Command Bus)
    participant QueryBus as Query Bus<br/>(Handler)
    participant Repo as SQLite Repo<br/>(Adapter)
    participant DB as SQLite DB

    React->>Hook: Call usePackageByExternalId(externalId)
    alt externalId is defined
        Hook->>TauriAPI: invoke("package_find_by_external_id", { externalId })
        TauriAPI->>QueryBus: route to handler
        QueryBus->>Repo: find_package_by_external_id(external_id)
        Repo->>DB: SELECT * FROM packages WHERE external_id = ?
        alt Match found
            DB-->>Repo: PackageView
            Repo-->>QueryBus: Ok(Some(PackageView))
            QueryBus-->>TauriAPI: PackageSummaryDto { packageId, packageName }
            TauriAPI-->>Hook: Ok(Some(PackageSummary))
        else No match
            DB-->>Repo: (empty)
            Repo-->>QueryBus: Ok(None)
            QueryBus-->>TauriAPI: Ok(None)
            TauriAPI-->>Hook: Ok(null)
        end
        Hook-->>React: Return query result (data)
    else externalId is undefined
        Hook-->>React: Query disabled, return undefined
    end
    React->>React: willReuseExisting = !!existingPackage
    React->>React: Render banner with reuse status
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

rust, frontend, ui

Poem

🐰 A little banner, clever and keen,
Checks if a package has already been seen,
Query and hook dance in harmony's light,
Reuse or create—now we show what is right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(link): wire willReuseExisting in MediaGrabberDialog (#136)' directly describes the main change - wiring the willReuseExisting flag into the MediaGrabberDialog component, which is the primary frontend objective and the overall goal of the PR.
Linked Issues check ✅ Passed All required objectives from issue #136 are met: backend query handler (find_package_by_external_id) returning PackageSummaryDto is implemented [Rust files], Tauri IPC command is registered [lib.rs, tauri_ipc.rs], frontend hook usePackageByExternalId with enabled gate is created [TypeScript hook], and MediaGrabberDialog calls the hook and derives willReuseExisting to pass to PlaylistPackageBanner [MediaGrabberDialog.tsx]; tests validate all components.
Out of Scope Changes check ✅ Passed All changes are within scope of issue #136: CQRS read query, Tauri IPC exposure, frontend hook, dialog wiring, and tests directly support the goal of surfacing the willReuseExisting decision; CHANGELOG.md documents the feature per standard practice.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-136-package-find-by-external-id

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 15 files

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src-tauri/src/application/test_support.rs (1)

676-683: ⚡ Quick win

Preserve deterministic mapping semantics in insert_with_external_id.

At Line 679, inserting into external_id_index unconditionally overwrites prior mappings. The SQLite adapter resolves deterministically by earliest (created_at, id) ordering, so this fake can return a different package in duplicate-fixture scenarios and mask parity bugs in handler tests.

♻️ Suggested patch
 pub(crate) fn insert_with_external_id(&self, view: PackageView, external_id: &str) {
     let pkg_id = view.id.clone();
     self.packages.lock().unwrap().push(view);
-    self.external_id_index
-        .lock()
-        .unwrap()
-        .insert(external_id.to_string(), pkg_id);
+    self.external_id_index
+        .lock()
+        .unwrap()
+        .entry(external_id.to_string())
+        .or_insert(pkg_id);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/application/test_support.rs` around lines 676 - 683,
insert_with_external_id currently always overwrites any existing external_id ->
pkg id mapping; change it to preserve the deterministic earliest (created_at,
id) semantics: when inserting into external_id_index in insert_with_external_id,
check if an entry already exists for external_id, and if so look up the existing
PackageView by that pkg id from self.packages to compare (created_at, id) with
the new view; only replace the mapping when the new view is strictly earlier by
(created_at, id) ordering, otherwise keep the existing mapping; refer to
insert_with_external_id, PackageView, self.packages, and external_id_index to
locate and implement this logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/usePackageByExternalId.ts`:
- Around line 4-13: The hook usePackageByExternalId sends a payload key
externalId that doesn't match the Tauri command parameter external_id; update
the object passed to useTauriQuery (the second argument of useTauriQuery in
usePackageByExternalId) to use the snake_case key external_id when externalId is
present so the Tauri invoke payload matches the Rust parameter name (leave
queryKey and enabled logic unchanged).

---

Nitpick comments:
In `@src-tauri/src/application/test_support.rs`:
- Around line 676-683: insert_with_external_id currently always overwrites any
existing external_id -> pkg id mapping; change it to preserve the deterministic
earliest (created_at, id) semantics: when inserting into external_id_index in
insert_with_external_id, check if an entry already exists for external_id, and
if so look up the existing PackageView by that pkg id from self.packages to
compare (created_at, id) with the new view; only replace the mapping when the
new view is strictly earlier by (created_at, id) ordering, otherwise keep the
existing mapping; refer to insert_with_external_id, PackageView, self.packages,
and external_id_index to locate and implement this logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 63881bd0-3ddd-4f79-966e-6461cabd6728

📥 Commits

Reviewing files that changed from the base of the PR and between 1c6c8a6 and ffa698f.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • src-tauri/src/adapters/driven/sqlite/package_read_repo.rs
  • src-tauri/src/adapters/driving/tauri_ipc.rs
  • src-tauri/src/application/queries/find_package_by_external_id.rs
  • src-tauri/src/application/queries/mod.rs
  • src-tauri/src/application/read_models/mod.rs
  • src-tauri/src/application/read_models/package_summary.rs
  • src-tauri/src/application/test_support.rs
  • src-tauri/src/domain/ports/driven/package_read_repository.rs
  • src-tauri/src/lib.rs
  • src/hooks/__tests__/usePackageByExternalId.test.tsx
  • src/hooks/usePackageByExternalId.ts
  • src/types/package.ts
  • src/views/LinkGrabberView/MediaGrabberDialog/MediaGrabberDialog.tsx
  • src/views/LinkGrabberView/__tests__/MediaGrabberDialog.test.tsx

Comment on lines +4 to +13
export function usePackageByExternalId(externalId: string | undefined) {
return useTauriQuery<PackageSummary | null>(
"package_find_by_external_id",
externalId ? { externalId } : undefined,
{
queryKey: ["package_find_by_external_id", { externalId }],
enabled: !!externalId,
staleTime: 30_000,
},
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 4 'export function useTauriQuery|function useTauriQuery|const useTauriQuery|invoke\(' src

Repository: mpiton/vortex

Length of output: 519


🏁 Script executed:

# Get full implementation of useTauriQuery
cat -n src/api/hooks.ts | head -30

Repository: mpiton/vortex

Length of output: 1512


🏁 Script executed:

# Find the Tauri command definition
rg -n 'package_find_by_external_id' src-tauri/src/ -A 5 -B 2

Repository: mpiton/vortex

Length of output: 2077


🏁 Script executed:

# Check tauriInvoke to see how args are handled
rg -n 'export.*tauriInvoke|function tauriInvoke' src/api/ -A 10

Repository: mpiton/vortex

Length of output: 567


🏁 Script executed:

# Look for other useTauriQuery calls to see the pattern of how args are structured
rg -n 'useTauriQuery' src/hooks/ -A 5 | head -50

Repository: mpiton/vortex

Length of output: 1617


🏁 Script executed:

# Check if there are other IPC commands with underscores and how they're called
rg -n 'invoke\(' src/ -B 2 -A 1 | grep -E '(invoke|_|Id|id)' | head -30

Repository: mpiton/vortex

Length of output: 39


🏁 Script executed:

# Find the download_detail Tauri command definition to see parameter naming
rg -n 'download_detail' src-tauri/src/adapters/driving/tauri_ipc.rs -B 2 -A 5 | head -30

Repository: mpiton/vortex

Length of output: 1425


🏁 Script executed:

# Check if there's any serialization configuration or naming strategy in the Tauri setup
rg -n 'serde|serialize|rename' src-tauri/src/adapters/driving/tauri_ipc.rs | head -20

Repository: mpiton/vortex

Length of output: 1011


🏁 Script executed:

# Check how Tauri invoke handles parameter naming with a concrete example
# Look for how other commands with underscored parameters are called
rg -n 'history_delete_entry|history_export|link_resolve|package_delete|package_set_password' src/hooks/ -A 3

Repository: mpiton/vortex

Length of output: 39


🏁 Script executed:

# Double-check the exact signature of package_find_by_external_id
cat -n src-tauri/src/adapters/driving/tauri_ipc.rs | sed -n '3197,3210p'

Repository: mpiton/vortex

Length of output: 579


🏁 Script executed:

# Check Tauri's invoke behavior and if there's any middleware for case conversion
# Search for any tauri configuration or plugins that might do case conversion
rg -n 'invoke|camelCase|snake_case|rename' src-tauri/src-tauri.conf.json src-tauri/Cargo.toml 2>/dev/null | head -20

Repository: mpiton/vortex

Length of output: 39


🏁 Script executed:

# Search for examples of how other hooks call commands with underscored parameters
rg -n 'useTauriQuery|useTauriMutation' src/hooks/ -A 4 | grep -E '(useTauri|_|[a-z]+Id)' | head -40

Repository: mpiton/vortex

Length of output: 1728


Ensure the payload key matches the Tauri command parameter name.

The hook sends { externalId } but the Tauri command in src-tauri/src/adapters/driving/tauri_ipc.rs:3200 expects the parameter external_id. Tauri's invoke forwards the object verbatim, and Rust deserialization will fail to map camelCase externalId to snake_case external_id, causing the query to error.

Required fix
-    externalId ? { externalId } : undefined,
+    externalId ? { external_id: externalId } : undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/usePackageByExternalId.ts` around lines 4 - 13, The hook
usePackageByExternalId sends a payload key externalId that doesn't match the
Tauri command parameter external_id; update the object passed to useTauriQuery
(the second argument of useTauriQuery in usePackageByExternalId) to use the
snake_case key external_id when externalId is present so the Tauri invoke
payload matches the Rust parameter name (leave queryKey and enabled logic
unchanged).

@mpiton mpiton merged commit b53eef5 into main May 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation frontend rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MediaGrabberDialog: wire willReuseExisting from a package_find_by_external_id query

1 participant