Merge pull request #357 from euzu/feature/epg_view

Feature/epg view
This commit is contained in:
euzu
2025-09-27 12:57:11 +02:00
committed by GitHub
27 changed files with 1131 additions and 409 deletions
+4
View File
@@ -8,6 +8,10 @@
- Series name empty paren problem fixed.
- Default sort removed
- Reconnect on WebSocket disconnect, WebSocket connection status icon in WEB-UI
- Added a Playlist EPG view with timeline, channels, active “now” line, and program details.
- New sidebar entry and icon for quick access to EPG.
- EPG data can be fetched from selected targets.
# 3.1.6 (2025-09-01)
- EPG Config View added
+4 -1
View File
@@ -8,4 +8,7 @@ pub(in crate::api) mod hls_api;
mod user_api;
pub(in crate::api) mod hdhomerun_api;
mod api_playlist_utils;
pub (in crate::api) mod websocket_api;
pub (in crate::api) mod websocket_api;
mod v1_api_playlist;
mod v1_api_user;
mod v1_api_config;
+11 -369
View File
@@ -1,369 +1,18 @@
use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_target};
use crate::api::api_utils::try_unwrap_body;
use crate::api::endpoints::download_api;
use crate::api::endpoints::user_api::user_api_register;
use crate::api::endpoints::v1_api_playlist::v1_api_playlist_register;
use crate::api::endpoints::v1_api_user::v1_api_user_register;
use crate::api::model::AppState;
use crate::auth::create_access_token;
use crate::auth::validator_admin;
use crate::model::{InputSource, ProxyUserCredentials};
use crate::model::{ConfigInput, ConfigInputOptions};
use crate::processing::processor::playlist;
use crate::repository::user_repository::store_api_user;
use crate::utils::ip_checker::get_ips;
use crate::{utils, VERSION};
use crate::api::api_utils::try_unwrap_body;
use crate::{VERSION};
use axum::response::IntoResponse;
use log::error;
use serde_json::json;
use shared::error::TuliproxError;
use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, InputType, IpCheckDto, PlaylistRequest, PlaylistRequestType, ProxyUserCredentialsDto, StatusCheck, WebplayerUrlRequest};
use shared::utils::{concat_path_leading_slash, sanitize_sensitive_info};
use std::collections::{BTreeMap};
use shared::model::{IpCheckDto,StatusCheck};
use shared::utils::{concat_path_leading_slash};
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::utils::{prepare_sources_batch, prepare_users};
use crate::utils::request::download_text_content;
fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str) -> Option<TuliproxError> {
match utils::save_api_proxy(file_path, backup_dir, api_proxy) {
Ok(()) => {}
Err(err) => {
error!("Failed to save api_proxy.yml {err}");
return Some(err);
}
}
None
}
fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option<TuliproxError> {
match utils::save_main_config(file_path, backup_dir, cfg) {
Ok(()) => {}
Err(err) => {
error!("Failed to save config.yml {err}");
return Some(err);
}
}
None
}
async fn save_config_api_proxy_user(
method: axum::http::Method,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path(target_name): axum::extract::Path<String>,
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
) -> impl axum::response::IntoResponse + Send {
credential.prepare();
if let Err(err) = credential.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
}
let is_update = method == axum::http::Method::PUT;
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut target_found = false;
for target_user in &api_proxy.user {
if target_user.target == target_name {
target_found = true;
}
for user in &target_user.credentials {
if !is_update && user.username == credential.username {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response();
}
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
if u == c && user.username != credential.username {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate token {c}")}))
).into_response();
}
}
}
}
if is_update && !target_found {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response();
}
for target in &mut api_proxy.user {
if target.target == target_name {
if is_update {
let mut updated = false;
for user in &mut target.credentials {
if user.username == credential.username {
*user = ProxyUserCredentials::from(&credential);
updated = true;
break;
}
}
if !updated {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response();
}
} else {
target.credentials.push(ProxyUserCredentials::from(&credential));
}
}
}
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
// Udate state after successful save
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
}
axum::http::StatusCode::OK.into_response()
}
async fn delete_config_api_proxy_user(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path((target_name, username)): axum::extract::Path<(String, String)>,
) -> impl axum::response::IntoResponse + Send {
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut modified = false;
for target_user in &mut api_proxy.user {
if target_user.target == target_name {
let count = target_user.credentials.len();
target_user.credentials.retain(|user| user.username != username);
modified = count != target_user.credentials.len();
break;
}
}
if modified {
let new_api_proxy = Arc::new(api_proxy);
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
} else {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User not found {username} in target {target_name}")}))).into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
async fn save_config_main(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(cfg): axum::extract::Json<ConfigDto>,
) -> impl axum::response::IntoResponse + Send {
if cfg.is_valid() {
let paths = app_state.app_config.paths.load();
let file_path = paths.config_file_path.as_str();
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
if let Some(err) = intern_save_config_main(file_path, backup_dir.as_ref(), &cfg) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
axum::http::StatusCode::OK.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response()
}
}
async fn save_config_api_proxy_config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(mut req_api_proxy): axum::extract::Json<Vec<ApiProxyServerInfoDto>>,
) -> impl axum::response::IntoResponse + Send {
for server_info in &mut req_api_proxy {
if !server_info.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response();
}
}
// TODO if hot reload is on, loaded twice
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
api_proxy.server = req_api_proxy.iter().map(Into::into).collect();
let new_api_proxy = Arc::new(api_proxy);
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
async fn playlist_update(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(targets): axum::extract::Json<Vec<String>>,
) -> impl axum::response::IntoResponse + Send {
let user_targets = if targets.is_empty() { None } else { Some(targets) };
let process_targets = app_state.app_config.sources.load().validate_targets(user_targets.as_ref());
match process_targets {
Ok(valid_targets) => {
let app_config = Arc::clone(&app_state.app_config);
let event_manager = Arc::clone(&app_state.event_manager);
tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client.load()), app_config, Arc::new(valid_targets), Some(event_manager)));
axum::http::StatusCode::OK.into_response()
}
Err(err) => {
error!("Failed playlist update {}", sanitize_sensitive_info(err.to_string().as_str()));
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
}
}
}
fn create_config_input_for_m3u(url: &str) -> ConfigInput {
ConfigInput {
id: 0,
name: String::from("m3u_req"),
input_type: InputType::M3u,
url: String::from(url),
enabled: true,
options: Some(ConfigInputOptions {
xtream_skip_live: false,
xtream_skip_vod: false,
xtream_skip_series: false,
xtream_live_stream_without_extension: false,
xtream_live_stream_use_prefix: true,
}),
..Default::default()
}
}
fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput {
ConfigInput {
id: 0,
name: String::from("xc_req"),
input_type: InputType::Xtream,
url: String::from(host),
username: Some(String::from(username)),
password: Some(String::from(password)),
enabled: true,
options: Some(ConfigInputOptions {
xtream_skip_live: false,
xtream_skip_vod: false,
xtream_skip_series: false,
xtream_live_stream_without_extension: false,
xtream_live_stream_use_prefix: true,
}),
..Default::default()
}
}
async fn playlist_content(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
) -> impl IntoResponse + Send {
let config = app_state.app_config.config.load();
match playlist_req.rtype {
PlaylistRequestType::Input => {
if let Some(source_id) = playlist_req.source_id {
get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid input"}))).into_response()
}
}
PlaylistRequestType::Target => {
if let Some(source_id) = playlist_req.source_id {
get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response()
}
}
PlaylistRequestType::Xtream => {
if let (Some(url), Some(username), Some(password)) = (playlist_req.url.as_ref(), playlist_req.username.as_ref(), playlist_req.password.as_ref()) {
let input = create_config_input_for_xtream(username, password, url);
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
}
}
PlaylistRequestType::M3U => {
if let Some(url) = playlist_req.url.as_ref() {
let input = create_config_input_for_m3u(url);
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
}
}
}
}
async fn playlist_webplayer(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_item): axum::extract::Json<WebplayerUrlRequest>,
) -> impl axum::response::IntoResponse + Send {
let access_token = create_access_token(&app_state.app_config.access_token_secret, 30);
let config = app_state.app_config.config.load();
let server_name = config.web_ui.as_ref().and_then(|web_ui| web_ui.player_server.as_ref()).map_or("default", |server_name| server_name.as_str());
let server_info = app_state.app_config.get_server_info(server_name);
let base_url = server_info.get_base_url();
format!("{base_url}/token/{access_token}/{}/{}/{}", playlist_item.target_id, playlist_item.cluster.as_stream_type(), playlist_item.virtual_id).into_response()
}
async fn config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl axum::response::IntoResponse + Send {
let paths = app_state.app_config.paths.load();
match utils::read_app_config_dto(&paths, true, false) {
Ok(mut app_config) => {
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false) {
error!("Failed to prepare sources batch: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config) {
error!("Failed to prepare users: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else {
axum::response::Json(app_config).into_response()
}
}
Err(err) => {
error!("Failed to read config files: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn config_batch_content(
axum::extract::Path(input_id): axum::extract::Path<u16>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl axum::response::IntoResponse + Send {
if let Some(config_input) = app_state.app_config.get_input_by_id(input_id) {
// The url is changed at this point, we need the raw url for the batch file
if let Some(batch_url) = config_input.t_batch_url.as_ref() {
let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned());
return match download_text_content(Arc::clone(&app_state.http_client.load()), &input_source, None).await {
Ok((content, _path)) => {
// Return CSV with explicit content-type
try_unwrap_body!(axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8")
.body(content))
}
Err(err) => {
error!("Failed to read batch file: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
}
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"}))).into_response()
}
use crate::api::endpoints::v1_api_config::v1_api_config_register;
async fn create_ipinfo_check(app_state: &Arc<AppState>) -> Option<(Option<String>, Option<String>)> {
let config = app_state.app_config.config.load();
@@ -429,19 +78,12 @@ pub fn v1_api_register(web_auth_enabled: bool, app_state: Arc<AppState>, web_ui_
let mut router = axum::Router::new();
router = router
.route("/status", axum::routing::get(status))
.route("/config", axum::routing::get(config))
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
.route("/config/main", axum::routing::post(save_config_main))
.route("/user/{target}", axum::routing::post(save_config_api_proxy_user))
.route("/user/{target}", axum::routing::put(save_config_api_proxy_user))
.route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
.route("/config/apiproxy", axum::routing::post(save_config_api_proxy_config))
.route("/playlist/webplayer", axum::routing::post(playlist_webplayer))
.route("/playlist/update", axum::routing::post(playlist_update))
.route("/playlist", axum::routing::post(playlist_content))
.route("/file/download", axum::routing::post(download_api::queue_download_file))
.route("/file/download/info", axum::routing::get(download_api::download_file_info))
.route("/ipinfo", axum::routing::get(ipinfo));
router = v1_api_config_register(router);
router = v1_api_user_register(router);
router = v1_api_playlist_register(router);
if web_auth_enabled {
router = router.route_layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_admin));
}
+137
View File
@@ -0,0 +1,137 @@
use crate::api::model::AppState;
use crate::model::{InputSource};
use axum::response::IntoResponse;
use axum::Router;
use serde_json::json;
use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto};
use std::sync::Arc;
use log::error;
use shared::error::TuliproxError;
use crate::api::api_utils::try_unwrap_body;
use crate::{utils};
use crate::utils::{prepare_sources_batch, prepare_users};
use crate::utils::request::download_text_content;
pub(in crate::api::endpoints) fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str) -> Option<TuliproxError> {
match utils::save_api_proxy(file_path, backup_dir, api_proxy) {
Ok(()) => {}
Err(err) => {
error!("Failed to save api_proxy.yml {err}");
return Some(err);
}
}
None
}
fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option<TuliproxError> {
match utils::save_main_config(file_path, backup_dir, cfg) {
Ok(()) => {}
Err(err) => {
error!("Failed to save config.yml {err}");
return Some(err);
}
}
None
}
async fn save_config_main(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(cfg): axum::extract::Json<ConfigDto>,
) -> impl axum::response::IntoResponse + Send {
if cfg.is_valid() {
let paths = app_state.app_config.paths.load();
let file_path = paths.config_file_path.as_str();
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
if let Some(err) = intern_save_config_main(file_path, backup_dir.as_ref(), &cfg) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
axum::http::StatusCode::OK.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response()
}
}
async fn save_config_api_proxy_config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(mut req_api_proxy): axum::extract::Json<Vec<ApiProxyServerInfoDto>>,
) -> impl axum::response::IntoResponse + Send {
for server_info in &mut req_api_proxy {
if !server_info.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response();
}
}
// TODO if hot reload is on, loaded twice
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
api_proxy.server = req_api_proxy.iter().map(Into::into).collect();
let new_api_proxy = Arc::new(api_proxy);
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
async fn config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl axum::response::IntoResponse + Send {
let paths = app_state.app_config.paths.load();
match utils::read_app_config_dto(&paths, true, false) {
Ok(mut app_config) => {
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false) {
error!("Failed to prepare sources batch: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config) {
error!("Failed to prepare users: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else {
axum::response::Json(app_config).into_response()
}
}
Err(err) => {
error!("Failed to read config files: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn config_batch_content(
axum::extract::Path(input_id): axum::extract::Path<u16>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl axum::response::IntoResponse + Send {
if let Some(config_input) = app_state.app_config.get_input_by_id(input_id) {
// The url is changed at this point, we need the raw url for the batch file
if let Some(batch_url) = config_input.t_batch_url.as_ref() {
let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned());
return match download_text_content(Arc::clone(&app_state.http_client.load()), &input_source, None).await {
Ok((content, _path)) => {
// Return CSV with explicit content-type
try_unwrap_body!(axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8")
.body(content))
}
Err(err) => {
error!("Failed to read batch file: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
}
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"}))).into_response()
}
pub fn v1_api_config_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
router
.route("/config", axum::routing::get(config))
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
.route("/config/main", axum::routing::post(save_config_main))
.route("/config/apiproxy", axum::routing::post(save_config_api_proxy_config))
}
@@ -0,0 +1,160 @@
use std::sync::Arc;
use axum::response::IntoResponse;
use axum::Router;
use log::error;
use serde_json::json;
use shared::model::{InputType, PlaylistEpgRequest, PlaylistRequest, PlaylistRequestType, WebplayerUrlRequest};
use shared::utils::sanitize_sensitive_info;
use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_target};
use crate::api::model::AppState;
use crate::auth::create_access_token;
use crate::model::{parse_xmltv_for_web_ui, ConfigInput, ConfigInputOptions};
use crate::processing::processor::playlist;
use url::Url;
fn create_config_input_for_m3u(url: &str) -> ConfigInput {
ConfigInput {
id: 0,
name: String::from("m3u_req"),
input_type: InputType::M3u,
url: String::from(url),
enabled: true,
options: Some(ConfigInputOptions {
xtream_skip_live: false,
xtream_skip_vod: false,
xtream_skip_series: false,
xtream_live_stream_without_extension: false,
xtream_live_stream_use_prefix: true,
}),
..Default::default()
}
}
fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput {
ConfigInput {
id: 0,
name: String::from("xc_req"),
input_type: InputType::Xtream,
url: String::from(host),
username: Some(String::from(username)),
password: Some(String::from(password)),
enabled: true,
options: Some(ConfigInputOptions {
xtream_skip_live: false,
xtream_skip_vod: false,
xtream_skip_series: false,
xtream_live_stream_without_extension: false,
xtream_live_stream_use_prefix: true,
}),
..Default::default()
}
}
async fn playlist_update(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(targets): axum::extract::Json<Vec<String>>,
) -> impl axum::response::IntoResponse + Send {
let user_targets = if targets.is_empty() { None } else { Some(targets) };
let process_targets = app_state.app_config.sources.load().validate_targets(user_targets.as_ref());
match process_targets {
Ok(valid_targets) => {
let app_config = Arc::clone(&app_state.app_config);
let event_manager = Arc::clone(&app_state.event_manager);
tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client.load()), app_config, Arc::new(valid_targets), Some(event_manager)));
axum::http::StatusCode::OK.into_response()
}
Err(err) => {
error!("Failed playlist update {}", sanitize_sensitive_info(err.to_string().as_str()));
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
}
}
}
async fn playlist_content(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
) -> impl IntoResponse + Send {
let config = app_state.app_config.config.load();
match playlist_req.rtype {
PlaylistRequestType::Input => {
if let Some(source_id) = playlist_req.source_id {
get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid input"}))).into_response()
}
}
PlaylistRequestType::Target => {
if let Some(source_id) = playlist_req.source_id {
get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response()
}
}
PlaylistRequestType::Xtream => {
if let (Some(url), Some(username), Some(password)) = (playlist_req.url.as_ref(), playlist_req.username.as_ref(), playlist_req.password.as_ref()) {
match Url::parse(url) {
Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {
let input = create_config_input_for_xtream(username, password, url);
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
}
_ => {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response()
}
}
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
}
}
PlaylistRequestType::M3U => {
if let Some(url) = playlist_req.url.as_ref() {
match Url::parse(url) {
Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {
let input = create_config_input_for_m3u(url);
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
}
_ => {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response()
}
}
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
}
}
}
}
async fn playlist_webplayer(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_item): axum::extract::Json<WebplayerUrlRequest>,
) -> impl axum::response::IntoResponse + Send {
let access_token = create_access_token(&app_state.app_config.access_token_secret, 30);
let config = app_state.app_config.config.load();
let server_name = config.web_ui.as_ref().and_then(|web_ui| web_ui.player_server.as_ref()).map_or("default", |server_name| server_name.as_str());
let server_info = app_state.app_config.get_server_info(server_name);
let base_url = server_info.get_base_url();
format!("{base_url}/token/{access_token}/{}/{}/{}", playlist_item.target_id, playlist_item.cluster.as_stream_type(), playlist_item.virtual_id).into_response()
}
async fn playlist_epg(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_epg_req): axum::extract::Json<PlaylistEpgRequest>,
) -> impl IntoResponse + Send {
if let Some(target) = app_state.app_config.get_target_by_id(playlist_epg_req.target_id) {
let config = &app_state.app_config.config.load();
if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) {
if let Ok(epg) = parse_xmltv_for_web_ui(&epg_path) {
return (axum::http::StatusCode::OK, axum::Json(epg)).into_response();
}
}
}
axum::http::StatusCode::NO_CONTENT.into_response()
}
pub fn v1_api_playlist_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
router
.route("/playlist/webplayer", axum::routing::post(playlist_webplayer))
.route("/playlist/update", axum::routing::post(playlist_update))
.route("/playlist/epg", axum::routing::post(playlist_epg))
.route("/playlist", axum::routing::post(playlist_content))
}
+132
View File
@@ -0,0 +1,132 @@
use crate::api::model::AppState;
use crate::model::ProxyUserCredentials;
use crate::repository::user_repository::store_api_user;
use axum::response::IntoResponse;
use axum::Router;
use serde_json::json;
use shared::model::{ApiProxyConfigDto, ProxyUserCredentialsDto};
use std::sync::Arc;
async fn save_config_api_proxy_user(
method: axum::http::Method,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path(target_name): axum::extract::Path<String>,
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
) -> impl axum::response::IntoResponse + Send {
credential.prepare();
if let Err(err) = credential.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
}
let is_update = method == axum::http::Method::PUT;
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut target_found = false;
for target_user in &api_proxy.user {
if target_user.target == target_name {
target_found = true;
}
for user in &target_user.credentials {
if !is_update && user.username == credential.username {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response();
}
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
if u == c && user.username != credential.username {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate token {c}")}))
).into_response();
}
}
}
}
if is_update && !target_found {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response();
}
for target in &mut api_proxy.user {
if target.target == target_name {
if is_update {
let mut updated = false;
for user in &mut target.credentials {
if user.username == credential.username {
*user = ProxyUserCredentials::from(&credential);
updated = true;
break;
}
}
if !updated {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response();
}
} else {
target.credentials.push(ProxyUserCredentials::from(&credential));
}
}
}
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
// Udate state after successful save
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
}
axum::http::StatusCode::OK.into_response()
}
async fn delete_config_api_proxy_user(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path((target_name, username)): axum::extract::Path<(String, String)>,
) -> impl axum::response::IntoResponse + Send {
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut modified = false;
for target_user in &mut api_proxy.user {
if target_user.target == target_name {
let count = target_user.credentials.len();
target_user.credentials.retain(|user| user.username != username);
modified = count != target_user.credentials.len();
break;
}
}
if modified {
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
} else {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User not found {username} in target {target_name}")}))).into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
pub fn v1_api_user_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
router
.route("/user/{target}", axum::routing::post(save_config_api_proxy_user))
.route("/user/{target}", axum::routing::put(save_config_api_proxy_user))
.route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
}
+1 -1
View File
@@ -58,7 +58,7 @@ fn get_epg_path_for_target_of_type(target_name: &str, epg_path: PathBuf) -> Opti
None
}
fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option<PathBuf> {
pub (in crate::api) fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option<PathBuf> {
// TODO if we have multiple targets, first one serves, this can be problematic when
// we use m3u playlist but serve xtream target epg
+1 -1
View File
@@ -159,7 +159,7 @@ impl AppConfig {
None => None
}
}
pub fn get_inputs_for_target(&self, target_name: &str) -> Option<Vec<Arc<ConfigInput>>> {
let sources = <Arc<ArcSwap<SourcesConfig>> as Access<SourcesConfig>>::load(&self.sources);
for source in &sources.sources {
+1 -1
View File
@@ -17,4 +17,4 @@ pub use self::healthcheck::*;
pub use self::playlist_categories::*;
pub use shared::model::xtream_const::*;
pub use self::config::*;
pub use self::input_source::*;
pub use self::input_source::*;
+184 -6
View File
@@ -1,8 +1,14 @@
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
use quick_xml::{Error, Writer};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::model::xmltv::XmlTagIcon::Undefined;
use chrono::{Datelike, TimeZone, Utc};
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
use quick_xml::{Error, Reader, Writer};
use shared::error::{TuliproxError, TuliproxErrorKind};
use shared::model::{parse_xmltv_time, EpgChannel, EpgProgramme, EpgTv};
use std::cmp::{max, min};
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
pub const EPG_TAG_TV: &str = "tv";
pub const EPG_TAG_PROGRAMME: &str = "programme";
@@ -15,7 +21,7 @@ pub const EPG_TAG_ICON: &str = "icon";
// https://github.com/XMLTV/xmltv/blob/master/xmltv.dtd
#[derive(Debug, Clone, Eq, PartialEq,Default)]
#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub enum XmlTagIcon {
#[default]
Undefined,
@@ -54,7 +60,7 @@ impl XmlTag {
// empty icon not processed
if self.icon == Undefined && self.name.eq(EPG_TAG_ICON) {
return Ok(());
return Ok(());
}
if let Some(attribs) = self.attributes.as_ref() {
@@ -119,3 +125,175 @@ impl TVGuide {
&self.epg_sources
}
}
fn filter_channels_and_programmes(
channels: &mut Vec<EpgChannel>,
programmes: &mut Vec<EpgProgramme>,
) {
for channel in channels.iter_mut() {
let mut i = 0;
while i < programmes.len() {
if programmes[i].channel == channel.id {
let prog = programmes.swap_remove(i);
channel.programmes.push(prog);
} else {
i += 1;
}
}
channel.programmes.sort_by_key(|p| p.start);
}
channels.retain(|c| !c.programmes.is_empty());
}
fn get_epg_interval(channels: &Vec<EpgChannel>) -> (i64, i64) {
let mut epg_start = i64::MAX;
let mut epg_stop = i64::MIN;
for channel in channels {
for programme in &channel.programmes {
epg_start = min(epg_start, programme.start);
epg_stop = max(epg_stop, programme.stop);
}
}
(epg_start, epg_stop)
}
#[allow(clippy::too_many_lines)]
pub fn parse_xmltv_for_web_ui(path: &Path) -> Result<EpgTv, TuliproxError> {
let file = File::open(path).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?;
let mut reader = Reader::from_reader(BufReader::new(file));
let mut buf = Vec::new();
let mut channels = Vec::new();
let mut programmes = Vec::new();
let mut current_channel: Option<EpgChannel> = None;
let mut current_programme: Option<EpgProgramme> = None;
let mut current_tag = String::new();
// only 1 day old epg
let now = Utc::now();
let yesterday_start = Utc.with_ymd_and_hms(now.year(), now.month(), now.day(), 0, 0, 0).unwrap()
- chrono::Duration::days(1);
let threshold_ts = yesterday_start.timestamp();
let get_attr_value = |attr: &quick_xml::events::attributes::Attribute| {
if let Ok(value) = attr.unescape_value() {
return Some(value.to_string());
}
None
};
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
current_tag.clone_from(&tag);
match tag.as_str() {
EPG_TAG_CHANNEL => {
let mut id = None;
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"id" {
if let Some(value) = get_attr_value(&attr) {
id = Some(value);
break;
}
}
}
if let Some(cid) = id {
current_channel = Some(EpgChannel::new(cid));
} else {
current_channel = None;
}
}
EPG_TAG_PROGRAMME => {
let mut start = None;
let mut stop = None;
let mut channel = None;
current_programme = None;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"start" => start = get_attr_value(&attr),
b"stop" => stop = get_attr_value(&attr),
b"channel" => channel = get_attr_value(&attr),
_ => {}
}
}
if let (Some(pstart), Some(pstop), Some(pchannel)) = (start, stop, channel) {
if let (Some(start_time), Some(stop_time)) = (parse_xmltv_time(&pstart), parse_xmltv_time(&pstop)) {
if stop_time >= threshold_ts {
let epg_programme = EpgProgramme::new(start_time, stop_time, pchannel);
current_programme = Some(epg_programme);
}
}
}
}
_ => {}
}
}
Ok(Event::Text(e)) => {
if let Ok(decoded) = e.decode() {
let text = decoded.trim();
if !text.is_empty() {
if let Some(channel) = &mut current_channel {
if current_tag == EPG_TAG_DISPLAY_NAME && channel.title.is_empty() {
channel.title = text.to_string();
}
}
if let Some(program) = &mut current_programme {
if current_tag == "title" && program.title.is_empty() {
program.title = text.to_string();
}
}
}
}
}
Ok(Event::End(e)) => {
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
match tag.as_str() {
EPG_TAG_CHANNEL => {
if let Some(channel) = current_channel.take() {
channels.push(channel);
}
}
EPG_TAG_PROGRAMME => {
if let Some(program) = current_programme.take() {
programmes.push(program);
}
}
_ => {}
}
current_tag.clear();
}
Ok(Event::Eof) => break,
Err(err) => return Err(TuliproxError::new(TuliproxErrorKind::Info, err.to_string())),
_ => {}
}
buf.clear();
}
filter_channels_and_programmes(&mut channels, &mut programmes);
if channels.is_empty() {
return Ok(EpgTv {
start: 0,
stop: 0,
channels,
})
}
let (epg_start, epg_stop) = get_epg_interval(&channels);
Ok(EpgTv {
start: epg_start,
stop: epg_stop,
channels,
})
}
+1
View File
@@ -154,6 +154,7 @@
"PLAYLIST_VIEWER": "Explore",
"PLAYLIST_EXPLORER": "Playlist Browser",
"PLAYLIST_UPDATE": "Playlist Update",
"PLAYLIST_EPG": "EPG",
"SOURCES": "Sources",
"SOURCE_PICKER": "Source Picker",
"LIST": "List",
+4
View File
@@ -495,5 +495,9 @@
{
"keys": ["WsDisconnected", "Offline"],
"path": "M12 2.02c-5.51 0-9.98 4.47-9.98 9.98s4.47 9.98 9.98 9.98 9.98-4.47 9.98-9.98S17.51 2.02 12 2.02m0 17.96c-4.4 0-7.98-3.58-7.98-7.98S7.6 4.02 12 4.02 19.98 7.6 19.98 12 16.4 19.98 12 19.98M12.75 5l-4.5 8.5h3.14V19l4.36-8.5h-3z"
},
{
"keys": ["Epg"],
"path": "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18zm-2-9H8v2h11zm0 4H8v2h11zM7 8H5v2h2zm0 4H5v2h2z"
}
]
+12
View File
@@ -29,6 +29,11 @@
--margin-mini: 0.5rem;
--margin-small: 0.75rem;
--margin-default: 1rem;
--epg-row-height: 4rem;
--epg-header-height: 2rem;
--epg-channels-width: 200px;
}
@media (max-width: size.$mobile-breakpoint) {
@@ -290,6 +295,13 @@
--code-null-literal-color: #fa5f2d;
--toolbar-bg-color: #161b25;
--epg-program-background-color: #57317a; //#31597a;
--epg-program-color: #ffffff;
--epg-program-active-background-color: #65a181;
--epg-program-active-color: #ffffff;
--epg-program-time-color: #acbcdd;
--epg-program-active-time-color: #000000;
}
body[data-theme="bright"] {
+1
View File
@@ -72,3 +72,4 @@
@forward "components/keyvalue_editor";
@forward "components/loading_screen";
@forward "components/websocket_status";
@forward "components/playlist/epg_view";
@@ -4,6 +4,7 @@
justify-content: center;
align-items: center;
padding: var(--padding-default);
width: 100%;
&__indicator {
display: flex;
@@ -0,0 +1,152 @@
.tp__epg {
display: flex;
flex-flow: column;
overflow: hidden;
width: 100%;
height: 100%;
position: relative;
&__header {
display: flex;
flex-flow: row;
}
&__body {
display: flex;
flex-flow: row;
overflow-x: auto;
overflow-y: auto;
position: relative;
}
&__channels-header,
&__timeline {
box-sizing: border-box;
position: sticky;
top: 0;
z-index: 10;
background-color: var(--background-color);
}
&__timeline {
display: flex;
flex-flow: row;
&-block {
display: flex;
flex-flow: column;
box-sizing: border-box;
border-left: 1px solid var(--text-color);
padding-left: 4px;
background-color: var(--background-color);
&-time, &-date {
box-sizing: border-box;
}
&-date {
color: var(--modest-text-color);
}
}
}
&__channels {
display: flex;
flex-flow: column;
//gap: var(--gap-default);
position: sticky;
left: 0;
z-index: 12;
background-color: var(--background-color);
border-right: 4px solid var(--background-color);
height: fit-content;
}
&__channels, &__channels-header {
box-sizing: border-box;
width: var(--epg-channels-width);
min-width: var(--epg-channels-width);
max-width: var(--epg-channels-width);
}
&__programs {
display: flex;
flex-flow: column;
//gap: var(--gap-default);
min-width: 100%;
height: fit-content;
position: relative;
}
&__channel {
display: flex;
flex-flow: row;
align-items: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
border-bottom: 1px solid var(--border-color);
}
&__channel-programs {
display: block;
position: relative;
border-bottom: 1px solid var(--border-color);
}
&__header,
&__channels-header,
&__timeline {
box-sizing: border-box;
max-height: var(--epg-header-height);
min-height: var(--epg-header-height);
height: var(--epg-header-height);
}
&__channel,
&__channel-programs {
box-sizing: border-box;
max-height: var(--epg-row-height);
min-height: var(--epg-row-height);
height: var(--epg-row-height);
}
&__program {
position: absolute;
display: flex;
flex-flow: column;
box-sizing: border-box;
border: 4px solid var(--background-color);
padding-left: var(--padding-small);
background-color: var(--epg-program-background-color);
color: var(--epg-program-color);
overflow: hidden;
border-radius: var(--border-radius);
height: 100%;
&-time {
color: var(--epg-program-time-color);
white-space: nowrap;
}
&-title {
}
&-active {
background-color: var(--epg-program-active-background-color);
color: var(--epg-program-active-color);
.tp__epg__program-time {
color: var(--epg-program-active-time-color);
}
}
}
&__now-line {
position: absolute;
top: 0;
bottom: 0;
height: 100%;
width: 2px; /* line thickness */
background-color: red;
z-index: 11; /* above programs */
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
use crate::app::components::{AppIcon, DashboardView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, ToastrView, UserlistView, WebsocketStatus};
use crate::app::components::{AppIcon, DashboardView, EpgView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, ToastrView, UserlistView, WebsocketStatus};
use crate::app::context::{ConfigContext, PlaylistContext, StatusContext};
use crate::hooks::{use_server_status, use_service_context};
use crate::model::{EventMessage, ViewType};
@@ -186,6 +186,9 @@ pub fn Home() -> Html {
<Panel class="tp__full-width" value={ViewType::PlaylistExplorer.to_string()} active={view_visible.to_string()}>
<PlaylistExplorerView/>
</Panel>
<Panel class="tp__full-width" value={ViewType::PlaylistEpg.to_string()} active={view_visible.to_string()}>
<EpgView/>
</Panel>
</div>
</div>
</div>
@@ -0,0 +1,175 @@
use std::rc::Rc;
use chrono::{Datelike, Local, TimeZone, Utc};
use gloo_timers::callback::Interval;
use web_sys::HtmlElement;
use crate::app::components::{Breadcrumbs, NoContent, PlaylistSourceSelector};
use crate::hooks::use_service_context;
use yew::platform::spawn_local;
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{EpgTv, PlaylistRequest, PlaylistRequestType};
use crate::model::{BusyStatus, EventMessage, ExplorerSourceType};
const TIME_BLOCK_WIDTH: f64 = 210.0;
const TIME_BLOCK_MINS: i64 = 30;
const PIXEL_PER_MIN:f64 = TIME_BLOCK_WIDTH as f64 / TIME_BLOCK_MINS as f64;
fn get_pos(secs: i64, start_mins: i64) -> i64 {
let mins = secs / 60;
let rel_mins = mins - start_mins;
(rel_mins as f64 * PIXEL_PER_MIN).round() as i64
}
#[function_component]
pub fn EpgView() -> Html {
let services = use_service_context();
let translate = use_translation();
let epg = use_state::<Option<EpgTv>, _>(|| None);
let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.PLAYLIST_EPG")]));
let container_ref = use_node_ref();
let now_line_ref = use_node_ref();
let handle_select_source = {
let service_ctx = services.clone();
let epg_set = epg.clone();
Some(Callback::from(move |req: PlaylistRequest| {
epg_set.set(None);
if req.rtype == PlaylistRequestType::Target {
if let Some(target_id) = req.source_id {
let service_ctx = service_ctx.clone();
let epg_set = epg_set.clone();
service_ctx.event.broadcast(EventMessage::Busy(BusyStatus::Show));
spawn_local(async move {
let playlist_epg = service_ctx.playlist.get_playlist_epg(target_id).await;
epg_set.set(playlist_epg);
service_ctx.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
});
}
}
}))
};
{
let container_ref = container_ref.clone();
let now_line_ref = now_line_ref.clone();
use_effect_with(epg.clone(), move |epg_tv| {
// TODO Active elements color change after time update
let epg_tv_clone = epg_tv.clone();
let calculate_position = Rc::new(move |epg_tv: &UseStateHandle<Option<EpgTv>>| {
if let Some(tv) = &**epg_tv {
if let (Some(div), Some(now_line)) = (container_ref.cast::<HtmlElement>(), now_line_ref.cast::<HtmlElement>()) {
let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60);
let start_window = (start_window_secs / 60).max(0);
let now = Utc::now().timestamp();
let now_line_pos = get_pos(now, start_window);
let container_width = div.client_width();
let scroll_pos = (now_line_pos as i32 - (container_width >> 1)).max(0);
div.set_scroll_left(scroll_pos);
now_line.style().set_property("left", &format!("{now_line_pos}px")).unwrap();
}
}
});
let calculate_pos = calculate_position.clone();
let interval = Interval::new(60_000, move || {
calculate_pos(&epg_tv_clone);
});
calculate_position(epg_tv);
|| drop(interval)
});
}
html! {
<div class="tp__epg tp__list-view">
<Breadcrumbs items={&*breadcrumbs}/>
<div class="tp__epg__header">
<h1>{translate.t("LABEL.PLAYLIST_EPG")}</h1>
</div>
<PlaylistSourceSelector hide_title={true} source_types={Some(vec![ExplorerSourceType::Hosted])} on_select={handle_select_source} />
<div class="tp__epg__body" ref={container_ref} >
{
if epg.is_none() {
html! {
<NoContent />
}
} else {
let tv = epg.as_ref().unwrap();
let start_window_secs = (tv.start / (TIME_BLOCK_MINS*60)) * (TIME_BLOCK_MINS*60);
let start_window = (start_window_secs / 60).max(0);
let end_window = (tv.stop / 60).max(0);
let window_duration = (end_window - start_window).max(0);
let num_blocks = (window_duration + TIME_BLOCK_MINS - 1) / TIME_BLOCK_MINS;
let now = Utc::now().timestamp();
let now_line_pos = get_pos(now, start_window);
let block_style= format!("width:{TIME_BLOCK_WIDTH}px; min-width:{TIME_BLOCK_WIDTH}px; max-width:{TIME_BLOCK_WIDTH}px");
html! {
<>
<div class="tp__epg__channels">
<div class="tp__epg__channels-header">
</div>
{ for tv.channels.iter().map(|ch| {
html! {
<div class="tp__epg__channel">
<div class="tp__epg__channel-title">
{ &ch.title }
</div>
</div>
}
})
}
</div>
<div class="tp__epg__programs">
<div class="tp__epg__timeline">
{ for (0..num_blocks).map(|i| {
let block_start = start_window + i as i64 * TIME_BLOCK_MINS;
let start_time_utc = Utc.timestamp_opt(block_start * 60, 0).unwrap();
let start_time_local = start_time_utc.with_timezone(&Local);
let hour_min = start_time_local.format("%H:%M").to_string();
let day_month = format!("{:02}.{:02}", start_time_local.day(), start_time_local.month());
html! {
<div class="tp__epg__timeline-block" style={block_style.clone()}>
<div class="tp__epg__timeline-block-time">{ hour_min }</div>
<div class="tp__epg__timeline-block-date">{ day_month }</div>
</div>
}
}) }
</div>
{ for tv.channels.iter().map(|ch| {
html! {
<div class="tp__epg__channel-programs">
{ for ch.programmes.iter().map(|p| {
let is_active = now >= p.start && now < p.stop;
let left = get_pos(p.start, start_window);
let right = get_pos(p.stop, start_window);
let width = right - left;
let pstart_time = Utc.timestamp_opt(p.start, 0).unwrap();
let pend_time = Utc.timestamp_opt(p.stop, 0).unwrap();
let pstart_time_local = pstart_time.with_timezone(&Local);
let pend_time_local = pend_time.with_timezone(&Local);
let pstart = pstart_time_local.format("%H:%M").to_string();
let pend = pend_time_local.format("%H:%M").to_string();
let program_style = format!("left:{left}px; width:{width}px; min-width:{width}px; max-width:{width}px");
html! {
<div class={classes!("tp__epg__program", if is_active { "tp__epg__program-active" } else {""})} style={program_style} title={ p.title.clone() }>
<div class="tp__epg__program-time">{ &pstart } {"-"} { &pend }</div>
<div class="tp__epg__program-title">{ &p.title }</div>
</div>
}})}
</div>
}
})
}
<div ref={now_line_ref} class="tp__epg__now-line" style={format!("left:{now_line_pos}px")}></div>
</div>
</>
}
}
}
</div>
</div>
}
}
@@ -18,6 +18,7 @@ mod playlist_update_view;
mod mapper_script_view;
mod mapper_counter_view;
mod epg_config_view;
mod epg_view;
use std::rc::Rc;
use yew_i18n::YewI18n;
@@ -42,6 +43,7 @@ pub use self::playlist_update_view::*;
pub use self::mapper_script_view::*;
pub use self::mapper_counter_view::*;
pub use self::epg_config_view::*;
pub use self::epg_view::*;
pub fn make_tags(data: &[(bool, &str)], translate: &YewI18n) -> Vec<Rc<Tag>> {
data.iter()
@@ -12,19 +12,34 @@ use yew::prelude::*;
use yew_i18n::use_translation;
use crate::app::components::input::Input;
#[derive(Properties, PartialEq, Clone)]
pub struct PlaylistSourceSelectorProps {
#[prop_or_default]
pub hide_title: bool,
#[prop_or_default]
pub source_types: Option<Vec<ExplorerSourceType>>,
#[prop_or_default]
pub on_select: Option<Callback<PlaylistRequest>>,
}
#[function_component]
pub fn PlaylistSourceSelector() -> Html {
pub fn PlaylistSourceSelector(props: &PlaylistSourceSelectorProps) -> Html {
let translate = use_translation();
let services_ctx = use_service_context();
let playlist_ctx = use_context::<PlaylistContext>().expect("Playlist context not found");
let playlist_explorer_ctx = use_context::<PlaylistExplorerContext>().expect("PlaylistExplorer context not found");
let playlist_explorer_ctx = use_context::<PlaylistExplorerContext>();
let active_source = use_state(|| ExplorerSourceType::Hosted);
let loading = use_state(|| false);
let custom_provider = use_state(|| InputType::Xtream);
let username_ref = use_node_ref();
let password_ref = use_node_ref();
let url_ref = use_node_ref();
let source_types = use_memo((), |_| vec![ExplorerSourceType::Hosted.to_string(), ExplorerSourceType::Provider.to_string(), ExplorerSourceType::Custom.to_string()]);
let source_types = use_memo(props.source_types.clone(), |st| {
let st = st.as_ref().map(|v| v.as_slice()).unwrap_or(&[
ExplorerSourceType::Hosted, ExplorerSourceType::Provider, ExplorerSourceType::Custom,
]);
st.iter().map(ToString::to_string).collect::<Vec<String>>()
});
let handle_source_select = {
let active_source_clone = active_source.clone();
@@ -39,25 +54,32 @@ pub fn PlaylistSourceSelector() -> Html {
let handle_source_download = {
let services = services_ctx.clone();
let playlist_explorer_ctx_clone = playlist_explorer_ctx.clone();
let set_loading = loading.clone();
Callback::from(move |request: PlaylistRequest| {
if !*set_loading {
let services = services.clone();
let playlist_explorer_ctx_clone = playlist_explorer_ctx_clone.clone();
set_loading.set(true);
services.event.broadcast(EventMessage::Busy(BusyStatus::Show));
let set_loading = set_loading.clone();
let req = request;
spawn_local(async move {
let playlist = services.playlist.get_playlist_categories(&req).await;
playlist_explorer_ctx_clone.playlist.set(playlist);
playlist_explorer_ctx_clone.playlist_request.set(Some(req));
set_loading.set(false);
services.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
});
}
})
if let Some(on_select) = &props.on_select {
let on_select = on_select.clone();
Callback::from(move |request: PlaylistRequest| {
on_select.emit(request)
})
} else {
let playlist_explorer_ctx_clone = playlist_explorer_ctx.expect("PlaylistExplorer context not found").clone();
Callback::from(move |request: PlaylistRequest| {
if !*set_loading {
let services = services.clone();
let playlist_explorer_ctx_clone = playlist_explorer_ctx_clone.clone();
set_loading.set(true);
services.event.broadcast(EventMessage::Busy(BusyStatus::Show));
let set_loading = set_loading.clone();
let req = request;
spawn_local(async move {
let playlist = services.playlist.get_playlist_categories(&req).await;
playlist_explorer_ctx_clone.playlist.set(playlist);
playlist_explorer_ctx_clone.playlist_request.set(Some(req));
set_loading.set(false);
services.event.broadcast(EventMessage::Busy(BusyStatus::Hide));
});
}
})
}
};
let handle_defined_source = {
@@ -129,7 +151,6 @@ pub fn PlaylistSourceSelector() -> Html {
})
};
let render_hosted = {
let playlist_ctx_clone = playlist_ctx.clone();
let handle_defined_source = handle_defined_source.clone();
@@ -240,9 +261,11 @@ pub fn PlaylistSourceSelector() -> Html {
html! {
<div class="tp__playlist-source-selector tp__list-list">
<div class="tp__playlist-source-selector__header tp__list-list__header">
<h1>{ translate.t("LABEL.SOURCES")}</h1>
</div>
{ html_if!(!props.hide_title, {
<div class="tp__playlist-source-selector__header tp__list-list__header">
<h1>{ translate.t("LABEL.SOURCES")}</h1>
</div>
})}
<div class="tp__playlist-source-selector__body tp__list-list__body">
<CollapsePanel class="tp__playlist-source-selector__source-picker" expanded={true}
title={translate.t("LABEL.SOURCE_PICKER")}>
+2
View File
@@ -143,6 +143,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
<MenuItem class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} label={translate.t("LABEL.UPDATE")} onclick={&handle_menu_click}></MenuItem>
<MenuItem class={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} label={translate.t("LABEL.PLAYLIST")} onclick={&handle_menu_click}></MenuItem>
<MenuItem class={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} label={translate.t("LABEL.PLAYLIST_VIEWER")} onclick={&handle_menu_click}></MenuItem>
<MenuItem class={if *active_menu == ViewType::PlaylistEpg { "active" } else {""}} icon="Epg" name={ViewType::PlaylistEpg.to_string()} label={translate.t("LABEL.PLAYLIST_EPG")} onclick={&handle_menu_click}></MenuItem>
</CollapsePanel>
</div>
}
@@ -160,6 +161,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
<IconButton class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={if *active_menu == ViewType::PlaylistExplorer { "active" } else {""}} icon="Live" name={ViewType::PlaylistExplorer.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={if *active_menu == ViewType::PlaylistEpg { "active" } else {""}} icon="Epg" name={ViewType::PlaylistEpg.to_string()} onclick={&handle_menu_click}></IconButton>
</div>
}
};
@@ -8,7 +8,7 @@ pub struct WebsocketStatusProps {
}
#[function_component]
pub fn WebsocketStatus(props: &WebsocketStatusProps) -> Html {
pub fn WebsocketStatus(_props: &WebsocketStatusProps) -> Html {
let status = use_state(|| true);
let services = use_service_context();
+5 -1
View File
@@ -9,6 +9,7 @@ const CONFIG: &str = "config";
const PLAYLIST_UPDATE: &str = "playlist_update";
const PLAYLIST_EDITOR: &str = "playlist_editor";
const PLAYLIST_EXPLORER: &str = "playlist_explorer";
const PLAYLIST_EPG: &str = "playlist_epg";
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -19,7 +20,8 @@ pub enum ViewType {
Config,
PlaylistUpdate,
PlaylistEditor,
PlaylistExplorer
PlaylistExplorer,
PlaylistEpg
}
impl FromStr for ViewType {
@@ -34,6 +36,7 @@ impl FromStr for ViewType {
PLAYLIST_UPDATE => Ok(ViewType::PlaylistUpdate),
PLAYLIST_EDITOR => Ok(ViewType::PlaylistEditor),
PLAYLIST_EXPLORER => Ok(ViewType::PlaylistExplorer),
PLAYLIST_EPG => Ok(ViewType::PlaylistEpg),
_ => Err(info_err!(format!("Unknown view type: {s}"))),
}
}
@@ -49,6 +52,7 @@ impl fmt::Display for ViewType {
ViewType::PlaylistUpdate => PLAYLIST_UPDATE,
ViewType::PlaylistEditor => PLAYLIST_EDITOR,
ViewType::PlaylistExplorer => PLAYLIST_EXPLORER,
ViewType::PlaylistEpg => PLAYLIST_EPG,
};
write!(f, "{s}")
}
+16 -1
View File
@@ -1,6 +1,6 @@
use crate::services::{get_base_href, request_post};
use log::error;
use shared::model::{CommonPlaylistItem, PlaylistCategoriesResponse, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest};
use shared::model::{CommonPlaylistItem, EpgTv, PlaylistCategoriesResponse, PlaylistEpgRequest, PlaylistRequest, UiPlaylistCategories, WebplayerUrlRequest};
use std::rc::Rc;
use shared::utils::{concat_path_leading_slash};
@@ -8,6 +8,7 @@ pub struct PlaylistService {
target_update_api_path: String,
playlist_api_path: String,
playlist_api_webplayer_url_path: String,
playlist_api_epg_path: String,
}
impl Default for PlaylistService {
fn default() -> Self {
@@ -22,6 +23,7 @@ impl PlaylistService {
target_update_api_path: concat_path_leading_slash(&base_href, "api/v1/playlist/update"),
playlist_api_path: concat_path_leading_slash(&base_href, "api/v1/playlist"),
playlist_api_webplayer_url_path: concat_path_leading_slash(&base_href, "api/v1/playlist/webplayer"),
playlist_api_epg_path: concat_path_leading_slash(&base_href, "api/v1/playlist/epg"),
}
}
pub async fn update_targets(&self, targets: &[&str]) -> bool {
@@ -52,4 +54,17 @@ impl PlaylistService {
}
}
}
pub async fn get_playlist_epg(&self, target_id: u16) -> Option<EpgTv> {
let request = PlaylistEpgRequest {
target_id,
};
match request_post::<&PlaylistEpgRequest, EpgTv>(&self.playlist_api_epg_path, &request, None, None).await {
Ok(content) => Some(content),
Err(err) => {
error!("{err}");
None
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub fn parse_xmltv_time(t: &str) -> Option<i64> {
let fmt = "%Y%m%d%H%M%S %z";
if let Ok(result) = DateTime::parse_from_str(t, fmt) {
Some(result
.with_timezone(&Utc)
.timestamp())
} else {
None
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EpgTv {
pub start: i64,
pub stop: i64,
pub channels: Vec<EpgChannel>,
}
impl PartialEq for EpgTv {
fn eq(&self, other: &Self) -> bool {
self.start == other.start
&& self.stop == other.stop
// Note: self.channels is skipped
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EpgChannel {
pub id: String,
pub title: String,
pub programmes: Vec<EpgProgramme>,
}
impl EpgChannel {
pub fn new(id: String) -> Self {
Self {
id,
title: String::new(),
programmes: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EpgProgramme {
pub start: i64,
pub stop: i64,
pub channel: String,
pub title: String,
}
impl EpgProgramme {
pub fn new(start: i64, stop: i64, channel: String) -> Self {
Self {
start,
stop,
channel,
title: String::new(),
}
}
}
+2
View File
@@ -16,6 +16,7 @@ mod playlist_request;
mod playlist_categories;
mod search_request;
mod webplayer_url_request;
mod epg;
pub use self::cluster_flags::*;
pub use self::playlist::*;
@@ -34,3 +35,4 @@ pub use self::playlist_request::*;
pub use self::playlist_categories::*;
pub use self::search_request::*;
pub use self::webplayer_url_request::*;
pub use self::epg::*;
+6 -1
View File
@@ -181,4 +181,9 @@ impl UiPlaylistCategories {
}
}
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct PlaylistEpgRequest {
pub target_id: u16,
}