feat(app): subscription grace clock and offline purge in the shared activation
The 30-day offline grace (D4) was produced by the Qt shell alone, and only when a login was REJECTED; its purge predicate had no caller in the live tree, so past the window the CMAF cache was blocked from playing but never removed (Tauri purged it; Slint recorded the gap). With member accounts now signing in, the clock has to key on the entitlement, not on a rejection, and it has to run wherever a session activates. `qbz_app::subscription_lifecycle` records the verdict from the core's session (`offline_streaming` clears the clock; a member keeps it running from its first observation) and purges the offline cache once the window has passed, through the cache a host registers with `AppRuntime::set_offline_cache`. `activate_at` runs it for every host (Qt via `activate`, qbzd directly), a daily ticker per session re-checks it, and a cache registered after activation is enforced against on registration. qbzd registers no cache and keeps the verdict. The Qt producers are gone; `offline_playback_allowed` stays the read gate. cc
This commit is contained in:
8 files changed
+681
-214
No files matched your search
+340
-135
@@ -485,6 +485,12 @@ impl QobuzClient {
|
||||
.and_then(|s| s.language_code.clone())
|
||||
}
|
||||
|
||||
/// A copy of the active session (entitlements included), `None` before
|
||||
/// login. The shared session lifecycle reads the verdict from here.
|
||||
pub async fn session(&self) -> Option<UserSession> {
|
||||
self.session.read().await.clone()
|
||||
}
|
||||
|
||||
/// Exchange an OAuth code for a full user session.
|
||||
///
|
||||
/// This implements the new Qobuz OAuth flow:
|
||||
@@ -792,7 +798,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::ALBUM_SEARCH);
|
||||
let timestamp = get_timestamp();
|
||||
let secret = self.secret().await?;
|
||||
let signature = sign_search("albumsearch", query, limit, offset, search_type, timestamp, &secret);
|
||||
let signature = sign_search(
|
||||
"albumsearch",
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
search_type,
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
let ts_str = timestamp.to_string();
|
||||
@@ -838,7 +852,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::TRACK_SEARCH);
|
||||
let timestamp = get_timestamp();
|
||||
let secret = self.secret().await?;
|
||||
let signature = sign_search("tracksearch", query, limit, offset, search_type, timestamp, &secret);
|
||||
let signature = sign_search(
|
||||
"tracksearch",
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
search_type,
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
let ts_str = timestamp.to_string();
|
||||
@@ -884,7 +906,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::ARTIST_SEARCH);
|
||||
let timestamp = get_timestamp();
|
||||
let secret = self.secret().await?;
|
||||
let signature = sign_search("artistsearch", query, limit, offset, search_type, timestamp, &secret);
|
||||
let signature = sign_search(
|
||||
"artistsearch",
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
search_type,
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
let ts_str = timestamp.to_string();
|
||||
@@ -924,7 +954,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::CATALOG_SEARCH);
|
||||
let timestamp = get_timestamp();
|
||||
let secret = self.secret().await?;
|
||||
let signature = sign_search("catalogsearch", query, limit, offset, None, timestamp, &secret);
|
||||
let signature = sign_search(
|
||||
"catalogsearch",
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
None,
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
let ts_str = timestamp.to_string();
|
||||
@@ -956,11 +994,15 @@ impl QobuzClient {
|
||||
) -> Result<SearchResultsPage<Artist>> {
|
||||
let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR);
|
||||
let http_response = self
|
||||
.signed_get(&url, "artistgetSimilarArtists", &[
|
||||
("artist_id", artist_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistgetSimilarArtists",
|
||||
&[
|
||||
("artist_id", artist_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_similar_artists({}) status={}",
|
||||
@@ -987,13 +1029,17 @@ impl QobuzClient {
|
||||
let locale = self.locale().await;
|
||||
|
||||
let http_response = self
|
||||
.signed_get(&url, "artistget", &[
|
||||
("artist_id", artist_id.to_string()),
|
||||
("extra", "tracks".to_string()),
|
||||
("lang", locale),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistget",
|
||||
&[
|
||||
("artist_id", artist_id.to_string()),
|
||||
("extra", "tracks".to_string()),
|
||||
("lang", locale),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_artist_tracks({}) status={}",
|
||||
@@ -1061,10 +1107,9 @@ impl QobuzClient {
|
||||
query.push(("genre_id".to_string(), gid.to_string()));
|
||||
}
|
||||
|
||||
let params: Vec<(&str, String)> = query.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let http_response = self
|
||||
.signed_get(&url, "albumgetFeatured", ¶ms)
|
||||
.await?;
|
||||
let params: Vec<(&str, String)> =
|
||||
query.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
let http_response = self.signed_get(&url, "albumgetFeatured", ¶ms).await?;
|
||||
log::debug!(
|
||||
"[API] get_featured_albums({}) status={}",
|
||||
featured_type,
|
||||
@@ -1148,10 +1193,7 @@ impl QobuzClient {
|
||||
if let Some(items_arr) = items_value.as_array_mut() {
|
||||
for item in items_arr {
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
let needs_backfill = obj
|
||||
.get("artist")
|
||||
.map(|v| v.is_null())
|
||||
.unwrap_or(true);
|
||||
let needs_backfill = obj.get("artist").map(|v| v.is_null()).unwrap_or(true);
|
||||
if needs_backfill {
|
||||
if let Some(first_artist) = obj
|
||||
.get("artists")
|
||||
@@ -1269,17 +1311,20 @@ impl QobuzClient {
|
||||
/// Qobuz radio for an artist (`/radio/artist`) — a generated track
|
||||
/// list. Ported from the legacy api client.
|
||||
pub async fn get_radio_artist(&self, artist_id: &str) -> Result<RadioResponse> {
|
||||
self.get_radio(paths::RADIO_ARTIST, "artist_id", artist_id).await
|
||||
self.get_radio(paths::RADIO_ARTIST, "artist_id", artist_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Qobuz radio for a track (`/radio/track`).
|
||||
pub async fn get_radio_track(&self, track_id: &str) -> Result<RadioResponse> {
|
||||
self.get_radio(paths::RADIO_TRACK, "track_id", track_id).await
|
||||
self.get_radio(paths::RADIO_TRACK, "track_id", track_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Qobuz radio for an album (`/radio/album`).
|
||||
pub async fn get_radio_album(&self, album_id: &str) -> Result<RadioResponse> {
|
||||
self.get_radio(paths::RADIO_ALBUM, "album_id", album_id).await
|
||||
self.get_radio(paths::RADIO_ALBUM, "album_id", album_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_radio(&self, path: &str, key: &str, id: &str) -> Result<RadioResponse> {
|
||||
@@ -1312,7 +1357,14 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
let http_response = self
|
||||
.signed_get(&url, "genrelist", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"genrelist",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_genres(parent={:?}) status={}",
|
||||
@@ -1350,7 +1402,14 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
let http_response = self
|
||||
.signed_get_auth(&url, "discoverindex", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"discoverindex",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
log::info!(
|
||||
"[API] get_discover_index genre_ids={:?} status={}",
|
||||
@@ -1404,7 +1463,14 @@ impl QobuzClient {
|
||||
// Derive method name from endpoint path: "/discover/newReleases" -> "discovernewReleases"
|
||||
let method_name = endpoint.replace('/', "").replace('.', "");
|
||||
let http_response = self
|
||||
.signed_get_auth(&url, &method_name, &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
&method_name,
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
log::info!(
|
||||
"[API] get_discover_albums({}) query={:?} status={}",
|
||||
@@ -1462,7 +1528,14 @@ impl QobuzClient {
|
||||
|
||||
// First get raw JSON to debug structure
|
||||
let raw_response: serde_json::Value = self
|
||||
.signed_get_auth(&url, "discoverplaylists", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"discoverplaylists",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -1497,9 +1570,7 @@ impl QobuzClient {
|
||||
pub async fn get_playlist_tags(&self) -> Result<Vec<PlaylistTag>> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_GET_TAGS);
|
||||
|
||||
let http_response = self
|
||||
.signed_get_auth(&url, "playlistgetTags", &[])
|
||||
.await?;
|
||||
let http_response = self.signed_get_auth(&url, "playlistgetTags", &[]).await?;
|
||||
log::info!("[API] get_playlist_tags status={}", http_response.status());
|
||||
|
||||
let raw: PlaylistTagsResponse = http_response.json().await?;
|
||||
@@ -1737,9 +1808,7 @@ impl QobuzClient {
|
||||
match serde_json::from_str::<QobuzLyricsDocument>(&body) {
|
||||
Ok(mut doc) if doc.original.is_some() || doc.translation.is_some() => {
|
||||
if language.is_some() && doc.original.is_some() && doc.translation.is_none() {
|
||||
doc.translation = self
|
||||
.fetch_translation_fallback(track_id, &urls)
|
||||
.await;
|
||||
doc.translation = self.fetch_translation_fallback(track_id, &urls).await;
|
||||
}
|
||||
Ok(Some(doc))
|
||||
}
|
||||
@@ -1811,7 +1880,14 @@ impl QobuzClient {
|
||||
];
|
||||
|
||||
let http_response = self
|
||||
.signed_get(&url, "artistget", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistget",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_artist_basic({}) status={}",
|
||||
@@ -1851,7 +1927,14 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
let http_response = self
|
||||
.signed_get(&url, "artistget", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistget",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_artist_detail({}) status={}",
|
||||
@@ -1902,7 +1985,14 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
let http_response = self
|
||||
.signed_get(&url, "artistget", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistget",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_artist({}, albums={}) status={}",
|
||||
@@ -1921,10 +2011,14 @@ impl QobuzClient {
|
||||
pub async fn get_playlist_track_ids(&self, playlist_id: u64) -> Result<PlaylistWithTrackIds> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_GET);
|
||||
let http_response = self
|
||||
.signed_get(&url, "playlistget", &[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("extra", "track_ids".to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"playlistget",
|
||||
&[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("extra", "track_ids".to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_playlist_track_ids({}) status={}",
|
||||
@@ -1976,8 +2070,17 @@ impl QobuzClient {
|
||||
let headers = self.api_headers().await?;
|
||||
let timestamp = get_timestamp();
|
||||
let secret = self.secret().await?;
|
||||
let ids_str: String = track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let sig = sign_request("trackgetList", &[("tracks_id", &ids_str)], timestamp, &secret);
|
||||
let ids_str: String = track_ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sig = sign_request(
|
||||
"trackgetList",
|
||||
&[("tracks_id", &ids_str)],
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
|
||||
let body = serde_json::json!({ "tracks_id": track_ids });
|
||||
log::debug!("[API] get_tracks_batch POST ({} IDs)", track_ids.len());
|
||||
@@ -2029,12 +2132,16 @@ impl QobuzClient {
|
||||
|
||||
// First page — gives us metadata + total track count
|
||||
let http_response = self
|
||||
.signed_get(&url, "playlistget", &[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("limit", PAGE_SIZE.to_string()),
|
||||
("offset", "0".to_string()),
|
||||
("extra", "tracks".to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"playlistget",
|
||||
&[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("limit", PAGE_SIZE.to_string()),
|
||||
("offset", "0".to_string()),
|
||||
("extra", "tracks".to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_playlist({}) status={}",
|
||||
@@ -2082,7 +2189,12 @@ impl QobuzClient {
|
||||
let ts = get_timestamp();
|
||||
let sig = sign_request(
|
||||
"playlistget",
|
||||
&[("extra", "tracks"), ("limit", &limit), ("offset", &offset_str), ("playlist_id", &pid)],
|
||||
&[
|
||||
("extra", "tracks"),
|
||||
("limit", &limit),
|
||||
("offset", &offset_str),
|
||||
("playlist_id", &pid),
|
||||
],
|
||||
ts,
|
||||
&secret,
|
||||
);
|
||||
@@ -2165,21 +2277,18 @@ impl QobuzClient {
|
||||
|
||||
/// Enumerate the award catalog (/award/explore). Paginated.
|
||||
/// Returns raw JSON items so callers can extract just (id, name).
|
||||
pub async fn get_award_explore(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<serde_json::Value> {
|
||||
pub async fn get_award_explore(&self, limit: u32, offset: u32) -> Result<serde_json::Value> {
|
||||
let url = endpoints::build_url(paths::AWARD_EXPLORE);
|
||||
log::debug!("[API] get_award_explore(limit={}, offset={})", limit, offset);
|
||||
log::debug!(
|
||||
"[API] get_award_explore(limit={}, offset={})",
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
let response: serde_json::Value = self
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"awardexplore",
|
||||
&[
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
],
|
||||
&[("limit", limit.to_string()), ("offset", offset.to_string())],
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
@@ -2250,14 +2359,14 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
// Current shape: V2AlbumGenericListDto {has_more, items}.
|
||||
let mut items_value = response.get("items").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let mut items_value = response
|
||||
.get("items")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if let Some(items_arr) = items_value.as_array_mut() {
|
||||
for item in items_arr {
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
let needs_backfill = obj
|
||||
.get("artist")
|
||||
.map(|v| v.is_null())
|
||||
.unwrap_or(true);
|
||||
let needs_backfill = obj.get("artist").map(|v| v.is_null()).unwrap_or(true);
|
||||
if needs_backfill {
|
||||
if let Some(first_artist) = obj
|
||||
.get("artists")
|
||||
@@ -2313,7 +2422,11 @@ impl QobuzClient {
|
||||
offset
|
||||
);
|
||||
let response: serde_json::Value = self
|
||||
.signed_get(&url, "labelexplore", &[("limit", limit.to_string()), ("offset", offset.to_string())])
|
||||
.signed_get(
|
||||
&url,
|
||||
"labelexplore",
|
||||
&[("limit", limit.to_string()), ("offset", offset.to_string())],
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -2342,13 +2455,28 @@ impl QobuzClient {
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
];
|
||||
if let Some(v) = sort { params.push(("sort", v.to_string())); }
|
||||
if let Some(v) = order { params.push(("order", v.to_string())); }
|
||||
if let Some(v) = genre_ids { params.push(("genre_ids", v.to_string())); }
|
||||
if let Some(v) = from_date { params.push(("from_date", v.to_string())); }
|
||||
if let Some(v) = to_date { params.push(("to_date", v.to_string())); }
|
||||
if let Some(v) = sort {
|
||||
params.push(("sort", v.to_string()));
|
||||
}
|
||||
if let Some(v) = order {
|
||||
params.push(("order", v.to_string()));
|
||||
}
|
||||
if let Some(v) = genre_ids {
|
||||
params.push(("genre_ids", v.to_string()));
|
||||
}
|
||||
if let Some(v) = from_date {
|
||||
params.push(("from_date", v.to_string()));
|
||||
}
|
||||
if let Some(v) = to_date {
|
||||
params.push(("to_date", v.to_string()));
|
||||
}
|
||||
|
||||
log::debug!("[API] get_label_albums({}, limit={}, offset={})", label_id, limit, offset);
|
||||
log::debug!(
|
||||
"[API] get_label_albums({}, limit={}, offset={})",
|
||||
label_id,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
let response: Value = self
|
||||
.signed_get(&url, "labelgetalbums", ¶ms)
|
||||
.await?
|
||||
@@ -2371,7 +2499,9 @@ impl QobuzClient {
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
];
|
||||
if let Some(v) = genre_ids { params.push(("genre_ids", v.to_string())); }
|
||||
if let Some(v) = genre_ids {
|
||||
params.push(("genre_ids", v.to_string()));
|
||||
}
|
||||
|
||||
log::debug!("[API] get_label_next_releases({})", label_id);
|
||||
let response: Value = self
|
||||
@@ -2398,9 +2528,15 @@ impl QobuzClient {
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
];
|
||||
if let Some(v) = sort { params.push(("sort", v.to_string())); }
|
||||
if let Some(v) = order { params.push(("order", v.to_string())); }
|
||||
if let Some(v) = genre_ids { params.push(("genre_ids", v.to_string())); }
|
||||
if let Some(v) = sort {
|
||||
params.push(("sort", v.to_string()));
|
||||
}
|
||||
if let Some(v) = order {
|
||||
params.push(("order", v.to_string()));
|
||||
}
|
||||
if let Some(v) = genre_ids {
|
||||
params.push(("genre_ids", v.to_string()));
|
||||
}
|
||||
|
||||
log::debug!("[API] get_label_awarded_releases({})", label_id);
|
||||
let response: Value = self
|
||||
@@ -2421,11 +2557,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::LABEL_GET_PLAYLISTS);
|
||||
log::debug!("[API] get_label_playlists({})", label_id);
|
||||
let response: Value = self
|
||||
.signed_get(&url, "labelgetplaylists", &[
|
||||
("label_id", label_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"labelgetplaylists",
|
||||
&[
|
||||
("label_id", label_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
],
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -2442,11 +2582,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::LABEL_GET_TOP_ARTISTS);
|
||||
log::debug!("[API] get_label_top_artists({})", label_id);
|
||||
let response: Value = self
|
||||
.signed_get(&url, "labelgettopartists", &[
|
||||
("label_id", label_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"labelgettopartists",
|
||||
&[
|
||||
("label_id", label_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
],
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -2463,11 +2607,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::LABEL_STORY);
|
||||
log::debug!("[API] get_label_story({})", label_id);
|
||||
let response: Value = self
|
||||
.signed_get(&url, "labelstory", &[
|
||||
("label_id", label_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.signed_get(
|
||||
&url,
|
||||
"labelstory",
|
||||
&[
|
||||
("label_id", label_id.to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
],
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -2489,7 +2637,12 @@ impl QobuzClient {
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sig = sign_request("labelgetList", &[("label_ids", &ids_str)], timestamp, &secret);
|
||||
let sig = sign_request(
|
||||
"labelgetList",
|
||||
&[("label_ids", &ids_str)],
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
|
||||
let body = serde_json::json!({ "label_ids": label_ids });
|
||||
log::debug!("[API] get_label_list POST ({} ids)", label_ids.len());
|
||||
@@ -2669,9 +2822,11 @@ impl QobuzClient {
|
||||
// restriction — every quality would 403 the same way. Abort the
|
||||
// whole fallback loop immediately instead of firing 5 more
|
||||
// requests per track and feeding the storm (issue #637).
|
||||
Err(e @ (ApiError::Forbidden(_)
|
||||
| ApiError::ForbiddenCircuitOpen(_)
|
||||
| ApiError::AuthenticationError(_))) => {
|
||||
Err(
|
||||
e @ (ApiError::Forbidden(_)
|
||||
| ApiError::ForbiddenCircuitOpen(_)
|
||||
| ApiError::AuthenticationError(_)),
|
||||
) => {
|
||||
log::warn!("Stream URL aborting quality fallback: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -2769,7 +2924,15 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_SEARCH);
|
||||
let timestamp = get_timestamp();
|
||||
let secret = self.secret().await?;
|
||||
let signature = sign_search("playlistsearch", query, limit, offset, None, timestamp, &secret);
|
||||
let signature = sign_search(
|
||||
"playlistsearch",
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
None,
|
||||
timestamp,
|
||||
&secret,
|
||||
);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
let ts_str = timestamp.to_string();
|
||||
@@ -2828,8 +2991,12 @@ impl QobuzClient {
|
||||
pub async fn delete_playlist(&self, playlist_id: u64) -> Result<()> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_DELETE);
|
||||
|
||||
self.signed_get_auth(&url, "playlistdelete", &[("playlist_id", playlist_id.to_string())])
|
||||
.await?;
|
||||
self.signed_get_auth(
|
||||
&url,
|
||||
"playlistdelete",
|
||||
&[("playlist_id", playlist_id.to_string())],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2843,10 +3010,14 @@ impl QobuzClient {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
self.signed_get_auth(&url, "playlistaddTracks", &[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("track_ids", track_ids_str),
|
||||
])
|
||||
self.signed_get_auth(
|
||||
&url,
|
||||
"playlistaddTracks",
|
||||
&[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("track_ids", track_ids_str),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -2865,10 +3036,14 @@ impl QobuzClient {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
self.signed_get_auth(&url, "playlistdeleteTracks", &[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("playlist_track_ids", track_ids_str),
|
||||
])
|
||||
self.signed_get_auth(
|
||||
&url,
|
||||
"playlistdeleteTracks",
|
||||
&[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("playlist_track_ids", track_ids_str),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -2919,11 +3094,15 @@ impl QobuzClient {
|
||||
insert_before
|
||||
);
|
||||
|
||||
self.signed_get_auth(&url, "playlistupdateTracksPosition", &[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("playlist_track_ids", track_ids_str),
|
||||
("insert_before", insert_before.to_string()),
|
||||
])
|
||||
self.signed_get_auth(
|
||||
&url,
|
||||
"playlistupdateTracksPosition",
|
||||
&[
|
||||
("playlist_id", playlist_id.to_string()),
|
||||
("playlist_track_ids", track_ids_str),
|
||||
("insert_before", insert_before.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -2965,7 +3144,11 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_SUBSCRIBE);
|
||||
|
||||
let response = self
|
||||
.signed_get_auth(&url, "playlistsubscribe", &[("playlist_id", playlist_id.to_string())])
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"playlistsubscribe",
|
||||
&[("playlist_id", playlist_id.to_string())],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
@@ -2985,7 +3168,11 @@ impl QobuzClient {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_UNSUBSCRIBE);
|
||||
|
||||
let response = self
|
||||
.signed_get_auth(&url, "playlistunsubscribe", &[("playlist_id", playlist_id.to_string())])
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"playlistunsubscribe",
|
||||
&[("playlist_id", playlist_id.to_string())],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
@@ -3006,7 +3193,11 @@ impl QobuzClient {
|
||||
let type_key = format!("{}_ids", fav_type); // album_ids, track_ids, artist_ids
|
||||
|
||||
let response = self
|
||||
.signed_get_auth(&url, "favoritecreate", &[(type_key.as_str(), item_id.to_string())])
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"favoritecreate",
|
||||
&[(type_key.as_str(), item_id.to_string())],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
@@ -3025,7 +3216,11 @@ impl QobuzClient {
|
||||
let type_key = format!("{}_ids", fav_type);
|
||||
|
||||
let response = self
|
||||
.signed_get_auth(&url, "favoritedelete", &[(type_key.as_str(), item_id.to_string())])
|
||||
.signed_get_auth(
|
||||
&url,
|
||||
"favoritedelete",
|
||||
&[(type_key.as_str(), item_id.to_string())],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
@@ -3054,7 +3249,14 @@ impl QobuzClient {
|
||||
|
||||
log::debug!("[API] get_artist_page({}) sort={:?}", artist_id, sort);
|
||||
let response: serde_json::Value = self
|
||||
.signed_get(&url, "artistpage", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistpage",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -3090,7 +3292,14 @@ impl QobuzClient {
|
||||
offset
|
||||
);
|
||||
let response: serde_json::Value = self
|
||||
.signed_get(&url, "artistgetReleasesGrid", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"artistgetReleasesGrid",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -3120,7 +3329,14 @@ impl QobuzClient {
|
||||
limit
|
||||
);
|
||||
let response: serde_json::Value = self
|
||||
.signed_get(&url, "artiststory", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||
.signed_get(
|
||||
&url,
|
||||
"artiststory",
|
||||
&query
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
@@ -3230,11 +3446,7 @@ impl QobuzClient {
|
||||
///
|
||||
/// This is the new streaming endpoint that returns encrypted CMAF segments
|
||||
/// instead of a direct file URL.
|
||||
pub async fn get_file_url(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
) -> Result<TrackFileUrl> {
|
||||
pub async fn get_file_url(&self, track_id: u64, quality: Quality) -> Result<TrackFileUrl> {
|
||||
let format_id = quality.id();
|
||||
let url = endpoints::build_url(paths::FILE_URL);
|
||||
|
||||
@@ -3295,11 +3507,7 @@ impl QobuzClient {
|
||||
let code = status.as_u16();
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
let preview = body_preview(response).await;
|
||||
log::warn!(
|
||||
"[CMAF] file/url 403 for track {}{}",
|
||||
track_id,
|
||||
preview
|
||||
);
|
||||
log::warn!("[CMAF] file/url 403 for track {}{}", track_id, preview);
|
||||
return Err(ApiError::Forbidden(preview));
|
||||
}
|
||||
return Err(if code == 404 {
|
||||
@@ -3316,10 +3524,7 @@ impl QobuzClient {
|
||||
} else if status.is_server_error() {
|
||||
ApiError::ServerError(code)
|
||||
} else {
|
||||
ApiError::ApiResponse(format!(
|
||||
"file/url failed with status {}",
|
||||
status
|
||||
))
|
||||
ApiError::ApiResponse(format!("file/url failed with status {}", status))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user