Files
qbz/crates/qbz-models/src/lib.rs
T
vicrodh 475f261dc1 feat(purchases): shared backend for the Qt port, with tags, covers and goodies
The frontend-agnostic half of Purchases. Ported against the Tauri reference,
which is the only implementation ever validated by a real user; the Slint
controller was read for divergences only and never as a source of truth.

Context that sets the bar for all of it: Qobuz does not sell Purchases in this
account's region, so no populated screen and no download can be exercised here.
Anything merely plausible ships unseen. Hence the tests below are the deliverable
as much as the code.

Behaviour ported or corrected:

- Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it
  from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested
  tracks page (measured against a live account), so that predicate is
  unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded"
  can never hide anything. `get_downloaded_purchase_album_counts` answers it
  instead, giving a reader at last to the `album_id` column and to the index that
  has always sat on it.
- `album_id` is written by every download path. It used to be set only by the
  album loop, so a release downloaded track by track produced rows the rule could
  never attribute. The `/track/get` payload each download already fetches carries
  the id.
- `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual
  primary key; erasing every format is now a separate, named call.
- `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate`
  panics off a char boundary — reachable because the mapping keeps Unicode
  alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where
  200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The
  cap stays in bytes so no already-downloaded path moves.
- The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both
  the reference and Qobuz's own desktop client, whose request helper takes a sign
  flag and passes it false for these and true only for `getFileUrl`. That
  signature is the entitlement proof and is untouched.
- The purchase-type guard runs before any I/O; a bogus type no longer issues a
  live authenticated GET before failing.
- Track subtitles survive. `PurchaseTrack` gained `version` and
  `build_purchase_album` maps it from the catalog track, closing a latent
  regression where every purchased track lost its edition.
- Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are
  best-effort: the audio file is the deliverable and is already on disk and
  registered before any of it runs.
- `goodies` is deserialized leniently. Its populated shape has never been
  observed — it comes back empty on every album nobody owns — and `/album/get`
  is parsed with the strict structs, so an unexpected shape would take the whole
  album down rather than merely hide a booklet.

Riding along, both found while building the above and both unrelated to
Purchases:

- The metadata EDITOR silently discarded saves. `write_album_tags_to_files`
  picked its write target as "the primary tag, or else the first one I can find",
  and lofty refuses to WRITE a tag type a container only supports READING —
  silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no
  Vorbis comments, so every edited field went into that ID3v2 tag, the save
  dropped the lot, and the call returned Ok. The user pressed Save, saw a success
  toast, and the file never changed. Same defect, same fix, in the new purchase
  tagger.
- `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its
  buffer from the response's own Content-Length and sets no total timeout, which
  is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on
  a machine that hard-freezes under memory pressure.

Tests: 277 across the four crates, covering every acceptance item the contract
set. The signature preimage character for character, the pagination loop
including the zero-total case, the sanitizer against the inputs that used to
panic, every deserializer default, and the wire envelopes against real bytes
captured from a live account. A download harness stands up a local HTTP server
and drives the pipeline end to end — real bytes over a real socket, a real FLAC
tagged and read back, a real database — and the two tag regressions were both
verified to FAIL against the old code before being accepted.

cc
2026-08-16 11:53:37 -06:00

152 lines
4.3 KiB
Rust

//! QBZ Models - Shared types, events, and traits
//!
//! This crate provides the foundation for all QBZ crates:
//! - Type definitions (Track, Album, Artist, etc.)
//! - Event definitions (CoreEvent enum)
//! - Trait definitions (FrontendAdapter)
//! - Playback types (QueueTrack, PlaybackState)
//! - Error types
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────┐
//! │ qbz-models (Tier 0) │
//! │ Types, Events, Traits - No dependencies on other qbz-* │
//! └─────────────────────────────────────────────────────────────┘
//! ↑
//! ┌────────────────────────┼────────────────────────┐
//! │ │ │
//! ┌───┴───┐ ┌─────┴─────┐ ┌─────┴─────┐
//! │qbz-audio│ │qbz-qobuz │ │qbz-player │
//! │ Tier 1 │ │ Tier 1 │ │ Tier 2 │
//! └────────┘ └───────────┘ └───────────┘
//! ```
//!
//! # Usage
//!
//! ```rust
//! use qbz_models::{Track, Album, CoreEvent, FrontendAdapter};
//! ```
pub mod error;
pub mod events;
pub mod lenient;
pub mod mixtape;
pub mod playback;
pub mod purchase_serde;
pub mod source;
pub mod traits;
pub mod types;
// Re-export commonly used types at crate root
pub use error::{QbzError, QbzResult};
pub use events::CoreEvent;
pub use lenient::{parse_items_array, parse_items_lenient};
pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, RepeatMode};
pub use source::{plex_thumb_url, ArtworkRef, PlaybackSource, TrackOriginTag};
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
pub use types::{
Album,
AlbumSuggestResponse,
AlbumSummary,
Artist,
RadioResponse,
ArtistAlbums,
ArtistBiography,
Goody,
// Discover types
DiscoverAlbum,
DiscoverAlbumDates,
DiscoverAlbumImage,
DiscoverArtist,
DiscoverAudioInfo,
DiscoverContainer,
DiscoverContainers,
DiscoverData,
DiscoverPlaylist,
DiscoverPlaylistImage,
DiscoverPlaylistsResponse,
DiscoverResponse,
Favorites,
Genre,
GenreInfo,
GenreListContainer,
GenreListResponse,
ImageSet,
Label,
LabelExploreResponse,
LabelGetListResponse,
LabelListPage,
LabelPageContainer,
LabelPageData,
LabelPageGenericList,
LabelStoryResponse,
// Award types
AlbumAward,
AwardMagazine,
AwardPageContainer,
AwardPageData,
AwardPageGenericList,
// Artist page types
ArtistStoryResponse,
ArtistStoryItem,
ArtistStoryImage,
ArtistStoryAuthor,
PageArtistAward,
PageArtistBiography,
PageArtistImages,
PageArtistName,
PageArtistPhysicalSupport,
PageArtistPlaylist,
PageArtistPlaylistImages,
PageArtistPlaylistOwner,
PageArtistPlaylists,
PageArtistPortrait,
PageArtistRelease,
PageArtistReleaseArtist,
PageArtistReleaseContributor,
PageArtistReleaseGroup,
PageArtistResponse,
PageArtistRights,
PageArtistSimilar,
PageArtistSimilarItem,
PageArtistTrack,
PageArtistTrackAlbum,
Playlist,
// Purchase types
PurchaseAlbum,
PurchaseFormatOption,
PurchaseIdsResponse,
PurchaseResponse,
PurchaseTrack,
PlaylistDuplicateResult,
PlaylistGenre,
PlaylistOwner,
PlaylistTag,
PlaylistTagsResponse,
PlaylistWithTrackIds,
Quality,
QualityLimit,
RawPlaylistTag,
ReleasesGridResponse,
MostPopularItem,
SearchAllResults,
SearchResults,
SearchResultsPage,
SessionStartResponse,
AssetOrigin,
AudioParams,
probe_streaminfo,
qobuz_cover_at_px,
ExternalStreamAsset,
StreamQualityInfo,
StreamRestriction,
StreamUrl,
Track,
TrackFileUrl,
TrackToAnalyse,
TracksContainer,
UserSession,
};