Skip to content

refactor(workspace): extract waveflow-core crate (Phase 1.a)#169

Open
InstaZDLL wants to merge 15 commits into
mainfrom
feat/phase-1a-extract-core
Open

refactor(workspace): extract waveflow-core crate (Phase 1.a)#169
InstaZDLL wants to merge 15 commits into
mainfrom
feat/phase-1a-extract-core

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 26, 2026

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 into waveflow-core so the future waveflow-server can 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. Zero tauri / cpal deps.
  • crates/app/ (waveflow) — Tauri 2 application. Holds every #[tauri::command] (now thin wrappers over the core traits), the real-time cpal + rtrb audio 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:

  1. chore(workspace) — virtual workspace skeleton
  2. refactor(core) — domain types
  3. refactor(core) — generic error type
  4. refactor(core) — HTTP clients (deezer/lastfm/lrclib)
  5. refactor(core)ProfileRepository
  6. refactor(core)LibraryRepository
  7. refactor(core)PlaylistRepository
  8. refactor(core)TrackRepository
  9. refactor(core) — artwork pipeline
  10. refactor(core) — audio analysis
  11. refactor(core) — smart-playlist engine (+ PathsContext)
  12. refactor(core) — scanner pure helpers + upserts
  13. refactor(core) — DSD pipeline
  14. docs(architecture)docs/architecture/crates.md + CLAUDE.md + bulk path updates + --workspace flag in CI + .coderabbit.yaml retargeted at the new layout
  15. fix(review) — 3 CodeRabbit findings introduced or owned by this PR

Stats: 158 files changed, +5 657 / -4 296. Bulk of the diff is git mv + one-line re-export shims to keep crate::* 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.toml clean
  • cargo test --workspace --manifest-path src-tauri/Cargo.toml111 passed / 0 failed (66 in app + 45 in core)
  • bun run typecheck / bun run lint clean (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_inner Tauri decoupling via a ScannerEventSink trait (so the orchestrator can move to core too)
  • ArtworkRepository (artwork table CRUD)
  • AppError legacy variants cleanup (collapse duplication once core owns Database/Io)
  • Move src-tauri/migrations/** to crates/core/migrations/**
  • Per-file file_hash recompute in edit::update_track_tags / update_track_cover / save_lyrics / set_track_rating (CLAUDE.md rule already exists)
  • Offline-mode guards on the Spotify command surface
  • DLNA cds.rs BrowseMetadata column-alias bug + serve_art backslash traversal check
  • Misc perf wins (batched DELETEs, blocking-pool offloads, JOIN-not-N+1)

If any of those should block the merge, flag them and I'll add commits on the same branch.

Test plan

  • CI matrix green on Linux / Windows / macOS
  • bun run tauri dev boots, library scan completes, playback works, profile switch works
  • DLNA opt-in still bound (Settings → Integrations)
  • Smart playlist regen ("Régénérer" on Home) materialises Daily Mix slots + On Repeat
  • Lyrics waterfall (cache → embedded → sidecar → LRCLIB) on a previously-unlooked-up track

Summary by CodeRabbit

  • New Features

    • Ajout d'une fonction de numérisation des images d'artistes locales : scan automatique et liaison des images artist.jpg/artist.png depuis les dossiers de la bibliothèque.
  • Improvements

    • Amélioration du support des formats DSD (DSF/DFF) avec extraction métadonnées optimisée.
    • Meilleure résolution des images d'artistes : ordre de priorité entre images locales, cache et sources externes.

Review Change Stack

InstaZDLL added 15 commits May 26, 2026 12:10
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.
@InstaZDLL InstaZDLL added scope: backend Rust/Tauri backend (src-tauri/) scope: deps Dependencies scope: ci CI/CD, workflows scope: docs Docs, README, assets type: refactor Code refactoring size: xl > 500 lines labels May 26, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

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

Changes

Refactor: extraction waveflow-core et recâblage waveflow (Tauri)

Layer / File(s) Summary
Workspace + recâblage complet (contracts → impl → commandes → docs/CI)
src-tauri/Cargo.toml, src-tauri/crates/*, .github/workflows/*, scripts/tauri.mjs, docs/**, .coderabbit.yaml, package.json, .gitignore, src-tauri/.gitignore
Workspace configuré, ajout de waveflow-core (domain, error, repositories SQLite, metadata, scanner, audio_format, artwork, smart_playlists) et re-exports côté app; commandes Tauri migrées vers dépôts core; chemins migrations ajustés; CI et docs mis à jour; wrapper Tauri ajouté.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • InstaZDLL/WaveFlow#27 — Touche la normalisation des checksums _sqlx_migrations, directement liée au changement de chemin sqlx::migrate! dans profile_io.rs.
  • InstaZDLL/WaveFlow#148 — Introduit/évolue “On Repeat”; ici, la génération et les signatures sont migrées vers CoreResult/PathsContext.
  • InstaZDLL/WaveFlow#137 — Travaille la forme TrackListItem/payloads; ici, la commande track.rs côté app réintroduit ces réponses en s’appuyant sur le repository core.

Suggested labels

type: chore, size: xl, scope: backend, scope: frontend, scope: docs, scope: deps

Poème

Nouveau cœur bat sous le capot, core en éclat
Tauri chante, délègue, et tout s’imbrique là-bas
Repos, scanner, playlists: même refrain
Les chemins migrent, CI s’aligne au matin
Un workspace naît — et la musique continue 🎵

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/phase-1a-extract-core

@InstaZDLL InstaZDLL self-assigned this May 26, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5cfa5b4 and 81dd967.

⛔ Files ignored due to path filters (18)
  • src-tauri/Cargo.lock is excluded by !**/*.lock, !src-tauri/Cargo.lock
  • src-tauri/crates/app/icons/128x128.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/128x128@2x.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/32x32.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/64x64.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square107x107Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square142x142Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square150x150Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square284x284Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square30x30Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square310x310Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square44x44Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square71x71Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/Square89x89Logo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/StoreLogo.png is excluded by !**/*.png
  • src-tauri/crates/app/icons/icon.ico is excluded by !**/*.ico
  • src-tauri/crates/app/icons/icon.png is excluded by !**/*.png
  • src-tauri/crates/core/src/smart_playlists/on_repeat.svg is excluded by !**/*.svg
📒 Files selected for processing (140)
  • .coderabbit.yaml
  • .github/workflows/ci.yml
  • .github/workflows/release-please-lockfile-build.yml
  • .gitignore
  • CLAUDE.md
  • docs/architecture/audio.md
  • docs/architecture/crates.md
  • docs/architecture/storage.md
  • docs/features/dlna.md
  • docs/features/integrations.md
  • docs/features/library.md
  • docs/features/playback.md
  • docs/features/playlists.md
  • docs/features/smart-playlists.md
  • docs/features/ui.md
  • package.json
  • scripts/tauri.mjs
  • src-tauri/.gitignore
  • src-tauri/Cargo.toml
  • src-tauri/crates/app/Cargo.toml
  • src-tauri/crates/app/build.rs
  • src-tauri/crates/app/capabilities/default.json
  • src-tauri/crates/app/icons/icon.icns
  • src-tauri/crates/app/src/analysis.rs
  • src-tauri/crates/app/src/audio/analytics.rs
  • src-tauri/crates/app/src/audio/crossfade.rs
  • src-tauri/crates/app/src/audio/decoder.rs
  • src-tauri/crates/app/src/audio/engine.rs
  • src-tauri/crates/app/src/audio/eq.rs
  • src-tauri/crates/app/src/audio/mod.rs
  • src-tauri/crates/app/src/audio/output.rs
  • src-tauri/crates/app/src/audio/resampler.rs
  • src-tauri/crates/app/src/audio/spectrum.rs
  • src-tauri/crates/app/src/audio/state.rs
  • src-tauri/crates/app/src/audio/wasapi_exclusive.rs
  • src-tauri/crates/app/src/backup.rs
  • src-tauri/crates/app/src/commands/analysis.rs
  • src-tauri/crates/app/src/commands/app_info.rs
  • src-tauri/crates/app/src/commands/backup.rs
  • src-tauri/crates/app/src/commands/browse.rs
  • src-tauri/crates/app/src/commands/changelog.rs
  • src-tauri/crates/app/src/commands/deezer.rs
  • src-tauri/crates/app/src/commands/diagnostics.rs
  • src-tauri/crates/app/src/commands/dlna.rs
  • src-tauri/crates/app/src/commands/duplicates.rs
  • src-tauri/crates/app/src/commands/edit.rs
  • src-tauri/crates/app/src/commands/integration.rs
  • src-tauri/crates/app/src/commands/library.rs
  • src-tauri/crates/app/src/commands/lyrics.rs
  • src-tauri/crates/app/src/commands/maintenance.rs
  • src-tauri/crates/app/src/commands/mod.rs
  • src-tauri/crates/app/src/commands/mood_radio.rs
  • src-tauri/crates/app/src/commands/offline.rs
  • src-tauri/crates/app/src/commands/player.rs
  • src-tauri/crates/app/src/commands/playlist.rs
  • src-tauri/crates/app/src/commands/playlist_cover.rs
  • src-tauri/crates/app/src/commands/preferences.rs
  • src-tauri/crates/app/src/commands/profile.rs
  • src-tauri/crates/app/src/commands/profile_io.rs
  • src-tauri/crates/app/src/commands/radio.rs
  • src-tauri/crates/app/src/commands/scan.rs
  • src-tauri/crates/app/src/commands/share.rs
  • src-tauri/crates/app/src/commands/similar.rs
  • src-tauri/crates/app/src/commands/smart_playlists.rs
  • src-tauri/crates/app/src/commands/spotify.rs
  • src-tauri/crates/app/src/commands/stats.rs
  • src-tauri/crates/app/src/commands/track.rs
  • src-tauri/crates/app/src/commands/tray.rs
  • src-tauri/crates/app/src/commands/wrapped.rs
  • src-tauri/crates/app/src/db/app_db.rs
  • src-tauri/crates/app/src/db/migration_heal.rs
  • src-tauri/crates/app/src/db/mod.rs
  • src-tauri/crates/app/src/db/profile_db.rs
  • src-tauri/crates/app/src/discord_presence.rs
  • src-tauri/crates/app/src/dlna/cds.rs
  • src-tauri/crates/app/src/dlna/config.rs
  • src-tauri/crates/app/src/dlna/description.rs
  • src-tauri/crates/app/src/dlna/http.rs
  • src-tauri/crates/app/src/dlna/mod.rs
  • src-tauri/crates/app/src/dlna/scpd_connection_manager.xml
  • src-tauri/crates/app/src/dlna/scpd_content_directory.xml
  • src-tauri/crates/app/src/dlna/ssdp.rs
  • src-tauri/crates/app/src/error.rs
  • src-tauri/crates/app/src/lib.rs
  • src-tauri/crates/app/src/logging.rs
  • src-tauri/crates/app/src/main.rs
  • src-tauri/crates/app/src/media_controls.rs
  • src-tauri/crates/app/src/metadata_artwork.rs
  • src-tauri/crates/app/src/notifications.rs
  • src-tauri/crates/app/src/offline.rs
  • src-tauri/crates/app/src/paths.rs
  • src-tauri/crates/app/src/queue.rs
  • src-tauri/crates/app/src/scrobbler.rs
  • src-tauri/crates/app/src/smart_playlists.rs
  • src-tauri/crates/app/src/spotify.rs
  • src-tauri/crates/app/src/state.rs
  • src-tauri/crates/app/src/thumbnails.rs
  • src-tauri/crates/app/src/watcher.rs
  • src-tauri/crates/app/tauri.conf.json
  • src-tauri/crates/core/Cargo.toml
  • src-tauri/crates/core/src/analysis.rs
  • src-tauri/crates/core/src/artwork/metadata.rs
  • src-tauri/crates/core/src/artwork/mod.rs
  • src-tauri/crates/core/src/artwork/thumbnails.rs
  • src-tauri/crates/core/src/audio_format/dsd/metadata.rs
  • src-tauri/crates/core/src/audio_format/dsd/mod.rs
  • src-tauri/crates/core/src/audio_format/dsd/parser.rs
  • src-tauri/crates/core/src/audio_format/dsd/pcm.rs
  • src-tauri/crates/core/src/audio_format/mod.rs
  • src-tauri/crates/core/src/domain/library.rs
  • src-tauri/crates/core/src/domain/mod.rs
  • src-tauri/crates/core/src/domain/playlist.rs
  • src-tauri/crates/core/src/domain/profile.rs
  • src-tauri/crates/core/src/domain/track.rs
  • src-tauri/crates/core/src/error.rs
  • src-tauri/crates/core/src/lib.rs
  • src-tauri/crates/core/src/metadata/deezer.rs
  • src-tauri/crates/core/src/metadata/lastfm.rs
  • src-tauri/crates/core/src/metadata/lrclib.rs
  • src-tauri/crates/core/src/metadata/mod.rs
  • src-tauri/crates/core/src/repository/library.rs
  • src-tauri/crates/core/src/repository/mod.rs
  • src-tauri/crates/core/src/repository/playlist.rs
  • src-tauri/crates/core/src/repository/profile.rs
  • src-tauri/crates/core/src/repository/sqlite/library.rs
  • src-tauri/crates/core/src/repository/sqlite/mod.rs
  • src-tauri/crates/core/src/repository/sqlite/playlist.rs
  • src-tauri/crates/core/src/repository/sqlite/profile.rs
  • src-tauri/crates/core/src/repository/sqlite/track.rs
  • src-tauri/crates/core/src/repository/track.rs
  • src-tauri/crates/core/src/scanner/extract.rs
  • src-tauri/crates/core/src/scanner/mod.rs
  • src-tauri/crates/core/src/scanner/upserts.rs
  • src-tauri/crates/core/src/smart_playlists/cover.rs
  • src-tauri/crates/core/src/smart_playlists/custom.rs
  • src-tauri/crates/core/src/smart_playlists/generator.rs
  • src-tauri/crates/core/src/smart_playlists/mod.rs
  • src-tauri/crates/core/src/smart_playlists/on_repeat.rs
  • src-tauri/src/commands/scan.rs
  • src-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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
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.

Comment thread scripts/tauri.mjs
Comment on lines +15 to +29
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ 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.

Comment on lines +23 to +25
use waveflow_core::scanner::{
canonical_name, split_artist_name, upsert_album, upsert_artist, upsert_artwork, upsert_genre,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +619 to 628
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?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

Comment on lines +490 to +494
// 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(())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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);
As per coding guidelines "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/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".

Comment on lines +1 to +6
//! `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).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment on lines +39 to +43
SELECT library_id,
COUNT(*) AS track_count,
COUNT(DISTINCT album_id) AS album_count,
COUNT(DISTINCT primary_artist) AS artist_count
FROM track
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +164 to +179
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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.

Comment on lines +52 to +56
raw.split([',', ';'])
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

@InstaZDLL
Copy link
Copy Markdown
Owner Author

Other findings memo :
Fix the following issues. The issues can be from different files or can overlap on same lines in one file.

  • 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/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.

  • 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/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.

  • 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/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(...)).

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

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

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

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

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

  • 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/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.

  • 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/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.

  • 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/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.

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

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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).

  • 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/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.

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

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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).

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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).

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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 out (atomic replace) so concurrent callers cannot corrupt the file; locate the check in function generate_thumbnails and protect the out path/variable accordingly.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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.

  • 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/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".

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

  • 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/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).

  • 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/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.

  • 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/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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: ci CI/CD, workflows scope: deps Dependencies scope: docs Docs, README, assets size: xl > 500 lines type: refactor Code refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1.a — Extract waveflow-core crate

1 participant