696 lines
21 KiB
Rust
696 lines
21 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
use super::config::Config;
|
|
|
|
use std::time::Duration;
|
|
|
|
#[derive(Clone)]
|
|
pub struct JellyfinClient {
|
|
base_url: String,
|
|
client: reqwest::blocking::Client,
|
|
token: String,
|
|
device_id: String,
|
|
}
|
|
|
|
/// Human-readable device label shown in Jellyfin's dashboard. Cosmetic only —
|
|
/// DeviceId is what the server keys sessions on.
|
|
fn device_name() -> String {
|
|
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
|
|
let h = h.trim();
|
|
if !h.is_empty() {
|
|
return h.to_string();
|
|
}
|
|
}
|
|
|
|
std::env::var("HOSTNAME").unwrap_or_else(|_| "cjtui".to_string())
|
|
}
|
|
|
|
pub struct LoginResult {
|
|
pub access_token: String,
|
|
pub user_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct AuthenticateUserByName<'a> {
|
|
#[serde(rename = "Username")]
|
|
username: &'a str,
|
|
#[serde(rename = "Pw")]
|
|
pw: &'a str,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PlaybackStartInfo<'a> {
|
|
#[serde(rename = "ItemId")]
|
|
item_id: &'a str,
|
|
|
|
#[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")]
|
|
position_ticks: Option<i64>,
|
|
|
|
#[serde(rename = "CanSeek")]
|
|
can_seek: bool,
|
|
|
|
#[serde(rename = "IsPaused")]
|
|
is_paused: bool,
|
|
|
|
#[serde(rename = "IsMuted")]
|
|
is_muted: bool,
|
|
|
|
// Optional but harmless to include; some servers like seeing it present.
|
|
#[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")]
|
|
media_source_id: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PlaybackProgressInfo<'a> {
|
|
#[serde(rename = "ItemId")]
|
|
item_id: &'a str,
|
|
|
|
#[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")]
|
|
position_ticks: Option<i64>,
|
|
|
|
#[serde(rename = "CanSeek")]
|
|
can_seek: bool,
|
|
|
|
#[serde(rename = "IsPaused")]
|
|
is_paused: bool,
|
|
|
|
#[serde(rename = "IsMuted")]
|
|
is_muted: bool,
|
|
|
|
#[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")]
|
|
media_source_id: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PlaybackStopInfo<'a> {
|
|
#[serde(rename = "ItemId")]
|
|
item_id: &'a str,
|
|
|
|
#[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")]
|
|
position_ticks: Option<i64>,
|
|
|
|
#[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")]
|
|
media_source_id: Option<&'a str>,
|
|
|
|
#[serde(rename = "Failed")]
|
|
failed: bool,
|
|
}
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
struct AuthenticationResult {
|
|
#[serde(rename = "AccessToken")]
|
|
access_token: Option<String>,
|
|
#[serde(rename = "User")]
|
|
user: Option<UserDto>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct UserDto {
|
|
#[serde(rename = "Id")]
|
|
id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ViewsResponse {
|
|
#[serde(rename = "Items", default)]
|
|
pub items: Vec<JfItem>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PlaylistItemsResponse {
|
|
#[serde(rename = "Items", default)]
|
|
items: Vec<JfItem>,
|
|
#[serde(rename = "TotalRecordCount")]
|
|
total_record_count: Option<usize>,
|
|
}
|
|
|
|
|
|
impl JellyfinClient {
|
|
pub fn from_config(cfg: &Config) -> Result<Self, String> {
|
|
let token = cfg
|
|
.access_token
|
|
.clone()
|
|
.ok_or_else(|| "missing access_token (need login)".to_string())?;
|
|
|
|
Ok(Self {
|
|
base_url: cfg.base_url.trim_end_matches('/').to_string(),
|
|
client: reqwest::blocking::Client::new(),
|
|
token,
|
|
device_id: cfg.device_id.clone().unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
pub fn unauthenticated(base_url: &str, device_id: &str) -> Self {
|
|
Self {
|
|
base_url: base_url.trim_end_matches('/').to_string(),
|
|
client: reqwest::blocking::Client::new(),
|
|
token: String::new(), // unused for login
|
|
device_id: device_id.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn login_by_name(&self, username: &str, password: &str) -> Result<LoginResult, String> {
|
|
let url = format!("{}/Users/AuthenticateByName", self.base_url);
|
|
|
|
let auth_header = format!(
|
|
"MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
|
|
device_name(),
|
|
self.device_id,
|
|
env!("CARGO_PKG_VERSION"),
|
|
);
|
|
|
|
let body = AuthenticateUserByName { username, pw: password };
|
|
|
|
let resp = self
|
|
.client
|
|
.post(url)
|
|
.header("Authorization", auth_header)
|
|
.json(&body)
|
|
.send()
|
|
.map_err(|e| format!("request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().unwrap_or_default();
|
|
return Err(format!("login failed: {status} {text}"));
|
|
}
|
|
|
|
let parsed: AuthenticationResult = resp
|
|
.json()
|
|
.map_err(|e| format!("bad response json: {e}"))?;
|
|
|
|
let token = parsed
|
|
.access_token
|
|
.ok_or_else(|| "missing AccessToken in response".to_string())?;
|
|
|
|
let user_id = parsed.user.and_then(|u| u.id);
|
|
|
|
Ok(LoginResult {
|
|
access_token: token,
|
|
user_id,
|
|
})
|
|
}
|
|
|
|
pub fn ping_base_url(base_url: &str) -> bool {
|
|
// Fast, unauthenticated check. Latch result at startup.
|
|
// We try a couple endpoints because Jellyfin setups vary.
|
|
let base = base_url.trim_end_matches('/');
|
|
|
|
let client = match reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_millis(900))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
let candidates = [
|
|
format!("{base}/System/Ping"),
|
|
format!("{base}/System/Info/Public"),
|
|
];
|
|
|
|
for url in candidates {
|
|
if let Ok(resp) = client.get(url).send() {
|
|
if resp.status().is_success() {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
pub fn mark_played(&self, user_id: &str, item_id: &str) -> Result<(), String> {
|
|
let base = self.base_url.trim_end_matches('/');
|
|
let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}");
|
|
|
|
let resp = self
|
|
.client
|
|
.post(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.send()
|
|
.map_err(|e| format!("mark_played request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("mark_played failed: HTTP {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn mark_unplayed(&self, user_id: &str, item_id: &str) -> Result<(), String> {
|
|
let base = self.base_url.trim_end_matches('/');
|
|
let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}");
|
|
|
|
let resp = self
|
|
.client
|
|
.delete(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.send()
|
|
.map_err(|e| format!("mark_unplayed request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("mark_unplayed failed: HTTP {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn report_playback_start(&self, item_id: &str, position_ticks: Option<i64>) -> Result<(), String> {
|
|
let base = self.base_url.trim_end_matches('/');
|
|
let url = format!("{base}/Sessions/Playing");
|
|
|
|
let body = PlaybackStartInfo {
|
|
item_id,
|
|
position_ticks,
|
|
can_seek: true,
|
|
is_paused: false,
|
|
is_muted: false,
|
|
media_source_id: Some(item_id),
|
|
};
|
|
|
|
let resp = self
|
|
.client
|
|
.post(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.json(&body)
|
|
.send()
|
|
.map_err(|e| format!("report_playback_start request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("report_playback_start HTTP {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn report_playback_progress(&self, item_id: &str, position_ticks: Option<i64>, is_paused: bool) -> Result<(), String> {
|
|
let base = self.base_url.trim_end_matches('/');
|
|
let url = format!("{base}/Sessions/Playing/Progress");
|
|
|
|
let body = PlaybackProgressInfo {
|
|
item_id,
|
|
position_ticks,
|
|
can_seek: true,
|
|
is_paused,
|
|
is_muted: false,
|
|
media_source_id: Some(item_id),
|
|
};
|
|
|
|
let resp = self
|
|
.client
|
|
.post(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.json(&body)
|
|
.send()
|
|
.map_err(|e| format!("report_playback_progress request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("report_playback_progress HTTP {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn report_playback_stopped(&self, item_id: &str, position_ticks: Option<i64>, failed: bool) -> Result<(), String> {
|
|
let base = self.base_url.trim_end_matches('/');
|
|
let url = format!("{base}/Sessions/Playing/Stopped");
|
|
|
|
let body = PlaybackStopInfo {
|
|
item_id,
|
|
position_ticks,
|
|
media_source_id: Some(item_id),
|
|
failed,
|
|
};
|
|
|
|
let resp = self
|
|
.client
|
|
.post(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.json(&body)
|
|
.send()
|
|
.map_err(|e| format!("report_playback_stopped request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("report_playback_stopped HTTP {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_views(&self, user_id: &str) -> Result<Vec<JfItem>, String> {
|
|
let url = format!("{}/Users/{}/Views", self.base_url, user_id);
|
|
|
|
let resp = self
|
|
.client
|
|
.get(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.send()
|
|
.map_err(|e| format!("Views request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().unwrap_or_default();
|
|
return Err(format!("Views failed: {status} {text}"));
|
|
}
|
|
|
|
let parsed: ViewsResponse = resp
|
|
.json()
|
|
.map_err(|e| format!("Views parse failed: {e}"))?;
|
|
|
|
Ok(parsed.items)
|
|
}
|
|
|
|
fn auth_header_value(&self) -> String {
|
|
// Identify as a client and include the token. DeviceId is per-install so
|
|
// that two machines on the same account hold independent sessions.
|
|
format!(
|
|
"MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\", Token=\"{}\"",
|
|
device_name(),
|
|
self.device_id,
|
|
env!("CARGO_PKG_VERSION"),
|
|
self.token,
|
|
)
|
|
}
|
|
|
|
pub fn list_items(
|
|
&self,
|
|
user_id: &str,
|
|
query: LibraryQuery,
|
|
start_index: usize,
|
|
limit: usize,
|
|
) -> Result<PagedItems, String> {
|
|
let url = format!("{}/Users/{}/Items", self.base_url, user_id);
|
|
|
|
let default_sort_by = if let Some(types) = query.include_item_types.as_deref() {
|
|
if types.contains("Episode") {
|
|
"ParentIndexNumber,IndexNumber,SortName".to_string()
|
|
} else {
|
|
"SortName".to_string()
|
|
}
|
|
} else {
|
|
"SortName".to_string()
|
|
};
|
|
|
|
let sort_by = query.sort_by.clone().unwrap_or(default_sort_by);
|
|
let sort_order = query
|
|
.sort_order
|
|
.clone()
|
|
.unwrap_or_else(|| "Ascending".to_string());
|
|
|
|
let recursive = if query.recursive { "true" } else { "false" };
|
|
|
|
let mut req = self
|
|
.client
|
|
.get(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.query(&[
|
|
("Recursive", recursive),
|
|
("StartIndex", &start_index.to_string()),
|
|
("Limit", &limit.to_string()),
|
|
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData,ChildCount,RecursiveItemCount"),
|
|
("SortBy", &sort_by),
|
|
("SortOrder", &sort_order),
|
|
]);
|
|
|
|
if let Some(types) = query.include_item_types.as_deref() {
|
|
// Jellyfin expects IncludeItemTypes as repeated query params, not CSV
|
|
for t in types.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
|
req = req.query(&[("IncludeItemTypes", t)]);
|
|
}
|
|
}
|
|
if let Some(parent) = query.parent_id.as_deref() {
|
|
req = req.query(&[("ParentId", parent)]);
|
|
}
|
|
if let Some(term) = query.search_term.as_deref() {
|
|
req = req.query(&[("SearchTerm", term)]);
|
|
}
|
|
if let Some(filters) = query.filters.as_deref() {
|
|
req = req.query(&[("Filters", filters)]);
|
|
}
|
|
|
|
let resp = req.send().map_err(|e| format!("request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().unwrap_or_default();
|
|
return Err(format!("list_items failed: {status} {text}"));
|
|
}
|
|
|
|
let parsed: ItemsResponse = resp
|
|
.json()
|
|
.map_err(|e| format!("bad response json: {e}"))?;
|
|
|
|
Ok(PagedItems {
|
|
items: parsed.items,
|
|
total: parsed.total_record_count.unwrap_or(0),
|
|
})
|
|
}
|
|
|
|
/// Returns true if there exists at least one resumable Episode under the given parent (Series or Season).
|
|
/// This is used to mark Series/Season rows as "in progress" even when Jellyfin doesn't populate
|
|
/// PlayedPercentage on those container types.
|
|
pub fn has_resumable_under(
|
|
&self,
|
|
user_id: &str,
|
|
parent_id: &str,
|
|
) -> Result<bool, String> {
|
|
// Series -> Season -> Episode, so this MUST be recursive for Series.
|
|
// Recursive=true is also fine for Season.
|
|
let mut q = LibraryQuery::all()
|
|
.with_parent(parent_id.to_string())
|
|
.with_item_types("Episode")
|
|
.with_filters("IsResumable");
|
|
|
|
q.recursive = true;
|
|
|
|
let page = self.list_items(user_id, q, 0, 1)?;
|
|
Ok(page.total > 0)
|
|
}
|
|
|
|
pub fn playlist_items(
|
|
&self,
|
|
user_id: &str,
|
|
playlist_id: &str,
|
|
start_index: usize,
|
|
limit: usize,
|
|
) -> Result<PagedItems, String> {
|
|
let url = format!("{}/Playlists/{}/Items", self.base_url, playlist_id);
|
|
|
|
let resp = self
|
|
.client
|
|
.get(url)
|
|
.header("Authorization", self.auth_header_value())
|
|
.query(&[
|
|
("UserId", user_id),
|
|
("StartIndex", &start_index.to_string()),
|
|
("Limit", &limit.to_string()),
|
|
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData,ChildCount,RecursiveItemCount"),
|
|
])
|
|
.send()
|
|
.map_err(|e| format!("request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().unwrap_or_default();
|
|
return Err(format!("playlist_items failed: {status} {text}"));
|
|
}
|
|
|
|
let parsed: PlaylistItemsResponse = resp
|
|
.json()
|
|
.map_err(|e| format!("bad response json: {e}"))?;
|
|
|
|
Ok(PagedItems {
|
|
items: parsed.items,
|
|
total: parsed.total_record_count.unwrap_or(0),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// What kind of library list we want.
|
|
#[derive(Clone)]
|
|
pub struct LibraryQuery {
|
|
pub include_item_types: Option<String>, // e.g. Some("Movie".into())
|
|
pub parent_id: Option<String>, // e.g. Some(series_id)
|
|
pub search_term: Option<String>, // e.g. Some("matrix".into())
|
|
pub recursive: bool, // true for big lists, false for drilldown
|
|
pub filters: Option<String>, // e.g. Some("IsResumable".into())
|
|
|
|
// optional overrides
|
|
pub sort_by: Option<String>, // e.g. Some("DateCreated".into())
|
|
pub sort_order: Option<String>, // "Ascending" or "Descending"
|
|
}
|
|
|
|
|
|
impl LibraryQuery {
|
|
pub fn movies() -> Self {
|
|
Self {
|
|
include_item_types: Some("Movie".into()),
|
|
parent_id: None,
|
|
search_term: None,
|
|
recursive: true,
|
|
filters: None,
|
|
sort_by: None,
|
|
sort_order: None,
|
|
}
|
|
}
|
|
pub fn shows() -> Self {
|
|
Self {
|
|
include_item_types: Some("Series".into()),
|
|
parent_id: None,
|
|
search_term: None,
|
|
recursive: true,
|
|
filters: None,
|
|
sort_by: None,
|
|
sort_order: None,
|
|
}
|
|
}
|
|
pub fn music() -> Self {
|
|
Self {
|
|
include_item_types: Some("Audio".into()),
|
|
parent_id: None,
|
|
search_term: None,
|
|
recursive: true,
|
|
filters: None,
|
|
sort_by: None,
|
|
sort_order: None,
|
|
}
|
|
}
|
|
|
|
// true "All" (no filter)
|
|
pub fn all() -> Self {
|
|
Self {
|
|
include_item_types: None,
|
|
parent_id: None,
|
|
search_term: None,
|
|
recursive: true,
|
|
filters: None,
|
|
sort_by: None,
|
|
sort_order: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
|
|
self.parent_id = Some(parent_id.into());
|
|
self.recursive = false; // drilldown is usually direct children
|
|
self
|
|
}
|
|
|
|
pub fn with_search(mut self, term: impl Into<String>) -> Self {
|
|
self.search_term = Some(term.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_item_types(mut self, types: impl Into<String>) -> Self {
|
|
self.include_item_types = Some(types.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_filters(mut self, filters: impl Into<String>) -> Self {
|
|
self.filters = Some(filters.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_sort_by(mut self, sort_by: impl Into<String>) -> Self {
|
|
self.sort_by = Some(sort_by.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_sort_order(mut self, sort_order: impl Into<String>) -> Self {
|
|
self.sort_order = Some(sort_order.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
|
|
pub struct PagedItems {
|
|
pub items: Vec<JfItem>,
|
|
pub total: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct JfStudio {
|
|
#[serde(rename = "Name")]
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone, Debug, Default)]
|
|
pub struct JfUserData {
|
|
#[serde(rename = "Played")]
|
|
pub played: Option<bool>,
|
|
|
|
#[serde(rename = "PlayedPercentage")]
|
|
pub played_percentage: Option<f64>,
|
|
|
|
#[serde(rename = "PlaybackPositionTicks")]
|
|
pub playback_position_ticks: Option<i64>,
|
|
|
|
#[serde(rename = "UnplayedItemCount")]
|
|
pub unplayed_item_count: Option<i32>,
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct JfItem {
|
|
#[serde(rename = "Id")]
|
|
pub id: String,
|
|
#[serde(rename = "Name")]
|
|
pub name: String,
|
|
#[serde(rename = "Type")]
|
|
pub item_type: Option<String>,
|
|
#[serde(rename = "ProductionYear")]
|
|
pub production_year: Option<i32>,
|
|
|
|
#[serde(rename = "ChildCount")]
|
|
pub child_count: Option<i64>,
|
|
#[serde(rename = "RecursiveItemCount")]
|
|
pub recursive_item_count: Option<i64>,
|
|
|
|
#[serde(rename = "IndexNumber")]
|
|
pub index_number: Option<i32>,
|
|
#[serde(rename = "ParentIndexNumber")]
|
|
pub parent_index_number: Option<i32>,
|
|
|
|
// ✅ add these
|
|
#[serde(rename = "RunTimeTicks")]
|
|
pub run_time_ticks: Option<i64>,
|
|
|
|
#[serde(rename = "Genres", default)]
|
|
pub genres: Vec<String>,
|
|
|
|
#[serde(rename = "Studios", default)]
|
|
pub studios: Vec<JfStudio>,
|
|
|
|
#[serde(rename = "Overview")]
|
|
pub overview: Option<String>,
|
|
|
|
#[serde(rename = "SeriesName")]
|
|
pub series_name: Option<String>,
|
|
|
|
#[serde(rename = "Album")]
|
|
pub album: Option<String>,
|
|
|
|
#[serde(rename = "AlbumArtist")]
|
|
pub album_artist: Option<String>,
|
|
|
|
#[serde(rename = "Artists", default)]
|
|
pub artists: Vec<String>,
|
|
|
|
#[serde(rename = "UserData")]
|
|
pub user_data: Option<JfUserData>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ItemsResponse {
|
|
#[serde(rename = "Items")]
|
|
items: Vec<JfItem>,
|
|
#[serde(rename = "TotalRecordCount")]
|
|
total_record_count: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StudioDto {
|
|
#[serde(rename = "Name")]
|
|
pub name: Option<String>,
|
|
} |