refactor(workspace): extract waveflow-core crate (Phase 1.a)#169
refactor(workspace): extract waveflow-core crate (Phase 1.a)#169InstaZDLL wants to merge 15 commits into
Conversation
First commit of the Phase 1.a refactor (RFC-001 §5): converts the
single `src-tauri` Cargo package into a virtual workspace with two
members — `waveflow-core` (empty for now, populated by subsequent
commits) and `waveflow` (the existing Tauri app, moved verbatim into
`crates/app/`).
Zero behaviour change for end users: the bundled binary is still
called `waveflow`, the dep graph is identical, all migrations and
runtime paths still resolve. The work this commit unblocks is the
incremental migration of business logic into `waveflow-core` so the
future `waveflow-server` (RFC §6.2) can depend on it without dragging
in Tauri.
Cross-cutting fixups required by the move:
- `[patch.crates-io]` for the vendored glib-0.18.5 backport lives at
the workspace root — Cargo silently ignores patches declared in a
workspace member.
- `crates/app/build.rs` rewrites the `git log` paths and `current_dir`
to walk three levels up to `.git` (one extra hop than before).
- `crates/app/tauri.conf.json` updates `frontendDist` and `licenseFile`
to `../../../dist` / `../../../LICENSE` (config now lives two levels
deeper than `src-tauri/`).
- The three `sqlx::migrate!` invocations in app_db.rs, profile_db.rs
and profile_io.rs go from `./migrations/...` to `../../migrations/...`
(the `migrations/` tree stays at the workspace root for now; it
moves into `crates/core/migrations/` when the repository traits
land in step 5).
- `scripts/tauri.mjs` is a small wrapper around `tauri` that injects
`--config src-tauri/crates/app/tauri.conf.json` for the three
subcommands that load it (`dev`, `build`, `bundle`); other
subcommands (`info`, `signer`, `icon`, …) pass through unchanged.
Required because `tauri-build` reads `tauri.conf.json` from
`current_dir()` (= `CARGO_MANIFEST_DIR`) with no env-var override,
so the file has to sit next to `crates/app/Cargo.toml`, but the CLI
can't auto-discover it from there when run from the repo root.
- Gitignore updates for the new `crates/app/{gen,icons,capabilities/updater.json}`
paths.
Validation on Linux: `cargo check --workspace --all-targets`,
`cargo check --manifest-path src-tauri/Cargo.toml --all-targets` (the
exact CI command), `cargo test --no-run`, `bun run tauri info` and
`bun run tauri build --help` all green. Full `bun run tauri build`
end-to-end check deferred to the final validation pass at the end of
the 1.a series.
Refs #128.
Move the plain-data DTOs that describe what every WaveFlow client
sees (`Track`, `TrackListItem`, `ListTracksResponse`, `Profile`,
`Library`, `Playlist`, plus their `Create*Input` / `Update*Input`
counterparts) out of `commands/{track,profile,library,playlist}.rs`
and into `crates/core/src/domain/`.
The original command modules keep a `pub use waveflow_core::domain::…`
re-export so existing call sites (`crate::commands::profile::Profile`,
the `sqlx::query_as::<_, Profile>(...)` patterns, etc.) keep working
without churn. The row-shaped types still tied to specific SQL
projections (`TrackRow`, the private query targets in
`commands/playlist.rs`) stay in app — they migrate naturally with
the repository traits in step 5.
`waveflow-core` picks up a single optional dep (`sqlx`) gated behind
a new `sqlite` feature. Each `FromRow` derive on the moved types is
`#[cfg_attr(feature = "sqlite", derive(sqlx::FromRow))]`, so the
crate stays storage-agnostic when the feature is off (the future
`waveflow-server` will toggle a `postgres` equivalent without
double-declaring the structs). Desktop opts in via
`waveflow-core = { path = "../core", features = ["sqlite"] }`.
`cargo check --workspace --all-targets` and `cargo test --workspace`
(111 passed) both green. Net diff: 225 LOC removed from app, 45 LOC
added (re-exports + the feature flag wiring).
Refs #128.
Add `waveflow_core::error::{CoreError, CoreResult}` so the bits of
`crates/core` landing in the next commits (HTTP clients, scanner,
analysis, repository traits) can return a portable error type that
makes sense in both the desktop crate and the future
`waveflow-server`.
`CoreError` carries the storage / IO / profile variants
(`Database(sqlx::Error)`, `Io`, `ProfileNotFound`, `NoActiveProfile`,
`Other`). `AppError` keeps everything it had and gains a transparent
`Core(#[from] CoreError)` arm at the top of the enum: when a
core function returns `CoreError`, the `?` operator flattens it into
`AppError::Core(_)` without any wrapper boilerplate at call sites,
and the Tauri serialisation path keeps working because
`#[error(transparent)]` forwards `Display` through to the inner
variant.
Deliberately *not* migrating the existing `AppError::Database`,
`AppError::Io`, `AppError::ProfileNotFound`, … variants right now.
There are 225+ call sites that construct or match on them (160
`AppError::Other`, 57 `AppError::Audio`, …). Migrating them in one
shot would be a huge churn for zero behaviour change. Those variants
shrink to the `Core` wrapper organically as the owning modules
move into `crates/core` in steps 4-7; `AppError` is collapsed in a
follow-up commit once nothing inside `crates/app` constructs them
directly anymore.
`waveflow-core` Cargo.toml: drops `optional = true` from `sqlx`
(needed unconditionally for `CoreError::Database`), the `sqlite`
feature is now an empty marker that gates the `FromRow` derive on
domain types via `cfg_attr`. Cargo's feature unification means
desktop's `runtime-tokio` + `sqlite` + `migrate` + `chrono` features
still apply to the workspace's effective sqlx build — only the
trait surface needed for derives is required at the core level.
`cargo check --workspace --all-targets` clean; `cargo test
--workspace` 111 passed / 0 failed; core compiles with and without
the `sqlite` feature.
Refs #128.
Move the three third-party metadata clients into
`waveflow_core::metadata::{deezer, lastfm, lrclib}`. None of them
touched Tauri, the audio engine, or the database — they are plain
`reqwest::Client` wrappers, ideal candidates for the first non-type
core module.
`crates/core/Cargo.toml` picks up the deps the clients carry along:
`reqwest` (rustls-tls + json), `md-5` + `hex` (Last.fm signed
methods), `regex` (Last.fm bio HTML stripping), `serde_json` (the
dynamic Last.fm + LRCLIB shapes that don't deserialize into fixed
structs). Desktop still declares the same deps in its own
`Cargo.toml` because other app-only modules (spotify, scrobbler,
queue, …) still pull on them directly; the duplicate declarations
are deliberate and become deletable in a later cleanup pass once
those modules either migrate or stop using these crates.
App-side wiring:
- Drop `mod deezer; mod lastfm; mod lrclib;` from `crates/app/src/lib.rs`.
- Update the six call sites in `commands/{similar,deezer,lyrics,
integration,player}.rs` and `scrobbler.rs` to import from
`waveflow_core::metadata::*` instead of `crate::*`.
`cargo check --workspace --all-targets` clean; `cargo test
--workspace` 111 passed / 0 failed.
Refs #128.
First slice of the Phase 1.a step 5 work — define a backend-agnostic storage trait per domain entity, with a SQLite implementation in core that the desktop commands consume directly. Subsequent commits do the same for `Library`, `Playlist`, `Track` and `Artwork`. `waveflow_core::repository::profile::ProfileRepository` mirrors the SQL the existing `commands/profile.rs` ran by hand: `list_all`, `get`, `insert` + `set_data_dir` (split so the caller can derive the data directory from the freshly assigned rowid), `rename`, `touch_last_used`, `delete_guarded`, `exists`. `delete_guarded` returns a dedicated `ProfileDeleteOutcome` so the boundary between "not found" and "would have left the table empty" stays in core's SQL — no extra round-trip needed for the call site to disambiguate. `SqliteProfileRepository` is a thin newtype around `SqlitePool`; desktop builds one per command (`SqliteProfileRepository::new(state .app_db.clone())`) and `SqlitePool` is `Arc`-backed so the clone is free. All six `app.db`-touching commands in `commands/profile.rs` are now thin wrappers that delegate to the repo. The two `profile_setting` commands stay untouched because they target the active profile's `data.db`, not `app.db` — they belong to a future `SettingsRepository` (`app_setting` + `profile_setting` together) that is out of scope for this slice. Cargo wiring on core: - `async-trait` joins the dep list. Native `async fn in trait` is stable but isn't dyn-compatible; the boxed-future trampoline keeps the door open for `&dyn ProfileRepository` parameters once they start showing up in axum handlers. - The `sqlite` feature was a marker only — it now actually enables `sqlx/sqlite` + `sqlx/runtime-tokio` so the `repository::sqlite::*` modules compile in isolation (`cargo check -p waveflow-core --features sqlite`). `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed. Refs #128.
Second slice of the Phase 1.a step 5 work. Same shape as `ProfileRepository`: trait in `crates/core/src/repository/library.rs`, SQLite implementation in `crates/core/src/repository/sqlite/library.rs`, desktop commands rewritten as thin wrappers around it. `LibraryRepository` covers both the `library` and `library_folder` tables — they're tightly coupled (every library has folders, deleting one cascades) so a single trait keeps the boundary readable. Methods: `list_all_with_counts` (the heavy JOIN that powers the sidebar), `exists`, `insert`, `update` (partial via `LibraryUpdate`), `delete`, `touch_updated_at`, plus the folder side `list_folders`, `list_folder_ids`, `insert_folder`, `insert_or_get_folder` (drag-and- drop dedup), `delete_folder_with_tracks` (transactional). The `LibraryFolder` row struct moves to `waveflow_core::domain::library` alongside `Library` and gains the same `#[cfg_attr(feature = "sqlite", derive(sqlx::FromRow))]` shape; `commands/library.rs` re-exports it so existing imports (`crate::commands::library::LibraryFolder`) keep resolving. All 9 commands in `commands/library.rs` were migrated: - `list_libraries`, `create_library`, `update_library`, `delete_library`, `rescan_library`, `add_folder_to_library`, `list_library_folders`, `import_paths`, `remove_folder_from_library` → all delegate to the repo. - `set_folder_watched` keeps calling `apply_toggle` (watcher coordination lives app-side). `scan_folder_inner` still gets called directly from `rescan_library` and `import_paths` — its migration is step 6 territory and will fall out naturally when the scanner moves to core. `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed. Refs #128.
Third slice of step 5. `PlaylistRepository` covers both the `playlist` and `playlist_track` surfaces — they're tightly coupled by foreign keys + the auto-cover regen pipeline, so a single trait is the honest boundary. Methods: `list_all_with_counts`, `get_with_counts`, `get_name` (small enough for the M3U exporter that it doesn't warrant a full row fetch), `exists`, `insert_custom` (smart playlists use a different surface, kept out of this trait for now), `update`, `delete`, `touch_updated_at`, then the `playlist_track` side: `list_user_playlists_containing` (smart playlists filtered out — the `+` popover would otherwise show toggles that don't reflect real membership), `append_track`, `append_tracks` (tx-managed bulk), `remove_track` (delete + renumber tail), `reorder_track` (clamped absolute reposition + bulk shift), `create_with_tracks` (the "insert playlist + first batch of tracks" atomic used by the M3U importer). The 13 desktop commands in `commands/playlist.rs` are now thin wrappers. Two queries stay inline for now: - `list_playlist_tracks` projects through the `TrackRow` shape that still lives in `crates/app` — migrates with `TrackRepository` in step 5.d. - `export_playlist_m3u` carries its own one-shot `ExportRow` projection (title + joined artist names + duration + file path); small enough that it doesn't earn a dedicated repo method. The auto-cover regen call (`playlist_cover::maybe_regen_auto_cover`) stays in the command handler — it's a filesystem + composite-image concern, not a row mutation. sqlx 0.9 surprise documented in passing: dynamic SQL strings (the `format!()`-built variants of `SELECT_WITH_COUNTS`) now need an explicit `sqlx::AssertSqlSafe(s)` wrap — the trait `SqlSafeStr` is only implemented for `&'static str` and the `AssertSqlSafe` newtype. `&str` borrowed from a local `String` is no longer accepted. `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed. Refs #128.
Fourth slice of step 5 — the busiest table in the schema gets its trait. `TrackRow` (the shared `query_as` target for every joined track listing) moves to `waveflow_core::domain::track`; the trait in `repository::track` carries the typed sort + filter enums so the command handlers stop massaging strings into `ORDER BY` clauses by hand. `TrackRepository` methods: - `get`, `list` (with `TrackListFilter` + `TrackSort`), `list_in_playlist`, `list_liked`, `search_fts`, `list_ids_in_source` (folder / album / artist via `TrackSource`). - `liked_ids`, `is_liked`, `like`, `unlike`, `get_file_path`, `set_rating`. The SQLite impl owns a `SELECT_TRACK_ROW` const that backs every method returning a row — collapses ~120 LOC of duplicated projection-SQL that the app crate had been carrying across `commands/track.rs`, `commands/playlist.rs::list_playlist_tracks`, and the four single-row branches of `get_track` / `search_tracks`. Scope explicitly *not* migrated in this commit: - `search_tracks_advanced` keeps its dynamic SQL builder inline in the app. The shape is too client-specific (genre IDs as a `?,?` expansion, BPM EXISTS subquery, optional FTS join) to lock down in a stable trait method before the future server defines its own filter contract. - `set_track_rating`'s `lofty::save_to_path` + the pause-if-playing handshake stay in app because they couple the audio engine state with file I/O — only the surrounding two SQL hits move into the trait. - `ArtworkRepository` is deliberately deferred: the `artwork` table is upserted exclusively from `scan.rs`'s helpers, which migrate to core as part of step 6 alongside the rest of the scanner. `commands/playlist.rs` also tightens up — `list_playlist_tracks` and `add_source_to_playlist` now call into the track repo's `list_in_playlist` / `list_ids_in_source` instead of the inline queries they previously carried. `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed. Refs #128.
…cache)
First slice of step 6 — the two pure helpers under
`crates/app/src/{thumbnails,metadata_artwork}.rs` had zero Tauri /
audio coupling, so they migrate straight into
`waveflow_core::artwork::{thumbnails, metadata}` with no shape
change.
What moves:
- `artwork::thumbnails` — the SIMD-resize pipeline that produces the
`<hash>_1x.jpg` / `<hash>_2x.jpg` variants alongside the full-size
cover, plus the worker-thread spawn helper used by the scan and
Deezer download paths.
- `artwork::metadata` — the blake3-keyed shared cache used by the
Deezer / Last.fm enrichers (`download_and_cache`, `path_for_hash`,
`existing_path`). The internal `spawn_thumbnail_job` call is
rewritten to point at `crate::artwork::thumbnails` instead of
`crate::thumbnails`.
Desktop wiring: `crates/app/src/{thumbnails,metadata_artwork}.rs`
become one-line re-export stubs (`pub use waveflow_core::artwork::*`)
so the 13 existing `crate::thumbnails::*` / `crate::metadata_artwork::*`
call sites keep resolving without churn — same pattern as the
`commands/*` re-exports established in step 2.
`crates/core/Cargo.toml` picks up the deps the pipeline carries
(`image` with the same lossy + lossless feature set the app uses,
`fast_image_resize`, `blake3`, `anyhow`) plus `tracing` for the
`warn!` paths that already had an app-side subscriber configured.
`cargo check --workspace --all-targets` clean; `cargo test
--workspace` 111 passed / 0 failed.
Refs #128.
Second slice of step 6 — `analysis.rs` (the per-track peak / loudness / ReplayGain / BPM computer) moves verbatim to `waveflow_core::analysis`. It had no Tauri / DB coupling, just `symphonia` decode + plain f64 math, so the move is a `git mv` plus a re-export stub in app for the single caller (`commands/analysis.rs`). `crates/core/Cargo.toml` picks up the `symphonia` declaration with the same codec feature set the desktop runtime needs — Cargo unifies it back to a single sqlx-style compile across the workspace. `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed. Refs #128.
Third slice of step 6 — the entire `smart_playlists/` directory
(cover composer, custom rule evaluator, Daily Mix generator, On
Repeat regen, the shared `mod.rs` enum + the `on_repeat.svg`
brand asset) migrates to `waveflow_core::smart_playlists`. None of
it touched Tauri; the coupling we did have was on `AppPaths` and
`AppError`, both addressed below.
Plumbing:
- `crate::error::{AppError, AppResult}` → `crate::error::{CoreError,
CoreResult}` everywhere via a single sed pass. `CoreError` gains
an `Audio(String)` variant that mirrors the desktop's
`AppError::Audio` byte-for-byte so the wire format stays the
same when the variant crosses the `#[from] CoreError` bridge.
- `crate::paths::AppPaths` is replaced by a new
`smart_playlists::PathsContext { metadata_artwork_dir,
app_db_path, profile_artwork_dir }` — owned `PathBuf`s so the
struct is `Clone`/`Send` without lifetime gymnastics across the
async generators. The `(profile_id, AppPaths)` argument pairs
collapse into a single `&PathsContext`; the helper that no
longer needs `profile_id` (it reads `profile_artwork_dir`
directly from the context) keeps the parameter as `_profile_id`
to preserve the public-symmetry hint at the call site.
- `crate::metadata_artwork::*` → `crate::artwork::metadata::*`
inside `cover.rs` (the metadata cache moved in 6.a).
`crates/core/Cargo.toml` picks up the deps the engine carries:
`resvg` + `usvg` + `tiny-skia` for the On Repeat SVG rasteriser,
`chrono` for the lookback-window math, and `tempfile` in
`[dev-dependencies]` for the cover render unit tests.
Desktop wiring:
- `crates/app/src/smart_playlists.rs` becomes a re-export stub
pointing at `waveflow_core::smart_playlists::{cover, custom,
generator, on_repeat, PathsContext, SmartPlaylistRules}`. Glob
re-export wasn't enough — submodule access (`crate::smart_playlists::cover::*`)
requires the modules themselves to be in scope.
- `commands/smart_playlists.rs` gains a tiny `paths_ctx(state,
profile_id)` helper that builds the `PathsContext` from
`AppPaths` once per command (the three `clone()`s are cheap;
`PathBuf` is a `Vec<u8>` underneath).
Test split: the 20 smart-playlist unit tests (cover composition,
custom rule eval, generator slot math) now live in `waveflow-core`.
Total stays at 111 (91 app + 20 core); `cargo check --workspace
--all-targets` clean.
Refs #128.
Final slice of step 6. Pulls the genuinely portable half of
`commands/scan.rs` (~1100 of its 2045 LOC) into a new
`waveflow_core::scanner` module:
- `scanner::extract` — `ExtractedFile`, `ExtractedCover`, the
tag-to-struct mapping (`extract_cover`, `extract_folder_cover`,
`extract_artist_image`, `extract_rating`, `extract_musical_key`,
`extract_album_artist`, `extract_compilation_flag`),
`file_type_label`, `extension_for_mime`, `hash_file`, and the
`AUDIO_EXTENSIONS` whitelist. The cover-pipeline helpers now point
at `crate::artwork::thumbnails::spawn_thumbnail_job` (in-crate)
instead of `crate::thumbnails`.
- `scanner::upserts` — `canonical_name`, `split_artist_name`,
`now_millis`, `VARIOUS_ARTISTS_LABEL`, the five `upsert_*`
helpers (artwork / artist / album / genre / artist_list),
`resolve_album_artist` (the Album-Artist grouping policy),
`link_local_artist_image`, `maybe_link_artist_images`, and the
`merge_implicit_compilations` post-scan promotion pass.
All eight scanner unit tests (folder-cover priority, JPEG
normalisation, artist-image walk-up + canonical-name stem match,
hash-addressed write) move alongside the code they cover into
`core/src/scanner/extract.rs`.
What stays in `crates/app/src/commands/scan.rs`:
- `ScanProgress`, `ScanSummary`, `maybe_emit_progress` — the
`scan:progress` Tauri-event shape and the throttled emitter.
- `extract_dsd_file` + the `extract_file` dispatcher — both still
call into `crate::audio::dsd::*`, which migrates to core in
step 7. The DSD-aware extractor stays app-side until then.
- `scan_folder` (`#[tauri::command]`), `scan_folder_inner` (the
walkdir + `buffered`-stream orchestrator), `rescan_local_artist_images`,
`ArtistImageScanSummary`.
App-side wiring:
- A `pub use waveflow_core::scanner::canonical_name;` re-export at
the top of `commands/scan.rs` keeps the two cross-file imports
in `commands/{radio,similar}.rs` resolving (`crate::commands::scan::canonical_name`).
- `commands/edit.rs` updates its scanner import to
`waveflow_core::scanner::*` directly (no re-export shim — the
call site is the only one outside scan.rs that pulled on
multiple helpers).
`crates/core/Cargo.toml` picks up `lofty = "0.24"` (tag parser).
Scanner orchestration (`scan_folder_inner` decoupling from
`tauri::AppHandle` via a `ScannerEventSink` trait) is the obvious
follow-up but stays outside the Phase 1.a brief — `scan_folder_inner`
is fine app-side for now.
`cargo check --workspace --all-targets` clean; `cargo test
--workspace` 111 passed / 0 failed (83 app + 28 core).
Refs #128.
Step 7 — the DSD container parser, 1-bit-to-PCM decimating FIR, and DSF/DFF metadata reader (`crate::audio::dsd::*`) migrate as-is to `waveflow_core::audio_format::dsd::*`. Zero Tauri coupling, zero audio-engine glue: the module was always self-contained. What moves: - `audio_format::dsd::parser` (461 LOC) — DSF (Sony chunk) + DFF (Philips IFF/FORM) container layouts, sample rate / channels / duration extraction. - `audio_format::dsd::pcm` (404 LOC) — Blackman-Harris FIR decimator that brings the 1-bit bitstream down to 88.2 / 96 kHz PCM the rest of the engine already understands. 17 unit tests travel with the code. - `audio_format::dsd::metadata` (370 LOC) — DSF carries an embedded ID3v2 footer chunk; this reader hands it to the `id3` crate without a full file roundtrip. DFF parses its native DIIN / COMT / CMNT atoms inline. `crates/core/Cargo.toml` picks up `id3 = "1"` for the metadata side. The DSD module's name as a top-level core entry is `audio_format` rather than `audio` to mark the boundary cleanly: the real-time `cpal` / `rtrb` engine stays in `crates/app` per the Phase 1.a split rules — what lives under `audio_format` is just format-level processing that's safe to run inside an axum handler on the future server. Call-site updates (only two): - `commands/scan.rs::extract_dsd_file` — imports flip from `crate::audio::dsd::*` to `waveflow_core::audio_format::dsd::*`. - `audio/crossfade.rs` — same flip for the playback decode path. - `audio/mod.rs` drops its `pub mod dsd;` declaration since the directory moved away. `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed (66 app + 45 core — DSD's 17 tests + the 8 scanner tests from step 6 + the 20 smart-playlist tests from 6.c now compile against core). Refs #128.
Wraps up the Phase 1.a refactor with the contributor-facing docs +
the CI tweaks the split needs.
New: `docs/architecture/crates.md` — single source of truth for the
crate layout, the "what goes in core vs app" rules, the re-export
shims that minimise churn in the app, the `sqlite` feature gating,
the Tauri-config relocation under `crates/app/`, and a note on
when (not now) to extract `waveflow-core` into its own repo.
Updated: `CLAUDE.md` Backend section — describes the
`waveflow-core` / `waveflow` split + the routing of each subsystem
across them. Dev-commands snippet bumps the two backend runs to
`--workspace`.
Updated: `docs/architecture/{audio,storage}.md` + every page under
`docs/features/` — bulk path rewrite for the modules that moved:
`audio/dsd/*` → `audio_format/dsd/*`,
`smart_playlists/*`, `metadata_artwork.rs` → `artwork/metadata.rs`,
`thumbnails.rs` → `artwork/thumbnails.rs`,
`analysis.rs`, `{deezer,lastfm,lrclib}.rs` → `metadata/*`, plus the
catch-all `src-tauri/src/` → `src-tauri/crates/app/src/` for
everything that stayed in app.
CI: `.github/workflows/{ci,release-please-lockfile-build}.yml`
gain an explicit `--workspace` flag on the `cargo check` /
`cargo test` invocations. The flag was already implied for a
virtual workspace, but stating it keeps the intent reviewable now
that `src-tauri/Cargo.toml` is no longer a single package.
`.coderabbit.yaml` path_instructions: repoint the desktop-only rules
at the new `src-tauri/crates/app/src/{audio,commands}/**` locations
and add per-subsystem guidance for the new `waveflow-core` surface
(`crates/core/**`, `repository/**`, `scanner/**`, `smart_playlists/**`,
`metadata/**`, `audio_format/**`). Encodes the "no tauri/cpal in core,
SQLite behind the `sqlite` feature, push portable logic out of
commands" rules so future reviews flag drift back into the app crate.
\`cargo check --workspace --all-targets\` clean; \`cargo test
--workspace\` 111 passed / 0 failed.
Refs #128.
Triaged the 47-finding CodeRabbit pass on the Phase 1.a refactor; fixes the 3 issues either introduced by or directly owned by this PR. The rest are pre-existing in code the refactor merely moved and are left for dedicated follow-up PRs to keep this one "zero behaviour change for end users" as advertised. - `crates/core/src/repository/sqlite/playlist.rs::remove_track`: the `SELECT position` ran on `&self.pool` outside the transaction that did the DELETE + position-shifting UPDATE. A concurrent `remove_track` / `reorder_track` could mutate the row between the two and corrupt the renumber. Open the transaction first, run the SELECT on `&mut *tx`, commit the empty path explicitly when the row is missing. - `crates/app/src/commands/similar.rs::try_deezer`: replace the `source_name.to_lowercase()` / `h.name.to_lowercase()` pair with `canonical_name(...)` so artists like `AC/DC` and `P!nk` match their Deezer counterparts. `canonical_name` is already imported from `crate::commands::scan` for the cache-key path; this aligns the search-fallback path with the rest of the module. - `crates/app/Cargo.toml`: the comment next to the `zip = "8"` line still said "zip 4.x". Doc-only fix to stop misleading future readers about the bundle backend. `cargo check --workspace --all-targets` clean; `cargo test --workspace` 111 passed / 0 failed.
📝 WalkthroughWalkthroughMigration vers un workspace Cargo: création de waveflow-core (domain, repositories, scanner, metadata, audio_format, artwork, smart_playlists) et recâblage du crate Tauri via re-exports et dépôts SQLite; mise à jour des commandes, chemins de migrations, CI, docs, scripts Tauri et config. ChangesRefactor: extraction waveflow-core et recâblage waveflow (Tauri)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Poème
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/features/smart-playlists.md`:
- Line 3: Update the incorrect link target for the smart playlists engine in the
document: replace the path fragment pointing to "smart_playlists/" under the app
crate with the core crate path used elsewhere so the link targets the extracted
engine (i.e. change the link in the first paragraph that references
smart_playlists to the same "../../src-tauri/crates/core/src/smart_playlists"
form used in the other occurrences); ensure this single-line link is consistent
with the other references on lines 19, 61, 73, 99, 101, 137.
In `@scripts/tauri.mjs`:
- Around line 15-29: Rename the top-level constants CONFIG_PATH and CONFIG_AWARE
to camelCase (e.g., configPath and configAware) and update all usages in this
file (including the args construction that references CONFIG_AWARE and
CONFIG_PATH) to the new names; ensure you only change the identifier names
(preserve string value 'src-tauri/crates/app/tauri.conf.json' and the Set
contents), and run a quick search in this module for any remaining references to
CONFIG_PATH/CONFIG_AWARE to update them to configPath/configAware.
In `@src-tauri/crates/app/src/commands/edit.rs`:
- Around line 23-25: La fonction update_track_tags ne recalcule pas le hash du
fichier ni ne met à jour track.file_hash après avoir réécrit les tags; modifiez
update_track_tags (dans src-tauri/crates/app/src/commands/edit.rs) pour, si la
piste éditée est la piste courante, suspendre la lecture, ré-hasher le fichier
modifié avec blake3 et assigner le nouveau hash à track.file_hash, puis
persister cette mise à jour (et mettre à jour tout cache/lookup dépendant du
hash) avant de reprendre la lecture; suivez la même séquence de
pause-rehash-update/persist pour les autres commandes similaires (save_lyrics,
set_track_rating) si applicable.
In `@src-tauri/crates/app/src/commands/integration.rs`:
- Line 14: The Last.fm login path (lastfm_login) currently calls
LastfmClient::auth_get_mobile_session unconditionally and must short-circuit
when offline; add a guard at the start of lastfm_login that checks
offline::is_offline() (and the persisted app_setting['network.offline_mode']
flag) and immediately return the offline-appropriate result (e.g., an empty
payload or a LastfmError::Offline/consistent local error) instead of calling
LastfmClient::auth_get_mobile_session so no network call is attempted while
offline.
In `@src-tauri/crates/app/src/commands/playlist.rs`:
- Around line 619-628: The current import builds a PlaylistDraft and calls
SqlitePlaylistRepository::create_with_tracks(&draft, &matched) but matched
contains only existing track_ids so M3U imports lose tracks on new machines;
instead pass the M3U entries through the scanner's upsert logic first and use
the resulting track IDs for playlist creation. Update the flow around
PlaylistDraft creation to call the scanner upsert/upsert_tracks function (the
scanner module or service used elsewhere in import) with the raw M3U entries,
collect the returned/created track IDs, and then call
SqlitePlaylistRepository::create_with_tracks(&draft, &upserted_ids) so playlists
always round-trip via the scanner.
In `@src-tauri/crates/app/src/commands/profile.rs`:
- Around line 69-79: Après l'INSERT du profil via profile_repo().insert(...) et
set_data_dir(...), envelopper l'initialisation des répertoires/BD
(ensure_profile_dirs() et profile_db::open()) de sorte qu'en cas d'erreur on
supprime le profil créé pour ne pas laisser une entrée cassée; concrètement,
après avoir calculé rel_dir et appelé repo.set_data_dir(profile_id,
&rel_dir).await?, exécuter ensure_profile_dirs(profile_id) et l'ouverture
profile_db::open(...); si l'une de ces opérations échoue, appeler le repository
pour retirer le profil créé (par ex. repo.delete(profile_id) ou
repo.remove(profile_id) selon l'API existante) et ré-émmettre l'erreur; assurer
que cette suppression est effectuée même si set_data_dir a réussi afin de garder
la DB cohérente.
In `@src-tauri/crates/app/src/commands/track.rs`:
- Around line 490-494: The DB is left with an outdated track.file_hash after
set_track_rating mutates the audio file; update the flow in set_track_rating
(and analogous commands edit::update_track_tags, save_lyrics) to 1) if the
edited track is the current playback track, pause playback (call the existing
playback pause helper), 2) after the file write succeeds re-compute the BLAKE3
hash of the file (use the project's blake3 hashing helper) and 3) persist the
new hash to the DB (either via repo.update_file_hash(track_id, new_hash) or
include file_hash in the same DB update that sets the rating) before emitting
"track:updated".
In `@src-tauri/crates/core/src/repository/playlist.rs`:
- Around line 1-6: Mettre à jour le doc-comment en tête de playlist.rs pour
refléter que la logique de smart-playlists a été déplacée dans crates/core
(répertoire src/smart_playlists) : remplacez la phrase indiquant que la logique
reste dans `crates/app` par une mention claire que le moteur smart-playlists vit
désormais dans `crates/core::smart_playlists`, qu’il doit consommer
`PathsContext` et rester portable côté serveur; conservez le reste du
commentaire sur la responsabilité du repository (ne pas évaluer les règles) et
mentionnez explicitement le chemin `src-tauri/crates/core/src/smart_playlists`
et l’exigence `PathsContext` pour aider la navigation future.
In `@src-tauri/crates/core/src/repository/sqlite/library.rs`:
- Around line 39-43: The artist_count currently uses COUNT(DISTINCT
primary_artist) on the track table which misses normalized multi-artist data;
change the query that builds the library totals (the SELECT starting with
"SELECT library_id, COUNT(*) AS track_count, ... FROM track") to join the
track_artist table (e.g., INNER JOIN track_artist ON track.id =
track_artist.track_id) and compute artist_count as COUNT(DISTINCT
track_artist.artist_id) (filtering by the same library scope) so distinct
artists are counted from canonical rows; also ensure any display-string
rebuilding elsewhere follows the GROUP_CONCAT ordering-by-position rule against
track_artist rows.
In `@src-tauri/crates/core/src/repository/sqlite/track.rs`:
- Around line 164-179: The search_fts function currently binds the incoming
limit directly, which allows limit ≤ 0 (and negative values) to produce
unbounded results; before building or executing the query in search_fts, enforce
a minimum bound on the limit (e.g., replace or shadow the incoming limit with a
clamped value such as 1 when limit <= 0) and then bind that sanitized limit to
the SQL query so TrackRow fetch_all on self.pool cannot return unexpectedly
massive results.
In `@src-tauri/crates/core/src/scanner/upserts.rs`:
- Around line 52-56: La méthode actuelle utilise raw.split([',', ';']) qui casse
des noms contenant ces caractères; changez la logique pour ne splitter que sur
les délimiteurs exacts ", " et "; " (par exemple en recherchant les séquences ",
" et "; " ou en utilisant une regex qui matche ces séquences), conservez l'ordre
en produisant des éléments avec leur position/index et insérez chaque artiste
individuel dans les lignes track_artist en enregistrant la position (p.ex. champ
position lié au track_id), puis assurez-vous que la reconstruction du display
utilise GROUP_CONCAT ORDER BY position pour reconstituer l'affichage dans le bon
ordre.
🪄 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: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 4a506d62-5d5f-46af-8237-2383b071163b
⛔ Files ignored due to path filters (18)
src-tauri/Cargo.lockis excluded by!**/*.lock,!src-tauri/Cargo.locksrc-tauri/crates/app/icons/128x128.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/128x128@2x.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/32x32.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/64x64.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square107x107Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square142x142Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square150x150Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square284x284Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square30x30Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square310x310Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square44x44Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square71x71Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/Square89x89Logo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/StoreLogo.pngis excluded by!**/*.pngsrc-tauri/crates/app/icons/icon.icois excluded by!**/*.icosrc-tauri/crates/app/icons/icon.pngis excluded by!**/*.pngsrc-tauri/crates/core/src/smart_playlists/on_repeat.svgis excluded by!**/*.svg
📒 Files selected for processing (140)
.coderabbit.yaml.github/workflows/ci.yml.github/workflows/release-please-lockfile-build.yml.gitignoreCLAUDE.mddocs/architecture/audio.mddocs/architecture/crates.mddocs/architecture/storage.mddocs/features/dlna.mddocs/features/integrations.mddocs/features/library.mddocs/features/playback.mddocs/features/playlists.mddocs/features/smart-playlists.mddocs/features/ui.mdpackage.jsonscripts/tauri.mjssrc-tauri/.gitignoresrc-tauri/Cargo.tomlsrc-tauri/crates/app/Cargo.tomlsrc-tauri/crates/app/build.rssrc-tauri/crates/app/capabilities/default.jsonsrc-tauri/crates/app/icons/icon.icnssrc-tauri/crates/app/src/analysis.rssrc-tauri/crates/app/src/audio/analytics.rssrc-tauri/crates/app/src/audio/crossfade.rssrc-tauri/crates/app/src/audio/decoder.rssrc-tauri/crates/app/src/audio/engine.rssrc-tauri/crates/app/src/audio/eq.rssrc-tauri/crates/app/src/audio/mod.rssrc-tauri/crates/app/src/audio/output.rssrc-tauri/crates/app/src/audio/resampler.rssrc-tauri/crates/app/src/audio/spectrum.rssrc-tauri/crates/app/src/audio/state.rssrc-tauri/crates/app/src/audio/wasapi_exclusive.rssrc-tauri/crates/app/src/backup.rssrc-tauri/crates/app/src/commands/analysis.rssrc-tauri/crates/app/src/commands/app_info.rssrc-tauri/crates/app/src/commands/backup.rssrc-tauri/crates/app/src/commands/browse.rssrc-tauri/crates/app/src/commands/changelog.rssrc-tauri/crates/app/src/commands/deezer.rssrc-tauri/crates/app/src/commands/diagnostics.rssrc-tauri/crates/app/src/commands/dlna.rssrc-tauri/crates/app/src/commands/duplicates.rssrc-tauri/crates/app/src/commands/edit.rssrc-tauri/crates/app/src/commands/integration.rssrc-tauri/crates/app/src/commands/library.rssrc-tauri/crates/app/src/commands/lyrics.rssrc-tauri/crates/app/src/commands/maintenance.rssrc-tauri/crates/app/src/commands/mod.rssrc-tauri/crates/app/src/commands/mood_radio.rssrc-tauri/crates/app/src/commands/offline.rssrc-tauri/crates/app/src/commands/player.rssrc-tauri/crates/app/src/commands/playlist.rssrc-tauri/crates/app/src/commands/playlist_cover.rssrc-tauri/crates/app/src/commands/preferences.rssrc-tauri/crates/app/src/commands/profile.rssrc-tauri/crates/app/src/commands/profile_io.rssrc-tauri/crates/app/src/commands/radio.rssrc-tauri/crates/app/src/commands/scan.rssrc-tauri/crates/app/src/commands/share.rssrc-tauri/crates/app/src/commands/similar.rssrc-tauri/crates/app/src/commands/smart_playlists.rssrc-tauri/crates/app/src/commands/spotify.rssrc-tauri/crates/app/src/commands/stats.rssrc-tauri/crates/app/src/commands/track.rssrc-tauri/crates/app/src/commands/tray.rssrc-tauri/crates/app/src/commands/wrapped.rssrc-tauri/crates/app/src/db/app_db.rssrc-tauri/crates/app/src/db/migration_heal.rssrc-tauri/crates/app/src/db/mod.rssrc-tauri/crates/app/src/db/profile_db.rssrc-tauri/crates/app/src/discord_presence.rssrc-tauri/crates/app/src/dlna/cds.rssrc-tauri/crates/app/src/dlna/config.rssrc-tauri/crates/app/src/dlna/description.rssrc-tauri/crates/app/src/dlna/http.rssrc-tauri/crates/app/src/dlna/mod.rssrc-tauri/crates/app/src/dlna/scpd_connection_manager.xmlsrc-tauri/crates/app/src/dlna/scpd_content_directory.xmlsrc-tauri/crates/app/src/dlna/ssdp.rssrc-tauri/crates/app/src/error.rssrc-tauri/crates/app/src/lib.rssrc-tauri/crates/app/src/logging.rssrc-tauri/crates/app/src/main.rssrc-tauri/crates/app/src/media_controls.rssrc-tauri/crates/app/src/metadata_artwork.rssrc-tauri/crates/app/src/notifications.rssrc-tauri/crates/app/src/offline.rssrc-tauri/crates/app/src/paths.rssrc-tauri/crates/app/src/queue.rssrc-tauri/crates/app/src/scrobbler.rssrc-tauri/crates/app/src/smart_playlists.rssrc-tauri/crates/app/src/spotify.rssrc-tauri/crates/app/src/state.rssrc-tauri/crates/app/src/thumbnails.rssrc-tauri/crates/app/src/watcher.rssrc-tauri/crates/app/tauri.conf.jsonsrc-tauri/crates/core/Cargo.tomlsrc-tauri/crates/core/src/analysis.rssrc-tauri/crates/core/src/artwork/metadata.rssrc-tauri/crates/core/src/artwork/mod.rssrc-tauri/crates/core/src/artwork/thumbnails.rssrc-tauri/crates/core/src/audio_format/dsd/metadata.rssrc-tauri/crates/core/src/audio_format/dsd/mod.rssrc-tauri/crates/core/src/audio_format/dsd/parser.rssrc-tauri/crates/core/src/audio_format/dsd/pcm.rssrc-tauri/crates/core/src/audio_format/mod.rssrc-tauri/crates/core/src/domain/library.rssrc-tauri/crates/core/src/domain/mod.rssrc-tauri/crates/core/src/domain/playlist.rssrc-tauri/crates/core/src/domain/profile.rssrc-tauri/crates/core/src/domain/track.rssrc-tauri/crates/core/src/error.rssrc-tauri/crates/core/src/lib.rssrc-tauri/crates/core/src/metadata/deezer.rssrc-tauri/crates/core/src/metadata/lastfm.rssrc-tauri/crates/core/src/metadata/lrclib.rssrc-tauri/crates/core/src/metadata/mod.rssrc-tauri/crates/core/src/repository/library.rssrc-tauri/crates/core/src/repository/mod.rssrc-tauri/crates/core/src/repository/playlist.rssrc-tauri/crates/core/src/repository/profile.rssrc-tauri/crates/core/src/repository/sqlite/library.rssrc-tauri/crates/core/src/repository/sqlite/mod.rssrc-tauri/crates/core/src/repository/sqlite/playlist.rssrc-tauri/crates/core/src/repository/sqlite/profile.rssrc-tauri/crates/core/src/repository/sqlite/track.rssrc-tauri/crates/core/src/repository/track.rssrc-tauri/crates/core/src/scanner/extract.rssrc-tauri/crates/core/src/scanner/mod.rssrc-tauri/crates/core/src/scanner/upserts.rssrc-tauri/crates/core/src/smart_playlists/cover.rssrc-tauri/crates/core/src/smart_playlists/custom.rssrc-tauri/crates/core/src/smart_playlists/generator.rssrc-tauri/crates/core/src/smart_playlists/mod.rssrc-tauri/crates/core/src/smart_playlists/on_repeat.rssrc-tauri/src/commands/scan.rssrc-tauri/src/commands/track.rs
💤 Files with no reviewable changes (4)
- src-tauri/src/commands/scan.rs
- src-tauri/crates/app/src/audio/mod.rs
- src-tauri/crates/app/src/lib.rs
- src-tauri/src/commands/track.rs
| # Smart playlists | ||
|
|
||
| Auto-generated playlists materialised from the user's listening history. Today: a 3-slot **Daily Mix** family bucketed by tempo, plus a single **On Repeat** playlist tracking the user's top played tracks over the last 30 days. Tomorrow: "Repeat Rewind", "Release Radar", per-mood mixes — the engine in [`smart_playlists/`](../../src-tauri/src/smart_playlists) is built around a discriminated `SmartPlaylistRules` enum so new families plug in without touching the regen flow. | ||
| Auto-generated playlists materialised from the user's listening history. Today: a 3-slot **Daily Mix** family bucketed by tempo, plus a single **On Repeat** playlist tracking the user's top played tracks over the last 30 days. Tomorrow: "Repeat Rewind", "Release Radar", per-mood mixes — the engine in [`smart_playlists/`](../../src-tauri/crates/app/src/smart_playlists) is built around a discriminated `SmartPlaylistRules` enum so new families plug in without touching the regen flow. |
There was a problem hiding this comment.
Chemin incorrect vers le moteur de smart playlists.
La ligne 3 pointe vers crates/app/src/smart_playlists mais d'après les guidelines et la structure du PR, le moteur de smart playlists a été extrait dans crates/core. Le lien devrait pointer vers ../../src-tauri/crates/core/src/smart_playlists (et non crates/app).
Les lignes 19, 61, 73, 99, 101, 137 utilisent correctement crates/core/src/smart_playlists/* — seule la ligne 3 est incohérente.
📝 Proposition de correction
-Auto-generated playlists materialised from the user's listening history. Today: a 3-slot **Daily Mix** family bucketed by tempo, plus a single **On Repeat** playlist tracking the user's top played tracks over the last 30 days. Tomorrow: "Repeat Rewind", "Release Radar", per-mood mixes — the engine in [`smart_playlists/`](../../src-tauri/crates/app/src/smart_playlists) is built around a discriminated `SmartPlaylistRules` enum so new families plug in without touching the regen flow.
+Auto-generated playlists materialised from the user's listening history. Today: a 3-slot **Daily Mix** family bucketed by tempo, plus a single **On Repeat** playlist tracking the user's top played tracks over the last 30 days. Tomorrow: "Repeat Rewind", "Release Radar", per-mood mixes — the engine in [`smart_playlists/`](../../src-tauri/crates/core/src/smart_playlists) is built around a discriminated `SmartPlaylistRules` enum so new families plug in without touching the regen flow.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Auto-generated playlists materialised from the user's listening history. Today: a 3-slot **Daily Mix** family bucketed by tempo, plus a single **On Repeat** playlist tracking the user's top played tracks over the last 30 days. Tomorrow: "Repeat Rewind", "Release Radar", per-mood mixes — the engine in [`smart_playlists/`](../../src-tauri/crates/app/src/smart_playlists) is built around a discriminated `SmartPlaylistRules` enum so new families plug in without touching the regen flow. | |
| Auto-generated playlists materialised from the user's listening history. Today: a 3-slot **Daily Mix** family bucketed by tempo, plus a single **On Repeat** playlist tracking the user's top played tracks over the last 30 days. Tomorrow: "Repeat Rewind", "Release Radar", per-mood mixes — the engine in [`smart_playlists/`](../../src-tauri/crates/core/src/smart_playlists) is built around a discriminated `SmartPlaylistRules` enum so new families plug in without touching the regen flow. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/features/smart-playlists.md` at line 3, Update the incorrect link target
for the smart playlists engine in the document: replace the path fragment
pointing to "smart_playlists/" under the app crate with the core crate path used
elsewhere so the link targets the extracted engine (i.e. change the link in the
first paragraph that references smart_playlists to the same
"../../src-tauri/crates/core/src/smart_playlists" form used in the other
occurrences); ensure this single-line link is consistent with the other
references on lines 19, 61, 73, 99, 101, 137.
| const CONFIG_PATH = 'src-tauri/crates/app/tauri.conf.json'; | ||
|
|
||
| // `--config` is a per-subcommand flag on the Tauri CLI, not a global | ||
| // one, and not every subcommand accepts it. Empirically (Tauri CLI | ||
| // 2.11) only the subcommands that actually load `tauri.conf.json` | ||
| // take the flag: `dev`, `build`, `bundle`. Everything else (info, | ||
| // icon, signer, completions, …) is passed through unchanged. | ||
| const CONFIG_AWARE = new Set(['dev', 'build', 'bundle']); | ||
|
|
||
| const argv = process.argv.slice(2); | ||
| const [subcommand] = argv; | ||
| const args = | ||
| subcommand && CONFIG_AWARE.has(subcommand) | ||
| ? [subcommand, '--config', CONFIG_PATH, ...argv.slice(1)] | ||
| : argv; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Respecter la convention camelCase pour les identifiants JS.
Les identifiants CONFIG_PATH et CONFIG_AWARE ne suivent pas la convention imposée pour les fichiers JS. Renomme-les en camelCase pour rester cohérent.
♻️ Proposition de correctif
-const CONFIG_PATH = 'src-tauri/crates/app/tauri.conf.json';
+const configPath = 'src-tauri/crates/app/tauri.conf.json';
@@
-const CONFIG_AWARE = new Set(['dev', 'build', 'bundle']);
+const configAware = new Set(['dev', 'build', 'bundle']);
@@
- subcommand && CONFIG_AWARE.has(subcommand)
- ? [subcommand, '--config', CONFIG_PATH, ...argv.slice(1)]
+ subcommand && configAware.has(subcommand)
+ ? [subcommand, '--config', configPath, ...argv.slice(1)]As per coding guidelines **/*.{ts,tsx,js,jsx,rs}: Frontend uses camelCase naming, backend Rust uses snake_case naming for all identifiers and command parameters.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/tauri.mjs` around lines 15 - 29, Rename the top-level constants
CONFIG_PATH and CONFIG_AWARE to camelCase (e.g., configPath and configAware) and
update all usages in this file (including the args construction that references
CONFIG_AWARE and CONFIG_PATH) to the new names; ensure you only change the
identifier names (preserve string value 'src-tauri/crates/app/tauri.conf.json'
and the Set contents), and run a quick search in this module for any remaining
references to CONFIG_PATH/CONFIG_AWARE to update them to configPath/configAware.
| use waveflow_core::scanner::{ | ||
| canonical_name, split_artist_name, upsert_album, upsert_artist, upsert_artwork, upsert_genre, | ||
| }; |
There was a problem hiding this comment.
update_track_tags doit remettre track.file_hash à jour après écriture.
Ce flux réécrit bien le fichier, mais il ne recalcule toujours pas le BLAKE3 ni ne met à jour track.file_hash ensuite. Dès qu’un tag change, les caches et lookups basés sur le hash restent donc attachés à l’ancienne version du fichier.
As per coding guidelines "src-tauri/crates/app/src/commands/{edit,lyrics,track}.rs: Commands that rewrite audio files (edit::update_track_tags, save_lyrics, set_track_rating) MUST pause playback when the edited track is the current track, then re-hash with blake3 and update track.file_hash".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/app/src/commands/edit.rs` around lines 23 - 25, La fonction
update_track_tags ne recalcule pas le hash du fichier ni ne met à jour
track.file_hash après avoir réécrit les tags; modifiez update_track_tags (dans
src-tauri/crates/app/src/commands/edit.rs) pour, si la piste éditée est la piste
courante, suspendre la lecture, ré-hasher le fichier modifié avec blake3 et
assigner le nouveau hash à track.file_hash, puis persister cette mise à jour (et
mettre à jour tout cache/lookup dépendant du hash) avant de reprendre la
lecture; suivez la même séquence de pause-rehash-update/persist pour les autres
commandes similaires (save_lyrics, set_track_rating) si applicable.
| use chrono::Utc; | ||
| use serde::Serialize; | ||
|
|
||
| use waveflow_core::metadata::lastfm::{LastfmClient, LastfmError}; |
There was a problem hiding this comment.
Le login Last.fm doit respecter le mode hors-ligne.
En passant maintenant par le client partagé ici, lastfm_login reste un chemin réseau sans garde offline::is_offline(). Avec network.offline_mode activé, on tente quand même auth_get_mobile_session et on renvoie une erreur réseau au lieu d’un refus local cohérent.
As per coding guidelines "src-tauri/crates/**/*.rs: Every outbound HTTP path (Deezer, Last.fm, similar, LRCLIB) must check offline::is_offline() first and short-circuit to an empty payload or cache; persist in app_setting['network.offline_mode']".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/app/src/commands/integration.rs` at line 14, The Last.fm
login path (lastfm_login) currently calls LastfmClient::auth_get_mobile_session
unconditionally and must short-circuit when offline; add a guard at the start of
lastfm_login that checks offline::is_offline() (and the persisted
app_setting['network.offline_mode'] flag) and immediately return the
offline-appropriate result (e.g., an empty payload or a
LastfmError::Offline/consistent local error) instead of calling
LastfmClient::auth_get_mobile_session so no network call is attempted while
offline.
| let draft = PlaylistDraft { | ||
| name, | ||
| description: None, | ||
| color_id: "violet".to_string(), | ||
| icon_id: "music".to_string(), | ||
| now_ms: now, | ||
| }; | ||
| let (new_id, imported_u32) = SqlitePlaylistRepository::new(pool.clone()) | ||
| .create_with_tracks(&draft, &matched) | ||
| .await?; |
There was a problem hiding this comment.
L’import M3U ne round-trip toujours pas via le scanner.
create_with_tracks() ne reçoit ici que des track_id déjà présents en base. Dans un profil neuf ou sur une autre machine, un .m3u valide importe donc une playlist vide au lieu de repasser les entrées par la logique d’upsert du scanner avant de créer les liens de playlist.
As per coding guidelines "src-tauri/crates/**/*.rs: M3U import/export for playlists must round-trip tracks via the scanner's upsert logic".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/app/src/commands/playlist.rs` around lines 619 - 628, The
current import builds a PlaylistDraft and calls
SqlitePlaylistRepository::create_with_tracks(&draft, &matched) but matched
contains only existing track_ids so M3U imports lose tracks on new machines;
instead pass the M3U entries through the scanner's upsert logic first and use
the resulting track IDs for playlist creation. Update the flow around
PlaylistDraft creation to call the scanner upsert/upsert_tracks function (the
scanner module or service used elsewhere in import) with the raw M3U entries,
collect the returned/created track IDs, and then call
SqlitePlaylistRepository::create_with_tracks(&draft, &upserted_ids) so playlists
always round-trip via the scanner.
| // 4. DB update (always — even when the file write fell through). | ||
| repo.set_rating(track_id, rating).await?; | ||
|
|
||
| let _ = app.emit("track:updated", track_id); | ||
| Ok(()) |
There was a problem hiding this comment.
Mettre à jour track.file_hash après l’écriture du tag.
set_track_rating modifie potentiellement le fichier audio mais ne recalcule pas le BLAKE3 ni ne persiste track.file_hash. Ça laisse une empreinte DB obsolète après édition.
Proposition de correctif
@@
- // 4. DB update (always — even when the file write fell through).
- repo.set_rating(track_id, rating).await?;
+ // 4. DB update (always — even when the file write fell through).
+ repo.set_rating(track_id, rating).await?;
+
+ // 5. Si le fichier a été touché, recalculer le hash et le persister.
+ if let Some(ref path_str) = file_path {
+ let new_hash = waveflow_core::scanner::hash_file(std::path::Path::new(path_str))
+ .map_err(|e| crate::error::AppError::Other(format!("hash recompute failed: {e}")))?;
+ sqlx::query("UPDATE track SET file_hash = ? WHERE id = ?")
+ .bind(new_hash)
+ .bind(track_id)
+ .execute(&pool)
+ .await?;
+ }
let _ = app.emit("track:updated", track_id);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/app/src/commands/track.rs` around lines 490 - 494, The DB is
left with an outdated track.file_hash after set_track_rating mutates the audio
file; update the flow in set_track_rating (and analogous commands
edit::update_track_tags, save_lyrics) to 1) if the edited track is the current
playback track, pause playback (call the existing playback pause helper), 2)
after the file write succeeds re-compute the BLAKE3 hash of the file (use the
project's blake3 hashing helper) and 3) persist the new hash to the DB (either
via repo.update_file_hash(track_id, new_hash) or include file_hash in the same
DB update that sets the rating) before emitting "track:updated".
| //! `PlaylistRepository`: the storage surface for the `playlist` and | ||
| //! `playlist_track` tables of a profile's `data.db`. Smart-playlist | ||
| //! generation logic stays out of this trait — the repository only | ||
| //! moves rows around; rule evaluation lives in the smart-playlist | ||
| //! module (still in `crates/app` at this point, scheduled to migrate | ||
| //! in step 6). |
There was a problem hiding this comment.
Documentation obsolète sur l’emplacement du moteur smart playlists.
La note indique que la logique smart-playlist reste dans crates/app, alors que l’architecture cible de ce PR la place côté crates/core. Mets ce commentaire à jour pour éviter les futures régressions de découpage.
As per coding guidelines src-tauri/crates/core/src/smart_playlists/**: « Moteur de smart playlists (...) Doit consommer PathsContext (...) et rester portable côté serveur. »
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/core/src/repository/playlist.rs` around lines 1 - 6, Mettre
à jour le doc-comment en tête de playlist.rs pour refléter que la logique de
smart-playlists a été déplacée dans crates/core (répertoire src/smart_playlists)
: remplacez la phrase indiquant que la logique reste dans `crates/app` par une
mention claire que le moteur smart-playlists vit désormais dans
`crates/core::smart_playlists`, qu’il doit consommer `PathsContext` et rester
portable côté serveur; conservez le reste du commentaire sur la responsabilité
du repository (ne pas évaluer les règles) et mentionnez explicitement le chemin
`src-tauri/crates/core/src/smart_playlists` et l’exigence `PathsContext` pour
aider la navigation future.
| SELECT library_id, | ||
| COUNT(*) AS track_count, | ||
| COUNT(DISTINCT album_id) AS album_count, | ||
| COUNT(DISTINCT primary_artist) AS artist_count | ||
| FROM track |
There was a problem hiding this comment.
Le calcul de artist_count ignore le modèle multi-artistes normalisé.
Ici, COUNT(DISTINCT primary_artist) ne respecte pas la source canonique track_artist, donc le compteur d’artistes peut être faux dès qu’un titre a plusieurs artistes. Le comptage doit s’appuyer sur les lignes track_artist liées aux tracks disponibles de la bibliothèque.
As per coding guidelines src-tauri/crates/**/*.rs: « Multi-artist track queries must split display strings (...) store individual artists in track_artist rows (...) and rebuild display strings via GROUP_CONCAT ordered by position ».
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/core/src/repository/sqlite/library.rs` around lines 39 - 43,
The artist_count currently uses COUNT(DISTINCT primary_artist) on the track
table which misses normalized multi-artist data; change the query that builds
the library totals (the SELECT starting with "SELECT library_id, COUNT(*) AS
track_count, ... FROM track") to join the track_artist table (e.g., INNER JOIN
track_artist ON track.id = track_artist.track_id) and compute artist_count as
COUNT(DISTINCT track_artist.artist_id) (filtering by the same library scope) so
distinct artists are counted from canonical rows; also ensure any display-string
rebuilding elsewhere follows the GROUP_CONCAT ordering-by-position rule against
track_artist rows.
| async fn search_fts(&self, fts_query: &str, limit: i64) -> CoreResult<Vec<TrackRow>> { | ||
| let sql = format!( | ||
| "{SELECT_TRACK_ROW} \ | ||
| FROM track_fts fts \ | ||
| JOIN track t ON t.id = fts.rowid \ | ||
| LEFT JOIN album al ON al.id = t.album_id \ | ||
| LEFT JOIN artist ar ON ar.id = t.primary_artist \ | ||
| LEFT JOIN artwork aw ON aw.id = al.artwork_id \ | ||
| WHERE track_fts MATCH ? AND t.is_available = 1 \ | ||
| ORDER BY rank \ | ||
| LIMIT ?" | ||
| ); | ||
| let rows = sqlx::query_as::<_, TrackRow>(sqlx::AssertSqlSafe(sql)) | ||
| .bind(fts_query) | ||
| .bind(limit) | ||
| .fetch_all(&self.pool) |
There was a problem hiding this comment.
Borne limit avant d’exécuter la requête FTS.
limit peut être ≤ 0; avec SQLite, une valeur négative peut rendre la requête effectivement non bornée. Ajoute une borne minimale côté repository pour éviter un retour massif inattendu.
🔧 Correctif proposé
async fn search_fts(&self, fts_query: &str, limit: i64) -> CoreResult<Vec<TrackRow>> {
+ let limit = limit.max(1);
let sql = format!(
"{SELECT_TRACK_ROW} \
FROM track_fts fts \
JOIN track t ON t.id = fts.rowid \
LEFT JOIN album al ON al.id = t.album_id \
LEFT JOIN artist ar ON ar.id = t.primary_artist \
LEFT JOIN artwork aw ON aw.id = al.artwork_id \
WHERE track_fts MATCH ? AND t.is_available = 1 \
ORDER BY rank \
LIMIT ?"
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async fn search_fts(&self, fts_query: &str, limit: i64) -> CoreResult<Vec<TrackRow>> { | |
| let sql = format!( | |
| "{SELECT_TRACK_ROW} \ | |
| FROM track_fts fts \ | |
| JOIN track t ON t.id = fts.rowid \ | |
| LEFT JOIN album al ON al.id = t.album_id \ | |
| LEFT JOIN artist ar ON ar.id = t.primary_artist \ | |
| LEFT JOIN artwork aw ON aw.id = al.artwork_id \ | |
| WHERE track_fts MATCH ? AND t.is_available = 1 \ | |
| ORDER BY rank \ | |
| LIMIT ?" | |
| ); | |
| let rows = sqlx::query_as::<_, TrackRow>(sqlx::AssertSqlSafe(sql)) | |
| .bind(fts_query) | |
| .bind(limit) | |
| .fetch_all(&self.pool) | |
| async fn search_fts(&self, fts_query: &str, limit: i64) -> CoreResult<Vec<TrackRow>> { | |
| let limit = limit.max(1); | |
| let sql = format!( | |
| "{SELECT_TRACK_ROW} \ | |
| FROM track_fts fts \ | |
| JOIN track t ON t.id = fts.rowid \ | |
| LEFT JOIN album al ON al.id = t.album_id \ | |
| LEFT JOIN artist ar ON ar.id = t.primary_artist \ | |
| LEFT JOIN artwork aw ON aw.id = al.artwork_id \ | |
| WHERE track_fts MATCH ? AND t.is_available = 1 \ | |
| ORDER BY rank \ | |
| LIMIT ?" | |
| ); | |
| let rows = sqlx::query_as::<_, TrackRow>(sqlx::AssertSqlSafe(sql)) | |
| .bind(fts_query) | |
| .bind(limit) | |
| .fetch_all(&self.pool) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/core/src/repository/sqlite/track.rs` around lines 164 - 179,
The search_fts function currently binds the incoming limit directly, which
allows limit ≤ 0 (and negative values) to produce unbounded results; before
building or executing the query in search_fts, enforce a minimum bound on the
limit (e.g., replace or shadow the incoming limit with a clamped value such as 1
when limit <= 0) and then bind that sanitized limit to the SQL query so TrackRow
fetch_all on self.pool cannot return unexpectedly massive results.
| raw.split([',', ';']) | ||
| .map(|s| s.trim()) | ||
| .filter(|s| !s.is_empty()) | ||
| .map(|s| s.to_string()) | ||
| .collect() |
There was a problem hiding this comment.
Le split multi-artistes ne respecte pas le contrat ", " / "; ".
Le code split actuellement sur chaque ,/;, ce qui peut casser des noms valides et polluer track_artist (IDs/artistes faux). Il faut splitter uniquement sur les délimiteurs de display spécifiés.
🔧 Correctif proposé
pub fn split_artist_name(raw: &str) -> Vec<String> {
- raw.split([',', ';'])
+ raw.split("; ")
+ .flat_map(|chunk| chunk.split(", "))
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}As per coding guidelines Multi-artist track queries must split display strings on ", " / "; ", store individual artists in track_artist rows linked by position, and rebuild display strings via GROUP_CONCAT ordered by position.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/crates/core/src/scanner/upserts.rs` around lines 52 - 56, La
méthode actuelle utilise raw.split([',', ';']) qui casse des noms contenant ces
caractères; changez la logique pour ne splitter que sur les délimiteurs exacts
", " et "; " (par exemple en recherchant les séquences ", " et "; " ou en
utilisant une regex qui matche ces séquences), conservez l'ordre en produisant
des éléments avec leur position/index et insérez chaque artiste individuel dans
les lignes track_artist en enregistrant la position (p.ex. champ position lié au
track_id), puis assurez-vous que la reconstruction du display utilise
GROUP_CONCAT ORDER BY position pour reconstituer l'affichage dans le bon ordre.
|
Other findings memo :
In @src-tauri/crates/app/Cargo.toml around lines 208 - 219, Update the outdated comment that says "zip 4.x" to reflect the actual dependency version "zip 8.x" next to the zip = { version = "8", default-features = false, features = ["deflate", "time"] } declaration; edit the comment text around that dependency in Cargo.toml so it accurately says "zip 8.x" (or "zip 8") and retains the note about deflate and pure-Rust backend.
In @src-tauri/crates/app/src/audio/analytics.rs around lines 209 - 227, The code issues two separate SQL queries to fetch album_id for current_id and track.id; replace them with one query using WHERE id IN (?, ?) against the same pool and bind both IDs in order, fetch the two results (e.g., with query_and_then or fetch_all) and assign to current_album and next_album (Option) before computing same_album; update the block that currently sets current_album/next_album and same_album so it executes one sqlx query on pool, extracts the two optional album_ids mapped to the correct IDs, and preserves the existing same_album comparison logic.
In @src-tauri/crates/app/src/audio/engine.rs around lines 395 - 495, Both set_output_device and set_wasapi_exclusive duplicate the async resume logic (DB lookup of file_path/duration, fetch_replay_gain_db, then send AudioCmd::LoadAndPlay); extract this into a helper like resume_playback_async(app: tauri::AppHandle, cmd_tx: Sender, track_id: i64, position_ms: u64) that performs the SQL query, calls crate::commands::player::fetch_replay_gain_db, constructs the LoadAndPlay payload and sends it; replace the duplicated tauri::async_runtime::spawn blocks in set_output_device and set_wasapi_exclusive with a single call to spawn(resume_playback_async(...)).
In @src-tauri/crates/app/src/commands/browse.rs around lines 753 - 770, The SQL ranking in the AlbumTrackRaw query (CTE named ranked used by tracks_raw) currently orders by t.bit_depth DESC which penalizes DSD tracks (bit_depth = 1); change the ORDER BY to treat DSD as highest quality by mapping or prioritizing bit_depth = 1 first (e.g. add (t.bit_depth = 1) DESC before t.bit_depth DESC or replace t.bit_depth with a CASE that maps bit_depth = 1 to a very large value), keeping the existing secondary sort keys (t.sample_rate DESC, t.file_size DESC, t.id ASC) so the collapse keeps DSD variants for mixed DSD/PCM albums.
In @src-tauri/crates/app/src/commands/browse.rs around lines 1381 - 1402, The computed start_ms is wrong because r.bucket is in localtime but you reconstruct the midnight as UTC via and_utc(); replace that logic that builds start_ms to construct a local midnight using chrono::Local (e.g. Local.with_ymd_and_hms or Local.from_local_datetime) for year/month from r.bucket and handle the chrono::LocalResult variants (Single, Ambiguous, None) deterministically (pick one of the ambiguous datetimes or return an error) before calling timestamp_millis() so start_ms matches the SQL bucket timezone.
In @src-tauri/crates/app/src/commands/deezer.rs around lines 531 - 538, The UPDATE that sets artwork_id for an album currently ignores the query result so an invalid album_id can return Ok(()) without modifying anything; after calling sqlx::query(...).execute(&pool).await, capture the result (e.g., let res = ...), call res.rows_affected() and return an Err (or propagate a not-found error) when rows_affected() == 0 so callers see a failure; apply the same fix pattern for the analogous block around lines 564-571. Ensure you reference the upsert_artwork_row call and the album_id bind to locate the UPDATE statement to modify.
In @src-tauri/crates/app/src/commands/deezer.rs around lines 781 - 831, batch_fetch_missing_album_covers currently calls enrich_album_inner but never persists the new artwork relationship, so albums stay with artwork_id NULL; after a successful enrich_album_inner return (in batch_fetch_missing_album_covers) check the returned enrich object for the new artwork identifier (e.g. enrich.artwork_id or derive it from enrich.cover_path) and execute an UPDATE on the album row to set album.artwork_id to that artwork's id (or insert the artwork and use its id), ensuring the DB column read by list_albums/get_album_detail is updated; update the success counting only after the DB update succeeds and reference the functions enrich_album_inner and batch_fetch_missing_album_covers and the album.artwork_id column when making the change.
In @src-tauri/crates/app/src/commands/dlna.rs around lines 44 - 52, The function build_resources is declared pub but appears to be used only within this crate/module; change its visibility to pub(crate) by updating the function signature for build_resources (which returns DlnaResources and takes &AppState) to restrict export to the crate, and run cargo check to ensure no external callers break—if any external uses exist, either keep it pub or update those call sites accordingly.
In @src-tauri/crates/app/src/commands/duplicates.rs around lines 66 - 71, The correlated subquery using GROUP_CONCAT to build artist_name for each track (SELECT GROUP_CONCAT(name, ', ') ... FROM track_artist ta JOIN artist ar ...) can be very slow on large libraries; change the query to avoid per-row subqueries by LEFT JOINing track_artist and artist to the main tracks result and aggregating artists in a single GROUP BY, or alternatively fetch tracks first and batch-query/articulate artist lists in Rust; update references to the artist_name projection, the track_artist and artist joins, and any code consuming artist_name (e.g., the alias artist_name and query using t.id) to match the new shape so duplicates.rs produces the same artist list without per-row GROUP_CONCAT subqueries.
In @src-tauri/crates/app/src/commands/duplicates.rs around lines 155 - 162, The current loop in the duplicates removal path iterates over track_ids and issues a separate DELETE per id (see the for id in track_ids loop, sqlx::query("DELETE FROM track WHERE id = ?"), tx and deleted variable), which is inefficient; replace it with a single batched DELETE executed once: either construct a parametrized DELETE ... WHERE id IN (?,?,...) with placeholders for all ids (or if you prefer SQLite-specific approach use DELETE FROM track WHERE id IN (SELECT value FROM json_each(?)) and bind a JSON array of ids), execute that single sqlx::query(...).bind(...).execute(&mut *tx).await, and set deleted from the returned rows_affected; also short-circuit when track_ids is empty to avoid invalid SQL.
In @src-tauri/crates/app/src/commands/edit.rs around lines 184 - 196, La fonction update_tracks_batch ne met pas à jour file_hash après l'appel à write_tags_to_file; applique la même correction que dans update_track_tags: après un write_tags_to_file(&path, &edit) réussi, recalculer/obtenir le nouveau file_hash (même logique que utilisée dans update_track_tags) et affecter-le à edit.file_hash (ou au champ approprié) avant d'appeler sync_db(&pool, *track_id, &edit). Assure-toi d'attraper et propager toute erreur éventuelle lors du recalcul du hash, et de conserver l'ajout d'erreurs à summary.errors pour track_id en cas d'échec.
In @src-tauri/crates/app/src/commands/edit.rs around lines 547 - 558, The code currently computes a blake3 hash of the image bytes and uses that for the artwork cache, but it never updates the track's file_hash after write_cover_to_file; compute the hash of the modified audio file (the file at file_path) after calling write_cover_to_file and assign it to track.file_hash (and persist it via the same updater you use elsewhere), leaving the image hashing logic (blake3::hash(&bytes) -> artwork cache) intact; update references to file_path, write_cover_to_file, track.file_hash and any save/update function used to persist the track so the stored file_hash reflects the new audio file contents rather than the image bytes.
In @src-tauri/crates/app/src/commands/edit.rs around lines 95 - 101, After write_tags_to_file(&path, &edit) completes, recompute the file's BLAKE3 hash and update track.file_hash on the edit object before calling sync_db(&pool, track_id, &edit). Specifically, after write_tags_to_file return, read the file at path and compute a BLAKE3 digest (use blake3::Hasher with streaming for large files), set edit.track.file_hash (or the equivalent field inside edit) to the new hex hash, then proceed to sync_db so the DB and scanner fast-path (mtime,size) remain consistent with the modified file.
In @src-tauri/crates/app/src/commands/integration.rs around lines 173 - 221, Before making the HTTP call via LastfmClient::auth_get_mobile_session in lastfm_login, call offline::is_offline() and short-circuit the flow when offline (do not call auth_get_mobile_session); return an appropriate LastfmStatus (e.g., configured flag based on local config presence but connected: false and username: None) or an AppResult error according to project convention so callers get a clear offline response instead of a network timeout. Ensure the check occurs before creating/using LastfmClient or invoking auth_get_mobile_session and keep existing DB write logic skipped when offline.
In @src-tauri/crates/app/src/commands/lyrics.rs around lines 1107 - 1115, The current code always pauses playback when payload.write_to_file is true (via engine.send(crate::audio::AudioCmd::Pause)), which must be restricted to Windows only; change the pause logic to run only on Windows (e.g., cfg!(windows) or cfg attribute) and keep behavior unchanged on macOS/Linux; after writing lyrics/files (in save_lyrics / edit::update_track_tags / set_track_rating paths) re-compute the file hash with blake3 and assign it to track.file_hash so the scanner’s (mtime,size) fast path remains valid, and preserve the existing small delay (tokio::time::sleep) only when the Windows-only pause is executed.
In @src-tauri/crates/app/src/commands/maintenance.rs around lines 34 - 72, The regen_in_dir function performs blocking filesystem operations (std::fs::read_dir, entry.path/is_file, file_stem) in a synchronous function, which can block the Tokio runtime; convert it into an async function (e.g., async fn regen_in_dir) and move blocking work into tokio::task::spawn_blocking or replace with async equivalents from tokio::fs (tokio::fs::read_dir, DirEntry::path/metadata) to avoid blocking; keep the same AppResult return semantics, preserve the existing error mapping to AppError::Io and tracing::warn calls, and continue to call crate::thumbnails::generate_thumbnails from the appropriate context (if generate_thumbnails is blocking, spawn that call on spawn_blocking as well) so behavior and logging remain unchanged.
In @src-tauri/crates/app/src/commands/player.rs around lines 754 - 762, The A-B loop handling currently only clears when both a_ms and b_ms are None; change it so passing None clears only that endpoint: when a_ms is Some store the value into shared.loop_a_ms (Ordering::Release) and when a_ms is None reset/clear the A endpoint (e.g., call the shared method that clears A or store a sentinel/reset value), do the same for b_ms with shared.loop_b_ms, and keep shared.clear_ab_loop() only for the case where both are None; update the logic in the function (e.g., player_set_ab_loop) and/or adjust the doc comment if the original behavior was intended.
In @src-tauri/crates/app/src/commands/playlist.rs around lines 396 - 419, canonical_path_key currently forces to_lowercase for all platforms which breaks case-sensitive filesystems; update the function so the UNC/verbatim prefix stripping and to_lowercase() are only applied on Windows (use cfg!(windows) or #[cfg(windows)] logic). Keep the canonicalize call and the s/canon variables, but if not on Windows simply return s (or s.as_ref()) without calling to_lowercase or performing the has_verbatim/has_unc checks; on Windows preserve the existing has_verbatim/has_unc handling and to_lowercase behavior. Ensure you reference canonical_path_key, canon, s, has_verbatim and has_unc when making the change so the platform-specific behavior is localized.
In @src-tauri/crates/app/src/commands/profile.rs around lines 242 - 249, The code currently calls the blocking std::fs::remove_dir_all on dir, which can block the Tokio runtime; replace that call with the async tokio::fs::remove_dir_all(&dir).await and keep the same error handling (tracing::warn!) so the directory removal is non‑blocking; if you cannot change the enclosing function to async, instead move the blocking call into tokio::task::spawn_blocking(|| std::fs::remove_dir_all(dir)) and .await that JoinHandle, preserving the existing error logging.
In @src-tauri/crates/app/src/commands/scan.rs around lines 153 - 159, The code currently hardcodes album_artist: None and is_compilation: false for DSD tracks, which loses Album Artist/compilation metadata and breaks album grouping; update the DSD metadata path to propagate the reader-extracted Album Artist and compilation flag into the record instead of setting those neutral defaults: locate where album_artist and is_compilation are set in the scan handling (symbols album_artist and is_compilation in the DSD branch of the scan logic) and replace the hardcoded values with the values returned from the DSD reader/parser (or map the reader's Album Artist tag and compilation flag into album_artist and is_compilation), ensuring the grouping key logic (canonical_title + album_artist_id and merge_implicit_compilations behavior) receives the actual metadata.
In @src-tauri/crates/app/src/commands/similar.rs around lines 506 - 511, The comparison uses to_lowercase() causing mismatches for names like "AC/DC" or "P!nk"; import canonical_name (from crate::commands::scan or waveflow_core::scanner) and use it instead of to_lowercase() when building canon and when comparing hit names inside the closure (the hits.into_iter().find(|h| ... ) block) so both source_name and h.name are canonicalized the same way before equality check; update the imports to include canonical_name and replace the two to_lowercase() calls with canonical_name(...) calls.
In @src-tauri/crates/app/src/commands/smart_playlists.rs around lines 115 - 133, Wrap the playlist INSERT and the subsequent custom::materialize call in a single database transaction so they succeed or fail together: start a transaction with pool.begin(), run the INSERT using the transaction (use sqlx::query(...).execute(&mut tx).await?), get the playlist_id from the insert result, then call custom::materialize using the same transaction (adjust custom::materialize to accept &mut Transaction if it currently takes &Pool or &PoolConnection), and only commit tx if materialize returns Ok (rollback on error). Apply the same transactional change to the other occurrence (the code around custom::materialize at the second location).
In @src-tauri/crates/app/src/commands/spotify.rs around lines 54 - 149, All Spotify command paths (spotify_login, spotify_get_access_token, spotify_list_playlists, spotify_get_playlist_tracks, spotify_get_queue, spotify_search) currently perform network ops or open a browser without checking offline mode; update each exported command to call offline::is_offline() at the start and short-circuit when true: for read/list/search calls return the appropriate empty payload types (e.g., empty Vec or default SpotifySearchResults) or a clear AppError/Result consistent with other services, and for spotify_login prevent opening the browser/awaiting callback and return an AppError or a SpotifyStatus indicating offline; ensure you use the same return shapes as each function (SpotifyAccessToken, Vec<...>, SpotifySearchResults, SpotifyStatus, spotify::SpotifyQueueSnapshot) so callers still deserialize correctly.
In @src-tauri/crates/app/src/commands/track.rs around lines 454 - 495, After writing the rating via write_rating_to_file in set_track_rating, recompute the file's blake3 hash on a blocking thread and persist it to the DB so track.file_hash stays in sync: inside the Some(path_str) branch (after write_rating_to_file returns), spawn a tokio::task::spawn_blocking that reads the file and computes the blake3 hash, then call the repository update to set the new file_hash (mirror the pattern used in save_lyrics), and ensure any errors from hashing or updating are logged (non-fatal) while still allowing repo.set_rating(track_id, rating).await? to run as before.
In @src-tauri/crates/app/src/db/profile_db.rs around lines 44 - 55, The ATTACH DATABASE SQL is built via format! using app_db_path_owned inside the after_connect closure which looks unsafe at first glance; add a clear code comment next to the after_connect/ app_db_path_owned usage stating that the path is not user-controlled and documenting its origin (e.g. initialized from AppState.paths.app_db at startup from a system-derived path), and note why escaping apostrophes is applied and that SQLite does not support parameter binding for ATTACH; reference the after_connect closure, app_db_path_owned variable, and AppState.paths.app_db so reviewers can verify the guarantee.
In @src-tauri/crates/app/src/discord_presence.rs around lines 357 - 397, The resolve_cover_url function currently calls crate::commands::deezer::enrich_album_inner which performs outbound HTTP; before invoking enrich_album_inner (and after resolving album_id), check offline::is_offline() and if it returns true skip the enrichment and return None (or keep existing cached result) to avoid network calls in offline mode; reference resolve_cover_url, enrich_album_inner, and offline::is_offline() when making the conditional guard.
In @src-tauri/crates/app/src/dlna/cds.rs around lines 380 - 390, The SQL selected column names do not match the fields expected by TrackRow in BrowseMetadata: the query selects t.primary_artist and t.bit_rate while sqlx::query_as::<_, TrackRow> expects artist_name and bitrate; update the SELECT to alias those columns to the struct field names (e.g. t.primary_artist AS artist_name and t.bit_rate AS bitrate) or alternatively rename TrackRow fields to match the SQL, ensuring the projection in the query and the TrackRow field names (used in the sqlx::query_as call) are identical.
In @src-tauri/crates/app/src/dlna/description.rs around lines 85 - 98, The xml_escape function currently preallocates String::with_capacity(input.len()), which underestimates worst-case growth when characters are escaped; update xml_escape to use a more conservative hint such as String::with_capacity(input.len() * 2) or String::with_capacity(input.len() + 32) so fewer reallocations occur when replacing chars like '&', '"', ''' with multi-byte entities.
In @src-tauri/crates/app/src/dlna/description.rs around lines 126 - 131, Le test existant descriptor_escapes_xml_metachars ne couvre que <, > et &; ajoute dans la même fonction (ou un nouveau test) un input comme device_descriptor("Test's "quoted"", "http://x") et vérifie que la sortie contient ' pour l'apostrophe et " pour les guillemets doubles; referencie la fonction device_descriptor lors de l'assertion pour s'assurer que les entités XML ' et " sont bien produites.
In @src-tauri/crates/app/src/dlna/http.rs around lines 232 - 258, The path-traversal check in serve_art (checking hash_with_ext.contains('/') || hash_with_ext.contains("..")) misses Windows backslashes; update the validation for hash_with_ext to reject any path-separator or parent-dir tokens (e.g., check for '/' and '\' and "..", or better use Path::new(&hash_with_ext).components().any(Component::ParentDir) and also ensure no separator characters) before joining against ctx.profile_artwork_dir / ctx.metadata_artwork_dir so that malicious inputs like "..\..." are refused; keep the rest of the serve_art flow (mime_for_art, tokio::fs::read) unchanged.
In @src-tauri/crates/app/src/dlna/mod.rs around lines 274 - 284, pick_lan_ip() currently returns the first non-loopback IPv4 which can pick Docker/VPN addresses; update it to prefer RFC1918 LAN addresses by filtering for 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16 first (return the first match), falling back to the first non-loopback IPv4 if none match; reference the pick_lan_ip function to implement this prioritized filtering so the SSDP-announced URL is more likely to be a real LAN address (you can keep the existing fallback behavior used elsewhere intact).
In @src-tauri/crates/app/src/paths.rs around lines 36 - 39, The conversion currently drops the original error from handle.path().app_data_dir() by using .map_err(|_| AppError::MissingAppDataDir); instead, preserve context by mapping the error into AppError with the source (e.g., change AppError::MissingAppDataDir to hold a Box or implement Fromanyhow::Error and do .map_err(|e| AppError::MissingAppDataDir(e.into()))), or if changing the enum shape isn’t possible, log the original error before mapping (e.g., capture e in .map_err(|e| { log::error!("app_data_dir failed: {:?}", e); AppError::MissingAppDataDir })). Ensure you reference handle.path().app_data_dir() and AppError::MissingAppDataDir when making the change.
In @src-tauri/crates/app/src/queue.rs around lines 937 - 975, The preshuffle snapshot currently serializes only track IDs, losing per-item identity and metadata so write_queue_order recreates rows as source_type='manual'/source_id=NULL and unshuffle uses track_id to find the current slot; instead, change the preshuffle/unshuffle flow to serialize the full queue item identity and metadata (the queue row primary key/ID plus source_type, source_id and any other columns used by append_to_user_queue) in preshuffle_json in preshuffle (where preshuffle_json is created) and restore by re-inserting/restoring those exact queue items (or calling a new helper like write_queue_order_from_items) so write_queue_order preserves original source_type/source_id and you locate the current index by the queue item ID (not track_id) when computing new_index in unshuffle/current_track; update any call sites (including append_to_user_queue logic) to rely on the stable queue item ID for boundaries.
In @src-tauri/crates/app/src/scrobbler.rs around lines 213 - 241, The current filter_map in fetch_due (inside run_once) drops rows with a NULL/empty artist but leaves those scrobble_queue entries in the DB so they are retried forever; change the logic to collect the IDs of dropped/poison rows while iterating (use the existing row tuple with the id and the artist variable), return None for those entries, and after fetch_due returns (in run_once) execute a single deletion of those IDs from scrobble_queue (e.g. DELETE ... WHERE id IN (...)) so the poison rows are removed immediately; reference the PendingScrobble construction, the artist variable, fetch_due and run_once and the scrobble_queue table when locating where to add the ID collection and deletion.
In @src-tauri/crates/app/src/spotify.rs around lines 423 - 444, La fonction list_playlists ne pagine que la première page (limit=50) et coupe les playlists au-delà de 50 ; changez-la pour itérer sur toutes les pages en appelant get_json successivement (ou en suivant la Page.next URL) jusqu'à ce que plus de pages n'existent, en accumulant les items Option comme actuellement, en aplatissant les None, en convertissant chaque PlaylistResponse via SpotifyPlaylistLite::from, et en retournant la collection complète; réutilisez les types Page, PlaylistResponse, SpotifyPlaylistLite et la constante API_BASE pour construire/follow la requête de pagination.
In @src-tauri/crates/app/src/spotify.rs around lines 466 - 503, La boucle de pagination silencieusement tronque les playlists quand MAX_PAGES (const MAX_PAGES) est atteint; remplacez le cas qui casse silencieusement par un retour d'erreur explicite: dans le match sur page.next, au lieu de tomber dans le _ => break quand pages >= MAX_PAGES et page.next.is_some(), renvoyez un Err décrivant que la pagination a été interrompue par le cap MAX_PAGES (inclure playlist_id, pages et éventuellement item_count) afin que l'appelant sache que la liste est incomplète; conservez le comportement actuel pour les autres chemins et utilisez les mêmes types d'erreur/context que les autres appels (p.ex. ceux autour de get_json/PlaylistTrackItem).
In @src-tauri/crates/app/src/spotify.rs around lines 290 - 326, Both exchange_code and refresh_token call the network even when the app is in offline mode; add an early check using offline::is_offline() at the top of each function (exchange_code and refresh_token) and short-circuit instead of making the reqwest POST: return a cached TokenResponse if available or an empty/default TokenResponse (Ok(TokenResponse::default())) so the UI stays consistent with app_setting['network.offline_mode'] (persisted offline state). Ensure you consult any existing cache retrieval logic before returning the empty response so cached tokens are used when present.
In @src-tauri/crates/app/src/state.rs around lines 176 - 180, The code calls previous.pool.close().await while holding the write lock obtained from self.profile.write(), which can block; change the flow to take the previous ActiveProfile out of the write guard, drop/release the guard, then perform previous.pool.close().await outside the lock, and finally re-acquire the write lock to set *guard = Some(ActiveProfile { profile_id, pool }) if necessary. Concretely: use guard.take() to extract previous, immediately drop the write guard (let guard go out of scope), await previous.pool.close().await if Some, then re-open self.profile.write().await and assign the new ActiveProfile.
In @src-tauri/crates/app/tauri.conf.json around lines 40 - 50, La configuration actuelle met "csp": null, ce qui désactive la protection XSS pour le renderer ; remplacez la valeur null de la clé csp dans tauri.conf.json par une politique CSP restrictive (par exemple restreindre default-src, script-src et style-src à 'self' et autorisations minimales) afin de protéger le WebView, tout en conservant assetProtocol.enable et les scopes existants ; assurez-vous que la politique couvre les besoins légitimes de votre UI (ajoutez nonces/hashes ou hôtes explicitement autorisés si nécessaire) et documentez tout assouplissement dans le repo.
In @src-tauri/crates/core/src/artwork/thumbnails.rs around lines 47 - 49, The current pre-check "if out.exists() { continue; }" in generate_thumbnails is vulnerable to TOCTOU when multiple threads/processes handle the same hash; replace it with either a per-hash lock/mutex keyed by the thumbnail hash (acquire before existence check and release after write) or perform atomic writes by writing to a temp file in the same directory and then rename() to
In @src-tauri/crates/core/src/audio_format/dsd/parser.rs at line 357, Replace the test uses of std::iter::repeat(value).take(count) with the stabilized std::iter::repeat_n(value, count) in the DSD parser tests: change the call inside the out.extend(...) where payload_bytes is used (the expression out.extend(std::iter::repeat(0xAA).take(payload_bytes as usize))) and the analogous site near the other test (the second repeat/take at the other test). Use repeat_n(0xAA, payload_bytes as usize) (and the equivalent for the other case) to keep the same behavior and casts while using the modern API.
In @src-tauri/crates/core/src/metadata/deezer.rs around lines 73 - 81, Add a Default implementation for DeezerClient that delegates to the existing constructor: implement std::default::Default for DeezerClient with fn default() -> Self { DeezerClient::new() }; this keeps the current reqwest client construction in DeezerClient::new() and makes DeezerClient usable with Default::default() and derive-based APIs while changing no construction logic.
In @src-tauri/crates/core/src/metadata/lrclib.rs around lines 56 - 81, The get method in Lrclib client makes an outbound HTTP call without checking offline mode; add an early check at the top of pub async fn get to call offline::is_offline() and short-circuit (e.g., return Ok(None) or load from cache) so no request is sent when offline; update any callers/tests if they expect a different empty payload and keep references to symbols: offline::is_offline, get, LrclibResponse, BASE_URL, and self.http to locate where to add the guard.
In @src-tauri/crates/core/src/repository/sqlite/playlist.rs around lines 191 - 223, The current append_tracks() and create_with_tracks() implementations hold a single long-running transaction while inserting thousands of playlist_track rows; change them to process track_ids in chunks (e.g., batch size 200) using repeated transactions obtained from self.pool.begin(), run the per-row INSERT OR IGNORE (or call the existing upsert helpers that accept &mut sqlx::SqliteConnection) inside each transaction, commit after each batch, and then update playlist.updated_at (in a final short transaction or the last batch) so the write lock and WAL growth are bounded; ensure next_position is preserved across batches and use the upsert helper signatures that accept &mut SqliteConnection to participate in the opened transaction.
In @src-tauri/crates/core/src/repository/sqlite/playlist.rs around lines 233 - 264, The SELECT that reads removed_position must be executed inside the same transaction as the DELETE/UPDATE to avoid races: move the sqlx::query_scalar("SELECT position FROM playlist_track ...").bind(playlist_id).bind(track_id).fetch_optional(...) so it runs after let mut tx = self.pool.begin().await? and use fetch_optional(&mut *tx) (not &self.pool), then pattern-match the Option (let Some(pos) = removed_position) while still holding tx before performing the DELETE, the position-shifting UPDATE and the playlist updated_at UPDATE, and finally commit the tx; update variable references (removed_position/pos/tx) accordingly.
In @src-tauri/crates/core/src/repository/track.rs around lines 86 - 90, The FTS5 query built by the caller can contain unescaped special characters (parentheses, boolean operators like AND/OR/NOT, quotes, etc.) which produces invalid FTS5 syntax and crashes search_fts; update the code so search_fts validates and/or escapes FTS5 metacharacters before binding or catches FTS5 syntax errors and returns a clear CoreResult error. Concretely, in search_fts (and/or the caller that appends * to words) implement a sanitizer that removes/escapes characters like ()"'+-*/^:[]{}<> and reserved words, or run a lightweight validator that rejects malformed tokens and returns a user-friendly error; additionally wrap the sqlx query in error handling to map FTS5 syntax errors into a recoverable CoreResult with guidance. Ensure references to search_fts and the caller that composes fts_query are updated accordingly.
In @src-tauri/crates/core/src/scanner/extract.rs around lines 120 - 125, file_type_label currently maps FileType::Mp4 to "AAC" which mislabels ALAC; update file_type_label to inspect the Mp4 variant's codec (e.g., match FileType::Mp4(codec) or use the Mp4Codec accessor) and return the specific codec label (e.g., "ALAC" for Mp4Codec::Alac, "AAC" for Mp4Codec::Aac) and fall back to a generic container label like "MP4" for unknown/other codecs; modify the match arm for FileType::Mp4 in file_type_label to perform this nested match against Mp4Codec instead of unconditionally returning "AAC".
In @src-tauri/crates/core/src/scanner/upserts.rs around lines 29 - 57, La doc pour split_artist_name dit qu'on ne doit scinder que sur les séquences exactes ", " et "; ", donc ajuste l'implémentation de split_artist_name pour respecter ça (ou mets à jour la doc si tu préfères garder le comportement actuel) ; par exemple, remplace le split sur caractères par une chaîne en deux étapes : d'abord raw.split(", ").flat_map(|s| s.split("; ")).map(|s| s.trim()).filter(|s| !s.is_empty()).map(|s| s.to_string()).collect() afin de ne scinder que quand la virgule/point-virgule est suivie d'un espace, en conservant le nom de fonction split_artist_name et le comportement de trimmer/filtrer.
In @src-tauri/crates/core/src/smart_playlists/cover.rs around lines 260 - 264, Remove the dead NonZeroU32 construction: delete the creation of src_w_nz, src_h_nz, dst_w_nz, dst_h_nz and the unused tuple binding (_ = (src_w_nz, src_h_nz, dst_w_nz, dst_h_nz)); since the v6 crate API accepts u32 directly, keep using crop_w, crop_h, dst_w, dst_h as u32 and remove the expect! checks to avoid confusing dead code (verify no other code uses src_w_nz/src_h_nz/dst_w_nz/dst_h_nz before removing).
In @src-tauri/crates/core/src/smart_playlists/generator.rs around lines 197 - 200, When tracks.is_empty() you currently delete_existing_slot(pool, bucket.slot()).await? and return Ok(0), which causes a misleading playlist id to be pushed into created; change the function to return an Option (return Ok(None) when tracks.is_empty() after calling delete_existing_slot) and update the caller that collects IDs (the code that pushes into created around the current call site) to only push Some(id) values (or filter out None), so no spurious 0 id is added; keep references to delete_existing_slot, bucket.slot(), tracks.is_empty(), and the created vector when making the changes.
In @src-tauri/crates/core/src/smart_playlists/mod.rs around lines 68 - 70, The to_json method on SmartPlaylistRules currently calls serde_json::to_string(...).expect(...) which can panic; change the signature of pub fn to_json(&self) to return Result<String, serde_json::Error> and propagate the serialization error by returning serde_json::to_string(self) instead of unwrapping; update any call sites that assumed a String to handle the Result (e.g., map_err/unwrap_or_else or ? where appropriate), and ensure this covers cases where custom::CustomRules may contain non-serializable data. |
Summary
Phase 1.a of RFC-001: turn the monolithic
src-tauri/package into a Cargo workspace with two members and lift every portable bit of business logic intowaveflow-coreso the futurewaveflow-servercan consume the exact same code without going through the Tauri runtime.crates/core/(waveflow-core) — domain DTOs, repository traits + SQLite impls, scanner helpers + upserts, smart-playlist engine, audio analysis, DSD parsers + 1-bit→PCM conversion, artwork pipeline (thumbnails + shared metadata cache), HTTP clients (Deezer / Last.fm / LRCLIB),CoreError. Zerotauri/cpaldeps.crates/app/(waveflow) — Tauri 2 application. Holds every#[tauri::command](now thin wrappers over the core traits), the real-timecpal+rtrbaudio engine, the DLNA / OS-media-controls / Discord-RPC integrations, the fs watcher, the tray, the profile pool wiring, paths.Closes #128.
How it's organised
15 commits, one logical step per commit so the diff stays reviewable:
chore(workspace)— virtual workspace skeletonrefactor(core)— domain typesrefactor(core)— generic error typerefactor(core)— HTTP clients (deezer/lastfm/lrclib)refactor(core)—ProfileRepositoryrefactor(core)—LibraryRepositoryrefactor(core)—PlaylistRepositoryrefactor(core)—TrackRepositoryrefactor(core)— artwork pipelinerefactor(core)— audio analysisrefactor(core)— smart-playlist engine (+PathsContext)refactor(core)— scanner pure helpers + upsertsrefactor(core)— DSD pipelinedocs(architecture)—docs/architecture/crates.md+ CLAUDE.md + bulk path updates +--workspaceflag in CI +.coderabbit.yamlretargeted at the new layoutfix(review)— 3 CodeRabbit findings introduced or owned by this PRStats: 158 files changed, +5 657 / -4 296. Bulk of the diff is
git mv+ one-line re-export shims to keepcrate::*imports resolving in the app crate.What stayed in
crates/app/Anything tied to Tauri, cpal, the desktop OS or the real-time audio path. Specifically:
#[tauri::command]handlers (even when the body is a thin call into core), the cpal callback + WASAPI exclusive thread (hot path, no allocs/locks/logs), souvlaki bridge, Discord RPC IPC client, native notifications, the DLNA worker, the fs watcher,AppPaths, the tray, the single-instance lock, the updater, the mini-player WebviewWindow.Full split rules in
docs/architecture/crates.md.Validation
cargo check --workspace --all-targets --manifest-path src-tauri/Cargo.tomlcleancargo test --workspace --manifest-path src-tauri/Cargo.toml→ 111 passed / 0 failed (66 in app + 45 in core)bun run typecheck/bun run lintclean (frontend untouched, sanity check only)Caveat: validated on Linux only. Windows / macOS bundling (
bun run tauri build) and the per-target audio paths (WASAPI exclusive, MediaRemote) are unchanged in shape so should be unaffected, but a green CI matrix on this PR is the real signal.Documented follow-ups (intentionally not in this PR)
These were called out by CodeRabbit and verified valid, but they're pre-existing in code the refactor merely moved; bundling them here would balloon the diff and risk regressions in paths Phase 1.a is supposed to leave alone:
scan_folder_innerTauri decoupling via aScannerEventSinktrait (so the orchestrator can move to core too)ArtworkRepository(artwork table CRUD)AppErrorlegacy variants cleanup (collapse duplication once core owns Database/Io)src-tauri/migrations/**tocrates/core/migrations/**file_hashrecompute inedit::update_track_tags/update_track_cover/save_lyrics/set_track_rating(CLAUDE.md rule already exists)cds.rsBrowseMetadata column-alias bug +serve_artbackslash traversal checkIf any of those should block the merge, flag them and I'll add commits on the same branch.
Test plan
bun run tauri devboots, library scan completes, playback works, profile switch worksSummary by CodeRabbit
New Features
artist.jpg/artist.pngdepuis les dossiers de la bibliothèque.Improvements