diff --git a/CHANGELOG.md b/CHANGELOG.md index 7746bda22..6ee9bc81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog # 2.2.2 (2025-02-xx) +- !BREAKING CHANGE! Target options moved to specific target output definitions. + +target options: +- ignore_logo: true|false, +- share_live_streams: true|false, +- remove_duplicates: true|false, + +target output type xtream: +- skip_live_direct_source: true|false, +- skip_video_direct_source: true|false, +- skip_series_direct_source: true|false, +- resolve_series: true|false, +- resolve_series_delay: seconds, +- resolve_vod: true|false, +- resolve_vod_delay: true|false, + +target output type m3u: +- filename: _optional_ +- include_type_in_url: true|false, +- mask_redirect_url: true|false, + +target output type strm: +- directory: _mandatory_, +- username: _optional_, +- underscore_whitespace: true|false, +- cleanup: true|false, +- kodi_style: true|false, +- strm_props: _optional_, list of strings, + +target output type hdhomerun: +- device: _mandatory_, +- username: _mandatory_, +- use_output: _optional_, m3u|xtream + +Example: +```yaml +targets: + - name: xc_m3u + output: + - type: xtream + skip_live_direct_source: true, + skip_video_direct_source: true, + - type: m3u + - type: strm + directory: /tmp/kodi + - type: hdhomerun + username: hdhruser + device: hdhr1 + use_output: xtream + options: {ignore_logo: false, share_live_streams: true, remove_duplicates: false} +``` + - input alias definition for same provider with same content but different credentials ```yaml - sources: @@ -15,7 +67,7 @@ targets: - name: test ``` -- The Web UI now includes a login feature for playlist users, allowing them to set their group for filtering and managing their own bouquet of groups. +- The Web UI now includes a login feature for playlist users, allowing them to set their groups for filtering and managing their own bouquet of groups. The playlist user can login with his credentials and can select the desired groups for his playlist. - Added `user_config_dir` to `config.yml`. It is the storage path for user configurations (f.e. bouquets). - New Filter field `input` can be used along `name`, `group`, `title`, `url` and `type`. Input is a `regexp` filter. `input ~ "provider\-\d+"` diff --git a/frontend/public/i18n/de_common.json b/frontend/public/i18n/de_common.json index b719af2f8..2660f15dd 100644 --- a/frontend/public/i18n/de_common.json +++ b/frontend/public/i18n/de_common.json @@ -103,6 +103,13 @@ "USER": { "SUCCESS": "Benutzer erfolgreich gespeichert", "FAIL": "Fehler beim Speichern des Benutzers!" + }, + "PLAYLIST_UPDATE": { + "SUCCESS": "Playlist Aktualisierung erfolgreich gestartet!", + "FAIL": "Playlist Aktualisierung fehlgeschlagen!" + }, + "SOURCE_SELECTOR": { + "MISSING_SELECTION": "Kein Eintrag ausgewählt" } } }, diff --git a/frontend/public/i18n/en_common.json b/frontend/public/i18n/en_common.json index 27a142e63..68940f93e 100644 --- a/frontend/public/i18n/en_common.json +++ b/frontend/public/i18n/en_common.json @@ -119,12 +119,12 @@ "DUPLICATE_TOKEN": "Duplicate token!", "TARGET_NOT_FOUND": "Target not found!", "MAX_CONNECTIONS_INVALID": "MaxConnections invalid" - }, - "PLAYLIST_UPDATE": { - "SUCCESS": "Successfully started playlist update!", - "FAIL": "Playlist update failed!" } }, + "PLAYLIST_UPDATE": { + "SUCCESS": "Successfully started playlist update!", + "FAIL": "Playlist update failed!" + }, "SOURCE_SELECTOR": { "MISSING_SELECTION": "No entry selected" } diff --git a/src/api/endpoints/hdhomerun_api.rs b/src/api/endpoints/hdhomerun_api.rs index 708961610..14be4cd4f 100644 --- a/src/api/endpoints/hdhomerun_api.rs +++ b/src/api/endpoints/hdhomerun_api.rs @@ -220,7 +220,20 @@ async fn lineup_status() -> impl Responder { async fn lineup_json(app_state: web::Data) -> impl Responder { let cfg = Arc::clone(&app_state.app_state.config); if let Some((credentials, target)) = cfg.get_target_for_username(&app_state.device.t_username) { - if target.has_output(&TargetType::Xtream) { + let use_output = target.get_hdhomerun_output().as_ref().and_then(|o| o.use_output.clone()); + let use_all = use_output.is_none(); + let use_m3u = use_output.as_ref() == Some(&TargetType::M3u); + let use_xtream = use_output.as_ref() == Some(&TargetType::Xtream); + if (use_all || use_m3u) && target.has_output(&TargetType::M3u) { + let iterator = M3uPlaylistIterator::new(&cfg,target,&credentials).await.ok(); + let stream = m3u_item_to_lineup_stream(iterator); + let body_stream = stream::once(async { Ok(Bytes::from("[")) }) + .chain(stream) + .chain(stream::once(async { Ok(Bytes::from("]")) })); + return HttpResponse::Ok() + .content_type("application/json") + .streaming(body_stream); + } else if (use_all || use_xtream) && target.has_output(&TargetType::Xtream) { let server_info = app_state.app_state.config.get_user_server_info(&credentials); let base_url = if credentials.proxy == ProxyType::Reverse { Some(server_info.get_base_url()) @@ -230,7 +243,7 @@ async fn lineup_json(app_state: web::Data) -> impl Responder let live_channels = XtreamPlaylistIterator::new(XtreamCluster::Live, &cfg, target, None, &credentials).await.ok(); let vod_channels = XtreamPlaylistIterator::new(XtreamCluster::Video, &cfg, target, None, &credentials).await.ok(); - // TODO include when resolved + // TODO include series when resolved //let series_channels = xtream_repository::iter_raw_xtream_playlist(cfg, target, XtreamCluster::Series); let user_credentials = Arc::new(credentials); let live_stream = xtream_item_to_lineup_stream(Arc::clone(&cfg), XtreamCluster::Live, Arc::clone(&user_credentials), base_url.clone(), live_channels); @@ -243,15 +256,6 @@ async fn lineup_json(app_state: web::Data) -> impl Responder return HttpResponse::Ok() .content_type("application/json") .streaming(body_stream); - } else if target.has_output(&TargetType::M3u) { - let iterator = M3uPlaylistIterator::new(&cfg,target,&credentials).await.ok(); - let stream = m3u_item_to_lineup_stream(iterator); - let body_stream = stream::once(async { Ok(Bytes::from("[")) }) - .chain(stream) - .chain(stream::once(async { Ok(Bytes::from("]")) })); - return HttpResponse::Ok() - .content_type("application/json") - .streaming(body_stream); } } HttpResponse::NotFound().finish() diff --git a/src/api/endpoints/xmltv_api.rs b/src/api/endpoints/xmltv_api.rs index c571ed650..d170436fc 100644 --- a/src/api/endpoints/xmltv_api.rs +++ b/src/api/endpoints/xmltv_api.rs @@ -14,8 +14,7 @@ use crate::api::api_utils::{get_user_target, serve_file}; use crate::api::model::app_state::AppState; use crate::api::model::request::UserApiRequest; use crate::model::api_proxy::ProxyUserCredentials; -use crate::model::config::{Config, ConfigTarget}; -use crate::model::config::TargetType; +use crate::model::config::{Config, ConfigTarget, TargetOutput}; use crate::repository::m3u_repository::m3u_get_epg_file_path; use crate::repository::storage::get_target_storage_path; use crate::repository::xtream_repository::{xtream_get_epg_file_path, xtream_get_storage_path}; @@ -53,21 +52,24 @@ fn get_epg_path_for_target_of_type(target_name: &str, epg_path: PathBuf) -> Opti } fn get_epg_path_for_target(config: &Config, target: &ConfigTarget) -> Option { + // TODO if we have multiple targets, first one serves, this can be problematic when + // we use m3u playlist but serve xtream target epg + // TODO if we share the same virtual_id for epg, can we store an epg file for the target ? for output in &target.output { - match output.target { - TargetType::M3u => { - if let Some(target_path) = get_target_storage_path(config, &target.name) { - return get_epg_path_for_target_of_type(&target.name, m3u_get_epg_file_path(&target_path)); - } - } - TargetType::Xtream => { + match output { + TargetOutput::Xtream(_) => { if let Some(storage_path) = xtream_get_storage_path(config, &target.name) { return get_epg_path_for_target_of_type(&target.name, xtream_get_epg_file_path(&storage_path)); } } - TargetType::Strm | TargetType::HdHomeRun => {} + TargetOutput::M3u(_) => { + if let Some(target_path) = get_target_storage_path(config, &target.name) { + return get_epg_path_for_target_of_type(&target.name, m3u_get_epg_file_path(&target_path)); + } } + TargetOutput::Strm(_) | TargetOutput::HdHomeRun(_) => {} + } } None } diff --git a/src/model/config.rs b/src/model/config.rs index 81b73cf5f..c03149992 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -84,6 +84,27 @@ impl Display for TargetType { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Hash)] +enum HdHomeRunUseTargetType { + #[serde(rename = "m3u")] + M3u, + #[serde(rename = "xtream")] + Xtream, +} + +impl TryFrom for HdHomeRunUseTargetType { + type Error = &'static str; + + fn try_from(value: TargetType) -> Result { + match value { + TargetType::Xtream => Ok(Self::Xtream), + TargetType::M3u => Ok(Self::M3u), + _ => Err("Not allowed!"), + } + } +} + + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)] pub enum ProcessingOrder { #[serde(rename = "frm")] @@ -274,32 +295,6 @@ pub struct ConfigTargetOptions { #[serde(default)] pub ignore_logo: bool, #[serde(default)] - pub underscore_whitespace: bool, - #[serde(default)] - pub cleanup: bool, - #[serde(default)] - pub kodi_style: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strm_props: Option>, - #[serde(default = "default_as_true")] - pub xtream_skip_live_direct_source: bool, - #[serde(default = "default_as_true")] - pub xtream_skip_video_direct_source: bool, - #[serde(default = "default_as_true")] - pub xtream_skip_series_direct_source: bool, - #[serde(default)] - pub xtream_resolve_series: bool, - #[serde(default = "default_as_two_u16")] - pub xtream_resolve_series_delay: u16, - #[serde(default)] - pub xtream_resolve_vod: bool, - #[serde(default = "default_as_two_u16")] - pub xtream_resolve_vod_delay: u16, - #[serde(default)] - pub m3u_include_type_in_url: bool, - #[serde(default)] - pub m3u_mask_redirect_url: bool, - #[serde(default)] pub share_live_streams: bool, #[serde(default)] pub remove_duplicates: bool, @@ -307,13 +302,67 @@ 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", alias = "filename")] - pub output: Option, +pub struct XtreamTargetOutput { + #[serde(default = "default_as_true")] + pub skip_live_direct_source: bool, + #[serde(default = "default_as_true")] + pub skip_video_direct_source: bool, + #[serde(default = "default_as_true")] + pub skip_series_direct_source: bool, + #[serde(default)] + pub resolve_series: bool, + #[serde(default = "default_as_two_u16")] + pub resolve_series_delay: u16, + #[serde(default)] + pub resolve_vod: bool, + #[serde(default = "default_as_two_u16")] + pub resolve_vod_delay: u16, +} + + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct M3uTargetOutput { #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default)] + pub include_type_in_url: bool, + #[serde(default)] + pub mask_redirect_url: bool, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StrmTargetOutput { + pub directory: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub username: Option, + #[serde(default)] + pub underscore_whitespace: bool, + #[serde(default)] + pub cleanup: bool, + #[serde(default)] + pub kodi_style: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strm_props: Option>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HdHomeRunTargetOutput { + pub device: String, + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_output: Option +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum TargetOutput { + Xtream(XtreamTargetOutput), + M3u(M3uTargetOutput), + Strm(StrmTargetOutput), + HdHomeRun(HdHomeRunTargetOutput), } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] @@ -348,7 +397,6 @@ pub struct ConfigTarget { pub t_mapping: Option>, } - impl ConfigTarget { pub fn prepare(&mut self, id: u16, templates: Option<&Vec>) -> Result<(), M3uFilterError> { self.id = id; @@ -360,46 +408,49 @@ impl ConfigTarget { let mut xtream_cnt = 0; let mut strm_needs_xtream = false; let mut hdhr_cnt = 0; - for target_output in &self.output { - let has_username = if let Some(username) = &target_output.username { !username.trim().is_empty() } else { false }; - let has_filename = if let Some(fname) = &target_output.output { !fname.trim().is_empty() } else { false }; + let mut hdhomerun_needs_m3u=false; + let mut hdhomerun_needs_xtream = false; - match target_output.target { - TargetType::M3u => { - m3u_cnt += 1; - if has_username { - warn!("Username for target output m3u is ignored: {}", self.name); - } - } - TargetType::Xtream => { + for target_output in &mut self.output { + match target_output { + TargetOutput::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 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); + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "unique target name is required for xtream type output: {}", self.name); } } - TargetType::Strm => { + TargetOutput::M3u(_) => { + m3u_cnt += 1; + } + TargetOutput::Strm(strm_output) => { strm_cnt += 1; - if !has_filename { - return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "filename is required for strm type: {}", self.name); + strm_output.directory = strm_output.directory.trim().to_string(); + if strm_output.directory.trim().is_empty() { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "directory is required for strm type: {}", self.name); } + let has_username = if let Some(username) = &strm_output.username { !username.trim().is_empty() } else { false }; if has_username { strm_needs_xtream = true; } } - TargetType::HdHomeRun => { + TargetOutput::HdHomeRun(hdhomerun_output) => { hdhr_cnt += 1; - if !has_username { + hdhomerun_output.username = hdhomerun_output.username.trim().to_string(); + if hdhomerun_output.username.is_empty() { return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Username is required for HdHomeRun type: {}", self.name); } - let hdhr_name = target_output.output.as_ref().map_or("", |s| s.trim()); - if hdhr_name.is_empty() { - return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Output is required for HdHomeRun type: {}", self.name); + + hdhomerun_output.device = hdhomerun_output.device.trim().to_string(); + if hdhomerun_output.device.is_empty() { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Device is required for HdHomeRun type: {}", self.name); + } + + if let Some(use_output) = hdhomerun_output.use_output.as_ref() { + match &use_output { + TargetType::M3u => { hdhomerun_needs_m3u=true;}, + TargetType::Xtream => {hdhomerun_needs_xtream=true;}, + _ => return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "HdHomeRun output option `use_output` only accepts `m3u` or `xtream` for target: {}", self.name), + }; } } } @@ -413,8 +464,16 @@ impl ConfigTarget { 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 hdhr_cnt > 0 && (xtream_cnt == 0 && m3u_cnt == 0) { - return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "HdHomeRun output is only permitted when used in combination with xtream or m3u output: {}", self.name); + if hdhr_cnt > 0 { + if xtream_cnt == 0 && m3u_cnt == 0 { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "HdHomeRun output is only permitted when used in combination with xtream or m3u output: {}", self.name); + } + if hdhomerun_needs_m3u && m3u_cnt == 0 { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "HdHomeRun output has `use_output=m3u` but no `m3u` output defined: {}", self.name); + } + if hdhomerun_needs_xtream && xtream_cnt == 0 { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "HdHomeRun output has `use_output=xtream` but no `xtream` output defined: {}", self.name); + } } if let Some(watch) = &self.watch { @@ -451,19 +510,45 @@ impl ConfigTarget { true } - pub fn get_m3u_filename(&self) -> Option<&String> { - for format in &self.output { - if format.target == TargetType::M3u { - return format.output.as_ref(); - } + pub(crate) fn get_xtream_output(&self) -> Option<&XtreamTargetOutput> { + if let Some(TargetOutput::Xtream(output)) = self.output.iter().find(|o| matches!(o, TargetOutput::Xtream(_))) { + Some(output) + } else { + None + } + } + + pub(crate) fn get_m3u_output(&self) -> Option<&M3uTargetOutput> { + if let Some(TargetOutput::M3u(output)) = self.output.iter().find(|o| matches!(o, TargetOutput::M3u(_))) { + Some(output) + } else { + None + } + } + + // pub(crate) fn get_strm_output(&self) -> Option<&StrmTargetOutput> { + // if let Some(TargetOutput::Strm(output)) = self.output.iter().find(|o| matches!(o, TargetOutput::Strm(_))) { + // Some(output) + // } else { + // None + // } + // } + + pub(crate) fn get_hdhomerun_output(&self) -> Option<&HdHomeRunTargetOutput> { + if let Some(TargetOutput::HdHomeRun(output)) = self.output.iter().find(|o| matches!(o, TargetOutput::HdHomeRun(_))) { + Some(output) + } else { + None } - None } pub fn has_output(&self, tt: &TargetType) -> bool { - for format in &self.output { - if tt.eq(&format.target) { - return true; + for target_output in &self.output { + match target_output { + TargetOutput::Xtream(_) => { if tt == &TargetType::Xtream { return true } } + TargetOutput::M3u(_) => { if tt == &TargetType::M3u { return true } } + TargetOutput::Strm(_) => { if tt == &TargetType::Strm { return true } } + TargetOutput::HdHomeRun(_) => { if tt == &TargetType::HdHomeRun { return true } } } } false @@ -1134,23 +1219,20 @@ impl Config { for source in &self.sources { for target in &source.targets { for output in &target.output { - match output.target { - TargetType::M3u | TargetType::Xtream => {} - TargetType::Strm => { - self.check_username(output.username.as_deref(), &target.name)?; + match output { + TargetOutput::Xtream(_) | TargetOutput::M3u(_) => {} + TargetOutput::Strm(strm_output) => { + self.check_username(strm_output.username.as_deref(), &target.name)?; } - TargetType::HdHomeRun => { + TargetOutput::HdHomeRun(hdhomerun_output) => { if check_homerun { - if let Some(hdhr_name) = output.output.as_ref() { - self.check_username(output.username.as_deref(), &target.name)?; - if let Some(homerun) = &mut self.hdhomerun { - for device in &mut homerun.devices { - if &device.name == hdhr_name { - if let Some(username) = output.username.as_ref() { - device.t_username.clone_from(username); - device.t_enabled = true; - } - } + let hdhr_name = &hdhomerun_output.device; + self.check_username(Some(&hdhomerun_output.username), &target.name)?; + if let Some(homerun) = &mut self.hdhomerun { + for device in &mut homerun.devices { + if &device.name == hdhr_name { + device.t_username.clone_from(&hdhomerun_output.username); + device.t_enabled = true; } } } diff --git a/src/model/xtream.rs b/src/model/xtream.rs index e1df9b227..49005a2c8 100644 --- a/src/model/xtream.rs +++ b/src/model/xtream.rs @@ -3,7 +3,7 @@ use std::iter::FromIterator; use std::rc::Rc; use crate::model::api_proxy::{ProxyType, ProxyUserCredentials}; -use crate::model::config::{Config, ConfigTargetOptions}; +use crate::model::config::{Config, XtreamTargetOutput}; use crate::model::playlist::{PlaylistEntry, PlaylistItem, XtreamCluster, XtreamPlaylistItem}; use crate::utils::json_utils::{opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32}; use serde::de::DeserializeOwned; @@ -493,16 +493,11 @@ pub struct XtreamMappingOptions { } impl XtreamMappingOptions { - pub fn from_target_options(options: Option<&ConfigTargetOptions>, cfg: &Config) -> Self { - let (skip_live_direct_source, skip_video_direct_source, skip_series_direct_source) = options - .map_or((false, false, false), |o| ( - o.xtream_skip_live_direct_source, - o.xtream_skip_video_direct_source, - o.xtream_skip_series_direct_source)); + pub fn from_target_options(target_output: &XtreamTargetOutput, cfg: &Config) -> Self { Self { - skip_live_direct_source, - skip_video_direct_source, - skip_series_direct_source, + skip_live_direct_source: target_output.skip_live_direct_source, + skip_video_direct_source: target_output.skip_video_direct_source, + skip_series_direct_source: target_output.skip_series_direct_source, rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(), } } diff --git a/src/processing/processor/mod.rs b/src/processing/processor/mod.rs index 768fe116c..5760394b3 100644 --- a/src/processing/processor/mod.rs +++ b/src/processing/processor/mod.rs @@ -31,14 +31,14 @@ macro_rules! create_resolve_options_function_for_xtream_target { ($cluster:ident) => { paste::paste! { fn [](target: &ConfigTarget, fpl: &FetchedPlaylist) -> (bool, u16) { - let (resolve, resolve_delay) = - target.options.as_ref().map_or((false, 0), |opt| { - (opt.[] && fpl.input.input_type == InputType::Xtream, - opt.[]) - }); - (resolve, resolve_delay) + match target.get_xtream_output() { + Some(xtream_output) => (xtream_output.[] && fpl.input.input_type == InputType::Xtream, + xtream_output.[]), + None => (false, 0) + } } } }; } use create_resolve_options_function_for_xtream_target; + diff --git a/src/repository/epg_repository.rs b/src/repository/epg_repository.rs index 727ba0177..91cfe6877 100644 --- a/src/repository/epg_repository.rs +++ b/src/repository/epg_repository.rs @@ -1,14 +1,13 @@ +use crate::m3u_filter_error::{notify_err, M3uFilterError, M3uFilterErrorKind}; +use crate::model::config::{Config, ConfigTarget, TargetOutput}; +use crate::model::xmltv::Epg; +use crate::repository::m3u_repository::m3u_get_epg_file_path; +use crate::repository::xtream_repository::{xtream_get_epg_file_path, xtream_get_storage_path}; +use crate::utils::debug_if_enabled; +use quick_xml::Writer; use std::fs::File; use std::io::{Cursor, Write}; -use std::path::{Path}; -use quick_xml::{Writer}; -use crate::utils::{debug_if_enabled}; -use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind, notify_err}; -use crate::model::config::{Config, ConfigTarget, TargetOutput}; -use crate::model::config::TargetType; -use crate::model::xmltv::{Epg}; -use crate::repository::m3u_repository::{m3u_get_epg_file_path}; -use crate::repository::xtream_repository::{xtream_get_epg_file_path, xtream_get_storage_path}; +use std::path::Path; fn epg_write_file(target: &ConfigTarget, epg: &Epg, path: &Path) -> Result<(), M3uFilterError> { let mut writer = Writer::new(Cursor::new(vec![])); @@ -23,7 +22,7 @@ fn epg_write_file(target: &ConfigTarget, epg: &Epg, path: &Path) -> Result<(), M } match epg_file.write_all(&result) { Ok(()) => { - debug_if_enabled!("Epg for target {} written to {}", target.name, path.to_str().unwrap_or("?")); + debug_if_enabled!("Epg for target {} written to {}", target.name, path.to_str().unwrap_or("?")); } Err(err) => return Err(notify_err!(format!("failed to write epg: {} - {}", path.to_str().unwrap_or("?"), err))), } @@ -38,13 +37,8 @@ fn epg_write_file(target: &ConfigTarget, epg: &Epg, path: &Path) -> Result<(), M pub fn epg_write(target: &ConfigTarget, cfg: &Config, target_path: &Path, epg: Option<&Epg>, output: &TargetOutput) -> Result<(), M3uFilterError> { if let Some(epg_data) = epg { - match &output.target { - TargetType::M3u => { - let path = m3u_get_epg_file_path(target_path); - debug_if_enabled!("writing m3u epg to {}", path.to_str().unwrap_or("?")); - epg_write_file(target, epg_data, &path)?; - } - TargetType::Xtream => { + match output { + TargetOutput::Xtream(_) => { match xtream_get_storage_path(cfg, &target.name) { Some(path) => { let epg_path = xtream_get_epg_file_path(&path); @@ -54,8 +48,13 @@ pub fn epg_write(target: &ConfigTarget, cfg: &Config, target_path: &Path, epg: O None => return Err(notify_err!(format!("failed to serialize epg for target: {}, storage path not found", target.name))), } } - TargetType::Strm | TargetType::HdHomeRun => {} + TargetOutput::M3u(_) => { + let path = m3u_get_epg_file_path(target_path); + debug_if_enabled!("writing m3u epg to {}", path.to_str().unwrap_or("?")); + epg_write_file(target, epg_data, &path)?; } + TargetOutput::Strm(_) | TargetOutput::HdHomeRun(_) => {} + } } Ok(()) } diff --git a/src/repository/kodi_repository.rs b/src/repository/kodi_repository.rs index 67d80d2f8..aac0288b8 100644 --- a/src/repository/kodi_repository.rs +++ b/src/repository/kodi_repository.rs @@ -1,7 +1,7 @@ -use crate::m3u_filter_error::{create_m3u_filter_error_result, info_err, notify_err}; +use crate::m3u_filter_error::{create_m3u_filter_error_result, info_err}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::model::api_proxy::{ApiProxyServerInfo, ProxyType, ProxyUserCredentials}; -use crate::model::config::{Config, ConfigTarget, TargetOutput}; +use crate::model::config::{Config, ConfigTarget, StrmTargetOutput}; use crate::model::playlist::{ FieldGetAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType, UUIDType, }; @@ -508,13 +508,6 @@ fn filter_strm_item(pli: &PlaylistItem) -> bool { || item_type == PlaylistItemType::Video } -fn get_strm_output_options(target: &ConfigTarget) -> (bool, bool, bool) { - target.options.as_ref().map_or_else( - || (false, false, false), - |o| (o.underscore_whitespace, o.cleanup, o.kodi_style), - ) -} - fn get_relative_path_str(full_path: &Path, root_path: &Path) -> String { full_path .strip_prefix(root_path) @@ -600,31 +593,25 @@ fn prepare_strm_files( pub async fn kodi_write_strm_playlist( target: &ConfigTarget, + target_output: &StrmTargetOutput, cfg: &Config, new_playlist: &[PlaylistGroup], - output: &TargetOutput, ) -> Result<(), M3uFilterError> { if new_playlist.is_empty() { return Ok(()); } - if output.output.is_none() { - return Err(notify_err!( - "Output directory missing. Write strm playlist failed".to_string() - )); - } let Some(root_path) = file_utils::get_file_path( &cfg.working_dir, - Some(std::path::PathBuf::from(&output.output.as_ref().map_or_else(|| "/tmp", |v| v.as_ref()))), + Some(std::path::PathBuf::from(&target_output.directory)) ) else { return Err(info_err!(format!( "Failed to get file path for {}", - output.output.as_deref().unwrap_or("") + target_output.directory ))); }; - let credentials_and_server_info = get_credentials_and_server_info(cfg, output); - let (underscore_whitespace, cleanup, kodi_style) = get_strm_output_options(target); + let credentials_and_server_info = get_credentials_and_server_info(cfg, target_output.username.as_ref()); let strm_index_path = strm_get_file_paths(&ensure_target_storage_path(cfg, target.name.as_str())?); let existing_strm = { @@ -638,7 +625,6 @@ pub async fn kodi_write_strm_playlist( let mut processed_strm: HashSet = HashSet::with_capacity(existing_strm.len()); let mut failed = vec![]; - let strm_props = target.options.as_ref().and_then(|o| o.strm_props.as_ref()); prepare_strm_output_directory(&root_path).await?; @@ -652,8 +638,8 @@ pub async fn kodi_write_strm_playlist( cfg, new_playlist, &root_path, - underscore_whitespace, - kodi_style, + target_output.underscore_whitespace, + target_output.kodi_style, ); for strm_file in strm_files { // file paths @@ -664,7 +650,7 @@ pub async fn kodi_write_strm_playlist( // create content let url = get_strm_url(credentials_and_server_info.as_ref(), &strm_file.strm_info); - let mut content = strm_props.map_or_else(Vec::new, std::clone::Clone::clone); + let mut content = target_output.strm_props.as_ref().map_or_else(Vec::new, std::clone::Clone::clone); content.push(url); let content_text = content.join("\r\n"); let content_as_bytes = content_text.as_bytes(); @@ -701,7 +687,7 @@ pub async fn kodi_write_strm_playlist( }; if let Err(err) = - cleanup_strm_output_directory(cleanup, &root_path, &existing_strm, &processed_strm).await + cleanup_strm_output_directory(target_output.cleanup, &root_path, &existing_strm, &processed_strm).await { failed.push(err); } @@ -799,9 +785,9 @@ async fn has_strm_file_same_hash(file_path: &PathBuf, content_hash: UUIDType) -> fn get_credentials_and_server_info( cfg: &Config, - output: &TargetOutput, + username: Option<&String>, ) -> Option<(ProxyUserCredentials, ApiProxyServerInfo)> { - let username = output.username.as_ref()?; + let username = username?; let credentials = cfg.get_user_credentials(username)?; if credentials.proxy != ProxyType::Reverse { return None; diff --git a/src/repository/m3u_playlist_iterator.rs b/src/repository/m3u_playlist_iterator.rs index 915fab403..6475d2329 100644 --- a/src/repository/m3u_playlist_iterator.rs +++ b/src/repository/m3u_playlist_iterator.rs @@ -34,6 +34,7 @@ impl M3uPlaylistIterator { target: &ConfigTarget, user: &ProxyUserCredentials, ) -> Result { + let m3u_output = target.get_m3u_output().ok_or_else(|| info_err!(format!("Unexpected failure, missing m3u target output for target {}", target.name)))?; let target_path = ensure_target_storage_path(cfg, target.name.as_str())?; let (m3u_path, idx_path) = m3u_get_file_paths(&target_path); @@ -43,9 +44,6 @@ impl M3uPlaylistIterator { IndexedDocumentIterator::::new(&m3u_path, &idx_path) .map_err(|err| info_err!(format!("Could not deserialize file {m3u_path:?} - {err}")))?; - let target_options = target.options.as_ref(); - 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 filter = user_get_bouquet_filter(cfg, &user.username, None, TargetType::M3u, XtreamCluster::Live).await; // TODO m3u bouquet filter @@ -56,8 +54,8 @@ impl M3uPlaylistIterator { username: user.username.to_string(), password: user.password.to_string(), target_options: target.options.clone(), - include_type_in_url, - mask_redirect_url, + include_type_in_url: m3u_output.include_type_in_url, + mask_redirect_url: m3u_output.mask_redirect_url, filter, proxy_type: user.proxy.clone(), _file_lock: file_lock, // Save lock inside struct diff --git a/src/repository/m3u_repository.rs b/src/repository/m3u_repository.rs index e927df38b..f4907b566 100644 --- a/src/repository/m3u_repository.rs +++ b/src/repository/m3u_repository.rs @@ -1,7 +1,7 @@ use crate::m3u_filter_error::{create_m3u_filter_error,info_err}; use crate::m3u_filter_error::{str_to_io_error, M3uFilterError, M3uFilterErrorKind}; use crate::model::api_proxy::ProxyUserCredentials; -use crate::model::config::{Config, ConfigTarget}; +use crate::model::config::{Config, ConfigTarget, M3uTargetOutput}; use crate::model::playlist::{M3uPlaylistItem, PlaylistGroup, PlaylistItem, PlaylistItemType}; use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentIterator, IndexedDocumentWriter}; use crate::repository::m3u_playlist_iterator::{M3uPlaylistM3uTextIterator}; @@ -33,8 +33,8 @@ pub fn m3u_get_epg_file_path(target_path: &Path) -> PathBuf { file_utils::add_prefix_to_filename(&path, "epg_", Some("xml")) } -fn persist_m3u_playlist_as_text(target: &ConfigTarget, cfg: &Config, m3u_playlist: &Vec) { - if let Some(filename) = target.get_m3u_filename() { +fn persist_m3u_playlist_as_text(cfg: &Config, target: &ConfigTarget, target_output: &M3uTargetOutput, m3u_playlist: &Vec) { + if let Some(filename) = target_output.filename.as_ref() { if let Some(m3u_filename) = file_utils::get_file_path(&cfg.working_dir, Some(PathBuf::from(filename))) { match File::create(&m3u_filename) { Ok(file) => { @@ -53,7 +53,7 @@ fn persist_m3u_playlist_as_text(target: &ConfigTarget, cfg: &Config, m3u_playlis } } -pub async fn m3u_write_playlist(target: &ConfigTarget, cfg: &Config, target_path: &Path, new_playlist: &[PlaylistGroup]) -> Result<(), M3uFilterError> { +pub async fn m3u_write_playlist( cfg: &Config, target: &ConfigTarget, target_output: &M3uTargetOutput, target_path: &Path, new_playlist: &[PlaylistGroup]) -> Result<(), M3uFilterError> { if !new_playlist.is_empty() { let (m3u_path, idx_path) = m3u_get_file_paths(target_path); let m3u_playlist = new_playlist.iter() @@ -61,7 +61,7 @@ pub async fn m3u_write_playlist(target: &ConfigTarget, cfg: &Config, target_path .filter(|&pli| pli.header.borrow().item_type != PlaylistItemType::SeriesInfo) .map(PlaylistItem::to_m3u).collect::>(); - persist_m3u_playlist_as_text(target, cfg, &m3u_playlist); + persist_m3u_playlist_as_text(cfg, target, target_output, &m3u_playlist); { let _file_lock = cfg.file_locks.write_lock(&m3u_path); match IndexedDocumentWriter::new(m3u_path.clone(), idx_path) { diff --git a/src/repository/playlist_repository.rs b/src/repository/playlist_repository.rs index caa15baa5..925e00e72 100644 --- a/src/repository/playlist_repository.rs +++ b/src/repository/playlist_repository.rs @@ -1,7 +1,7 @@ use std::path::{Path}; use crate::m3u_filter_error::{info_err}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; -use crate::model::config::{Config, ConfigTarget, TargetType}; +use crate::model::config::{Config, ConfigTarget, TargetOutput}; use crate::model::playlist::{PlaylistGroup, PlaylistItemType}; use crate::model::xmltv::Epg; use crate::repository::epg_repository::epg_write; @@ -43,12 +43,13 @@ pub async fn persist_playlist(playlist: &mut [PlaylistGroup], epg: Option<&Epg>, } for output in &target.output { - 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).await, - TargetType::HdHomeRun => Ok(()), + let result = match output { + TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(target, cfg, playlist).await, + TargetOutput::M3u(m3u_output) => m3u_write_playlist(cfg, target, m3u_output, &target_path, playlist).await, + TargetOutput::Strm(strm_output) => kodi_write_strm_playlist(target, strm_output, cfg, playlist).await, + TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()), }; + if let Err(err) = result { errors.push(err); } else if !playlist.is_empty() { diff --git a/src/repository/xtream_playlist_iterator.rs b/src/repository/xtream_playlist_iterator.rs index 3854561df..2a85adc49 100644 --- a/src/repository/xtream_playlist_iterator.rs +++ b/src/repository/xtream_playlist_iterator.rs @@ -28,6 +28,7 @@ impl XtreamPlaylistIterator { category_id: Option, user: &ProxyUserCredentials, ) -> Result { + let xtream_output = target.get_xtream_output().ok_or_else(|| info_err!(format!("Unexpected: xtream output required for target {}", target.name)))?; if let Some(storage_path) = xtream_get_storage_path(config, target.name.as_str()) { let (xtream_path, idx_path) = xtream_get_file_paths(&storage_path, cluster); if !xtream_path.exists() || !idx_path.exists() { @@ -38,7 +39,7 @@ impl XtreamPlaylistIterator { let reader = IndexedDocumentIterator::::new(&xtream_path, &idx_path) .map_err(|err| info_err!(format!("Could not deserialize file {xtream_path:?} - {err}")))?; - let options = XtreamMappingOptions::from_target_options(target.options.as_ref(), config); + let options = XtreamMappingOptions::from_target_options(xtream_output, config); let server_info = config.get_user_server_info(user); let filter = user_get_bouquet_filter(config, &user.username, category_id, TargetType::Xtream, cluster).await; diff --git a/src/repository/xtream_repository.rs b/src/repository/xtream_repository.rs index 93883fa8d..b8f17d2db 100644 --- a/src/repository/xtream_repository.rs +++ b/src/repository/xtream_repository.rs @@ -13,7 +13,7 @@ use crate::repository::storage::hex_encode; use crate::utils::file::file_utils::file_reader; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind, str_to_io_error, info_err, create_m3u_filter_error, create_m3u_filter_error_result, notify_err}; use crate::model::api_proxy::{ProxyType, ProxyUserCredentials}; -use crate::model::config::{Config, ConfigInput, ConfigTarget}; +use crate::model::config::{Config, ConfigInput, ConfigTarget, XtreamTargetOutput}; use crate::model::playlist::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; use crate::model::xtream::{rewrite_doc_urls, PlaylistXtreamCategory, XtreamMappingOptions, XtreamSeriesEpisode, INFO_RESOURCE_PREFIX, INFO_RESOURCE_PREFIX_EPISODE, SEASON_RESOURCE_PREFIX}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; @@ -265,27 +265,6 @@ pub async fn xtream_write_playlist( XtreamCluster::Video => &mut vod_col, }; - // let col = match header.item_type { - // PlaylistItemType::LiveUnknown | PlaylistItemType::LiveHls => { - // header.category_id = *cat_id; - // Some(&mut live_col) - // } - // _ => { - // if header.get_provider_id().is_some() { - // header.category_id = *cat_id; - // Some(match header.xtream_cluster { - // XtreamCluster::Live => &mut live_col, - // XtreamCluster::Series => &mut series_col, - // XtreamCluster::Video => &mut vod_col, - // }) - // // } else { - // let title = header.title.as_str(); - // errors.push(format!("Channel does not have an id: {title}")); - // errors.push(format!("Channel does not have an id: {title}")); - // None - // } - // } - // }; drop(header); col.push(pli); } @@ -572,7 +551,7 @@ pub fn xtream_load_vod_info( fn rewrite_xtream_vod_info

( config: &Config, - target: &ConfigTarget, + xtream_output: &XtreamTargetOutput, pli: &P, user: &ProxyUserCredentials, doc: &mut Map, @@ -602,7 +581,7 @@ fn rewrite_xtream_vod_info

( movie_data.insert(TAG_STREAM_ID.to_string(), Value::Number(serde_json::value::Number::from(stream_id))); movie_data.insert(TAG_CATEGORY_ID.to_string(), Value::Number(serde_json::value::Number::from(category_id))); movie_data.insert(TAG_CATEGORY_IDS.to_string(), Value::Array(vec![Value::Number(serde_json::value::Number::from(category_id))])); - let options = XtreamMappingOptions::from_target_options(target.options.as_ref(), config); + let options = XtreamMappingOptions::from_target_options(xtream_output, config); if options.skip_video_direct_source { movie_data.insert(TAG_DIRECT_SOURCE.to_string(), Value::String(String::new())); } else { @@ -619,7 +598,7 @@ fn rewrite_xtream_vod_info

( pub fn rewrite_xtream_vod_info_content

( config: &Config, - target: &ConfigTarget, + xtream_output: &XtreamTargetOutput, pli: &P, user: &ProxyUserCredentials, content: &str, @@ -627,12 +606,13 @@ pub fn rewrite_xtream_vod_info_content

( P: PlaylistEntry, { let mut doc = serde_json::from_str::>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?; - rewrite_xtream_vod_info(config, target, pli, user, &mut doc) + rewrite_xtream_vod_info(config, xtream_output, pli, user, &mut doc) } pub fn write_and_get_xtream_vod_info

( config: &Config, target: &ConfigTarget, + xtream_output: &XtreamTargetOutput, pli: &P, user: &ProxyUserCredentials, content: &str, @@ -641,12 +621,13 @@ pub fn write_and_get_xtream_vod_info

( { let mut doc = serde_json::from_str::>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?; xtream_write_vod_info(config, target.name.as_str(), pli.get_virtual_id(), content).ok(); - rewrite_xtream_vod_info(config, target, pli, user, &mut doc) + rewrite_xtream_vod_info(config, xtream_output, pli, user, &mut doc) } fn rewrite_xtream_series_info

( config: &Config, target: &ConfigTarget, + xtream_output: &XtreamTargetOutput, pli: &P, user: &ProxyUserCredentials, doc: &mut Map, @@ -689,7 +670,7 @@ fn rewrite_xtream_series_info

( let virtual_id = pli.get_virtual_id(); { let (mut target_id_mapping, file_lock) = get_target_id_mapping(config, &target_path); - let options = XtreamMappingOptions::from_target_options(target.options.as_ref(), config); + let options = XtreamMappingOptions::from_target_options(xtream_output, config); let provider_url = pli.get_provider_url(); for episode_list in episodes.values_mut().filter_map(Value::as_array_mut) { @@ -732,6 +713,7 @@ fn rewrite_xtream_series_info

( pub fn rewrite_xtream_series_info_content

( config: &Config, target: &ConfigTarget, + xtream_output: &XtreamTargetOutput, pli_series_info: &P, user: &ProxyUserCredentials, content: &str, @@ -739,12 +721,13 @@ pub fn rewrite_xtream_series_info_content

( P: PlaylistEntry, { let mut doc = serde_json::from_str::>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?; - rewrite_xtream_series_info(config, target, pli_series_info, user, &mut doc) + rewrite_xtream_series_info(config, target, xtream_output, pli_series_info, user, &mut doc) } pub fn write_and_get_xtream_series_info

( config: &Config, target: &ConfigTarget, + xtream_output: &XtreamTargetOutput, pli_series_info: &P, user: &ProxyUserCredentials, content: &str, @@ -754,7 +737,7 @@ pub fn write_and_get_xtream_series_info

( let mut doc = serde_json::from_str::>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?; let virtual_id = pli_series_info.get_virtual_id(); xtream_write_series_info(config, target.name.as_str(), virtual_id, content).ok(); - rewrite_xtream_series_info(config, target, pli_series_info, user, &mut doc) + rewrite_xtream_series_info(config, target, xtream_output, pli_series_info, user, &mut doc) } pub fn xtream_get_input_info( diff --git a/src/utils/network/xtream.rs b/src/utils/network/xtream.rs index 40202da56..c64d43a3d 100644 --- a/src/utils/network/xtream.rs +++ b/src/utils/network/xtream.rs @@ -68,32 +68,32 @@ pub async fn get_xtream_stream_info

(client: Arc, where P: PlaylistEntry, { + let xtream_output = target.get_xtream_output().ok_or_else(|| Error::new(std::io::ErrorKind::Other, "Unexpected error, missing xtream output"))?; + if cluster == XtreamCluster::Series { if let Some(content) = xtream_repository::xtream_load_series_info(config, target.name.as_str(), pli.get_virtual_id()) { // Deliver existing target content - return rewrite_xtream_series_info_content(config, target, pli, user, &content); + return rewrite_xtream_series_info_content(config, target, xtream_output, pli, user, &content); } // Check if the content has been resolved - let resolve_series = target.options.as_ref().is_some_and(|opt| opt.xtream_resolve_series); - if resolve_series { + if xtream_output.resolve_series { if let Some(provider_id) = pli.get_provider_id() { if let Some(content) = xtream_get_input_info(config, input, provider_id, XtreamCluster::Series) { - return xtream_repository::write_and_get_xtream_series_info(config, target, pli, user, &content); + return xtream_repository::write_and_get_xtream_series_info(config, target, xtream_output, pli, user, &content); } } } } else if cluster == XtreamCluster::Video { if let Some(content) = xtream_repository::xtream_load_vod_info(config, target.name.as_str(), pli.get_virtual_id()) { // Deliver existing target content - return rewrite_xtream_vod_info_content(config, target, pli, user, &content); + return rewrite_xtream_vod_info_content(config, xtream_output, pli, user, &content); } // Check if the content has been resolved - let resolve_vod = target.options.as_ref().is_some_and(|opt| opt.xtream_resolve_vod); - if resolve_vod { + if xtream_output.resolve_vod { if let Some(provider_id) = pli.get_provider_id() { if let Some(content) = xtream_get_input_info(config, input, provider_id, XtreamCluster::Video) { - return xtream_repository::write_and_get_xtream_vod_info(config, target, pli, user, &content); + return xtream_repository::write_and_get_xtream_vod_info(config, target, xtream_output, pli, user, &content); } } } @@ -102,8 +102,8 @@ where if let Ok(content) = get_xtream_stream_info_content(client, info_url, input).await { return match cluster { XtreamCluster::Live => Ok(content), - XtreamCluster::Video => xtream_repository::write_and_get_xtream_vod_info(config, target, pli, user, &content), - XtreamCluster::Series => xtream_repository::write_and_get_xtream_series_info(config, target, pli, user, &content), + XtreamCluster::Video => xtream_repository::write_and_get_xtream_vod_info(config, target, xtream_output, pli, user, &content), + XtreamCluster::Series => xtream_repository::write_and_get_xtream_series_info(config, target, xtream_output, pli, user, &content), }; } diff --git a/src/utils/string_utils.rs b/src/utils/string_utils.rs index c6efab3b8..76e964e76 100644 --- a/src/utils/string_utils.rs +++ b/src/utils/string_utils.rs @@ -40,4 +40,4 @@ pub fn get_trimmed_string(value: &Option) -> Option { } } None -} \ No newline at end of file +}