From 73615701b565284b8d4073352dfdfe6c817264a9 Mon Sep 17 00:00:00 2001 From: euzu Date: Mon, 30 Dec 2024 12:40:22 +0100 Subject: [PATCH] reverse proxy mode for kodi export --- CHANGELOG.md | 1 + src/api/api_utils.rs | 11 +---- src/api/xtream_api.rs | 4 +- src/model/api_proxy.rs | 11 +++++ src/model/config.rs | 40 +++++++++++++++--- src/repository/kodi_repository.rs | 55 ++++++++++++++++++++----- src/repository/m3u_playlist_iterator.rs | 3 +- src/repository/playlist_repository.rs | 2 +- src/repository/xtream_repository.rs | 9 ++-- 9 files changed, 102 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0192528..d97474d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - added live channel connection sharing in reverse proxy mode. To activate set `share_live_streams` in target options. - added `info` and `tmdb-id` caching for vod. - The `kodi` format for movies can contain the `tmdb-id` (_optional_). To add the `tmdb-id` you can set now `kodi_style`, `xtream_resolve_vod`, `xtream_resolve_vod_delay`, `xtream_resolve_series` and `xtream_resolve_series_delay` to target options. +- `kodi` output can now have `username` attribute to use reverse proxy mode when combined with `xtream` output. # 2.0.10 (2024-12-03) - added Target Output Option `m3u_include_type_in_url`, default false. This adds `live`, `movie`, `series` to the url of the stream in reverse proxy mode. diff --git a/src/api/api_utils.rs b/src/api/api_utils.rs index 07fd99eb6..59dcf14d2 100644 --- a/src/api/api_utils.rs +++ b/src/api/api_utils.rs @@ -1,8 +1,8 @@ use crate::api::model::app_state::AppState; use crate::api::model::request::UserApiRequest; use crate::api::model::shared_stream::SharedStream; -use crate::model::api_proxy::{ApiProxyServerInfo, ProxyUserCredentials}; -use crate::model::config::{Config, ConfigInput, ConfigTarget}; +use crate::model::api_proxy::{ProxyUserCredentials}; +use crate::model::config::{ConfigInput, ConfigTarget}; use crate::utils::request_utils; use crate::utils::request_utils::mask_sensitive_info; use actix_web::http::header::{HeaderValue, CACHE_CONTROL}; @@ -56,13 +56,6 @@ pub fn get_user_target<'a>(api_req: &'a UserApiRequest, app_state: &'a AppState) get_user_target_by_credentials(username, password, api_req, app_state) } -pub fn get_user_server_info(cfg: &Config, user: &ProxyUserCredentials) -> ApiProxyServerInfo { - let server_info_list = cfg.t_api_proxy.read().unwrap().as_ref().unwrap().server.clone(); - let server_info_name = user.server.as_ref().map_or("default", |server_name| server_name.as_str()); - server_info_list.iter().find(|c| c.name.eq(server_info_name)).map_or_else(|| server_info_list.first().unwrap().clone(), std::clone::Clone::clone) -} - - /// Creates a notify stream for the given URL if a shared stream exists. async fn create_notify_stream( app_state: &AppState, diff --git a/src/api/xtream_api.rs b/src/api/xtream_api.rs index 7ba52c3ea..6ec8b7514 100644 --- a/src/api/xtream_api.rs +++ b/src/api/xtream_api.rs @@ -14,7 +14,7 @@ use log::{debug, error, warn, Level}; use serde_json::{Map, Value}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; -use crate::api::api_utils::{get_user_server_info, get_user_target, get_user_target_by_credentials, is_stream_share_enabled, serve_file, stream_response}; +use crate::api::api_utils::{get_user_target, get_user_target_by_credentials, is_stream_share_enabled, serve_file, stream_response}; use crate::api::model::app_state::AppState; use crate::api::model::request::UserApiRequest; use crate::api::model::xtream::XtreamAuthorizationResponse; @@ -158,7 +158,7 @@ fn get_xtream_player_api_stream_url(input: &ConfigInput, context: &str, action_p } fn get_user_info(user: &ProxyUserCredentials, cfg: &Config) -> XtreamAuthorizationResponse { - let server_info = get_user_server_info(cfg, user); + let server_info = cfg.get_user_server_info(user); XtreamAuthorizationResponse::new(&server_info, user) } diff --git a/src/model/api_proxy.rs b/src/model/api_proxy.rs index 162ee9763..77c7a4897 100644 --- a/src/model/api_proxy.rs +++ b/src/model/api_proxy.rs @@ -297,4 +297,15 @@ impl ApiProxyConfig { } None } + + pub fn get_user_credentials(&self,username: &str) -> Option { + let result = self.user.iter() + .flat_map(|target_user| &target_user.credentials) + .find(|credential| credential.username == username) + .cloned(); + if result.is_none() { + debug!("Could not find any user {username}"); + } + result + } } diff --git a/src/model/config.rs b/src/model/config.rs index 26b218e88..f56b44ab6 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -18,7 +18,7 @@ use crate::filter::{get_filter, prepare_templates, Filter, MockValueProcessor, P use crate::info_err; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::messaging::MsgKind; -use crate::model::api_proxy::{ApiProxyConfig, ProxyUserCredentials}; +use crate::model::api_proxy::{ApiProxyConfig, ApiProxyServerInfo, ProxyUserCredentials}; use crate::model::mapping::Mapping; use crate::model::mapping::Mappings; use crate::utils::default_utils::{default_as_default, default_as_true, default_as_two_u16}; @@ -318,11 +318,14 @@ pub struct ConfigTargetOptions { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct TargetOutput { #[serde(alias = "type")] pub target: TargetType, #[serde(skip_serializing_if = "Option::is_none")] pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] @@ -366,26 +369,37 @@ impl ConfigTarget { let mut m3u_cnt = 0; let mut strm_cnt = 0; let mut xtream_cnt = 0; + let mut strm_needs_xtream = false; for format in &self.output { + let has_username = if let Some(username) = &format.username { !username.trim().is_empty() } else { false }; + let has_filename = if let Some(fname) = &format.filename { !fname.trim().is_empty() } else { false }; + match format.target { TargetType::M3u => { m3u_cnt += 1; + if has_username { + warn!("Username for target output m3u is ignored: {}", self.name); + } } TargetType::Strm => { strm_cnt += 1; - if format.filename.is_none() { + if !has_filename { return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "filename is required for strm type: {}", self.name); } + if has_username { + strm_needs_xtream = true; + } } TargetType::Xtream => { xtream_cnt += 1; if default_as_default().eq_ignore_ascii_case(&self.name) { return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "unique target name is required for xtream type: {}", self.name); } - if let Some(fname) = &format.filename { - if !fname.trim().is_empty() { - warn!("Filename for target output xtream is ignored: {}", self.name); - } + if has_username { + warn!("Username for target output xtream is ignored: {}", self.name); + } + if has_filename { + warn!("Filename for target output xtream is ignored: {}", self.name); } } } @@ -395,6 +409,10 @@ impl ConfigTarget { return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Multiple output formats with same type : {}", self.name); } + if strm_cnt > 0 && strm_needs_xtream && xtream_cnt == 0 { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "strm output with a username is only permitted when used in combination with xtream output: {}", self.name); + } + if let Some(watch) = &self.watch { let regexps: Result, _> = watch.iter().map(|s| regex::Regex::new(s)).collect(); match regexps { @@ -903,6 +921,10 @@ impl Config { self.t_api_proxy.read().unwrap().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name_by_token(token))) } + pub fn get_user_credentials(&self, username: &str) -> Option { + self.t_api_proxy.read().unwrap().as_ref().and_then(|api_proxy| api_proxy.get_user_credentials(username)) + } + pub fn get_input_by_id(&self, input_id: u16) -> Option<&ConfigInput> { for source in &self.sources { for input in &source.inputs { @@ -1041,6 +1063,12 @@ impl Config { } } } + + pub fn get_user_server_info(&self, user: &ProxyUserCredentials) -> ApiProxyServerInfo { + let server_info_list = self.t_api_proxy.read().unwrap().as_ref().unwrap().server.clone(); + let server_info_name = user.server.as_ref().map_or("default", |server_name| server_name.as_str()); + server_info_list.iter().find(|c| c.name.eq(server_info_name)).map_or_else(|| server_info_list.first().unwrap().clone(), std::clone::Clone::clone) + } } /// Returns the targets that were specified as parameters. diff --git a/src/repository/kodi_repository.rs b/src/repository/kodi_repository.rs index df09a860f..ee85cd382 100644 --- a/src/repository/kodi_repository.rs +++ b/src/repository/kodi_repository.rs @@ -1,5 +1,6 @@ use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; -use crate::model::config::{Config, ConfigTarget}; +use crate::model::api_proxy::{ApiProxyServerInfo, ProxyType, ProxyUserCredentials}; +use crate::model::config::{Config, ConfigTarget, TargetOutput}; use crate::model::playlist::{FieldAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType}; use crate::model::xtream::XtreamSeriesEpisode; use crate::repository::bplustree::BPlusTree; @@ -11,13 +12,13 @@ use crate::{create_m3u_filter_error_result, notify_err}; use chrono::Datelike; use log::error; use regex::Regex; +use serde::Serialize; use std::collections::HashMap; use std::fs::File; use std::io::Write; use std::path::PathBuf; use std::rc::Rc; use std::sync::LazyLock; -use serde::Serialize; struct KodiStyle { year: Regex, @@ -72,7 +73,6 @@ fn kodi_style_rename_year<'a>( style: &KodiStyle, release_date: Option<&'a String>, ) -> (&'a str, Option) { - let mut years = Vec::new(); let cur_year = u32::try_from(chrono::Utc::now().year()).unwrap_or(0); @@ -136,7 +136,7 @@ async fn kodi_style_rename(cfg: &Config, strm_item_info: &StrmItemInfo, style: & } { match value { InputTmdbIndexValue::Video(vod_record) => vod_record.tmdb_id, - InputTmdbIndexValue::Series(episode) => episode.tmdb_id, + InputTmdbIndexValue::Series(episode) => episode.tmdb_id, } } else { 0 }; @@ -264,6 +264,7 @@ struct StrmItemInfo { title: Rc, item_type: PlaylistItemType, provider_id: Option, + virtual_id: u32, input_id: u16, url: Rc, series_name: Option, @@ -278,6 +279,7 @@ fn extract_item_info(pli: &PlaylistItem) -> StrmItemInfo { let title = Rc::clone(&header.title); let item_type = header.item_type; let provider_id = header.get_provider_id(); + let virtual_id = header.virtual_id; let input_id = header.input_id; // TODO reverse proxy url let url = Rc::clone(&header.url); @@ -292,7 +294,7 @@ fn extract_item_info(pli: &PlaylistItem) -> StrmItemInfo { (series_name, release_date, season, episode) } else { (None, None, None, None) }; - StrmItemInfo { group, title, item_type, provider_id, input_id, url, series_name, release_date, season, episode } + StrmItemInfo { group, title, item_type, provider_id, virtual_id, input_id, url, series_name, release_date, season, episode } } fn prepare_strm_output_directory(cleanup: bool, path: &PathBuf) -> Result<(), M3uFilterError> { @@ -319,15 +321,23 @@ fn get_strm_output_options(target: &ConfigTarget) -> (bool, bool, bool) { |o| (o.underscore_whitespace, o.cleanup, o.kodi_style)) } -pub async fn kodi_write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_playlist: &[PlaylistGroup], filename: Option<&str>) -> Result<(), M3uFilterError> { +pub async fn kodi_write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_playlist: &[PlaylistGroup], output: &TargetOutput) -> Result<(), M3uFilterError> { let mut result = Ok(()); if !new_playlist.is_empty() { - if filename.is_none() { + if output.filename.is_none() { return Err(notify_err!("write strm playlist failed: ".to_string())); } + let credentials_and_server_info = output.username.as_ref() + .and_then(|username| cfg.get_user_credentials(username)) + .filter(|credentials| credentials.proxy == ProxyType::Reverse) + .map(|credentials| { + let server_info = cfg.get_user_server_info(&credentials); + (credentials, server_info) + }); + let (underscore_whitespace, cleanup, kodi_style) = get_strm_output_options(target); - let Some(path) = file_utils::get_file_path(&cfg.working_dir, Some(std::path::PathBuf::from(&filename.as_ref().unwrap()))) else { - return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Failed to get file path for {}", filename.unwrap_or("")); + let Some(path) = file_utils::get_file_path(&cfg.working_dir, Some(std::path::PathBuf::from(&output.filename.as_ref().unwrap()))) else { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Failed to get file path for {}", output.filename.as_deref().unwrap_or("")); }; prepare_strm_output_directory(cleanup, &path)?; let mut input_tmdb_indexes: InputTmdbIndexMap = HashMap::new(); @@ -353,10 +363,13 @@ pub async fn kodi_write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_p error!("cant create directory: {output_path:?}"); return create_m3u_filter_error_result!(M3uFilterErrorKind::Notify, "failed to create directory for strm playlist:{output_path:?} {e}"); }; + + let url = get_strm_url(credentials_and_server_info.as_ref(), &str_item_info); + let file_path = output_path.join(format!("{strm_file_name}.strm")); match File::create(&file_path) { Ok(mut strm_file) => { - match file_utils::check_write(&strm_file.write_all(str_item_info.url.as_bytes())) { + match file_utils::check_write(&strm_file.write_all(url.as_bytes())) { Ok(()) => {} Err(err) => { error!("failed to write strm playlist: {err}"); @@ -374,3 +387,25 @@ pub async fn kodi_write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_p } result } + +fn get_strm_url(credentials_and_server_info: Option<&(ProxyUserCredentials, ApiProxyServerInfo)>, str_item_info: &StrmItemInfo) -> String { + let url = credentials_and_server_info.as_ref() + .map_or_else(|| str_item_info.url.to_string(), + |(user, server_info)| + if let Some(stream_type) = match str_item_info.item_type { + PlaylistItemType::Series => Some("series"), + PlaylistItemType::Live => Some("live"), + PlaylistItemType::Video => Some("movie"), + _ => None, + } { + format!("{}/{stream_type}/{}/{}/{}", + server_info.get_base_url(), + user.username, + user.password, + str_item_info.virtual_id) + } else { + str_item_info.url.to_string() + }, + ); + url +} diff --git a/src/repository/m3u_playlist_iterator.rs b/src/repository/m3u_playlist_iterator.rs index a815cdc90..8dca5feb3 100644 --- a/src/repository/m3u_playlist_iterator.rs +++ b/src/repository/m3u_playlist_iterator.rs @@ -1,4 +1,3 @@ -use crate::api::api_utils::get_user_server_info; use crate::info_err; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::model::api_proxy::{ProxyType, ProxyUserCredentials}; @@ -44,7 +43,7 @@ impl M3uPlaylistIterator { let include_type_in_url = target_options.is_some_and( |opts| opts.m3u_include_type_in_url); let mask_redirect_url = target_options.is_some_and(|opts| opts.m3u_mask_redirect_url); - let server_info = get_user_server_info(cfg, user); + let server_info = cfg.get_user_server_info(user); Ok(Self { reader, base_url: server_info.get_base_url(), diff --git a/src/repository/playlist_repository.rs b/src/repository/playlist_repository.rs index 4e1e36f65..5c67aa919 100644 --- a/src/repository/playlist_repository.rs +++ b/src/repository/playlist_repository.rs @@ -49,7 +49,7 @@ pub async fn persist_playlist(playlist: &mut [PlaylistGroup], epg: Option<&Epg>, let result = match output.target { TargetType::M3u => m3u_write_playlist(target, cfg, &target_path, playlist).await, TargetType::Xtream => xtream_write_playlist(target, cfg, playlist).await, - TargetType::Strm => kodi_write_strm_playlist(target, cfg, playlist, output.filename.as_deref()).await, + TargetType::Strm => kodi_write_strm_playlist(target, cfg, playlist, output).await, }; if let Err(err) = result { diff --git a/src/repository/xtream_repository.rs b/src/repository/xtream_repository.rs index 25c6e8e26..ea9c1ec05 100644 --- a/src/repository/xtream_repository.rs +++ b/src/repository/xtream_repository.rs @@ -12,13 +12,13 @@ use crate::model::config::{Config, ConfigInput, ConfigTarget}; use crate::model::playlist::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; use crate::model::xtream::{XtreamMappingOptions, XtreamSeriesEpisode}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; -use crate::repository::indexed_document::{IndexedDocumentGarbageCollector, IndexedDocumentWriter, IndexedDocumentDirectAccess}; +use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentGarbageCollector, IndexedDocumentWriter}; use crate::repository::storage::{get_input_storage_path, get_target_id_mapping_file, get_target_storage_path, hash_string, FILE_SUFFIX_DB, FILE_SUFFIX_INDEX}; use crate::repository::target_id_mapping::{TargetIdMapping, VirtualIdRecord}; use crate::repository::xtream_playlist_iterator::XtreamPlaylistIterator; -use crate::utils::json_utils::{json_iter_array, json_write_documents_to_file}; -use crate::{create_m3u_filter_error, create_m3u_filter_error_result, notify_err, info_err}; use crate::utils::file_utils::open_readonly_file; +use crate::utils::json_utils::{json_iter_array, json_write_documents_to_file}; +use crate::{create_m3u_filter_error, create_m3u_filter_error_result, info_err, notify_err}; pub static COL_CAT_LIVE: &str = "cat_live"; pub static COL_CAT_SERIES: &str = "cat_series"; @@ -391,6 +391,7 @@ pub async fn xtream_get_item_for_stream_id( xtream_read_series_item_for_stream_id(config, virtual_id, &storage_path).await } PlaylistItemType::Series => { + // TODO reverse proxy mode not working when resolve_series let mut item = xtream_read_series_item_for_stream_id(config, mapping.parent_virtual_id, &storage_path).await?; item.provider_id = mapping.provider_id; Ok(item) @@ -829,7 +830,7 @@ pub async fn xtream_update_input_series_episodes_record_from_wal_file( match bincode::deserialize(&buffer[0..len]) { Ok(episode) => { tree_record_index.insert(provider_id, episode); - }, + } Err(err) => { error!("Failed to delete deserialize record WAL file for series episode {err}"); }