mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-30 10:42:29 +01:00
epg view
This commit is contained in:
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,145 @@
|
||||
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;
|
||||
|
||||
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()) {
|
||||
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 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))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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);
|
||||
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) = 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();
|
||||
}
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+159
-3
@@ -1,7 +1,12 @@
|
||||
use std::cmp::{max, min};
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::{Error, Writer};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use quick_xml::{Error, Reader, Writer};
|
||||
use std::collections::{HashMap};
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::{Path, PathBuf};
|
||||
use shared::error::{TuliproxError, TuliproxErrorKind};
|
||||
use shared::model::{EpgChannel, EpgProgramme, EpgTv};
|
||||
use crate::model::xmltv::XmlTagIcon::Undefined;
|
||||
|
||||
pub const EPG_TAG_TV: &str = "tv";
|
||||
@@ -119,3 +124,154 @@ 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());
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
EPG_TAG_PROGRAMME => {
|
||||
let mut start = None;
|
||||
let mut stop = None;
|
||||
let mut channel = 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) {
|
||||
let epg_programme = EpgProgramme::new(pstart, pstop, 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);
|
||||
|
||||
let (epg_start, epg_stop) = get_epg_interval(&channels);
|
||||
|
||||
Ok(EpgTv {
|
||||
start: epg_start,
|
||||
stop: epg_stop,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,"
|
||||
}
|
||||
]
|
||||
@@ -29,6 +29,11 @@
|
||||
--margin-mini: 0.5rem;
|
||||
--margin-small: 0.75rem;
|
||||
--margin-default: 1rem;
|
||||
|
||||
--epg-row-height: 2rem;
|
||||
--epg-channels-width: 200px;
|
||||
--epg-time-block-width: 200px;
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: size.$mobile-breakpoint) {
|
||||
@@ -290,6 +295,11 @@
|
||||
--code-null-literal-color: #fa5f2d;
|
||||
|
||||
--toolbar-bg-color: #161b25;
|
||||
|
||||
--epg-program-background-color: #31597a;
|
||||
--epg-program-color: #ffffff;
|
||||
--epg-program-active-background-color: #65a181;
|
||||
--epg-program-active-color: #ffffff;
|
||||
}
|
||||
|
||||
body[data-theme="bright"] {
|
||||
|
||||
@@ -72,3 +72,4 @@
|
||||
@forward "components/keyvalue_editor";
|
||||
@forward "components/loading_screen";
|
||||
@forward "components/websocket_status";
|
||||
@forward "components/playlist/epg_view";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
.tp__epg {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__timeline {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
&-block {
|
||||
box-sizing: border-box;
|
||||
width: var(--epg-time-block-width);
|
||||
min-width: var(--epg-time-block-width);
|
||||
max-width: var(--epg-time-block-width);
|
||||
border-left: 1px solid var(--text-color);
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__channels {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: var(--gap-default);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
&__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);
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
&__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: flex;
|
||||
flex-flow: row nowrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
&__header,
|
||||
&__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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-right: var(--gap-default) solid var(--background-color);
|
||||
padding-left: var(--padding-small);
|
||||
background-color: var(--epg-program-background-color);
|
||||
color: var(--epg-program-color);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&-active {
|
||||
background-color: var(--epg-program-active-background-color);
|
||||
color: var(--epg-program-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,100 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use crate::app::components::NoContent;
|
||||
use crate::hooks::use_service_context;
|
||||
use yew::platform::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
const TIME_BLOCK_WIDTH: f64 = 200.0;
|
||||
|
||||
#[function_component]
|
||||
pub fn EpgView() -> Html {
|
||||
let services = use_service_context();
|
||||
let epg = use_state(|| None);
|
||||
|
||||
{
|
||||
let service_ctx = services.clone();
|
||||
let epg_set = epg.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let service_ctx = service_ctx.clone();
|
||||
let epg_set = epg_set.clone();
|
||||
spawn_local(async move {
|
||||
let playlist_epg = service_ctx.playlist.get_playlist_epg(1).await;
|
||||
epg_set.set(playlist_epg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if epg.is_none() {
|
||||
return html! {
|
||||
<div class="tp__epg">
|
||||
<NoContent />
|
||||
</div>
|
||||
};
|
||||
}
|
||||
let block_size = 30; // Minutes
|
||||
let tv = epg.as_ref().unwrap();
|
||||
let start_window = (tv.start / 60).max(0);
|
||||
let start_window = (start_window / block_size) * block_size; // 30 minutes
|
||||
let window_duration = ((tv.stop / 60) - start_window).max(0);
|
||||
let num_blocks = (window_duration + block_size - 1) / block_size;
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
html! {
|
||||
<div class="tp__epg">
|
||||
<div class="tp__epg__header">
|
||||
<div class="tp__epg__channels-header">
|
||||
</div>
|
||||
<div class="tp__epg__timeline">
|
||||
{ for (0..num_blocks).map(|i| {
|
||||
let block_start = start_window + i as i64 * block_size;
|
||||
let block_end = (block_start + block_size).min(start_window + window_duration);
|
||||
let start_time = Utc.timestamp_opt(block_start * 60, 0).unwrap();
|
||||
let end_time = Utc.timestamp_opt(block_end * 60, 0).unwrap();
|
||||
|
||||
html! {
|
||||
<div class="tp__epg__timeline-block">
|
||||
{ start_time.format("%H:%M").to_string() }
|
||||
</div>
|
||||
}
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
<div class="tp__epg__body">
|
||||
<div class="tp__epg__channels">
|
||||
{ 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">
|
||||
{ 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 prog_start_min = (p.start / 60) as i64;
|
||||
let prog_stop_min = (p.stop / 60) as i64;
|
||||
let rel_start = prog_start_min - start_window;
|
||||
let rel_stop = prog_stop_min - start_window;
|
||||
let left = ((rel_start as f64 / 30.0) * TIME_BLOCK_WIDTH).round();
|
||||
let width = (((rel_stop - rel_start) as f64 / 30.0) * TIME_BLOCK_WIDTH).round();
|
||||
html! {
|
||||
<div class={classes!("tp__epg__program", if is_active { "tp__epg__program-active" } else {""})} style={format!("left:{}px; width:{}px; min-width:{}px; max-width:{}px", left, width, width, width)} title={ p.title.clone() }>
|
||||
{ &p.title }
|
||||
</div>
|
||||
}})}
|
||||
</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()
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn parse_xmltv_time(t: &str) -> i64 {
|
||||
let fmt = "%Y%m%d%H%M%S %z";
|
||||
DateTime::parse_from_str(t, fmt)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EpgTv {
|
||||
pub start: i64,
|
||||
pub stop: i64,
|
||||
pub channels: Vec<EpgChannel>,
|
||||
}
|
||||
|
||||
#[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: String, stop: String, channel: String) -> Self {
|
||||
Self {
|
||||
start: parse_xmltv_time(&start),
|
||||
stop: parse_xmltv_time(&stop),
|
||||
channel,
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -181,4 +181,9 @@ impl UiPlaylistCategories {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct PlaylistEpgRequest {
|
||||
pub target_id: u16,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user