diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c59d22e4..4fc8919ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,78 +1,68 @@ # Changelog -# 3.3.0 (2025-12-xx) -- !BREAKING CHANGE! To align input definitions with the SourceEditor, inputs are now defined globally in the inputs section of the config file. - Each source can reference one or more inputs by their name in the inputs attribute. -- !BREAKING CHANGE! Due to some heavy refactoring, the old data format is invalid. You need to clean your data folder and update the playlists. -- !BREAKING CHANGE! B+Tree storage format has changed to a more efficient Slotted Page architecture. -- **B+Tree Storage Engine Optimizations**: - - Transitioned to a **Slotted Page Architecture**, significantly improving space utilization and allowing for variable-length keys. - - Implemented **Adaptive LZ4 Compression** for stored values, optimizing disk footprint. - - Refactored I/O layer for **Atomic Writes** and file locking, ensuring data integrity during parallel access or power loss. - - Introduced **B+Tree Compaction** to reclaim space after deletions or mass updates. - - Added **Batch Upsert** functionality for much higher throughput during mass inserts/updates. -- !BREAKING CHANGE! config.yml threads attribute is now renamed to process_parallel and is a boolean (true or false). -- !BREAKING CHANGE! config.yml adds a reverse proxy config field rewrite_secret to keep resource URLs valid after restart. -- !BREAKING CHANGE! removed `forced_retry_interval_secs`. -- !BREAKING CHANGE! `name` attribute is mandatory for input type batch. The name attribute is used for playlist `uuid` and needs to be stable. The first alias is renamed with the input `name` attribute. -- !BREAKING CHANGE! Redesigned Favorites handling: replaced the implicit `create_alias` flag with an explicit `add_favourite(group_name)` script function. -- Mapping & Filtering Enhancements: - - Integrated `match_as_ascii` flag for robust, accent-independent text matching (e.g., matching "Cinema" against "Cinéma"). - - `match_as_ascii` is supported in mapping filters, mapper scripts, and favorites. - - `ValueProvider` and `ValueAccessor` now support on-the-fly deunicoding. -- Resource-Cache: - - Avoid blocking the runtime when warming the cache. - - Async cache persistence write pipeline so response caching no longer blocks the runtime. - - Made cache storage more robust. Incomplete downloads will be deleted from cache. -- File Operations - - Normalize FileLockManager paths so aliases share the same lock. - - Use async file operations for playlist persistence to avoid blocking the async runtime. - - JSON playlist/category writers (Xtream collections, user bouquets) now stream through Tokio I/O, so persisting these files no longer blocks the runtime. - - Playlist EPG exports now write via async file handles to avoid blocking during XML serialization. - - Config and API proxy save endpoints now serialize via Tokio I/O, so editing configs through the API no longer blocks runtime threads. - - Video download queue now uses async file I/O to keep the runtime responsive during large transfers -- M3U playlist exports now stream asynchronously to keep the runtime responsive. -- Shared stream burst buffer uses zero-copy data buffers to reduce memory usage. -- Added detailed shared-stream/buffer/provider logging to trace lag, cache persistence, and session/provider lifecycle events. -- Connection registration failures now trigger an explicit disconnect to prevent zombie sockets. -- API user DB persistence (merge/backup/store) now executes through async Tokio I/O so user-management APIs remain responsive without blocking. -- Playlist updates now use Tokio tasks instead of spawning per-source threads/runtimes, reducing CPU and memory overhead during large syncs. -- XMLTV timeshift responses stream asynchronously end-to-end to keep the Axum runtime responsive. -- main now uses #[tokio::main], removing manual runtime boilerplate and keeping every branch async end-to-end. -- Healthcheck CLI path now uses the async Reqwest client so startup checks no longer block a dedicated thread. -- Shared stream shutdown now drops registry locks before releasing provider handles to prevent cross-lock stalls. -- Added order: none support for group/channel sorting, allowing mappings to retain source order. -- Session tracking now matches repeated HLS segment connections by session token so a single user maintains one active connection count, even when new TCP sockets are opened. -- EPG icon URLs are now rewritten in reverse proxy mode. -- Short EPG is now served from local disk if available. -- WebUI API-User category selection implemented. -- Stream table "Copy-To-Clipboard" functions added. -- Refactored provider connection handling to avoid potential race conditions. -- WebUI: Added Panel API config view and new `/api/v1/config/sources` endpoint to persist + hot-reload `sources.yml`. -- Added exp_date field to inputs, aliases, and CSV batch files; accepts dates in "YYYY-MM-DD HH:MM:SS" format or Unix timestamps (seconds since epoch). -- Added cloudflare_header to reverse proxy disable_header settings. -- Added CPU usage to the WebUI view. -- Fixed race conditions during simultaneous access to shared streams. -- Fixed race conditions during simultaneous access by the same user. -- Added extended debug logging for client requests and ID chain (request/action/virtual) to trace stream resolution. -- Fixed xtream series/catchup lookups using the series-info virtual_id so episode requests now keep their own virtual_id/session. -- **NEW FEATURE `panel_api`** Added optional `panel_api` integration to renew expired accounts or provision new aliases when provider connections are exhausted. -- **NEW FEATURE: Local library Module** - Comprehensive local video file scanning and metadata management - - Recursive directory scanning with async tokio::fs operations - - Automatic classification (Movies vs TV Series) using configurable regex patterns - - Multi-source metadata resolution with priority: NFO files → TMDB API → filename parsing - - JSON-based metadata storage with UUID tracking and virtual ID management - - TMDB API integration with configurable rate limiting (default 250ms) - - NFO file reading/writing support (Kodi/Jellyfin/Emby/Plex compatible) - - Incremental scanning (only processes changed files based on modification timestamps) - - Orphaned entry cleanup for deleted files - - New CLI flags: `--scan-library`, `--force-library-rescan` - - New API endpoints: `POST /api/v1/library/scan`, `GET /api/v1/library/status` - - New input type: `library` for source.yml integration -- `kick_secs` added to config.yml `web_ui` config. Default 90 seconds, if a user is kicked from the `web_ui`, they can't connect for this duration. - This setting is also used for sleep-timed streams. -- **NEW FEATURE: Added new db-viewer options to print db content** - `tuliprox --dbx /opt/tuliprox/data/all_channels/xtream/video.db` - `tuliprox --dbm /opt/tuliprox/data/all_channels/m3u.db` +# 3.3.0 (2026-01-03) + +### ⚠️ Breaking Changes +- **Global Input Definitions**: To align input definitions with the SourceEditor, inputs are now defined globally in the `inputs` section of the config file. Each source can reference one or more inputs by their name in the `inputs` attribute. +- **Data Format Migration**: Due to heavy refactoring, the old data format is invalid. You need to clean your `data` folder and update the playlists. +- **B+Tree Storage Format**: Storage format has changed to a more efficient Slotted Page architecture. +- **Configuration Renames**: + - `threads` attribute in `config.yml` renamed to `process_parallel` (boolean). + - Added mandatory `rewrite_secret` to `reverse_proxy` config for stable resource URLs. + - Removed `forced_retry_interval_secs`. +- **Input Batch Changes**: `name` attribute is now mandatory for input type batch to ensure stable playlist UUIDs. +- **Favorites Redesign**: Replaced implicit `create_alias` with explicit `add_favourite(group_name)` script function. + +### 🌟 New Features +- **Discord Notifications**: Support for Discord notifications via webhooks with optional Handlebars templates. +- **Enhanced REST Messaging**: Support for custom HTTP methods, headers, and Handlebars templating. +- **Local Library Module**: Comprehensive local video file scanning and metadata management. + - Recursive scanning, automatic classification, and NFO/TMDB metadata resolution. + - Incremental scanning and virtual ID management. +- **Panel API Integration**: Optional integration to renew expired accounts or provision new aliases when connections are exhausted. +- **Playlist Caching**: Added `cache_duration` to inputs, allowing configurable provider playlist cache times during subsequent updates (e.g., `60s`, `5m` `12h`, `1d`). +- **Database Viewer**: New CLI flags `--dbx` and `--dbm` to inspect internal database content. + +### ⚙️ Engine & Storage Optimizations +- **Slotted Page Architecture**: Improved space utilization and support for variable-length keys. +- **Adaptive LZ4 Compression**: Optimized disk footprint for stored values. +- **Atomic I/O Layer**: Refactored for atomic writes and file locking, ensuring data integrity. +- **B+Tree Compaction**: Reclaim space after deletions or mass updates. +- **Batch Upsert**: Significantly higher throughput during mass inserts/updates. + +### 🔍 Mapping & Filtering Enhancements +- **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. + +### 💻 WebUI & API +- **Source Editor Integration**: Redesigned UI for global input management and hot-reloading. +- **Messaging Config View**: New UI for configuring Discord and enhanced REST settings. +- **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. + +### 🚀 Performance & Stability +- **Full Async Runtime**: Transitioned to `#[tokio::main]` and async I/O throughout the entire application. +- **Non-Blocking Operations**: Cache persistence, playlist exports, and config saves moved to async tasks to prevent runtime stalls. +- **Zero-Copy Buffers**: Reduced memory usage for shared stream burst buffers. +- **Improved Connection Handling**: Refactored provider registration to prevent zombie sockets and race conditions. +- **HLS Session Tracking**: Improved session matching to maintain correct active connection counts. +- **Resource Cache**: Avoid blocking runtime, async persistence, robust storage, incomplete downloads deleted. +- **File Operations**: Normalized FileLockManager paths, async playlist persistence, async JSON writers, async EPG exports, async config/API proxy saves, async video download queue. +- **M3U Exports**: Stream asynchronously. +- **Logging**: Detailed shared-stream/buffer/provider logging. +- **Connection Failures**: Explicit disconnect on registration failures. +- **API User DB**: Async persistence for user management APIs. +- **Playlist Updates**: Use Tokio tasks for reduced overhead. +- **XMLTV Timeshift**: Stream asynchronously. +- **Healthcheck CLI**: Uses async Reqwest client. +- **Shared Stream Shutdown**: Drops registry locks before releasing provider handles. +- **EPG Icon URLs**: Rewritten in reverse proxy mode. +- **Short EPG**: Served from local disk. +- **Client Requests**: Extended debug logging for client requests and ID chain. +- **XTream Fixes**: Fixed series/catchup lookups using `series-info virtual_id`. +- **Cloudflare Header**: Added `cloudflare_header` to reverse proxy `disable_header` settings. +- **Kick Seconds**: `kick_secs` added to `config.yml web_ui` config. # 3.2.0 (2025-11-14) - Added `name` attribute to Staged Input. diff --git a/Cargo.lock b/Cargo.lock index 60bccee6a..6ebd73ae7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.30" +version = "3.2.31" dependencies = [ "anyhow", "base64", @@ -1866,6 +1866,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "handlebars" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2711,6 +2727,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3961,7 +3992,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.30" +version = "3.2.31" dependencies = [ "base64", "bitflags 2.10.0", @@ -4535,7 +4566,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.30" +version = "3.2.31" dependencies = [ "arc-swap", "async-compression", @@ -4558,6 +4589,7 @@ dependencies = [ "flate2", "fs2", "futures", + "handlebars", "hyper", "hyper-util", "indexmap", diff --git a/README.md b/README.md index 56762df2c..a5314e101 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Be aware that different configurations (e.g. user bouquets) along the playlists ### 1.4 `messaging` `messaging` is an optional configuration for receiving messages. -Currently `telegram`, `rest` and `pushover.net` is supported. +Currently `telegram`, `discord`, `rest` and `pushover.net` is supported. Messaging is Opt-In, you need to set the `notify_on` message types which are - `info` @@ -144,13 +144,49 @@ messaging: chat_ids: - '' - ':' + discord: + url: '' + template: '{"content": "{{message}}"}' # optional handlebars template rest: - url: '' + url: '' + method: 'POST' # optional, default POST + headers: + - 'Content-Type: application/json' + template: '{"text": "{{message}}"}' # optional handlebars template pushover: token: user: url: `optional`, default is `https://api.pushover.net/1/messages.json` + +### 1.4.1 Messaging Templating +For `discord` and `rest` messaging, you can use [Handlebars](https://handlebarsjs.com/) templates to format the message body. + +**Context Variables**: +- `message`: The raw message content or event summary. +- `kind`: The type of notification (`info`, `stats`, `error`, `watch`). +- `timestamp`: Current UTC timestamp in RFC3339 format. +- `event`: If the message is a JSON string (like `watch` events), it is parsed and available as an object. + +**Example REST Template (JSON)**: +```handlebars +{ + "summary": "Tuliprox {{kind}}: {{message}}", + "occurred_at": "{{timestamp}}" +} +``` + +**Example Discord Template (Complex Embed)**: +```handlebars +{ + "content": "Tuliprox Notification", + "embeds": [{ + "title": "Event: {{kind}}", + "description": "{{message}}", + "color": 3447003, + "footer": { "text": "Reported at {{timestamp}}" } + }] +} ``` For more information: [Telegram bots](https://core.telegram.org/bots/tutorial) @@ -784,6 +820,11 @@ Each input has the following attributes: - `username` only mandatory for type `xtream` - `password` only mandatory for type `xtream` - `panel_api` _optional_ for provider panel api operations +- `cache_duration` (_optional_): Playlist cache duration. + Supported units are `s`, `m`, `h`, and `d` (seconds, minutes, hours, days). + Examples: `12h`, `1d`, `30m`. + If `cache_duration` is set, the cached provider playlist stored on disk is reused + for subsequent updates instead of downloading it again. - `exp_date` optional, is a date as "YYYY-MM-DD HH:MM:SS" format like `2028-11-30 12:34:12` or Unix timestamp (seconds since epoch) - `options` is optional, + `xtream_skip_live` true or false, live section can be skipped. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b6f197655..e54ca8fb2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.30" +version = "3.2.31" edition = "2021" rust-version = "1.87.0" @@ -8,6 +8,7 @@ rust-version = "1.87.0" [dependencies] lol_html = "2.7" +handlebars = "6" shared = { version = "3", path = "../shared" } serde = { version = "1.0", features = ["derive", "rc"] } serde-saphyr = "0.0.12" diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index 4514a9e69..62779ecaa 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -866,6 +866,7 @@ mod tests { exp_date: None, t_batch_url: None, panel_api: None, + cache_duration_seconds: 0, } } diff --git a/backend/src/messaging.rs b/backend/src/messaging.rs index 54d6cd44b..de2a237a1 100644 --- a/backend/src/messaging.rs +++ b/backend/src/messaging.rs @@ -1,27 +1,89 @@ -use std::borrow::Cow; use crate::model::MessagingConfig; use crate::utils::{telegram_create_instance, telegram_send_message, SendMessageOption, SendMessageParseMode}; use log::{debug, error}; -use reqwest::header; +use reqwest::{header, Method}; use shared::model::MsgKind; use shared::utils::json_str_to_markdown; +use handlebars::Handlebars; +use serde_json::{json, Value}; +use chrono::Utc; +use std::borrow::Cow; +use std::str::FromStr; fn is_enabled(kind: MsgKind, cfg: &MessagingConfig) -> bool { cfg.notify_on.contains(&kind) } -async fn send_http_post_request(client: &reqwest::Client, msg: &str, messaging: &MessagingConfig) { +fn render_template(template: Option<&str>, msg: &str, kind: MsgKind) -> String { + let hb = Handlebars::new(); + let timestamp = Utc::now().to_rfc3339(); + + let mut data = json!({ + "message": msg, + "kind": kind.to_string(), + "timestamp": timestamp, + }); + + if let Ok(json_val) = serde_json::from_str::(msg) { + if let Some(obj) = data.as_object_mut() { + obj.insert("event".to_string(), json_val); + } + } + + match template { + Some(t) => { + match hb.render_template(t, &data) { + Ok(rendered) => rendered, + Err(e) => { + error!("Failed to render template: {e}"); + msg.to_string() + } + } + } + None => msg.to_string(), + } +} + +async fn send_rest_message(client: &reqwest::Client, msg: &str, kind: MsgKind, messaging: &MessagingConfig) { if let Some(rest) = &messaging.rest { - let data = msg.to_owned(); + let body = render_template(rest.template.as_deref(), msg, kind); + let method = Method::from_str(&rest.method).unwrap_or(Method::POST); + + let mut rb = client.request(method, &rest.url); + + let has_content_type = rest.headers.keys().any(|k| k.eq_ignore_ascii_case("content-type")); + if !has_content_type { + rb = rb.header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()); + } + + for (key, value) in &rest.headers { + rb = rb.header(key, value); + } + + match rb.body(body).send().await { + Ok(_) => debug!("Message sent successfully to rest api"), + Err(e) => error!("Message wasn't sent to rest api because of: {e}"), + } + } +} + +async fn send_discord_message(client: &reqwest::Client, msg: &str, kind: MsgKind, messaging: &MessagingConfig) { + if let Some(discord) = &messaging.discord { + let body = if let Some(template) = &discord.template { + render_template(Some(template), msg, kind) + } else { + json!({ "content": msg }).to_string() + }; + match client - .post(&rest.url) + .post(&discord.url) .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) - .body(data) + .body(body) .send() .await { - Ok(_) => debug!("Text message sent successfully to rest api"), - Err(e) => error!("Text message wasn't sent to rest api because of: {e}"), + Ok(_) => debug!("Message sent successfully to Discord"), + Err(e) => error!("Message wasn't sent to Discord because of: {e}"), } } } @@ -78,9 +140,10 @@ async fn dispatch_send_message(client: &reqwest::Client, kind: MsgKind, cfg: Opt if let Some(messaging) = cfg { if is_enabled(kind, messaging) { tokio::join!( - send_telegram_message(client, msg, messaging, json), - send_http_post_request(client, msg, messaging), - send_pushover_message(client, msg, messaging) + send_telegram_message(client, msg, messaging, json), + send_rest_message(client, msg, kind, messaging), + send_pushover_message(client, msg, messaging), + send_discord_message(client, msg, kind, messaging) ); } } @@ -93,3 +156,34 @@ pub async fn send_message_json(client: &reqwest::Client, kind: MsgKind, cfg: Opt pub async fn send_message(client: &reqwest::Client, kind: MsgKind, cfg: Option<&MessagingConfig>, msg: &str) { dispatch_send_message(client, kind, cfg, msg, false).await; } + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::MsgKind; + + #[test] + fn test_render_template_simple() { + let msg = "Hello World"; + let kind = MsgKind::Info; + let rendered = render_template(Some("Message: {{message}}, Kind: {{kind}}"), msg, kind); + assert!(rendered.contains("Message: Hello World")); + assert!(rendered.contains("Kind: Info")); + } + + #[test] + fn test_render_template_json() { + let msg = r#"{"name": "test", "value": 123}"#; + let kind = MsgKind::Watch; + let rendered = render_template(Some("Added: {{event.name}}"), msg, kind); + assert_eq!(rendered, "Added: test"); + } + + #[test] + fn test_render_template_none() { + let msg = "Hello World"; + let kind = MsgKind::Info; + let rendered = render_template(None, msg, kind); + assert_eq!(rendered, "Hello World"); + } +} diff --git a/backend/src/model/config/messaging.rs b/backend/src/model/config/messaging.rs index a9855b55b..6f8720462 100644 --- a/backend/src/model/config/messaging.rs +++ b/backend/src/model/config/messaging.rs @@ -1,4 +1,4 @@ -use shared::model::{MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; +use shared::model::{DiscordMessagingConfigDto, MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; use crate::model::macros; #[derive(Debug, Clone)] @@ -32,21 +32,64 @@ impl From<&TelegramMessagingConfig> for TelegramMessagingConfigDto { #[derive(Debug, Clone)] pub struct RestMessagingConfig { pub url: String, + pub method: String, + pub headers: std::collections::HashMap, + pub template: Option, } macros::from_impl!(RestMessagingConfig); impl From<&RestMessagingConfigDto> for RestMessagingConfig { fn from(dto: &RestMessagingConfigDto) -> Self { + let mut headers = std::collections::HashMap::new(); + for h in &dto.headers { + if let Some((k, v)) = h.split_once(':') { + headers.insert(k.trim().to_string(), v.trim().to_string()); + } + } Self { url: dto.url.clone(), + method: dto.method.clone().unwrap_or_else(|| "POST".to_string()), + headers, + template: dto.template.clone(), } } } impl From<&RestMessagingConfig> for RestMessagingConfigDto { - fn from(instance: &RestMessagingConfig) -> Self { + fn from(model: &RestMessagingConfig) -> Self { + let headers = model.headers.iter() + .map(|(k, v)| format!("{k}: {v}")) + .collect(); + Self { + url: model.url.clone(), + method: Some(model.method.clone()), + headers, + template: model.template.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct DiscordMessagingConfig { + pub url: String, + pub template: Option, +} + +macros::from_impl!(DiscordMessagingConfig); +impl From<&DiscordMessagingConfigDto> for DiscordMessagingConfig { + fn from(dto: &DiscordMessagingConfigDto) -> Self { + Self { + url: dto.url.clone(), + template: dto.template.clone(), + } + } +} + +impl From<&DiscordMessagingConfig> for DiscordMessagingConfigDto { + fn from(instance: &DiscordMessagingConfig) -> Self { Self { url: instance.url.clone(), + template: instance.template.clone(), } } } @@ -85,6 +128,7 @@ pub struct MessagingConfig { pub telegram: Option, pub rest: Option, pub pushover: Option, + pub discord: Option, } macros::from_impl!(MessagingConfig); @@ -95,6 +139,7 @@ impl From<&MessagingConfigDto> for MessagingConfig { telegram: dto.telegram.as_ref().map(Into::into), rest: dto.rest.as_ref().map(Into::into), pushover: dto.pushover.as_ref().map(Into::into), + discord: dto.discord.as_ref().map(Into::into), } } } @@ -106,6 +151,7 @@ impl From<&MessagingConfig> for MessagingConfigDto { telegram: instance.telegram.as_ref().map(Into::into), rest: instance.rest.as_ref().map(Into::into), pushover: instance.pushover.as_ref().map(Into::into), + discord: instance.discord.as_ref().map(Into::into), } } } \ No newline at end of file diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 7a2a80425..9592e5eb6 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -358,7 +358,7 @@ async fn process_source(client: &reqwest::Client, app_config: Arc, so // Download the playlist for input let (playlist_groups, mut error_list) = { broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name)); - // Caching Logic Integrated + // Caching Logic let (downloaded_playlist, mut download_err, was_cached) = playlist_download_from_input(client, &app_config, input).await; let (playlist, error) = if was_cached { diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 7f622cdff..3e7fb6bbd 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.30" +version = "3.2.31" edition = "2021" rust-version = "1.87.0" [dependencies] -shared = { version = "3.2.30", path = "../shared" } +shared = { version = "3.2.31", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 111049068..a37212216 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -356,7 +356,9 @@ "PANEL_STATUS_DISABLED": "Disabled", "VALIDATION": "Validation", "TEMPLATES": "Templates", - "CACHE_DURATION": "Cache duration" + "CACHE_DURATION": "Cache duration", + "BODY_TEMPLATE": "Body Template", + "WEBHOOK_URL": "Webhook URL" }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" @@ -750,4 +752,4 @@ "ZM": "Zambia", "ZW": "Zimbabwe" } -} +} \ No newline at end of file diff --git a/frontend/src/app/components/config/messaging_config_view.rs b/frontend/src/app/components/config/messaging_config_view.rs index 6216cbb92..461a92126 100644 --- a/frontend/src/app/components/config/messaging_config_view.rs +++ b/frontend/src/app/components/config/messaging_config_view.rs @@ -1,8 +1,8 @@ use crate::app::components::config::config_page::{ConfigForm, LABEL_MESSAGING_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::{Card, Chip, RadioButtonGroup}; -use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, config_field_empty, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; -use shared::model::{MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; +use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, config_field_empty, config_field_hide, config_field_optional, config_field_optional_hide, edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; +use shared::model::{DiscordMessagingConfigDto, MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto}; use std::rc::Rc; use std::str::FromStr; use yew::prelude::*; @@ -19,6 +19,12 @@ const LABEL_MARKDOWN: &str = "LABEL.MARKDOWN"; const LABEL_URL: &str = "LABEL.URL"; const LABEL_TOKEN: &str = "LABEL.TOKEN"; const LABEL_USER: &str = "LABEL.USER"; +const LABEL_DISCORD: &str = "LABEL.DISCORD"; +const LABEL_METHOD: &str = "LABEL.METHOD"; +const LABEL_HEADERS: &str = "LABEL.HEADERS"; +const LABEL_BODY_TEMPLATE: &str = "LABEL.BODY_TEMPLATE"; +const LABEL_WEBHOOK_URL: &str = "LABEL.WEBHOOK_URL"; +const LABEL_ADD_HEADER: &str = "LABEL.ADD_HEADER"; generate_form_reducer!( state: TelegramMessagingConfigFormState { form: TelegramMessagingConfigDto }, @@ -35,6 +41,18 @@ generate_form_reducer!( action_name: RestMessagingConfigFormAction, fields { Url => url: String, + Method => method: Option, + Headers => headers: Vec, + Template => template: Option, + } +); + +generate_form_reducer!( + state: DiscordMessagingConfigFormState { form: DiscordMessagingConfigDto }, + action_name: DiscordMessagingConfigFormAction, + fields { + Url => url: String, + Template => template: Option, } ); @@ -79,6 +97,12 @@ pub fn MessagingConfigView() -> Html { modified: false, }); + let discord_state: UseReducerHandle = + use_reducer(|| DiscordMessagingConfigFormState { + form: DiscordMessagingConfigDto::default(), + modified: false, + }); + let messaging_state: UseReducerHandle = use_reducer(|| MessagingConfigFormState { form: MessagingConfigDto::default(), @@ -104,24 +128,28 @@ pub fn MessagingConfigView() -> Html { let telegram_state = telegram_state.clone(); let rest_state = rest_state.clone(); let pushover_state = pushover_state.clone(); + let discord_state = discord_state.clone(); - let deps = ( + let dependencies = ( messaging_state.modified, telegram_state.modified, rest_state.modified, pushover_state.modified, + discord_state.modified, messaging_state, telegram_state, rest_state, pushover_state, + discord_state, ); - use_effect_with(deps, move |(mm, tm, rm, pm, m, t, r, p)| { + use_effect_with(dependencies, move |(mm, tm, rm, pm, dm, m, t, r, p, d)| { let mut form = m.form.clone(); form.telegram = Some(t.form.clone()); form.rest = Some(r.form.clone()); form.pushover = Some(p.form.clone()); + form.discord = Some(d.form.clone()); - let modified = *mm || *tm || *rm || *pm; + let modified = *mm || *tm || *rm || *pm || *dm; on_form_change.emit(ConfigForm::Messaging(modified, form)); }); } @@ -157,6 +185,13 @@ pub fn MessagingConfigView() -> Html { || () }); + let discord_state = discord_state.clone(); + let discord_cfg = msg_config.discord.as_ref().map_or_else(DiscordMessagingConfigDto::default, |t| t.clone()); + use_effect_with((discord_cfg, config_view_ctx.edit_mode.clone()), move |(discord_cfg, _mode)| { + discord_state.dispatch(DiscordMessagingConfigFormAction::SetAll(discord_cfg.clone())); + || () + }); + use_effect_with((msg_config, config_view_ctx.edit_mode.clone()), move |(msg_config, _mode)| { msg_state.dispatch(MessagingConfigFormAction::SetAll(msg_config.clone())); || () @@ -199,6 +234,8 @@ pub fn MessagingConfigView() -> Html {

{translate.t(LABEL_REST)}

{ config_field!(entry, translate.t(LABEL_URL), url) } + { config_field_optional_hide!(entry, translate.t(LABEL_METHOD), method) } + { config_field_optional!(entry, translate.t(LABEL_BODY_TEMPLATE), template) }
}, None => html! { @@ -209,6 +246,22 @@ pub fn MessagingConfigView() -> Html { }, }; + let render_discord = |discord: Option<&DiscordMessagingConfigDto>| match discord { + Some(entry) => html! { + +

{translate.t(LABEL_DISCORD)}

+ { config_field!(entry, translate.t(LABEL_WEBHOOK_URL), url) } + { config_field_optional!(entry, translate.t(LABEL_BODY_TEMPLATE), template) } +
+ }, + None => html! { + +

{translate.t(LABEL_DISCORD)}

+ { config_field_empty!(translate.t(LABEL_WEBHOOK_URL)) } +
+ }, + }; + let render_pushover = |pushover: Option<&PushoverMessagingConfigDto>| match pushover { Some(entry) => html! { @@ -249,6 +302,7 @@ pub fn MessagingConfigView() -> Html { {render_telegram(msg_state.form.telegram.as_ref())} {render_rest(msg_state.form.rest.as_ref())} {render_pushover(msg_state.form.pushover.as_ref())} + {render_discord(msg_state.form.discord.as_ref())} } @@ -290,6 +344,9 @@ pub fn MessagingConfigView() -> Html {

{translate.t(LABEL_REST)}

{ edit_field_text!(rest_state, translate.t(LABEL_URL), url, RestMessagingConfigFormAction::Url) } + { edit_field_text_option!(rest_state, translate.t(LABEL_METHOD), method, RestMessagingConfigFormAction::Method) } + { edit_field_list!(rest_state, translate.t(LABEL_HEADERS), headers, RestMessagingConfigFormAction::Headers, translate.t(LABEL_ADD_HEADER)) } + { edit_field_text_option!(rest_state, translate.t(LABEL_BODY_TEMPLATE), template, RestMessagingConfigFormAction::Template) }
@@ -298,6 +355,12 @@ pub fn MessagingConfigView() -> Html { { edit_field_text!(pushover_state, translate.t(LABEL_TOKEN), token, PushoverMessagingConfigFormAction::Token, true) } { edit_field_text!(pushover_state, translate.t(LABEL_USER), user, PushoverMessagingConfigFormAction::User) } + + +

{translate.t(LABEL_DISCORD)}

+ { edit_field_text!(discord_state, translate.t(LABEL_WEBHOOK_URL), url, DiscordMessagingConfigFormAction::Url) } + { edit_field_text_option!(discord_state, translate.t(LABEL_BODY_TEMPLATE), template, DiscordMessagingConfigFormAction::Template) } +
} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 008d6fc27..0e937ad6b 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "3.2.30" +version = "3.2.31" edition = "2021" rust-version = "1.87.0" diff --git a/shared/src/model/config/messaging.rs b/shared/src/model/config/messaging.rs index 1c593aaed..d1861a21a 100644 --- a/shared/src/model/config/messaging.rs +++ b/shared/src/model/config/messaging.rs @@ -21,9 +21,26 @@ impl TelegramMessagingConfigDto { #[serde(deny_unknown_fields)] pub struct RestMessagingConfigDto { pub url: String, + pub method: Option, + pub headers: Vec, + pub template: Option, } impl RestMessagingConfigDto { + pub fn is_empty(&self) -> bool { + self.url.is_empty() && self.headers.is_empty() && self.template.is_none() + } +} + +#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct DiscordMessagingConfigDto { + pub url: String, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub template: Option, +} + +impl DiscordMessagingConfigDto { pub fn is_empty(&self) -> bool { self.url.trim().is_empty() } @@ -57,6 +74,8 @@ pub struct MessagingConfigDto { pub rest: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pushover: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, } impl MessagingConfigDto { @@ -65,6 +84,7 @@ impl MessagingConfigDto { && (self.telegram.is_none() || self.telegram.as_ref().is_some_and(|c| c.is_empty())) && (self.rest.is_none() || self.rest.as_ref().is_some_and(|c| c.is_empty())) && (self.pushover.is_none() || self.pushover.as_ref().is_some_and(|c| c.is_empty())) + && (self.discord.is_none() || self.discord.as_ref().is_some_and(|c| c.is_empty())) } pub fn clean(&mut self) { @@ -77,6 +97,9 @@ impl MessagingConfigDto { if self.pushover.as_ref().is_some_and(|c| c.is_empty()) { self.pushover = None; } + if self.discord.as_ref().is_some_and(|c| c.is_empty()) { + self.discord = None; + } } } \ No newline at end of file