Skip to content

Commit

Permalink
feat: support podcast episodes and prepare for livestreams
Browse files Browse the repository at this point in the history
Refactors Track implementation to handle different content types:
* Songs - Regular music tracks (refactored)
* Episodes - Podcast episodes (added)
* Livestreams - Radio stations (preparation)

Changes:
- Uses getter methods for field access
- Renames album_cover to cover_id
- Adds available/external flags
- Adds external URL support
- Updates display formatting

This is a breaking change as it changes how track data is accessed.
  • Loading branch information
roderickvd committed Jan 1, 2025
1 parent efe072a commit e50d1ee
Show file tree
Hide file tree
Showing 20 changed files with 1,611 additions and 526 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
- [remote] Add audio format and bitrate to `track_changed` event
- [signal] New module for unified signal handling across platforms
- [tests] Add anonymized API response examples
- [track] Support for podcast episodes with external streaming

### Changed
- [docs] Enhanced documentation for signal handling and lifecycle management
- [main] Improved signal handling and graceful shutdown
- [remote] Renamed `ALBUM_COVER` to `COVER_ID` in the `track_changed` event
- [track] Renamed `album_cover` to `cover_id` for consistency

## [0.7.0] - 2024-12-28

Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ reqwest = { version = "0.12", default-features = false, features = [
"stream",
] }
rodio = { version = "0.20", default-features = false, features = [
"symphonia-aac",
"symphonia-flac",
"symphonia-mp3",
] }
Expand Down
45 changes: 28 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
- Volume normalization to maintain consistent levels across tracks
- Configurable initial volume level with automatic fallback to client control
- **Content Support**:
- **Songs**: Stream regular music tracks and your uploaded MP3s
- **Podcasts**: Listen to your favorite shows with direct streaming
- **Flow and Mixes**: Access personalized playlists and [mixes](https://www.deezer.com/explore/mixes) tailored to your preferences
- **User MP3s**: Play your [uploaded MP3 files](https://support.deezer.com/hc/en-gb/articles/115004221605-Upload-MP3s) alongside streamed content
- **Playback Reporting**: Contribute to accurate artist monetization metrics
Expand All @@ -55,8 +57,9 @@

### Planned Features

- [Live radio](https://www.deezer.com/explore/radio) and [podcast](https://www.deezer.com/explore/podcasts) integration
- [Live radio](https://www.deezer.com/explore/radio) integration
- Device registration
- RAM-backed storage

## Installation

Expand Down Expand Up @@ -310,22 +313,30 @@ Emitted when playback is paused
No additional variables
#### `track_changed`
Emitted when the track changes
Variables:
- `TRACK_ID`: The ID of the track
- `TITLE`: The track title
- `ARTIST`: The main artist name
- `ALBUM_TITLE`: The album title
- `ALBUM_COVER`: The album cover ID, which can be used to construct image URLs:
```
https://e-cdns-images.dzcdn.net/images/cover/{album_cover}/{resolution}x{resolution}.{format}
```
where `{resolution}` is the desired resolution in pixels (up to 1920) and
`{format}` is either `jpg` (smaller file size) or `png` (higher quality).
Deezer's default is 500x500.jpg
- `DURATION`: Track duration in seconds
- `FORMAT`: The audio format and bitrate (e.g., "MP3 320K" showing constant bitrate, or "FLAC 1.234M" showing variable bitrate)
Emitted when the track changes. The variables differ based on content type:
| Variable | Music | Podcast | Radio |
|---------------|--------------------------|----------------------------|--------------------------|
| `TRACK_TYPE` | `song` | `episode` | `livestream` |
| `TRACK_ID` | Song ID | Episode ID | Livestream ID |
| `TITLE` | Song title | Episode title | _(not set)_ |
| `ARTIST` | Artist name | Podcast title | Station name |
| `ALBUM_TITLE` | Album title | _(not set)_ | _(not set)_ |
| `COVER_ID` | Album art | Podcast art | Station logo |
| `DURATION` | Song duration (seconds) | Episode duration (seconds) | _(not set)_ |
| `FORMAT` | Audio format and bitrate | Audio format and bitrate | Audio format and bitrate |
The `FORMAT` value shows the audio configuration (e.g., "MP3 320K" for constant bitrate,
or "FLAC 1.234M" for variable bitrate).
The `COVER_ID` can be used to construct image URLs:
```
https://e-cdns-images.dzcdn.net/images/cover/{cover_id}/{resolution}x{resolution}.{format}
```
where `{resolution}` is the desired resolution in pixels (up to 1920) and
`{format}` is either `jpg` (smaller file size) or `png` (higher quality).
Deezer's default is `500x500.jpg`.
```
#### `connected`
Emitted when a Deezer client connects to control playback
Expand Down
87 changes: 60 additions & 27 deletions src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use std::time::SystemTime;

use futures_util::TryFutureExt;
use md5::{Digest, Md5};
use reqwest::{
self,
Expand All @@ -45,10 +46,21 @@ use crate::{
protocol::{
self, auth,
connect::{
queue::{self, TrackType},
queue::{self},
AudioQuality, UserId,
},
gateway::{self, MediaUrl, Queue, UserData},
gateway::{
self,
list_data::{
episodes::{self, EpisodeData},
livestream::{self, LivestreamData},
songs::{self, SongData},
ListData,
},
user_radio::{self, UserRadio},
MediaUrl, Queue, Response, UserData,
},
Codec,
},
tokens::UserToken,
};
Expand Down Expand Up @@ -239,7 +251,7 @@ impl Gateway {
pub async fn refresh(&mut self) -> Result<()> {
// Send an empty JSON map
match self
.request::<gateway::UserData>(Self::EMPTY_JSON_OBJECT, None)
.request::<UserData>(Self::EMPTY_JSON_OBJECT, None)
.await
{
Ok(response) => {
Expand Down Expand Up @@ -309,7 +321,7 @@ impl Gateway {
&mut self,
body: impl Into<reqwest::Body>,
headers: Option<HeaderMap>,
) -> Result<gateway::Response<T>>
) -> Result<Response<T>>
where
T: std::fmt::Debug + gateway::Method + for<'de> Deserialize<'de>,
{
Expand Down Expand Up @@ -385,7 +397,7 @@ impl Gateway {

/// Returns a reference to the current user data if available.
#[must_use]
pub fn user_data(&self) -> Option<&gateway::UserData> {
pub fn user_data(&self) -> Option<&UserData> {
self.user_data.as_ref()
}

Expand Down Expand Up @@ -449,27 +461,48 @@ impl Gateway {
/// * Network request fails
/// * Response parsing fails
pub async fn list_to_queue(&mut self, list: &queue::List) -> Result<Queue> {
let track_list = gateway::list_data::Request {
track_ids: list
.tracks
.iter()
.map(|track| {
let track_type = track.typ.enum_value_or_default();
if track_type == TrackType::TRACK_TYPE_SONG {
track.id.parse().map_err(Into::into)
} else {
Err(Error::unimplemented(format!(
"{track_type:?} not yet implemented"
)))
}
})
.collect::<std::result::Result<Vec<_>, _>>()?,
};
let ids = list
.tracks
.iter()
.map(|track| track.id.parse().map_err(Error::from))
.collect::<std::result::Result<Vec<_>, _>>()?;

if let Some(first) = list.tracks.first() {
let response: Response<ListData> = match first.typ.enum_value_or_default() {
queue::TrackType::TRACK_TYPE_SONG => {
let songs = songs::Request { song_ids: ids };
let request = serde_json::to_string(&songs)?;
self.request::<SongData>(request, None)
.map_ok(Into::into)
.await?
}
queue::TrackType::TRACK_TYPE_EPISODE => {
let episodes = episodes::Request { episode_ids: ids };
let request = serde_json::to_string(&episodes)?;
self.request::<EpisodeData>(request, None)
.map_ok(Into::into)
.await?
}
queue::TrackType::TRACK_TYPE_LIVE => {
let radio = livestream::Request {
livestream_id: first.id.parse()?,
supported_codecs: vec![Codec::AAC, Codec::MP3],
};
let request = serde_json::to_string(&radio)?;
self.request::<LivestreamData>(request, None)
.map_ok(Into::into)
.await?
}
queue::TrackType::TRACK_TYPE_CHAPTER => {
return Err(Error::unimplemented(
"audio books not implemented - report what you were trying to play to the developers",
));
}
};

let body = serde_json::to_string(&track_list)?;
match self.request::<gateway::ListData>(body, None).await {
Ok(response) => Ok(response.all().clone()),
Err(e) => Err(e),
Ok(response.all().clone())
} else {
Ok(Queue::default())
}
}

Expand All @@ -487,9 +520,9 @@ impl Gateway {
/// * Network request fails
/// * Response parsing fails
pub async fn user_radio(&mut self, user_id: UserId) -> Result<Queue> {
let request = gateway::user_radio::Request { user_id };
let request = user_radio::Request { user_id };
let body = serde_json::to_string(&request)?;
match self.request::<gateway::UserRadio>(body, None).await {
match self.request::<UserRadio>(body, None).await {
Ok(response) => {
// Transform the `UserRadio` response into a `Queue`. This is done to have
// `UserRadio` re-use the `ListData` struct (for which `Queue` is an alias).
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
//! * Application lifecycle
//! * Connection retry logic with exponential backoff
//!
//! * Audio content:
//! - Songs
//! - Podcast episodes
//! - Live radio (future)
//!
//! # Runtime Behavior
//!
//! The application:
Expand Down Expand Up @@ -221,6 +226,7 @@ fn init_logger(config: &Args) {
}

// Filter log messages of external crates.
logger.filter_module("symphonia_codec_aac", LevelFilter::Warn);
logger.filter_module("symphonia_bundle_flac", LevelFilter::Warn);
logger.filter_module("symphonia_bundle_mp3", LevelFilter::Warn);
logger.filter_module("symphonia_core", LevelFilter::Warn);
Expand Down
28 changes: 20 additions & 8 deletions src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,21 +707,26 @@ impl Player {
ratio = f32::powf(10.0, difference / 20.0);
}
None => {
warn!("track {track} has no gain information, skipping normalization");
warn!(
"{} {track} has no gain information, skipping normalization",
track.typ()
);
}
}
}

let rx = if ratio < 1.0 {
debug!(
"attenuating track {track} by {difference:.1} dB ({})",
"attenuating {} {track} by {difference:.1} dB ({})",
track.typ(),
Percentage::from_ratio_f32(ratio)
);
let attenuated = decoder.amplify(ratio);
sources.append_with_signal(attenuated)
} else if ratio > 1.0 {
debug!(
"amplifying track {track} by {difference:.1} dB ({}) (with limiter)",
"amplifying {} {track} by {difference:.1} dB ({}) (with limiter)",
track.typ(),
Percentage::from_ratio_f32(ratio)
);
let amplified = decoder.automatic_gain_control(
Expand Down Expand Up @@ -792,13 +797,14 @@ impl Player {
let next_position = self.position.saturating_add(1);
if let Some(next_track) = self.queue.get(next_position) {
let next_track_id = next_track.id();
let next_track_typ = next_track.typ();
if !self.skip_tracks.contains(&next_track_id) {
match self.load_track(next_position).await {
Ok(rx) => {
self.preload_rx = rx;
}
Err(e) => {
error!("failed to preload next track: {e}");
error!("failed to preload next {next_track_typ}: {e}");
self.mark_unavailable(next_track_id);
}
}
Expand All @@ -810,6 +816,7 @@ impl Player {
None => {
if let Some(track) = self.track() {
let track_id = track.id();
let track_typ = track.typ();
if self.skip_tracks.contains(&track_id) {
self.go_next();
} else {
Expand All @@ -824,7 +831,7 @@ impl Player {
}
}
Err(e) => {
error!("failed to load track: {e}");
error!("failed to load {track_typ}: {e}");
self.mark_unavailable(track_id);
}
}
Expand Down Expand Up @@ -1270,7 +1277,7 @@ impl Player {
#[must_use]
pub fn progress(&self) -> Option<Percentage> {
if let Some(track) = self.track() {
let duration = track.duration();
let duration = track.duration()?;
if duration.is_zero() {
return None;
}
Expand Down Expand Up @@ -1307,10 +1314,15 @@ impl Player {
/// * Seek operation fails
pub fn set_progress(&mut self, progress: Percentage) -> Result<()> {
if let Some(track) = self.track() {
info!("setting track progress to {progress}");
info!("setting {} progress to {progress}", track.typ());
let progress = progress.as_ratio_f32();
if progress < 1.0 {
let progress = track.duration().mul_f32(progress);
let progress = track
.duration()
.ok_or_else(|| {
Error::unavailable(format!("duration unknown for {} {track}", track.typ()))
})?
.mul_f32(progress);

// Try to seek only if the track has started downloading, otherwise defer the seek.
// This prevents stalling the player when seeking in a track that has not started.
Expand Down
Loading

0 comments on commit e50d1ee

Please sign in to comment.