Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub fn run() {
channel_exists,
update_source,
get_epg,
get_now_playing_batch,
download,
add_epg,
remove_epg,
Expand Down Expand Up @@ -429,6 +430,15 @@ async fn get_epg(channel: Channel) -> Result<Vec<EPG>, String> {
xtream::get_epg(channel).await.map_err(map_err_frontend)
}

#[tauri::command]
async fn get_now_playing_batch(
channels: Vec<Channel>,
) -> Result<std::collections::HashMap<i64, String>, String> {
xtream::get_now_playing_batch(channels)
.await
.map_err(map_err_frontend)
}

#[tauri::command]
async fn download(
state: State<'_, Mutex<AppState>>,
Expand Down
106 changes: 106 additions & 0 deletions src-tauri/src/xtream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::join;
use url::Url;

Expand All @@ -33,6 +34,7 @@ const GET_SERIES_CATEGORIES: &str = "get_series_categories";
const GET_LIVE_STREAM_CATEGORIES: &str = "get_live_categories";
const GET_VOD_CATEGORIES: &str = "get_vod_categories";
const GET_EPG: &str = "get_simple_data_table";
const GET_SHORT_EPG: &str = "get_short_epg";
const LIVE_STREAM_EXTENSION: &str = "ts";
const NO_SEASON_NUMBER: i64 = -9999;

Expand Down Expand Up @@ -109,6 +111,20 @@ struct XtreamEPGItem {
end: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct ShortEPGResponse {
#[serde(default)]
epg_listings: Vec<ShortEPGItem>,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
struct ShortEPGItem {
#[serde(default)]
title: String,
#[serde(default)]
now_playing: u8,
}

fn build_xtream_url(source: &mut Source) -> Result<Url> {
let mut url = Url::parse(&source.url.clone().context("Missing URL")?)?;
source.url_origin = Some(
Expand Down Expand Up @@ -588,3 +604,93 @@ fn get_timeshift_url(mut url: Url, start: String, end: String, stream_id: &str)
.append_pair("duration", &duration);
Ok(url.to_string())
}

pub async fn get_now_playing_batch(
channels: Vec<Channel>,
) -> Result<HashMap<i64, String>> {
let mut by_source: HashMap<i64, Vec<Channel>> = HashMap::new();
for ch in channels {
if let (Some(source_id), Some(_stream_id)) = (ch.source_id, ch.stream_id) {
by_source.entry(source_id).or_default().push(ch);
}
}

let mut result: HashMap<i64, String> = HashMap::new();
let semaphore = Arc::new(tokio::sync::Semaphore::new(10));

for (source_id, channels) in by_source {
let source = sql::get_source_from_id(source_id)?;
let mut base_url = build_xtream_url(&mut source.clone())?;
let user_agent = get_user_agent_from_source(&source)?;
base_url.query_pairs_mut().append_pair("action", GET_SHORT_EPG);

let client = Client::builder().user_agent(&user_agent).build()?;

let mut handles = Vec::new();
for ch in channels {
let client = client.clone();
let mut url = base_url.clone();
let sem = semaphore.clone();
let channel_id = ch.id.unwrap_or(-1);
let stream_id = ch.stream_id.unwrap().to_string();

handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await;

// Try get_short_epg first
url.query_pairs_mut()
.append_pair("stream_id", &stream_id)
.append_pair("limit", "2");
let mut title: Option<String> = match client.get(url.clone()).send().await {
Ok(resp) => {
let text = resp.text().await.unwrap_or_default();
serde_json::from_str::<ShortEPGResponse>(&text).ok().and_then(|e| {
let item = e.epg_listings.iter().find(|i| i.now_playing == 1)
.or_else(|| e.epg_listings.first());
item.and_then(|i| {
BASE64_STANDARD.decode(&i.title).ok().and_then(|b| String::from_utf8(b).ok())
})
})
},
Err(_) => None,
};

// Fall back to full EPG if short EPG returned nothing
if title.is_none() {
let mut full_url = url.clone();
full_url.set_query(None);
// Rebuild query params from the base URL parts
let pairs: Vec<(String, String)> = url.query_pairs()
.filter(|(k, _)| k != "action" && k != "limit")
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
full_url.query_pairs_mut().clear();
for (k, v) in &pairs {
full_url.query_pairs_mut().append_pair(k, v);
}
full_url.query_pairs_mut().append_pair("action", GET_EPG);
title = match client.get(full_url).send().await {
Ok(resp) => {
resp.json::<XtreamEPG>().await.ok().and_then(|e| {
e.epg_listings.into_iter().find(|i| i.now_playing == 1).and_then(|i| {
BASE64_STANDARD.decode(&i.title).ok().and_then(|b| String::from_utf8(b).ok())
})
})
},
Err(_) => None,
};
}

(channel_id, title)
}));
}

for handle in handles {
if let Ok((id, Some(title))) = handle.await {
result.insert(id, title);
}
}
}

Ok(result)
}
8 changes: 8 additions & 0 deletions src/app/channel-tile/channel-tile.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
white-space: nowrap;
}

.channel-now-playing {
font-size: 0.75rem;
color: #8cb4ff;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

.channel-source {
font-size: 0.75rem;
color: #adb5bd;
Expand Down
3 changes: 2 additions & 1 deletion src/app/channel-tile/channel-tile.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
</div>
<div class="channel-title-container d-flex flex-column justify-content-center">
<div class="channel-title">{{ channel?.name }}</div>
<div class="channel-source">{{ getSourceName() }}</div>
<div *ngIf="nowPlaying" class="channel-now-playing">{{ nowPlaying }}</div>
<div *ngIf="!nowPlaying" class="channel-source">{{ getSourceName() }}</div>
</div>
<div class="ms-auto d-flex flex-column justify-content-center align-items-center h-100">
<svg *ngIf="channel?.tv_archive === true" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
Expand Down
1 change: 1 addition & 0 deletions src/app/channel-tile/channel-tile.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class ChannelTileComponent implements OnDestroy, AfterViewInit {
@Input() channel?: Channel;
@Input() id!: number;
@Input() viewMode: number = 0;
@Input() nowPlaying: string = "";
@ViewChild(MatMenuTrigger, { static: true }) matMenuTrigger!: MatMenuTrigger;
menuTopLeftPosition = { x: 0, y: 0 };
showImage: boolean = true;
Expand Down
3 changes: 2 additions & 1 deletion src/app/home/home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ <h4 *ngIf="this.nodeStack.hasNodes()" class="ms-2 mb-0">
</div>
<div class="row gy-3" [@fade]="channelsVisible ? 'visible' : 'hidden'">
<app-channel-tile [attr.id]="i == 0 ? 'first' : null" *ngFor="let channel of channels; let i = index"
class="col-lg-4 col-md-4" [id]="i" [channel]="channel" [viewMode]="viewType"></app-channel-tile>
class="col-lg-4 col-md-4" [id]="i" [channel]="channel" [viewMode]="viewType"
[nowPlaying]="nowPlayingMap.get(channel.id!) || ''"></app-channel-tile>
</div>
</div>

Expand Down
24 changes: 24 additions & 0 deletions src/app/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export class HomeComponent implements AfterViewInit, OnDestroy {
loading = false;
nodeStack: Stack = new Stack();
showScrollTop = false;
nowPlayingMap: Map<number, string> = new Map();

scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
Expand Down Expand Up @@ -283,6 +284,29 @@ export class HomeComponent implements AfterViewInit, OnDestroy {
this.error.handleError(e);
}
this.loading = false;
this.fetchNowPlaying();
}

async fetchNowPlaying() {
const xtreamLive = this.channels.filter(
(c) =>
c.media_type == MediaType.livestream &&
c.source_id != null &&
this.memory.XtreamSourceIds.has(c.source_id!)
);
if (xtreamLive.length === 0) return;
try {
const data: Record<string, string> = await invoke("get_now_playing_batch", {
channels: xtreamLive,
});
const map = new Map<number, string>();
for (const [id, title] of Object.entries(data)) {
map.set(Number(id), title);
}
this.nowPlayingMap = map;
} catch (e) {
console.error("Failed to fetch now playing", e);
}
}

checkScrollTop() {
Expand Down