From e01197cf52a517a5a4eab363d0f613d8ab809350 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:04:37 +0100 Subject: [PATCH] Feature/for each genre review (#541) * New Features Added for_each DSL construct for iterating over playlist item fields, with split() function support. Added "genre" as a queryable and editable playlist item field. Bug Fixes Improved short EPG request handling with automatic default limits. Fixed EPG timestamp formatting to use UTC consistently. --- .gitignore | 1 + CHANGELOG.md | 3 + Cargo.lock | 6 +- README.md | 34 +++ backend/Cargo.toml | 2 +- backend/src/api/endpoints/xmltv_api.rs | 8 +- .../src/api/model/provider_lineup_manager.rs | 1 + backend/src/model/config/favourites.rs | 2 +- backend/src/model/config/sort.rs | 2 +- backend/src/model/config/target.rs | 4 +- backend/src/model/mapping.rs | 4 +- backend/src/processing/parser/xtream.rs | 3 +- backend/src/processing/processor/playlist.rs | 13 +- backend/src/processing/processor/sort.rs | 4 +- docker/Dockerfile | 45 +-- frontend/Cargo.toml | 4 +- .../app/components/filter/filter_editor.rs | 2 +- .../src/app/components/filter/filter_input.rs | 2 +- .../app/components/playlist/filter_view.rs | 36 ++- .../components/playlist/mapper_conter_view.rs | 281 ------------------ .../components/playlist/mapper_script_view.rs | 34 ++- frontend/src/services/config_service.rs | 4 +- shared/Cargo.toml | 2 +- shared/src/foundation/filter.rs | 83 +----- shared/src/foundation/mapper.pest | 14 +- shared/src/foundation/mapper.rs | 256 +++++++++++++++- shared/src/foundation/mod.rs | 17 +- shared/src/foundation/value_provider.rs | 176 +++++++++++ shared/src/model/config/favourites.rs | 2 +- shared/src/model/config/rename.rs | 2 +- shared/src/model/config/sort.rs | 2 +- shared/src/model/config/source.rs | 2 +- shared/src/model/config/target.rs | 2 +- shared/src/model/item_field.rs | 7 + shared/src/model/mapping.rs | 8 +- shared/src/model/playlist.rs | 4 + shared/src/model/stream_properties.rs | 18 +- 37 files changed, 629 insertions(+), 461 deletions(-) delete mode 100644 frontend/src/app/components/playlist/mapper_conter_view.rs create mode 100644 shared/src/foundation/value_provider.rs diff --git a/.gitignore b/.gitignore index 511b35326..c9883f923 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ docker/binaries # Ignore documentation files AGENTS.md +PLAN.md *.m3u diff --git a/CHANGELOG.md b/CHANGELOG.md index e2494d6db..725f70b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ - **Accent-Independent Matching**: Integrated `match_as_ascii` flag for robust text matching (e.g., "Cinema" matches "Cinéma"). - **Deunicoding Support**: `ValueProvider` and `ValueAccessor` now support on-the-fly deunicoding. - **Flexible Sorting**: Added `order: none` support to retain source order in mappings. +- **Mapper Loop enhancement**: Updated `for_each` syntax to `variable.for_each((key, value) => { ... })`. Added support for `_` ignored variables in loop. ## 💻 WebUI & API - **Source Editor Integration**: Redesigned UI for global input management and hot-reloading. @@ -70,6 +71,8 @@ - **Performance Monitoring**: Added CPU usage display to the dashboard. - **Stream Table Enhancements**: Added "Copy-To-Clipboard" functions and improved connection monitoring. - **UX Improvements**: Implemented API-user category selection and better session tracking for HLS. +- **Filter View**: Compacted pretty printing for filters. +- **Mapper View**: Updated to support new `for_each` syntax. - Added **Stream Buffer** settings (Enabled, Size) to Reverse Proxy configuration UI. - Added **TMDB** settings (Rate Limit, Cache Duration, Language) and **Metadata Formats** (NFO support) to Library configuration UI. diff --git a/Cargo.lock b/Cargo.lock index af47fb8f1..b2605ff94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.54" +version = "3.2.55" dependencies = [ "anyhow", "base64", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.54" +version = "3.2.55" dependencies = [ "base64", "bitflags 2.10.0", @@ -4592,7 +4592,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.54" +version = "3.2.55" dependencies = [ "arc-swap", "async-compression", diff --git a/README.md b/README.md index cdfe315b7..9bf5f2471 100644 --- a/README.md +++ b/README.md @@ -1770,6 +1770,40 @@ Example `if then else` block } ``` +Example `for each` block + +Iterates over a `Named` result (a list of key-value tuples). +The syntax is `variable.for_each( (key, value) => { ... })`. +The parameters `key` and `value` are variable names you define to access the tuple elements inside the loop. + +You can use `_` for parameters you want to ignore (e.g., `(_, value)` or `(key, _)`). However, at least one parameter must be named (you cannot use `(_, _)`). + +`Named` variables are created by: +1. **`split()` function**: keys are indices ("0", "1", ...), values are the split parts. +2. **Regex with capture groups**: keys are group names (or indices), values are the captured matches. + +```dsl + # 1. Using split() + # Split the genre string into a Named result (index as key, genre as value) + genres = split(@Genre, "[,/&]") + + # Iterate over each genre, ignoring the index + genres.for_each((_, genre) => { + # 'genre' will contain the split string value + add_favourite(concat("Genre - ", genre)) + }) + + # 2. Using Regex with named capture groups + # Extract info using regex, creating a Named result like [("Movie", "Inception"), ("Year", "2010")] + info = @Title ~ "(?P.*?)\s-\s(?P\d{4})" + + info.for_each((k, v) => { + # k will be "Movie" then "Year" + # v will be "Inception" then "2010" + print(concat("Found ", k, ": ", v)) + }) +``` + Example of removing prefix `@Caption = replace(@Caption, "UK:", "EN:"` diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f012f4aea..40e9cc582 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.54" +version = "3.2.55" edition = "2021" rust-version = "1.89.0" diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index c432eff4e..3bae38c03 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -194,7 +194,8 @@ async fn serve_epg_with_rewrites( for programme in programmes { let mut elem = BytesStart::new("programme"); - let (user_start, user_stop) = apply_user_offset(programme.start, programme.stop, epg_processing_options.offset_minutes); + // We dont need to apply offset because the time contains the time zone offset as +00000 + let (user_start, user_stop) = (programme.start, programme.stop); // apply_user_offset(programme.start, programme.stop, epg_processing_options.offset_minutes); elem.push_attribute(("start", format_xmltv_time_utc(user_start).as_str())); elem.push_attribute(("stop", format_xmltv_time_utc(user_stop).as_str())); elem.push_attribute(("channel", channel.id.as_ref())); @@ -285,6 +286,8 @@ fn from_programme(stream_id: &Arc, epg_id: &Arc, programme: &EpgProgr } } +const DEFAULT_SHORT_EPG_LIMIT: u32 = 4; + pub async fn serve_short_epg( app_state: &Arc, epg_path: &Path, @@ -295,6 +298,8 @@ pub async fn serve_short_epg( limit: u32, ) -> axum::response::Response { let short_epg = { + // It seems provider set limit to 4 if it is undefined oor 0. + let limit = if limit > 0 { limit} else { DEFAULT_SHORT_EPG_LIMIT }; if file_exists_async(epg_path).await { if let Some(epg_channel) = get_epg_channel(app_state, channel_id, epg_path).await { let epg_processing_options = get_epg_processing_options(app_state, user, target); @@ -402,3 +407,4 @@ pub fn xmltv_api_register() -> axum::Router> { axum::routing::get(epg_api_resource), ) } + diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index 567cfa7d2..db7dcda33 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -960,6 +960,7 @@ mod tests { priority, max_connections, exp_date: None, + enabled: true, } } diff --git a/backend/src/model/config/favourites.rs b/backend/src/model/config/favourites.rs index cb0fa52ce..4616a1b32 100644 --- a/backend/src/model/config/favourites.rs +++ b/backend/src/model/config/favourites.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use crate::model::macros; -use shared::foundation::filter::{CompiledRegex, Filter}; +use shared::foundation::{CompiledRegex, Filter}; use shared::model::{ConfigFavouritesDto, ItemField, XtreamCluster}; #[derive(Debug, Clone)] diff --git a/backend/src/model/config/sort.rs b/backend/src/model/config/sort.rs index 93d84ee00..653e62338 100644 --- a/backend/src/model/config/sort.rs +++ b/backend/src/model/config/sort.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use regex::Regex; use shared::model::{ConfigSortRuleDto, ConfigSortDto, ItemField, SortOrder, SortTarget}; -use shared::foundation::filter::Filter; +use shared::foundation::Filter; use crate::model::macros; diff --git a/backend/src/model/config/target.rs b/backend/src/model/config/target.rs index a522b9206..aa0677c1a 100644 --- a/backend/src/model/config/target.rs +++ b/backend/src/model/config/target.rs @@ -7,8 +7,8 @@ use shared::model::{ConfigTargetDto, ConfigTargetOptions, HdHomeRunTargetOutputD ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto, TargetType, TraktConfigDto, XtreamTargetOutputDto}; use shared::model::PlaylistItemType; use std::sync::Arc; -use shared::foundation::filter::Filter; -use shared::foundation::filter::ValueProvider; +use shared::foundation::Filter; +use shared::foundation::ValueProvider; #[derive(Clone, Debug)] pub struct ProcessTargets { diff --git a/backend/src/model/mapping.rs b/backend/src/model/mapping.rs index dd23f3af1..454d3bfed 100644 --- a/backend/src/model/mapping.rs +++ b/backend/src/model/mapping.rs @@ -1,5 +1,5 @@ -use shared::foundation::filter::Filter; -use shared::foundation::mapper::MapperScript; +use shared::foundation::Filter; +use shared::foundation::MapperScript; use shared::model::{MapperDto, MappingCounter, MappingCounterDefinition, MappingDefinitionDto, MappingDto, MappingsDto, PatternTemplate}; use crate::model::macros; diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 683b91efd..09dc6fc4b 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -379,6 +379,7 @@ mod tests { use crate::utils::async_file_reader; use shared::model::{XtreamCluster, XtreamSeriesInfo}; use std::fs; + use shared::utils::Internable; #[test] fn test_read_json_file_into_struct() { @@ -400,7 +401,7 @@ mod tests { async fn test_read_json_stream_into_struct() -> std::io::Result<()> { if fs::exists("/tmp/vod_streams.json").unwrap_or(false) { let reader = Box::pin(async_file_reader(tokio::fs::File::open("/tmp/vod_streams.json").await?)); - match map_to_xtream_streams(XtreamCluster::Video, reader, "test").await { + match map_to_xtream_streams(XtreamCluster::Video, reader, &"test".intern()).await { Ok(_streams) => { println!("{:?}", _streams.get(1)); println!("{:?}", _streams.get(100)); diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 2119d4375..c2628affb 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -9,8 +9,8 @@ use tokio::sync::{Mutex, OwnedRwLockWriteGuard, RwLock}; use tokio::task::JoinSet; use crate::api::model::{EventManager, EventMessage, PlaylistStorageState, UpdateGuard}; -use crate::messaging::{send_message}; -use crate::model::messaging::{MessageContent}; +use crate::messaging::send_message; +use crate::model::messaging::MessageContent; use crate::model::Epg; @@ -35,16 +35,17 @@ use crate::utils::{debug_if_enabled, trace_if_enabled}; use futures::StreamExt; use indexmap::IndexMap; use log::{debug, error, info, log_enabled, warn, Level}; +use shared::concat_string; use shared::error::{get_errors_notify_message, notify_err, TuliproxError}; -use shared::foundation::filter::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}; +use shared::foundation::{get_field_value, set_field_value, ValueAccessor, ValueProvider}; +use shared::foundation::Filter; use shared::model::xtream_const::XTREAM_CLUSTER; +use shared::model::UUIDType; use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState, ProcessingOrder, XtreamCluster}; use shared::utils::{create_alias_uuid, default_as_default, interner_gc, Internable}; use std::time::Instant; -use shared::concat_string; -use shared::model::UUIDType; fn is_valid(pli: &PlaylistItem, filter: &Filter, match_as_ascii: bool) -> bool { let provider = ValueProvider { pli, match_as_ascii }; @@ -866,7 +867,7 @@ pub async fn exec_processing(client: &reqwest::Client, app_config: Arc>, playlist_state: Option>, update_guard: Option, disabled_headers: Option) { - let _guard = if let Some(guard) = update_guard { + let _guard = if let Some(guard) = update_guard { if let Some(permit) = guard.try_playlist() { Some(permit) } else { diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index 22bc8ac80..4046b89e8 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -1,5 +1,5 @@ use crate::model::{ConfigSortRule, ConfigTarget}; -use shared::foundation::filter::ValueProvider; +use shared::foundation::ValueProvider; use shared::model::{PlaylistGroup, SortOrder, SortTarget}; use std::cmp::Ordering; use std::sync::Arc; @@ -240,7 +240,7 @@ fn sort_channels_in_groups( mod tests { use crate::model::ConfigSortRule; use crate::processing::processor::sort::playlist_comparator; - use shared::foundation::filter::Filter; + use shared::foundation::Filter; use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget}; use std::cmp::Ordering; use std::sync::Arc; diff --git a/docker/Dockerfile b/docker/Dockerfile index 111b0e4f4..7d0f182a9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -93,26 +93,31 @@ WORKDIR /src COPY resources ./resources # Combine ffmpeg commands into a single layer to reduce image size -RUN ffmpeg -loop 1 -i ./resources/channel_unavailable.jpg -t 10 -r 1 -an \ - -vf "scale=1920:1080" \ - -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \ - ./resources/channel_unavailable.ts && \ - ffmpeg -loop 1 -i ./resources/user_connections_exhausted.jpg -t 10 -r 1 -an \ - -vf "scale=1920:1080" \ - -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \ - ./resources/user_connections_exhausted.ts && \ - ffmpeg -loop 1 -i ./resources/provider_connections_exhausted.jpg -t 10 -r 1 -an \ - -vf "scale=1920:1080" \ - -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \ - ./resources/provider_connections_exhausted.ts && \ - ffmpeg -loop 1 -i ./resources/user_account_expired.jpg -t 10 -r 1 -an \ - -vf "scale=1920:1080" \ - -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \ - ./resources/user_account_expired.ts && \ - ffmpeg -loop 1 -i ./resources/panel_api_provisioning.ppm -t 10 -r 1 -an \ - -vf "scale=1920:1080" \ - -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \ - ./resources/panel_api_provisioning.ts +RUN ffmpeg -y -nostdin -loop 1 -i ./resources/channel_unavailable.jpg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ + -c:v libx264 -r 30 -g 30 -keyint_min 30 -sc_threshold 0 -pix_fmt yuv420p -preset veryfast -crf 23 \ + -c:a aac -b:a 128k -ac 2 \ + -t 10 -muxrate 2000k \ + -f mpegts ./resources/channel_unavailable.ts && \ + ffmpeg -y -nostdin -loop 1 -i ./resources/user_connections_exhausted.jpg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ + -c:v libx264 -r 30 -g 30 -keyint_min 30 -sc_threshold 0 -pix_fmt yuv420p -preset veryfast -crf 23 \ + -c:a aac -b:a 128k -ac 2 \ + -t 10 -muxrate 2000k \ + -f mpegts ./resources/user_connections_exhausted.ts && \ + ffmpeg -y -nostdin -loop 1 -i ./resources/provider_connections_exhausted.jpg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ + -c:v libx264 -r 30 -g 30 -keyint_min 30 -sc_threshold 0 -pix_fmt yuv420p -preset veryfast -crf 23 \ + -c:a aac -b:a 128k -ac 2 \ + -t 10 -muxrate 2000k \ + -f mpegts ./resources/provider_connections_exhausted.ts && \ + ffmpeg -y -nostdin -loop 1 -i ./resources/user_account_expired.jpg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ + -c:v libx264 -r 30 -g 30 -keyint_min 30 -sc_threshold 0 -pix_fmt yuv420p -preset veryfast -crf 23 \ + -c:a aac -b:a 128k -ac 2 \ + -t 10 -muxrate 2000k \ + -f mpegts ./resources/user_account_expired.ts && \ + ffmpeg -y -nostdin -loop 1 -i ./resources/panel_api_provisioning.jpg -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 \ + -c:v libx264 -r 30 -g 30 -keyint_min 30 -sc_threshold 0 -pix_fmt yuv420p -preset veryfast -crf 23 \ + -c:a aac -b:a 128k -ac 2 \ + -t 10 -muxrate 2000k \ + -f mpegts ./resources/panel_api_provisioning.ts # ----------------------------------------------------------------- # Stage 4: Prepare timezone data diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index e6a0f954e..638e06f64 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.54" +version = "3.2.55" edition = "2021" rust-version = "1.89.0" [dependencies] -shared = { version = "3.2.54", path = "../shared" } +shared = { version = "3.2.55", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/src/app/components/filter/filter_editor.rs b/frontend/src/app/components/filter/filter_editor.rs index cc085908b..331b95685 100644 --- a/frontend/src/app/components/filter/filter_editor.rs +++ b/frontend/src/app/components/filter/filter_editor.rs @@ -3,7 +3,7 @@ use crate::app::ConfigContext; use shared::model::PatternTemplate; use yew::{classes, function_component, html, use_context, use_effect_with, use_state, Callback, Html, Properties, TargetCast}; use yew_i18n::use_translation; -use shared::foundation::filter::{get_filter}; +use shared::foundation::{get_filter}; use crate::app::components::{CollapsePanel, FilterView}; #[derive(Properties, Clone, PartialEq, Debug)] diff --git a/frontend/src/app/components/filter/filter_input.rs b/frontend/src/app/components/filter/filter_input.rs index f0575b1a1..00afcac1f 100644 --- a/frontend/src/app/components/filter/filter_input.rs +++ b/frontend/src/app/components/filter/filter_input.rs @@ -4,7 +4,7 @@ use crate::app::components::{AppIcon, FilterEditor, FilterView}; use crate::app::ConfigContext; use crate::model::{DialogAction, DialogActions, DialogResult}; use crate::services::DialogService; -use shared::foundation::filter::get_filter; +use shared::foundation::get_filter; use shared::model::PatternTemplate; use yew::platform::spawn_local; use yew::prelude::*; diff --git a/frontend/src/app/components/playlist/filter_view.rs b/frontend/src/app/components/playlist/filter_view.rs index 154877064..db9243379 100644 --- a/frontend/src/app/components/playlist/filter_view.rs +++ b/frontend/src/app/components/playlist/filter_view.rs @@ -1,4 +1,4 @@ -use shared::foundation::filter::Filter; +use shared::foundation::Filter; use yew::prelude::*; #[derive(Properties, PartialEq, Clone)] @@ -42,7 +42,7 @@ fn newline(pretty: bool) -> Html { if pretty { html! {
} } else { - html!{} + html! {} } } @@ -53,11 +53,7 @@ fn render_filter(filter: &Filter, pretty: bool, level: usize, do_indent: bool, p <> { indent(level, do_indent && pretty) } {"("} - {newline(pretty)} - { indent(level +1 , pretty) } - { render_filter(inner, pretty, level + 1, false, p_count+1) } - {newline(pretty)} - { indent(level , pretty) } + { render_filter(inner, pretty, level, false, p_count+1) } { ")" } } @@ -66,7 +62,7 @@ fn render_filter(filter: &Filter, pretty: bool, level: usize, do_indent: bool, p <> { indent(level, do_indent && pretty) } - {format!("{:?}", field)} + {format!("{}", field)} {" ~ "} {format!("\"{}\"", regex.restr)} @@ -77,7 +73,20 @@ fn render_filter(filter: &Filter, pretty: bool, level: usize, do_indent: bool, p { indent(level, do_indent && pretty) } {format!("{:?}", field)}{" = "} - {format!("{:?}", t)} + { + { + match t { + shared::model::PlaylistItemType::Live => "live", + shared::model::PlaylistItemType::Video + | shared::model::PlaylistItemType::LocalVideo => "movie", + shared::model::PlaylistItemType::Series + | shared::model::PlaylistItemType::SeriesInfo + | shared::model::PlaylistItemType::LocalSeries + | shared::model::PlaylistItemType::LocalSeriesInfo => "series", + _ => "unsupported" + } + } + } }, @@ -86,20 +95,17 @@ fn render_filter(filter: &Filter, pretty: bool, level: usize, do_indent: bool, p <> { indent(level, do_indent && pretty) } {format!(" {:?} ", op)} - {newline(pretty)} - { indent(level, pretty) } - { render_filter(inner, pretty, level, do_indent && pretty, p_count) } + { render_filter(inner, pretty, level, false, p_count) } } - }, + } Filter::BinaryExpression(left, op, right) => html! { { render_filter(left, pretty, level, do_indent && pretty, p_count) } { newline(pretty) } { indent(level, pretty) } {format!(" {:?} ", op)} - { newline(pretty) } - { render_filter(right, pretty, level, pretty, p_count) } + { render_filter(right, pretty, level, false, p_count) } }, } diff --git a/frontend/src/app/components/playlist/mapper_conter_view.rs b/frontend/src/app/components/playlist/mapper_conter_view.rs deleted file mode 100644 index 5249e6eec..000000000 --- a/frontend/src/app/components/playlist/mapper_conter_view.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::ops::Deref; -use regex::Regex; -use shared::foundation::mapper::{MapperScript, Statement, Expression, ExprId, MapKey, BuiltInFunction, MapCase, AssignmentTarget, MatchCase, RegexSource, MapCaseKey}; -use yew::prelude::*; - -#[derive(Properties, PartialEq, Clone)] -pub struct MapperCounterViewProps { - #[prop_or_default] - pub pretty: bool, - #[prop_or(false)] - pub inline: bool, - pub script: Option, -} - -#[function_component] -pub fn MapperCounterView(props: &MapperScriptViewProps) -> Html { - html! { -
- { - match props.script.as_ref() { - Some(script) => html! { -
-                            { render_script(script, props.pretty, 0, false, 1) }
-                        
- }, - None => html! { }, - } - } -
- } -} - -struct FormatParams { - pub pretty: bool, - pub level: usize, - pub do_indent: bool, - pub p_count: usize, -} - -impl FormatParams { - fn inc_level(&mut self, inc: usize) -> &mut Self { - self.level += inc; - self - } - fn dec_level(&mut self, dec: usize) -> &mut Self { - self.level -= dec; - self - } -} - -// Indents with spaces for pretty printing -fn indent(level: usize, do_indent: bool) -> Html { - if do_indent { - let spaces: AttrValue = " ".repeat(level * 2).into(); - html! { <>{ spaces } } - } else { - html! {} - } -} - -fn newline(format_params: &mut FormatParams) -> Html { - if format_params.pretty { - html! {
} - } else { - html!{} - } -} - -fn render_args(args: &[ExprId], script: &MapperScript, format_params: &mut FormatParams) -> Html { - html! { - <> - { - for args.iter().map(|expr_id| { - render_expression(expr_id, script, format_params) - }) - } - - } -} - -fn render_var_access(name: &str, field: &str) -> Html { - html! { { name }{ field } } -} - -fn render_field(field: &str) -> Html { - html! { {"@"}{ field } } -} - -fn render_identifier(ident: &str) -> Html { - html! { { ident } } -} - -fn render_map_key(key: &MapKey) -> Html { - match key { - MapKey::Identifier(ident) => render_identifier(ident), - MapKey::FieldAccess(field) => render_field(field), - MapKey::VarAccess(name, field) => render_var_access(name, field), - } -} - -fn render_function_call(name: &BuiltInFunction, args: &[ExprId], script: &MapperScript, format_params: &mut FormatParams) -> Html { - html! { - {name.to_string()}{"("}{render_args(args, script, format_params)}{")"} - } -} - -fn render_literal(literal: &str) -> Html { - html! { {"'"}{ literal }{"'"} } -} - -fn render_num_literal(literal: &f64) -> Html { - html! { { literal } } -} - -fn render_null_value() -> Html { - html! { { "null" } } -} - -fn render_map_case(case: &MapCase, script: &MapperScript, format_params: &mut FormatParams) -> Html { - let keys_html = html! { - <> - { - for case.keys.iter().enumerate().map(|(i, key)| { - let item = match key { - MapCaseKey::Text(text) => render_literal(text), - MapCaseKey::RangeFrom(from) => html! { format!("{from}..") }, - MapCaseKey::RangeTo(to) => html! { format!("..{to}") }, - MapCaseKey::RangeFull(from, to) => html! { format!("{from}..{to}") }, - MapCaseKey::RangeEq(val) => html! { val.to_string() }, - MapCaseKey::AnyMatch => html! { "_" }, - }; - - if i < case.keys.len() - 1 { - html! { <> { item } { ", " } } - } else { - html! { { item } } - } - }) - } - - }; - let has_bracket = case.keys.len() > 1; - html! { - <> - {indent(format_params.level, true)} - {if has_bracket {"("} else {""}} - {keys_html} - {if has_bracket {")"} else {""}} - {" => "} {render_expression(&case.expression, script, format_params)}{","} - {newline(format_params)} - - } -} - - -fn render_map_cases(cases: &[MapCase], script: &MapperScript, format_params: &mut FormatParams) -> Html { - html!{ - <> - { - for cases.iter().map(|case| render_map_case(case, script, format_params)) - } - - } -} - -fn render_map_block(map_key: &MapKey, cases: &[MapCase], script: &MapperScript, format_params: &mut FormatParams) -> Html { - html! { - <> - {indent(format_params.level, true)} - {"map "} - {render_map_key(map_key)} - {" {"} - {newline(format_params)} - {render_map_cases(cases, script, format_params.inc_level(1))} - {newline(format_params.dec_level(1))} - {indent(format_params.level, true)} - {"}"} - - } -} - - -fn render_block(expr_ids: &[ExprId], script: &MapperScript, format_params: &mut FormatParams) -> Html { - html! { - <> - {"{"} - { newline(format_params) } - { - for expr_ids.iter().map(|expr_id| { - html! { - <> - {render_expression(expr_id, script, format_params)} - {newline(format_params)} - - }}) - } - { newline(format_params) } - {"}"} - {newline(format_params)} - - - } -} - -fn render_assignment(target: &AssignmentTarget, expr_id: &ExprId, script: &MapperScript, format_params: &mut FormatParams) -> Html { - let target_html = match target { - AssignmentTarget::Identifier(ident) => render_identifier(ident), - AssignmentTarget::Field(field) => render_field(field), - }; - - html! { - <> - { target_html } {" = "} - { render_expression(expr_id, script, format_params) } - { newline(format_params) } - - } -} - -fn render_match_block(match_cases: &[MatchCase], script: &MapperScript, format_params: &mut FormatParams) -> Html { - html! { - <> - {"!! TODO MATCH BLOCK !!!"} - {newline(format_params)} - - } -} - - -fn render_regex_source(source: &RegexSource) -> Html { - match source { - RegexSource::Identifier(ident) => render_identifier(ident), - RegexSource::Field(field) => render_field(field), - } -} - -fn render_regexp(field: &RegexSource, pattern: &String, regex: &Regex) -> Html { - html! { <> {render_regex_source(field)} {" ~ "} {"'"}{ pattern }{"'"} } -} - -fn render_expression(expr_id: &ExprId, script: &MapperScript, format_params: &mut FormatParams) -> Html { - script.get_expr_by_id(*expr_id.deref()).map(|expression| { - match expression { - Expression::Identifier(ident) => render_identifier(ident), - Expression::StringLiteral(literal) => render_literal(literal), - Expression::NumberLiteral(num) => render_num_literal(num), - Expression::FieldAccess(field) => render_field(field), - Expression::VarAccess(name, field) => render_var_access(name, field), - Expression::RegexExpr { field, pattern, re_pattern } => render_regexp(field, pattern, re_pattern), - Expression::FunctionCall { name, args } => render_function_call(name, args, script, format_params), - Expression::Assignment { target, expr } => render_assignment(target, expr , script, format_params), - Expression::MatchBlock(match_cases) => render_match_block(match_cases, script, format_params), - Expression::MapBlock { key, cases} => render_map_block(key, cases, script, format_params), - Expression::NullValue => render_null_value(), - Expression::Block(expr_ids) => render_block(expr_ids, script, format_params), - } - }).unwrap_or_else(|| html! { {"ExprNotFound"} }) -} - -fn render_script(script: &MapperScript, pretty: bool, level: usize, do_indent: bool, p_count: usize) -> Html { - let mut format_params = FormatParams { - pretty, level, do_indent, p_count - }; - let items = script.statements.iter().map(|stmt| { - match stmt { - Statement::Expression(expr_id) => html! { - <> - {render_expression(expr_id, script, &mut format_params)} - {newline(&mut format_params)} - - }, - Statement::Comment(comment) => html!{
{ comment }
}, - } - }); - - html! { - <> - { for items } - - } -} diff --git a/frontend/src/app/components/playlist/mapper_script_view.rs b/frontend/src/app/components/playlist/mapper_script_view.rs index 0581194be..ddf7879f0 100644 --- a/frontend/src/app/components/playlist/mapper_script_view.rs +++ b/frontend/src/app/components/playlist/mapper_script_view.rs @@ -1,6 +1,6 @@ use std::ops::Deref; use regex::Regex; -use shared::foundation::mapper::{MapperScript, Statement, Expression, ExprId, MapKey, BuiltInFunction, MapCase, AssignmentTarget, MatchCase, RegexSource, MapCaseKey, MatchCaseKey}; +use shared::foundation::{AssignmentTarget, BuiltInFunction, ExprId, Expression, ForEachExpr, ForEachKey, MapCase, MapCaseKey, MapKey, MapperScript, MatchCase, MatchCaseKey, RegexSource, Statement}; use yew::prelude::*; #[derive(Properties, PartialEq, Clone)] @@ -179,6 +179,33 @@ fn render_map_block(map_key: &MapKey, cases: &[MapCase], script: &MapperScript, } } +fn render_for_each_key(key: &ForEachKey) -> Html { + match key { + ForEachKey::Identifier(ident) => render_identifier(ident), + ForEachKey::VarAccess(name, field) => render_var_access(name, field), + } +} + +fn render_for_each_block(for_each_key: &ForEachKey, expr: &ForEachExpr, script: &MapperScript, format_params: &mut FormatParams) -> Html { + let key_var = expr.key_var.clone().unwrap_or_else(|| "_".to_string()); + let value_var = expr.value_var.clone().unwrap_or_else(|| "_".to_string()); + + html! { + <> + {indent(format_params.level, true)} + {render_for_each_key(for_each_key)} + {".for_each"}{"("}{"("} + {key_var}{", "}{value_var} + {")"}{" => "} + {" {"} + {newline(format_params)} + {render_expression(&expr.expression, script, format_params.inc_level(1))} + {newline(format_params.dec_level(1))} + {indent(format_params.level, true)} + {"})"} + + } +} fn render_block(expr_ids: &[ExprId], script: &MapperScript, format_params: &mut FormatParams) -> Html { html! { @@ -297,9 +324,10 @@ fn render_expression(expr_id: &ExprId, script: &MapperScript, format_params: &mu Expression::VarAccess(name, field) => render_var_access(name, field), Expression::RegexExpr { field, pattern, re_pattern } => render_regexp(field, pattern, re_pattern), Expression::FunctionCall { name, args } => render_function_call(name, args, script, format_params), - Expression::Assignment { target, expr } => render_assignment(target, expr , script, format_params), + Expression::Assignment { target, expr } => render_assignment(target, expr, script, format_params), Expression::MatchBlock(match_cases) => render_match_block(match_cases, script, format_params), - Expression::MapBlock { key, cases} => render_map_block(key, cases, script, format_params), + Expression::MapBlock { key, cases } => render_map_block(key, cases, script, format_params), + Expression::ForEachBlock { key, expr } => render_for_each_block(key, expr, script, format_params), Expression::NullValue => render_null_value(), Expression::Block(expr_ids) => render_block(expr_ids, script, format_params), } diff --git a/frontend/src/services/config_service.rs b/frontend/src/services/config_service.rs index a91dc9517..430534b93 100644 --- a/frontend/src/services/config_service.rs +++ b/frontend/src/services/config_service.rs @@ -8,8 +8,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use log::error; use futures_signals::signal::Mutable; use futures_signals::signal::SignalExt; -use shared::foundation::filter::{get_filter, prepare_templates}; -use shared::foundation::mapper::MapperScript; +use shared::foundation::{get_filter, prepare_templates}; +use shared::foundation::MapperScript; use shared::utils::{concat_path, concat_path_leading_slash}; use crate::error::Error; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index edce6dedc..7dd77d278 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "3.2.54" +version = "3.2.55" edition = "2021" rust-version = "1.89.0" diff --git a/shared/src/foundation/filter.rs b/shared/src/foundation/filter.rs index 5e01df0d9..7cb9ad7b1 100644 --- a/shared/src/foundation/filter.rs +++ b/shared/src/foundation/filter.rs @@ -2,6 +2,10 @@ use pest_derive::Parser; +use crate::error::{info_err_res, TuliproxError}; +use crate::info_err; +pub use crate::model::{ItemField, PatternTemplate, PlaylistItemType, TemplateValue}; +use crate::utils::{DirectedGraph, Internable, CONSTANTS}; use enum_iterator::all; use indexmap::IndexSet; use log::{error, log_enabled, trace, Level}; @@ -10,79 +14,7 @@ use pest::Parser; use std::cmp::Ordering; use std::collections::HashMap; use std::sync::Arc; -use crate::error::{info_err_res, TuliproxError}; -use crate::info_err; -pub use crate::model::{ItemField, PatternTemplate, PlaylistItem, PlaylistItemType, FieldGetAccessor, FieldSetAccessor, TemplateValue}; -use crate::utils::{deunicode_string, DirectedGraph, Internable, CONSTANTS}; - -pub fn get_field_value(pli: &PlaylistItem, field: ItemField) -> Arc { - let header = &pli.header; - match field { - ItemField::Group => Arc::clone(&header.group), - ItemField::Name => Arc::clone(&header.name), - ItemField::Title => Arc::clone(&header.title), - ItemField::Url => Arc::clone(&header.url), - ItemField::Input => Arc::clone(&header.input_name), - ItemField::Type => header.item_type.intern(), - ItemField::Caption => if header.title.is_empty() { Arc::clone(&header.name) } else { Arc::clone(&header.title) }, - } -} - -pub fn set_field_value(pli: &mut PlaylistItem, field: ItemField, value: String) -> bool { - let header = &mut pli.header; - match field { - ItemField::Group => header.group = value.intern(), - ItemField::Name => header.name = value.intern(), - ItemField::Title => header.title = value.intern(), - ItemField::Url => header.url = value.intern(), - ItemField::Input => header.input_name = value.intern(), - ItemField::Caption => { - header.title = value.intern(); - header.name = header.title.clone(); - } - ItemField::Type => {}, - } - true -} - -pub struct ValueProvider<'a> { - pub pli: &'a PlaylistItem, - pub match_as_ascii: bool, -} - -impl ValueProvider<'_> { - pub fn get(&self, field: &str) -> Option> { - let val = self.pli.header.get_field(field)?; - if self.match_as_ascii { - return Some(deunicode_string(&val).into_owned().into()) - } - Some(val) - } -} - -pub struct ValueAccessor<'a> { - pub pli: &'a mut PlaylistItem, - pub virtual_items: Vec<(String, PlaylistItem)>, - pub match_as_ascii: bool, -} - -impl ValueAccessor<'_> { - pub fn get(&self, field: &str) -> Option> { - let val = self.pli.header.get_field(field)?; - if self.match_as_ascii { - return Some(deunicode_string(&val).into_owned().into()) - } - Some(val) - } - - pub fn set(&mut self, field: &str, value: &str) { - if self.pli.header.set_field(field, value) { - trace!("Property {field} set to {value}"); - } else { - error!("Can't set unknown field {field} set to {value}"); - } - } -} +use crate::foundation::value_provider::ValueProvider; #[derive(Debug, Clone)] pub struct CompiledRegex { @@ -99,7 +31,7 @@ impl PartialEq for CompiledRegex { #[derive(Parser)] #[grammar_inline = r#" WHITESPACE = _{ " " | "\t" | "\r" | "\n"} -field = { ^"group" | ^"title" | ^"name" | ^"url" | ^"input" | ^"caption"} +field = { ^"group" | ^"title" | ^"name" | ^"genre" | ^"url" | ^"input" | ^"caption"} and = { ^"and" } or = { ^"or" } not = { ^"not" } @@ -183,7 +115,7 @@ impl Filter { Self::FieldComparison(field, rewc) => { let (is_match, value) = if field == &ItemField::Caption { get_caption(provider, rewc) - } else if let Some(value) = provider.get(field.as_str()) { + } else if let Some(value) = provider.get(field.as_str()) { (rewc.re.is_match(&value), value) } else { (false, "".intern()) @@ -550,7 +482,6 @@ fn build_dependency_graph( } pub fn prepare_templates(templates: &mut Vec) -> Result, TuliproxError> { - let graph = build_dependency_graph(templates)?; let mut template_values = HashMap::new(); let mut template_map = HashMap::with_capacity(templates.len()); diff --git a/shared/src/foundation/mapper.pest b/shared/src/foundation/mapper.pest index 87f317535..48a85d500 100644 --- a/shared/src/foundation/mapper.pest +++ b/shared/src/foundation/mapper.pest @@ -10,15 +10,15 @@ number_range_to = { ".." ~ number } number_range_full = { number ~ ".." ~ number } number_range_eq = { number } number_range = _{ number_range_full | number_range_from | number_range_to | number_range_eq} -field = { ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo" | ^"logo_small" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" } +field = { ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo" | ^"logo_small" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" | ^"genre" } field_access = _{ "@" ~ field } regex_source = _{ field_access | identifier } regex_expr = { regex_source ~ regex_op ~ string_literal } block_expr = { "{" ~ statements ~ "}" } condition = { function_call | var_access | field_access } assignment = { (field_access | identifier) ~ "=" ~ expression } -expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } -function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format"} +expression = { assignment | map_block | match_block | for_each_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } +function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "split" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" } function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" } any_match = { "_" } match_case_key = { any_match | identifier } @@ -30,6 +30,14 @@ map_case_key = { any_match | number_range | map_case_key_list } map_case = { map_case_key ~ "=>" ~ expression } map_key = { var_access | field_access } map_block = { "map" ~ map_key ~ "{" ~ NEWLINE* ~ (map_case ~ ("," ~ NEWLINE* ~ map_case)*)? ~ ","? ~ NEWLINE* ~ "}" } +for_each_param = { any_match | identifier } +for_each_params = { "(" ~ for_each_param ~ "," ~ for_each_param ~ ")" } +for_each_target_nested = { identifier ~ "." ~ identifier } +for_each_target_simple = { identifier } +for_each_block = { + (for_each_target_nested ~ ^".for_each" ~ "(" ~ for_each_params ~ "=>" ~ expression ~ ")") | + (for_each_target_simple ~ ^".for_each" ~ "(" ~ for_each_params ~ "=>" ~ expression ~ ")") +} statement = _{ expression } comment = _{ "#" ~ (!NEWLINE ~ ANY)* } statement_reparator = _{ ";" | NEWLINE } diff --git a/shared/src/foundation/mapper.rs b/shared/src/foundation/mapper.rs index 99cbbff40..55334a119 100644 --- a/shared/src/foundation/mapper.rs +++ b/shared/src/foundation/mapper.rs @@ -1,8 +1,8 @@ #![allow(clippy::empty_docs)] -use crate::error::{info_err_res, info_err, TuliproxError}; -use crate::foundation::filter::ValueAccessor; +use crate::error::{info_err, info_err_res, TuliproxError}; use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; +use crate::foundation::value_provider::ValueAccessor; use crate::model::{PatternTemplate, PlaylistItemType, TemplateValue}; use crate::utils::{Capitalize, Internable}; use log::{debug, trace}; @@ -33,15 +33,15 @@ number_range_to = { ".." ~ number } number_range_full = { number ~ ".." ~ number } number_range_eq = { number } number_range = _{ number_range_full | number_range_from | number_range_to | number_range_eq} -field = { ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo" | ^"logo_small" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" } +field = { ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo" | ^"logo_small" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" | ^"genre" } field_access = _{ "@" ~ field } regex_source = _{ field_access | identifier } regex_expr = { regex_source ~ regex_op ~ string_literal } block_expr = { "{" ~ statements ~ "}" } condition = { function_call | var_access | field_access } assignment = { (field_access | identifier) ~ "=" ~ expression } -expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } -function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" } +expression = { assignment | map_block | match_block | for_each_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } +function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "split" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" } function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" } any_match = { "_" } match_case_key = { any_match | identifier } @@ -53,6 +53,14 @@ map_case_key = { any_match | number_range | map_case_key_list } map_case = { map_case_key ~ "=>" ~ expression } map_key = { var_access | field_access } map_block = { "map" ~ map_key ~ "{" ~ NEWLINE* ~ (map_case ~ ("," ~ NEWLINE* ~ map_case)*)? ~ ","? ~ NEWLINE* ~ "}" } +for_each_param = { any_match | identifier } +for_each_params = { "(" ~ for_each_param ~ "," ~ for_each_param ~ ")" } +for_each_target_nested = { identifier ~ "." ~ identifier } +for_each_target_simple = { identifier } +for_each_block = { + (for_each_target_nested ~ ^".for_each" ~ "(" ~ for_each_params ~ "=>" ~ expression ~ ")") | + (for_each_target_simple ~ ^".for_each" ~ "(" ~ for_each_params ~ "=>" ~ expression ~ ")") +} statement = _{ expression } comment = _{ "#" ~ (!NEWLINE ~ ANY)* } statement_reparator = _{ ";" | NEWLINE } @@ -108,12 +116,26 @@ pub enum MapKey { } +#[derive(Debug, Clone, PartialEq)] +pub enum ForEachKey { + Identifier(String), + VarAccess(String, String), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ForEachExpr { + pub key_var: Option, + pub value_var: Option, + pub expression: ExprId, +} + #[derive(Debug, Clone, PartialEq)] pub enum BuiltInFunction { Concat, Uppercase, Lowercase, Capitalize, + Split, Trim, Print, ToNumber, @@ -134,6 +156,7 @@ impl FromStr for BuiltInFunction { "capitalize" => Ok(Self::Capitalize), "lowercase" => Ok(Self::Lowercase), "uppercase" => Ok(Self::Uppercase), + "split" => Ok(Self::Split), "trim" => Ok(Self::Trim), "print" => Ok(Self::Print), "number" => Ok(Self::ToNumber), @@ -155,6 +178,7 @@ impl Display for BuiltInFunction { Self::Capitalize => "capitalize", Self::Lowercase => "lowercase", Self::Uppercase => "uppercase", + Self::Split => "split", Self::Trim => "trim", Self::Print => "print", Self::ToNumber => "number", @@ -187,6 +211,7 @@ pub enum Expression { Assignment { target: AssignmentTarget, expr: ExprId }, MatchBlock(Vec), MapBlock { key: MapKey, cases: Vec }, + ForEachBlock { key: ForEachKey, expr: ForEachExpr }, NullValue, Block(Vec), } @@ -208,6 +233,7 @@ impl PartialEq for Expression { (Assignment { target: t1, expr: e1 }, Assignment { target: t2, expr: e2 }) => t1 == t2 && e1 == e2, (MatchBlock(m1), MatchBlock(m2)) => m1 == m2, (MapBlock { key: k1, cases: c1 }, MapBlock { key: k2, cases: c2 }) => k1 == k2 && c1 == c2, + (ForEachBlock { key: k1, expr: c1 }, ForEachBlock { key: k2, expr: c2 }) => k1 == k2 && c1 == c2, (NullValue, NullValue) => true, (Block(b1), Block(b2)) => b1 == b2, _ => false, @@ -546,6 +572,11 @@ impl MapperScript { Rule::map_block => { Self::parse_map_block(pair.into_inner(), expressions) } + + Rule::for_each_block => { + Self::parse_for_each_block(pair.into_inner(), expressions) + } + Rule::null => { Ok(Some(Expression::NullValue)) } @@ -608,6 +639,62 @@ impl MapperScript { Ok(Some(Expression::MapBlock { key, cases })) } } + + fn parse_for_each_param(pair: Pair) -> Result, TuliproxError> { + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::identifier => Ok(Some(inner.as_str().to_string())), + Rule::any_match => Ok(None), + _ => info_err_res!("Unexpected for_each_param: {:?}", inner.as_rule()), + } + } + + fn parse_for_each_params(pair: Pair) -> Result<(Option, Option), TuliproxError> { + let mut inner = pair.into_inner(); + let key = Self::parse_for_each_param(inner.next().unwrap())?; + let val = Self::parse_for_each_param(inner.next().unwrap())?; + + if key.is_none() && val.is_none() { + return info_err_res!("At least one parameter must be named in for_each loop"); + } + + Ok((key, val)) + } + + fn parse_for_each_block(mut pairs: Pairs, expressions: &mut Vec) -> Result, TuliproxError> { + let first = pairs.next().unwrap(); + let key = match first.as_rule() { + Rule::for_each_target_simple => { + ForEachKey::Identifier(first.as_str().trim().to_string()) + } + Rule::for_each_target_nested => { + let text = first.as_str(); + let splitted: Vec<&str> = text.splitn(2, '.').collect(); + ForEachKey::VarAccess(splitted[0].trim().to_string(), splitted[1].trim().to_string()) + } + _ => return info_err_res!("Unexpected for each target: {:?}", first.as_rule()), + }; + + if let Some(params_pair) = pairs.next() { // .for_each + let (key_var, value_var) = Self::parse_for_each_params(params_pair)?; + + let expr_pair = pairs.next().unwrap(); + if let Some(expr) = MapperScript::parse_expression(expr_pair, expressions)? { + expressions.push(expr); + let expr_id = ExprId(expressions.len() - 1); + return Ok(Some(Expression::ForEachBlock { + key, + expr: ForEachExpr { + key_var, + value_var, + expression: expr_id, + }, + })); + } + } + + Ok(None) + } } pub struct MapperContext<'a> { @@ -709,6 +796,11 @@ impl<'a> MapperContext<'a> { return info_err_res!("Function accepts only one argument {:?}, {} given", name, args.len()); } } + BuiltInFunction::Split => { + if args.len() != 2 { + return info_err_res!("Function accepts two arguments {:?}, {} given", name, args.len()); + } + } BuiltInFunction::Replace => { if args.len() != 3 { return info_err_res!("Function accepts three arguments {:?}, {} given", name, args.len()); @@ -731,6 +823,9 @@ impl<'a> MapperContext<'a> { Expression::MapBlock { key, cases } => { self.validate_map_block(identifiers, key, cases)?; } + Expression::ForEachBlock { key, expr } => { + self.validate_for_each_block(identifiers, key, expr)?; + } Expression::Block(expressions) => { for expr_id in expressions { self.validate_expr(*expr_id, identifiers)?; @@ -813,6 +908,36 @@ impl<'a> MapperContext<'a> { } Ok(()) } + + fn validate_for_each_block(&mut self, identifiers: &mut HashSet, key: &ForEachKey, expr: &ForEachExpr) -> Result<(), TuliproxError> { + match key { + ForEachKey::Identifier(ident) + | ForEachKey::VarAccess(ident, _) => { + if !identifiers.contains(ident.as_str()) { + return info_err_res!("For each key identifier unknown {}", ident); + } + } + } + let mut local_identifiers = identifiers.clone(); + + if let Some(key_var) = &expr.key_var { + if local_identifiers.contains(key_var) { + return info_err_res!("For each key variable shadows existing identifier {}", key_var); + } + local_identifiers.insert(key_var.clone()); + } + + if let Some(value_var) = &expr.value_var { + if local_identifiers.contains(value_var) { + return info_err_res!("For each value variable shadows existing identifier {}", value_var); + } + local_identifiers.insert(value_var.clone()); + } + + self.validate_expr(expr.expression, &mut local_identifiers)?; + + Ok(()) + } } #[derive(Debug, Clone)] @@ -1102,6 +1227,19 @@ impl Expression { BuiltInFunction::Trim => Value(concat_args(&evaluated_args).iter().map(|s| s.trim()).collect::>().join(" ").trim().to_string()), BuiltInFunction::Lowercase => Value(concat_args(&evaluated_args).join(" ").to_lowercase()), BuiltInFunction::Capitalize => Value(concat_args(&evaluated_args).iter().map(Capitalize::capitalize).collect::>().join(" ")), + BuiltInFunction::Split => { + let string = extract_evaluated_arg_value!(evaluated_args, 0); + let pattern = extract_evaluated_arg_value!(evaluated_args, 1); + + if let (Some(text), Some(pat)) = (string, pattern) { + match crate::model::REGEX_CACHE.get_or_compile(pat) { + Ok(re) => Named(re.split(text).enumerate().map(|(i, s)| (i.to_string(), s.trim().to_string())).collect()), + Err(e) => Failure(format!("Invalid regex pattern '{}': {}", pat, e)), + } + } else { + Undefined + } + } BuiltInFunction::Print => { trace!("[MapperScript] {}", concat_args(&evaluated_args).join("")); Undefined @@ -1249,7 +1387,7 @@ impl Expression { let group_name = extract_evaluated_arg_value!(evaluated_args, 0); if let Some(group) = group_name { let item_type = accessor.pli.header.item_type; - if item_type != PlaylistItemType::Series && item_type != PlaylistItemType::LocalSeries { + if item_type != PlaylistItemType::Series && item_type != PlaylistItemType::LocalSeries { let mut pli = accessor.pli.clone(); pli.header.group = group.intern(); pli.header.uuid = crate::utils::create_alias_uuid(&accessor.pli.header.uuid, group); @@ -1390,6 +1528,58 @@ impl Expression { } Undefined } + Expression::ForEachBlock { key, expr } => { + let key_value = match key { + ForEachKey::Identifier(ident) => { + if !ctx.has_var(ident) { + return Failure(format!("For each expression invalid! Variable with name {ident} not found.")); + } + let v = ctx.get_var(ident); + match v { + Named(_) | AnyValue | Failure(_) => v.clone(), + Undefined => Undefined, + _ => Failure(format!("Variable with name {ident} must be a Named list.")), + } + } + ForEachKey::VarAccess(name, field) => { + match ctx.variables.get(name) { + None => Failure(format!("Variable with name {name} not found.")), + Some(value) => match value { + AnyValue | Failure(_) => value.clone(), + Named(values) => { + let filtered: Vec<(String, String)> = values.iter() + .filter(|(k, _)| k == field) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if filtered.is_empty() { + Undefined + } else { + Named(filtered) + } + } + Undefined => Undefined, + _ => Failure(format!("Variable with name {name} must be a Named list.")), + }, + } + } + }; + + let values = match key_value { + Named(key_value) => key_value, + Failure(_) => return key_value, + _ => Vec::new() + }; + for (k, val) in values { + if let Some(key_var) = &expr.key_var { + ctx.set_var(key_var, EvalResult::Value(k)); + } + if let Some(value_var) = &expr.value_var { + ctx.set_var(value_var, EvalResult::Value(val)); + } + expr.expression.eval(ctx, accessor); + } + Undefined + } Expression::Block(expressions) => { let mut result = Undefined; for expr in expressions { @@ -1404,7 +1594,7 @@ impl Expression { #[cfg(test)] mod tests { use super::*; - use crate::model::{PlaylistItem, PlaylistItemHeader}; + use crate::model::{PlaylistItem, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties}; #[test] fn test_mapper_dsl_eval() { @@ -1591,4 +1781,56 @@ mod tests { mapper.eval(&mut accessor, None); assert_eq!(accessor.virtual_items.len(), 0); } + + #[test] + fn test_mapper_split_loop() { + use crate::model::PlaylistItemType; + let dsl = r#" + genres = split(@Genre, ",") + print(genres) + genres.for_each((_, gen) => { + add_favourite(concat("Genre - ", gen)) + }) + "#; + + let mapper = MapperScript::parse(dsl, None).expect("Parsing failed"); + + // Test with Video (should work) + let mut video = PlaylistItem { + header: PlaylistItemHeader { + name: "Movie 1".to_string().into(), + item_type: PlaylistItemType::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + details: Some(VideoStreamDetailProperties { + genre: Some("A, B, C".intern()), + ..VideoStreamDetailProperties::default() + }), + ..VideoStreamProperties::default() + }))), + ..Default::default() + } + }; + let mut accessor = ValueAccessor { pli: &mut video, virtual_items: vec![], match_as_ascii: false }; + mapper.eval(&mut accessor, None); + assert_eq!(accessor.virtual_items.len(), 3); + assert_eq!(&*accessor.virtual_items[0].1.header.group, "Genre - A"); + assert_eq!(&*accessor.virtual_items[1].1.header.group, "Genre - B"); + assert_eq!(&*accessor.virtual_items[2].1.header.group, "Genre - C"); + + // Test with SeriesInfo (should work) + let mut series_info = PlaylistItem { + header: PlaylistItemHeader { + name: "Series 1".to_string().into(), + item_type: PlaylistItemType::SeriesInfo, + additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties { + genre: Some("A, B, C".intern()), + ..SeriesStreamProperties::default() + }))), + ..Default::default() + } + }; + let mut accessor = ValueAccessor { pli: &mut series_info, virtual_items: vec![], match_as_ascii: false }; + mapper.eval(&mut accessor, None); + assert_eq!(accessor.virtual_items.len(), 3); + } } diff --git a/shared/src/foundation/mod.rs b/shared/src/foundation/mod.rs index 1dc915362..f197491b4 100644 --- a/shared/src/foundation/mod.rs +++ b/shared/src/foundation/mod.rs @@ -1,8 +1,9 @@ -pub mod filter; -pub mod mapper; -// -// pub use filter::{Filter, -// get_filter, prepare_templates, ValueProvider, ValueAccessor, -// get_field_value, set_field_value, -// apply_templates_to_pattern, apply_templates_to_pattern_single}; -// pub use mapper::*; +mod filter; +mod mapper; +mod value_provider; + +pub use filter::{Filter, CompiledRegex, + prepare_templates, get_filter, apply_templates_to_pattern, + apply_templates_to_pattern_single}; +pub use mapper::*; +pub use value_provider::*; diff --git a/shared/src/foundation/value_provider.rs b/shared/src/foundation/value_provider.rs new file mode 100644 index 000000000..ba30a0124 --- /dev/null +++ b/shared/src/foundation/value_provider.rs @@ -0,0 +1,176 @@ +use std::sync::Arc; +use log::{error, trace}; +use crate::model::{FieldGetAccessor, FieldSetAccessor, ItemField, PlaylistItem, PlaylistItemType}; +use crate::utils::{deunicode_string, Internable}; + +#[macro_export] +macro_rules! set_genre { + ($header:ident, $value:ident) => { + if let Some(ref mut additional_properties) = $header.additional_properties { + match additional_properties { + $crate::model::StreamProperties::Video(v) => { + if let Some(details) = &mut v.details { + details.genre = Some($value.intern()); + true + } else { + v.details = Some($crate::model::VideoStreamDetailProperties { + genre: Some($value.intern()), + ..$crate::model::VideoStreamDetailProperties::default() + }); + true + } + } + $crate::model::StreamProperties::Series(s) => { + s.genre = Some($value.intern()); + true + } + $crate::model::StreamProperties::Live(_) + | $crate::model::StreamProperties::Episode(_) => false, + } + } else { + let empty_str = "".intern(); + match $header.item_type { + PlaylistItemType::LocalVideo + | PlaylistItemType::Video => { + $header.additional_properties = Some($crate::model::StreamProperties::Video(Box::from($crate::model::VideoStreamProperties { + name: $header.title.clone(), + category_id: $header.category_id, + stream_id: $header.virtual_id, + stream_icon: $header.logo.clone(), + direct_source: Arc::clone(&empty_str), + custom_sid: None, + added: Arc::clone(&empty_str), + container_extension: $header.get_container_extension().unwrap_or_else(|| Arc::clone(&empty_str)), + rating: None, + rating_5based: None, + stream_type: None, + trailer: None, + tmdb: None, + is_adult: 0, + details: Some($crate::model::VideoStreamDetailProperties { + genre: Some($value.intern()), + ..$crate::model::VideoStreamDetailProperties::default() + }), + }))); + true + } + PlaylistItemType::LocalSeriesInfo + | PlaylistItemType::SeriesInfo => { + $header.additional_properties = Some($crate::model::StreamProperties::Series(Box::from($crate::model::SeriesStreamProperties { + name: $header.title.clone(), + category_id: $header.category_id, + series_id: $header.virtual_id, + backdrop_path: None, + cast: Arc::clone(&empty_str), + cover: Arc::clone(&empty_str), + director: Arc::clone(&empty_str), + episode_run_time: None, + genre: Some($value.intern()), + last_modified: None, + plot: None, + rating: 0.0, + rating_5based: 0.0, + release_date: None, + youtube_trailer: Arc::clone(&empty_str), + tmdb: None, + details: None, + }))); + true + } + _ => false, + } + } + }; +} + +#[macro_export] +macro_rules! get_genre { + ($header:ident) => { + $header.additional_properties.as_ref().and_then(|props| { + match props { + $crate::model::StreamProperties::Video(v) => { + v.details.as_ref().and_then(|details| details.genre.as_ref().map(Arc::clone)) + } + $crate::model::StreamProperties::Series(s) => { s.genre.as_ref().map(Arc::clone) } + $crate::model::StreamProperties::Live(_) + | $crate::model::StreamProperties::Episode(_) => None + } + }) + }; +} + +pub use set_genre; +pub use get_genre; + +pub fn get_field_value(pli: &PlaylistItem, field: ItemField) -> Arc { + let header = &pli.header; + match field { + ItemField::Group => Arc::clone(&header.group), + ItemField::Name => Arc::clone(&header.name), + ItemField::Title => Arc::clone(&header.title), + ItemField::Genre => get_genre!(header).unwrap_or_else(|| "".intern()), + ItemField::Url => Arc::clone(&header.url), + ItemField::Input => Arc::clone(&header.input_name), + ItemField::Type => header.item_type.intern(), + ItemField::Caption => if header.title.is_empty() { Arc::clone(&header.name) } else { Arc::clone(&header.title) }, + } +} + +pub fn set_field_value(pli: &mut PlaylistItem, field: ItemField, value: String) -> bool { + let header = &mut pli.header; + match field { + ItemField::Group => header.group = value.intern(), + ItemField::Name => header.name = value.intern(), + ItemField::Title => header.title = value.intern(), + ItemField::Genre => { + return set_genre!(header, value); + } + ItemField::Url => header.url = value.intern(), + ItemField::Input => header.input_name = value.intern(), + ItemField::Caption => { + header.title = value.intern(); + header.name = header.title.clone(); + } + ItemField::Type => {} + } + true +} + +pub struct ValueProvider<'a> { + pub pli: &'a PlaylistItem, + pub match_as_ascii: bool, +} + +impl ValueProvider<'_> { + pub fn get(&self, field: &str) -> Option> { + let val = self.pli.header.get_field(field)?; + if self.match_as_ascii { + return Some(deunicode_string(&val).into_owned().into()); + } + Some(val) + } +} + +pub struct ValueAccessor<'a> { + pub pli: &'a mut PlaylistItem, + pub virtual_items: Vec<(String, PlaylistItem)>, + pub match_as_ascii: bool, +} + +impl ValueAccessor<'_> { + pub fn get(&self, field: &str) -> Option> { + let val = self.pli.header.get_field(field)?; + if self.match_as_ascii { + return Some(deunicode_string(&val).into_owned().into()); + } + Some(val) + } + + pub fn set(&mut self, field: &str, value: &str) { + if self.pli.header.set_field(field, value) { + trace!("Property {field} set to {value}"); + } else { + error!("Can't set unknown field {field} set to {value}"); + } + } +} \ No newline at end of file diff --git a/shared/src/model/config/favourites.rs b/shared/src/model/config/favourites.rs index b7c39c957..ea3e0ec11 100644 --- a/shared/src/model/config/favourites.rs +++ b/shared/src/model/config/favourites.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use crate::error::{TuliproxError}; -use crate::foundation::filter::{get_filter, Filter}; +use crate::foundation::{get_filter, Filter}; use crate::model::{PatternTemplate, XtreamCluster}; use crate::utils::{arc_str_serde, xtream_cluster_serde}; diff --git a/shared/src/model/config/rename.rs b/shared/src/model/config/rename.rs index 9bc6a5cf4..781be1baa 100644 --- a/shared/src/model/config/rename.rs +++ b/shared/src/model/config/rename.rs @@ -1,5 +1,5 @@ use crate::error::{info_err_res, TuliproxError}; -use crate::foundation::filter::apply_templates_to_pattern_single; +use crate::foundation::apply_templates_to_pattern_single; use crate::model::{ItemField, PatternTemplate}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] diff --git a/shared/src/model/config/sort.rs b/shared/src/model/config/sort.rs index c40f98a00..dfcc0d864 100644 --- a/shared/src/model/config/sort.rs +++ b/shared/src/model/config/sort.rs @@ -1,5 +1,5 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; -use crate::foundation::filter::{apply_templates_to_pattern, get_filter, Filter}; +use crate::foundation::{apply_templates_to_pattern, get_filter, Filter}; use crate::model::{ItemField, PatternTemplate, TemplateValue}; use crate::{handle_tuliprox_error_result_list, info_err, info_err_res}; use regex::Regex; diff --git a/shared/src/model/config/source.rs b/shared/src/model/config/source.rs index a4717dfc9..9e8decd05 100644 --- a/shared/src/model/config/source.rs +++ b/shared/src/model/config/source.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use crate::info_err_res; use crate::error::{TuliproxError}; -use crate::foundation::filter::prepare_templates; +use crate::foundation::prepare_templates; use crate::model::{ConfigInputDto, HdHomeRunDeviceOverview, PatternTemplate}; use crate::model::config::target::ConfigTargetDto; use crate::utils::{arc_str_vec_serde, default_as_default, Internable}; diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index 253cbf150..b5d405aad 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -2,7 +2,7 @@ use crate::utils::is_blank_optional_string; use log::warn; use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::{info_err_res, handle_tuliprox_error_result_list}; -use crate::foundation::filter::{get_filter, Filter}; +use crate::foundation::{get_filter, Filter}; use crate::model::{ClusterFlags, ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, HdHomeRunDeviceOverview, PatternTemplate, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto}; use crate::utils::{is_true, is_false, default_as_true, default_resolve_delay_secs, default_as_default, diff --git a/shared/src/model/item_field.rs b/shared/src/model/item_field.rs index 80f7c294e..6ced58f78 100644 --- a/shared/src/model/item_field.rs +++ b/shared/src/model/item_field.rs @@ -13,6 +13,8 @@ pub enum ItemField { Name, #[serde(rename = "title")] Title, + #[serde(rename = "genre")] + Genre, #[serde(rename = "url")] Url, #[serde(rename = "input")] @@ -27,6 +29,7 @@ impl ItemField { const GROUP: &'static str = "Group"; const NAME: &'static str = "Name"; const TITLE: &'static str = "Title"; + const GENRE: &'static str = "Genre"; const URL: &'static str = "Url"; const INPUT: &'static str = "Input"; const TYPE: &'static str = "Type"; @@ -37,6 +40,7 @@ impl ItemField { Self::Group => Self::GROUP, Self::Name => Self::NAME, Self::Title => Self::TITLE, + Self::Genre => Self::GENRE, Self::Url => Self::URL, Self::Input => Self::INPUT, Self::Type => Self::TYPE, @@ -51,6 +55,7 @@ impl Display for ItemField { Self::Group => Self::GROUP, Self::Name => Self::NAME, Self::Title => Self::TITLE, + Self::Genre => Self::GENRE, Self::Url => Self::URL, Self::Input => Self::INPUT, Self::Type => Self::TYPE, @@ -69,6 +74,8 @@ impl FromStr for ItemField { Ok(Self::Name) } else if s.eq_ignore_ascii_case(Self::TITLE) { Ok(Self::Title) + } else if s.eq_ignore_ascii_case(Self::GENRE) { + Ok(Self::Genre) } else if s.eq_ignore_ascii_case(Self::CAPTION) { Ok(Self::Caption) } else if s.eq_ignore_ascii_case(Self::URL) { diff --git a/shared/src/model/mapping.rs b/shared/src/model/mapping.rs index 1e4fef2d5..b7df46ba4 100644 --- a/shared/src/model/mapping.rs +++ b/shared/src/model/mapping.rs @@ -5,8 +5,8 @@ use std::sync::atomic::AtomicU32; use std::sync::Arc; use log::{trace}; use crate::error::{TuliproxError, info_err_res}; -use crate::foundation::filter::{apply_templates_to_pattern_single, get_filter, prepare_templates, Filter}; -use crate::foundation::mapper::MapperScript; +use crate::foundation::{apply_templates_to_pattern_single, get_filter, prepare_templates, Filter}; +use crate::foundation::MapperScript; use crate::model::PatternTemplate; pub const COUNTER_FIELDS: &[&str] = &["name", "title", "caption", "chno"]; @@ -109,6 +109,7 @@ pub enum MapperOperation { Lowercase { field: String }, Uppercase { field: String }, Capitalize { field: String }, + Split { field: String, value: String }, Suffix { field: String, value: String }, Prefix { field: String, value: String }, Set { field: String, value: String }, @@ -135,7 +136,8 @@ impl MapperOperation { } } - MapperOperation::Suffix { ref field, ref mut value } + MapperOperation::Split { ref field, ref mut value } + | MapperOperation::Suffix { ref field, ref mut value } | MapperOperation::Prefix { ref field, ref mut value } | MapperOperation::Set { ref field, ref mut value } => { if !valid_property!(field.as_str(), MAPPER_FIELDS) { diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 1c603f46e..8c4bbb6ea 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -370,6 +370,8 @@ macro_rules! generate_field_accessor_impl_for_playlist_item_header { self.epg_channel_id.as_ref().map(Arc::clone) } else if bytes.eq_ignore_ascii_case(b"chno") { Some(self.chno.to_string().intern()) + } else if bytes.eq_ignore_ascii_case(b"genre") { + crate::get_genre!(self) } else { None } @@ -407,6 +409,8 @@ macro_rules! generate_field_accessor_impl_for_playlist_item_header { } else { false } + } else if bytes.eq_ignore_ascii_case(b"genre") { + return crate::set_genre!(self, value); } else { false } diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 839287750..13f021dd3 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -37,7 +37,7 @@ pub struct LiveStreamProperties { pub is_adult: i32, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct VideoStreamDetailProperties { #[serde(default, with = "arc_str_option_serde")] pub kinopoisk_url: Option>, @@ -79,17 +79,9 @@ pub struct VideoStreamDetailProperties { pub duration_secs: Option>, #[serde(default, with = "arc_str_option_serde")] pub duration: Option>, - #[serde( - default, - serialize_with = "serialize_json_as_opt_string", - deserialize_with = "deserialize_json_as_opt_string" - )] + #[serde(default, serialize_with = "serialize_json_as_opt_string", deserialize_with = "deserialize_json_as_opt_string")] pub video: Option>, - #[serde( - default, - serialize_with = "serialize_json_as_opt_string", - deserialize_with = "deserialize_json_as_opt_string" - )] + #[serde(default, serialize_with = "serialize_json_as_opt_string", deserialize_with = "deserialize_json_as_opt_string")] pub audio: Option>, #[serde(default)] pub bitrate: u32, @@ -99,7 +91,7 @@ pub struct VideoStreamDetailProperties { pub status: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct VideoStreamProperties { #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub name: Arc, @@ -215,7 +207,7 @@ pub struct SeriesStreamDetailProperties { pub episodes: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct SeriesStreamProperties { #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub name: Arc,