This commit is contained in:
euzu
2025-09-26 14:13:30 +02:00
parent aad400bc34
commit 94a39ccc52
23 changed files with 901 additions and 380 deletions
+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,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))
}
+133
View File
@@ -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))
}
+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::*;
+159 -3
View File
@@ -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)
}
+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,"
}
]
+10
View File
@@ -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"] {
+1
View File
@@ -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);
}
}
}
+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,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()
+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>
}
};
+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
}
}
}
}
+53
View File
@@ -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(),
}
}
}
+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,
}