fix(kiosk): auto-hide OSD, prioritize hardware-decoded formats, clean YouTube share URLs
This commit is contained in:
Generated
+1
-1
@@ -1460,7 +1460,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellyfin-kiosk-rs"
|
name = "jellyfin-kiosk-rs"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellyfin-kiosk-rs"
|
name = "jellyfin-kiosk-rs"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
[2m2026-11-05T21:13:59.466559Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Starting Jellyfin HTPC Kiosk (Rust)...
|
||||||
|
[2m2026-11-05T21:13:59.467206Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::config[0m[2m:[0m Loaded config from ../config.yaml
|
||||||
|
[2m2026-11-05T21:13:59.474938Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::player[0m[2m:[0m libmpv initialized successfully (vo=libmpv, hwdec=auto)
|
||||||
|
[2m2026-11-05T21:13:59.549439Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::yatse[0m[2m:[0m Yatse Kodi TCP JSON-RPC socket server listening on 0.0.0.0:9090
|
||||||
|
[2m2026-11-05T21:13:59.549444Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Yatse Kodi JSON-RPC server listening on http://0.0.0.0:8080
|
||||||
|
[2m2026-11-05T21:13:59.549923Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::eventserver[0m[2m:[0m Kodi EventServer listening on UDP 0.0.0.0:9777
|
||||||
|
[2m2026-11-05T21:13:59.551471Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs[0m[2m:[0m Published Zeroconf services for Yatse discovery: 'Kodi (bart-laptop)'
|
||||||
|
[2m2026-11-05T21:13:59.691500Z[0m [32m INFO[0m [2mwinit::platform_impl::linux::x11::window[0m[2m:[0m Guessed window scale factor: 1
|
||||||
|
[2m2026-11-05T21:13:59.729454Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::youtube[0m[2m:[0m Loaded cached YouTube token from youtube_token.json
|
||||||
|
Cannot load libcuda.so.1
|
||||||
|
[2m2026-11-05T21:13:59.854273Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::player[0m[2m:[0m libmpv OpenGL render context created successfully!
|
||||||
|
[2m2026-11-05T21:13:59.916581Z[0m [32m INFO[0m [2mjellyfin_kiosk_rs::jellyfin[0m[2m:[0m Authenticated successfully with Jellyfin as bart
|
||||||
@@ -135,6 +135,7 @@ pub struct KioskApp {
|
|||||||
was_playing: bool,
|
was_playing: bool,
|
||||||
playback_osd_open: bool,
|
playback_osd_open: bool,
|
||||||
playback_osd_idx: usize,
|
playback_osd_idx: usize,
|
||||||
|
playback_osd_until: Option<Instant>,
|
||||||
last_seen_volume: f64,
|
last_seen_volume: f64,
|
||||||
volume_hud_until: Option<Instant>,
|
volume_hud_until: Option<Instant>,
|
||||||
osd_notice: Option<(String, Instant)>,
|
osd_notice: Option<(String, Instant)>,
|
||||||
@@ -236,6 +237,7 @@ impl KioskApp {
|
|||||||
was_playing: false,
|
was_playing: false,
|
||||||
playback_osd_open: false,
|
playback_osd_open: false,
|
||||||
playback_osd_idx: 0,
|
playback_osd_idx: 0,
|
||||||
|
playback_osd_until: None,
|
||||||
last_seen_volume: 100.0,
|
last_seen_volume: 100.0,
|
||||||
volume_hud_until: None,
|
volume_hud_until: None,
|
||||||
osd_notice: None,
|
osd_notice: None,
|
||||||
@@ -1070,19 +1072,24 @@ impl KioskApp {
|
|||||||
} else {
|
} else {
|
||||||
self.playback_osd_idx - 1
|
self.playback_osd_idx - 1
|
||||||
};
|
};
|
||||||
|
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||||
}
|
}
|
||||||
RemoteNavAction::Right => {
|
RemoteNavAction::Right => {
|
||||||
self.playback_osd_idx = (self.playback_osd_idx + 1) % 10;
|
self.playback_osd_idx = (self.playback_osd_idx + 1) % 10;
|
||||||
|
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||||
}
|
}
|
||||||
RemoteNavAction::Up | RemoteNavAction::Down => {
|
RemoteNavAction::Up | RemoteNavAction::Down => {
|
||||||
// Kodi behavior: Up or Down closes the OSD control bar
|
// Kodi behavior: Up or Down closes the OSD control bar
|
||||||
self.playback_osd_open = false;
|
self.playback_osd_open = false;
|
||||||
|
self.playback_osd_until = None;
|
||||||
}
|
}
|
||||||
RemoteNavAction::Select => {
|
RemoteNavAction::Select => {
|
||||||
self.execute_osd_action(self.playback_osd_idx);
|
self.execute_osd_action(self.playback_osd_idx);
|
||||||
|
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||||
}
|
}
|
||||||
RemoteNavAction::Back => {
|
RemoteNavAction::Back => {
|
||||||
self.playback_osd_open = false;
|
self.playback_osd_open = false;
|
||||||
|
self.playback_osd_until = None;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1109,13 +1116,16 @@ impl KioskApp {
|
|||||||
self.osd_notice = Some(("⏪ -60s".to_string(), Instant::now() + Duration::from_millis(1500)));
|
self.osd_notice = Some(("⏪ -60s".to_string(), Instant::now() + Duration::from_millis(1500)));
|
||||||
}
|
}
|
||||||
RemoteNavAction::Select => {
|
RemoteNavAction::Select => {
|
||||||
|
// Single Select/OK opens the OSD and starts the auto-hide timer without pausing!
|
||||||
self.playback_osd_open = true;
|
self.playback_osd_open = true;
|
||||||
self.playback_osd_idx = 2; // Pause/Play
|
self.playback_osd_idx = 2; // Pause/Play
|
||||||
|
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||||
}
|
}
|
||||||
RemoteNavAction::Back => {
|
RemoteNavAction::Back => {
|
||||||
// Return to library browser while media keeps playing in background
|
// Return to library browser while media keeps playing in background
|
||||||
self.view_mode = ViewMode::Browser;
|
self.view_mode = ViewMode::Browser;
|
||||||
self.playback_osd_open = false;
|
self.playback_osd_open = false;
|
||||||
|
self.playback_osd_until = None;
|
||||||
let p = self.player.clone();
|
let p = self.player.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
p.set_video_visible(false).await;
|
p.set_video_visible(false).await;
|
||||||
@@ -1651,15 +1661,27 @@ impl eframe::App for KioskApp {
|
|||||||
self.view_mode = ViewMode::Player;
|
self.view_mode = ViewMode::Player;
|
||||||
self.playback_osd_open = true;
|
self.playback_osd_open = true;
|
||||||
self.playback_osd_idx = 2; // Pause/Play
|
self.playback_osd_idx = 2; // Pause/Play
|
||||||
|
self.playback_osd_until = Some(Instant::now() + Duration::from_secs(4));
|
||||||
self.last_playing_title = current_title;
|
self.last_playing_title = current_title;
|
||||||
} else if !is_playing && self.was_playing {
|
} else if !is_playing && self.was_playing {
|
||||||
self.view_mode = ViewMode::Browser;
|
self.view_mode = ViewMode::Browser;
|
||||||
self.playback_osd_open = false;
|
self.playback_osd_open = false;
|
||||||
|
self.playback_osd_until = None;
|
||||||
self.last_playing_title.clear();
|
self.last_playing_title.clear();
|
||||||
}
|
}
|
||||||
self.was_playing = is_playing;
|
self.was_playing = is_playing;
|
||||||
|
|
||||||
if is_playing || self.volume_hud_until.is_some() || self.osd_notice.is_some() || self.folder_loading {
|
// Auto-hide playback OSD for video
|
||||||
|
if self.playback_osd_open {
|
||||||
|
if let Some(until) = self.playback_osd_until {
|
||||||
|
if Instant::now() >= until {
|
||||||
|
self.playback_osd_open = false;
|
||||||
|
self.playback_osd_until = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_playing || self.volume_hud_until.is_some() || self.playback_osd_until.is_some() || self.osd_notice.is_some() || self.folder_loading {
|
||||||
ctx.request_repaint_after(Duration::from_millis(100));
|
ctx.request_repaint_after(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub enum PlayableTarget {
|
|||||||
OpenFolder { folder_id: String, name: String },
|
OpenFolder { folder_id: String, name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct MediaItem {
|
pub struct MediaItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -48,6 +48,11 @@ pub struct MediaItem {
|
|||||||
pub has_backdrop_image: bool,
|
pub has_backdrop_image: bool,
|
||||||
pub parent_primary_image_item_id: Option<String>,
|
pub parent_primary_image_item_id: Option<String>,
|
||||||
pub parent_backdrop_item_id: Option<String>,
|
pub parent_backdrop_item_id: Option<String>,
|
||||||
|
pub recursive_item_count: Option<u32>,
|
||||||
|
pub child_count: Option<u32>,
|
||||||
|
pub series_id: Option<String>,
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaItem {
|
impl MediaItem {
|
||||||
@@ -604,22 +609,95 @@ impl JellyfinClient {
|
|||||||
if let Ok(headers) = self.get_authed_headers().await {
|
if let Ok(headers) = self.get_authed_headers().await {
|
||||||
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres",
|
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||||
base_url, show_id, user_id
|
base_url, show_id, user_id
|
||||||
);
|
);
|
||||||
return fetch_endpoint(&self.client, &url, &headers).await;
|
return fetch_endpoint(&self.client, &url, &headers).await;
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_episodes_paged(
|
||||||
|
&self,
|
||||||
|
show_id: Option<&str>,
|
||||||
|
season: Option<u32>,
|
||||||
|
start: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> (usize, Vec<MediaItem>) {
|
||||||
|
let base_url = self.config.server_url.trim_end_matches('/');
|
||||||
|
if let Ok(headers) = self.get_authed_headers().await {
|
||||||
|
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
||||||
|
let url = if let Some(sid) = show_id {
|
||||||
|
if let Some(s_num) = season {
|
||||||
|
format!(
|
||||||
|
"{}/Shows/{}/Episodes?userId={}&season={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||||
|
base_url, sid, user_id, s_num, start, limit
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}/Shows/{}/Episodes?userId={}&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||||
|
base_url, sid, user_id, start, limit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}/Users/{}/Items?includeItemTypes=Episode&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,SeriesId",
|
||||||
|
base_url, user_id, start, limit
|
||||||
|
)
|
||||||
|
};
|
||||||
|
return fetch_endpoint_paged(&self.client, &url, &headers).await;
|
||||||
|
}
|
||||||
|
(0, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_songs_paged(
|
||||||
|
&self,
|
||||||
|
album_id: Option<&str>,
|
||||||
|
artist_id: Option<&str>,
|
||||||
|
start: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> (usize, Vec<MediaItem>) {
|
||||||
|
let base_url = self.config.server_url.trim_end_matches('/');
|
||||||
|
if let Ok(headers) = self.get_authed_headers().await {
|
||||||
|
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
||||||
|
let url = if let Some(aid) = album_id {
|
||||||
|
format!(
|
||||||
|
"{}/Users/{}/Items?parentId={}&includeItemTypes=Audio&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
|
||||||
|
base_url, user_id, aid, start, limit
|
||||||
|
)
|
||||||
|
} else if let Some(arid) = artist_id {
|
||||||
|
format!(
|
||||||
|
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
|
||||||
|
base_url, user_id, arid, start, limit
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}/Users/{}/Items?includeItemTypes=Audio&recursive=true&startIndex={}&limit={}&fields=Overview,ProductionYear,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres,Album,AlbumId,ArtistItems,Artists,RunTimeTicks",
|
||||||
|
base_url, user_id, start, limit
|
||||||
|
)
|
||||||
|
};
|
||||||
|
return fetch_endpoint_paged(&self.client, &url, &headers).await;
|
||||||
|
}
|
||||||
|
(0, Vec::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
|
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
|
||||||
|
fetch_endpoint_paged(client, url, headers).await.1
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_endpoint_paged(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> (usize, Vec<MediaItem>) {
|
||||||
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
|
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
return parse_items(&json);
|
let items = parse_items(&json);
|
||||||
|
let total = json["TotalRecordCount"]
|
||||||
|
.as_u64()
|
||||||
|
.map(|n| n as usize)
|
||||||
|
.unwrap_or(items.len());
|
||||||
|
return (total, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vec::new()
|
(0, Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
||||||
@@ -630,7 +708,16 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
|||||||
let name = it["Name"].as_str().unwrap_or("Onbekend").to_string();
|
let name = it["Name"].as_str().unwrap_or("Onbekend").to_string();
|
||||||
let item_type = it["Type"].as_str().unwrap_or("Video").to_string();
|
let item_type = it["Type"].as_str().unwrap_or("Video").to_string();
|
||||||
let series_name = it["SeriesName"].as_str().map(|s| s.to_string());
|
let series_name = it["SeriesName"].as_str().map(|s| s.to_string());
|
||||||
let artist_name = it["AlbumArtist"].as_str().or_else(|| it["Artist"].as_str()).map(|s| s.to_string());
|
let artist_name = it["AlbumArtist"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| it["Artist"].as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
it["Artists"].as_array().and_then(|arr| arr.first().and_then(|a| a.as_str())).map(|s| s.to_string())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
it["ArtistItems"].as_array().and_then(|arr| arr.first().and_then(|a| a["Name"].as_str())).map(|s| s.to_string())
|
||||||
|
});
|
||||||
let genre = it["Genres"].as_array().and_then(|arr| {
|
let genre = it["Genres"].as_array().and_then(|arr| {
|
||||||
let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect();
|
let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect();
|
||||||
if list.is_empty() {
|
if list.is_empty() {
|
||||||
@@ -661,6 +748,11 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
|||||||
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
|
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
|
||||||
let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32);
|
let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32);
|
||||||
let season_name = it["SeasonName"].as_str().map(|s| s.to_string());
|
let season_name = it["SeasonName"].as_str().map(|s| s.to_string());
|
||||||
|
let recursive_item_count = it["RecursiveItemCount"].as_u64().map(|n| n as u32);
|
||||||
|
let child_count = it["ChildCount"].as_u64().map(|n| n as u32);
|
||||||
|
let series_id = it["SeriesId"].as_str().map(|s| s.to_string());
|
||||||
|
let album_id = it["AlbumId"].as_str().map(|s| s.to_string());
|
||||||
|
let album = it["Album"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
if !id.is_empty() {
|
if !id.is_empty() {
|
||||||
out.push(MediaItem {
|
out.push(MediaItem {
|
||||||
@@ -684,6 +776,11 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
|||||||
has_backdrop_image,
|
has_backdrop_image,
|
||||||
parent_primary_image_item_id,
|
parent_primary_image_item_id,
|
||||||
parent_backdrop_item_id,
|
parent_backdrop_item_id,
|
||||||
|
recursive_item_count,
|
||||||
|
child_count,
|
||||||
|
series_id,
|
||||||
|
album_id,
|
||||||
|
album,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ impl PlayerController {
|
|||||||
"yt-dlp"
|
"yt-dlp"
|
||||||
};
|
};
|
||||||
mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path));
|
mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path));
|
||||||
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best");
|
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080][vcodec!*='av01']+bestaudio/bestvideo[height<=?1080]+bestaudio/best");
|
||||||
mpv.set_option_string("ytdl-raw-options", "no-playlist=");
|
mpv.set_option_string("ytdl-raw-options", "no-playlist=");
|
||||||
|
|
||||||
let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) };
|
let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) };
|
||||||
|
|||||||
+270
-13
@@ -135,13 +135,35 @@ fn parse_limits(params: Option<&Value>, total: usize) -> (usize, usize) {
|
|||||||
let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||||
let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||||
let start = start.min(total);
|
let start = start.min(total);
|
||||||
let end = if end_val == 0 { total } else { (start + end_val).min(total) };
|
let end = if end_val == 0 || end_val <= start {
|
||||||
|
total
|
||||||
|
} else {
|
||||||
|
end_val.min(total)
|
||||||
|
};
|
||||||
return (start, end);
|
return (start, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(0, total)
|
(0, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_limits_raw(params: Option<&Value>, default_limit: usize) -> (usize, usize) {
|
||||||
|
if let Some(p) = params {
|
||||||
|
let limits_obj = p.get("limits")
|
||||||
|
.or_else(|| p.as_array().and_then(|a| a.iter().find_map(|x| x.get("limits"))));
|
||||||
|
if let Some(limits) = limits_obj {
|
||||||
|
let start = limits.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||||
|
let end_val = limits.get("end").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||||
|
let limit = if end_val > start {
|
||||||
|
end_val - start
|
||||||
|
} else {
|
||||||
|
default_limit
|
||||||
|
};
|
||||||
|
return (start, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0, default_limit)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct JsonRpcRequest {
|
struct JsonRpcRequest {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -207,10 +229,30 @@ pub fn normalize_playback_url(file: &str) -> String {
|
|||||||
if let Some(query) = trimmed.split('?').nth(1) {
|
if let Some(query) = trimmed.split('?').nth(1) {
|
||||||
let decoded = percent_decode(query);
|
let decoded = percent_decode(query);
|
||||||
if decoded.starts_with("http://") || decoded.starts_with("https://") {
|
if decoded.starts_with("http://") || decoded.starts_with("https://") {
|
||||||
return decoded;
|
return normalize_playback_url(&decoded);
|
||||||
}
|
}
|
||||||
if let Some(url_part) = decoded.split("url=").nth(1) {
|
if let Some(url_part) = decoded.split("url=").nth(1) {
|
||||||
return percent_decode(url_part);
|
return normalize_playback_url(&percent_decode(url_part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if trimmed.contains("youtube.com/watch") || trimmed.contains("youtu.be/") || trimmed.contains("youtube.com/shorts/") {
|
||||||
|
if let Some(pos) = trimmed.find("v=") {
|
||||||
|
let rest = &trimmed[pos + 2..];
|
||||||
|
let vid = rest.split('&').next().unwrap_or(rest);
|
||||||
|
if !vid.is_empty() {
|
||||||
|
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||||
|
}
|
||||||
|
} else if let Some(pos) = trimmed.find("youtu.be/") {
|
||||||
|
let rest = &trimmed[pos + 9..];
|
||||||
|
let vid = rest.split('?').next().unwrap_or(rest);
|
||||||
|
if !vid.is_empty() {
|
||||||
|
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||||
|
}
|
||||||
|
} else if let Some(pos) = trimmed.find("youtube.com/shorts/") {
|
||||||
|
let rest = &trimmed[pos + 19..];
|
||||||
|
let vid = rest.split('?').next().unwrap_or(rest);
|
||||||
|
if !vid.is_empty() {
|
||||||
|
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,6 +722,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
if let Some(item) = item_opt {
|
if let Some(item) = item_opt {
|
||||||
let movieid_opt = item.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
let movieid_opt = item.get("movieid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||||
let episodeid_opt = item.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
let episodeid_opt = item.get("episodeid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||||
|
let songid_opt = item.get("songid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())));
|
||||||
let file_opt = item.get("file").and_then(|f| f.as_str()).map(|s| s.to_string());
|
let file_opt = item.get("file").and_then(|f| f.as_str()).map(|s| s.to_string());
|
||||||
|
|
||||||
let p = state.player.clone();
|
let p = state.player.clone();
|
||||||
@@ -712,14 +755,28 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
let _ = p.play(&stream_url, &ep_title).await;
|
let _ = p.play(&stream_url, &ep_title).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if let Some(sid) = songid_opt {
|
||||||
|
if let Some(jellyfin_id) = id_map.get_str(sid).await {
|
||||||
|
let stream_url = format!("{}/Audio/{}/stream?static=true&api_key={}", base_url, jellyfin_id, token);
|
||||||
|
tracing::info!("Player.Open songid={} (jellyfin={}) -> {}", sid, jellyfin_id, stream_url);
|
||||||
|
let _ = p.play(&stream_url, "Muziek via Yatse").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if let Some(file) = file_opt {
|
} else if let Some(file) = file_opt {
|
||||||
if file.starts_with("jellyfin://movie/") || file.starts_with("jellyfin://episode/") || file.starts_with("jellyfin://video/") {
|
if file.starts_with("jellyfin://movie/") || file.starts_with("jellyfin://episode/") || file.starts_with("jellyfin://video/") {
|
||||||
let item_id = file.rsplit('/').next().unwrap_or("");
|
let item_id = file.rsplit('/').next().unwrap_or("");
|
||||||
let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, item_id, token);
|
let stream_url = format!("{}/Videos/{}/stream?static=true&api_key={}", base_url, item_id, token);
|
||||||
tracing::info!("Player.Open file='{}' -> {}", file, stream_url);
|
tracing::info!("Player.Open video file='{}' -> {}", file, stream_url);
|
||||||
let _ = p.play(&stream_url, "Media from Yatse").await;
|
let _ = p.play(&stream_url, "Media from Yatse").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if file.starts_with("jellyfin://audio/") || file.starts_with("jellyfin://song/") {
|
||||||
|
let item_id = file.rsplit('/').next().unwrap_or("");
|
||||||
|
let stream_url = format!("{}/Audio/{}/stream?static=true&api_key={}", base_url, item_id, token);
|
||||||
|
tracing::info!("Player.Open audio file='{}' -> {}", file, stream_url);
|
||||||
|
let _ = p.play(&stream_url, "Muziek via Yatse").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let norm_url = normalize_playback_url(&file);
|
let norm_url = normalize_playback_url(&file);
|
||||||
tracing::info!("Player.Open file: '{}' -> normalized: '{}'", file, norm_url);
|
tracing::info!("Player.Open file: '{}' -> normalized: '{}'", file, norm_url);
|
||||||
@@ -1307,7 +1364,8 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"rating": s.rating.unwrap_or(0.0),
|
"rating": s.rating.unwrap_or(0.0),
|
||||||
"plot": s.overview.clone().unwrap_or_default(),
|
"plot": s.overview.clone().unwrap_or_default(),
|
||||||
"genre": genres,
|
"genre": genres,
|
||||||
"episode": 0,
|
"episode": s.recursive_item_count.unwrap_or(1),
|
||||||
|
"season": s.child_count.unwrap_or(1),
|
||||||
"watchedepisodes": 0,
|
"watchedepisodes": 0,
|
||||||
"file": format!("jellyfin://show/{}", s.id),
|
"file": format!("jellyfin://show/{}", s.id),
|
||||||
"thumbnail": s.thumb_url(base_url),
|
"thumbnail": s.thumb_url(base_url),
|
||||||
@@ -1352,7 +1410,8 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"rating": s.rating.unwrap_or(0.0),
|
"rating": s.rating.unwrap_or(0.0),
|
||||||
"plot": s.overview.clone().unwrap_or_default(),
|
"plot": s.overview.clone().unwrap_or_default(),
|
||||||
"genre": genres,
|
"genre": genres,
|
||||||
"episode": 0,
|
"episode": s.recursive_item_count.unwrap_or(1),
|
||||||
|
"season": s.child_count.unwrap_or(1),
|
||||||
"watchedepisodes": 0,
|
"watchedepisodes": 0,
|
||||||
"file": format!("jellyfin://show/{}", s.id),
|
"file": format!("jellyfin://show/{}", s.id),
|
||||||
"thumbnail": s.thumb_url(base_url),
|
"thumbnail": s.thumb_url(base_url),
|
||||||
@@ -1457,8 +1516,9 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
p.get("season").and_then(|v| v.as_u64().map(|n| n as u32))
|
p.get("season").and_then(|v| v.as_u64().map(|n| n as u32))
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut episodes_out = Vec::new();
|
let base_url = &state.client.config.server_url;
|
||||||
if let Some(tid) = tid_opt {
|
if let Some(tid) = tid_opt {
|
||||||
|
let mut episodes_out = Vec::new();
|
||||||
if let Some(jellyfin_id) = state.id_map.get_str(tid).await {
|
if let Some(jellyfin_id) = state.id_map.get_str(tid).await {
|
||||||
let episodes = {
|
let episodes = {
|
||||||
let cache = state.episodes_cache.read().await;
|
let cache = state.episodes_cache.read().await;
|
||||||
@@ -1473,7 +1533,6 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let base_url = &state.client.config.server_url;
|
|
||||||
for ep in &eps {
|
for ep in &eps {
|
||||||
let s_num = ep.parent_index_number.unwrap_or(1);
|
let s_num = ep.parent_index_number.unwrap_or(1);
|
||||||
if let Some(sf) = season_filter {
|
if let Some(sf) = season_filter {
|
||||||
@@ -1508,7 +1567,6 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let total = episodes_out.len();
|
let total = episodes_out.len();
|
||||||
let (start, end) = parse_limits(req.params.as_ref(), total);
|
let (start, end) = parse_limits(req.params.as_ref(), total);
|
||||||
let paged = episodes_out[start..end].to_vec();
|
let paged = episodes_out[start..end].to_vec();
|
||||||
@@ -1520,6 +1578,53 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
},
|
},
|
||||||
"id": id
|
"id": id
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
let (start, limit) = parse_limits_raw(req.params.as_ref(), 100);
|
||||||
|
let (total, eps) = state.client.fetch_episodes_paged(None, None, start, limit).await;
|
||||||
|
let mut episodes_out = Vec::new();
|
||||||
|
for ep in eps {
|
||||||
|
let s_num = ep.parent_index_number.unwrap_or(1);
|
||||||
|
let ep_num = ep.index_number.unwrap_or(1);
|
||||||
|
let epid = state.id_map.get_or_create(&ep.id).await;
|
||||||
|
let show_tid = if let Some(ref sid) = ep.series_id {
|
||||||
|
state.id_map.get_or_create(sid).await
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let runtime = ep.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
|
||||||
|
let playcount = if ep.played_percentage.unwrap_or(0.0) >= 90.0 { 1 } else { 0 };
|
||||||
|
|
||||||
|
episodes_out.push(json!({
|
||||||
|
"episodeid": epid,
|
||||||
|
"tvshowid": show_tid,
|
||||||
|
"season": s_num,
|
||||||
|
"episode": ep_num,
|
||||||
|
"label": format!("{}x{:02}. {}", s_num, ep_num, ep.name),
|
||||||
|
"title": ep.name,
|
||||||
|
"plot": ep.overview.clone().unwrap_or_default(),
|
||||||
|
"runtime": runtime,
|
||||||
|
"rating": ep.rating.unwrap_or(0.0),
|
||||||
|
"playcount": playcount,
|
||||||
|
"file": format!("jellyfin://episode/{}", ep.id),
|
||||||
|
"thumbnail": ep.thumb_url(base_url),
|
||||||
|
"fanart": ep.backdrop_url(base_url),
|
||||||
|
"art": {
|
||||||
|
"thumb": ep.thumb_url(base_url),
|
||||||
|
"poster": ep.poster_url(base_url),
|
||||||
|
"fanart": ep.backdrop_url(base_url),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let actual_end = start + episodes_out.len();
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"result": {
|
||||||
|
"limits": {"start": start, "end": actual_end, "total": total},
|
||||||
|
"episodes": episodes_out
|
||||||
|
},
|
||||||
|
"id": id
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"VideoLibrary.GetEpisodeDetails" => {
|
"VideoLibrary.GetEpisodeDetails" => {
|
||||||
let eid_opt = req.params.as_ref().and_then(|p| {
|
let eid_opt = req.params.as_ref().and_then(|p| {
|
||||||
@@ -1682,11 +1787,155 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"AudioLibrary.GetSongs" => {
|
"AudioLibrary.GetSongs" => {
|
||||||
|
let al_opt = req.params.as_ref().and_then(|p| {
|
||||||
|
p.get("albumid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||||
|
});
|
||||||
|
let ar_opt = req.params.as_ref().and_then(|p| {
|
||||||
|
p.get("artistid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
});
|
||||||
|
|
||||||
|
let album_jf = if let Some(aid) = al_opt {
|
||||||
|
state.id_map.get_str(aid).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let artist_jf = if let Some(arid) = ar_opt {
|
||||||
|
state.id_map.get_str(arid).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (start, limit) = parse_limits_raw(req.params.as_ref(), 100);
|
||||||
|
let (total, songs) = state.client.fetch_songs_paged(album_jf.as_deref(), artist_jf.as_deref(), start, limit).await;
|
||||||
|
|
||||||
|
let base_url = &state.client.config.server_url;
|
||||||
|
let mut songs_out = Vec::new();
|
||||||
|
for s in songs {
|
||||||
|
let songid = state.id_map.get_or_create(&s.id).await;
|
||||||
|
let duration = s.runtime_ticks.map(|t| (t / 10_000_000) as i64).unwrap_or(0);
|
||||||
|
let track = s.index_number.unwrap_or(0);
|
||||||
|
let disc = s.parent_index_number.unwrap_or(1);
|
||||||
|
let artist = s.artist_name.as_deref().unwrap_or("Onbekend");
|
||||||
|
let album = s.album.as_deref().unwrap_or("");
|
||||||
|
let file = format!("jellyfin://audio/{}", s.id);
|
||||||
|
|
||||||
|
songs_out.push(json!({
|
||||||
|
"songid": songid,
|
||||||
|
"label": s.name,
|
||||||
|
"title": s.name,
|
||||||
|
"artist": [artist],
|
||||||
|
"album": album,
|
||||||
|
"duration": duration,
|
||||||
|
"track": track,
|
||||||
|
"disc": disc,
|
||||||
|
"year": s.year.unwrap_or(0),
|
||||||
|
"file": file,
|
||||||
|
"thumbnail": s.square_url(base_url),
|
||||||
|
"fanart": s.backdrop_url(base_url),
|
||||||
|
"art": {
|
||||||
|
"thumb": s.square_url(base_url),
|
||||||
|
"fanart": s.backdrop_url(base_url)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let actual_end = start + songs_out.len();
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": {
|
"result": {
|
||||||
"limits": {"start": 0, "end": 0, "total": 0},
|
"limits": {"start": start, "end": actual_end, "total": total},
|
||||||
"songs": []
|
"songs": songs_out
|
||||||
|
},
|
||||||
|
"id": id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"AudioLibrary.GetSongDetails" => {
|
||||||
|
let sid_opt = req.params.as_ref().and_then(|p| {
|
||||||
|
p.get("songid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||||
|
});
|
||||||
|
let mut details = json!({});
|
||||||
|
if let Some(sid) = sid_opt {
|
||||||
|
if let Some(jellyfin_id) = state.id_map.get_str(sid).await {
|
||||||
|
details = json!({
|
||||||
|
"songid": sid,
|
||||||
|
"file": format!("jellyfin://audio/{}", jellyfin_id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"result": {
|
||||||
|
"songdetails": details
|
||||||
|
},
|
||||||
|
"id": id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"AudioLibrary.GetAlbumDetails" => {
|
||||||
|
let aid_opt = req.params.as_ref().and_then(|p| {
|
||||||
|
p.get("albumid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||||
|
});
|
||||||
|
let mut details = json!({});
|
||||||
|
if let Some(aid) = aid_opt {
|
||||||
|
if let Some(jellyfin_id) = state.id_map.get_str(aid).await {
|
||||||
|
let lib = state.client.get_library().await;
|
||||||
|
if let Some(al) = lib.music_albums.iter().find(|x| x.id == jellyfin_id) {
|
||||||
|
let base_url = &state.client.config.server_url;
|
||||||
|
details = json!({
|
||||||
|
"albumid": aid,
|
||||||
|
"title": al.name,
|
||||||
|
"label": al.name,
|
||||||
|
"artist": al.artist_name.as_deref().unwrap_or(""),
|
||||||
|
"year": al.year.unwrap_or(0),
|
||||||
|
"thumbnail": al.square_url(base_url),
|
||||||
|
"fanart": al.backdrop_url(base_url),
|
||||||
|
"art": {
|
||||||
|
"thumb": al.square_url(base_url),
|
||||||
|
"fanart": al.backdrop_url(base_url),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"result": {
|
||||||
|
"albumdetails": details
|
||||||
|
},
|
||||||
|
"id": id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"AudioLibrary.GetArtistDetails" => {
|
||||||
|
let aid_opt = req.params.as_ref().and_then(|p| {
|
||||||
|
p.get("artistid").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||||
|
.or_else(|| p.as_array().and_then(|a| a.first()).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))))
|
||||||
|
});
|
||||||
|
let mut details = json!({});
|
||||||
|
if let Some(aid) = aid_opt {
|
||||||
|
if let Some(jellyfin_id) = state.id_map.get_str(aid).await {
|
||||||
|
let lib = state.client.get_library().await;
|
||||||
|
if let Some(ar) = lib.music_artists.iter().find(|x| x.id == jellyfin_id) {
|
||||||
|
let base_url = &state.client.config.server_url;
|
||||||
|
details = json!({
|
||||||
|
"artistid": aid,
|
||||||
|
"artist": ar.name,
|
||||||
|
"label": ar.name,
|
||||||
|
"thumbnail": ar.square_url(base_url),
|
||||||
|
"fanart": ar.backdrop_url(base_url),
|
||||||
|
"art": {
|
||||||
|
"thumb": ar.square_url(base_url),
|
||||||
|
"fanart": ar.backdrop_url(base_url),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"result": {
|
||||||
|
"artistdetails": details
|
||||||
},
|
},
|
||||||
"id": id
|
"id": id
|
||||||
})
|
})
|
||||||
@@ -1731,11 +1980,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"),
|
normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"),
|
||||||
"https://youtube.com/watch?v=6G45Qk0HQNY"
|
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"),
|
normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"),
|
||||||
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
|
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_playback_url("https://youtu.be/6G45Qk0HQNY?si=something"),
|
||||||
|
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1778,6 +2031,7 @@ mod tests {
|
|||||||
has_backdrop_image: true,
|
has_backdrop_image: true,
|
||||||
parent_primary_image_item_id: None,
|
parent_primary_image_item_id: None,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1851,6 +2105,7 @@ mod tests {
|
|||||||
has_backdrop_image: true,
|
has_backdrop_image: true,
|
||||||
parent_primary_image_item_id: None,
|
parent_primary_image_item_id: None,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1880,6 +2135,7 @@ mod tests {
|
|||||||
has_backdrop_image: false,
|
has_backdrop_image: false,
|
||||||
parent_primary_image_item_id: None,
|
parent_primary_image_item_id: None,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
MediaItem {
|
MediaItem {
|
||||||
id: "ep-uuid-2".into(),
|
id: "ep-uuid-2".into(),
|
||||||
@@ -1902,6 +2158,7 @@ mod tests {
|
|||||||
has_backdrop_image: false,
|
has_backdrop_image: false,
|
||||||
parent_primary_image_item_id: None,
|
parent_primary_image_item_id: None,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,6 +466,11 @@ impl YouTubeClient {
|
|||||||
has_backdrop_image: false,
|
has_backdrop_image: false,
|
||||||
parent_primary_image_item_id: thumb,
|
parent_primary_image_item_id: thumb,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
recursive_item_count: None,
|
||||||
|
child_count: None,
|
||||||
|
series_id: None,
|
||||||
|
album_id: None,
|
||||||
|
album: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,6 +550,11 @@ impl YouTubeClient {
|
|||||||
has_backdrop_image: false,
|
has_backdrop_image: false,
|
||||||
parent_primary_image_item_id: thumb,
|
parent_primary_image_item_id: thumb,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
recursive_item_count: None,
|
||||||
|
child_count: None,
|
||||||
|
series_id: None,
|
||||||
|
album_id: None,
|
||||||
|
album: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,6 +658,11 @@ impl YouTubeClient {
|
|||||||
has_backdrop_image: false,
|
has_backdrop_image: false,
|
||||||
parent_primary_image_item_id: thumb,
|
parent_primary_image_item_id: thumb,
|
||||||
parent_backdrop_item_id: None,
|
parent_backdrop_item_id: None,
|
||||||
|
recursive_item_count: None,
|
||||||
|
child_count: None,
|
||||||
|
series_id: None,
|
||||||
|
album_id: None,
|
||||||
|
album: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"access_token": "ya29.a0AehfTOkVjtpy5kB4TQRuD7E0MXJAKSipRjAkysg0HnjvBvZlnb1y2WQ6UCSPxOLPliLxdri6A9AB_MlgMPHp1AXnsKHXL3dA_tkVYp3yuAhud4nUCniG0WDpgUpJ_paOAGYf5vtiJqDprd7W_8612JfnVwKW2ymR1ptNJpJ-aZivVvMsebsJcjDruq05haZGvmQtTkLTaCgYKAYESARESFQHGX2MiCxxRMG9_Lvhm7KGNtUmMIw0207",
|
"access_token": "ya29.a0AehfTOmzi40Xcd4HJsYRCt-r2ZiRc2jDoynMSoynkK2pi5hAZEbmdbxN6Vid2ka9mE3rmEYafFlYoP9YZ7h4GjuGHE_nBnvLQNRHGtZCXbGKjmNc31QREZTbxc4uITVv-1VrNARf-_pgVOPywGNCfkinq8LbeDDT5wSX3Xs98J3bLpxccN9ZTErVFYMl_1BNXi_wWDPZaCgYKAZsSARESFQHGX2MiHjn2bDhDuW0uFN8u1498hw0207",
|
||||||
"refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk",
|
"refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk",
|
||||||
"expires_at": 1793817948,
|
"expires_at": 1793916488,
|
||||||
"token_type": "Bearer"
|
"token_type": "Bearer"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user