8.2 KiB
Implementation Notes and Design Decisions
Overview
pmoparadise is a Rust client library for Radio Paradise's streaming API, designed following idiomatic Rust patterns and inspired by the structure of pmoqobuz.
Architecture Decisions
1. Module Structure
The crate is organized into focused modules:
client.rs- HTTP client and API methodsmodels.rs- Data structures with serde serializationstream.rs- Block streaming functionalitytrack.rs- Per-track extraction (feature-gated)error.rs- Type-safe error handling
This separation ensures clear boundaries and makes the code maintainable.
2. Async/Await with Tokio
Decision: Use async/await throughout the API with tokio runtime.
Rationale:
- Radio Paradise API calls are I/O bound
- Streaming large FLAC blocks benefits from async I/O
- Tokio is the de facto standard for async Rust
- Enables efficient prefetching and concurrent operations
3. Type Safety
Decision: Use strong typing for all API concepts (EventId, DurationMs, Bitrate enum).
Rationale:
- Prevents mixing up event IDs with durations
- Enum for Bitrate makes invalid states unrepresentable
- Compile-time guarantees reduce runtime errors
- Self-documenting code
4. Error Handling
Decision: Use thiserror for structured errors with specific variants.
Rationale:
- Users can match on specific error types
- Better error messages than strings
- Idiomatic Rust error handling
- Easy to extend with new error types
5. Feature Gates
Decision: Gate the per-track functionality behind a feature flag.
Rationale:
- Most users don't need FLAC decoding
- Reduces dependencies for common use cases
claxon,hound,tempfileadd significant compile time- Keeps the default build lightweight
API Design Decisions
1. Builder Pattern for Client
Decision: Provide both new() and builder() methods.
Rationale:
new()for simple cases (good defaults)builder()for customization (bitrate, proxy, timeout)- Common Rust pattern (reqwest, etc.)
- Extensible without breaking changes
2. Block-Centric API
Decision: Focus on blocks as the primary abstraction, not individual songs.
Rationale:
- Matches Radio Paradise's actual architecture
- Blocks are the unit of streaming
- Enables efficient prefetching
- Transparent about implementation details
3. Prefetching Support
Decision: Provide explicit prefetch_next() method rather than automatic prefetching.
Rationale:
- Gives users control over when network calls happen
- Allows batching metadata requests
- Simpler to reason about
- Users can implement custom prefetch strategies
4. Stream Trait Implementation
Decision: Return a custom BlockStream that implements Stream<Item = Result<Bytes>>.
Rationale:
- Standard Rust async iterator pattern
- Compatible with futures combinators
- Easy to consume with
while let Some(chunk) = stream.next().await - Can be piped to any sink
Per-Track Feature Decisions
1. Why It's Optional and Discouraged
Decision: Document limitations and recommend player-based seeking.
Rationale:
- FLAC doesn't support random access
- Must download entire block (50-100 MB)
- CPU-intensive decoding
- Players (mpv, ffmpeg) handle this better
Trade-offs:
- Prefetch vs Per-Track:
- Prefetch: Low latency, efficient, recommended
- Per-track: High latency, resource-intensive, only for special cases
2. Implementation Approach
Decision: Download to tempfile, decode with claxon, expose PCM/WAV.
Rationale:
- Claxon is pure Rust (no C dependencies)
- Tempfile ensures cleanup
- WAV export is a common use case
- Simple implementation
Alternatives Considered:
- Streaming decode: Too complex, claxon doesn't support seeking
- HTTP range requests: Radio Paradise blocks don't support it reliably
- Caching decoded blocks: Too much memory
3. Helper Method for Players
Decision: Provide track_position_seconds() to get timing for external players.
Rationale:
- Gives users the information they need
- Doesn't dictate how to use it
- Works with any player
- Zero overhead
Data Model Decisions
1. HashMap for Songs
Decision: Use HashMap<String, Song> matching the API response.
Rationale:
- Matches JSON structure exactly
- Easy serde deserialization
- Provides
songs_ordered()helper for iteration - Preserves all data from API
2. Optional Fields
Decision: Make many fields Option<T> (year, rating, cover, etc.).
Rationale:
- API doesn't always provide all fields
- Future-proof against API changes
- Explicit about what's guaranteed
3. Extra Fields
Decision: Use #[serde(flatten)] for unknown fields.
Rationale:
- Forwards compatibility
- Don't break on new API fields
- Can inspect raw data if needed
Testing Strategy
1. Unit Tests
- Inline tests for data model parsing
- Tests for timing calculations
- Builder pattern validation
2. Integration Tests with Mocks
Decision: Use wiremock for HTTP mocking.
Rationale:
- Don't hit real API in CI
- Reproducible tests
- Fast execution
- Can test error conditions
3. Example Programs
Decision: Provide runnable examples for all major features.
Rationale:
- Examples serve as documentation
- Users can copy-paste working code
- Tested in CI (via
cargo test --doc)
Documentation Strategy
1. Extensive Rustdoc
Decision: Document every public function, struct, and enum.
Rationale:
- Discoverability via docs.rs
- IDE autocomplete shows docs
- Examples in docs are tested
- Professional appearance
2. README with Use Cases
Decision: Detailed README covering common scenarios.
Rationale:
- First thing users see
- Explains design decisions
- Guides users to best practices
- Warns about per-track limitations
3. Module-Level Documentation
Decision: Each module has overview documentation.
Rationale:
- Explains purpose of module
- Links to related modules
- Top-down understanding
Performance Considerations
1. Streaming vs Downloading
- Streaming (
stream_block): Low latency, constant memory - Downloading (
download_block): Required for per-track, high memory
2. Prefetching
- Metadata prefetch is cheap (~1KB JSON)
- Block prefetch is expensive (~50-100MB)
- Leave block caching to users
3. Connection Pooling
Decision: Allow sharing reqwest::Client.
Rationale:
- Reuse connections
- User controls connection pool size
- Works with existing infrastructure
Future Extensions
Possible Additions (Not Implemented)
- Channel Support: Main mix, mellow, rock, world (API supports this)
- Historical Blocks: Fetch blocks by date/time
- Playlist API: If Radio Paradise adds it
- WebSocket Live Updates: Real-time now-playing updates
- Caching Layer: Optional disk cache for blocks
Why Not Included Now
- Keep initial release focused
- No user demand yet
- Can add without breaking changes
- Some features may require API changes
Lessons Learned
What Worked Well
- Builder pattern: Easy to extend
- Feature gates: Keeps default build fast
- Strong typing: Caught many bugs at compile time
- Integration tests: Gave confidence in refactoring
What Could Be Improved
- FLAC seeking: Claxon limitations make per-track expensive
- Error messages: Could be more actionable
- Examples: Could add more advanced patterns
Comparison with pmoqobuz
Similarities
- Builder pattern for client
- Serde models
- Async/await
- Integration with PMOMusic ecosystem
Differences
- No caching layer: Radio Paradise API is simpler, less need
- Streaming focus: Qobuz is track-based, Paradise is block-based
- No authentication: Paradise API is public (for metadata)
- Feature gates: Paradise has optional FLAC decoding
Conclusion
This implementation prioritizes:
- Ergonomics: Easy for common cases, flexible for advanced
- Performance: Async, streaming, minimal allocations
- Safety: Type-safe, comprehensive error handling
- Documentation: Extensive docs and examples
- Honesty: Clear about limitations (per-track)
The result is a production-ready library that's pleasant to use and maintains high code quality standards.