From 9c34d8e8cadbe145e16b73aeee93cabeb031f8ec Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:43:25 +0200 Subject: [PATCH] feat: invert staged input connection flow and credentials inheritance (#779) * feat: invert staged input connection flow --- .vscode/tasks.json | 38 +- CHANGELOG.md | 132 +- backend/Cargo.toml | 1 - backend/src/api/api_utils.rs | 4 +- backend/src/api/model/provider_config.rs | 3 +- .../src/api/model/provider_lineup_manager.rs | 2 + backend/src/api/sys_usage.rs | 14 +- backend/src/media_server/types.rs | 2 +- backend/src/model/config/input.rs | 464 +---- backend/src/model/config/source.rs | 27 +- backend/src/model/input_source.rs | 16 +- backend/src/processing/processor/playlist.rs | 355 ++-- .../src/processing/processor/stream_probe.rs | 2 +- backend/src/repository/alias_repository.rs | 2 +- backend/src/utils/network/m3u.rs | 7 +- backend/src/utils/network/xtream.rs | 133 +- docs/src/configuration/api-proxy.md | 8 +- docs/src/configuration/source.md | 108 +- frontend/public/assets/i18n/ar.json | 1824 +++++++++++++++++ frontend/public/assets/i18n/en.json | 30 +- frontend/public/assets/i18n/index.json | 3 +- frontend/public/assets/i18n/ru.json | 11 +- frontend/scss/_theme.scss | 16 + frontend/scss/app/_component.scss | 1 - .../scss/app/components/_keyvalue_editor.scss | 5 + frontend/scss/app/components/_toastr.scss | 7 +- .../playlist/input/_staged_input_view.scss | 32 - .../source_editor/_source_editor_block.scss | 36 + frontend/scss/themes/_theme-aurora.scss | 4 + .../scss/themes/_theme-banana-yellow.scss | 4 + frontend/scss/themes/_theme-bright.scss | 4 + .../scss/themes/_theme-clubroom-contrast.scss | 4 + .../scss/themes/_theme-cool-elegance.scss | 4 + .../themes/_theme-cvd-friendly-bright.scss | 4 + .../scss/themes/_theme-cvd-friendly-dark.scss | 4 + frontend/scss/themes/_theme-dark.scss | 4 + frontend/scss/themes/_theme-dopamine.scss | 4 + frontend/scss/themes/_theme-dracula.scss | 4 + .../scss/themes/_theme-graperoot-dark.scss | 4 + frontend/scss/themes/_theme-mermaidcore.scss | 4 + frontend/scss/themes/_theme-monokai.scss | 4 + frontend/scss/themes/_theme-nature-pure.scss | 4 + frontend/scss/themes/_theme-nord.scss | 4 + frontend/scss/themes/_theme-paper.scss | 4 + frontend/scss/themes/_theme-refined-dark.scss | 4 + .../scss/themes/_theme-sun-washed-soft.scss | 4 + .../scss/themes/_theme-vibrant-elegance.scss | 4 + .../scss/themes/_theme-vintage-neutral.scss | 4 + frontend/src/app/components/config/macros.rs | 19 + frontend/src/app/components/confirm_dialog.rs | 1 + frontend/src/app/components/custom_dialog.rs | 112 +- frontend/src/app/components/field_wrapper.rs | 37 + frontend/src/app/components/input.rs | 54 +- frontend/src/app/components/mod.rs | 5 +- frontend/src/app/components/number_input.rs | 35 +- .../playlist/input/input_type_view.rs | 1 + .../src/app/components/playlist/input/mod.rs | 5 +- .../playlist/input/staged_input_view.rs | 78 - .../app/components/playlist/input_table.rs | 18 +- frontend/src/app/components/popup_menu.rs | 30 +- .../components/source_editor/block_view.rs | 9 +- .../components/source_editor/editor_model.rs | 14 + .../components/source_editor/editor_view.rs | 393 +++- .../components/source_editor/input_form.rs | 678 +++--- .../app/components/source_editor/layout.rs | 130 +- .../components/source_editor/output_form.rs | 1 + .../src/app/components/source_editor/rules.rs | 52 +- .../app/components/source_editor/sidebar.rs | 26 +- .../components/source_editor/target_form.rs | 33 +- frontend/src/app/components/textarea.rs | 36 +- frontend/src/hooks/mod.rs | 2 + frontend/src/hooks/use_key_down.rs | 42 + shared/src/model/config/input.rs | 582 ++---- shared/src/model/config/macros.rs | 5 + .../src/model/config/media_server_catalog.rs | 269 +-- shared/src/model/config/source.rs | 186 +- shared/src/model/config/target.rs | 30 +- 77 files changed, 4156 insertions(+), 2090 deletions(-) create mode 100644 frontend/public/assets/i18n/ar.json delete mode 100644 frontend/scss/app/components/playlist/input/_staged_input_view.scss create mode 100644 frontend/src/app/components/field_wrapper.rs delete mode 100644 frontend/src/app/components/playlist/input/staged_input_view.rs create mode 100644 frontend/src/hooks/use_key_down.rs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 30524efdc..72715f240 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,7 +5,16 @@ "label": "backend: run", "type": "shell", "command": "cargo run -p tuliprox --bin tuliprox -- -s -p ${workspaceFolder}/config", - "problemMatcher": ["$rustc"], + "options": { + "env": { + "CARGO_PROFILE_DEV_DEBUG": "0", + "TULIPROX_HOME": "${workspaceFolder}", + "TULIPROX_PROXY_REWRITE_SECRET": "00112233445566778899aabbccddeeff", + "TULIPROX_HOST_IP": "127.0.0.1", + "TULIPROX_DOMAIN": "localhost" + } + }, + "problemMatcher": [], "group": "build", "presentation": { "panel": "dedicated", @@ -16,7 +25,16 @@ "label": "backend: watch", "type": "shell", "command": "cargo watch -i 'data/**' -i 'config/**' -x 'run -p tuliprox --bin tuliprox -- -s -p ${workspaceFolder}/config'", - "problemMatcher": ["$rustc"], + "options": { + "env": { + "CARGO_PROFILE_DEV_DEBUG": "0", + "TULIPROX_HOME": "${workspaceFolder}", + "TULIPROX_PROXY_REWRITE_SECRET": "00112233445566778899aabbccddeeff", + "TULIPROX_HOST_IP": "127.0.0.1", + "TULIPROX_DOMAIN": "localhost" + } + }, + "problemMatcher": [], "isBackground": true, "presentation": { "panel": "dedicated", @@ -28,7 +46,13 @@ "type": "shell", "command": "trunk serve", "options": { - "cwd": "${workspaceFolder}/frontend" + "cwd": "${workspaceFolder}/frontend", + "env": { + "CARGO_INCREMENTAL": "0", + "CARGO_PROFILE_DEV_DEBUG": "0", + "CARGO_BUILD_JOBS": "2", + "RUST_MIN_STACK": "33554432" + } }, "isBackground": true, "problemMatcher": [], @@ -47,7 +71,7 @@ "label": "test", "type": "shell", "command": "make test", - "problemMatcher": ["$rustc"], + "problemMatcher": [], "group": { "kind": "test", "isDefault": true @@ -57,14 +81,14 @@ "label": "lint (clippy)", "type": "shell", "command": "make lint", - "problemMatcher": ["$rustc"], + "problemMatcher": [], "group": "build" }, { "label": "lint-fix", "type": "shell", "command": "make lint-fix", - "problemMatcher": ["$rustc"] + "problemMatcher": [] }, { "label": "fmt", @@ -82,7 +106,7 @@ "label": "coverage (llvm-cov)", "type": "shell", "command": "cargo llvm-cov --workspace --html", - "problemMatcher": ["$rustc"] + "problemMatcher": [] }, { "label": "deps: deny", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cfb130aa..b8f90d5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ ## ⚠️ Breaking Changes +- **Staged inputs reworked into a first-class `staged` input type.** The old nested `staged:` block on + provider inputs (with `enabled`, `live_source`, `vod_source`, and `series_source`) has been removed. + A staged source is now its own input with `type: staged`. It points to one non-staged `m3u` / + `xtream` provider through `staged.for_input`, and `staged.clusters` selects which clusters (`live`, + `vod`, `series`) are loaded from the staged playlist. Clusters not selected there are loaded from the + provider input itself. The merged result is stored under the provider input, so playlist delivery and + stream/API routing continue to use the provider. + + Before: + + ```yaml + inputs: + - name: provider_a + type: xtream + url: http://provider-a.tv/player_api.php + username: alice + password: secret + staged: + url: http://lists.example/list1.m3u + type: m3u + live_source: staged + ``` + + After: + + ```yaml + inputs: + - name: provider_a + type: xtream + url: http://provider-a.tv/player_api.php + username: alice + password: secret + - name: provider_a_list + type: staged + url: http://lists.example/list1.m3u + staged: + provider: provider_a + clusters: [live] + ``` + + A staged input cannot be linked directly to a target. It must reference an existing non-staged `m3u` / + `xtream` provider input, and each provider can have at most one staged overlay. Staged inputs do not + use `priority`, `max_connections`, or `cache_duration`; the linked provider controls stream limits and + refresh cadence. + - **Case-insensitive EPG channel-id matching**: EPG channel-id matching is now case-insensitive (ASCII). Ids are no longer lowercased when parsed — output preserves the source's original case. Users whose sources provide MixedCase ids will see MixedCase ids in the M3U/XMLTV output instead of the previously-lowercased form; downstream players may @@ -129,6 +174,12 @@ - **Popup Menu Keyboard Dismissal**: - Popup menus now close when pressing `Escape`, in addition to clicking outside, improving keyboard accessibility. +- **Accessible Modal Dialogs**: + - All dialogs (confirm, content, and custom) now expose `role="dialog"` and `aria-modal`, trap keyboard focus + within the dialog while open (Tab/Shift+Tab cycle through its controls), close on `Escape` when dismissable, + and restore focus to the previously focused element when closed. Confirm dialogs also expose their title as an + accessible label. + - **Recoverable Error Boundary**: - Added an `ErrorBoundary` component that wraps each main view (dashboard, stats, streams, downloads, users, sources, playlists, EPG, RBAC, config) and the API-user playlist, so a recoverable failure shows a fallback with @@ -484,83 +535,12 @@ - `allowed_countries`: ISO-style country codes resolved through GeoIP. - The rules use OR semantics: any matching CIDR or country allows the request. -## 🚀 Performance +## 🛠 Maintenance -- **EPG Programme Lookup**: `EpgChannel::get_programme_with_limit` now binary-searches the sorted programme list - for the current window boundary instead of scanning linearly, reducing per-channel EPG lookup from `O(p)` to - `O(log p)` on large guides. The selected programmes are unchanged. -- **Item Type Field Access**: Reading the `Type` field during sorting and filtering no longer re-interns the - type label on every access. The five fixed type labels are now interned once and cached, so `Type`-keyed - rules return a cheap `Arc` clone instead of performing an interner hash-map lookup per item. -- **EPG XML Parsing**: The Web UI XMLTV parser no longer allocates an owned `String` for every XML element. It now - borrows the element name (via `from_utf8_lossy` without `to_string`) and tracks the active text element with a - small enum, removing one to two heap allocations per element on large EPG guides. -- **VOD/Recording Download Loop**: The per-chunk download-control check is now throttled to at most every 200ms - instead of running on every chunk. Pause/cancel/restart still take effect immediately via the existing - `control_notify` wakeups in the `select!` loop; the throttled poll only covers the rare race where a control - change fires mid-write, removing an unthrottled per-chunk lock read from multi-GB downloads. -- **Playlist Filtering**: `apply_filter_to_playlist` now pre-sizes each surviving-channel buffer to the source - group length instead of growing from a fixed small capacity, removing repeated reallocations while collecting - survivors for permissive filters (the common case where most channels pass). -- **Remote File Download**: `get_remote_content_as_file` no longer drains the response stream twice. It previously - consumed the whole body in an initial loop with no idle protection and then re-entered an idle-timeout `select!` - loop on an already-exhausted stream (dead code). The redundant first loop is removed, so the download runs through - a single loop and the idle timeout now actually guards the transfer. -- **Client Stream Keep-Alive Handling**: After a run of empty keep-alive chunks, `ClientStream` now backs off with a - short timer instead of immediately re-waking the task. A provider that streams an endless sequence of empty chunks - can no longer keep the task hot with back-to-back `Poll::Pending` re-wakes. -- **Library Orphan Cleanup**: `cleanup_orphaned` no longer reloads the entire metadata library for every orphaned - entry (and again for each thumbnail reference check). It now reads the library once, partitions entries into - orphaned and surviving, and deletes unreferenced thumbnails against a precomputed surviving-thumbnail set, turning - an O(orphans × library) scan into a single linear pass. -- **Series Expansion Merge**: Applying resolved series groups back into the in-memory playlist no longer re-scans - (and re-normalizes the title of) every existing group for each incoming group. A new batched `merge_groups`/ - `extend_playlist` path builds `(cluster, normalized_title)` and `(cluster, id)` indexes once and merges the whole - batch in a single linear pass, replacing the previous O(groups²) behavior on series-heavy inputs while moving - channels instead of cloning them. -- **Group Flatten Pre-Sizing**: `flatten_groups` now pre-allocates its merge buffer and category index to the - incoming group count instead of growing them from empty, reducing reallocation churn when assembling large target - playlists. -- **Custom Provider Playlist Streaming**: The custom provider playlist endpoint now streams its UI items lazily via - `stream_json_or_bin_response_stream` (like the target and input endpoints) instead of collecting every channel into - a second `Vec` and serializing the whole body at once, lowering peak memory and latency for large - provider imports. - -## 🧹 Maintainability - -- **Output Capability Descriptor**: Introduced `TargetType::capabilities()` — a single exhaustive table describing - whether each output format supports playlist filtering, EPG, and the in-memory cache. The EPG write/path sites now - derive "this format has no EPG" from the table instead of silent empty match arms, and a new `TargetOutput::filter()` - accessor co-locates the per-format filter lookup so the playlist writer no longer re-matches every variant. Adding an - output format now centers on one descriptor entry plus the format-specific writer. -- **Input Capability Descriptor**: Introduced `InputType::capabilities()` — a single exhaustive table describing each - input type's persistence family (`InputPersistence`), whether generic probing needs a provider connection, and - whether the custom-provider endpoint can serve it. Backend persist/load routing, the stream-probe requirement check, - and the custom-provider endpoint now derive from this table instead of re-listing `InputType` variants in parallel - `match` arms, so most new input variants only need a single descriptor entry and the dependent sites stay in sync. -- **ItemField Single-Source Binding**: The directly-bound `ItemField` variants (`Group`/`Name`/`Title`/`Url`/`Input`) - are now listed once and both `get_field_value` and `set_field_value` are generated from that single list via a - callback macro, so a new simple field can no longer be wired into the read half but forgotten in the write half (or - vice versa). The asymmetric `Genre`/`Type`/`Caption` cases stay explicit because their read/write behavior differs. -- **Resolver Stage Summaries**: The background VOD and series resolve-queueing loops now emit a single concise - debug summary (total / queued / expanded / skipped-by-filter / skipped-by-resolve-filter / elapsed) per stage in - addition to the existing detailed per-item logs, making operational analysis of the hot resolver paths easier - without raising the default log level. -- **InputType Family Helpers**: Added `InputType::is_batch()` and routed the scattered - `matches!(input_type, M3uBatch | XtreamBatch)` / `Xtream | XtreamBatch` / `M3u | M3uBatch` checks across the backend - and frontend through the centralized `is_batch()` / `is_xtream()` / `is_m3u()` methods, so the batch/family rules - live on the enum instead of being re-encoded (and risking silent drift) at every call site. -- **PlaylistItemType Classification Helpers**: Added `PlaylistItemType::is_video()` / `is_series()` and routed the - repeated `matches!(item_type, Video | LocalVideo)` and `matches!(item_type, Series | LocalSeries)` boolean checks - (filter evaluation, stream probing, trakt matching, playlist persistence, custom-provider filtering, metadata probe - routing) through them, so the family classification is defined once on the enum instead of re-encoded per call site. -- **XtreamCluster Behavior Centralization**: Added `XtreamCluster::info_action_and_id_field()` (and a backend - `xtream_cluster_category_collection()` helper) so the per-cluster `player_api` action / id-field and the - `cat_live`/`cat_vod`/`cat_series` collection names are derived once instead of being re-matched at each storage and - network call site, reducing the chance of a forgotten or inconsistent cluster arm. -- **Derived GeoIP Error Display**: `GeoIpUpdateError` now derives its `Display`/`Error` implementation via `thiserror` - instead of a hand-written `match self` formatter, so adding a variant only requires an `#[error(...)]` attribute next - to it rather than remembering to extend a separate formatting block (a classic source of stale or missing messages). +- **Shared `FieldWrapper` For Form Inputs**: + - Extracted the repeated label / field-id / `tp__input-wrapper` scaffolding from the `Input`, `NumberInput`, and + `TextArea` primitives into a single shared `FieldWrapper` component, reducing duplication while keeping the + rendered markup and behavior unchanged. ## 3.3.0 (2026-04-02) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9369c085e..ffc52fd57 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -87,7 +87,6 @@ ipnet = "2" strum.workspace = true strum_macros.workspace = true - [target.'cfg(unix)'.dependencies] libc = "0.2" [target.'cfg(not(target_os = "linux"))'.dependencies] diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 21ec256a7..c459fbbd4 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -2298,7 +2298,7 @@ async fn open_media_server_stream_for_input( .provider("media-server") .detail("media-server playback proxy is not implemented for this input type")); } - InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => { + InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library | InputType::Staged => { return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) .provider("media-server") .detail("playlist item is not backed by a media-server input")); @@ -3666,7 +3666,7 @@ async fn open_media_server_image_resource( .provider("media-server") .detail("media-server image proxy is not implemented for this input type")); } - InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => { + InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library | InputType::Staged => { return Err(MediaServerError::new(MediaServerErrorKind::MediaServerStreamOpenFailed) .provider("media-server") .detail("media-server image input is not backed by a media-server input")); diff --git a/backend/src/api/model/provider_config.rs b/backend/src/api/model/provider_config.rs index cebff753d..67523ea7f 100644 --- a/backend/src/api/model/provider_config.rs +++ b/backend/src/api/model/provider_config.rs @@ -374,7 +374,7 @@ impl Deref for ProviderConfigWrapper { #[cfg(test)] mod tests { use super::*; - use shared::model::{InputFetchMethod, InputType}; + use shared::model::{InputFetchMethod, InputType, StagedInputType}; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -396,6 +396,7 @@ mod tests { media_server: None, aliases: None, method: InputFetchMethod::default(), + staged_type: StagedInputType::default(), staged: None, exp_date: None, t_batch_url: None, diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index df38681ba..d175622a1 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -995,6 +995,7 @@ mod tests { }; use std::sync::atomic::AtomicU16; use tokio::time::{sleep, Duration}; + use shared::model::StagedInputType; macro_rules! should_available { ($lineup:expr, $provider_id:expr, $grace_period_timeout_secs: expr) => { @@ -1055,6 +1056,7 @@ mod tests { headers: HashMap::default(), options: None, method: InputFetchMethod::default(), + staged_type: StagedInputType::default(), staged: None, exp_date: None, t_batch_url: None, diff --git a/backend/src/api/sys_usage.rs b/backend/src/api/sys_usage.rs index e1bed7cd3..c9d56b380 100644 --- a/backend/src/api/sys_usage.rs +++ b/backend/src/api/sys_usage.rs @@ -100,7 +100,6 @@ impl DiskProbe { /// Return `(total_bytes, free_bytes_available_to_caller)`. /// Returns `(0, 0)` if the underlying syscall fails. - #[allow(clippy::useless_conversion)] fn sample(&self) -> (u64, u64) { #[cfg(unix)] { @@ -111,9 +110,16 @@ impl DiskProbe { if rc != 0 { return (0, 0); } - let bsize = stat.f_frsize as u64; - let total = u64::from(stat.f_blocks).saturating_mul(bsize); - let free = u64::from(stat.f_bavail).saturating_mul(bsize); + // 32-bit systems expose f_frsize as a narrower integer. + #[cfg(target_pointer_width = "32")] + let bsize = u64::from(stat.f_frsize); + + // 64-bit systems already expose f_frsize as u64. + #[cfg(target_pointer_width = "64")] + let bsize = stat.f_frsize; + + let total = stat.f_blocks.saturating_mul(bsize); + let free = stat.f_bavail.saturating_mul(bsize); (total, free) } #[cfg(windows)] diff --git a/backend/src/media_server/types.rs b/backend/src/media_server/types.rs index d575fdea8..f077325b1 100644 --- a/backend/src/media_server/types.rs +++ b/backend/src/media_server/types.rs @@ -30,7 +30,7 @@ impl TryFrom for MediaServerKind { InputType::Emby => Ok(Self::Emby), InputType::Jellyfin => Ok(Self::Jellyfin), InputType::Plex => Ok(Self::Plex), - InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => { + InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library | InputType::Staged => { Err("input type is not a media-server input") } } diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index eccf835e8..b9164b1f0 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -5,9 +5,9 @@ use log::warn; use shared::foundation::Filter; use shared::{apply_flags, create_bitset}; use shared::error::TuliproxError; -use shared::model::{ClusterSource, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, StagedInputDto, XtreamCluster}; +use shared::model::{ClusterFlags, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, StagedInputType}; use shared::utils::{ - get_credentials_from_url, is_non_blank_optional_string, parse_provider_scheme_url_parts, sanitize_sensitive_info, Internable, + get_credentials_from_url, get_credentials_from_url_str, is_non_blank_optional_string, parse_provider_scheme_url_parts, sanitize_sensitive_info, Internable, BATCH_SCHEME_PREFIX, PROVIDER_SCHEME_PREFIX, }; use shared::{check_input_connections, write_if_some}; @@ -118,6 +118,21 @@ pub struct MediaServerInputConfig { pub allow_relay: bool, } +#[derive(Debug, Clone)] +pub struct ConfigInputStaged { + pub for_input: Option>, + pub clusters: ClusterFlags, +} + +impl From<&ConfigInputStagedDto> for ConfigInputStaged { + fn from(dto: &ConfigInputStagedDto) -> Self { + Self { + for_input: dto.for_input.clone(), + clusters: dto.clusters, + } + } +} + impl From<&MediaServerInputConfigDto> for MediaServerInputConfig { fn from(dto: &MediaServerInputConfigDto) -> Self { let mut normalized = dto.clone(); @@ -186,85 +201,6 @@ impl InputUserInfo { } } -#[derive(Debug, Clone)] -pub struct StagedInput { - pub enabled: bool, - pub name: Arc, - pub url: String, - pub username: Option, - pub password: Option, - pub method: InputFetchMethod, - pub input_type: InputType, - pub headers: HashMap, - /// Provider configuration for failover support when using `provider://` scheme. - pub provider_config: Option>, - pub live_source: ClusterSource, - pub vod_source: ClusterSource, - pub series_source: ClusterSource, - pub cluster_sources_configured: bool, -} - -impl Default for StagedInput { - fn default() -> Self { - Self { - enabled: false, - name: Arc::default(), - url: String::new(), - username: None, - password: None, - method: InputFetchMethod::default(), - input_type: InputType::default(), - headers: HashMap::default(), - provider_config: None, - live_source: ClusterSource::Staged, - vod_source: ClusterSource::Input, - series_source: ClusterSource::Input, - cluster_sources_configured: false, - } - } -} - -impl StagedInput { - /// Returns the `ClusterSource` for the given `XtreamCluster`. - pub fn get_cluster_source(&self, cluster: XtreamCluster) -> ClusterSource { - match cluster { - XtreamCluster::Live => self.live_source, - XtreamCluster::Video => self.vod_source, - XtreamCluster::Series => self.series_source, - } - } -} - -macros::from_impl!(StagedInput); -impl From<&StagedInputDto> for StagedInput { - fn from(dto: &StagedInputDto) -> Self { - let resolve = |opt: Option, default: ClusterSource| -> ClusterSource { - opt.unwrap_or(default) - }; - let (live_default, vod_default, series_default) = if dto.input_type.is_m3u() { - (ClusterSource::Staged, ClusterSource::Input, ClusterSource::Input) - } else { - (ClusterSource::Staged, ClusterSource::Staged, ClusterSource::Staged) - }; - - Self { - enabled: dto.enabled, - name: dto.name.clone(), - input_type: dto.input_type, - url: dto.url.clone(), - username: dto.username.clone(), - password: dto.password.clone(), - method: dto.method, - headers: dto.headers.clone(), - provider_config: None, // Resolved later in ConfigInput::prepare() - live_source: resolve(dto.live_source, live_default), - vod_source: resolve(dto.vod_source, vod_default), - series_source: resolve(dto.series_source, series_default), - cluster_sources_configured: dto.live_source.is_some() || dto.vod_source.is_some() || dto.series_source.is_some(), - } - } -} - #[derive(Debug, Clone)] pub struct ConfigInputAlias { pub id: u16, @@ -313,7 +249,8 @@ pub struct ConfigInput { pub priority: i16, pub max_connections: u16, pub method: InputFetchMethod, - pub staged: Option, + pub staged_type: StagedInputType, + pub staged: Option, pub exp_date: Option, pub t_batch_url: Option, pub panel_api: Option, @@ -338,71 +275,6 @@ impl ConfigInput { .ok_or_else(|| TuliproxError::ConfigInput(format!("Failed to resolve provider config for {}", sanitize_sensitive_info(url)))) } - fn prepare_staged_input( - &mut self, - provider_configs: &[Arc], - used_provider_configs: &mut Vec>, - skip_live: bool, - skip_vod: bool, - skip_series: bool, - ) -> Result<(), TuliproxError> { - if let Some(staged_input) = &mut self.staged { - if staged_input.enabled { - if staged_input.url.starts_with(PROVIDER_SCHEME_PREFIX) { - let provider_cfg = Self::resolve_provider_config(&staged_input.url, provider_configs)?; - staged_input.provider_config = Some(provider_cfg.clone()); - used_provider_configs.push(provider_cfg); - } - - check_input_credentials!(staged_input, staged_input.input_type, false, true); - if !matches!(staged_input.input_type, InputType::M3u | InputType::Xtream) { - return Err(TuliproxError::ConfigInput(format!( - "Staged input can only be from type m3u or xtream (input: {}, staged: {})", - self.name, - staged_input.name - ))); - } - - if self.input_type.is_xtream() { - let live_uses_staged = matches!(staged_input.live_source, ClusterSource::Staged) && !skip_live; - let vod_uses_staged = matches!(staged_input.vod_source, ClusterSource::Staged) && !skip_vod; - let series_uses_staged = matches!(staged_input.series_source, ClusterSource::Staged) && !skip_series; - - if !live_uses_staged && !vod_uses_staged && !series_uses_staged { - return Err(TuliproxError::ConfigInput(format!( - "Staged input is enabled but no cluster source uses 'staged'; set at least one of live_source/vod_source/series_source to 'staged' (input: {}, staged: {})", - self.name, - staged_input.name - ))); - } - - if staged_input.input_type.is_m3u() && (vod_uses_staged || series_uses_staged) { - return Err(TuliproxError::ConfigInput(format!( - "Staged M3U input cannot provide VOD or Series clusters; use 'input' or 'skip' (input: {}, staged: {})", - self.name, - staged_input.name - ))); - } - } - - if self.input_type.is_m3u() { - if staged_input.cluster_sources_configured { - warn!( - "Input '{}': cluster source fields (live_source/vod_source/series_source) are ignored for M3U main inputs", - self.name - ); - } - staged_input.live_source = ClusterSource::Staged; - staged_input.vod_source = ClusterSource::Staged; - staged_input.series_source = ClusterSource::Staged; - staged_input.cluster_sources_configured = false; - } - } - } - - Ok(()) - } - fn prepare_aliases( &mut self, provider_configs: &[Arc], @@ -444,10 +316,20 @@ impl ConfigInput { #[inline] pub fn get_download_input_type(&self) -> InputType { - self.staged - .as_ref() - .filter(|staged| staged.enabled) - .map_or(self.input_type, |staged| staged.input_type) + self.input_type + } + + pub fn resolve_staged_download_type(&mut self) { + self.input_type = self.staged_type.input_type(); + + // For m3u inputs credentials may live in the URL itself. + let (username, password) = get_credentials_from_url_str(&self.url); + if username.is_some() { + self.username = username; + } + if password.is_some() { + self.password = password; + } } #[inline] @@ -507,12 +389,6 @@ impl ConfigInput { self.name ))); } - if self.staged.as_ref().is_some_and(|staged| staged.enabled) { - return Err(TuliproxError::ConfigInput(format!( - "media-server input does not support staged inputs (input: {})", - self.name - ))); - } if self.epg.is_some() { return Err(TuliproxError::ConfigInput(format!( "media-server input does not support EPG configuration (input: {})", @@ -587,7 +463,7 @@ impl ConfigInput { ))); } } - InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library => {} + InputType::M3u | InputType::Xtream | InputType::M3uBatch | InputType::XtreamBatch | InputType::Library | InputType::Staged => {} } Ok(()) @@ -623,10 +499,6 @@ impl ConfigInput { if self.enabled { check_input_credentials!(self, self.input_type, false, false); check_input_connections!(self, self.input_type, false); - let skip_live = self.has_flag(ConfigInputFlags::XtreamSkipLive); - let skip_vod = self.has_flag(ConfigInputFlags::XtreamSkipVod); - let skip_series = self.has_flag(ConfigInputFlags::XtreamSkipSeries); - self.prepare_staged_input(provider_configs, &mut used_provider_configs, skip_live, skip_vod, skip_series)?; self.apply_expiration(); self.prepare_aliases(provider_configs, &mut used_provider_configs)?; @@ -725,6 +597,7 @@ impl ConfigInput { priority: alias.priority, max_connections: alias.max_connections, method: self.method, + staged_type: self.staged_type, staged: None, exp_date: None, t_batch_url: None, @@ -813,8 +686,9 @@ impl From<&ConfigInputDto> for ConfigInput { priority: dto.priority, max_connections: dto.max_connections, method: dto.method, + staged_type: dto.staged_type, exp_date: dto.exp_date, - staged: dto.staged.as_ref().map(StagedInput::from), + staged: dto.staged.as_ref().map(ConfigInputStaged::from), t_batch_url: None, panel_api: dto.panel_api.as_ref().map(PanelApiConfig::from), cache_duration_seconds: dto.cache_duration_seconds, @@ -1240,49 +1114,6 @@ mod tests { assert!(err.to_string().contains("Malformed provider URL")); } - #[test] - fn test_prepare_fails_on_malformed_provider_url_in_staged_input() { - let mut input = ConfigInput { - name: "test_input".into(), - input_type: InputType::M3u, - url: "http://example.com/playlist.m3u".to_string(), - enabled: true, - staged: Some(StagedInput { - enabled: true, - name: "staged".into(), - input_type: InputType::M3u, - url: "provider:///bad".to_string(), - ..Default::default() - }), - ..Default::default() - }; - - let err = input.prepare(&[]).unwrap_err(); - assert!(err.to_string().contains("Malformed provider URL")); - } - - #[test] - fn test_prepare_ignores_disabled_staged_provider_url() { - let mut input = ConfigInput { - name: "test_input".into(), - input_type: InputType::M3u, - url: "http://example.com/playlist.m3u".to_string(), - enabled: true, - staged: Some(StagedInput { - enabled: false, - name: "staged".into(), - input_type: InputType::M3u, - url: "provider:///bad".to_string(), - ..Default::default() - }), - ..Default::default() - }; - - input - .prepare(&[]) - .expect("disabled staged input should not enforce provider URL validation"); - } - #[test] fn test_prepare_fails_on_malformed_provider_url_in_alias() { let mut input = ConfigInput { @@ -1309,209 +1140,76 @@ mod tests { } #[test] - fn test_get_download_input_type_uses_staged_when_enabled() { - let input = ConfigInput { - input_type: InputType::Xtream, - staged: Some(StagedInput { - enabled: true, - input_type: InputType::M3u, - ..Default::default() - }), - ..Default::default() - }; - - assert_eq!(input.get_download_input_type(), InputType::M3u); - } - - #[test] - fn test_get_download_input_type_uses_primary_when_staged_disabled() { - let input = ConfigInput { - input_type: InputType::Xtream, - staged: Some(StagedInput { - enabled: false, - input_type: InputType::M3u, - ..Default::default() - }), - ..Default::default() - }; - + fn test_get_download_input_type_returns_input_type() { + let input = ConfigInput { input_type: InputType::Xtream, ..Default::default() }; assert_eq!(input.get_download_input_type(), InputType::Xtream); } #[test] - fn test_staged_from_dto_defaults_m3u() { - let dto = StagedInputDto { - input_type: InputType::M3u, - ..StagedInputDto::default() - }; - let staged = StagedInput::from(&dto); - assert_eq!(staged.live_source, ClusterSource::Staged); - assert_eq!(staged.vod_source, ClusterSource::Input); - assert_eq!(staged.series_source, ClusterSource::Input); - } - - #[test] - fn test_staged_from_dto_defaults_xtream() { - let dto = StagedInputDto { - input_type: InputType::Xtream, - ..StagedInputDto::default() - }; - let staged = StagedInput::from(&dto); - assert_eq!(staged.live_source, ClusterSource::Staged); - assert_eq!(staged.vod_source, ClusterSource::Staged); - assert_eq!(staged.series_source, ClusterSource::Staged); - } - - #[test] - fn test_staged_from_dto_explicit_overrides() { - let dto = StagedInputDto { - input_type: InputType::Xtream, - live_source: Some(ClusterSource::Input), - vod_source: Some(ClusterSource::Skip), - series_source: Some(ClusterSource::Staged), - ..StagedInputDto::default() - }; - let staged = StagedInput::from(&dto); - assert_eq!(staged.live_source, ClusterSource::Input); - assert_eq!(staged.vod_source, ClusterSource::Skip); - assert_eq!(staged.series_source, ClusterSource::Staged); - } - - #[test] - fn test_get_cluster_source() { - let staged = StagedInput { - live_source: ClusterSource::Staged, - vod_source: ClusterSource::Input, - series_source: ClusterSource::Skip, - ..Default::default() - }; - assert_eq!(staged.get_cluster_source(XtreamCluster::Live), ClusterSource::Staged); - assert_eq!(staged.get_cluster_source(XtreamCluster::Video), ClusterSource::Input); - assert_eq!(staged.get_cluster_source(XtreamCluster::Series), ClusterSource::Skip); - } - - #[test] - fn test_staged_from_dto_tracks_cluster_sources_configured_flag() { - let dto = StagedInputDto { - input_type: InputType::Xtream, - live_source: Some(ClusterSource::Input), - ..StagedInputDto::default() - }; - let staged = StagedInput::from(&dto); - assert!(staged.cluster_sources_configured); - } - - #[test] - fn test_prepare_m3u_main_input_ignores_cluster_sources() { - let mut input = ConfigInput { - name: "m3u_main".into(), - input_type: InputType::M3u, - url: "http://main.example/playlist.m3u".to_string(), - enabled: true, - staged: Some(StagedInput { - enabled: true, - input_type: InputType::Xtream, - url: "http://staged.example".to_string(), - username: Some("u".to_string()), - password: Some("p".to_string()), - live_source: ClusterSource::Input, - vod_source: ClusterSource::Skip, - series_source: ClusterSource::Input, - cluster_sources_configured: true, - ..StagedInput::default() - }), + fn test_resolve_staged_download_type_does_not_inherit_provider_connection_profile() { + let mut staged = ConfigInput { + name: "staged_a".into(), + input_type: InputType::Staged, + url: "http://staged.example".to_string(), ..Default::default() }; - input.prepare(&[]).expect("prepare should succeed"); + staged.resolve_staged_download_type(); - let staged = input.staged.expect("staged should exist"); - assert_eq!(staged.live_source, ClusterSource::Staged); - assert_eq!(staged.vod_source, ClusterSource::Staged); - assert_eq!(staged.series_source, ClusterSource::Staged); - assert!(!staged.cluster_sources_configured); + assert_eq!(staged.input_type, InputType::M3u); + assert_eq!(staged.username, None); + assert_eq!(staged.password, None); + assert!(staged.headers.is_empty()); + assert!(InputFetchMethod::is_default(&staged.method)); + assert_eq!(staged.url, "http://staged.example"); } #[test] - fn test_prepare_enabled_staged_requires_at_least_one_staged_cluster_source() { - let mut input = ConfigInput { - name: "xtream_main".into(), - input_type: InputType::Xtream, - url: "http://main.example".to_string(), - username: Some("main_user".to_string()), - password: Some("main_pass".to_string()), - enabled: true, - staged: Some(StagedInput { - enabled: true, - input_type: InputType::Xtream, - url: "http://staged.example".to_string(), - username: Some("staged_user".to_string()), - password: Some("staged_pass".to_string()), - live_source: ClusterSource::Input, - vod_source: ClusterSource::Skip, - series_source: ClusterSource::Input, - ..StagedInput::default() - }), + fn test_resolve_staged_download_type_preserves_xtream_staged_type() { + let mut staged = ConfigInput { + name: "staged_a".into(), + input_type: InputType::Staged, + staged_type: StagedInputType::Xtream, + url: "http://staged.example".to_string(), ..Default::default() }; - let err = input - .prepare(&[]) - .expect_err("expected validation error for staged source selection"); - assert!(err.to_string().contains("no cluster source uses 'staged'")); + staged.resolve_staged_download_type(); + + assert_eq!(staged.input_type, InputType::Xtream); } #[test] - fn test_prepare_staged_m3u_rejects_vod_series_staged() { - let mut input = ConfigInput { - name: "xtream_main".into(), - input_type: InputType::Xtream, - url: "http://main.example".to_string(), - username: Some("main_user".to_string()), - password: Some("main_pass".to_string()), - enabled: true, - staged: Some(StagedInput { - enabled: true, - input_type: InputType::M3u, - url: "http://staged.example/playlist.m3u".to_string(), - vod_source: ClusterSource::Staged, - ..StagedInput::default() - }), + fn test_resolve_staged_download_type_keeps_own_credentials() { + let mut staged = ConfigInput { + name: "staged_a".into(), + input_type: InputType::Staged, + url: "http://staged.example".to_string(), + username: Some("own_user".to_string()), + password: Some("own_pass".to_string()), ..Default::default() }; - let err = input - .prepare(&[]) - .expect_err("expected staged M3U validation error for vod_source=staged"); - assert!(err.to_string().contains("Staged M3U input cannot provide VOD or Series")); + staged.resolve_staged_download_type(); + + assert_eq!(staged.username.as_deref(), Some("own_user")); + assert_eq!(staged.password.as_deref(), Some("own_pass")); } #[test] - fn test_prepare_staged_m3u_vod_staged_allowed_when_vod_skipped() { - let mut input = ConfigInput { - name: "xtream_main".into(), - input_type: InputType::Xtream, - url: "http://main.example".to_string(), - username: Some("main_user".to_string()), - password: Some("main_pass".to_string()), - enabled: true, - options: Some(ConfigInputOptions::from(&ConfigInputOptionsDto { - xtream_skip_vod: true, - ..ConfigInputOptionsDto::default() - })), - staged: Some(StagedInput { - enabled: true, - input_type: InputType::M3u, - url: "http://staged.example/playlist.m3u".to_string(), - vod_source: ClusterSource::Staged, - ..StagedInput::default() - }), + fn test_resolve_staged_download_type_m3u_extracts_url_credentials() { + let mut staged = ConfigInput { + name: "staged_a".into(), + input_type: InputType::Staged, + url: "http://staged.example/get.php?username=urluser&password=urlpass".to_string(), ..Default::default() }; - input - .prepare(&[]) - .expect("staged M3U vod_source=staged is valid when VOD is skipped"); + staged.resolve_staged_download_type(); + + assert_eq!(staged.input_type, InputType::M3u); + assert_eq!(staged.username.as_deref(), Some("urluser")); + assert_eq!(staged.password.as_deref(), Some("urlpass")); } #[test] diff --git a/backend/src/model/config/source.rs b/backend/src/model/config/source.rs index 41911c740..87feb7d41 100644 --- a/backend/src/model/config/source.rs +++ b/backend/src/model/config/source.rs @@ -414,7 +414,7 @@ macros::try_from_impl!(SourcesConfig); impl TryFrom<&SourcesConfigDto> for SourcesConfig { type Error = TuliproxError; fn try_from(dto: &SourcesConfigDto) -> Result { - let mut inputs = Vec::>::new(); + let mut inputs = Vec::::new(); let mut batch_files = Vec::::new(); let mut input_names = HashSet::new(); let provider: Vec<_> = dto.provider.as_ref() @@ -428,9 +428,22 @@ impl TryFrom<&SourcesConfigDto> for SourcesConfig { batch_files.push(path); } input_names.insert(input.name.clone()); - inputs.push(Arc::new(input)); + inputs.push(input); } + // Resolve staged playlist inputs to their configured download type. + // The provider link is kept for overlay routing and must not affect staged playlist fetching. + if inputs.iter().any(|input| input.input_type.is_staged()) { + for input in &mut inputs { + if !input.input_type.is_staged() { + continue; + } + input.resolve_staged_download_type(); + } + } + + let inputs: Vec> = inputs.into_iter().map(Arc::new).collect(); + let mut sources = Vec::new(); for source_dto in &dto.sources { // Validate that all input references exist @@ -457,6 +470,16 @@ impl SourcesConfig { self.sources.get(idx) } + pub(crate) fn get_staged_input_for_provider(&self, provider_name: &Arc) -> Option<&Arc> { + self.inputs.iter().find(|input| { + input + .staged + .as_ref() + .and_then(|staged| staged.for_input.as_ref()) + .is_some_and(|name| name == provider_name) + }) + } + pub fn get_target_by_id(&self, target_id: u16) -> Option> { for source in &self.sources { for target in &source.targets { diff --git a/backend/src/model/input_source.rs b/backend/src/model/input_source.rs index 99d2f259c..4fcf30656 100644 --- a/backend/src/model/input_source.rs +++ b/backend/src/model/input_source.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; use shared::model::InputFetchMethod; -use crate::model::{ConfigInput, ConfigProvider, StagedInput}; +use crate::model::{ConfigInput, ConfigProvider}; /// Represents an input source for fetching content. /// @@ -58,18 +58,4 @@ impl From<&ConfigInput> for InputSource { headers: input.headers.clone(), } } -} - -impl From<&StagedInput> for InputSource { - fn from(input: &StagedInput) -> Self { - Self { - name: input.name.clone(), - url: input.url.clone(), - provider: input.provider_config.clone(), - username: input.username.clone(), - password: input.password.clone(), - method: input.method, - headers: input.headers.clone(), - } - } } \ No newline at end of file diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 74408aa94..dd4ec219c 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -43,9 +43,9 @@ use shared::{ error::{get_errors_notify_message, TuliproxError}, foundation::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}, model::{ - ClusterSource, CounterModifier, FieldGetAccessor, FieldSetAccessor, InputStats, InputType, ItemField, - PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateProgressEvent, PlaylistStats, ProcessingOrder, - SourceStats, StreamProperties, TargetStats, UUIDType, XtreamCluster, + ClusterFlags, CounterModifier, FieldGetAccessor, FieldSetAccessor, InputStats, InputType, ItemField, + PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, ProcessingOrder, SourceStats, StreamProperties, + TargetStats, UUIDType, XtreamCluster, }, utils::{ create_alias_uuid, interner_gc, @@ -65,6 +65,7 @@ use tokio::{ sync::{Mutex, OwnedRwLockWriteGuard, RwLock}, task::JoinSet, }; +use shared::model::PlaylistUpdateProgressEvent; const PLAYLIST_UPDATE_MAX_DURATION_SECS: u64 = 3600; @@ -344,44 +345,11 @@ impl PlaylistDownloadResult { } } -/// Returns true when the input uses a hybrid download strategy: -/// M3U staged providing some clusters and Xtream main providing others. -fn is_hybrid_m3u_xtream(input: &ConfigInput) -> bool { - if !input.input_type.is_xtream() { - return false; - } - input.staged.as_ref().is_some_and(|s| s.enabled && s.input_type.is_m3u()) -} - fn collect_effective_skip_clusters(input: &ConfigInput) -> Vec { if !input.input_type.is_xtream() { return vec![]; } - - let mut skip_cluster = xtream::get_skip_cluster(input); - if let Some(staged) = input.staged.as_ref().filter(|staged| staged.enabled) { - for cluster in [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] { - if staged.get_cluster_source(cluster) == ClusterSource::Skip && !skip_cluster.contains(&cluster) { - skip_cluster.push(cluster); - } - } - } - skip_cluster -} - -fn hybrid_has_m3u_staged_cluster(input: &ConfigInput, skip_cluster: &[XtreamCluster]) -> bool { - if !is_hybrid_m3u_xtream(input) { - return false; - } - - input.staged.as_ref().is_some_and(|staged| { - [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] - .iter() - .any(|cluster| { - !skip_cluster.contains(cluster) - && staged.get_cluster_source(*cluster) == ClusterSource::Staged - }) - }) + xtream::get_skip_cluster(input) } async fn download_plex_media_server_playlist( @@ -423,6 +391,44 @@ fn filter_skipped_clusters_from_source(source: PlaylistSource, input: &ConfigInp PlaylistSource::filtered(source, skip_set) } +fn cluster_selected(cluster: XtreamCluster, clusters: ClusterFlags) -> bool { + match cluster { + XtreamCluster::Live => clusters.contains(ClusterFlags::Live), + XtreamCluster::Video => clusters.contains(ClusterFlags::Vod), + XtreamCluster::Series => clusters.contains(ClusterFlags::Series), + } +} + +fn apply_staged_overlay_groups( + provider_name: &Arc, + clusters: ClusterFlags, + provider_groups: Vec, + staged_groups: Vec, +) -> Vec { + let mut groups: Vec = provider_groups + .into_iter() + .filter(|group| !cluster_selected(group.xtream_cluster, clusters)) + .collect(); + + groups.extend( + staged_groups + .into_iter() + .filter(|group| cluster_selected(group.xtream_cluster, clusters)) + .map(|mut group| { + for item in &mut group.channels { + item.header.input_name = Arc::clone(provider_name); + } + group + }), + ); + + groups +} + +fn should_apply_staged_overlay(download_result: &PlaylistDownloadResult) -> bool { + !download_result.was_cached +} + #[allow(clippy::too_many_lines)] async fn playlist_download_from_input( client: &reqwest::Client, @@ -450,20 +456,14 @@ async fn playlist_download_from_input( Ok(true) => {} } - let hybrid = is_hybrid_m3u_xtream(input); let download_input_type = input.get_download_input_type(); - // Use per-cluster cache for effective Xtream downloads and hybrid M3U+Xtream inputs. - let use_per_cluster_cache = hybrid || download_input_type.is_xtream(); + // Use per-cluster cache for effective Xtream downloads. + let use_per_cluster_cache = download_input_type.is_xtream(); let mut xtream_clusters_to_download = Vec::new(); - let mut needs_m3u_download = false; let fully_cached = if use_per_cluster_cache { let skip_cluster = collect_effective_skip_clusters(input); - let (staged_candidates, main_candidates) = - xtream::partition_clusters_by_source(input, None, &skip_cluster); - - let mut xtream_cache_candidates = staged_candidates; - xtream_cache_candidates.extend(main_candidates); + let xtream_cache_candidates = xtream::requested_clusters(None, &skip_cluster); for cluster in xtream_cache_candidates { if !input_cache::is_cache_valid(&status, cluster.as_ref(), cache_duration) { @@ -471,14 +471,7 @@ async fn playlist_download_from_input( } } - if hybrid { - if hybrid_has_m3u_staged_cluster(input, &skip_cluster) { - needs_m3u_download = !input_cache::is_cache_valid(&status, "default", cache_duration); - } - xtream_clusters_to_download.is_empty() && !needs_m3u_download - } else { - xtream_clusters_to_download.is_empty() - } + xtream_clusters_to_download.is_empty() } else { input_cache::is_cache_valid(&status, "default", cache_duration) }; @@ -487,53 +480,7 @@ async fn playlist_download_from_input( return PlaylistDownloadResult::new(vec![], vec![], true, false); } - let (playlist, errors, persisted, m3u_error_count, xtream_error_count) = if hybrid { - let mut playlist = Vec::new(); - let mut all_errors = Vec::new(); - let mut m3u_error_count = 0usize; - let mut xtream_error_count = 0usize; - let mut m3u_added_groups = false; - - if needs_m3u_download { - if let Some(staged) = input.staged.as_ref() { - let staged_source: crate::model::InputSource = staged.into(); - let (m3u_playlist, m3u_errors) = - m3u::download_m3u_playlist_from_source(app_config, client, config, input, Some(staged_source)).await; - m3u_error_count = m3u_errors.len(); - m3u_added_groups = !m3u_playlist.is_empty(); - playlist.extend(m3u_playlist); - all_errors.extend(m3u_errors); - } else { - warn!( - "hybrid input '{}' requires a staged M3U cluster but none is present; skipping M3U download", - input.name - ); - m3u_error_count = 1; - } - } - - let mut xtream_persisted = false; - if !xtream_clusters_to_download.is_empty() { - let (xtream_playlist, xtream_errors, persisted) = xtream::download_xtream_playlist( - app_config, - client, - input, - Some(xtream_clusters_to_download.as_slice()), - ) - .await; - xtream_error_count = xtream_errors.len(); - playlist.extend(xtream_playlist); - all_errors.extend(xtream_errors); - xtream_persisted = persisted; - - if m3u_added_groups { - // Keep merged hybrid output in memory when staged M3U contributed groups. - xtream_persisted = false; - } - } - - (playlist, all_errors, xtream_persisted, m3u_error_count, xtream_error_count) - } else { + let (playlist, errors, persisted, _m3u_error_count, _xtream_error_count) = { match download_input_type { InputType::M3u => { let (p, e) = m3u::download_m3u_playlist(app_config, client, config, input).await; @@ -569,26 +516,22 @@ async fn playlist_download_from_input( 0, 0, ), + InputType::Staged => ( + vec![], + vec![TuliproxError::Download(format!( + "staged input '{}' was not resolved against a parent input", + input.name + ))], + false, + 0, + 0, + ), } }; // Update Status - let mut save_status = false; - if hybrid { - if needs_m3u_download { - let m3u_state = if m3u_error_count == 0 { ClusterState::Ok } else { ClusterState::Failed }; - input_cache::update_cluster_status(&mut status, "default", m3u_state); - save_status = true; - } - - if !xtream_clusters_to_download.is_empty() { - let state = if xtream_error_count == 0 { ClusterState::Ok } else { ClusterState::Failed }; - for cluster in &xtream_clusters_to_download { - input_cache::update_cluster_status(&mut status, cluster.as_ref(), state.clone()); - } - save_status = true; - } - } else if errors.is_empty() { + let save_status; + if errors.is_empty() { if use_per_cluster_cache { for cluster in &xtream_clusters_to_download { input_cache::update_cluster_status(&mut status, cluster.as_ref(), ClusterState::Ok); @@ -646,7 +589,7 @@ async fn process_source( let (mut playlist_groups, mut error_list) = { broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name)); - let (mut download_err, playlist, error) = download_input(ctx, input).await; + let (mut download_err, playlist, error) = download_input(ctx, input, false).await; if let Some(err) = error { broadcast_step( @@ -789,10 +732,23 @@ async fn load_cached_input_playlist( } } +#[allow(clippy::too_many_lines)] async fn download_input( ctx: &PlaylistProcessingContext, input: &Arc, + allow_staged_input: bool, ) -> (Vec, PlaylistSource, Option) { + if input.staged.is_some() && !allow_staged_input { + return (vec![], MemoryPlaylistSource::default().into_source(), None); + } + + let staged_overlay = if input.staged.is_none() { + let sources = ctx.config.sources.load(); + sources.get_staged_input_for_provider(&input.name).cloned() + } else { + None + }; + // Coordination Logic let need_download = !ctx.is_input_downloaded(&input.name).await; // Keep this lock for the whole critical section (download + persist/load + mark processed) @@ -860,8 +816,9 @@ async fn download_input( preloaded_playlist = Some((cached_playlist, None)); } } + let apply_staged_overlay = should_apply_staged_overlay(&playlist_download_result); - let (playlist, error) = if let Some(preloaded) = preloaded_playlist { + let (mut playlist, mut error) = if let Some(preloaded) = preloaded_playlist { preloaded } else if playlist_download_result.was_cached || playlist_download_result.persisted { match load_input_playlist(ctx, input, None).await { @@ -874,7 +831,28 @@ async fn download_input( (MemoryPlaylistSource::new(pl).into_source(), err) }; - let playlist = filter_skipped_clusters_from_source(playlist, input); + playlist = filter_skipped_clusters_from_source(playlist, input); + + if let Some(staged_input) = staged_overlay.filter(|_| apply_staged_overlay) { + let clusters = staged_input.staged.as_ref().map_or_else(ClusterFlags::all, |staged| staged.clusters); + let (mut staged_download_err, mut staged_playlist, staged_error) = + Box::pin(download_input(ctx, &staged_input, true)).await; + playlist_download_result.download_err.append(&mut staged_download_err); + if let Some(staged_error) = staged_error { + playlist_download_result.download_err.push(staged_error); + } else { + let provider_groups = playlist.take_groups(); + let staged_groups = staged_playlist.take_groups(); + let merged_groups = apply_staged_overlay_groups(&input.name, clusters, provider_groups, staged_groups); + let (merged_playlist, persist_error) = persist_input_playlist(&ctx.config, input, merged_groups).await; + playlist = MemoryPlaylistSource::new(merged_playlist).into_source(); + if error.is_none() { + error = persist_error; + } else if let Some(persist_error) = persist_error { + playlist_download_result.download_err.push(persist_error); + } + } + } if mark_as_processed { // Mark after persist/load so other workers only see this input as ready when data is usable. @@ -1686,9 +1664,8 @@ pub async fn exec_processing( #[cfg(test)] mod tests { use super::*; - use crate::model::StagedInput; use shared::foundation::{get_filter, ValueProvider}; - use shared::model::{PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; + use shared::model::{ClusterFlags, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; use shared::utils::Internable; fn item_with_props(props: StreamProperties) -> PlaylistItem { @@ -1748,79 +1725,26 @@ mod tests { } #[test] - fn hybrid_detection_requires_xtream_main_and_m3u_staged() { - let hybrid_input = ConfigInput { - name: "hybrid".intern(), + fn collect_effective_skip_clusters_uses_input_skip_flags() { + use crate::model::{ConfigInputFlags, ConfigInputOptions}; + let input = ConfigInput { + name: "skip_live".intern(), input_type: InputType::Xtream, - staged: Some(StagedInput { enabled: true, input_type: InputType::M3u, ..StagedInput::default() }), - ..ConfigInput::default() - }; - assert!(is_hybrid_m3u_xtream(&hybrid_input)); - - let non_hybrid_main_m3u = ConfigInput { - name: "main_m3u".intern(), - input_type: InputType::M3u, - staged: Some(StagedInput { enabled: true, input_type: InputType::Xtream, ..StagedInput::default() }), - ..ConfigInput::default() - }; - assert!(!is_hybrid_m3u_xtream(&non_hybrid_main_m3u)); - } - - #[test] - fn collect_effective_skip_clusters_includes_staged_skip_only_when_enabled() { - let input_with_enabled_staged = ConfigInput { - name: "enabled".intern(), - input_type: InputType::Xtream, - staged: Some(StagedInput { - enabled: true, - live_source: ClusterSource::Skip, - vod_source: ClusterSource::Input, - series_source: ClusterSource::Staged, - ..StagedInput::default() + options: Some(ConfigInputOptions { + flags: ConfigInputFlags::XtreamSkipLive.into(), + ..ConfigInputOptions::defaults().clone() }), ..ConfigInput::default() }; - let skip_enabled = collect_effective_skip_clusters(&input_with_enabled_staged); - assert!(skip_enabled.contains(&XtreamCluster::Live)); - assert!(!skip_enabled.contains(&XtreamCluster::Video)); - assert!(!skip_enabled.contains(&XtreamCluster::Series)); - - let input_with_disabled_staged = ConfigInput { - name: "disabled".intern(), - input_type: InputType::Xtream, - staged: Some(StagedInput { - enabled: false, - live_source: ClusterSource::Skip, - vod_source: ClusterSource::Skip, - series_source: ClusterSource::Skip, - ..StagedInput::default() - }), - ..ConfigInput::default() - }; - assert!(collect_effective_skip_clusters(&input_with_disabled_staged).is_empty()); - } - - #[test] - fn hybrid_m3u_detects_any_staged_cluster_selection() { - let hybrid = ConfigInput { - name: "hybrid".intern(), - input_type: InputType::Xtream, - staged: Some(StagedInput { - enabled: true, - input_type: InputType::M3u, - live_source: ClusterSource::Input, - vod_source: ClusterSource::Staged, - series_source: ClusterSource::Skip, - ..StagedInput::default() - }), - ..ConfigInput::default() - }; - assert!(hybrid_has_m3u_staged_cluster(&hybrid, &[])); - assert!(!hybrid_has_m3u_staged_cluster(&hybrid, &[XtreamCluster::Video])); + let skip = collect_effective_skip_clusters(&input); + assert!(skip.contains(&XtreamCluster::Live)); + assert!(!skip.contains(&XtreamCluster::Video)); + assert!(!skip.contains(&XtreamCluster::Series)); } #[test] fn filter_skipped_clusters_removes_cached_groups() { + use crate::model::{ConfigInputFlags, ConfigInputOptions}; let live_item = PlaylistItem { header: shared::model::PlaylistItemHeader { xtream_cluster: XtreamCluster::Live, @@ -1853,10 +1777,9 @@ mod tests { let input = ConfigInput { name: "skip_live".intern(), input_type: InputType::Xtream, - staged: Some(StagedInput { - enabled: true, - live_source: ClusterSource::Skip, - ..StagedInput::default() + options: Some(ConfigInputOptions { + flags: ConfigInputFlags::XtreamSkipLive.into(), + ..ConfigInputOptions::defaults().clone() }), ..ConfigInput::default() }; @@ -1867,6 +1790,56 @@ mod tests { assert_eq!(filtered_groups[0].xtream_cluster, XtreamCluster::Video); } + fn test_group(cluster: XtreamCluster, item_name: &str, input_name: &str) -> PlaylistGroup { + PlaylistGroup { + id: 1, + title: item_name.intern(), + xtream_cluster: cluster, + channels: vec![PlaylistItem { + header: PlaylistItemHeader { + name: item_name.intern(), + input_name: input_name.intern(), + xtream_cluster: cluster, + item_type: match cluster { + XtreamCluster::Live => PlaylistItemType::Live, + XtreamCluster::Video => PlaylistItemType::Video, + XtreamCluster::Series => PlaylistItemType::Series, + }, + ..Default::default() + }, + }], + } + } + + #[test] + fn staged_overlay_replaces_selected_clusters_and_rewrites_input_name() { + let provider_name = "provider".intern(); + let provider_groups = vec![ + test_group(XtreamCluster::Live, "provider-live", "provider"), + test_group(XtreamCluster::Video, "provider-vod", "provider"), + ]; + let staged_groups = vec![ + test_group(XtreamCluster::Live, "staged-live", "staged"), + test_group(XtreamCluster::Series, "staged-series", "staged"), + ]; + + let groups = + apply_staged_overlay_groups(&provider_name, ClusterFlags::Live, provider_groups, staged_groups); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].title.as_ref(), "provider-vod"); + assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider"); + assert_eq!(groups[1].title.as_ref(), "staged-live"); + assert_eq!(groups[1].channels[0].header.input_name.as_ref(), "provider"); + } + + #[test] + fn staged_overlay_is_skipped_when_provider_playlist_is_cached() { + let result = PlaylistDownloadResult::new(vec![], vec![], true, false); + + assert!(!should_apply_staged_overlay(&result)); + } + fn make_test_item(name: &str, item_type: PlaylistItemType) -> PlaylistItem { let header = PlaylistItemHeader { diff --git a/backend/src/processing/processor/stream_probe.rs b/backend/src/processing/processor/stream_probe.rs index 36d960a0b..40f3c014f 100644 --- a/backend/src/processing/processor/stream_probe.rs +++ b/backend/src/processing/processor/stream_probe.rs @@ -190,7 +190,7 @@ async fn prepare_generic_stream_metadata( ProbeStorageKind::Xtream, ) } - InputType::Emby | InputType::Jellyfin | InputType::Plex => return Ok(PreparedGenericProbeOutcome::Noop), + InputType::Emby | InputType::Jellyfin | InputType::Plex | InputType::Staged => return Ok(PreparedGenericProbeOutcome::Noop), }; if !db_path.exists() { diff --git a/backend/src/repository/alias_repository.rs b/backend/src/repository/alias_repository.rs index 7cf526f75..b50af4e6f 100644 --- a/backend/src/repository/alias_repository.rs +++ b/backend/src/repository/alias_repository.rs @@ -202,7 +202,7 @@ pub fn csv_read_inputs_from_reader( InputType::M3uBatch | InputType::M3u => InputType::M3uBatch, InputType::XtreamBatch | InputType::Xtream => InputType::XtreamBatch, InputType::Library => InputType::Library, - InputType::Emby | InputType::Jellyfin | InputType::Plex => batch_input_type, + InputType::Emby | InputType::Jellyfin | InputType::Plex | InputType::Staged => batch_input_type, }; let mut result = vec![]; let mut default_columns = vec![]; diff --git a/backend/src/utils/network/m3u.rs b/backend/src/utils/network/m3u.rs index e5cef8d52..ffadc5372 100644 --- a/backend/src/utils/network/m3u.rs +++ b/backend/src/utils/network/m3u.rs @@ -23,12 +23,7 @@ pub async fn download_m3u_playlist_from_source( explicit_source: Option, ) -> (Vec, Vec) { let storage_dir = &cfg.storage_dir; - let input_source: InputSource = explicit_source.unwrap_or_else(|| { - match input.staged.as_ref() { - None => input.into(), - Some(staged) => if staged.enabled { staged.into() } else { input.into() }, - } - }); + let input_source: InputSource = explicit_source.unwrap_or_else(|| input.into()); let persist_file_path = prepare_file_path(input.persist.as_deref(), storage_dir, ""); match request::get_input_text_content_as_stream( app_config, diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index b0731fe48..c277fd1af 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -3,7 +3,6 @@ use crate::messaging::send_message; use crate::model::{is_input_expired, xtream_mapping_option_from_target_options, AppConfig, ConfigInput, ConfigInputFlags, ConfigTarget, MessageContent, XtreamTargetOutput}; use crate::model::{InputSource, ProxyUserCredentials}; -use shared::model::ClusterSource; use crate::processing::parser::xtream; use crate::processing::parser::xtream::parse_xtream_series_info; use crate::repository::VirtualIdRecord; @@ -349,40 +348,20 @@ pub async fn notify_account_expire(exp_date: Option, app_config: &Arc, skip_cluster: &[XtreamCluster], -) -> (Vec, Vec) { - let mut staged_clusters = Vec::new(); - let mut main_clusters = Vec::new(); - +) -> Vec { let all_clusters = [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series]; - for cluster in &all_clusters { - let is_requested = requested.is_none_or(|c| c.contains(cluster)); - if !is_requested || skip_cluster.contains(cluster) { - continue; - } - - if let Some(staged) = input.staged.as_ref().filter(|s| s.enabled) { - match staged.get_cluster_source(*cluster) { - ClusterSource::Staged => { - // M3U staged inputs are handled by the M3U download path, never by Xtream API calls. - if staged.input_type.is_xtream() { - staged_clusters.push(*cluster); - } - } - ClusterSource::Input => main_clusters.push(*cluster), - ClusterSource::Skip => {} // excluded - } - } else { - main_clusters.push(*cluster); - } - } - - (staged_clusters, main_clusters) + all_clusters + .into_iter() + .filter(|cluster| { + let is_requested = requested.is_none_or(|c| c.contains(cluster)); + is_requested && !skip_cluster.contains(cluster) + }) + .collect() } /// Downloads xtream clusters from a single source (either main input or staged input). @@ -475,24 +454,12 @@ async fn download_xtream_from_source( pub async fn download_xtream_playlist(app_config: &Arc, client: &reqwest::Client, input: &ConfigInput, clusters: Option<&[XtreamCluster]>) -> (Vec, Vec, bool) { let skip_cluster = get_skip_cluster(input); - let (staged_clusters, main_clusters) = partition_clusters_by_source(input, clusters, &skip_cluster); + let main_clusters = requested_clusters(clusters, &skip_cluster); let mut all_groups = Vec::with_capacity(128); let mut all_errors = Vec::new(); let mut any_disk = false; - if !staged_clusters.is_empty() { - if let Some(staged) = input.staged.as_ref().filter(|s| s.enabled) { - let source: InputSource = staged.into(); - let (g, e, d) = - download_xtream_from_source(app_config, client, input, &source, staged.input_type, &staged_clusters) - .await; - all_groups.extend(g); - all_errors.extend(e); - any_disk |= d; - } - } - if !main_clusters.is_empty() { check_alias_user_state(app_config, client, input).await; let source: InputSource = input.into(); @@ -586,23 +553,14 @@ pub fn create_vod_info_from_item(pli: &XtreamPlaylistItem) -> String { #[cfg(test)] mod tests { - use super::{create_vod_info_from_item, partition_clusters_by_source}; - use crate::model::{ConfigInput, ConfigInputFlags, ConfigInputFlagsSet, ConfigInputOptions, ProxyUserCredentials, StagedInput}; + use super::{create_vod_info_from_item, requested_clusters}; + use crate::model::{ConfigInput, ConfigInputFlags, ConfigInputFlagsSet, ConfigInputOptions, ProxyUserCredentials}; use serde_json::Value; use shared::model::{ - ClusterSource, InputType, PlaylistItemType, ProxyType, XtreamCluster, XtreamPlaylistItem, + InputType, PlaylistItemType, ProxyType, XtreamCluster, XtreamPlaylistItem, }; use shared::utils::Internable; - fn test_input_with_staged(staged: StagedInput) -> ConfigInput { - ConfigInput { - name: "test".intern(), - input_type: InputType::Xtream, - staged: Some(staged), - ..ConfigInput::default() - } - } - fn options_with_flags(flags: &[ConfigInputFlags]) -> ConfigInputOptions { let mut set = ConfigInputFlagsSet::new(); for flag in flags { @@ -612,63 +570,32 @@ mod tests { } #[test] - fn partition_respects_skip_flags_before_cluster_source() { - let staged = StagedInput { - enabled: true, + fn requested_clusters_respects_skip_flags() { + let input = ConfigInput { + name: "test".intern(), input_type: InputType::Xtream, - live_source: ClusterSource::Staged, - vod_source: ClusterSource::Input, - series_source: ClusterSource::Skip, - ..StagedInput::default() + options: Some(options_with_flags(&[ConfigInputFlags::XtreamSkipLive])), + ..ConfigInput::default() }; - let mut input = test_input_with_staged(staged); - input.options = Some(options_with_flags(&[ConfigInputFlags::XtreamSkipLive])); let skip_cluster = super::get_skip_cluster(&input); - let (staged_clusters, main_clusters) = partition_clusters_by_source(&input, None, &skip_cluster); + let clusters = requested_clusters(None, &skip_cluster); - assert!(staged_clusters.is_empty()); - assert_eq!(main_clusters, vec![XtreamCluster::Video]); + assert!(!clusters.contains(&XtreamCluster::Live)); + assert!(clusters.contains(&XtreamCluster::Video)); + assert!(clusters.contains(&XtreamCluster::Series)); } #[test] - fn partition_excludes_staged_clusters_for_m3u_staged_inputs() { - let staged = StagedInput { - enabled: true, - input_type: InputType::M3u, - live_source: ClusterSource::Staged, - vod_source: ClusterSource::Input, - series_source: ClusterSource::Input, - ..StagedInput::default() - }; - let input = test_input_with_staged(staged); - - let (staged_clusters, main_clusters) = partition_clusters_by_source( - &input, - Some(&[XtreamCluster::Live, XtreamCluster::Video]), - &[], - ); - - assert!(staged_clusters.is_empty()); - assert_eq!(main_clusters, vec![XtreamCluster::Video]); + fn requested_clusters_filters_by_requested_set() { + let clusters = requested_clusters(Some(&[XtreamCluster::Live, XtreamCluster::Video]), &[]); + assert_eq!(clusters, vec![XtreamCluster::Live, XtreamCluster::Video]); } #[test] - fn partition_routes_clusters_by_staged_xtream_cluster_sources() { - let staged = StagedInput { - enabled: true, - input_type: InputType::Xtream, - live_source: ClusterSource::Staged, - vod_source: ClusterSource::Input, - series_source: ClusterSource::Skip, - ..StagedInput::default() - }; - let input = test_input_with_staged(staged); - - let (staged_clusters, main_clusters) = partition_clusters_by_source(&input, None, &[]); - - assert_eq!(staged_clusters, vec![XtreamCluster::Live]); - assert_eq!(main_clusters, vec![XtreamCluster::Video]); + fn requested_clusters_excludes_skip_clusters() { + let clusters = requested_clusters(None, &[XtreamCluster::Series]); + assert_eq!(clusters, vec![XtreamCluster::Live, XtreamCluster::Video]); } fn test_vod_item() -> XtreamPlaylistItem { diff --git a/docs/src/configuration/api-proxy.md b/docs/src/configuration/api-proxy.md index 56a16bbaa..a1049af7c 100644 --- a/docs/src/configuration/api-proxy.md +++ b/docs/src/configuration/api-proxy.md @@ -93,7 +93,7 @@ user: password: mysecurepassword token: auth_token_abc proxy: reverse - output_clusters: [live, vod] + output_clusters: "[live, vod]" server: default max_connections: 1 epg_timeshift: Europe/Paris @@ -102,7 +102,7 @@ user: priority: -10 # Compact inline syntax is also supported: - - { username: x3452, password: p, token: 4342sd, proxy: redirect, output_clusters: [live, vod, series], server: external, epg_timeshift: -2:30 } + - { username: x3452, password: p, token: 4342sd, proxy: redirect, output_clusters: "[live, vod, series]", server: external, epg_timeshift: -2:30 } ``` **Crucial Concept:** By default, Tuliprox acts purely as a stream mapper. If you want Tuliprox to actively evaluate the @@ -199,11 +199,11 @@ user: credentials: - username: live_only password: secret - output_clusters: [live] + output_clusters: "[live]" - username: vod_series_only password: secret - output_clusters: [vod, series] + output_clusters: "[vod, series]" ``` Behavior: diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 4203dac64..4436c3047 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -121,7 +121,6 @@ inputs: options: { } epg: { } aliases: [ ] - staged: { } panel_api: { } ``` @@ -130,7 +129,7 @@ inputs: | Parameter | Type | Required | Default | Technical Impact & Background | |:------------------------|:-------|:--------:|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `name` | String | Yes | | Internal reference ID for Tuliprox. Must be strictly unique. Critical for persistent UUID generation! | -| `type` | Enum | No | `m3u` | Allowed: `m3u`, `xtream`, `library` (Local files) and `m3u_batch`, `xtream_batch` (CSV offloading). | +| `type` | Enum | No | `m3u` | Allowed: `m3u`, `xtream`, `library`, `staged`, `emby`, `jellyfin`, `plex`, and `m3u_batch` / `xtream_batch` (CSV offloading). | | `url` | String | Yes | | The Provider URL. Tuliprox supports magic scheme prefixes: `http(s)://`, `file://`, `batch://`, and **`provider://my_failover_provider`** (for the Failover System above). | | `username` / `password` | String | Often | | Mandatory if `type` = `xtream`. | | `enabled` | Bool | No | `true` | If `false`, this input is completely ignored in all processing. | @@ -141,7 +140,7 @@ inputs: | `headers` | Dict | No | | Custom HTTP headers for the download (e.g., `User-Agent: My-Player`). | | `epg` | Object | No | | Allows mapping of external XMLTV files (see [below](#input-subsections-object-keys)). | | `aliases` | List | No | | Connection pooling / Sub-accounts (see [below](#input-subsections-object-keys)). | -| `staged` | Object | No | | Hybrid architecture feature (see [below](#input-subsections-object-keys)). | +| `staged` | Object | No | | Staged overlay settings. Only valid when `type: staged` (see [below](#input-subsections-object-keys)). | | `panel_api` | Object | No | | Automated reseller account generation (see [below](#input-subsections-object-keys)). | #### Input URL Schemes (`inputs[].url`) @@ -175,7 +174,7 @@ logic. | `options` | Behavior controls for metadata resolution, stream probing, and skip logic. | [See Options](#22-input-options-options) | | `epg` | XMLTV source management and Smart Match fuzzy logic settings. | [See EPG](#23-epg-assignment--smart-match-epg) | | `aliases` | Connection pooling for multiple subscriptions from the same provider. | [See Aliases](#24-provider-aliases-aliases--batch) | -| `staged` | Hybrid architecture for sideloading external playlists into an input. | [See Staged](#25-staged-sources-staged) | +| `staged` | Overlay settings for first-class staged inputs. | [See Staged](#25-staged-sources-staged) | | `panel_api` | Automated reseller panel integration (provisioning/renewal). | [See Panel API](#26-provider-panel-api-panel_api) | --- @@ -781,86 +780,67 @@ http://p2.com/get.php?username=u2&password=p2;1;5;true ### 2.5 Staged Sources (`staged`) -The **"Staged Source"** acts as a structural template for an input's playlist. -Instead of manually mapping and sorting channels within Tuliprox, this feature allows you to "inject" a pre-configured -external playlist -(e.g., from a GitHub repository or a third-party playlist editor) to define the layout while keeping the actual delivery -linked to your main provider. +The **staged input** is a first-class input type for pre-formatted playlists. Tuliprox reads the selected playlist +clusters from the staged source, then stores the merged result in the linked provider input. Stream delivery and API +requests continue to use that provider input. -**The Hybrid Mechanism:** +This is useful when an external playlist editor already has the desired channel order, groups, and original stream IDs. +For example, an IPTV editor can provide the Live playlist layout while the actual streams are still opened against the +Xtream or M3U provider. -* **Structure Provider (Staged):** The external source dictates the channel order, selection, and group naming during - the update process. - It is used **temporarily** only for updating the playlist structure. -* **Data Provider (Main Input):** Regular queries (streaming, authentication, EPG mapping, and metadata fetching) - still go through the main provider defined in the root of the input. +**Data flow:** -**Use Case:** +* `staged input -> provider input`: the staged input is an overlay for that provider. Clusters listed in + `staged.clusters` are loaded from the staged input; the remaining clusters are loaded from the provider input itself. + The merged playlist is persisted under the provider input, and streaming/API requests still target the provider input. +* `staged input -> target` is not supported. Use a normal `m3u` or `xtream` input if the source should be connected + directly to a target. -If you have a perfectly maintained playlist in another online tool or want to bypass Tuliprox's internal mapping for a -specific provider, -you can "plug in" that playlist as a staged source. It won’t replace your main provider; it simply acts as a blueprint -for the update cycle. +#### Configuration Example (Provider With Staged Live Overlay) -#### Configuration Example (Xtream Main + Staged Xtream) - -In this setup, Tuliprox uses an external provider's structure for Live-TV, -but keeps the local provider's data for VOD and ignores the series section entirely. +In this setup, Live-TV comes from the external staged playlist, while VOD and Series come from the original Xtream +provider. ```yaml inputs: - name: provider_main type: xtream - url: [ http://provider-a.example:8080 ](http://provider-a.example:8080) + url: http://provider-a.example:8080 username: main_user password: main_pass - options: - xtream_skip_live: false - xtream_skip_vod: false - xtream_skip_series: false + + - name: provider_main_editor_live + type: staged + url: http://editor.example/provider-main-live.m3u staged: - enabled: true - type: xtream - url: [ http://provider-b.example:8080 ](http://provider-b.example:8080) - username: staged_user - password: staged_pass - live_source: staged - vod_source: input - series_source: skip + for_input: provider_main + clusters: [live] ``` #### Parameters -| Parameter | Type | Required | Default | Technical Impact & Background | -|:------------------------|:-------|:--------:|:-----------|:------------------------------------------------------------------------------------------------------| -| `enabled` | Bool | No | `false` | Master switch for the hybrid staged architecture. | -| `type` | Enum | No | `m3u` | Format of the staged source. Allowed: `m3u`, `xtream`. | -| `url` | String | Yes | | Download URL (HTTP/HTTPS) or local file path (can be gzip). For `xtream`, use the base hostname:port. | -| `username` / `password` | String | Yes | | Mandatory only if staged `type` is `xtream`. | -| `method` | Enum | No | `GET` | HTTP request method (`GET` or `POST`). | -| `headers` | Dict | No | | Custom HTTP headers for the staged download. | -| `live_source` | Enum | No | `staged` | Source for the Live-TV section: `staged`, `input`, or `skip`. | -| `vod_source` | Enum | No | *(Varies)* | Source for the VOD section: `staged`, `input`, or `skip`. | -| `series_source` | Enum | No | *(Varies)* | Source for the Series section: `staged`, `input`, or `skip`. | +| Parameter | Type | Required | Default | Technical Impact & Background | +|:------------------------|:-------|:--------:|:--------|:------------------------------------------------------------------------------------------------------| +| `type` | Enum | Yes | | Must be `staged`. | +| `staged_type` | Enum | No | `m3u` | Format of the staged source. Allowed: `m3u`, `xtream`. | +| `url` | String | Yes | | Download URL (HTTP/HTTPS) or local file path. For `staged_type: xtream`, use the base hostname:port. | +| `username` / `password` | String | Yes | | Mandatory only if `staged_type: xtream`. Not inherited from the provider input. | +| `method` | Enum | No | `GET` | HTTP request method (`GET` or `POST`). Not inherited from the provider input. | +| `headers` | Dict | No | | Custom HTTP headers for the staged download. Not inherited from the provider input. | +| `staged.for_input` | String | Yes | | Provider input name. Must reference a non-staged `m3u` or `xtream` input. | +| `staged.clusters` | List | No | all | Clusters loaded from the staged input: `live`, `vod`, `series`. | -#### Staged Cluster Source Behavior & Logic +#### Staged Cluster Behavior & Validation -The selection of data sources for different clusters (Live, VOD, Series) follows specific validation and priority rules: +`staged.clusters` is the group of clusters loaded from the staged input. -1. **Global Skip Priority:** If `input.options.xtream_skip_live|vod|series` is set to `true`, that section is skipped - entirely, - regardless of the `staged` settings. -2. **Xtream Main Inputs:** - * At least one cluster (`live`, `vod`, or `series`) must be set to `staged`. - If all effective values resolve to `input` or `skip`, the configuration is considered invalid. - * **Default Behavior:** If staged `type` is `xtream`, all clusters default to `staged`. -3. **M3U Staged Sources:** - * M3U sources cannot provide structured VOD or Series metadata for Xtream clusters. - Therefore, `vod_source: staged` and `series_source: staged` are **invalid** if the staged type is `m3u`. - * **Default Behavior:** If staged `type` is `m3u`, it defaults to `live_source: staged`, `vod_source: input`, and - `series_source: input`. -4. **M3U Main Inputs:** If the primary input `type` is `m3u`, the cluster source fields (`live_source`, etc.) are - ignored. +* The referenced provider supplies all clusters not listed in `staged.clusters`. +* `staged.for_input` must reference an existing non-staged `m3u` or `xtream` input. +* Each provider input can have at most one staged overlay. +* `staged.clusters` must not be empty. +* Source definitions must reference the provider input, not the staged input. +* Staged inputs do not use `priority`, `max_connections`, or `cache_duration`. The linked provider input controls + stream limits and refresh cadence. If the provider input is still cached, Tuliprox does not query the staged input. #### File Persistence (`persist`) diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json new file mode 100644 index 000000000..4d29fdd9c --- /dev/null +++ b/frontend/public/assets/i18n/ar.json @@ -0,0 +1,1824 @@ +{ + "BACKOFF_JITTER_PERCENT_VALIDATION": "يجب أن يكون نطاق نسبة تباعد الارتداد بين 0 و95.", + "COUNTRY": { + "AD": "أندورا", + "AE": "الإمارات العربية المتحدة", + "AF": "أفغانستان", + "AG": "أنتيغوا وباربودا", + "AI": "أنغويلا", + "AL": "ألبانيا", + "AM": "أرمينيا", + "AO": "أنغولا", + "AQ": "أنتاركتيكا", + "AR": "الأرجنتين", + "AS": "ساموا الأمريكية", + "AT": "النمسا", + "AU": "أستراليا", + "AW": "أروبا", + "AX": "جزر أولاند", + "AZ": "أذربيجان", + "BA": "البوسنة والهرسك", + "BB": "بربادوس", + "BD": "بنغلاديش", + "BE": "بلجيكا", + "BF": "بوركينا فاسو", + "BG": "بلغاريا", + "BH": "البحرين", + "BI": "بوروندي", + "BJ": "بنين", + "BL": "سان بارتليمي", + "BM": "برمودا", + "BN": "بروناي دار السلام", + "BO": "بوليفيا", + "BQ": "بونير وسينت أوستاتيوس وسابا", + "BR": "البرازيل", + "BS": "جزر البهاما", + "BT": "بوتان", + "BV": "جزيرة بوفيه", + "BW": "بوتسوانا", + "BY": "بيلاروسيا", + "BZ": "بليز", + "CA": "كندا", + "CC": "جزر كوكوس (كيلينغ)", + "CD": "جمهورية الكونغو الديمقراطية", + "CF": "جمهورية أفريقيا الوسطى", + "CG": "الكونغو", + "CH": "سويسرا", + "CI": "كوت ديفوار", + "CK": "جزر كوك", + "CL": "تشيلي", + "CM": "الكاميرون", + "CN": "الصين", + "CO": "كولومبيا", + "CR": "كوستاريكا", + "CU": "كوبا", + "CV": "الرأس الأخضر", + "CW": "كوراساو", + "CX": "جزيرة الكريسماس", + "CY": "قبرص", + "CZ": "تشيكيا", + "DE": "ألمانيا", + "DJ": "جيبوتي", + "DK": "الدنمارك", + "DM": "دومينيكا", + "DO": "جمهورية الدومينيكان", + "DZ": "الجزائر", + "Docker": "Docker", + "EC": "الإكوادور", + "EE": "إستونيا", + "EG": "مصر", + "EH": "الصحراء الغربية", + "ER": "إريتريا", + "ES": "إسبانيا", + "ET": "إثيوبيا", + "FI": "فنلندا", + "FJ": "فيجي", + "FK": "جزر فوكلاند (مالفيناس)", + "FM": "ميكرونيزيا", + "FO": "جزر فارو", + "FR": "فرنسا", + "GA": "الغابون", + "GB": "المملكة المتحدة", + "GD": "غرينادا", + "GE": "جورجيا", + "GF": "غويانا الفرنسية", + "GG": "غيرنزي", + "GH": "غانا", + "GI": "جبل طارق", + "GL": "غرينلاند", + "GM": "غامبيا", + "GN": "غينيا", + "GP": "غوادلوب", + "GQ": "غينيا الاستوائية", + "GR": "اليونان", + "GS": "جورجيا الجنوبية وجزر ساندويتش الجنوبية", + "GT": "غواتيمالا", + "GU": "غوام", + "GW": "غينيا بيساو", + "GY": "غويانا", + "HK": "هونغ كونغ", + "HM": "جزيرة هيرد وجزر ماكدونالد", + "HN": "هندوراس", + "HR": "كرواتيا", + "HT": "هايتي", + "HU": "المجر", + "ID": "إندونيسيا", + "IE": "أيرلندا", + "IL": "إسرائيل", + "IM": "جزيرة مان", + "IN": "الهند", + "IO": "إقليم المحيط الهندي البريطاني", + "IQ": "العراق", + "IR": "إيران", + "IS": "آيسلندا", + "IT": "إيطاليا", + "JE": "جيرسي", + "JM": "جامايكا", + "JO": "الأردن", + "JP": "اليابان", + "KE": "كينيا", + "KG": "قيرغيزستان", + "KH": "كمبوديا", + "KI": "كيريباتي", + "KM": "جزر القمر", + "KN": "سانت كيتس ونيفيس", + "KP": "كوريا (الشمالية)", + "KR": "كوريا (الجنوبية)", + "KW": "الكويت", + "KY": "جزر كايمان", + "KZ": "كازاخستان", + "LA": "جمهورية لاو الديمقراطية الشعبية", + "LAN": "LAN", + "LB": "لبنان", + "LC": "سانت لوسيا", + "LI": "ليختنشتاين", + "LK": "سريلانكا", + "LR": "ليبيريا", + "LS": "ليسوتو", + "LT": "ليتوانيا", + "LU": "لوكسمبورغ", + "LV": "لاتفيا", + "LY": "ليبيا", + "Link-Local": "رابط محلي", + "Loopback": "استرجاع", + "MA": "المغرب", + "MC": "موناكو", + "MD": "مولدوفا", + "ME": "الجبل الأسود", + "MF": "سانت مارتن (الجزء الفرنسي)", + "MG": "مدغشقر", + "MH": "جزر مارشال", + "MK": "مقدونيا الشمالية", + "ML": "مالي", + "MM": "ميانمار", + "MN": "منغوليا", + "MO": "ماكاو", + "MP": "جزر ماريانا الشمالية", + "MQ": "مارتينيك", + "MR": "موريتانيا", + "MS": "مونتسيرات", + "MT": "مالطا", + "MU": "موريشيوس", + "MV": "جزر المالديف", + "MW": "ملاوي", + "MX": "المكسيك", + "MY": "ماليزيا", + "MZ": "موزمبيق", + "NA": "ناميبيا", + "NC": "كاليدونيا الجديدة", + "NE": "النيجر", + "NF": "جزيرة نورفولك", + "NG": "نيجيريا", + "NI": "نيكاراغوا", + "NL": "هولندا", + "NO": "النرويج", + "NP": "نيبال", + "NR": "ناورو", + "NU": "نيوي", + "NZ": "نيوزيلندا", + "OM": "عُمان", + "PA": "بنما", + "PE": "بيرو", + "PF": "بولينيزيا الفرنسية", + "PG": "بابوا غينيا الجديدة", + "PH": "الفلبين", + "PK": "باكستان", + "PL": "بولندا", + "PM": "سانت بيير وميكلون", + "PN": "بيتكيرن", + "PR": "بورتوريكو", + "PS": "فلسطين", + "PT": "البرتغال", + "PW": "بالاو", + "PY": "باراغواي", + "QA": "قطر", + "RE": "لا ريونيون", + "RO": "رومانيا", + "RS": "صربيا", + "RU": "روسيا", + "RW": "رواندا", + "SA": "المملكة العربية السعودية", + "SB": "جزر سليمان", + "SC": "سيشل", + "SD": "السودان", + "SE": "السويد", + "SG": "سنغافورة", + "SH": "سانت هيلينا وأسينشيون وتريستان دا كونا", + "SI": "سلوفينيا", + "SJ": "سفالبارد ويان ماين", + "SK": "سلوفاكيا", + "SL": "سيراليون", + "SM": "سان مارينو", + "SN": "السنغال", + "SO": "الصومال", + "SR": "سورينام", + "SS": "جنوب السودان", + "ST": "ساو تومي وبرينسيب", + "SV": "السلفادور", + "SX": "سينت مارتن (الجزء الهولندي)", + "SY": "الجمهورية العربية السورية", + "SZ": "إسواتيني", + "TC": "جزر توركس وكايكوس", + "TD": "تشاد", + "TF": "الأراضي الفرنسية الجنوبية", + "TG": "توغو", + "TH": "تايلاند", + "TJ": "طاجيكستان", + "TK": "توكيلاو", + "TL": "تيمور ليشتي", + "TM": "تركمانستان", + "TN": "تونس", + "TO": "تونغا", + "TR": "تركيا", + "TT": "ترينيداد وتوباغو", + "TV": "توفالو", + "TW": "تايوان", + "TZ": "تنزانيا", + "UA": "أوكرانيا", + "UG": "أوغندا", + "UM": "جزر الولايات المتحدة الصغيرة النائية", + "US": "الولايات المتحدة", + "UY": "أوروغواي", + "UZ": "أوزبكستان", + "VA": "الكرسي الرسولي", + "VC": "سانت فنسنت وجزر غرينادين", + "VE": "فنزويلا", + "VG": "جزر فيرجن البريطانية", + "VI": "جزر فيرجن الأمريكية", + "VN": "فيتنام", + "VU": "فانواتو", + "WF": "واليس وفوتونا", + "WS": "ساموا", + "YE": "اليمن", + "YT": "مايوت", + "ZA": "جنوب أفريقيا", + "ZM": "زامبيا", + "ZW": "زيمبابوي" + }, + "UNAUTHORIZED": "غير مصرح به", + "EXPLANATION": { + "API_PROXY_CONFIG": { + "USE_USER_DB": "في حالة التفعيل، يتم تحميل مستخدمي وكيل API من قاعدة بيانات المستخدمين.", + "AUTH_ERROR_STATUS": "رمز حالة HTTP الذي يتم إرجاعه عند فشل المصادقة. الافتراضي هو 403 (ممنوع)." + }, + "API_PROXY_SERVER_INFO": { + "HOST": "المضيف الأعلى الذي يشير إليه ملف تعريف خادم الوكيل هذا.", + "MESSAGE": "رسالة الاستجابة التي يُرجعها ملف تعريف خادم الوكيل هذا.", + "NAME": "اسم العرض المستخدم لتعريف ملف تعريف خادم وكيل API هذا.", + "PATH": "بادئة مسار اختيارية تُستخدم عند توجيه الطلبات إلى ملف تعريف خادم الوكيل هذا.", + "PORT": "منفذ أعلى اختياري يستخدمه ملف تعريف خادم الوكيل هذا.", + "PROTOCOL": "البروتوكول المستخدم من قبل نقطة نهاية خادم وكيل API هذا (مثل http أو https).", + "TIMEZONE": "المنطقة الزمنية المطبقة بواسطة ملف تعريف خادم الوكيل هذا للاستجابات الحساسة للوقت." + }, + "CACHE_CONFIG": { + "DIR": "دليل نظام الملفات حيث يتم تخزين ذاكرة التخزين المؤقت لقائمة التشغيل والبيانات الوصفية.", + "ENABLED": "يُفعّل أو يُعطّل التخزين المؤقت على القرص لموارد الإدخال على مستوى العالم.", + "SIZE": "الحد الأقصى للحجم (بالبايت) المسموح به لدليل ذاكرة التخزين المؤقت. الوحدات المسموح بها: B، KB، MB، GB، TB" + }, + "HLS_CACHE_CONFIG": { + "CACHE_BYTES": "الحد الإجمالي للقرص لملفات ذاكرة التخزين المؤقت لـ HLS.", + "CACHE_BYTES_PER_SESSION": "حد القرص لكل جلسة HLS مشتركة.", + "CACHE_DURATION": "مدة الاستبقاء لملفات ذاكرة التخزين المؤقت لـ HLS غير المحمية.", + "CACHE_PATH": "الدليل الأساسي لملفات أجزاء HLS وذاكرة التخزين المؤقت لـ MAP.", + "MAX_CONCURRENT_SEGMENT_FETCHES_GLOBAL": "الحد العام لجلب أجزاء HLS بالتوازي.", + "MAX_CONCURRENT_SEGMENT_FETCHES_PER_SESSION": "حد لكل جلسة لجلب الأجزاء بالتوازي.", + "MAX_SEGMENTS_PREFETCH": "عدد الأجزاء المعروفة من المصدر المراد جلبها مسبقًا لكل جلسة.", + "MANIFEST_RECOVERY_BURST": "يشغل دفعة صغيرة متدرجة عند محاولة استعادة الجدول الزمني الأولى بعد رفض البيان.", + "ORIGIN_MANIFEST_TIMEOUT_MS": "مهلة طلبات بيان المصدر.", + "ORIGIN_SEGMENT_TIMEOUT_MS": "مهلة طلبات أجزاء المصدر.", + "SEGMENT_REPAIR_APPLY_TO_FIRST_SEGMENTS": "الأجزاء التي يتم فحصها لكل عقد وصول.", + "SEGMENT_REPAIR_MAX_PARALLEL_REPAIRS": "وظائف الإصلاح المتزامنة. يجب ألا تتجاوز الجلب المسبق.", + "SEGMENT_REPAIR_MAX_LEVEL": "مستوى إصلاح TS الأقصى المسموح به للإصلاحات المستمدة من المُحفّز.", + "SEGMENT_REPAIR_POSTPROCESS_TIMEOUT_MS": "مهلة سلسلة المعالجة اللاحقة الكاملة للمقطع.", + "SEGMENT_REPAIR_SIZE_INCREASE": "نمو حجم المقطع المسموح به لمستوى الإصلاح المحدد.", + "SEGMENT_REPAIR_TRIGGER": "تحدد سياسة المُحفّز التلقائي للترميز مستوى الإصلاح المطلوب.", + "CORRUPT_SEGMENT_WATCHDOG": "يتعامل مع التحذيرات المتبقية لفساد حزم TS بعد سياسة الإصلاح المنتظمة.", + "CORRUPT_SEGMENT_WATCHDOG_MAX_PARALLEL_JOBS": "وظائف التطهير المتزامنة لكلب المراقبة.", + "SESSION_IDLE_TIMEOUT": "وقت الخمول قبل إزالة جلسات HLS غير المستخدمة.", + "STRIP_MODE": "مدى تأخر قائمة التشغيل الأولية عن الحافة المباشرة.", + "STRIP_VALUE": "قيمة الفجوة بالأجزاء أو الثواني." + }, + "CONFIG_API": { + "HOST": "المضيف الذي يستمع عليه واجهة Tuliprox البرمجية. الافتراضي: 0.0.0.0", + "PORT": "منفذ TCP الذي تستمع عليه واجهة Tuliprox البرمجية. الافتراضي: 8901", + "WEB_ROOT": "مسار نظام الملفات الذي يخدم أصول الواجهة الأمامية لنقطة نهاية الـ API. الافتراضي: ./web" + }, + "CONFIG_INPUT": { + "CACHE_DURATION": "مدة ذاكرة التخزين المؤقت لقائمة التشغيل (مثل 12h، 1d، 30m). تُعاد استخدام قوائم التشغيل المخزنة لتقليل التنزيلات.", + "ENABLED": "إذا تم تعطيله، يتم تخطي المعالجة لهذا الإدخال.", + "EXP_DATE": "تاريخ انتهاء الصلاحية اليدوي لهذا الإدخال (بتنسيق YYYY-MM-DD HH:MM:SS أو طابع Unix الزمني).", + "MAX_CONNECTIONS": "حد الاتصالات المتزامنة لهذا الإدخال. 0 يعني غير محدود.", + "NAME": "اسم قابل للقراءة البشرية لهذا الإدخال. يجب أن يكون فريدًا.", + "PASSWORD": "كلمة المرور أو بيانات الاعتماد المستخدمة لهذا الحساب أو الخدمة.", + "PRIORITY": "ترتيب المعالجة. الأرقام الأقل تعني أولوية أعلى (مثل -1 أعلى من 0).", + "URL": "عنوان URL لمصدر الإدخال. الأنظمة المدعومة:\n\n```http(s)://\nfile://\nprovider:///...\nbatch://...(يُحوّل تلقائيًا بين m3u/xtream إلى الأنواع الدفعية).```\n\nلمصادر m3u_batch/xtream_batch، يُسمح فقط بمصادر CSV المحلية (batch://...).", + "USERNAME": "اسم المستخدم لتسجيل الدخول لهذا الحساب أو الخدمة." + }, + "CONFIG_INPUT_ALIAS": { + "ENABLED": "يُفعّل أو يُعطّل هذا الاسم المستعار المحدد.", + "EXP_DATE": "تاريخ انتهاء صلاحية اتصال الاسم المستعار هذا.", + "MAX_CONNECTIONS": "الحد الأقصى لعدد الاتصالات المتزامنة المسموح بها لهذا الاسم المستعار المحدد.", + "NAME": "اسم قابل للقراءة البشرية لهذا الإدخال.", + "PASSWORD": "كلمة المرور أو بيانات الاعتماد المستخدمة لهذا الحساب أو الخدمة.", + "PRIORITY": "أولوية هذا الاسم المستعار عند تجاوز الفشل. الرقم الأقل يعني أولوية أعلى.", + "URL": "عنوان URL المستهدف لهذا الإعداد.", + "USERNAME": "اسم المستخدم لتسجيل الدخول لهذا الحساب أو الخدمة." + }, + "CONFIG_PROVIDER": { + "NAME": "معرف مزود فريد يستخدمه provider:// للإشارة.", + "URLS": "قائمة مرتبة لعناوين URL الأساسية للمزود. يدور Tuliprox بينها عند تجاوز الفشل." + }, + "CONFIG_INPUT_OPTIONS": { + "PROBE_DELAY": "التأخير بالثواني بين طلبات الفحص (الافتراضي 2) لتقليل الضغط على المزود.", + "PROBE_LIVE": "يُفعّل التحليل التقني في الخلفية للقنوات المباشرة لاكتشاف برامج الترميز والدقة.", + "PROBE_LIVE_INTERVAL_HOURS": "الفاصل الزمني بالساعات بين إعادة فحص القنوات المباشرة (الافتراضي: 120).", + "PROBE_SERIES": "يُفعّل التحليل التقني لإدخالات المسلسلات (يتطلب ffprobe و probe_stream).", + "PROBE_VOD": "يُفعّل التحليل التقني لإدخالات الفيديو حسب الطلب (يتطلب ffprobe و probe_stream).", + "PROBE_FILTER": "تعبير تصفية لفحص الإدخالات المطابقة فقط بشكل انتقائي (مثل 'name ~ \"S01\"').", + "RESOLVE_BACKGROUND": "إذا كان صحيحًا، يتم معالجة مهام حل البيانات الوصفية في الخلفية (الافتراضي صحيح).", + "RESOLVE_DELAY": "التأخير بالثواني بين طلبات حل البيانات الوصفية (الافتراضي 2).", + "RESOLVE_FILTER": "تعبير تصفية لحل الإدخالات المطابقة فقط بشكل انتقائي.", + "RESOLVE_SERIES": "يُفعّل حل البيانات الوصفية (TMDB/NFO) لإدخالات المسلسلات.", + "RESOLVE_TMDB": "يحاول حل معرفات TMDB المفقودة بناءً على العنوان إذا كانت مفقودة من المزود.", + "RESOLVE_VOD": "يُفعّل حل البيانات الوصفية (TMDB/NFO) لإدخالات الفيديو حسب الطلب.", + "XTREAM_LIVE_STREAM_USE_PREFIX": "إذا كان صحيحًا، يُضيف بادئة /live/ إلى روابط بث Xtream (الافتراضي صحيح).", + "XTREAM_LIVE_STREAM_WITHOUT_EXTENSION": "إذا كان صحيحًا، يزيل امتداد .ts من روابط بث Xtream (الافتراضي خطأ).", + "XTREAM_SKIP_LIVE": "إذا كان صحيحًا، يتم تخطي قسم البث المباشر لمزود Xtream أثناء التحديث.", + "XTREAM_SKIP_SERIES": "إذا كان صحيحًا، يتم تخطي قسم المسلسلات لمزود Xtream أثناء التحديث.", + "XTREAM_SKIP_VOD": "إذا كان صحيحًا، يتم تخطي قسم الفيديو حسب الطلب لمزود Xtream أثناء التحديث." + }, + "CONFIG_TARGET": { + "ENABLED": "إذا تم تعطيله، يتم تخطي المعالجة لمخرج الهدف هذا.", + "MAPPING": "قائمة معرفات التعيين لتطبيقها على هذا الهدف.", + "NAME": "اسم قابل للقراءة البشرية لهذا الإدخال. يُستخدم للمعالجة الانتقائية عبر وسيطة CLI -t.", + "USE_MEMORY_CACHE": "إذا تم التفعيل، يتم تخزين قائمة التشغيل في ذاكرة الوصول العشوائي لتقليل عبء القرص (يزيد من استخدام الذاكرة).", + "WATCH": "قائمة أنماط المجموعات لمراقبة التغييرات. يتم إرسال الإشعارات عبر المراسلة المكوّنة." + }, + "CONFIG_TARGET_OPTIONS": { + "SHARE_LIVE_STREAMS": "يُفعّل مشاركة البث المباشر. المفتاح الرئيسي يضبط HLS و MPEG-TS معًا؛ يمكن تغيير كل تنسيق أيضًا بشكل منفصل." + }, + "CONFIG_TARGET_SHARE_LIVE_STREAMS": { + "HLS": "يُفعّل مشاركة البث المباشر HLS لمسار وكيل ذاكرة التخزين المؤقت HLS الجديد.", + "MPEG_TS": "يُفعّل مشاركة البث المباشر MPEG-TS لمسار مشاركة البث الحالي." + }, + "CONTENT_SECURITY_POLICY_CONFIG": { + "CUSTOM_ATTRIBUTES": "سمات CSP الإضافية الملحقة بالسياسة القياسية.", + "ENABLED": "يُفعّل أو يُعطّل رؤوس سياسة أمان المحتوى لـ WebUI." + }, + "DISK_ALERT_CONFIG": { + "WARN_PERCENT": "استخدام القرص (0-100) الذي يتم عنده إرسال أول تحذير.\n\nأقل من هذه القيمة يُعتبر القرص طبيعيًا. أعلى من العتبة الحرجة، يتصاعد التنبيه.", + "CRITICAL_PERCENT": "استخدام القرص (0-100) الذي يتصاعد عنده التنبيه إلى حرج ويُعامل على أنه 'القرص ممتلئ'.\n\nيجب أن يكون أكبر من عتبة التحذير. يُعاد إرسال التنبيه أثناء بقاء القرص في هذه الحالة.", + "REPEAT_INTERVAL_SECS": "كل كم ثانية يتم إعادة إرسال التنبيه أثناء بقاء القرص في نفس حالة التحذير أو الحرج.\n\nاضبط على 0 لتعطيل إعادة التسليح. الافتراضي: 3600 (ساعة)." + }, + "DEFAULT": "لا يوجد شرح مخصص متاح لهذا الحقل بعد.", + "DISCORD": { + "TEMPLATE_MESSAGE": "نموذج جسم الرسالة لإشعارات Discord." + }, + "DISCORD_MESSAGING_CONFIG": { + "TEMPLATES": "قوالب Handlebars لأجسام رسائل Discord.", + "URL": "عنوان URL لـ webhook Discord لإرسال رسائل الإشعارات." + }, + "BEST_MATCH_THRESHOLD": "عتبة الثقة العالية (0-100). تُقبل المطابقات التي تتجاوز هذه القيمة فورًا كأفضل مرشح.", + "DNS_MAX_ADDRS": "حد اختياري لعناوين IP التي يتم حلها والمُحتفظ بها لكل اسم مضيف. اتركه فارغًا لعدم وجود حد صريح.", + "FUZZY_MATCHING": "يسمح بمطابقة التشابه الضبابي إذا فشلت المطابقة الدقيقة للمعرفات المعيارية.", + "MATCH_THRESHOLD": "الحد الأدنى لدرجة التشابه (0-100) المطلوبة لمرشح مطابقة EPG.", + "NAME_PREFIX": "يحدد كيفية التعامل مع بادئات القنوات أثناء التطبيع (تجاهل/بادئة/لاحقة).", + "NAME_PREFIX_SEPARATOR": "قائمة الأحرف المستخدمة لفصل البادئات عن أسماء القنوات قبل المطابقة.", + "NAME_PREFIX_VALUE": "نص البادئة/اللاحقة المستخدم بواسطة وضع بادئة الاسم.", + "NORMALIZE_REGEX": "تعبير منتظم اختياري يستخدم لتطبيع أسماء القنوات قبل المطابقة.", + "STRIP": "المصطلحات التي يتم تجاهلها أثناء المطابقة الذكية لـ EPG (مثل '4K'، 'HD'، 'HEVC').", + "EPG_CONFIG": { + "BEST_MATCH_THRESHOLD": "عتبة الثقة العالية (0-100). تُقبل المطابقات التي تتجاوز هذه القيمة فورًا كأفضل مرشح.", + "ENABLED": "يُفعّل المطابقة الذكية لـ EPG. إذا تم تعطيله، لا يتم تطبيق قواعد التطبيع/المطابقة الضبابية.", + "FUZZY_MATCHING": "يسمح بمطابقة التشابه الضبابي إذا فشلت المطابقة الدقيقة للمعرفات المعيارية.", + "MATCH_THRESHOLD": "الحد الأدنى لدرجة التشابه (0-100) المطلوبة لمرشح مطابقة EPG.", + "NAME_PREFIX": "يحدد كيفية التعامل مع بادئات القنوات أثناء التطبيع (تجاهل/بادئة/لاحقة).", + "NAME_PREFIX_SEPARATOR": "قائمة الأحرف (مثل ':'، '|') التي تفصل بادئات القنوات عن العنوان الرئيسي أثناء المطابقة الذكية.", + "NAME_PREFIX_VALUE": "نص البادئة/اللاحقة الذي يجب إزالته أو إلحاقه وفقًا لوضع بادئة الاسم.", + "NORMALIZE_REGEX": "تعبير منتظم اختياري يستخدم لتطبيع أسماء القنوات قبل المطابقة.", + "STRIP": "المصطلحات التي يتم تجاهلها أثناء المطابقة الذكية لـ EPG (مثل '4K'، 'HD'، 'HEVC')." + }, + "EPG_SMART_MATCH_CONFIG": { + "BEST_MATCH_THRESHOLD": "عتبة الثقة العالية (0-100). تُقبل المطابقات التي تتجاوز هذه القيمة فورًا كأفضل مرشح.", + "ENABLED": "يُفعّل المطابقة الذكية لـ EPG. إذا تم تعطيله، لا يتم تطبيق قواعد التطبيع/المطابقة الضبابية.", + "FUZZY_MATCHING": "يسمح بمطابقة التشابه الضبابي إذا فشلت المطابقة الدقيقة للمعرفات المعيارية.", + "MATCH_THRESHOLD": "الحد الأدنى لدرجة التشابه (0-100) المطلوبة لمرشح مطابقة EPG.", + "NAME_PREFIX": "يحدد كيفية التعامل مع بادئات القنوات أثناء التطبيع (تجاهل/بادئة/لاحقة).", + "NAME_PREFIX_SEPARATOR": "قائمة الأحرف المستخدمة لفصل البادئات عن أسماء القنوات قبل المطابقة.", + "NAME_PREFIX_VALUE": "نص البادئة/اللاحقة المستخدم بواسطة وضع بادئة الاسم.", + "NORMALIZE_REGEX": "تعبير منتظم اختياري يستخدم لتطبيع أسماء القنوات قبل المطابقة.", + "STRIP": "المصطلحات التي يتم تجاهلها أثناء المطابقة الذكية لـ EPG (مثل '4K'، 'HD'، 'HEVC')." + }, + "EPG_SMART_MATCH_FORM": { + "NAME_PREFIX_MODE": "يحدد كيفية التعامل مع بادئات القنوات أثناء التطبيع (تجاهل/بادئة/لاحقة)." + }, + "EPG_SMART_MATCH_FORM_DATA": { + "BEST_MATCH_THRESHOLD": "عتبة الثقة العالية (0-100). تُقبل المطابقات التي تتجاوز هذه القيمة فورًا كأفضل مرشح.", + "ENABLED": "يُفعّل المطابقة الذكية لـ EPG. إذا تم تعطيله، لا يتم تطبيق قواعد التطبيع/المطابقة الضبابية.", + "FUZZY_MATCHING": "يسمح بمطابقة التشابه الضبابي إذا فشلت المطابقة الدقيقة للمعرفات المعيارية.", + "MATCH_THRESHOLD": "الحد الأدنى لدرجة التشابه (0-100) المطلوبة لمرشح مطابقة EPG.", + "NAME_PREFIX_SEPARATOR": "قائمة الأحرف المستخدمة لفصل البادئات عن أسماء القنوات قبل المطابقة.", + "NAME_PREFIX_VALUE": "نص البادئة/اللاحقة المستخدم بواسطة وضع بادئة الاسم.", + "NORMALIZE_REGEX": "تعبير منتظم اختياري يستخدم لتطبيع أسماء القنوات قبل المطابقة.", + "STRIP": "المصطلحات التي يتم تجاهلها أثناء المطابقة الذكية لـ EPG (مثل '4K'، 'HD'، 'HEVC')." + }, + "EPG_SOURCE": { + "LOGO_OVERRIDE": "يستبدل شعارات قائمة التشغيل/المزود بأيقونات من مصدر EPG بتنسيق XMLTV هذا.", + "PRIORITY": "يحدد مصدر EPG المفضل إذا كانت مصادر متعددة توفر بيانات لنفس القناة.", + "URL": "عنوان URL لملف XMLTV لـ EPG." + }, + "FFPROBE": "يتحكم FFprobe في كيفية إجراء تحليل البث.\n\nيُفعّل أو يُعطّل التحليل التقني للبث ويُكوّن المهلات وأعماق الفحص.\nمثال:\n```\nffprobe:\n enabled: true\n timeout: 60\n analyze_duration: 10s\n probe_size: 10MB\n```", + "FFPROBE_CONFIG": { + "ANALYZE_DURATION": "كم من وقت التشغيل يُسمح لـ FFprobe بفحصه للفيديو حسب الطلب/المسلسلات.\n\nالقيم الأعلى يمكن أن تجد مزيدًا من التفاصيل لكنها تستغرق وقتًا أطول.\n\nأمثلة: `10s`، `30s`، `1m`.", + "ENABLED": "يُفعّل أو يُعطّل تحليل البث باستخدام FFprobe.\n\nعند التعطيل، يتخطى التطبيق الفحص التقني (تفاصيل الفيديو/الصوت).", + "LIVE_ANALYZE_DURATION": "كم من وقت التشغيل قد يفحصه FFprobe للبث المباشر.\n\nعادة ما تكون فحوصات البث المباشر أسرع مع القيم الصغيرة.\n\nأمثلة: `2s`، `5s`، `10s`.", + "LIVE_PROBE_SIZE": "كم من البيانات قد يقرأها FFprobe للبث المباشر.\n\nالقيم الأصغر تقلل الحمل وتجعل الفحوصات أسرع.\n\nأمثلة: `512KB`، `1MB`، `5MB`.", + "PROBE_SIZE": "كم من البيانات قد يقرأها FFprobe للفيديو حسب الطلب/المسلسلات.\n\nالقيم الأعلى يمكن أن تحسن الكشف لكن تستخدم مزيدًا من النطاق الترددي.\n\nأمثلة: `1MB`، `5MB`، `10MB`.", + "TIMEOUT": "الحد الأقصى للثواني التي قد تستغرقها عملية FFprobe واحدة.\n\nإذا كان البث بطيئًا جدًا، يتوقف الفحص عند الوصول إلى هذا الحد.\n\nاتركه فارغًا لاستخدام الافتراضي (60 ثانية)." + }, + "GEO_IP_CONFIG": { + "ENABLED": "إذا تم التفعيل، يتم إجراء بحث Geo-IP للطلبات الواردة.", + "URL": "عنوان URL لتنزيل قاعدة بيانات Geo-IP (مثل MaxMind GeoLite2-City.mmdb).", + "UNAVAILABLE_POLICY": "`deny` هو الافتراضي الآمن. إذا تم تعطيل Geo-IP أو كان مفقودًا أو لم يتم تحميله، فإن قواعد الوصول إلى الشبكة القائمة على الدولة ترفض الطلبات التي لم تطابق CIDR مُكوّن.\n\n`allow` هو قبول صريح للمخاطر. يسمح بقواعد الوصول إلى الشبكة القائمة على الدولة عندما لا يكون Geo-IP متاحًا. حالات فشل CIDR فقط والبلدان غير المعروفة وتعارضات الدولة لا تزال ترفض." + }, + "HD_HOME_RUN_CONFIG": { + "AUTH": "سلسلة المصادقة أو الرمز المطلوب للوصول إلى واجهة HDHomeRun البرمجية.", + "ENABLED": "يُفعّل محاكاة مخرج الهدف HDHomeRun.", + "PROPRIETARY_DISCOVERY": "يُفعّل بروتوكولات اكتشاف HDHomeRun الاحتكارية.", + "SSDP_DISCOVERY": "يُفعّل اكتشاف SSDP القياسي لمحاكاة HDHomeRun على الشبكة المحلية." + }, + "HD_HOME_RUN_DEVICE_CONFIG": { + "DEVICE_ID": "معرف الجهاز الفريد المستخدم لتعريف HDHomeRun. يُولّد تلقائيًا إذا لم يتم تعيينه.", + "DEVICE_TYPE": "سلسلة نوع الجهاز المُعلنة للعملاء.", + "DEVICE_UDN": "اسم الجهاز الفريد (UUID) لجهاز HDHomeRun الافتراضي هذا. يُولّد تلقائيًا إذا لم يتم تعيينه.", + "FIRMWARE_NAME": "اسم ملف البرنامج الثابت المُعلن لعملاء HDHomeRun.", + "FIRMWARE_VERSION": "سلسلة إصدار البرنامج الثابت المُعلن لعملاء HDHomeRun.", + "FRIENDLY_NAME": "اسم العرض القابل للقراءة البشرية المعروض لعملاء HDHomeRun.", + "MANUFACTURER": "اسم الشركة المصنعة المُعلن لعملاء HDHomeRun.", + "MODEL_NAME": "سلسلة اسم الطراز المُعلن لعملاء HDHomeRun.", + "MODEL_NUMBER": "سلسلة رقم الطراز المُعلن لعملاء HDHomeRun.", + "NAME": "معرف داخلي لجهاز HDHomeRun الافتراضي هذا. يجب أن يكون فريدًا.", + "PORT": "منفذ HTTP الذي يستمع عليه هذا الجهاز الافتراضي. يُعيّن تلقائيًا إذا كان 0.", + "TUNER_COUNT": "عدد الموالفات الافتراضية التي يعلن عنها هذا الجهاز. الحد الأدنى 1." + }, + "HD_HOME_RUN_TARGET_OUTPUT": { + "DEVICE": "معرف الجهاز الافتراضي المكشوف بواسطة محاكاة HDHomeRun.", + "USERNAME": "اسم المستخدم لتسجيل الدخول لهذا الحساب أو الخدمة." + }, + "INPUT_FORM": { + "ALIASES": "أسماء مستعارة اختيارية تُستخدم لإدارة خطوط متعددة من نفس المزود.", + "EPG_SOURCES": "مصادر EPG (دليل البرامج الإلكتروني) المرتبطة بهذا الإدخال.\n يمكنك استخدام `auto` لإنشاء عنوان URL لـ EPG بناءً على إعدادات الإدخال الخاصة بك،\nأو يمكنك كتابة عنوان URL الكامل لـ EPG.", + "FETCH_METHOD": "طريقة HTTP المستخدمة لاسترداد الإدخال (مثل GET، POST).", + "HEADERS": "رؤوس HTTP المخصصة المُرسلة عند استرداد قائمة تشغيل الإدخال هذه.", + "INPUT_TYPE": "يحدد بروتوكول البث أو تنسيق المصدر (مثل xtream، m3u).", + "LIVE_SOURCE": "محدد المصدر لمجموعة البث المباشر عند تفعيل الإدخال المُرحّل (`default`، `staged`، `input`، `skip`).", + "PERSIST": "مسار ملف اختياري يُستخدم لحفظ قائمة تشغيل الإدخال التي تم تنزيلها أو إعادة استخدامها.", + "PROVIDERS": "تحدد ملفات تعريف المزود عناوين URL الأعلى القابلة لإعادة الاستخدام وسلوك DNS التي يمكن لهذا الإدخال الإشارة إليها.", + "SIMPLE_INPUT": { + "URL": "عنوان URL المصدر لهذا الإدخال. تُدعم فقط عناوين `file://` و`http://` و`https://`." + }, + "STAGED_CLUSTERS": "المجموعات التي يتجاوزها هذا الإدخال المُرحّل في إدخال المزود المتصل.", + "STAGED_PERSIST": "مسار ملف اختياري يُستخدم لحفظ قائمة التشغيل المُرحّلة التي تم تنزيلها أو إعادة استخدامها.", + "URL": "عنوان URL المصدر لهذا الإدخال." + , + "SERIES_SOURCE": "محدد المصدر لمجموعة المسلسلات عند تفعيل الإدخال المُرحّل (`default`، `staged`، `input`، `skip`).", + "VOD_SOURCE": "محدد المصدر لمجموعة الفيديو حسب الطلب عند تفعيل الإدخال المُرحّل (`default`، `staged`، `input`، `skip`)." + }, + "MEDIA_SERVER": { + "ACCOUNT_TOKEN": "رمز حساب اختياري تستخدمه تكاملات خادم الوسائط التي تتطلب رمز مصادقة على مستوى الحساب.", + "ALLOW_RELAY": "يسمح لخادم الوسائط باستخدام اتصالات الترحيل عندما لا يكون الوصول المباشر متاحًا.", + "API_KEY": "مفتاح API أو رمز يُستخدم لمصادقة الطلبات ضد واجهة خادم الوسائط البرمجية.", + "LIBRARIES": "قائمة مفصولة بفواصل لمكتبات الوسائط المراد استخدامها من هذا الخادم. اتركها فارغة لتضمين جميع المكتبات المتاحة.", + "PREFER_HTTPS": "يفضل عناوين HTTPS عندما يعرض خادم الوسائط كلًا من خيارات الاتصال HTTP و HTTPS.", + "SERVER_ID": "معرف خادم فريد اختياري يُستخدم عندما تحتاج التكامل إلى خادم محدد من حساب.", + "SERVER_NAME": "اسم عرض الخادم اختياري يُستخدم لتحديد أو تسمية اتصال خادم الوسائط.", + "TOKEN": "رمز المصادقة المستخدم للاتصال بخادم الوسائط.", + "USER_ID": "معرف مستخدم اختياري يُستخدم عندما يتطلب خادم الوسائط تشغيل الطلبات كمستخدم محدد." + }, + "IP_CHECK_CONFIG": { + "PATTERN_IPV4": "تعبير منتظم اختياري لاستخراج عنوان IPv4 من الاستجابة (الافتراضي يطابق التنسيق العشري القياسي).", + "PATTERN_IPV6": "تعبير منتظم اختياري لاستخراج عنوان IPv6 من الاستجابة (الافتراضي يطابق التنسيق السداسي القياسي).", + "URL": "عنوان URL لخدمة تُرجع عنوان IP العام الحالي (IPv4 أو IPv6).", + "URL_IPV4": "عنوان URL مخصص لجلب عنوان IPv4 العام الحالي (مثل https://ipinfo.io/ip).", + "URL_IPV6": "عنوان URL مخصص لجلب عنوان IPv6 العام الحالي." + }, + "LIBRARY_CONFIG": { + "ENABLED": "يُفعّل أو يُعطّل ميزة مكتبة الوسائط المحلية على مستوى العالم.", + "FORMATS": "قائمة تنسيقات البيانات الوصفية للكتابة (مثل 'nfo' للملفات الجانبية المتوافقة مع Kodi).", + "SUPPORTED_EXTENSIONS": "قائمة امتدادات ملفات الفيديو (مثل mp4، mkv) المدعومة بواسطة الماسح." + }, + "LIBRARY_METADATA_CONFIG": { + "FALLBACK_TO_FILENAME": "إذا فشل حل NFO أو TMDB، يحاول Tuliprox تخمين البيانات الوصفية من اسم الملف.", + "PATH": "الدليل حيث يتم تخزين البيانات الوصفية للمكتبة المعالجة." + }, + "LIBRARY_METADATA_READ_CONFIG": { + "JELLYFIN": "يقرأ ملفات البيانات الوصفية NFO المُنسقة لـ Jellyfin.", + "KODI": "يقرأ ملفات البيانات الوصفية NFO المُنسقة لـ Kodi.", + "PLEX": "يقرأ ملفات البيانات الوصفية XML أو الملفات الجانبية المُنسقة لـ Plex." + }, + "LIBRARY_PLAYLIST_CONFIG": { + "MOVIE_CATEGORY": "اسم فئة الأفلام المحلية في قائمة التشغيل المُولّدة (الافتراضي 'أفلام محلية').", + "SERIES_CATEGORY": "اسم فئة المسلسلات المحلية في قائمة التشغيل المُولّدة (الافتراضي 'مسلسلات محلية')." + }, + "LOG_CONFIG": { + "LOG_ACTIVE_USER": "إذا تم التفعيل، يتم تسجيل عدد عملاء الوكيل العكسي النشطين بانتظام.", + "LOG_LEVEL": "يضبط مستوى تفصيل سجلات التطبيق (مثل info، debug، error).", + "SANITIZE_SENSITIVE_INFO": "إذا تم التفعيل، يتم إخفاء كلمات المرور والرموز في السجلات.", + "RUNTIME_CONFIG_REPORT_ENABLED": "إذا تم التفعيل، يتم طباعة تهيئة وقت التشغيل في السجل عند بدء التشغيل.", + "RUNTIME_CONFIG_REPORT_FORMAT": "يحدد التنسيق الذي تُطبع به تهيئة وقت التشغيل في السجل." + }, + "M3U_TARGET_OUTPUT": { + "FILENAME": "اسم ملف M3U المُولّد (مثل playlist.m3u).", + "INCLUDE_TYPE_IN_URL": "إذا كان صحيحًا، يتم تضمين نوع البث (مباشر/فيديو/مسلسلات) صراحةً في عناوين URL المُولّدة.", + "MASK_REDIRECT_URL": "إذا تم التفعيل، يتم إخفاء عنوان URL الحقيقي للمزود خلف رابط تحويل Tuliprox حتى في وضع التحويل." + }, + "MAIN_CONFIG": { + "ACCEPT_INSECURE_SSL_CERTIFICATES": "إذا تم التفعيل، يتم تخطي التحقق من صحة شهادة SSL لجميع الطلبات الصادرة.", + "BACKUP_DIR": "دليل نظام الملفات المستخدم لنسخ التهيئة الاحتياطية.", + "CONFIG_HOT_RELOAD": "إذا كان صحيحًا، يتم إعادة تحميل ملفات التهيئة تلقائيًا عند التغيير.", + "CONNECT_TIMEOUT_SECS": "الحد الأقصى لمهلة الاتصال بالثواني.", + "INTERNER_GC_INTERVAL_SECS": "الفاصل الزمني بالثواني بين فحوصات جمع القمامة في الخلفية لـ string interner.", + "INTERNER_GC_MIN_POOL_SIZE": "الحد الأدنى لعدد السلاسل المُدمجة قبل تشغيل GC الخلفي لـ interner.", + "CUSTOM_STREAM_RESPONSE_PATH": "مسار إلى دليل يحتوي على ملفات استجابة الخطأ المخصصة لحالات فشل البث.", + "CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS": "الحد الأقصى للمدة بالثواني لتقديم فيديو استجابة البث المخصص. `0` يعطل المهلة.", + "CUSTOM_STREAM_RESPONSE_ENABLED": "يُفعّل/يُعطّل فيديو استجابة البث المخصص.", + "CUSTOM_STREAM_RESPONSE_ERROR_STATUS": "إذا تم تعطيل فيديو استجابة البث المخصص، فإن هذه القيمة تحدد حالة خطأ HTTP الافتراضية.", + "DEFAULT_USER_AGENT": "سلسلة User-Agent المستخدمة للطلبات الصادرة إذا لم يتم تحديدها في مكان آخر.", + "DISK_BASED_PROCESSING": "إذا تم التفعيل، تتم معالجة قوائم تشغيل الإدخال من القرص لتوفير ذاكرة الوصول العشوائي.\nإذا كان لديك ذاكرة وصول عشوائي كافية، يمكنك ترك هذا الخيار معطلاً لتحسين الأداء.", + "MAPPING_PATH": "مسار أو دليل يحتوي على ملفات تهيئة التعيين.", + "PROCESS_PARALLEL": "إذا تم التفعيل، تتم معالجة قوائم تشغيل الإدخال بالتوازي لتقليل زمن الوصول. ومع ذلك، هذا غير مدعوم حاليًا.", + "SLEEP_TIMER_MINS": "يُغلق البث تلقائيًا ويوقف البث بعد العدد المحدد من الدقائق من عدم النشاط.", + "TEMPLATE_PATH": "مسار أو دليل يحتوي على ملفات تهيئة القوالب.", + "UPDATE_ON_BOOT": "إذا تم التفعيل، يُشغّل Tuliprox تحديثًا كاملاً لقائمة التشغيل فورًا عند بدء التشغيل. مفيد أثناء الإعداد الأولي، لكن عادةً يُضبط على false للعمليات المستقرة.", + "USER_ACCESS_CONTROL": "إذا تم التفعيل، فإن السمات (إذا تم تعيينها)\n\n- تاريخ انتهاء الصلاحية،\n- الحالة و\n- max_connections\n\nيتم فحصها للسماح أو رفض الوصول لمستخدمي واجهة API.", + "USER_CONFIG_DIR": "مسار الدليل حيث يتم تخزين تجاوزات المستخدم وملفات التهيئة.", + "STORAGE_DIR": "الدليل الأساسي حيث يخزن Tuliprox ملفات البيانات وقواعد البيانات الخاصة به." + }, + "MESSAGING_CONFIG": { + "NOTIFY_ON": "حدد الأحداث (مثل 'info'، 'stats'، 'error') التي تُفعّل الإشعارات لهذا النوع المحدد.", + "REST_HEADERS": "رؤوس HTTP مخصصة تُرسل مع طلبات إشعار REST (بتنسيق JSON).", + "TELEGRAM_CHAT_IDS": "قائمة معرفات محادثة Telegram لتلقي الإشعارات. التنسيق: '' أو ':'." + }, + "METADATA_LOG_CONFIG": { + "LOG_ACTIVE_USER": "يسجل أي مستخدم WebUI بدأ أو يشاهد معالجة البيانات الوصفية.", + "PROGRESS_INTERVAL": "كم مرة يكتب التطبيق تحديثات التقدم أثناء تشغيل مهام البيانات الوصفية.\n\nالفواصل القصيرة تعرض مزيدًا من التقدم المباشر، والفواصل الطويلة تحافظ على السجلات أنظف.\n\nأمثلة: `10s`، `30s`، `1m`.", + "QUEUE_INTERVAL": "كم مرة يكتب التطبيق رسائل حالة الطابور إلى السجل.\n\nاستخدم قيمة أصغر إذا كنت تريد رؤية النشاط بشكل أكثر تكرارًا. استخدم قيمة أكبر إذا كانت السجلات مزدحمة جدًا.\n\nأمثلة: `15s`، `30s`، `1m`.", + "SANITIZE_SENSITIVE_INFO": "يحاول إخفاء كلمات المرور والرموز في إخراج السجل لأمان." + }, + "METADATA_UPDATE_CONFIG": { + "CACHE_PATH": "دليل لذاكرة التخزين المؤقت للبيانات الوصفية وحالات إعادة المحاولة.\n\nيتم حل المسارات النسبية مقابل `storage_dir`.\n\nأمثلة: `metadata`، `/app/data/metadata`.", + "MAX_QUEUE_SIZE": "الحد الأقصى لعدد مهام البيانات الوصفية المحفوظة في الذاكرة لعامل إدخال واحد.\n\nعند الوصول إلى هذا الحد، يتم رفض المهام الإضافية لحماية استقرار النظام واستخدام الذاكرة.", + "RETRY_DELAY": "الحد الأدنى من التأخير لجدولة إعادة الطابور المؤقتة.\n\nاستخدم هذا لمنع حلقات إعادة المحاولة السريعة الفورية عندما يتم تأجيل المهام مؤقتًا.\n\nاستخدم وحدات المدة مثل `2s`، `5s`، `10s`.", + "WORKER_IDLE_TIMEOUT": "كم من الوقت ينتظر عامل البيانات الوصفية للمهام الجديدة أثناء الخمول قبل أن يتمكن من إيقاف نفسه.\n\nالقيمة الأقل تحرر الموارد بشكل أسرع؛ القيمة الأعلى تحافظ على دفء العمال لأحمال العمل المتقطعة.\n\nاستخدم وحدات المدة مثل `30s`، `1m`، `5m`.", + "NO_CHANGE_CACHE_TTL_SECS": "كم من الوقت تُحفظ نتائج الحل التي لم تتغير (بالثواني).\n\nبينما تكون صالحة، يتم تخطي مجموعات أسباب الحل المتطابقة لنفس العنصر لتقليل العمل المتكرر.\n\nمثال: `3600` (ساعة واحدة).", + "PROBE_FAIRNESS_RESOLVE_BURST": "حد الإنصاف بين عمل الحل وعمل الفحص.\n\nبعد هذا العدد من مهام نطاق الحل المتتالية، يتم تحديد أولوية لمهمة نطاق الفحص المعلقة لتجنب تجويع الفحص.\n\nالقيم الأقل تفضل استجابة الفحص؛ القيم الأعلى تفضل إنتاجية الحل." + }, + "OUTPUT_HDHOMERUN_FORM": { + "USE_OUTPUT": "يُفعّل أو يُعطّل إنشاء المخرج تحديدًا لتنسيق HDHomeRun." + }, + "OUTPUT_M3U_FORM": { + "FILTER": "تعبير تصفية يستخدم لتحديد العناصر التي يجب تضمينها في مخرج M3U." + }, + "OUTPUT_STRM_FORM": { + "EXPORT_STYLE": "اتفاقية التسمية المستخدمة لإنشاء ملفات .strm (مثل kodi، plex، jellyfin).", + "FILTER": "تعبير تصفية يستخدم لتحديد العناصر التي يجب تصديرها كملفات .strm." + }, + "OUTPUT_XTREAM_FORM": { + "FILTER": "تعبير تصفية يستخدم لتحديد العناصر التي يجب تضمينها في مخرج Xtream.", + "TRAKT_LISTS": "يربط قوائم Trakt.tv للمزامنة مع مخرج هدف Xtream هذا." + }, + "PLAYLIST_EPG_SOURCE_SELECTOR": { + "URL": "عنوان URL المصدر المستخدم لتنزيل بيانات EPG." + }, + "PLAYLIST_SOURCE_SELECTOR": { + "PASSWORD": "كلمة مرور المصدر المستخدمة لتحميل قوائم التشغيل من مزود محمي.", + "URL": "عنوان URL المصدر المستخدم لتنزيل بيانات قائمة التشغيل.", + "USERNAME": "اسم مستخدم المصدر المستخدم لتحميل قوائم التشغيل من مزود محمي." + }, + "PROVIDER_DNS": { + "ENABLED": "يُفعّل سلوك DNS/IP-connect لهذا المزود.", + "KEEP_VHOST": "إذا تم التفعيل، يحتفظ باسم المضيف الأصلي في رأس المضيف أثناء الاتصال عبر IP المُحَل.", + "REFRESH_SECS": "فاصل تحديث DNS بالثواني لأسماء مضيف المزود (الحد الأدنى للقيمة الفعالة هو 10)." + }, + "PUSHOVER_MESSAGING_CONFIG": { + "TOKEN": "رمز API لتطبيق Pushover.", + "URL": "عنوان URL اختياري مخصص لـ Pushover API. اتركه فارغًا لاستخدام الافتراضي.", + "USER": "مفتاح مستخدم Pushover الذي يحدد متلقي الإشعار." + }, + "PROVIDER_FORM": { + "URL_SELECTION_POLICY": "يتحكم في كيفية اختيار كل طلب جديد لـ URL البدء للمزود. `resume_last_working` يبدأ من آخر URL ناجح. `restart_from_first` يبدأ دائمًا من أول URL مُكوّن مرة أخرى ولا يتجاوز الفشل إلا إذا واجه هذا الطلب خطأ.", + "URLS": "قائمة عناوين URL الأساسية لهذا المزود. تُستخدم بالترتيب ويُدور بها عند تفعيل تجاوز الفشل.", + "DNS": { + "ON_CONNECT_ERROR": "يحدد سلوك خطأ الاتصال: جرب عنوان IP المُحَل التالي أولاً، أو انتقل مباشرة إلى عنوان URL المزود التالي.", + "ON_RESOLVE_ERROR": "يحدد سلوك خطأ الحل: احتفظ بآخر عناوين IP جيدة، أو ارجع إلى الاتصالات القائمة على اسم المضيف.", + "PREFER": "تفضيل عائلة IP لحل DNS (`system`، `ipv4`، `ipv6`).", + "SCHEMES": "يقيد معالجة DNS/IP-connect على أنظمة URL المحددة (`http`، `https`)." + } + }, + "PROBE": "يتحكم Probe في إعادة محاولة تحليل البث التقني.\n\nاستخدم هذه المجموعة لتحديد خطوات إعادة المحاولة وحدود إعادة المحاولة وسلوك التهدئة لفحوصات البث القائمة على FFprobe.", + "PROBE_CONFIG": { + "BACKOFF_JITTER_PERCENT": "تباعد عشوائي يُضاف إلى تأخيرات إعادة محاولة الفحص.\n\nقيمة 20 تعني أن التأخيرات تختلف بمقدار ±20%. يساعد في توزيع عمليات إعادة المحاولة عبر العمال لتجنب مشاكل القطيع.\n\nيجب أن تكون بين 0 و95.", + "COOLDOWN": "وقت التوقف بعد استنفاد عمليات إعادة محاولة الفحص.\n\nأثناء التهدئة، يتم تخطي هذا العنصر للفحص. بعد انتهاء التهدئة، يمكن بدء الفحص مرة أخرى.\n\nأمثلة: `1d`، `3d`، `7d`.", + "MAX_ATTEMPTS": "عدد محاولات الفحص المسموح بها قبل بدء التهدئة.\n\nالقيم الأقل تقلل حمل المزود، والقيم الأعلى يمكن أن تستعيد مزيدًا من حالات الفشل المؤقتة.", + "RETRY_BACKOFF_STEP_1": "وقت الانتظار بعد أول فشل فحص.\n\nاستخدم هذا كخطوة إعادة محاولة أولى لطيفة.\n\nمثال: `10m`.", + "RETRY_BACKOFF_STEP_2": "وقت الانتظار بعد فشل الفحص الثاني.\n\nعادة ما يُضبط أعلى من الخطوة 1.\n\nمثال: `30m`.", + "RETRY_BACKOFF_STEP_3": "وقت الانتظار بعد فشل الفحص الثالث وما بعده.\n\nهذا هو أطول تأخير فحص منتظم قبل أن تتولى التهدئة.\n\nمثال: `1h`.", + "RETRY_LOAD_RETRY_DELAY": "وقت الانتظار قبل إعادة محاولة تحميل بيانات حالة إعادة المحاولة المحفوظة بعد فشل القراءة.\n\nهذا يساعد عندما يكون التخزين مشغولاً أو غير متاح مؤقتًا.\n\nأمثلة: `30s`، `1m`، `5m`.", + "USER_PRIORITY": "أولوية الاتصال المخصصة لمهام الفحص. أدنى أولوية = 127 (افتراضي)، أعلى أولوية = -128. لا تضبط أقل من 1 لأن أولوية المستخدم الافتراضية هي 0." + }, + "PROXY_CONFIG": { + "PASSWORD": "كلمة المرور أو بيانات الاعتماد المستخدمة لهذا الحساب أو الخدمة.", + "URL": "عنوان URL المستهدف لهذا الإعداد.", + "USERNAME": "اسم المستخدم لتسجيل الدخول لهذا الحساب أو الخدمة." + }, + "PROXY_USER_CREDENTIALS": { + "COMMENT": "ملاحظة خاصة اختيارية لحساب المستخدم هذا.", + "EPG_REQUEST_TIMESHIFT": "تجاوز لتحويل وقت EPG من معاملات طلب العميل.", + "EPG_TIMESHIFT": "المنطقة الزمنية أو الفرق (مثل +2:00، Europe/Berlin) لضبط أوقات EPG لهذا المستخدم.", + "EXP_DATE": "طابع Unix الزمني أو 'YYYY-MM-DD' عندما ينتهي وصول هذا المستخدم.", + "MAX_CONNECTIONS": "حد البث المتزامن لهذا المستخدم. 0 = غير محدود.", + "SOFT_CONNECTIONS": "فتحات مزود إضافية فوق max_connections. يمكن إزاحة الاتصالات الناعمة بواسطة أي اتصال عادي أو اتصال نام بأولوية أعلى.", + "SOFT_PRIORITY": "الأولوية المستخدمة أثناء استخدام اتصال هذا المستخدم لفتح ناعم. بمجرد الترقية مرة أخرى إلى الفتحة العادية، تنطبق الأولوية المنتظمة مرة أخرى.", + "PASSWORD": "كلمة مرور الوصول لقائمة التشغيل والبث لهذا المستخدم.", + "NETWORK_ACCESS_COUNTRIES": "أضف رمز بلد واحد لكل إدخال بتنسيق ISO 3166-1 alpha-2، مثل `NL`، `FR`، أو `IT`.\n\nإذا تم التعيين، يتم رفض الطلبات من بلدان أخرى ما لم تطابق شبكة مسموح بها أيضًا.", + "NETWORK_ACCESS_NETWORKS": "أضف شبكة عميل مسموح بها واحدة لكل إدخال بترميز CIDR. كل من IPv4 و IPv6 مدعومان، مثل `192.168.1.5/32`، `192.168.0.0/16`، `10.0.0.0/8`، أو `2001:db8::/32`.\n\nيتم فحص الشبكات قبل قواعد GeoIP للبلدان.", + "PLAYLIST": "قائمة التشغيل المعينة خصيصًا لسياق وكيل المستخدم.\n\nتتحكم مفاتيح التبديل `L`، `V`، و `S` في مجموعات المخرج التي يجب أن يتلقاها هذا المستخدم من هذا الهدف:\n`L` = مباشر، `V` = فيديو حسب الطلب، `S` = مسلسلات.\n\nحدد مجموعة واحدة على الأقل لتفعيل التصفية. إذا لم يتم تحديد أي مجموعة، يكون تصفية المجموعات غير نشطة ويتم تسليم جميع المجموعات للهدف المعين.", + "PROXY": "تعريفات وصول الوكيل أو الأدوار للمستخدم المعين.", + "SERVER": "عنوان أو تعريف خادم الوكيل المستهدف.", + "STATUS": "الحالة النشطة أو المعطلة لبيانات اعتماد وكيل المستخدم.", + "TOKEN": "رمز API فريد يمكن استخدامه بدلاً من اسم المستخدم/كلمة المرور.", + "UI_ENABLED": "إذا كان صحيحًا، يمكن لهذا المستخدم تسجيل الدخول إلى محرر باقة WebUI المبسط (الافتراضي صحيح).", + "PRIORITY": "أولوية الاتصال المخصصة لسياق وكيل المستخدم هذا. الافتراضي = 0، أدنى أولوية = 127، أعلى أولوية = -128." + }, + "RATE_LIMIT_CONFIG": { + "BURST_SIZE": "يحدد العدد الأولي من الاتصالات المتاحة قبل تطبيق التقييد (مثل 10).", + "ENABLED": "يُفعّل تحديد المعدل لكل IP لاتصالات الوكيل العكسي.", + "PERIOD_MILLIS": "الفاصل الزمني بالميلي ثانية الذي يتم عنده تجديد اتصال واحد (مثل 500)." + }, + "RESOLVE": "يتحكم Resolve في إعادة محاولات البحث عن البيانات الوصفية.\n\nاستخدم هذه المجموعة لضبط عدد المرات التي يتم فيها إعادة محاولة عمليات البحث الفاشلة عن البيانات الوصفية والمدة التي يجب أن ينتظرها النظام بين عمليات إعادة المحاولة. تدعم حقول المدة لواحق مثل '30s'، '10m'، '1h'، '7d'.", + "RESOLVE_CONFIG": { + "EXHAUSTION_RESET_GAP": "كم من الوقت ينتظر التطبيق قبل السماح بدورة حل جديدة للعناصر التي تم استنفادها سابقًا.\n\nاستخدم قيمًا أعلى لتجنب الطرق المتكرر على نفس الإدخالات الفاشلة.\n\nأمثلة: `30m`، `1h`، `2h`.", + "MAX_ATTEMPTS": "عدد محاولات الحل المسموح بها لعنصر واحد في دورة تحديث واحدة.\n\nبعد هذا الحد، يتم إيقاف العنصر مؤقتًا الآن وإعادة محاولة في دورة لاحقة.", + "MAX_RETRY_BACKOFF": "الحد الأقصى لوقت الانتظار بين إعادة محاولات الحل المتكررة.\n\nإذا ظل شيء ما يفشل، ينتظر التطبيق وقتًا أطول في كل مرة، لكن أبدًا أطول من هذه القيمة.\n\nأمثلة: `1m`، `10m`، `1h`.", + "MIN_RETRY_BASE": "الحد الأدنى لوقت الانتظار قبل إعادة محاولة الحل.\n\nهذا يمنع حلقات إعادة المحاولة السريعة جدًا عندما يكون لدى المصدر مشكلات مؤقتة.\n\nأمثلة: `5s`، `30s`، `1m`." + }, + "RESOURCE_RETRY_CONFIG": { + "BACKOFF_MILLIS": "وقت الانتظار بين محاولات الطلب الفاشلة (مثل 250 مللي ثانية).", + "BACKOFF_MULTIPLIER": "يضاعف الارتداد الأساسي بعد كل محاولة فاشلة لإنشاء تأخير أُسي." + }, + "REST_MESSAGING_CONFIG": { + "HEADERS": "رؤوس HTTP مخصصة لإرسالها مع الطلب (بتنسيق JSON).", + "METHOD": "طريقة HTTP المستخدمة لطلب الإشعار (مثل POST، PUT). الافتراضي: POST.", + "TEMPLATES": "قوالب Handlebars لأجسام رسائل إشعار REST.", + "URL": "عنوان URL المستهدف الذي يتلقى webhook الإشعار." + }, + "REVERSE_PROXY_CONFIG": { + "ADMISSION_STRATEGIES": "يحدد ما قد يحاوله Tuliprox أيضًا بعد استنفاد قبول المستخدم العادي.\n\nاستخدم هذا للتحكم في سماح الانتقال القصير أثناء التبديل أو لطرد بث مستخدم موجود، إما من نفس IP فقط أو عبر حساب المستخدم بالكامل، بدلاً من رفض الطلب الجديد فورًا.\n\nيتم تقييم الاستراتيجيات من الأعلى إلى الأسفل.\n\nتعمل استراتيجيات السماح فقط عندما تكون `grace_period_millis` أكبر من 0. يمكن تفعيل استراتيجية سماح واحدة فقط في وقت واحد.", + "FAILOVER_REDIRECT_PATTERNS": "أنماط تعبير منتظم تحدد استجابات خطأ HTTP التي تُفعّل دوران تجاوز الفشل التلقائي.", + "RESOURCE_REWRITE_DISABLED": "إذا تم التفعيل، يتم تعطيل إعادة كتابة URL للشعارات و EPG (يعطل أيضًا التخزين المؤقت).", + "REWRITE_SECRET": "سر دائم لعناوين URL للموارد المستقرة عبر عمليات إعادة التشغيل.\nمثال:\n```\nreverse_proxy:\n rewrite_secret: A1B2C3D4E5F60718293A4B5C6D7E8F90\n```", + "STREAM_HISTORY": "يُخزّن بيانات تتبع دورة حياة البث في ملفات ثنائية يومية. هذا هو مصدر البيانات الخام لتصحيح الأخطاء وتحليل QoS وضبط تجاوز الفشل المستقبلي.", + "QOS_AGGREGATION": "يبني لقطات QoS مضغوطة من سجل البث في الخلفية. تم تصميم هذه اللقطات كإدخال لاحق لقرارات تجاوز الفشل." + }, + "REVERSE_PROXY_DISABLED_HEADER_CONFIG": { + "CLOUDFLARE_HEADER": "يزيل رؤوس Cloudflare المحددة (مثل CF-Connecting-IP) قبل التوجيه إلى الأعلى.", + "CUSTOM_HEADER": "قائمة رؤوس HTTP مخصصة لإزالتها بشكل غير مشروط قبل التوجيه.", + "REFERER_HEADER": "يزيل على وجه التحديد رأس 'Referer' من الطلبات الواردة.", + "X_HEADER": "يُزيل أي رأس يبدأ بـ 'X-' (مثل X-Forwarded-For) من الطلبات الواردة." + }, + "SCHEDULES_CONFIG": { + "SCHEDULE": "تعبير cron قياسي يحدد متى يتم تشغيل المهمة (مثل '0 0 8 * * * *').", + "TARGETS": "قائمة قوائم التشغيل المستهدفة للتحديث أثناء مهمة PlaylistUpdate المجدولة.", + "TYPE": "نوع المهمة التي سيتم تنفيذها في الوقت المجدول (مثل PlaylistUpdate، LibraryScan)." + }, + "STAGED_INPUT": { + "HEADERS": "رؤوس HTTP مخصصة تُرسل عند استرداد هذه التهيئة المُرحّلة.", + "PASSWORD": "كلمة المرور أو بيانات الاعتماد المستخدمة لهذا الحساب أو الخدمة.", + "URL": "عنوان URL المصدر للإدخال المُرحّل. استخدم http(s):// أو file:// أو provider:///... . عناوين URL الدفعية غير مدعومة للإدخالات المُرحّلة.", + "USERNAME": "اسم المستخدم لتسجيل الدخول لهذا الحساب أو الخدمة." + }, + "STREAM_BUFFER_CONFIG": { + "ENABLED": "يُخزّن البث في الذاكرة. مفيد إذا كان المزود أسرع من مشغل المستهلك.", + "SIZE": "حجم مخزن البث المؤقت في أجزاء بحجم 8192 بايت. القيمة 1024 تعني تقريبًا 8 ميجابايت." + }, + "STREAM_CONFIG": { + "CATCHUP_SESSION_TTL_SECS": "كم من الوقت يُحافظ على حجز حساب مزود catchup نشطًا بعد آخر طلب حتى يظل الاستئناف/التقديم على نفس حساب المزود.", + "GRACE_PERIOD_HOLD_STREAM": "إذا تم التفعيل، يحتفظ ببث المزود مفتوحًا لفترة وجيزة بعد فصل آخر عميل للسماح بإعادة الاتصال السريعة.", + "GRACE_PERIOD_MILLIS": "المدة بالميلي ثانية للسماح بتجاوز max_connections أثناء تبديل القنوات السريع (الافتراضي 2000).", + "GRACE_PERIOD_TIMEOUT_SECS": "كم ثانية للانتظار قبل إنهاء الاتصالات الزائدة بعد انتهاء فترة السماح (الافتراضي 5).", + "HLS_SESSION_TTL_SECS": "كم من الوقت يُحافظ على حجز حساب مزود HLS نشطًا بين طلبات قائمة التشغيل أو المقطع حتى يمكن إعادة استخدام حساب المزود نفسه.", + "METRICS_ENABLED": "يُفعّل عدادات النطاق الترددي وبيانات البث المباشر في جدول البث. عطّل هذا لإزالة عبء قياس البث بالكامل.", + "RETRY": "عدد المرات لإعادة محاولة الاتصال بالمزود عند فشل البث الأولي.", + "SHARED_BURST_BUFFER_MB": "حجم المخزن المؤقت الأولي بالميجابايت لتوفير بيانات فورية للعملاء الجدد المنضمين إلى بث مشترك.", + "THROTTLE": "يُفعّل تحديد النطاق الترددي للبث الصادر.", + "THROTTLE_KBPS": "الحد الأقصى للنطاق الترددي بالكيلوبت في الثانية لكل بث." + }, + "STREAM_HISTORY_CONFIG": { + "STREAM_HISTORY_ENABLED": "يُفعّل تتبع دورة حياة البث الدائم. يُسجل Tuliprox الاتصالات الناجحة وعمليات الفصل وأعطال بدء التشغيل في ملفات سجل يومية.", + "STREAM_HISTORY_BATCH_SIZE": "عدد سجلات السجل المخزنة في المخزن المؤقت قبل تفريغ كتلة كاتب على القرص. القيم الأعلى تقلل تكرار الكتابة، والقيم الأقل تجعل السجلات مرئية في وقت سابق.", + "STREAM_HISTORY_RETENTION_DAYS": "كم من أقسام يوم UTC من سجل البث يتم الاحتفاظ بها قبل تقليم الملفات القديمة.", + "STREAM_HISTORY_DIRECTORY": "الدليل المستخدم لملفات سجل البث. يتم حل المسارات النسبية مقابل storage_dir." + }, + "QOS_AGGREGATION_CONFIG": { + "ENABLED": "يُفعّل عامل تجميع QoS في الخلفية. له تأثير فقط عند تفعيل سجل البث.", + "INTERVAL_SECS": "فاصل الاقتراع بالثواني لإعادة بناء لقطات QoS من سجل البث. القيم الأقل تُحدّث بشكل أسرع لكنها تزيد من عمل الخلفية." + }, + "STRM_TARGET_OUTPUT": { + "ADD_QUALITY_TO_FILENAME": "إذا كان صحيحًا، تُلحق جودة الوسائط (مثل 1080p، 4K) باسم ملف .strm المُولّد.", + "CLEANUP": "إذا تم التفعيل، يتم إفراغ دليل الوجهة قبل إنشاء ملفات .strm جديدة. استخدم بحذر!", + "DIRECTORY": "مسار نظام الملفات حيث يتم إنشاء ملفات .strm وهياكل المجلدات.", + "STRM_PROPS": "قائمة خصائص بث Kodi (مثل inputstream=...) لتضمينها في ملفات .strm.", + "UNDERSCORE_WHITESPACE": "إذا تم التفعيل، يستبدل جميع المسافات بشرطات سفلية في أسماء الملفات والمسارات.", + "USERNAME": "المستخدم الذي سيتم تضمين بياناته في عناوين URL لـ .strm." + }, + "TARGET_FORM": { + "FILTER": "تعبير تصفية عام مطبق على الهدف قبل إنشاء مخرجات صريحة.", + "FORCE_REDIRECT": "يُجبر البث مباشرة على الوكيل أو المزود بدلاً من الحل بشكل فردي.", + "PROCESSING_ORDER": "يحدد الأولوية أو التسلسل الذي يتم به مطابقة قنوات الهدف وإدراجها." + }, + "TELEGRAM_CONFIG": { + "BOT_TOKEN": "رمز Telegram Bot API. احصل عليه من @BotFather.", + "MARKDOWN": "إذا تم التفعيل، يتم إرسال الرسائل كنص مُنسق MarkdownV2.", + "TEMPLATES": "قوالب Handlebars لأجسام رسائل Telegram." + }, + "TELEGRAM_MESSAGING_CONFIG": { + "BOT_TOKEN": "رمز Telegram Bot API. احصل عليه من @BotFather.", + "CHAT_IDS": "قائمة معرفات محادثة Telegram لتلقي الإشعارات. التنسيق: '' أو ':'.", + "MARKDOWN": "إذا تم التفعيل، يتم إرسال الرسائل كنص مُنسق MarkdownV2.", + "TEMPLATES": "قوالب Handlebars لأجسام رسائل Telegram." + }, + "TMDB_CONFIG": { + "API_KEY": "مفتاح TMDB API الخاص بك.\n\nتحتاج إلى هذا فقط إذا كنت تريد استخدام حدود حساب TMDB الخاص بك.", + "CACHE_DURATION_DAYS": "كم يوم تبقى نتائج TMDB مخزنة مؤقتًا قبل التحديث.\n\nذاكرة التخزين المؤقت الأطول تعني استدعاءات API أقل.", + "COOLDOWN": "توقف بعد بحث TMDB ناجح لم يُرجع أي تطابق.\n\nهذا يمنع إعادة المحاولات التي لا نهاية لها للعناوين غير الموجودة حاليًا في TMDB.\n\nأمثلة: `1d`، `3d`، `7d`.", + "ENABLED": "يُفعّل البحث عن البيانات الوصفية من TMDB.\n\nأوقف تشغيل هذا إذا كنت لا تريد مطابقة العنوان القائمة على TMDB.", + "LANGUAGE": "اللغة المفضلة لعناوين وبيانات TMDB الوصفية.\n\nمثال: `en-US`.", + "RATE_LIMIT_MS": "الحد الأدنى لوقت الانتظار بين طلبات TMDB بالميلي ثانية.\n\nالقيم الأعلى ألطف على واجهة TMDB البرمجية.\n\nأمثلة: `250`، `500`، `1000`.", + "MATCH_THRESHOLD": "الحد الأدنى لدرجة المطابقة لبحث TMDB. يجب أن تكون القيمة بين 1 - 100" + }, + "TRAKT_LIST_CONFIG": { + "CATEGORY_NAME": "اسم الفئة المستخدم في قائمة التشغيل للعناصر من قائمة Trakt هذه.", + "FUZZY_MATCH_THRESHOLD": "حد التشابه لمطابقة عناصر Trakt مع تدفقات المزود (0-100).", + "LIST_SLUG": "السلاج (جزء من URL) لقائمة Trakt المراد جلبها.", + "USER": "اسم مستخدم Trakt الذي يملك القائمة." + }, + "TRAKT_LIST_FORM": { + "TRAKT_CONTENT_TYPE": "يحدد ما إذا كان سيتم جلب 'أفلام' أو 'عروض' من قائمة Trakt المعينة." + }, + "VIDEO_CONFIG": { + "DOWNLOAD_RETRY_BACKOFF_JITTER_PERCENT": "تباعد عشوائي يُضاف إلى تأخيرات إعادة محاولة التنزيل.\n\nقيمة 20 تعني أن التأخيرات تختلف بمقدار ±20% تقريبًا. يساعد هذا في تجنب طفرات إعادة المحاولة عندما تفشل العديد من التنزيلات في نفس الوقت.\n\nيجب أن تبقى بين 0 و95.", + "DOWNLOAD_RETRY_MAX_ATTEMPTS": "الحد الأقصى لعدد محاولات إعادة المحاولة المؤقتة قبل وضع علامة على النقل على أنه فاشل.\n\nينطبق هذا على التنزيلات وتسجيلات البث المباشر بعد فشل بدء التشغيل أو النقل المؤقت.\n\nيجب أن يكون 1 على الأقل.", + "DOWNLOAD_RETRY_BACKOFF_INITIAL": "وقت الانتظار الأولي بالثواني بعد أول فشل تنزيل مؤقت.\n\nتنمو عمليات إعادة المحاولة اللاحقة من هذه القاعدة باستخدام المضاعف المُكوّن.", + "DOWNLOAD_RETRY_BACKOFF_MULTIPLIER": "المضاعف المطبق على كل تأخير إعادة محاولة مؤقت لاحق.\n\nمثال: القيمة الأولية 3 والمضاعف 3.0 ينتج تقريبًا 3s، 9s، 27s حتى يتم الوصول إلى الحد الأقصى للتأخير.", + "DOWNLOAD_RETRY_BACKOFF_MAX": "الحد الأقصى لتأخير إعادة المحاولة بالثواني.\n\nبمجرد وصول النمو الأسي إلى هذه القيمة، تظل عمليات إعادة المحاولة اللاحقة محددة هنا.", + "EXTENSIONS": "قائمة امتدادات ملفات الفيديو الصالحة (مثل mp4، avi، mkv). تُصنّف التدفقات التي تنتهي بهذه على أنها VODs فيديو.", + "HEADERS": "رؤوس HTTP مخصصة مطبقة على طلبات تنزيل الفيديو (مثل User-Agent).", + "MAX_BACKGROUND_PER_PROVIDER": "الحد الأقصى لعدد عمليات النقل ذات الأولوية الخلفية المسموح بها لكل مزود قبل انتظار وظائف الخلفية الإضافية.\n\nاضبط على 0 لتعطيل هذا الحد.", + "RESERVE_SLOTS_FOR_USERS": "يحتفظ بهذا العدد من فتحات المزود خالية للبث العادي للمستخدم قبل السماح للتنزيلات الخلفية باستهلاكها.\n\nاضبط على 0 لتعطيل هامش الاحتياطي.", + "WEB_SEARCH": "قالب URL للبحث الخارجي على الويب (مثل IMDb). {} يُستبدل بالعنوان." + }, + "VIDEO_DOWNLOAD_CONFIG": { + "DIRECTORY": "دليل الوجهة لملفات الفيديو المنزّلة.", + "EPISODE_PATTERN": "تعبير منتظم مع مجموعة التقاط 'episode' لتنظيف أسماء ملفات المسلسلات.\nمثال:\n```\nepisode_pattern: '.*(?P[Ss]\\d{1,2}.*?[Ee]\\d{1,2}).*'\n```", + "ORGANIZE_INTO_DIRECTORIES": "إذا تم التفعيل، يُنشئ مجلدات فرعية لكل فيلم أو مسلسل أثناء التنزيل." + }, + "WEBUI_CONFIG": { + "CONTENT_SECURITY_POLICY_CUSTOM_ATTRIBUTES": "توجيهات CSP (سياسة أمان المحتوى) المخصصة المطبقة على واجهة الويب." + }, + "WEB_AUTH_CONFIG": { + "ENABLED": "يُفعّل أو يُعطّل المصادقة لـ Web-UI.", + "ISSUER": "اسم المُصدر المُضمّن في رموز مصادقة JWT.", + "SECRET": "المفتاح السري المستخدم لتوقيع والتحقق من رموز مصادقة JWT. تغيير هذا يُسجل خروج الجميع.", + "TOKEN_TTL_MINS": "كم دقيقة تظل جلسة تسجيل الدخول صالحة (الافتراضي 30).", + "USERFILE": "مسار الملف النصي الذي يحتوي على مستخدمي WebUI (الافتراضي user.txt).", + "GROUPFILE": "مسار الملف النصي الذي يحتوي على مجموعات WebUI والأذونات (الافتراضي groups.txt)." + }, + "WEB_UI_CONFIG": { + "ENABLED": "يُفعّل أو يُعطّل واجهة الإدارة المركزية المستندة إلى الويب.", + "KICK_SECS": "المدة بالثواني التي يتم فيها حظر المستخدم المُطرود من إعادة الاتصال (الافتراضي 90).", + "PATH": "بادئة مسار اختيارية (مثل /ui) لتكامل الوكيل العكسي.", + "PLAYER_SERVER": "تجاوز خادم اختياري يُستخدم خصيصًا لمشغل الويب المدمج.", + "USER_UI_ENABLED": "يُفعّل أو يُعطّل محرر باقة البسيط للمستخدمين القياسيين.", + "COMBINE_VIEWS_STATS_STREAMS": "إذا تم التعيين، يتم دمج طرق عرض الإحصائيات والبث في عرض واحد.", + "LANDING_PAGE": "العرض الافتراضي المعروض عند تحميل واجهة الويب.", + "STREAM_INFO": "يتحكم في حقول معلومات البث المرئية في عرض البث.", + "HIDE_GROUP": "إذا تم التفعيل، لا يتم عرض مجموعة البث.", + "HIDE_IP": "إذا تم التفعيل، لا يتم عرض عناوين IP الخاصة بالعميل.", + "HIDE_COUNTRY": "إذا تم التفعيل، لا يتم عرض معلومات بلد العميل.", + "HIDE_SHARED": "إذا تم التفعيل، يتم إخفاء مؤشر البث المشترك.", + "HIDE_DURATION": "إذا تم التفعيل، لا يتم عرض مدة البث.", + "HIDE_BANDWIDTH": "إذا تم التفعيل، يتم إخفاء مقاييس النطاق الترددي لكل بث.", + "HIDE_TRANSFERRED": "إذا تم التفعيل، لا يتم عرض إجمالي البايتات المُنقولة.", + "HIDE_PLAYER": "إذا تم التفعيل، يتم إخفاء حقل المشغل/وكيل المستخدم.", + "HIDE_USER_COMMENT": "إذا تم التفعيل، لا يتم عرض تعليقات المستخدم على التدفقات.", + "HIDE_EPG": "إذا تم التفعيل، يتم قمع معلومات دليل البرامج الإلكتروني للتدفقات." + }, + "XTREAM_TARGET_OUTPUT": { + "SKIP_LIVE_DIRECT_SOURCE": "إذا تم التفعيل، لا يتم ربط التدفقات المباشرة مباشرة ولكن يتم تحويلها/حلها.", + "SKIP_SERIES_DIRECT_SOURCE": "إذا تم التفعيل، لا يتم ربط حلقات المسلسلات مباشرة ولكن يتم تحويلها/حلها.", + "SKIP_VIDEO_DIRECT_SOURCE": "إذا تم التفعيل، لا يتم ربط ملفات VOD مباشرة ولكن يتم تحويلها/حلها." + } + }, + "HINT": { + "CONFIG": { + "API_PROXY": { + "PATH": "بادئة مسار URL" + }, + "LOG": { + "ACTIVE_USER": "يُفعّل سجل معلومات المستخدم النشط" + }, + "PANEL": { + "ENABLE": "يُفعّل API لوحة لهذا الإدخال لتكوين التزويد.", + "INFO": "كوين التزويد عبر API لوحة المزود العام لكل إدخال. استخدم قيمة معامل الاستعلام 'auto' لـ api_key وبيانات الاعتماد حيثما يلزم." + }, + "PROXY": { + "CUSTOM_HEADERS": "الرؤوس المدرجة هنا يتم إزالتها قبل إرسال طلب الأعلى", + "DISABLED_HEADER": "يزيل الرؤوس قبل إرسال الطلبات إلى مزود الأعلى", + "REFERER_HEADER": "إذا تم التفعيل، لا يُرسل رأس المُحيل إلى المزود", + "RESOURCE_REWRITE_DISABLE": "إذا تم التفعيل، يتم تعطيل إعادة كتابة URL للموارد في وضع الوكيل العكسي", + "THROTTLE": "الوحدات المدعومة هي KB/s,MB/s,KiB/s,MiB/s,kbps,mbps,Mibps", + "X_HEADER": "إذا تم التفعيل، تتم إزالة الرؤوس التي تبدأ بـ 'X-' قبل توجيه الطلب" + }, + "USER": { + "TARGET_FORCE_REDIRECT": "يتم تعيين إعادة التوجيه الإجباري للهدف" + }, + "WEB_UI": { + "PATH": "تكوين بادئة مسار، مما يسمح بالوصول إليها خلف وكيل عكسي تحت مسار فرعي مخصص. مثل https://yourdomain.com/tuliprox" + } + } + }, + "INFO": { + "PROCESS_PARALLEL": "المعالجة المتوازية: إذا كان لديك عدة مزودين، يمكنك تفعيلها. الوصول إلى مزودك بالتوازي يمكن أن يسبب حظرًا.", + "RESTART_TO_APPLY_CHANGES": "تحتاج إلى إعادة التشغيل لتطبيق التغييرات.", + "SCHEDULE_EXAMPLE": "مثال على الجدول", + "WEB_ROOT": "Web-Root: يحتوي على ملفات واجهة الويب. عادة ما يتم وضعها في نفس الدليل تحت ./web" + }, + "LABEL": { + "ALL_TARGETS": "جميع الأهداف", + "ACCEPT_INSECURE_SSL_CERTIFICATES": "قبول شهادات SSL غير الآمنة", + "ACTIVE": "نشط", + "ACTIVE_PROVIDER_CONNECTIONS": "اتصالات المزود النشطة", + "ACTIVE_USER": "المستخدم النشط", + "ACTIVE_USERS": "المستخدمون النشطون", + "ACTIVE_USER_CONNECTIONS": "اتصالات المستخدم النشطة", + "ADD_ALIAS": "إضافة اسم مستعار", + "ADD_ATTRIBUTE": "إضافة سمة", + "ADD_CHAT_ID": "إضافة معرف محادثة", + "ADD_DEVICE": "إضافة جهاز", + "ADD_DIRECTORY": "إضافة دليل", + "ADD_EPG_SOURCE": "إضافة مصدر EPG", + "ADD_EXTENSION": "إضافة امتداد", + "ADD_FORMAT": "إضافة تنسيق", + "ADD_HEADER": "إضافة رأس", + "ADD_COUNTRY": "إضافة بلد", + "ADD_NETWORK": "إضافة شبكة", + "ADD_MAPPING": "إضافة تعيين", + "ADD_PATTERN": "إضافة نمط", + "ADD_PROPERTY": "إضافة خاصية", + "ADD_PROVIDER": "إضافة مزود", + "ADD_QUALITY_TO_FILENAME": "الجودة", + "ADD_SERVER": "إضافة خادم", + "ADD_STRIP_ENTRY": "إضافة إدخال تجريد", + "ADD_TRAKT_CHART": "إضافة مخطط Trakt", + "ADD_TRAKT_LIST": "إضافة قائمة Trakt", + "ADD_URL": "إضافة URL", + "ADD_WATCH": "إضافة مراقبة", + "ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST": "طرد آخر بث لنفس الـ IP", + "ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST": "طرد أقدم بث لنفس الـ IP", + "ADMISSION_STRATEGY_EVICT_USER_LATEST": "طرد آخر بث للمستخدم", + "ADMISSION_STRATEGY_EVICT_USER_OLDEST": "طرد أقدم بث للمستخدم", + "ADMISSION_STRATEGY_GRACE_HOLD_STREAM": "بث سماح معلق", + "ADMISSION_STRATEGY_GRACE_INSTANT_STREAM": "بث سماح فوري", + "ADVANCED": "متقدم", + "ALIAS": "اسم مستعار", + "ALIASES": "أسماء مستعارة", + "ALIAS_NAME": "اسم الاسم المستعار", + "ALL": "الكل", + "ALLOWED_COUNTRIES": "البلدان المسموح بها", + "ALLOWED_NETWORKS": "الشبكات المسموح بها", + "ACCOUNT_TOKEN": "رمز الحساب", + "ALLOW_RELAY": "السماح بالترحيل", + "APPLY_TO_FIRST_SEGMENTS": "تطبيق على الأجزاء الأولى", + "API": "API", + "API_CONFIG": "API", + "API_CONFIGURATION": "تكوين API", + "API_KEY": "مفتاح API", + "API_PROXY": "وكيل API", + "API_PROXY_CONFIG": "تكوين وكيل API", + "API_URL": "عنوان URL لـ API", + "API_USER_AGENT": "وكيل المستخدم", + "API_VERSION": "إصدار API", + "AUDIO": "صوت", + "AUTH": "مصادقة", + "AUTO": "تلقائي", + "BACKOFF_MILLIS": "ارتداد (مللي ثانية)", + "BACKOFF_MULTIPLIER": "مضاعف الارتداد", + "BANDWIDTH": "النطاق الترددي", + "BACKUP_DIR": "دليل النسخ الاحتياطي", + "BEST_MATCH_THRESHOLD": "حد أفضل تطابق", + "BODY_TEMPLATE": "قالب الجسم", + "BOT_TOKEN": "رمز الروبوت", + "BUFFER_ENABLED": "المخزن المؤقت مفعّل", + "BUFFER_SIZE": "حجم المخزن المؤقت", + "BUILD_TIME": "وقت البناء", + "BURST_SIZE": "حجم الدفعة", + "CACHE": "ذاكرة تخزين مؤقت", + "CACHE_BYTES": "بايتات ذاكرة التخزين المؤقت", + "CACHE_BYTES_PER_SESSION": "بايتات ذاكرة التخزين المؤقت لكل جلسة", + "CACHE_DIR": "دليل ذاكرة التخزين المؤقت", + "CACHE_DURATION": "مدة ذاكرة التخزين المؤقت", + "CACHE_DURATION_DAYS": "مدة ذاكرة التخزين المؤقت (أيام)", + "CACHE_PATH": "مسار ذاكرة التخزين المؤقت", + "CANCEL": "إلغاء", + "CF_HEADER": "إزالة رؤوس Cloudflare", + "CHANNEL": "قناة", + "CHANNELS": "قنوات", + "CHAT_IDS": "معرفات المحادثة", + "CLEANUP": "تنظيف", + "CATCHUP_SESSION_TTL_SECS": "TTL جلسة Catchup (ثواني)", + "CLEAR": "مسح", + "CLIENT_IP": "IP العميل", + "CLOSE": "إغلاق", + "CLUSTER": "مجموعة", + "COMMENT": "تعليق", + "CONFIG": "تكوين", + "CONFIGURATION": "التكوين", + "CONFIG_HOT_RELOAD": "إعادة التحميل الساخن للتكوين", + "CONNECT_TIMEOUT_SECS": "مهلة الاتصال (ثواني)", + "INTERNER_GC_INTERVAL_SECS": "فاصل GC للمُدمج (ثواني)", + "INTERNER_GC_MIN_POOL_SIZE": "الحد الأدنى لحجم تجمع GC للمُدمج", + "CONTENT_SECURITY_POLICY": "سياسة أمان المحتوى", + "CONTENT_TYPE": "نوع المحتوى", + "COPY_CREDENTIALS": "نسخ بيانات الاعتماد", + "COPY_DETAILS": "نسخ التفاصيل", + "COPY_LINK_PROVIDER_URL": "نسخ عنوان URL للمزود", + "COPY_LINK_TULIPROX_VIRTUAL_ID": "نسخ المعرف الافتراضي", + "COPY_LINK_TULIPROX_WEBPLAYER_URL": "نسخ عنوان URL لمشغل الويب", + "COUNTER": "عداد", + "COUNTRY": "البلد", + "CPU": "وحدة المعالجة المركزية", + "CREATE": "إنشاء", + "CREATED_AT": "تم الإنشاء في", + "CUSTOM": "مخصص", + "CUSTOM_ATTRIBUTES": "سمات مخصصة", + "CUSTOM_HEADERS": "رؤوس مخصصة", + "CUSTOM_STREAM_RESPONSE_PATH": "دليل استجابة البث المخصص", + "CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS": "مهلة استجابة البث المخصصة (ثواني)", + "CUSTOM_STREAM_RESPONSE_ENABLED": "استجابة البث المخصصة مفعّلة", + "CUSTOM_STREAM_RESPONSE_ERROR_STATUS": "حالة خطأ HTTP لاستجابة البث المخصصة", + "DASHBOARD": "لوحة التحكم", + "DEFAULT_EPG": "EPG الافتراضي", + "DEFAULT_USER_AGENT": "وكيل المستخدم الافتراضي", + "DELAY": "تأخير", + "DELETE": "حذف", + "DESELECT_ALL": "إلغاء تحديد الكل", + "DEVICE": "جهاز", + "DEVICES": "أجهزة", + "DEVICE_AUTH": "المصادقة", + "DEVICE_TYPE": "نوع الجهاز", + "DEVICE_UDN": "UDN الجهاز", + "DIRECTORY": "دليل", + "DISABLED": "معطل", + "DISABLED_HEADER": "رأس معطل", + "DISCORD": "Discord", + "DISK": "القرص", + "DISK_ALERT": "تنبيه القرص", + "DISK_ALERT_CRITICAL_PERCENT": "العتبة الحرجة (٪)", + "DISK_ALERT_REPEAT_INTERVAL_SECS": "فاصل إعادة الإشعار (ثواني)", + "DISK_ALERT_WARN_PERCENT": "عتبة التحذير (٪)", + "DISK_BASED_PROCESSING": "المعالجة القائمة على القرص", + "DOCUMENTATION": "التوثيق", + "DOCUMENTATION_CONTENT": "ابدأ مع tuliprox: الميزات والإعداد والأمثلة", + "DNS_CONNECT_ROTATE_PROVIDER_URL": "تدوير URL المزود", + "DNS_CONNECT_TRY_NEXT_IP": "جرب IP التالي", + "DNS_ENABLED": "DNS مفعّل", + "DNS_KEEP_VHOST": "الاحتفاظ بـ VHost في DNS", + "DNS_MAX_ADDRS": "الحد الأقصى لعناوين DNS", + "DNS_ON_CONNECT_ERROR": "DNS عند خطأ الاتصال", + "DNS_ON_RESOLVE_ERROR": "DNS عند خطأ الحل", + "DNS_PREFER": "تفضيل DNS", + "DNS_PREFER_IPV4": "IPv4", + "DNS_PREFER_IPV6": "IPv6", + "DNS_PREFER_SYSTEM": "النظام", + "DNS_REFRESH_SECS": "تحديث DNS (ثواني)", + "DNS_RESOLVE_FALLBACK_TO_HOSTNAME": "الرجوع إلى اسم المضيف", + "DNS_RESOLVE_KEEP_LAST_GOOD": "الاحتفاظ بآخر جيد", + "DNS_SCHEME_HTTP": "HTTP", + "DNS_SCHEME_HTTPS": "HTTPS", + "DNS_SCHEMES": "أنظمة DNS", + "DOWNLOAD": "تنزيل", + "DOWNLOADS": "التنزيلات", + "DOWNLOAD_CANCEL": "ملغى", + "DOWNLOAD_DOWNLOADED": "تم التنزيل", + "DOWNLOAD_FILE_SIZE": "حجم الملف", + "DOWNLOAD_FINISHED": "مكتمل", + "DOWNLOAD_QUEUE": "الطابور", + "ATTEMPT": "محاولة", + "NEXT_RETRY": "إعادة المحاولة التالية", + "DOWNLOAD_RETRY_BACKOFF_JITTER_PERCENT": "تباعد إعادة محاولة التنزيل (٪)", + "DOWNLOAD_RETRY_MAX_ATTEMPTS": "الحد الأقصى لمحاولات إعادة محاولة التنزيل", + "DOWNLOAD_RETRY_BACKOFF_INITIAL": "ارتداد إعادة محاولة التنزيل الأولي (ثواني)", + "DOWNLOAD_RETRY_BACKOFF_MULTIPLIER": "مضاعف ارتداد إعادة محاولة التنزيل", + "DOWNLOAD_RETRY_BACKOFF_MAX": "الحد الأقصى لارتداد إعادة محاولة التنزيل (ثواني)", + "DOWNLOAD_STATE_COMPLETED": "مكتمل", + "DOWNLOAD_STATE_DOWNLOADING": "جارٍ التنزيل", + "DOWNLOAD_STATE_FAILED": "فشل", + "DOWNLOAD_STATE_PAUSED": "متوقف مؤقتًا", + "DOWNLOAD_STATE_QUEUED": "في الطابور", + "DOWNLOAD_STATE_RETRY_WAITING": "في انتظار إعادة المحاولة", + "DOWNLOAD_STATE_SCHEDULED": "مجدول", + "DOWNLOAD_STATE_WAITING_FOR_CAPACITY": "في انتظار السعة", + "DURATION": "المدة", + "EDIT": "تحرير", + "EMPTY": "", + "ENABLED": "مفعّل", + "EPG": "EPG", + "EDIT_EPG_SMART_MATCH": "تحرير المطابقة الذكية لـ EPG", + "EPG_LOGO_OVERRIDE": "تجاوز الشعار", + "EPG_REQUEST_TIMESHIFT": "إزاحة وقت طلب EPG", + "EPG_SMART_MATCH": "المطابقة الذكية لـ EPG", + "EPG_SOURCES": "مصادر EPG", + "EPG_SOURCE_URL": "عنوان URL لمصدر EPG", + "EPG_TIMESHIFT": "إزاحة وقت EPG", + "EPISODE_PATTERN": "نمط الحلقة", + "EXAMPLES": "أمثلة", + "EXPORT_STYLE": "نمط التصدير", + "EXP_DATE": "تاريخ الانتهاء", + "EXTENDED_ATTRIBUTES": "سمات موسعة", + "EXTENSIONS": "الامتدادات", + "FAILOVER_REDIRECT_PATTERNS": "أنماط إعادة توجيه تجاوز الفشل", + "FALLBACK_TO_FILENAME": "الرجوع إلى اسم الملف", + "FFPROBE": "FFprobe", + "FFPROBE_ENABLED": "FFprobe مفعّل", + "FFPROBE_TIMEOUT": "مهلة FFprobe (ثانية)", + "FIELD": "حقل", + "FILENAME": "اسم الملف", + "FILTER": "تصفية", + "FIRMWARE_NAME": "اسم البرنامج الثابت", + "FIRMWARE_VERSION": "إصدار البرنامج الثابت", + "FLAT": "مسطح", + "FORCE_REDIRECT": "إعادة التوجيه الإجباري", + "FORMAT": "تنسيق", + "FORMATS": "تنسيقات", + "FRIENDLY_NAME": "اسم ودود", + "FUZZY_MATCHING": "المطابقة الضبابية", + "GEOIP": "Geo-IP", + "GEOIP_UNAVAILABLE_POLICY_DENY": "رفض", + "GEOIP_UNAVAILABLE_POLICY_ALLOW": "سماح", + "GEOIP_UNAVAILABLE_POLICY": "سياسة عدم توفر Geo-IP", + "GITHUB": "GitHub", + "GRACE_PERIOD": "فترة السماح مللي ثانية", + "GRACE_PERIOD_HOLD_STREAM": "الاحتفاظ بالبث لفترة السماح", + "GRACE_PERIOD_MILLIS": "فترة السماح (ميلي ثانية)", + "GRACE_PERIOD_TIMEOUT": "مهلة فترة السماح ثانية", + "GRACE_PERIOD_TIMEOUT_SECS": "مهلة فترة السماح (ثواني)", + "HLS_SESSION_TTL_SECS": "TTL جلسة HLS (ثواني)", + "GROUP": "مجموعة", + "GROUPS": "مجموعات", + "GROUP_PATTERN": "نمط المجموعة", + "HDHOMERUN": "hdhomerun", + "HDHOMERUN_CONFIG": "HdHomerun", + "HEADERS": "رؤوس", + "HEADER_NAME": "اسم الرأس", + "HEADER_VALUE": "قيمة الرأس", + "HELP": "مساعدة", + "HLS": "HLS", + "HLS_CACHE_PROXY": "وكيل ذاكرة التخزين المؤقت HLS", + "H264": "H.264", + "HEVC": "HEVC", + "HLS_CACHE_SEGMENT_REPAIR": "إصلاح مقاطع HLS في ذاكرة التخزين المؤقت", + "HOST": "مضيف", + "ID": "معرف", + "IGNORE_LOGO": "تجاهل الشعار", + "INCLUDE_TYPE_IN_URL": "تضمين النوع في URL", + "INFINITE": "لا نهائي", + "INPUTS": "الإدخالات", + "INTERVAL_SECS": "فاصل الثواني", + "INPUT_TYPE": "نوع الإدخال", + "IP_CHECK": "فحص IP", + "IP_CHECK_CONFIG": "فحص IP", + "IP_INFO": "معلومات IP", + "IPv4": "IPv4", + "IPv6": "IPv6", + "ISSUER": "المُصدر", + "JELLYFIN": "Jellyfin", + "JOIN_ON_DISCORD": "انضم إلينا على Discord", + "KICK": "طرد", + "KICK_DURATION": "مدة الطرد (ثواني)", + "KODI": "Kodi", + "LANGUAGE": "اللغة", + "LIBRARY": "المكتبة المحلية", + "LIBRARY_CONFIG": "المكتبة", + "LIBRARIES": "المكتبات", + "LIST": "قائمة", + "LIVE": "مباشر", + "LIVE_SHORT": "م", + "LIVE_STREAMS": "البث المباشر", + "LIVE_STREAM_USE_PREFIX": "استخدام بادئة", + "LIVE_STREAM_WITHOUT_EXTENSION": "بدون امتداد", + "LOG": "سجل", + "LOGIN": "تسجيل الدخول", + "LOGOUT": "تسجيل الخروج", + "LOGO_OVERRIDE": "تجاوز الشعار", + "LOG_ACTIVE_USER": "تسجيل المستخدم النشط", + "LOG_CONFIG": "سجل", + "LOG_LEVEL": "مستوى السجل", + "M3U": "m3u", + "M3U_BATCH": "m3u دفعة", + "MAIN": "رئيسي", + "MAIN_CONFIG": "رئيسي", + "MEDIA_SERVER": "خادم الوسائط", + "MANUFACTURER": "الشركة المصنعة", + "MAPPER": "مُعيّن", + "MAPPING": "تعيين", + "MAPPING_PATH": "مسار التعيين", + "MARKDOWN": "Markdown", + "MASK_REDIRECT_URL": "إخفاء عنوان URL للتحويل", + "MATCH_AS_ASCII": "مطابقة ASCII", + "MATCH_THRESHOLD": "حد المطابقة", + "MAX_ATTEMPTS": "الحد الأقصى للمحاولات", + "MAX_BACKGROUND_PER_PROVIDER": "الحد الأقصى للخلفية لكل مزود", + "MAX_CON": "الحد الأقصى للاتصالات", + "MAX_CONNECTIONS": "الحد الأقصى للاتصالات", + "MAX_CONCURRENT_SEGMENT_FETCHES_GLOBAL": "الحد الأقصى العام لجلب الأجزاء المتزامنة", + "MAX_CONCURRENT_SEGMENT_FETCHES_PER_SESSION": "الحد الأقصى لجلب الأجزاء المتزامنة لكل جلسة", + "MAX_SEGMENTS_PREFETCH": "الحد الأقصى لجلب الأجزاء مسبقًا", + "MAX_PARALLEL_REPAIRS": "الحد الأقصى للإصلاحات المتزامنة", + "SOFT_PRIORITY": "الأولوية الناعمة", + "SOFT_CONNECTIONS": "اتصالات ناعمة", + "SOFT_CON": "اتصال ناعم", + "MEMORY": "الذاكرة", + "MESSAGE": "رسالة", + "MESSAGING": "المراسلة", + "MESSAGING_CONFIG": "المراسلة", + "METADATA": "البيانات الوصفية", + "METADATA_BACKOFF_JITTER_PERCENT": "نسبة تباعد الارتداد", + "METADATA_FFPROBE_ANALYZE_DURATION": "مدة تحليل FFprobe (مثل 10s، 1m)", + "METADATA_FFPROBE_LIVE_ANALYZE_DURATION": "مدة تحليل FFprobe للبث المباشر (مثل 5s، 30s)", + "METADATA_FFPROBE_LIVE_PROBE_SIZE": "حجم فحص FFprobe للبث المباشر (مثل 5MB، 1MB)", + "METADATA_FFPROBE_PROBE_SIZE": "حجم فحص FFprobe (مثل 10MB، 512KB)", + "METADATA_MAX_ATTEMPTS_PROBE": "الحد الأقصى لمحاولات الفحص", + "METADATA_MAX_ATTEMPTS_RESOLVE": "الحد الأقصى لمحاولات الحل", + "METADATA_MAX_QUEUE_SIZE": "الحد الأقصى لحجم الطابور", + "METADATA_MAX_RESOLVE_RETRY_BACKOFF": "الحد الأقصى لارتداد إعادة محاولة الحل", + "METADATA_NO_CHANGE_CACHE_TTL_SECS": "TTL ذاكرة التخزين المؤقت لعدم التغيير (ثواني)", + "METADATA_PATH": "مسار البيانات الوصفية", + "METADATA_PROBE_FAIRNESS_RESOLVE_BURST": "دفعة الحل قبل إنصاف الفحص", + "METADATA_PROBE_COOLDOWN": "تهدئة الفحص", + "METADATA_PROBE_RETRY_BACKOFF_STEP_1": "خطوة ارتداد الفحص 1", + "METADATA_PROBE_RETRY_BACKOFF_STEP_2": "خطوة ارتداد الفحص 2", + "METADATA_PROBE_RETRY_BACKOFF_STEP_3": "خطوة ارتداد الفحص 3", + "METADATA_PROBE_RETRY_LOAD_RETRY_DELAY": "تأخير إعادة محاولة تحميل حالة الفحص", + "METADATA_PROGRESS_LOG_INTERVAL": "فاصل سجل التقدم", + "METADATA_QUEUE_LOG_INTERVAL": "فاصل سجل الطابور", + "METADATA_RESOLVE_EXHAUSTION_RESET_GAP": "فاصل إعادة تعيين استنفاد الحل", + "METADATA_RESOLVE_MIN_RETRY_BASE": "قاعدة الحد الأدنى لإعادة محاولة الحل", + "METADATA_RETRY_DELAY": "تأخير إعادة المحاولة", + "METADATA_TMDB_COOLDOWN": "تهدئة TMDB", + "METADATA_UPDATE_CONFIG": "تحديث البيانات الوصفية", + "METADATA_WORKER_IDLE_TIMEOUT": "مهلة خمول العامل", + "METADATA_PROBE_USER_PRIORITY": "أولوية مستخدم الفحص", + "METHOD": "طريقة", + "MODEL_NAME": "اسم الطراز", + "MODEL_NUMBER": "رقم الطراز", + "MOVIE": "فيلم", + "MOVIE_CATEGORY": "فئة الأفلام", + "MSG_KIND_DISK_ALERT": "تنبيه القرص", + "MPEG_TS": "MPEG-TS", + "MSG_KIND_ERROR": "خطأ", + "MSG_KIND_INFO": "معلومات", + "MSG_KIND_STATS": "إحصائيات", + "MSG_KIND_WATCH": "مراقبة", + "NAME": "اسم", + "NAME_PREFIX": "بادئة الاسم", + "NAME_PREFIX_VALUE": "قيمة بادئة الاسم", + "NAME_PREFIX_SEPARATOR": "فاصل بادئة الاسم", + "NETWORK": "شبكة", + "NEW_NAME": "اسم جديد", + "NEW_PLAYLIST": "قائمة تشغيل جديدة", + "NEW_USER": "مستخدم جديد", + "NORMALIZE_REGEX": "تعبير منتظم للتطبيع", + "NOTES": "ملاحظات", + "NOTIFY_ON": "إشعار عند", + "NOT_ACTIVATED": "الميزة غير مفعّلة", + "OK": "موافق", + "ONE_YEAR": "سنة واحدة", + "OPEN_DISCORD": "فتح Discord", + "OPEN_DOCUMENTATION": "التوثيق", + "OPEN_GITHUB": "فتح GitHub", + "OPTIONS": "خيارات", + "ORDER": "ترتيب", + "ORGANIZE_INTO_DIRECTORIES": "التنظيم في مجلدات", + "ORIGIN_MANIFEST_TIMEOUT_MS": "مهلة بيان المصدر (مللي ثانية)", + "MANIFEST_RECOVERY_BURST": "دفعة استعادة البيان", + "ORIGIN_SEGMENT_TIMEOUT_MS": "مهلة مقطع المصدر (مللي ثانية)", + "OUTPUT": "مخرج", + "PANEL": "لوحة", + "PANEL_ACCOUNT_INFO": "معلومات الحساب", + "PREFER_HTTPS": "تفضيل HTTPS", + "PANEL_ADULT_CONTENT": "محتوى البالغين", + "PANEL_ALIAS_POOL": "تجمع الأسماء المستعارة", + "PANEL_ALIAS_POOL_MAX": "الحجم: الحد الأقصى", + "PANEL_ALIAS_POOL_MIN": "الحجم: الحد الأدنى", + "PANEL_ALIAS_POOL_REMOVE_EXPIRED": "إزالة المنتهية الصلاحية", + "PANEL_CLIENT_INFO": "معلومات العميل", + "PANEL_CLIENT_NEW": "عميل جديد", + "PANEL_CLIENT_RENEW": "تجديد العميل", + "PANEL_CONFIG": "لوحة", + "PANEL_CREDITS": "أرصدة", + "PANEL_PROBE_INTERVAL_SEC": "فترة الفحص (ثانية)", + "PANEL_PROVISIONING": "التزويد", + "PANEL_PROVISION_COOLDOWN_SEC": "التهدئة (ثانية)", + "PANEL_PROVISION_METHOD": "طريقة الفحص", + "PANEL_PROVISION_OFFSET": "إزاحة التجديد (مثل 30m، 12h)", + "PANEL_PROVISION_TIMEOUT_SEC": "المهلة (ثانية)", + "PANEL_STATUS_DISABLED": "معطل", + "PANEL_STATUS_INVALID": "غير صالح", + "PANEL_STATUS_READY": "جاهز", + "PASSWORD": "كلمة المرور", + "PATH": "مسار", + "PATTERN": "نمط", + "PATTERN_IPV4": "نمط IPv4", + "PATTERN_IPV6": "نمط IPv6", + "PERIOD_MILLIS": "فترة مللي ثانية", + "PERSIST": "استمرار", + "PLAYER_SERVER": "خادم المشغل", + "PLAYLIST": "قائمة التشغيل", + "PLAYLISTS": "قوائم التشغيل", + "PLAYLIST_BROWSER": "متصفح قائمة التشغيل", + "PLAYLIST_EPG": "EPG", + "PLAYLIST_EXPLORER": "متصفح قائمة التشغيل", + "PLAYLIST_UPDATE": "تحديث قائمة التشغيل", + "PLAYLIST_VIEWER": "استكشاف", + "PLEX": "Plex", + "PORT": "منفذ", + "PRIORITY": "أولوية", + "PROBE": "فحص", + "PROBE_DELAY_SEC": "تأخير الفحص (ثانية)", + "PROBE_LIVE_INTERVAL_HOURS": "فاصل البث المباشر (ساعة)", + "PROBE_FILTER": "مرشح الفحص", + "PROBE_STREAM": "فحص البث", + "PROCESSING_ORDER": "ترتيب المعالجة", + "PROCESS_PARALLEL": "معالجة متوازية", + "PROPERTIES": "خصائص", + "PROPRIETARY_DISCOVERY": "اكتشاف احتكاري", + "PROTOCOL": "بروتوكول", + "PROVIDER": "مزود", + "PROVIDER_DNS": "DNS المزود", + "PROVIDER_ID": "معرف المزود", + "PROVIDER_NAME": "اسم المزود", + "PROVIDER_URL_SELECTION_POLICY": "سياسة اختيار URL للمزود", + "PROVIDER_URL_SELECTION_RESUME_LAST_WORKING": "استئناف آخر عامل", + "PROVIDER_URL_SELECTION_RESTART_FROM_FIRST": "إعادة البدء من الأول", + "SELECTION_POLICY": "سياسة الاختيار", + "PROVIDER_URLS": "عناوين URL للمزود", + "PROXY": "وكيل", + "PROXY_CONFIG": "وكيل", + "PUSHOVER": "Pushover", + "QOS_AGGREGATION": "تجميع QoS", + "QOS_AGGREGATION_ENABLED": "تفعيل تجميع QoS", + "RATE_LIMIT": "تحديد المعدل", + "RATE_LIMIT_MS": "تحديد المعدل (مللي ثانية)", + "READ_EXISTING": "قراءة الموجود", + "RBAC": "مستخدم WebUI", + "RBAC_ACTIONS": "إجراءات", + "RBAC_ADD_GROUP": "إضافة مجموعة", + "RBAC_ADD_USER": "إضافة مستخدم", + "RBAC_BUILTIN": "مدمج", + "RBAC_DOMAIN": "نطاق", + "RBAC_EDIT_GROUP": "تحرير المجموعة", + "RBAC_EDIT_USER": "تحرير المستخدم", + "RBAC_GROUP_MANAGEMENT": "إدارة المجموعات", + "RBAC_LEAVE_BLANK": "اتركه فارغًا للاحتفاظ بالحالي", + "RBAC_LOADING": "جارٍ التحميل...", + "RBAC_NO_PERMISSIONS": "لا توجد أذونات", + "RBAC_PERMISSIONS": "الأذونات", + "RBAC_READ": "قراءة", + "RBAC_USERS": "المستخدمون", + "RBAC_WRITE": "كتابة", + "RECONNECT": "إعادة الاتصال", + "RECURSIVE": "متكرر", + "REDIRECT": "إعادة توجيه", + "REFERER_HEADER": "إزالة رأس المُحيل", + "REFRESH": "تحديث", + "REGEXP": "تعبير منتظم", + "RELEASES": "الإصدارات", + "REMOVE_DUPLICATES": "إزالة التكرارات", + "RENAME": "إعادة تسمية", + "RENAME_SETTINGS": "إعادة تسمية الإعدادات", + "REPEAT_PASSWORD": "تكرار كلمة المرور", + "RESOLVE": "حل", + "RESOLVE_BACKGROUND": "الحل في الخلفية", + "RESOLVE_DELAY_SEC": "تأخير الحل (ثانية)", + "RESOLVE_FILTER": "مرشح الحل", + "RESOLVE_TMDB": "حل TMDB", + "RESOURCE_IMAGE_CACHE": "ذاكرة التخزين المؤقت للموارد/الصور", + "RESOURCE_RETRY": "إعادة محاولة المورد", + "RESOURCE_REWRITE_DISABLE": "تعطيل إعادة كتابة المورد", + "RESOURCE_REWRITE_DISABLED": "إعادة كتابة المورد معطلة", + "RESERVE_SLOTS_FOR_USERS": "حجز فتحات للمستخدمين", + "REST": "REST", + "RETRY": "إعادة المحاولة", + "REVERSE": "عكسي", + "REVERSE_PROXY": "وكيل عكسي", + "REVERSE_PROXY_CONFIG": "وكيل عكسي", + "REWRITE_SECRET": "إعادة كتابة السر", + "REPAIR_POLICY": "السياسة", + "REPAIR_TRIGGER": "محفز الإصلاح", + "POSTPROCESS_TIMEOUT_MS": "مهلة المعالجة اللاحقة (مللي ثانية)", + "CORRUPT_SEGMENT_WATCHDOG": "كلب مراقبة المقطع الفاسد", + "MAX_WATCHDOG_JOBS": "الحد الأقصى لوظائف كلب المراقبة", + "SANITIZE_SENSITIVE_INFO": "تطهير المعلومات الحساسة", + "SAVE": "حفظ", + "SCAN_DIRECTORIES": "مسح المجلدات", + "SCHEDULE": "جدول", + "SCHEDULES": "الجداول", + "SCHEDULES_CONFIG": "الجداول", + "SCRIPT": "سكربت", + "SEARCH": "بحث", + "SEASON": "موسم", + "SECRET": "سر", + "SELECT_ALL": "تحديد الكل", + "SELF_HOSTED": "استضافة ذاتية", + "SEQUENCE": "تسلسل", + "SERIES": "مسلسلات", + "SERIES_CATEGORY": "فئة المسلسلات", + "SERIES_SHORT": "س", + "SERVER": "خادم", + "SERVER_ID": "معرف الخادم", + "SERVER_NAME": "اسم الخادم", + "SERVER_TIME": "وقت الخادم", + "SEGMENT_REPAIR": "الحد الأقصى لمستوى الإصلاح", + "SEGMENT_REPAIR_AV_DESYNC": "عدم تزامن صوت/فيديو", + "SEGMENT_REPAIR_CODEC_PARAMETERS_MISSING": "معاملات الترميز مفقودة", + "SEGMENT_REPAIR_DECODE_SLICE_HEADER_ERROR": "خطأ في فك تشفير رأس الشريحة", + "SEGMENT_REPAIR_INVALID_DATA": "بيانات غير صالحة", + "SEGMENT_REPAIR_INVALID_METADATA_NALU": "NALU بيانات وصفية غير صالح", + "SEGMENT_REPAIR_INVALID_NAL": "NAL غير صالح", + "SEGMENT_REPAIR_INVALID_VCL_NALU": "NALU VCL غير صالح", + "SEGMENT_REPAIR_MISSING_PICTURE": "صورة مفقودة", + "SEGMENT_REPAIR_MISSING_PPS": "PPS مفقود", + "SEGMENT_REPAIR_MISSING_SPS": "SPS مفقود", + "SEGMENT_REPAIR_MISSING_VPS": "VPS مفقود", + "SEGMENT_REPAIR_MMCO_UNREF_SHORT_FAILURE": "فشل MMCO unref قصير", + "SEGMENT_REPAIR_MULTIPLE_DOLBY_VISION_RPUS": "RPUs Dolby Vision متعددة", + "SEGMENT_REPAIR_NO_FRAME": "لا يوجد إطار", + "SEGMENT_REPAIR_PPS_ID_OUT_OF_RANGE": "معرف PPS خارج النطاق", + "SEGMENT_REPAIR_REORDER_BUFFER_INCREASE": "زيادة مخزن إعادة الترتيب", + "SEGMENT_SIZE_INCREASE": "زيادة حجم المقطع", + "SETTINGS": "الإعدادات", + "SESSION_IDLE_TIMEOUT": "مهلة خمول الجلسة (ثواني)", + "SHARED": "مشترك", + "SHARED_BURST_BUFFER_BYTES": "مخزن الدفعة المشترك (ميجابايت)", + "SHARE_LIVE_STREAMS": "مشاركة البث المباشر", + "SHOW_SELECTED": "إظهار المحدد", + "SIZE": "الحجم", + "SIZE_INCREASE": "زيادة الحجم", + "SKIP": "تخطي", + "SKIP_DIRECT_SOURCE": "تخطي المصدر المباشر", + "SKIP_LIVE": "تخطي البث المباشر", + "SKIP_SERIES": "تخطي المسلسلات", + "SKIP_VOD": "تخطي VOD", + "SLEEP_TIMER_MINS": "مؤقت السكون (دقائق)", + "SMART_EPG": "EPG ذكي", + "SOMETHING_WENT_WRONG": "حدث خطأ ما", + "SORT": "فرز", + "SORT_SETTINGS": "إعدادات الفرز", + "SOURCE": "مصدر", + "SOURCES": "المصادر", + "SOURCE_EDITOR": "المصادر", + "SOURCE_PICKER": "منتقي المصدر", + "SPECIFIC_TEMPLATES": "قوالب محددة", + "SSDP_DISCOVERY": "اكتشاف SSDP", + "STAGED": "مُرحّل", + "CHILD": "فرع", + "LIVE_SOURCE": "مصدر البث المباشر", + "VOD_SOURCE": "مصدر VOD", + "SERIES_SOURCE": "مصدر المسلسلات", + "START": "بدء", + "STAR_ON_GITHUB": "إذا أعجبك هذا المشروع، ضع نجمة ⭐ على GitHub", + "STATS": "إحصائيات", + "STATUS": "الحالة", + "HEALTH_BANNER": "صحة النظام", + "HEALTH_HEALTHY": "سليم", + "HEALTH_DEGRADED": "متدهور", + "HEALTH_UNHEALTHY": "غير سليم", + "HEALTH_UNKNOWN": "غير معروف", + "HEALTH_WEBSOCKET": "اتصال في الوقت الفعلي", + "HEALTH_BACKEND": "حالة الخلفية", + "HEALTH_PROVIDERS": "سعة المزود", + "HEALTH_CONNECTED": "متصل", + "HEALTH_DISCONNECTED": "غير متصل", + "HEALTH_PROVIDERS_OK": "جميعها سليمة", + "HEALTH_PROVIDERS_NONE": "لا مزودين نشطين", + "STREAM": "بث", + "STREAM_GRACE": "سماح البث", + "STREAM_SESSION": "جلسة البث", + "STREAMS": "تيارات البث", + "STREAM_METRICS_ENABLED": "تفعيل مقاييس البث", + "STREAM_BUFFER": "مخزن البث المؤقت", + "STREAM_HISTORY": "سجل البث", + "STREAM_HISTORY_BYTES": "البايتات المُرسلة", + "STREAM_HISTORY_EVENT": "حدث", + "STREAM_HISTORY_FROM": "من", + "STREAM_HISTORY_IP": "IP", + "STREAM_HISTORY_LOAD": "حمولة", + "STREAM_HISTORY_LOADING": "جارٍ التحميل...", + "STREAM_HISTORY_QOS": "لقطة QoS", + "STREAM_HISTORY_REASON": "السبب", + "STREAM_HISTORY_CONFIDENCE": "الثقة", + "CONTAINER": "الحاوية", + "STREAM_HISTORY_TIME": "الوقت", + "STREAM_HISTORY_TO": "إلى", + "STREAM_HISTORY_ENABLED": "تفعيل سجل البث", + "STREAM_HISTORY_BATCH_SIZE": "حجم الدفعة", + "STREAM_HISTORY_DETAILS": "التفاصيل", + "STREAM_HISTORY_DISCONNECTS": "الانقطاعات", + "STREAM_HISTORY_CONNECT_FAILED": "فشل الاتصال", + "STREAM_HISTORY_RUNTIME_ABORTS": "إلغاءات وقت التشغيل", + "STREAM_HISTORY_FIRST_BYTE": "أول بايت", + "STREAM_HISTORY_QOS_DETAIL": "تفاصيل QoS", + "STREAM_HISTORY_RETENTION_DAYS": "أيام الاستبقاء", + "STREAM_HISTORY_SCORE": "الدرجة", + "STREAM_HISTORY_SESSIONS": "الجلسات", + "STREAM_HISTORY_SUMMARY": "ملخص", + "STREAM_HISTORY_WINDOW": "النافذة", + "STREAM_ID": "معرف البث", + "STRIP": "تجريد", + "STRIP_MODE": "وضع التجريد", + "STRIP_VALUE": "قيمة التجريد", + "STRM": "strm", + "STRM_PROPS": "خصائص", + "SUBMIT": "إرسال", + "SUPPORTED_EXTENSIONS": "الامتدادات المدعومة", + "TARGET": "هدف", + "TARGETS": "الأهداف", + "TECH": "تقنية", + "TELEGRAM": "Telegram", + "THRESHOLD_SCORE": "درجة العتبة", + "TEMPLATES": "القوالب", + "TEMPLATE_PATH": "مسار القالب", + "THROTTLE": "تحديد السرعة", + "THROTTLE_KBPS": "تحديد السرعة كيلوبت/ث", + "THUMBNAILS": "الصور المصغرة", + "WIDTH": "العرض", + "HEIGHT": "الارتفاع", + "QUALITY": "الجودة", + "TIMEZONE": "المنطقة الزمنية", + "TMDB": "TMDB", + "TOKEN": "رمز", + "TOKEN_TTL_MINS": "TTL الرمز (دقائق)", + "TRAKT": "Trakt", + "TRAKT_CATEGORY_NAME": "اسم الفئة", + "TRAKT_CHARTS": "الرسوم البيانية", + "TRAKT_CHART_KIND": "نوع الرسم البياني", + "TRAKT_CHART_KIND_MOVIES": "أفلام", + "TRAKT_CHART_KIND_SHOWS": "عروض", + "TRAKT_CHART_TYPE": "نوع الرسم البياني", + "TRAKT_CHART_TYPE_POPULAR": "شائع", + "TRAKT_CHART_TYPE_TRENDING": "مت Trakt", + "TRAKT_FUZZY_MATCH_THRESHOLD": "حد المطابقة الضبابية", + "TRAKT_TMDB_ONLY": "TMDB فقط", + "TRAKT_LISTS": "القوائم", + "TRAKT_LIST_SLUG": "سلاج القائمة", + "TRAKT_CONTENT_TYPE": "نوع محتوى Trakt", + "TRAKT_CONTENT_TYPE_BOTH": "كلاهما", + "TRAKT_CONTENT_TYPE_SERIES": "مسلسلات", + "TRAKT_CONTENT_TYPE_VOD": "VOD", + "TRAKT_USER": "مستخدم Trakt", + "TRANSFERRED": "تم التحويل", + "TUNER_COUNT": "عدد الموالفات", + "TYPE": "النوع", + "UI_ENABLED": "واجهة المستخدم", + "UNDERSCORE_WHITESPACE": "شرطة سفلية للمسافات", + "UPDATE": "تحديث", + "UPDATE_GEOIP_DB": "تحديث قاعدة بيانات Geo-IP", + "UPDATE_LOCAL_LIBRARY": "تحديث المكتبة المحلية", + "UPDATE_ON_BOOT": "التحديث عند الإقلاع", + "URL": "URL", + "URL_IPV4": "URL IPv4", + "URL_IPV6": "URL IPv6", + "USER": "مستخدم", + "USER_ID": "معرف المستخدم", + "GROUPFILE": "ملف المجموعات", + "USERFILE": "ملف المستخدم", + "USERLIST": "قائمة المستخدمين", + "USERNAME": "اسم المستخدم", + "USERS": "المستخدمون", + "USER_ACCESS_CONTROL": "التحكم في وصول المستخدم", + "USER_AGENT": "وكيل المستخدم", + "USER_CONFIG_DIR": "دليل تهيئة المستخدم", + "USER_STATUS_ACTIVE": "نشط", + "USER_STATUS_BANNED": "محظور", + "USER_STATUS_DISABLED": "معطل", + "USER_STATUS_EXPIRED": "منتهي الصلاحية", + "USER_STATUS_PENDING": "قيد الانتظار", + "USER_STATUS_TRIAL": "تجريبي", + "USER_UI": "واجهة المستخدم", + "USER_UI_ENABLED": "واجهة المستخدم مفعّلة", + "COMBINE_VIEWS_STATS_STREAMS": "دمج الإحصائيات والبث", + "USE_MEMORY_CACHE": "ذاكرة التخزين المؤقت", + "USE_OUTPUT": "استخدام المخرج", + "USE_USER_DB": "استخدام قاعدة بيانات المستخدم", + "AUTH_ERROR_STATUS": "حالة خطأ المصادقة", + "VALIDATION": "تحقق", + "VERSION": "الإصدار", + "VIDEO": "فيديو", + "VIDEO_CONFIG": "فيديو", + "VIEW": "عرض", + "VOD": "VOD", + "VOD_SHORT": "ف", + "WATCH": "مراقبة", + "WEBHOOK_URL": "عنوان URL لـ Webhook", + "WEB_AUTH": "مصادقة", + "WEB_ROOT": "جذر الويب", + "WEB_SEARCH": "بحث الويب", + "WEB_SEARCH_URL": "عنوان URL لبحث الويب", + "WEB_UI": "واجهة الويب", + "WEB_UI_CONFIG": "واجهة الويب", + "WEB_UI_PATH": "بادئة المسار", + "WELCOME": "مرحبًا", + "STORAGE_DIR": "دليل التخزين", + "XC": "xc", + "XTREAM": "xtream", + "XTREAM_BATCH": "xc دفعة", + "XTREAM_CODES": "xtream codes", + "X_HEADER": "إزالة رؤوس X-*", + "YES": "نعم", + "FORCE": "إجبار", + "ACTIVE_STREAMS": "تيارات البث النشطة", + "PLAYER": "مشغل", + "TITLE": "العنوان", + "TOGGLE_SIDEBAR": "تبديل الشريط الجانبي", + "SELECTED": "محدد", + "FILTERED": "مُصفى", + "TOTAL": "الإجمالي", + "INPUT": "إدخال", + "ACTIONS": "إجراءات", + "ERROR": "خطأ", + "RECORD": "تسجيل", + "LANDING_PAGE": "الصفحة المقصودة", + "VIEW_TYPE_DASHBOARD": "لوحة التحكم", + "VIEW_TYPE_STATS": "الإحصائيات", + "VIEW_TYPE_STREAMS": "تيارات البث", + "VIEW_TYPE_STREAM_HISTORY": "سجل البث", + "VIEW_TYPE_DOWNLOADS": "التنزيلات", + "VIEW_TYPE_USERS": "مستخدمو API", + "VIEW_TYPE_CONFIG": "التكوين", + "VIEW_TYPE_SOURCE_EDITOR": "المصادر", + "VIEW_TYPE_PLAYLIST_UPDATE": "تحديث قائمة التشغيل", + "VIEW_TYPE_PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل", + "VIEW_TYPE_PLAYLIST_EXPLORER": "مستكشف قائمة التشغيل", + "VIEW_TYPE_PLAYLIST_EPG": "EPG قائمة التشغيل", + "VIEW_TYPE_RBAC": "مستخدمو WebUI", + "ADMISSION_STRATEGIES": "استراتيجيات القبول", + "RUNTIME_CONFIG_REPORT": "تقرير تهيئة وقت التشغيل", + "RUNTIME_CONFIG_REPORT_FORMAT": "تنسيق تقرير تهيئة وقت التشغيل", + "STREAM_INFO": "معلومات عرض البث", + "HIDE_GROUP": "إخفاء المجموعة", + "HIDE_IP": "إخفاء IP العميل", + "HIDE_COUNTRY": "إخفاء البلد", + "HIDE_SHARED": "إخفاء المشترك", + "HIDE_DURATION": "إخفاء المدة", + "HIDE_BANDWIDTH": "إخفاء النطاق الترددي", + "HIDE_TRANSFERRED": "إخفاء المُحوَّل", + "HIDE_PLAYER": "إخفاء المشغل", + "HIDE_USER_COMMENT": "إخفاء تعليق المستخدم", + "HIDE_EPG": "إخفاء EPG", + "ROWS": "الصفوف", + "FIRST_PAGE": "الصفحة الأولى", + "LAST_PAGE": "الصفحة الأخيرة", + "NEXT_PAGE": "الصفحة التالية", + "PREVIOUS_PAGE": "الصفحة السابقة", + "PAGE": "صفحة", + "PAGES": "صفحات", + "NO_CONTENT": "لا يوجد محتوى" + }, + "MESSAGES": { + "CLIPBOARD_NOT_SUPPORTED": "الحافظة غير مدعومة.\nمتصفحك أو السياق الحالي لا يسمح بالوصول إلى الحافظة.\nيرجى استخدام HTTPS أو localhost.", + "CONFIG_CHANGED": "تم تغيير التكوين على الخادم.", + "CONFIRM_DELETE": "هل تريد حقًا حذفه؟", + "CONFIRM_SOURCES_SAVE": "هل تريد حقًا الحفظ؟", + "DOWNLOAD": { + "DOWNLOAD_QUEUED": "تمت إضافة التنزيل إلى الطابور.", + "RECORDING_QUEUED": "تمت إضافة التسجيل إلى الطابور.", + "DOWNLOAD_PAUSED": "تم إيقاف التنزيل مؤقتًا.", + "DOWNLOAD_RESUMED": "تم استئناف التنزيل", + "DOWNLOAD_CANCELLED": "تم إلغاء التنزيل.", + "DOWNLOAD_REMOVED": "تمت إزالة التنزيل.", + "DOWNLOAD_RETRIED": "تمت إعادة محاولة التنزيل.", + "CONFIRM_CANCEL": "هل تريد حقًا إلغاء هذا النقل؟", + "CONFIRM_REMOVE": "هل تريد حقًا إزالة هذا الإدخال؟", + "FAIL": "فشل التنزيل!", + "INVALID_RECORD_DURATION": "يجب أن تكون مدة التسجيل عددًا موجبًا من الدقائق.", + "INVALID_RECORD_START": "وقت بدء التسجيل غير صالح.", + "GEOIP": { + "FAIL": "فشل تنزيل قاعدة بيانات Geo-IP!", + "SUCCESS": "تم تنزيل قاعدة بيانات Geo-IP بنجاح" + }, + "PLAYLIST": { + "FAIL": "فشل تنزيل قائمة التشغيل!", + "SUCCESS": "تم تنزيل قائمة التشغيل بنجاح" + }, + "SERVER_CONFIG": { + "FAIL": "فشل تنزيل تهيئة الخادم!" + }, + "SUCCESS": "تم التنزيل بنجاح", + "USER_BOUQUET": { + "FAIL": "فشل تنزيل باقات المستخدم!" + } + }, + "FAILED_TO_KICK_USER_STREAM": "فشل طرد بث المستخدم", + "FAILED_TO_RETRIEVE_PROVIDER_URL": "فشل استرداد عنوان URL للمزود", + "FAILED_TO_RETRIEVE_WEBPLAYER_URL": "فشل استرداد عنوان URL لمشغل الويب", + "INVALID_FILETYPE": "نوع ملف غير صالح", + "INVALID_REGEXP": "تعبير منتظم غير صالح", + "LIBRARY_UPDATE": { + "FAIL": "فشل تحديث المكتبة!", + "SUCCESS": "تم بدء تحديث المكتبة بنجاح!" + }, + "LOGIN": { + "MESSAGE": "أدخل بيانات اعتمادك" + }, + "SESSION": { + "EXPIRED": "انتهت جلستك. يرجى تسجيل الدخول مرة أخرى." + }, + "NO_ACCESS_MESSAGE": "حسابك لا يحتوي على أذونات معينة. اتصل بمسؤول.", + "NO_ACCESS_TITLE": "لا يوجد وصول", + "NO_CONTENT": "لا يوجد محتوى", + "EMPTY_STATE": { + "HDHOMERUN_DEVICES_TITLE": "لا توجد أجهزة HDHomeRun", + "HDHOMERUN_DEVICES_HINT": "قم بالتبديل إلى وضع التحرير وأضف جهازًا لمحاكاة موالف HDHomeRun للمشغلات الخاصة بك.", + "SCHEDULES_TITLE": "لا توجد جداول", + "SCHEDULES_HINT": "أضف جدولًا لتشغيل تحديثات قائمة التشغيل تلقائيًا في الأوقات التي تختارها.", + "API_PROXY_SERVER_TITLE": "لا توجد نقاط نهاية خادم", + "API_PROXY_SERVER_HINT": "أضف إدخال خادم لتحديد المضيف والمنافذ التي يستخدمها عملاؤك للوصول إلى قوائم التشغيل." + }, + "PLAYLIST": { + "WEBPLAYER_URL_COPY_TO_CLIPBOARD": "عنوان URL لمشغل الويب المنسوخ صالح لمدة 30 ثانية." + }, + "PLAYLIST_EXPLORER": { + "SELECT_A_PLAYLIST_TO_VIEW_CONTENT": "حدد قائمة تشغيل لعرض محتواها.", + "SELECT_A_PLAYLIST_HINT": "اختر مصدرًا من المنتقي أعلاه لتصفح قنواته وأفلامه ومسلسلاته." + }, + "EPG": { + "SELECT_AN_EPG_TO_VIEW_CONTENT": "حدد EPG لعرض محتواه.", + "SELECT_AN_EPG_HINT": "اختر مصدر EPG أعلاه لمعاينة دليل برنامجه." + }, + "RBAC": { + "ADMIN_HINT": "المستخدم مسؤول — المجموعات الإضافية ليس لها تأثير.", + "GROUP_CREATED": "تم إنشاء المجموعة بنجاح", + "GROUP_DELETED": "تم حذف المجموعة بنجاح", + "GROUP_NAME_REQUIRED": "اسم المجموعة مطلوب", + "GROUP_UPDATED": "تم تحديث المجموعة بنجاح", + "PASSWORD_MIN_LENGTH": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل", + "PASSWORDS_DONT_MATCH": "كلمات المرور غير متطابقة", + "USER_CREATED": "تم إنشاء المستخدم بنجاح", + "USER_DELETED": "تم حذف المستخدم بنجاح", + "USER_UPDATED": "تم تحديث المستخدم بنجاح", + "USERNAME_REQUIRED": "اسم المستخدم مطلوب", + "WRITE_WITHOUT_READ": "الكتابة مفعّلة بدون قراءة" + }, + "PLAYLIST_UPDATE": { + "FAIL": "فشل تحديث قائمة التشغيل!", + "FAIL_FINISH": "فشل تحديث قائمة التشغيل!", + "SUCCESS": "تم بدء تحديث قائمة التشغيل بنجاح!", + "SUCCESS_FINISH": "تم تحديث قائمة التشغيل بنجاح!", + "URL_MANDATORY": "URL مطلوب!", + "USERNAME_PASSWORD_MANDATORY": "اسم المستخدم وكلمة المرور مطلوبان!" + }, + "SAVE": { + "API_PROXY_CONFIG": { + "FAIL": "فشل حفظ تكوين ApiProxy!", + "NON_UNIQUE_SERVER_NAME": "اسم معلومات الخادم \"{name}\" موجود بالفعل.", + "SUCCESS": "تم حفظ تكوين ApiProxy!" + }, + "BOUQUET": { + "FAIL": "فشل حفظ الباقة!", + "SUCCESS": "تم حفظ الباقة بنجاح" + }, + "MAIN_CONFIG": { + "FAIL": "فشل حفظ التكوين الرئيسي!", + "SUCCESS": "تم حفظ التكوين الرئيسي!" + }, + "NO_CHANGES": "لا توجد تغييرات", + "REFRESH_INCOMPLETE": { + "API_PROXY_CONFIG": "تكوين وكيل api", + "BASE": "تم حفظ التغييرات لكن فشل تحديث تهيئة الخادم - البقاء في وضع التحرير", + "MAIN_CONFIG": "التهيئة الرئيسية", + "MISSING_REFRESHED": "تحديث مفقود", + "SOURCES_CONFIG": "تكوين المصادر", + "UNKNOWN": "حالة تحديث غير معروفة" + }, + "SOURCES_CONFIG": { + "FAIL": "فشل حفظ تكوين المصادر!", + "SUCCESS": "تم حفظ تكوين المصادر!" + }, + "USER": { + "DUPLICATE_TOKEN": "رمز مكرر!", + "DUPLICATE_USERNAME": "اسم مستخدم مكرر!", + "FAIL": "فشل حفظ المستخدم!", + "MAX_CONNECTIONS_INVALID": "الحد الأقصى للاتصالات غير صالح", + "NOTHING_TO_SAVE": "لم يتغير شيء", + "SUCCESS": "تم حفظ المستخدم بنجاح", + "TARGET_NOT_FOUND": "الهدف غير موجود!", + "TARGET_NOT_SELECTED": "لم يتم تحديد قائمة التشغيل", + "USERNAME_REQUIRED": "اسم المستخدم مطلوب" + } + }, + "SCHEDULE_EXISTS": "الجدول موجود بالفعل", + "SCHEDULE_NOT_SET": "يرجى إدخال جدول قبل الإضافة", + "SOURCE_SELECTOR": { + "MISSING_SELECTION": "لم يتم تحديد أي إدخال" + }, + "SOURCE_EDITOR": { + "URL_USERNAME_AND_PASSWORD_MANDATORY": "URL واسم المستخدم وكلمة المرور مطلوبة!" + }, + "TARGET_NOT_EXISTS": "الهدف غير موجود", + "USER_DELETED": "تم حذف المستخدم بنجاح" + , + "VALIDATION": { + "NETWORK_ACCESS_COUNTRIES": "أدخل رمز بلد واحد بتنسيق ISO المؤلف من حرفين لكل إدخال، مثل DE.", + "NETWORK_ACCESS_NETWORKS": "أدخل CIDR واحد لكل إدخال، مثل 192.168.0.0/16 أو 2001:db8::/32." + } + }, + "SETUP": { + "DESC": { + "API": "تكوين إعدادات API ووكيل API.", + "API_USERS": "تكوين مستخدمي API وبيانات اعتماد الهدف.", + "FINISH": "راجع التكوين وأرسله.", + "HDHOMERUN": "تكوين إعدادات HDHomeRun.", + "IP_CHECK": "تكوين إعدادات فحص IP.", + "LIBRARY": "تكوين إعدادات المكتبة.", + "LOG": "تكوين إعدادات السجل.", + "MAIN": "تكوين إعدادات التطبيق الرئيسية.", + "METADATA_UPDATE": "تكوين تحديث البيانات الوصفية وسلوك الطابور.", + "MESSAGING": "تكوين إعدادات المراسلة.", + "PROXY": "تكوين إعدادات الوكيل الصادر.", + "REVERSE_PROXY": "تكوين إعدادات الوكيل العكسي.", + "SCHEDULES": "تكوين إعدادات المجدول.", + "SOURCES": "تكوين إدخالات وتعيينات المصادر.", + "UNKNOWN": "تكوين خطوة الإعداد هذه.", + "VIDEO": "تكوين إعدادات الفيديو.", + "WEB_UI": "تكوين إعدادات WebUI.", + "WELCOME": "أنشئ أول مستخدم WebUI. سيتم كتابة بيانات الاعتماد هذه في user.txt." + }, + "LABEL": { + "API": "API", + "API_USERS": "مستخدمو API", + "BACK": "رجوع", + "FINISH": "إنهاء", + "FINISH_SETUP": "إنهاء الإعداد", + "HDHOMERUN": "HdHomerun", + "IP_CHECK": "فحص IP", + "LIBRARY": "مكتبة", + "LOG": "سجل", + "MAIN": "رئيسي", + "METADATA_UPDATE": "تحديث البيانات الوصفية", + "MESSAGING": "مراسلة", + "NEXT": "التالي", + "PROXY": "وكيل", + "REVERSE_PROXY": "وكيل عكسي", + "SCHEDULES": "الجداول", + "SOURCES": "المصادر", + "STEP": "خطوة", + "STEPS": "خطوات الإعداد", + "SUBMITTED": "تم الإرسال", + "SUBMITTING": "جارٍ الإرسال...", + "VIDEO": "فيديو", + "WEBUI_PASSWORD": "كلمة مرور WebUI", + "WEBUI_PASSWORD_REPEAT": "تكرار كلمة مرور WebUI", + "WEBUI_USER": "مستخدم WebUI", + "WEBUI_USERNAME": "اسم مستخدم WebUI", + "WEB_UI": "Web-Ui", + "WELCOME": "مرحبًا" + }, + "MSG": { + "COMPLETE_RESTART_HINT": "تم الإعداد بنجاح. أعد تشغيل التطبيق/الحاوية للمتابعة.", + "COMPLETE_SUCCESS": "تم الإعداد بنجاح", + "RESTART_TO_CONTINUE": "أعد تشغيل التطبيق للمتابعة.", + "WEBUI_PASSWORDS_DO_NOT_MATCH": "كلمات مرور WebUI غير متطابقة", + "WEBUI_PASSWORD_MIN_LENGTH": "يجب أن تتكون كلمة مرور WebUI من 8 أحرف على الأقل", + "WEBUI_PASSWORD_REQUIRED": "كلمة مرور WebUI مطلوبة", + "WEBUI_USERNAME_REQUIRED": "اسم مستخدم WebUI مطلوب", + "WELCOME": "مرحبًا بك في إعداد tuliprox. سيرشدك هذا المعالج خلال عملية الإعداد الأولية." + } + }, + "SOURCE_EDITOR": { + "BRICK_InputEmby": "Emby", + "BRICK_InputJellyfin": "Jellyfin", + "BRICK_InputLibrary": "مكتبة", + "BRICK_InputM3u": "M3u", + "BRICK_InputStaged": "مُرحّل", + "BRICK_InputPlex": "Plex", + "BRICK_InputXtream": "Xtream", + "BRICK_OutputHdHomeRun": "HDHR", + "BRICK_OutputM3u": "M3u", + "BRICK_OutputStrm": "STRM", + "BRICK_OutputXtream": "Xtream", + "BRICK_Target": "هدف" + }, + "TITLE": { + "USER_BOUQUET_EDITOR": "اختيار فئة قائمة التشغيل" + } +} diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 324d73d74..cf97cb8f4 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -476,11 +476,29 @@ "HEADERS": "Custom HTTP headers sent when retrieving the input playlist.", "INPUT_TYPE": "Defines the streaming protocol or source format (e.g. xtream, m3u).", "LIVE_SOURCE": "Source selector for the Live cluster when staged input is enabled (`default`, `staged`, `input`, `skip`).", - "PROVIDERS": "Provider profiles define reusable upstream URLs and DNS behavior that this input can reference." + "PERSIST": "Optional file path used to persist or reuse the downloaded input playlist.", + "PROVIDERS": "Provider profiles define reusable upstream URLs and DNS behavior that this input can reference.", + "SIMPLE_INPUT": { + "URL": "Source URL for this input. Only `file://`, `http://`, and `https://` URLs are supported." + }, + "STAGED_CLUSTERS": "Clusters this staged input overrides on the connected provider input.", + "STAGED_PERSIST": "Optional file path used to persist or reuse the downloaded staged playlist.", + "URL": "Source URL for this input." , "SERIES_SOURCE": "Source selector for the Series cluster when staged input is enabled (`default`, `staged`, `input`, `skip`).", "VOD_SOURCE": "Source selector for the VOD cluster when staged input is enabled (`default`, `staged`, `input`, `skip`)." }, + "MEDIA_SERVER": { + "ACCOUNT_TOKEN": "Optional account token used by media-server integrations that require an account-scoped authentication token.", + "ALLOW_RELAY": "Allows the media server to use relay connections when direct access is not available.", + "API_KEY": "API key or token used to authenticate requests against the media server API.", + "LIBRARIES": "Comma-separated list of media libraries to use from this server. Leave empty to include all available libraries.", + "PREFER_HTTPS": "Prefers HTTPS URLs when the media server exposes both HTTP and HTTPS connection options.", + "SERVER_ID": "Optional unique server identifier used when the integration needs a specific server from an account.", + "SERVER_NAME": "Optional server display name used to select or label the media server connection.", + "TOKEN": "Authentication token used to connect to the media server.", + "USER_ID": "Optional user ID used when the media server requires requests to run as a specific user." + }, "IP_CHECK_CONFIG": { "PATTERN_IPV4": "Optional regex to extract IPv4 address from the response (default matches standard decimal format).", "PATTERN_IPV6": "Optional regex to extract IPv6 address from the response (default matches standard hex format).", @@ -882,6 +900,8 @@ "ALL": "All", "ALLOWED_COUNTRIES": "Allowed Countries", "ALLOWED_NETWORKS": "Allowed Networks", + "ACCOUNT_TOKEN": "Account token", + "ALLOW_RELAY": "Allow relay", "APPLY_TO_FIRST_SEGMENTS": "Apply to first segments", "API": "Api", "API_CONFIG": "API", @@ -1094,6 +1114,7 @@ "LANGUAGE": "Language", "LIBRARY": "Local Library", "LIBRARY_CONFIG": "Library", + "LIBRARIES": "Libraries", "LIST": "List", "LIVE": "Live", "LIVE_SHORT": "L", @@ -1113,6 +1134,7 @@ "M3U_BATCH": "m3u batch", "MAIN": "Main", "MAIN_CONFIG": "Main", + "MEDIA_SERVER": "Media server", "MANUFACTURER": "Manufacturer", "MAPPER": "Mapper", "MAPPING": "Mapping", @@ -1200,6 +1222,7 @@ "OUTPUT": "Output", "PANEL": "Panel", "PANEL_ACCOUNT_INFO": "Account info", + "PREFER_HTTPS": "Prefer HTTPS", "PANEL_ADULT_CONTENT": "Adult content", "PANEL_ALIAS_POOL": "Alias pool", "PANEL_ALIAS_POOL_MAX": "Size: max", @@ -1329,6 +1352,8 @@ "SERIES_CATEGORY": "Series Category", "SERIES_SHORT": "S", "SERVER": "Server", + "SERVER_ID": "Server id", + "SERVER_NAME": "Server name", "SERVER_TIME": "Server time", "SEGMENT_REPAIR": "Max repair level", "SEGMENT_REPAIR_AV_DESYNC": "A/V desync", @@ -1373,6 +1398,7 @@ "SPECIFIC_TEMPLATES": "Specific Templates", "SSDP_DISCOVERY": "SSDP Discovery", "STAGED": "Staged", + "CHILD": "Child", "LIVE_SOURCE": "Live Source", "VOD_SOURCE": "VOD Source", "SERIES_SOURCE": "Series Source", @@ -1480,6 +1506,7 @@ "URL_IPV4": "Url IPv4", "URL_IPV6": "Url IPv6", "USER": "User", + "USER_ID": "User id", "GROUPFILE": "Groupfile", "USERFILE": "Userfile", "USERLIST": "Userlist", @@ -1789,6 +1816,7 @@ "BRICK_InputJellyfin": "Jellyfin", "BRICK_InputLibrary": "Library", "BRICK_InputM3u": "M3u", + "BRICK_InputStaged": "Staged", "BRICK_InputPlex": "Plex", "BRICK_InputXtream": "Xtream", "BRICK_OutputHdHomeRun": "HDHR", diff --git a/frontend/public/assets/i18n/index.json b/frontend/public/assets/i18n/index.json index 09c7ea337..d3dc4af2b 100644 --- a/frontend/public/assets/i18n/index.json +++ b/frontend/public/assets/i18n/index.json @@ -1,6 +1,7 @@ { "languages": [ { "code": "en", "label": "English", "dir": "ltr" }, - { "code": "ru", "label": "Русский", "dir": "ltr" } + { "code": "ru", "label": "Русский", "dir": "ltr" }, + { "code": "ar", "label": "العربية", "dir": "rtl" } ] } diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 602368fce..83806c598 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -446,7 +446,14 @@ "HEADERS": "Пользовательские HTTP-заголовки, отправляемые при получении плейлиста ввода.", "INPUT_TYPE": "Определяет протокол потоковой передачи или формат источника (например, xtream, m3u).", "LIVE_SOURCE": "Селектор источника для кластера Live, когда включен промежуточный ввод (`default`, `staged`, `input`, `skip`).", - "PROVIDERS": "Профили провайдера определяют повторно используемые upstream URL и поведение DNS, на которые может ссылаться этот ввод." + "PERSIST": "Необязательный путь к файлу для сохранения или повторного использования загруженного плейлиста ввода.", + "PROVIDERS": "Профили провайдера определяют повторно используемые upstream URL и поведение DNS, на которые может ссылаться этот ввод.", + "SIMPLE_INPUT": { + "URL": "URL источника для этого ввода. Поддерживаются только URL `file://`, `http://` и `https://`." + }, + "STAGED_CLUSTERS": "Кластеры, которые этот промежуточный ввод переопределяет для подключенного ввода провайдера.", + "STAGED_PERSIST": "Необязательный путь к файлу для сохранения или повторного использования загруженного промежуточного плейлиста.", + "URL": "URL источника для этого ввода." , "SERIES_SOURCE": "Селектор источника для кластера Series, когда включен промежуточный ввод (`default`, `staged`, `input`, `skip`).", "VOD_SOURCE": "Селектор источника для кластера VOD, когда включен промежуточный ввод (`default`, `staged`, `input`, `skip`)." @@ -1295,6 +1302,7 @@ "SPECIFIC_TEMPLATES": "Конкретные шаблоны", "SSDP_DISCOVERY": "Обнаружение SSDP", "STAGED": "Промежуточный", + "CHILD": "Дочерний", "LIVE_SOURCE": "Источник Live", "VOD_SOURCE": "Источник VOD", "SERIES_SOURCE": "Источник сериалов", @@ -1683,6 +1691,7 @@ "BRICK_InputJellyfin": "Jellyfin", "BRICK_InputLibrary": "Библиотека", "BRICK_InputM3u": "M3u", + "BRICK_InputStaged": "Промежуточный", "BRICK_InputPlex": "Plex", "BRICK_InputXtream": "Xtream", "BRICK_OutputHdHomeRun": "HDHR", diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index cb7cb527a..1092e63c9 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -367,6 +367,10 @@ body { --theme-brand-brick-input-xtream: var(--theme-palette-graph-input-xtream); --theme-brand-brick-input-m3u: var(--theme-palette-graph-input-m3u); --theme-brand-brick-input-library: var(--theme-palette-graph-input-library); + --theme-brand-brick-input-staged: var(--theme-palette-graph-input-staged); + --theme-brand-brick-input-jellyfin: var(--theme-palette-graph-input-jellyfin); + --theme-brand-brick-input-emby: var(--theme-palette-graph-input-emby); + --theme-brand-brick-input-plex: var(--theme-palette-graph-input-plex); --theme-brand-brick-target: var(--theme-palette-graph-target); --theme-brand-brick-output: var(--theme-palette-graph-output); --theme-brand-brick-output-xtream: var(--theme-palette-graph-output-xtream); @@ -670,6 +674,18 @@ body { --brick-input-library-border-color: color-mix(in srgb, var(--theme-brand-brick-input-library) 80%, transparent); --brick-input-library-background-color: color-mix(in srgb, var(--theme-brand-brick-input-library) 53%, transparent); --brick-input-library-shadow-color: color-mix(in srgb, var(--theme-brand-brick-input-library) 50%, transparent); + --brick-input-staged-border-color: color-mix(in srgb, var(--theme-brand-brick-input-staged) 80%, transparent); + --brick-input-staged-background-color: color-mix(in srgb, var(--theme-brand-brick-input-staged) 53%, transparent); + --brick-input-staged-shadow-color: color-mix(in srgb, var(--theme-brand-brick-input-staged) 50%, transparent); + --brick-input-jellyfin-border-color: color-mix(in srgb, var(--theme-brand-brick-input-jellyfin) 80%, transparent); + --brick-input-jellyfin-background-color: color-mix(in srgb, var(--theme-brand-brick-input-jellyfin) 53%, transparent); + --brick-input-jellyfin-shadow-color: color-mix(in srgb, var(--theme-brand-brick-input-jellyfin) 50%, transparent); + --brick-input-emby-border-color: color-mix(in srgb, var(--theme-brand-brick-input-emby) 80%, transparent); + --brick-input-emby-background-color: color-mix(in srgb, var(--theme-brand-brick-input-emby) 53%, transparent); + --brick-input-emby-shadow-color: color-mix(in srgb, var(--theme-brand-brick-input-emby) 50%, transparent); + --brick-input-plex-border-color: color-mix(in srgb, var(--theme-brand-brick-input-plex) 80%, transparent); + --brick-input-plex-background-color: color-mix(in srgb, var(--theme-brand-brick-input-plex) 53%, transparent); + --brick-input-plex-shadow-color: color-mix(in srgb, var(--theme-brand-brick-input-plex) 50%, transparent); --brick-target-border-color: color-mix(in srgb, var(--theme-brand-brick-target) 80%, transparent); --brick-target-background-color: color-mix(in srgb, var(--theme-brand-brick-target) 53%, transparent); --brick-target-shadow-color: color-mix(in srgb, var(--theme-brand-brick-target) 50%, transparent); diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index 41d43c463..95fb5ca2a 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -40,7 +40,6 @@ @forward "components/playlist/input/input_table"; @forward "components/playlist/input/input_type"; @forward "components/playlist/input/batch_input_content_view"; -@forward "components/playlist/input/staged_input_view"; @forward "components/playlist/playlist_mappings"; @forward "components/playlist/playlist_processing"; @forward "components/playlist/source_selector"; diff --git a/frontend/scss/app/components/_keyvalue_editor.scss b/frontend/scss/app/components/_keyvalue_editor.scss index 1528d5909..5d3c02d45 100644 --- a/frontend/scss/app/components/_keyvalue_editor.scss +++ b/frontend/scss/app/components/_keyvalue_editor.scss @@ -19,5 +19,10 @@ flex-flow: row; box-sizing: border-box; gap: var(--gap-small); + + .svg-icon { + height: 2rem; + width: 2rem; + } } } \ No newline at end of file diff --git a/frontend/scss/app/components/_toastr.scss b/frontend/scss/app/components/_toastr.scss index b6981b18a..04a0031d7 100644 --- a/frontend/scss/app/components/_toastr.scss +++ b/frontend/scss/app/components/_toastr.scss @@ -54,6 +54,7 @@ from { transform: scaleX(1); } + to { transform: scaleX(0); } @@ -71,6 +72,7 @@ .tp__toast.error { background-color: var(--toastr-error-background-color); color: var(--toastr-error-color); + .tp__icon-button { fill: var(--toastr-error-color); } @@ -79,6 +81,7 @@ .tp__toast.info { background-color: var(--toastr-info-background-color); color: var(--toastr-info-color); + .tp__icon-button { fill: var(--toastr-info-color); } @@ -87,6 +90,7 @@ .tp__toast.warning { background-color: var(--toastr-warning-background-color); color: var(--toastr-warning-color); + .tp__icon-button { fill: var(--toastr-warning-color); } @@ -97,6 +101,7 @@ opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); @@ -112,4 +117,4 @@ animation: none; display: none; } -} +} \ No newline at end of file diff --git a/frontend/scss/app/components/playlist/input/_staged_input_view.scss b/frontend/scss/app/components/playlist/input/_staged_input_view.scss deleted file mode 100644 index 1ed844a78..000000000 --- a/frontend/scss/app/components/playlist/input/_staged_input_view.scss +++ /dev/null @@ -1,32 +0,0 @@ -.tp__staged-input-view { - display: flex; - flex-flow: column; - box-sizing: border-box; - overflow: auto; pointer-events: auto; - touch-action: pan-x pan-y; - -webkit-overflow-scrolling: touch; - gap: var(--gap-small); - - &__row { - display: flex; - flex-flow: row; - gap: var(--gap-small); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: var(--padding-micro) var(--padding-mini); - background-color: var(--table-card-background-color); - overflow: hidden; - align-items: center; - - label { - font-weight: bold; - white-space: nowrap; - vertical-align: middle; - color: var(--modest-text-color); - - &:after { - content: ":"; - } - } - } -} \ No newline at end of file diff --git a/frontend/scss/app/components/source_editor/_source_editor_block.scss b/frontend/scss/app/components/source_editor/_source_editor_block.scss index f4a80e0e2..3e06aa753 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_block.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_block.scss @@ -176,6 +176,26 @@ $port-size: 10px; background-color: var(--brick-input-library-background-color); } + &-InputStaged { + border-color: var(--brick-input-staged-border-color); + background-color: var(--brick-input-staged-background-color); + } + + &-InputJellyfin { + border-color: var(--brick-input-jellyfin-border-color); + background-color: var(--brick-input-jellyfin-background-color); + } + + &-InputEmby { + border-color: var(--brick-input-emby-border-color); + background-color: var(--brick-input-emby-background-color); + } + + &-InputPlex { + border-color: var(--brick-input-plex-border-color); + background-color: var(--brick-input-plex-background-color); + } + &-Target { border-color: var(--brick-target-border-color); background-color: var(--brick-target-background-color); @@ -280,6 +300,22 @@ $port-size: 10px; @include brick-handle-input-color(var(--brick-input-library-background-color), var(--brick-input-library-border-color)); } + &-InputStaged { + @include brick-handle-input-color(var(--brick-input-staged-background-color), var(--brick-input-staged-border-color)); + } + + &-InputJellyfin { + @include brick-handle-input-color(var(--brick-input-jellyfin-background-color), var(--brick-input-jellyfin-border-color)); + } + + &-InputEmby { + @include brick-handle-input-color(var(--brick-input-emby-background-color), var(--brick-input-emby-border-color)); + } + + &-InputPlex { + @include brick-handle-input-color(var(--brick-input-plex-background-color), var(--brick-input-plex-border-color)); + } + &-Target { .tp__source-editor__block-content { border-color: var(--brick-target-border-color); diff --git a/frontend/scss/themes/_theme-aurora.scss b/frontend/scss/themes/_theme-aurora.scss index 2457f1ecd..055e1063e 100644 --- a/frontend/scss/themes/_theme-aurora.scss +++ b/frontend/scss/themes/_theme-aurora.scss @@ -248,6 +248,10 @@ body[data-theme="aurora"] { --theme-palette-graph-input-xtream: #22d3ee; --theme-palette-graph-input-m3u: #38bdf8; --theme-palette-graph-input-library: #c084fc; + --theme-palette-graph-input-staged: #f97316; + --theme-palette-graph-input-jellyfin: #15803d; + --theme-palette-graph-input-emby: #22c55e; + --theme-palette-graph-input-plex: #86efac; --theme-palette-graph-target: #14b8a6; --theme-palette-graph-output: #f59e0b; --theme-palette-graph-output-xtream: #5eead4; diff --git a/frontend/scss/themes/_theme-banana-yellow.scss b/frontend/scss/themes/_theme-banana-yellow.scss index ddc676f5c..39ad6ba8c 100644 --- a/frontend/scss/themes/_theme-banana-yellow.scss +++ b/frontend/scss/themes/_theme-banana-yellow.scss @@ -258,6 +258,10 @@ body[data-theme="banana-yellow"] { --theme-palette-graph-input-xtream: #d5ab20; --theme-palette-graph-input-m3u: #58aaa2; --theme-palette-graph-input-library: #d49aca; + --theme-palette-graph-input-staged: #ea580c; + --theme-palette-graph-input-jellyfin: #0369a1; + --theme-palette-graph-input-emby: #0284c7; + --theme-palette-graph-input-plex: #38bdf8; --theme-palette-graph-target: #4d7c4c; --theme-palette-graph-output: #d09126; --theme-palette-graph-output-xtream: #6aaea6; diff --git a/frontend/scss/themes/_theme-bright.scss b/frontend/scss/themes/_theme-bright.scss index 27fe948b5..d2cf5a8b3 100644 --- a/frontend/scss/themes/_theme-bright.scss +++ b/frontend/scss/themes/_theme-bright.scss @@ -258,6 +258,10 @@ body[data-theme="bright"] { --theme-palette-graph-input-xtream: #3b82f6; --theme-palette-graph-input-m3u: #0ea5e9; --theme-palette-graph-input-library: #8b5cf6; + --theme-palette-graph-input-staged: #ea580c; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #22c55e; --theme-palette-graph-output: #f59e0b; --theme-palette-graph-output-xtream: #06b6d4; diff --git a/frontend/scss/themes/_theme-clubroom-contrast.scss b/frontend/scss/themes/_theme-clubroom-contrast.scss index 97fdc67a8..e9fda2400 100644 --- a/frontend/scss/themes/_theme-clubroom-contrast.scss +++ b/frontend/scss/themes/_theme-clubroom-contrast.scss @@ -248,6 +248,10 @@ body[data-theme="clubroom-contrast"] { --theme-palette-graph-input-xtream: #4f8790; --theme-palette-graph-input-m3u: #6eaab0; --theme-palette-graph-input-library: #7b5d48; + --theme-palette-graph-input-staged: #f97316; + --theme-palette-graph-input-jellyfin: #15803d; + --theme-palette-graph-input-emby: #22c55e; + --theme-palette-graph-input-plex: #86efac; --theme-palette-graph-target: #7d9950; --theme-palette-graph-output: #c18a42; --theme-palette-graph-output-xtream: #588c93; diff --git a/frontend/scss/themes/_theme-cool-elegance.scss b/frontend/scss/themes/_theme-cool-elegance.scss index 6de702383..780b95d1e 100644 --- a/frontend/scss/themes/_theme-cool-elegance.scss +++ b/frontend/scss/themes/_theme-cool-elegance.scss @@ -258,6 +258,10 @@ body[data-theme="cool-elegance"] { --theme-palette-graph-input-xtream: #6c90ab; --theme-palette-graph-input-m3u: #5f99b8; --theme-palette-graph-input-library: #aebce4; + --theme-palette-graph-input-staged: #d0943c; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #4d7c4c; --theme-palette-graph-output: #b99563; --theme-palette-graph-output-xtream: #6aaea6; diff --git a/frontend/scss/themes/_theme-cvd-friendly-bright.scss b/frontend/scss/themes/_theme-cvd-friendly-bright.scss index 51327b93d..4750da426 100644 --- a/frontend/scss/themes/_theme-cvd-friendly-bright.scss +++ b/frontend/scss/themes/_theme-cvd-friendly-bright.scss @@ -258,6 +258,10 @@ body[data-theme="cvd-friendly-bright"] { --theme-palette-graph-input-xtream: #387fb3; --theme-palette-graph-input-m3u: #207c99; --theme-palette-graph-input-library: #8d70ad; + --theme-palette-graph-input-staged: #d55e00; + --theme-palette-graph-input-jellyfin: #0072b2; + --theme-palette-graph-input-emby: #56b4e9; + --theme-palette-graph-input-plex: #bdd7e7; --theme-palette-graph-target: #14846c; --theme-palette-graph-output: #b97a00; --theme-palette-graph-output-xtream: #207c99; diff --git a/frontend/scss/themes/_theme-cvd-friendly-dark.scss b/frontend/scss/themes/_theme-cvd-friendly-dark.scss index a13b792e5..d6d073a98 100644 --- a/frontend/scss/themes/_theme-cvd-friendly-dark.scss +++ b/frontend/scss/themes/_theme-cvd-friendly-dark.scss @@ -248,6 +248,10 @@ body[data-theme="cvd-friendly-dark"] { --theme-palette-graph-input-xtream: #2f6f9f; --theme-palette-graph-input-m3u: #2e9bb1; --theme-palette-graph-input-library: #8d70ad; + --theme-palette-graph-input-staged: #e69f00; + --theme-palette-graph-input-jellyfin: #0072b2; + --theme-palette-graph-input-emby: #56b4e9; + --theme-palette-graph-input-plex: #bdd7e7; --theme-palette-graph-target: #24a388; --theme-palette-graph-output: #dfa23c; --theme-palette-graph-output-xtream: #2f6f9f; diff --git a/frontend/scss/themes/_theme-dark.scss b/frontend/scss/themes/_theme-dark.scss index 2227c0e5e..c158fd558 100644 --- a/frontend/scss/themes/_theme-dark.scss +++ b/frontend/scss/themes/_theme-dark.scss @@ -248,6 +248,10 @@ body[data-theme="dark"] { --theme-palette-graph-input-xtream: #0061cf; --theme-palette-graph-input-m3u: #5c88ff; --theme-palette-graph-input-library: #c341db; + --theme-palette-graph-input-staged: #f97316; + --theme-palette-graph-input-jellyfin: #15803d; + --theme-palette-graph-input-emby: #22c55e; + --theme-palette-graph-input-plex: #86efac; --theme-palette-graph-target: #2aa198; --theme-palette-graph-output: #f39c12; --theme-palette-graph-output-xtream: #0061cf; diff --git a/frontend/scss/themes/_theme-dopamine.scss b/frontend/scss/themes/_theme-dopamine.scss index 12b3780ea..54fa7a6f2 100644 --- a/frontend/scss/themes/_theme-dopamine.scss +++ b/frontend/scss/themes/_theme-dopamine.scss @@ -248,6 +248,10 @@ body[data-theme="dopamine"] { --theme-palette-graph-input-xtream: #00f5ff; --theme-palette-graph-input-m3u: #59fff7; --theme-palette-graph-input-library: #8b5cf6; + --theme-palette-graph-input-staged: #ff9500; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #c6ff00; --theme-palette-graph-output: #ff9f1c; --theme-palette-graph-output-xtream: #00d9ff; diff --git a/frontend/scss/themes/_theme-dracula.scss b/frontend/scss/themes/_theme-dracula.scss index 80952045c..de931fd50 100644 --- a/frontend/scss/themes/_theme-dracula.scss +++ b/frontend/scss/themes/_theme-dracula.scss @@ -248,6 +248,10 @@ body[data-theme="dracula"] { --theme-palette-graph-input-xtream: #8be9fd; --theme-palette-graph-input-m3u: #8be9fd; --theme-palette-graph-input-library: #bd93f9; + --theme-palette-graph-input-staged: #ff79c6; + --theme-palette-graph-input-jellyfin: #50fa7b; + --theme-palette-graph-input-emby: #8be9fd; + --theme-palette-graph-input-plex: #f1fa8c; --theme-palette-graph-target: #30b853; --theme-palette-graph-output: #ffb86c; --theme-palette-graph-output-xtream: #8be9fd; diff --git a/frontend/scss/themes/_theme-graperoot-dark.scss b/frontend/scss/themes/_theme-graperoot-dark.scss index 257bb5bef..ed0cb8b3a 100644 --- a/frontend/scss/themes/_theme-graperoot-dark.scss +++ b/frontend/scss/themes/_theme-graperoot-dark.scss @@ -248,6 +248,10 @@ body[data-theme="graperoot-dark"] { --theme-palette-graph-input-xtream: #60a5fa; --theme-palette-graph-input-m3u: #c084fc; --theme-palette-graph-input-library: #e9d5ff; + --theme-palette-graph-input-staged: #fb923c; + --theme-palette-graph-input-jellyfin: #15803d; + --theme-palette-graph-input-emby: #22c55e; + --theme-palette-graph-input-plex: #86efac; --theme-palette-graph-target: #1cb45e; --theme-palette-graph-output: #facc15; --theme-palette-graph-output-xtream: #38bdf8; diff --git a/frontend/scss/themes/_theme-mermaidcore.scss b/frontend/scss/themes/_theme-mermaidcore.scss index 59e37343e..ccf521f57 100644 --- a/frontend/scss/themes/_theme-mermaidcore.scss +++ b/frontend/scss/themes/_theme-mermaidcore.scss @@ -248,6 +248,10 @@ body[data-theme="mermaidcore"] { --theme-palette-graph-input-xtream: #5ccfe6; --theme-palette-graph-input-m3u: #7ed7ff; --theme-palette-graph-input-library: #a893ff; + --theme-palette-graph-input-staged: #fb923c; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #14b8a6; --theme-palette-graph-output: #ffc887; --theme-palette-graph-output-xtream: #8ae9df; diff --git a/frontend/scss/themes/_theme-monokai.scss b/frontend/scss/themes/_theme-monokai.scss index 998f4e897..b2c2bdb79 100644 --- a/frontend/scss/themes/_theme-monokai.scss +++ b/frontend/scss/themes/_theme-monokai.scss @@ -248,6 +248,10 @@ body[data-theme="monokai"] { --theme-palette-graph-input-xtream: #66d9ef; --theme-palette-graph-input-m3u: #78dce8; --theme-palette-graph-input-library: #ab9df2; + --theme-palette-graph-input-staged: #fd971f; + --theme-palette-graph-input-jellyfin: #f92672; + --theme-palette-graph-input-emby: #ff6188; + --theme-palette-graph-input-plex: #ffb3c0; --theme-palette-graph-target: #a6e22e; --theme-palette-graph-output: #ffb86c; --theme-palette-graph-output-xtream: #5ad4e0; diff --git a/frontend/scss/themes/_theme-nature-pure.scss b/frontend/scss/themes/_theme-nature-pure.scss index f18474876..16c3ce419 100644 --- a/frontend/scss/themes/_theme-nature-pure.scss +++ b/frontend/scss/themes/_theme-nature-pure.scss @@ -258,6 +258,10 @@ body[data-theme="nature-pure"] { --theme-palette-graph-input-xtream: #7c6a3f; --theme-palette-graph-input-m3u: #6a8e7c; --theme-palette-graph-input-library: #9e8778; + --theme-palette-graph-input-staged: #d0943c; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #4d7c4c; --theme-palette-graph-output: #ba7a3a; --theme-palette-graph-output-xtream: #6aaea6; diff --git a/frontend/scss/themes/_theme-nord.scss b/frontend/scss/themes/_theme-nord.scss index 266ce6c9d..379ba568d 100644 --- a/frontend/scss/themes/_theme-nord.scss +++ b/frontend/scss/themes/_theme-nord.scss @@ -249,6 +249,10 @@ body[data-theme="nord"] { --theme-palette-graph-input-xtream: #5e81ac; --theme-palette-graph-input-m3u: #409aff; --theme-palette-graph-input-library: #64b5f6; + --theme-palette-graph-input-staged: #a3be8c; + --theme-palette-graph-input-jellyfin: #bf616a; + --theme-palette-graph-input-emby: #d08770; + --theme-palette-graph-input-plex: #ebcb8b; --theme-palette-graph-target: #88c0d0; --theme-palette-graph-output: #d8b66f; --theme-palette-graph-output-xtream: #88c0d0; diff --git a/frontend/scss/themes/_theme-paper.scss b/frontend/scss/themes/_theme-paper.scss index 3e85f0a21..09ea47ad9 100644 --- a/frontend/scss/themes/_theme-paper.scss +++ b/frontend/scss/themes/_theme-paper.scss @@ -257,6 +257,10 @@ body[data-theme="paper"] { --theme-palette-graph-input-xtream: #8c6b3f; --theme-palette-graph-input-m3u: #5a9b89; --theme-palette-graph-input-library: #b989b2; + --theme-palette-graph-input-staged: #c2410c; + --theme-palette-graph-input-jellyfin: #15803d; + --theme-palette-graph-input-emby: #22c55e; + --theme-palette-graph-input-plex: #86efac; --theme-palette-graph-target: #4d7c4c; --theme-palette-graph-output: #c0822f; --theme-palette-graph-output-xtream: #6aaea6; diff --git a/frontend/scss/themes/_theme-refined-dark.scss b/frontend/scss/themes/_theme-refined-dark.scss index bae503f86..a785e4b11 100644 --- a/frontend/scss/themes/_theme-refined-dark.scss +++ b/frontend/scss/themes/_theme-refined-dark.scss @@ -248,6 +248,10 @@ body[data-theme="refined-dark"] { --theme-palette-graph-input-xtream: #0061cf; --theme-palette-graph-input-m3u: #5c88ff; --theme-palette-graph-input-library: #c341db; + --theme-palette-graph-input-staged: #f97316; + --theme-palette-graph-input-jellyfin: #15803d; + --theme-palette-graph-input-emby: #22c55e; + --theme-palette-graph-input-plex: #86efac; --theme-palette-graph-target: #2aa198; --theme-palette-graph-output: #f39c12; --theme-palette-graph-output-xtream: #0061cf; diff --git a/frontend/scss/themes/_theme-sun-washed-soft.scss b/frontend/scss/themes/_theme-sun-washed-soft.scss index ba798de11..4031fb656 100644 --- a/frontend/scss/themes/_theme-sun-washed-soft.scss +++ b/frontend/scss/themes/_theme-sun-washed-soft.scss @@ -257,6 +257,10 @@ body[data-theme="sun-washed-soft"] { --theme-palette-graph-input-xtream: #d5b28d; --theme-palette-graph-input-m3u: #8cbec2; --theme-palette-graph-input-library: #d4b3d2; + --theme-palette-graph-input-staged: #ea580c; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #4d7c4c; --theme-palette-graph-output: #d6a474; --theme-palette-graph-output-xtream: #6aaea6; diff --git a/frontend/scss/themes/_theme-vibrant-elegance.scss b/frontend/scss/themes/_theme-vibrant-elegance.scss index 4f93ab97f..c5c369522 100644 --- a/frontend/scss/themes/_theme-vibrant-elegance.scss +++ b/frontend/scss/themes/_theme-vibrant-elegance.scss @@ -247,6 +247,10 @@ body[data-theme="vibrant-elegance"] { --theme-palette-graph-input-xtream: #26c9c4; --theme-palette-graph-input-m3u: #40c4d8; --theme-palette-graph-input-library: #7ed7c9; + --theme-palette-graph-input-staged: #f97316; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #14b8a6; --theme-palette-graph-output: #e9b949; --theme-palette-graph-output-xtream: #6de4d2; diff --git a/frontend/scss/themes/_theme-vintage-neutral.scss b/frontend/scss/themes/_theme-vintage-neutral.scss index 006509c28..288c93895 100644 --- a/frontend/scss/themes/_theme-vintage-neutral.scss +++ b/frontend/scss/themes/_theme-vintage-neutral.scss @@ -257,6 +257,10 @@ body[data-theme="vintage-neutral"] { --theme-palette-graph-input-xtream: #8d7a53; --theme-palette-graph-input-m3u: #78958d; --theme-palette-graph-input-library: #a9a4c0; + --theme-palette-graph-input-staged: #c2410c; + --theme-palette-graph-input-jellyfin: #b45309; + --theme-palette-graph-input-emby: #d97706; + --theme-palette-graph-input-plex: #fbbf24; --theme-palette-graph-target: #4d7c4c; --theme-palette-graph-output: #b08548; --theme-palette-graph-output-xtream: #6aaea6; diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index f73c1f9df..315d3b4f3 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -18,6 +18,25 @@ macro_rules! config_field_optional { } }; + ($config:expr, $label:expr, $field:ident, $hint:expr) => { + html! { +
+ <$crate::app::components::FieldLabel + label={$label.to_string()} + field_id={$crate::app::components::dto_field_id(&$config, stringify!($field))} + hint_key={$hint} + /> + + { + match $config.$field.as_ref() { + Some(value) => html! { &value }, + None => Html::default(), + } + } + +
+ } + }; } #[macro_export] diff --git a/frontend/src/app/components/confirm_dialog.rs b/frontend/src/app/components/confirm_dialog.rs index 8a9d06b2f..d3fe6730d 100644 --- a/frontend/src/app/components/confirm_dialog.rs +++ b/frontend/src/app/components/confirm_dialog.rs @@ -60,6 +60,7 @@ pub fn ConfirmDialog(props: &ConfirmDialogProps) -> Html { open={*is_open} class="tp__confirm-dialog" modal=true + aria_label={Some(props.title.replace('\n', " "))} close_on_backdrop_click={props.close_on_backdrop_click} on_close={Some(on_close)} > diff --git a/frontend/src/app/components/custom_dialog.rs b/frontend/src/app/components/custom_dialog.rs index ebdb9f3a2..7e6c5d292 100644 --- a/frontend/src/app/components/custom_dialog.rs +++ b/frontend/src/app/components/custom_dialog.rs @@ -1,6 +1,26 @@ -use web_sys::MouseEvent; +use wasm_bindgen::JsCast; +use web_sys::{window, Element, HtmlElement, KeyboardEvent, MouseEvent}; use yew::prelude::*; +const FOCUSABLE_SELECTOR: &str = "a[href], button:not([disabled]), input:not([disabled]), \ +select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])"; + +fn focusable_elements(container: &Element) -> Vec { + let mut result = Vec::new(); + if let Ok(nodes) = container.query_selector_all(FOCUSABLE_SELECTOR) { + for i in 0..nodes.length() { + if let Some(element) = nodes.item(i).and_then(|node| node.dyn_into::().ok()) { + // `offset_parent` is `None` for elements that are not rendered + // (e.g. `display: none`), which should not receive focus. + if element.offset_parent().is_some() { + result.push(element); + } + } + } + } + result +} + #[derive(Properties, PartialEq)] pub struct CustomDialogProps { pub children: Children, @@ -12,21 +32,57 @@ pub struct CustomDialogProps { #[prop_or(false)] pub close_on_backdrop_click: bool, pub on_close: Option>, + #[prop_or_default] + pub aria_label: Option, } #[component] pub fn CustomDialog(props: &CustomDialogProps) -> Html { let is_open = use_state(|| props.open); + let dialog_ref = use_node_ref(); + let previously_focused = use_mut_ref(|| None::); // Update state when props change { let is_open = is_open.clone(); + let previously_focused = previously_focused.clone(); use_effect_with(props.open, move |&open| { + if open { + *previously_focused.borrow_mut() = window() + .and_then(|w| w.document()) + .and_then(|document| document.active_element()) + .and_then(|el| el.dyn_into::().ok()); + } is_open.set(open); || () }); } + { + let dialog_ref = dialog_ref.clone(); + let previously_focused = previously_focused.clone(); + use_effect_with(*is_open, move |open| { + if *open { + if let Some(document) = window().and_then(|w| w.document()) { + if let Some(container) = dialog_ref.cast::() { + let focus_inside = + document.active_element().is_some_and(|active| container.contains(Some(active.as_ref()))); + if !focus_inside { + let _ = container.focus(); + } + } + } + } + + let previously_focused = previously_focused.clone(); + move || { + if let Some(previous) = previously_focused.borrow_mut().take() { + let _ = previous.focus(); + } + } + }); + } + // Handle backdrop click let on_backdrop_click = { let on_close = props.on_close.clone(); @@ -41,6 +97,49 @@ pub fn CustomDialog(props: &CustomDialogProps) -> Html { }) }; + // Close on Escape + let on_key_down = { + let dialog_ref = dialog_ref.clone(); + let on_close = props.on_close.clone(); + let dismissable = props.close_on_backdrop_click; + + Callback::from(move |event: KeyboardEvent| match event.key().as_str() { + "Escape" => { + event.stop_propagation(); + if dismissable { + if let Some(on_close) = &on_close { + event.prevent_default(); + on_close.emit(()); + } + } + } + "Tab" => { + event.stop_propagation(); + let Some(container) = dialog_ref.cast::() else { + return; + }; + let focusables = focusable_elements(&container); + let Some(first) = focusables.first() else { + event.prevent_default(); + return; + }; + let last = focusables.last().unwrap_or(first); + let active = window().and_then(|w| w.document()).and_then(|d| d.active_element()); + + if event.shift_key() { + if active.as_ref().is_some_and(|a| a.is_same_node(Some(first.as_ref()))) { + event.prevent_default(); + let _ = last.focus(); + } + } else if active.as_ref().is_some_and(|a| a.is_same_node(Some(last.as_ref()))) { + event.prevent_default(); + let _ = first.focus(); + } + } + _ => {} + }) + }; + // Only render if open if !*is_open { return html! {}; @@ -48,7 +147,16 @@ pub fn CustomDialog(props: &CustomDialogProps) -> Html { html! {
-
+
diff --git a/frontend/src/app/components/field_wrapper.rs b/frontend/src/app/components/field_wrapper.rs new file mode 100644 index 000000000..4a0978b9b --- /dev/null +++ b/frontend/src/app/components/field_wrapper.rs @@ -0,0 +1,37 @@ +use crate::app::components::FieldLabel; +use yew::prelude::*; + +#[derive(Properties, PartialEq)] +pub struct FieldWrapperProps { + pub field_id: String, + #[prop_or_default] + pub label: Option, + #[prop_or_default] + pub hint_key: Option, + #[prop_or_default] + pub class: Classes, + #[prop_or(true)] + pub link_label: bool, + pub children: Children, +} + +#[component] +pub fn FieldWrapper(props: &FieldWrapperProps) -> Html { + let for_id = props.link_label.then(|| props.field_id.clone()); + + html! { +
+ { props.label.as_ref().map_or_else(Html::default, |label| html! { + + }) } +
+ { for props.children.iter() } +
+
+ } +} diff --git a/frontend/src/app/components/input.rs b/frontend/src/app/components/input.rs index 398240e5c..da588c3a2 100644 --- a/frontend/src/app/components/input.rs +++ b/frontend/src/app/components/input.rs @@ -1,5 +1,5 @@ use crate::{ - app::components::{resolve_field_id, AppIcon, FieldLabel, IconButton}, + app::components::{resolve_field_id, AppIcon, FieldWrapper, IconButton}, html_if, }; use web_sys::{HtmlInputElement, InputEvent, KeyboardEvent, MouseEvent}; @@ -30,6 +30,8 @@ pub struct InputProps { #[prop_or_default] pub placeholder: Option, #[prop_or_default] + pub hint_key: Option, + #[prop_or_default] pub aria_label: Option, } @@ -75,36 +77,24 @@ pub fn Input(props: &InputProps) -> Html { if props.label.is_some() { None } else { props.aria_label.clone().or_else(|| props.placeholder.clone()) }; html! { -
- { if props.label.is_some() { - html! { - - } - } else { html!{} } - } -
- { html_if!(props.icon.is_some(), { - - })} - - { html_if!(props.hidden, { - - })} -
-
+ + { html_if!(props.icon.is_some(), { + + })} + + { html_if!(props.hidden, { + + })} + } } diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index e09930122..873322ad9 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -60,6 +60,7 @@ mod cluster_flags_input; mod country; mod field_explanation; mod field_id; +mod field_wrapper; mod filter; mod particle_flow_background; mod setup; @@ -75,8 +76,8 @@ pub(crate) use self::{ accordion::*, accordion_panel::*, authentication::*, breadcrumbs::*, card::*, cell_value::*, chip::*, cluster_flags_input::*, collapse_panel::*, country::*, csv_table::*, custom_dialog::*, dashboard::*, date_input::*, date_input_action::*, downloads::DownloadsView, drop_down_icon_button::*, error_boundary::*, field_explanation::*, - field_id::*, filter::*, health_banner::*, hide_content::*, home::*, icon_button::*, key_value_editor::*, - language_picker::*, loading_screen::*, login::*, no_access::*, no_content::*, panel::*, + field_id::*, field_wrapper::*, filter::*, health_banner::*, hide_content::*, home::*, icon_button::*, + key_value_editor::*, language_picker::*, loading_screen::*, login::*, no_access::*, no_content::*, panel::*, particle_flow_background::*, playlist::*, radio_button_group::*, rbac::*, reveal_content::*, role_based_content::*, search::*, select::*, setup::*, sidebar::*, source_editor::*, svg_icon::*, table::*, tabset::*, tag_list::*, text_button::*, textarea::*, theme_picker::*, title_card::*, toastr::*, toggle_switch::*, userlist::*, diff --git a/frontend/src/app/components/number_input.rs b/frontend/src/app/components/number_input.rs index 25124745f..7f2d323e4 100644 --- a/frontend/src/app/components/number_input.rs +++ b/frontend/src/app/components/number_input.rs @@ -1,4 +1,4 @@ -use crate::app::components::{resolve_field_id, FieldLabel}; +use crate::app::components::{resolve_field_id, FieldWrapper}; use shared::utils::{format_float_localized, parse_localized_float}; use web_sys::{HtmlInputElement, KeyboardEvent}; use yew::{prelude::*, TargetCast}; @@ -97,27 +97,16 @@ pub fn NumberInput(props: &NumberInputProps) -> Html { }; html! { -
- { if let Some(label) = &props.label { - html! { - - } - } else { html!{} } } -
- -
-
+ + + } } diff --git a/frontend/src/app/components/playlist/input/input_type_view.rs b/frontend/src/app/components/playlist/input/input_type_view.rs index 3c03088d5..98ed5d551 100644 --- a/frontend/src/app/components/playlist/input/input_type_view.rs +++ b/frontend/src/app/components/playlist/input/input_type_view.rs @@ -20,6 +20,7 @@ pub fn InputTypeView(props: &InputTypeViewProps) -> Html { InputType::Emby => "LABEL.EMBY", InputType::Jellyfin => "LABEL.JELLYFIN", InputType::Plex => "LABEL.PLEX", + InputType::Staged => "LABEL.STAGED", }; html! { diff --git a/frontend/src/app/components/playlist/input/mod.rs b/frontend/src/app/components/playlist/input/mod.rs index f651bebff..344091ae8 100644 --- a/frontend/src/app/components/playlist/input/mod.rs +++ b/frontend/src/app/components/playlist/input/mod.rs @@ -2,8 +2,5 @@ mod batch_input_content_view; mod input_headers; mod input_options; mod input_type_view; -mod staged_input_view; -pub use self::{ - batch_input_content_view::*, input_headers::*, input_options::*, input_type_view::*, staged_input_view::*, -}; +pub use self::{batch_input_content_view::*, input_headers::*, input_options::*, input_type_view::*}; diff --git a/frontend/src/app/components/playlist/input/staged_input_view.rs b/frontend/src/app/components/playlist/input/staged_input_view.rs deleted file mode 100644 index bcb286fed..000000000 --- a/frontend/src/app/components/playlist/input/staged_input_view.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::{ - app::components::{Chip, HideContent, InputHeaders}, - html_if, - i18n::use_translation, -}; -use shared::model::{ClusterSource, InputType, StagedInputDto}; -use yew::prelude::*; - -#[derive(Properties, Clone, PartialEq, Debug)] -pub struct StagedInputViewProps { - pub input: Option, -} - -#[component] -pub fn StagedInputView(props: &StagedInputViewProps) -> Html { - let translate = use_translation(); - - match props.input.as_ref() { - Some(input) => { - let label = match input.input_type { - InputType::M3u => "LABEL.M3U", - InputType::Xtream => "LABEL.XTREAM", - InputType::M3uBatch => "LABEL.M3U_BATCH", - InputType::XtreamBatch => "LABEL.XTREAM_BATCH", - InputType::Library => "LABEL.LIBRARY", - InputType::Emby => "LABEL.EMBY", - InputType::Jellyfin => "LABEL.JELLYFIN", - InputType::Plex => "LABEL.PLEX", - }; - html! { -
- -
- - { &input.url } -
- { - html_if!(input.username.is_some() || input.password.is_some(), { -
- - { input.username.as_ref().map_or_else(String::new, |username| username.clone()) } -
- }) - } -
- - -
- - { - { - let fmt = |cs: &Option| -> String { - cs.as_ref().map(ToString::to_string).unwrap_or_else(|| ClusterSource::default().to_string()) - }; - html! { - <> -
- - { fmt(&input.live_source) } -
-
- - { fmt(&input.vod_source) } -
-
- - { fmt(&input.series_source) } -
- - } - } - } -
- } - } - None => html! {}, - } -} diff --git a/frontend/src/app/components/playlist/input_table.rs b/frontend/src/app/components/playlist/input_table.rs index 80b793f64..78c1e67d2 100644 --- a/frontend/src/app/components/playlist/input_table.rs +++ b/frontend/src/app/components/playlist/input_table.rs @@ -1,8 +1,7 @@ use crate::{ app::components::{ convert_bool_to_chip_style, make_translated_header_callback, AppIcon, BatchInputContentView, Chip, - EpgConfigView, HideContent, InputHeaders, InputOptions, InputTypeView, RevealContent, StagedInputView, Table, - TableDefinition, + EpgConfigView, HideContent, InputHeaders, InputOptions, InputTypeView, RevealContent, Table, TableDefinition, }, html_if, i18n::use_translation, @@ -28,7 +27,7 @@ const HEADERS: [&str; 15] = [ "LABEL.METHOD", "LABEL.EPG", "LABEL.HEADERS", - "LABEL.STAGED", + "LABEL.PROVIDER", "LABEL.EXP_DATE", ]; @@ -74,8 +73,8 @@ pub fn InputTable(props: &InputTableProps) -> Html { 7 => { html! { } } - 8 => html! { dto.priority.to_string() }, - 9 => html! { dto.max_connections.to_string() }, + 8 => html_if!(!dto.input_type.is_staged(), { dto.priority.to_string() }), + 9 => html_if!(!dto.input_type.is_staged(), { dto.max_connections.to_string() }), 10 => html! { dto.method.to_string() }, 11 => html_if!(dto.epg.is_some(), { Html { } } - 13 => html_if!(dto.staged.is_some(), - { - - }), + 13 => dto + .staged + .as_ref() + .and_then(|staged| staged.for_input.as_ref()) + .map_or_else(|| html! {}, |provider| html! { provider.as_ref() }), 14 => dto .exp_date .as_ref() diff --git a/frontend/src/app/components/popup_menu.rs b/frontend/src/app/components/popup_menu.rs index e75912206..42e99f064 100644 --- a/frontend/src/app/components/popup_menu.rs +++ b/frontend/src/app/components/popup_menu.rs @@ -1,3 +1,4 @@ +use crate::hooks::use_key_down; use wasm_bindgen::{prelude::Closure, JsCast}; use web_sys::{window, HtmlElement, KeyboardEvent, MouseEvent}; use yew::{create_portal, prelude::*}; @@ -106,30 +107,11 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html { } { - use_effect_with((props.is_open, props.on_close.clone()), move |(is_open, on_close)| { - let browser_window = web_sys::window(); - let handler = if *is_open { - let on_close = on_close.clone(); - let handler = Closure::wrap(Box::new(move |event: KeyboardEvent| { - if event.key() == "Escape" { - on_close.emit(()); - } - }) as Box); - - if let Some(win) = browser_window.as_ref() { - let _ = win.add_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref()); - } - Some(handler) - } else { - None - }; - - move || { - if let Some(handler) = handler { - if let Some(win) = browser_window.as_ref() { - let _ = win.remove_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref()); - } - } + let is_open = props.is_open; + let on_close = props.on_close.clone(); + use_key_down((is_open, on_close.clone()), move |event: &KeyboardEvent| { + if is_open && event.key() == "Escape" { + on_close.emit(()); } }); } diff --git a/frontend/src/app/components/source_editor/block_view.rs b/frontend/src/app/components/source_editor/block_view.rs index 85dff51d4..b6fa741b1 100644 --- a/frontend/src/app/components/source_editor/block_view.rs +++ b/frontend/src/app/components/source_editor/block_view.rs @@ -40,9 +40,8 @@ pub fn BlockView(props: &BlockProps) -> Html { let from_id = block_id; let to_id = block_id; - let is_target = block_type.is_target(); - let is_input = !is_target && block_type.is_input(); - let is_output = !is_input && !is_target; + let has_input_port = block_type.has_input_port(); + let has_output_port = block_type.has_output_port(); let port_style = match port_status { PortStatus::Valid => "tp__source-editor__block-port--valid", @@ -173,7 +172,7 @@ pub fn BlockView(props: &BlockProps) -> Html { }
- {html_if!(is_target || is_output, { + {html_if!(has_input_port, { // Left port Html { }} /> })} - {html_if!(is_target || is_input, { + {html_if!(has_output_port, { // Right port bool { matches!(self, Self::OutputXtream | Self::OutputM3u | Self::OutputHdHomeRun | Self::OutputStrm) } + + pub fn is_chainable_input(&self) -> bool { matches!(self, Self::InputM3u | Self::InputXtream) } + + /// Left port — block can receive a connection. + pub fn has_input_port(&self) -> bool { self.is_target() || self.is_output() || self.is_chainable_input() } + + /// Right port — block can be the source of a connection. + pub fn has_output_port(&self) -> bool { self.is_target() || self.is_input() } } // Convert from String to BlockType @@ -67,6 +78,7 @@ impl From<&str> for BlockType { BlockType::INPUT_EMBY => BlockType::InputEmby, BlockType::INPUT_JELLYFIN => BlockType::InputJellyfin, BlockType::INPUT_PLEX => BlockType::InputPlex, + BlockType::INPUT_STAGED => BlockType::InputStaged, BlockType::TARGET => BlockType::Target, BlockType::OUTPUT_M3U => BlockType::OutputM3u, BlockType::OUTPUT_XTREAM => BlockType::OutputXtream, @@ -90,6 +102,7 @@ impl From for BlockType { InputType::Emby => BlockType::InputEmby, InputType::Jellyfin => BlockType::InputJellyfin, InputType::Plex => BlockType::InputPlex, + InputType::Staged => BlockType::InputStaged, } } } @@ -104,6 +117,7 @@ impl fmt::Display for BlockType { BlockType::InputEmby => Self::INPUT_EMBY, BlockType::InputJellyfin => Self::INPUT_JELLYFIN, BlockType::InputPlex => Self::INPUT_PLEX, + BlockType::InputStaged => Self::INPUT_STAGED, BlockType::Target => Self::TARGET, BlockType::OutputM3u => Self::OUTPUT_M3U, BlockType::OutputXtream => Self::OUTPUT_XTREAM, diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index c69e46871..47376040a 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -7,7 +7,7 @@ use crate::{ }, ConfigContext, PlaylistContext, }, - hooks::use_service_context, + hooks::{is_text_input_focused, use_key_down, use_service_context}, i18n::use_translation, model::DialogResult, services::DialogService, @@ -15,8 +15,9 @@ use crate::{ use gloo_timers::callback::Timeout; use shared::{ model::{ - permission::Permission, ConfigInputDto, ConfigSourceDto, ConfigTargetDto, HdHomeRunTargetOutputDto, InputType, - M3uTargetOutputDto, SourcesConfigDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto, + permission::Permission, ConfigInputDto, ConfigInputStagedDto, ConfigSourceDto, ConfigTargetDto, + HdHomeRunTargetOutputDto, InputType, M3uTargetOutputDto, SourcesConfigDto, StrmTargetOutputDto, + TargetOutputDto, XtreamTargetOutputDto, }, utils::BATCH_SCHEME_PREFIX, }; @@ -26,7 +27,7 @@ use std::{ rc::Rc, }; use wasm_bindgen::{prelude::Closure, JsCast}; -use web_sys::{window, Element, Event, HtmlElement, MouseEvent, TouchEvent, WheelEvent}; +use web_sys::{window, Element, Event, HtmlElement, KeyboardEvent, MouseEvent, TouchEvent, WheelEvent}; use yew::{platform::spawn_local, prelude::*}; const PENDING_LINE: &str = "pending-line"; @@ -34,6 +35,7 @@ const SELECTION_RECT: &str = "selection-rect"; const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0; const PORT_SNAP_THRESHOLD: f32 = 100.0; +const CLONE_OFFSET: f32 = 30.0; const MIN_ZOOM_FACTOR: f32 = 0.5; const MAX_ZOOM_FACTOR: f32 = 1.0; const ZOOM_INDICATOR_TIMEOUT_MS: u32 = 900; @@ -250,6 +252,14 @@ fn world_from_screen(position: Position, canvas_offset: Position, zoom_factor: f fn clamp_zoom_factor(zoom_factor: f32) -> f32 { zoom_factor.clamp(MIN_ZOOM_FACTOR, MAX_ZOOM_FACTOR) } +fn initial_layout_view_transform() -> (Position, f32) { ((0.0, 0.0), 1.0) } + +fn reset_layout_view(editor_state: &mut EditorState) { + let (offset, zoom) = initial_layout_view_transform(); + editor_state.canvas_offset = offset; + editor_state.zoom_factor = zoom; +} + fn apply_zoom_at_screen_point(editor_state: &mut EditorState, next_zoom: f32, anchor_screen: Position) -> bool { let next_zoom = clamp_zoom_factor(next_zoom); if (next_zoom - editor_state.zoom_factor).abs() < f32::EPSILON { @@ -271,6 +281,7 @@ fn create_instance(block_type: BlockType) -> BlockInstance { BlockType::InputEmby => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::Emby))), BlockType::InputJellyfin => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::Jellyfin))), BlockType::InputPlex => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::Plex))), + BlockType::InputStaged => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::Staged))), BlockType::Target => { let dto = ConfigTargetDto { name: String::new(), @@ -332,10 +343,40 @@ fn normalize_input_type_by_url(input: &mut ConfigInputDto, block_type: BlockType BlockType::InputPlex => { input.input_type = InputType::Plex; } + BlockType::InputStaged => { + input.input_type = InputType::Staged; + } _ => {} } } +fn is_valid_staged_provider_block_type(block_type: BlockType) -> bool { block_type.is_chainable_input() } + +fn is_staged_provider_connection(from_block: &Block, to_block: &Block) -> bool { + matches!(from_block.block_type, BlockType::InputStaged) && is_valid_staged_provider_block_type(to_block.block_type) +} + +/// Builds a collision-free `_copy` name (then `_copy_2`, `_copy_3`, ...) for a duplicated block. +/// Returns an empty string for an empty base so unnamed blocks stay unnamed. +fn unique_copy_name(base: &str, existing: &HashSet) -> String { + let base = base.trim(); + if base.is_empty() { + return String::new(); + } + let first = format!("{base}_copy"); + if !existing.contains(&first) { + return first; + } + let mut counter = 2; + loop { + let candidate = format!("{base}_copy_{counter}"); + if !existing.contains(&candidate) { + return candidate; + } + counter += 1; + } +} + fn input_dedupe_key(input_config: &ConfigInputDto) -> Option { if input_config.id > 0 { Some(format!("id:{}", input_config.id)) @@ -350,6 +391,7 @@ fn editor_state_to_sources_config(base_sources: &SourcesConfigDto, editor_state: let mut sources_config = base_sources.clone(); let mut gen_sources: Vec = Vec::new(); let mut gen_inputs: Vec = Vec::new(); + let mut input_index_by_block_id: HashMap = HashMap::new(); // find all input blocks first let input_blocks: Vec<&Block> = editor_state.blocks.iter().filter(|b| b.block_type.is_input()).collect(); @@ -357,7 +399,64 @@ fn editor_state_to_sources_config(base_sources: &SourcesConfigDto, editor_state: if let BlockInstance::Input(input) = &block.instance { let mut normalized_input = input.as_ref().clone(); normalize_input_type_by_url(&mut normalized_input, block.block_type); + let next_index = gen_inputs.len(); gen_inputs.push(normalized_input); + input_index_by_block_id.insert(block.id, next_index); + } + } + + // Staged provider relation is driven by staged-input -> provider-input graph links. + for conn in &editor_state.connections { + let (Some(from_block), Some(to_block)) = (editor_state.get_block(conn.from), editor_state.get_block(conn.to)) + else { + continue; + }; + if !is_staged_provider_connection(from_block, to_block) { + continue; + } + + let Some(from_input_index) = input_index_by_block_id.get(&from_block.id).copied() else { + continue; + }; + + let provider_name = if let BlockInstance::Input(provider_input) = &to_block.instance { + let name = provider_input.name.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } + } else { + None + }; + + if let Some(provider_name) = provider_name { + let staged = gen_inputs[from_input_index].staged.get_or_insert_with(ConfigInputStagedDto::default); + staged.for_input = Some(provider_name.into()); + } + } + + // If a staged input has no provider link, clear the provider to keep UI graph authoritative. + for (block_id, input_index) in &input_index_by_block_id { + let Some(block) = editor_state.get_block(*block_id) else { + continue; + }; + if !matches!(block.block_type, BlockType::InputStaged) { + continue; + } + let has_provider_link = editor_state.connections.iter().any(|conn| { + if conn.from != *block_id { + return false; + } + let Some(to_block) = editor_state.get_block(conn.to) else { + return false; + }; + is_staged_provider_connection(block, to_block) + }); + if !has_provider_link { + if let Some(staged) = gen_inputs[*input_index].staged.as_mut() { + staged.for_input = None; + } } } @@ -371,7 +470,9 @@ fn editor_state_to_sources_config(base_sources: &SourcesConfigDto, editor_state: for conn in &editor_state.connections { if conn.to == target_block.id { if let Some(input_block) = editor_state.get_block(conn.from) { - if input_block.block_type.is_input() { + if input_block.block_type.is_input() + && !matches!(input_block.block_type, BlockType::InputStaged) + { if let BlockInstance::Input(input_dto) = &input_block.instance { source_dto.inputs.push(input_dto.name.clone()); } @@ -620,13 +721,62 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { } } + // Rebuild staged provider links from config (staged input -> provider input). + let input_name_to_id: HashMap = gen_blocks + .iter() + .filter_map(|block| { + if let BlockInstance::Input(input) = &block.instance { + let name = input.name.trim(); + if name.is_empty() { + None + } else { + Some((name.to_string(), block.id)) + } + } else { + None + } + }) + .collect(); + let mut existing_connections: HashSet<(BlockId, BlockId)> = + gen_connections.iter().map(|c| (c.from, c.to)).collect(); + for block in &gen_blocks { + if let BlockInstance::Input(input) = &block.instance { + if !matches!(block.block_type, BlockType::InputStaged) { + continue; + } + let Some(provider_name) = input + .staged + .as_ref() + .and_then(|staged| staged.for_input.as_deref()) + .map(str::trim) + .filter(|provider| !provider.is_empty()) + else { + continue; + }; + let Some(provider_id) = input_name_to_id.get(provider_name).copied() else { + continue; + }; + if provider_id == block.id { + continue; + } + let Some(provider_block) = gen_blocks.iter().find(|b| b.id == provider_id) else { + continue; + }; + if !is_staged_provider_connection(block, provider_block) { + continue; + } + if existing_connections.insert((block.id, provider_id)) { + gen_connections.push(Connection { from: block.id, to: provider_id }); + } + } + } + if !gen_blocks.is_empty() { layout(&mut gen_blocks, &gen_connections); } { let mut editor_state = editor_state_ref.borrow_mut(); - editor_state.canvas_offset = (0.0, 0.0); - editor_state.zoom_factor = 1.0; + reset_layout_view(&mut editor_state); editor_state.pinch_distance = None; editor_state.pan_start = (0.0, 0.0); editor_state.drag.reset_dragging(); @@ -647,6 +797,203 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { ); } + { + let editor_state_ref = editor_state_ref.clone(); + let force_update = force_update.clone(); + let emit_sources_change = emit_sources_change.clone(); + use_key_down((can_write_sources, *force_update), move |event: &KeyboardEvent| { + if !can_write_sources || event.key() != "Delete" { + return; + } + // Ignore the key while the user is typing in a form field. + if is_text_input_focused(event) { + return; + } + + let mut editor_state = editor_state_ref.borrow_mut(); + let mut ids: Vec = editor_state.selection.selected_blocks.iter().copied().collect(); + if ids.is_empty() { + return; + } + // Delete highest ids first so re-indexing never invalidates a pending id. + ids.sort_unstable_by(|a, b| b.cmp(a)); + + let mut changed = false; + for id in ids { + let before_blocks = editor_state.blocks.len(); + editor_state.blocks.retain(|b| b.id != id); + editor_state.connections.retain(|c| c.from != id && c.to != id); + if before_blocks == editor_state.blocks.len() { + continue; + } + changed = true; + for block in editor_state.blocks.iter_mut() { + if block.id >= id { + block.id -= 1; + } + } + for conn in editor_state.connections.iter_mut() { + if conn.from >= id { + conn.from -= 1; + } + if conn.to >= id { + conn.to -= 1; + } + } + } + + let max_id = editor_state.blocks.iter().map(|b| b.id).max().unwrap_or(0); + editor_state.next_id = max_id + 1; + editor_state.selection.reset_selection(); + editor_state.block_elements.clear(); + editor_state.connection_elements.clear(); + drop(editor_state); + + if changed { + event.prevent_default(); + force_update.set(*force_update + 1); + emit_sources_change.emit(()); + } + }); + } + + // Duplicate selected blocks (Ctrl/Cmd+D) + { + let editor_state_ref = editor_state_ref.clone(); + let force_update = force_update.clone(); + let emit_sources_change = emit_sources_change.clone(); + use_key_down((can_write_sources, *force_update), move |event: &KeyboardEvent| { + if !can_write_sources || !event.key().eq_ignore_ascii_case("d") || !(event.ctrl_key() || event.meta_key()) { + return; + } + // Ignore the key while the user is typing in a form field. + if is_text_input_focused(event) { + return; + } + + let mut editor_state = editor_state_ref.borrow_mut(); + let mut selected: Vec = editor_state.selection.selected_blocks.iter().copied().collect(); + if selected.is_empty() { + return; + } + // Clone in id order so new ids are allocated deterministically. + selected.sort_unstable(); + let selected_set: HashSet = selected.iter().copied().collect(); + + // Track names per namespace, kept current as clone names are minted. + let mut input_names: HashSet = editor_state + .blocks + .iter() + .filter_map(|b| match &b.instance { + BlockInstance::Input(dto) => Some(dto.name.to_string()), + _ => None, + }) + .collect(); + let mut target_names: HashSet = editor_state + .blocks + .iter() + .filter_map(|b| match &b.instance { + BlockInstance::Target(dto) => Some(dto.name.to_string()), + _ => None, + }) + .collect(); + + let mut old_to_new: HashMap = HashMap::new(); + let mut input_rename: HashMap = HashMap::new(); + let mut new_blocks: Vec = Vec::with_capacity(selected.len()); + + for old_id in &selected { + let Some(source) = editor_state.blocks.iter().find(|b| b.id == *old_id) else { + continue; + }; + let block_type = source.block_type; + let position = (source.position.0 + CLONE_OFFSET, source.position.1 + CLONE_OFFSET); + let source_instance = source.instance.clone(); + let new_id = editor_state.next_id; + editor_state.next_id += 1; + old_to_new.insert(*old_id, new_id); + + let instance = match &source_instance { + BlockInstance::Input(dto) => { + let mut cloned = dto.as_ref().clone(); + let old_name = cloned.name.to_string(); + let new_name = unique_copy_name(&old_name, &input_names); + if !new_name.is_empty() { + input_names.insert(new_name.clone()); + if !old_name.trim().is_empty() { + input_rename.insert(old_name, new_name.clone()); + } + cloned.name = new_name.into(); + } + BlockInstance::Input(Rc::new(cloned)) + } + BlockInstance::Target(dto) => { + let mut cloned = dto.as_ref().clone(); + let new_name = unique_copy_name(&cloned.name, &target_names); + if !new_name.is_empty() { + target_names.insert(new_name.clone()); + cloned.name = new_name; + } + BlockInstance::Target(Rc::new(cloned)) + } + BlockInstance::Output(dto) => BlockInstance::Output(Rc::new(dto.as_ref().clone())), + }; + + new_blocks.push(Block { id: new_id, block_type, position, instance }); + } + + if new_blocks.is_empty() { + return; + } + + // When a staged input and its provider are cloned together, point the + // clone at the cloned provider instead of the original. + for block in new_blocks.iter_mut() { + let replacement = if let BlockInstance::Input(dto) = &block.instance { + dto.staged + .as_ref() + .and_then(|staged| staged.for_input.as_deref()) + .and_then(|provider| input_rename.get(provider)) + .map(|new_provider| { + let mut cloned = dto.as_ref().clone(); + if let Some(staged) = cloned.staged.as_mut() { + staged.for_input = Some(new_provider.clone().into()); + } + BlockInstance::Input(Rc::new(cloned)) + }) + } else { + None + }; + if let Some(instance) = replacement { + block.instance = instance; + } + } + + // Duplicate connections fully contained in the selection. + let cloned_connections: Vec = editor_state + .connections + .iter() + .filter(|c| selected_set.contains(&c.from) && selected_set.contains(&c.to)) + .filter_map(|c| match (old_to_new.get(&c.from), old_to_new.get(&c.to)) { + (Some(&from), Some(&to)) => Some(Connection { from, to }), + _ => None, + }) + .collect(); + + let new_ids: HashSet = new_blocks.iter().map(|b| b.id).collect(); + editor_state.blocks.extend(new_blocks); + editor_state.connections.extend(cloned_connections); + editor_state.selection.selected_blocks = new_ids; + editor_state.block_elements.clear(); + editor_state.connection_elements.clear(); + drop(editor_state); + + event.prevent_default(); + force_update.set(*force_update + 1); + emit_sources_change.emit(()); + }); + } + let collect_block_elements = { let editor_state_ref = editor_state_ref.clone(); @@ -697,8 +1044,7 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let mut editor_state = editor_state_ref.borrow_mut(); let connections = editor_state.connections.clone(); layout(&mut editor_state.blocks, &connections); - editor_state.canvas_offset = (0.0, 0.0); - editor_state.zoom_factor = 1.0; + reset_layout_view(&mut editor_state); editor_state.pinch_distance = None; editor_state.pan_start = (0.0, 0.0); @@ -781,7 +1127,7 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { }) }; - let handle_confirm_save = { + let perform_confirm_save = { let confirm = dialog.clone(); let on_save = handle_save.clone(); let translator = translate.clone(); @@ -798,6 +1144,28 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { }) }; + let handle_confirm_save = { + let perform_confirm_save = perform_confirm_save.clone(); + Callback::from(move |_s: String| { + perform_confirm_save.emit(()); + }) + }; + + // Save changes shortcut (Ctrl/Cmd+S) + { + let perform_confirm_save = perform_confirm_save.clone(); + let show_save_button = props.show_save_button; + use_key_down((can_write_sources, show_save_button), move |event: &KeyboardEvent| { + if !can_write_sources || !show_save_button { + return; + } + if event.key().eq_ignore_ascii_case("s") && (event.ctrl_key() || event.meta_key()) { + event.prevent_default(); + perform_confirm_save.emit(()); + } + }); + } + // ----------------- Drag Start from Sidebar ----------------- let handle_drag_start = { let editor_state_ref = editor_state_ref.clone(); @@ -2138,4 +2506,9 @@ mod tests { assert_eq!(input.input_type, expected_input_type); } } + + #[test] + fn initial_layout_view_matches_manual_layout_origin() { + assert_eq!(initial_layout_view_transform(), ((0.0, 0.0), 1.0)); + } } diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index b4191bb18..8d43cfdd9 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -1,10 +1,10 @@ use crate::{ app::{ components::{ - config::HasFormData, key_value_editor::KeyValueEditor, select::Select, AliasItemForm, BlockId, - BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, EpgSmartMatchForm, EpgSourceItemForm, - FilterInput, IconButton, Panel, ProviderItemForm, RadioButtonGroup, SourceEditorContext, TextButton, - TitledCard, ToolAction, + config::HasFormData, input::Input, key_value_editor::KeyValueEditor, AliasItemForm, BlockId, BlockInstance, + Card, ClusterFlagsInput, ClusterFlagsInputMode, EditMode, EpgSmartMatchForm, EpgSourceItemForm, + FilterInput, HideContent, IconButton, Panel, ProviderItemForm, RadioButtonGroup, SourceEditorContext, + TextButton, TitledCard, ToggleSwitch, ToolAction, }, ConfigContext, }, @@ -19,9 +19,10 @@ use shared::{ concat_string, error::TuliproxError, model::{ - ClusterSource, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigProviderDto, - EpgSmartMatchConfigDto, EpgSourceDto, InputFetchMethod, InputType, OnConnectErrorPolicy, - ProviderUrlSelectionPolicy, StagedInputDto, XtreamLoginRequest, + ClusterFlags, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, + ConfigProviderDto, EpgSmartMatchConfigDto, EpgSourceDto, InputFetchMethod, MediaServerInputConfigDto, + MediaServerLibrarySelector, OnConnectErrorPolicy, ProviderUrlSelectionPolicy, StagedInputType, + XtreamLoginRequest, }, utils::{Internable, BATCH_SCHEME_PREFIX}, }; @@ -39,7 +40,6 @@ use yew::{ }; const LABEL_NAME: &str = "LABEL.NAME"; -const LABEL_INPUT_TYPE: &str = "LABEL.INPUT_TYPE"; const LABEL_FETCH_METHOD: &str = "LABEL.METHOD"; const LABEL_HEADERS: &str = "LABEL.HEADERS"; const LABEL_URL: &str = "LABEL.URL"; @@ -84,12 +84,94 @@ const LABEL_METADATA: &str = "LABEL.METADATA"; const LABEL_CACHE_DURATION: &str = "LABEL.CACHE_DURATION"; const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; const LABEL_OPTIONS: &str = "LABEL.OPTIONS"; -const LABEL_STAGED: &str = "LABEL.STAGED"; -const LABEL_LIVE_SOURCE: &str = "LABEL.LIVE_SOURCE"; -const LABEL_VOD_SOURCE: &str = "LABEL.VOD_SOURCE"; -const LABEL_SERIES_SOURCE: &str = "LABEL.SERIES_SOURCE"; const LABEL_EPG: &str = "LABEL.EPG"; const LABEL_ALIAS: &str = "LABEL.ALIAS"; +const LABEL_TYPE: &str = "LABEL.TYPE"; +const LABEL_CLUSTER: &str = "LABEL.CLUSTER"; +const LABEL_MEDIA_SERVER: &str = "LABEL.MEDIA_SERVER"; +const LABEL_LIBRARIES: &str = "LABEL.LIBRARIES"; +const LABEL_TOKEN: &str = "LABEL.TOKEN"; +const LABEL_API_KEY: &str = "LABEL.API_KEY"; +const LABEL_USER_ID: &str = "LABEL.USER_ID"; +const LABEL_ACCOUNT_TOKEN: &str = "LABEL.ACCOUNT_TOKEN"; +const LABEL_SERVER_ID: &str = "LABEL.SERVER_ID"; +const LABEL_SERVER_NAME: &str = "LABEL.SERVER_NAME"; +const LABEL_PREFER_HTTPS: &str = "LABEL.PREFER_HTTPS"; +const LABEL_ALLOW_RELAY: &str = "LABEL.ALLOW_RELAY"; + +fn input_persist_hint_key(staged_input: bool) -> &'static str { + if staged_input { + "INPUT_FORM.STAGED_PERSIST" + } else { + "INPUT_FORM.PERSIST" + } +} + +fn input_url_hint_key(simple_input: bool) -> &'static str { + if simple_input { + "INPUT_FORM.SIMPLE_INPUT.URL" + } else { + "INPUT_FORM.URL" + } +} + +fn staged_type_options() -> Rc> { + Rc::new(vec![StagedInputType::M3u.to_string(), StagedInputType::Xtream.to_string()]) +} + +fn staged_type_from_selection(selections: &[String]) -> StagedInputType { + selections.first().and_then(|value| value.parse::().ok()).unwrap_or_default() +} + +fn libraries_to_text(libraries: &[MediaServerLibrarySelector]) -> String { + libraries + .iter() + .map(|library| match library { + MediaServerLibrarySelector::Name(name) => name.as_str(), + MediaServerLibrarySelector::Detailed(details) => details.name.as_deref().unwrap_or_default(), + }) + .filter(|name| !name.trim().is_empty()) + .collect::>() + .join(", ") +} + +fn libraries_from_text(value: &str, previous: &[MediaServerLibrarySelector]) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + previous + .iter() + .find(|library| match library { + MediaServerLibrarySelector::Name(existing) => existing.trim() == name, + MediaServerLibrarySelector::Detailed(details) => { + details.name.as_deref().is_some_and(|n| n.trim() == name) + } + }) + .cloned() + .unwrap_or_else(|| MediaServerLibrarySelector::Name(name.to_string())) + }) + .collect() +} + +fn mutate_media_server( + current: &Option, + change: impl FnOnce(&mut MediaServerInputConfigDto), +) -> Option { + let mut media_server = current.clone().unwrap_or_default(); + change(&mut media_server); + Some(media_server) +} + +fn mutate_staged( + current: &Option, + change: impl FnOnce(&mut ConfigInputStagedDto), +) -> Option { + let mut staged = current.clone().unwrap_or_default(); + change(&mut staged); + Some(staged) +} fn provider_url_selection_policy_label_key(policy: ProviderUrlSelectionPolicy) -> &'static str { match policy { @@ -121,11 +203,11 @@ const LABEL_LIVE_STREAMS: &str = "LABEL.LIVE_STREAMS"; const LABEL_EPG_SMART_MATCH: &str = "LABEL.EPG_SMART_MATCH"; const LABEL_EDIT_EPG_SMART_MATCH: &str = "LABEL.EDIT_EPG_SMART_MATCH"; -#[derive(Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] enum InputFormPage { Main, Options, - Staged, + Libraries, Epg, Alias, Provider, @@ -134,7 +216,7 @@ enum InputFormPage { impl InputFormPage { const MAIN: &str = "Main"; const OPTIONS: &str = "Options"; - const STAGED: &str = "Staged"; + const LIBRARIES: &str = "Libraries"; const EPG: &str = "Epg"; const ALIAS: &str = "Alias"; const PROVIDER: &str = "Provider"; @@ -147,7 +229,7 @@ impl FromStr for InputFormPage { match s { Self::MAIN => Ok(InputFormPage::Main), Self::OPTIONS => Ok(InputFormPage::Options), - Self::STAGED => Ok(InputFormPage::Staged), + Self::LIBRARIES => Ok(InputFormPage::Libraries), Self::EPG => Ok(InputFormPage::Epg), Self::ALIAS => Ok(InputFormPage::Alias), Self::PROVIDER => Ok(InputFormPage::Provider), @@ -164,7 +246,7 @@ impl Display for InputFormPage { match *self { InputFormPage::Main => Self::MAIN, InputFormPage::Options => Self::OPTIONS, - InputFormPage::Staged => Self::STAGED, + InputFormPage::Libraries => Self::LIBRARIES, InputFormPage::Epg => Self::EPG, InputFormPage::Alias => Self::ALIAS, InputFormPage::Provider => Self::PROVIDER, @@ -178,7 +260,7 @@ impl Internable for InputFormPage { match self { Self::Main => Self::MAIN, Self::Options => Self::OPTIONS, - Self::Staged => Self::STAGED, + Self::Libraries => Self::LIBRARIES, Self::Epg => Self::EPG, Self::Alias => Self::ALIAS, Self::Provider => Self::PROVIDER, @@ -211,23 +293,6 @@ generate_form_reducer!( } ); -generate_form_reducer!( - state: StagedInputDtoFormState { form: StagedInputDto }, - action_name: StagedInputFormAction, - fields { - Enabled => enabled: bool, - Url => url: String, - Username => username: Option, - Password => password: Option, - Method => method: InputFetchMethod, - InputType => input_type: InputType, - LiveSource => live_source: Option, - VodSource => vod_source: Option, - SeriesSource => series_source: Option, - // Headers => headers: HashMap, - } -); - generate_form_reducer!( state: ConfigInputFormState { form: ConfigInputDto }, action_name: ConfigInputFormAction, @@ -241,41 +306,13 @@ generate_form_reducer!( Priority => priority: i16, MaxConnections => max_connections: u16, Method => method: InputFetchMethod, + StagedType => staged_type: StagedInputType, + Staged => staged: Option, + MediaServer => media_server: Option, ExpDate => exp_date: Option, CacheDuration => cache_duration: Option, } ); -fn cluster_source_options(current: Option) -> Rc> { - let options: [(Option, &str); 3] = [ - (Some(ClusterSource::Staged), "staged"), - (Some(ClusterSource::Input), "input"), - (Some(ClusterSource::Skip), "skip"), - ]; - Rc::new( - options - .iter() - .map(|(val, label)| DropDownOption { - id: label.to_string(), - label: html! { label.to_string() }, - selected: *val == current, - }) - .collect(), - ) -} - -fn apply_parsed_input_type(staged_input_state: &UseReducerHandle, selected: Option<&str>) { - let input_type = - selected.and_then(|value| value.parse::().ok()).unwrap_or(staged_input_state.form.input_type); - if staged_input_state.form.input_type != input_type { - staged_input_state.dispatch(StagedInputFormAction::InputType(input_type)); - // Clear Xtream-specific credentials when switching to a non-Xtream type - // so they don't persist invisibly in the DTO. - if !input_type.is_xtream() { - staged_input_state.dispatch(StagedInputFormAction::Username(None)); - staged_input_state.dispatch(StagedInputFormAction::Password(None)); - } - } -} #[derive(Properties, PartialEq, Clone)] pub struct ConfigInputViewProps { @@ -319,8 +356,6 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { use_reducer(|| ConfigInputFormState { form: ConfigInputDto::default(), modified: false }); let input_options_state: UseReducerHandle = use_reducer(|| ConfigInputOptionsDtoFormState { form: ConfigInputOptionsDto::default(), modified: false }); - let staged_input_state: UseReducerHandle = - use_reducer(|| StagedInputDtoFormState { form: StagedInputDto::default(), modified: false }); // State for EPG sources, Aliases, Headers, and Providers let epg_sources_state = use_state(Vec::::new); @@ -343,23 +378,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let exp_date_request_in_flight = use_mut_ref(|| false); let exp_date_request_token = use_mut_ref(|| 0_u64); - let staged_input_types = use_memo(staged_input_state.form.input_type, |input_type| { - let default_it = input_type; - [ - InputType::M3u, - InputType::Xtream, - // InputType::M3uBatch, - // InputType::XtreamBatch, - ] - .iter() - .map(|t| DropDownOption { id: t.to_string(), label: html! { t.to_string() }, selected: t == default_it }) - .collect::>() - }); - { let input_form_state = input_form_state.clone(); let input_options_state = input_options_state.clone(); - let staged_input_state = staged_input_state.clone(); let epg_sources_state = epg_sources_state.clone(); let epg_smart_match_state = epg_smart_match_state.clone(); let aliases_state = aliases_state.clone(); @@ -375,8 +396,19 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { .and_then(|cfg| cfg.sources.provider.clone()) .unwrap_or_default(); if let Some(input) = cfg { - if input.input_type.is_library() && matches!(*view_visible, InputFormPage::Staged | InputFormPage::Epg) - { + let is_library = input.input_type.is_library(); + let is_media_server = input.input_type.is_media_server(); + let is_staged = input.input_type.is_staged(); + let current_page = *view_visible; + let page_allowed = match current_page { + InputFormPage::Main => true, + InputFormPage::Libraries => is_media_server, + InputFormPage::Alias | InputFormPage::Options | InputFormPage::Provider => { + !is_library && !is_staged && !is_media_server + } + InputFormPage::Epg => !is_library && !is_staged && !is_media_server, + }; + if !page_allowed { view_visible.set(InputFormPage::Main); } @@ -386,10 +418,6 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { input.options.as_ref().map_or_else(ConfigInputOptionsDto::default, |d| d.clone()), )); - staged_input_state.dispatch(StagedInputFormAction::SetAll( - input.staged.as_ref().map_or_else(StagedInputDto::default, |c| c.clone()), - )); - // Load headers headers_state.set(input.headers.clone()); @@ -425,7 +453,6 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } else { input_form_state.dispatch(ConfigInputFormAction::SetAll(ConfigInputDto::default())); input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll(ConfigInputOptionsDto::default())); - staged_input_state.dispatch(StagedInputFormAction::SetAll(StagedInputDto::default())); headers_state.set(HashMap::new()); epg_sources_state.set(Vec::new()); epg_smart_match_state.set(None); @@ -753,14 +780,19 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }; let library_input = input_form_state.form.input_type.is_library(); + let media_server_input = input_form_state.form.input_type.is_media_server(); let xtream_input = input_form_state.form.input_type.is_xtream(); + let staged_input = input_form_state.form.input_type.is_staged(); + let staged_xtream_input = staged_input && input_form_state.form.staged_type == StagedInputType::Xtream; + let credentials_input = xtream_input || staged_xtream_input || media_server_input; + let options_input = !library_input && !media_server_input; let render_options = || { let headers = headers_state.clone(); if !props.allow_write { return html! { - { html_if!(xtream_input, { + { html_if!(xtream_input || staged_xtream_input, { <>
@@ -822,7 +854,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } html! { - { html_if!(xtream_input, { + { html_if!(xtream_input || staged_xtream_input, { <>
@@ -897,160 +929,15 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } }; - let render_staged = || { - let staged_method_selection = Rc::new(vec![staged_input_state.form.method.to_string()]); - let staged_input_state_1 = staged_input_state.clone(); - let staged_input_state_2 = staged_input_state.clone(); - let staged_input_state_live = staged_input_state.clone(); - let staged_input_state_vod = staged_input_state.clone(); - let staged_input_state_series = staged_input_state.clone(); - let show_cluster_sources = input_form_state.form.input_type.is_xtream(); - let live_source_options = cluster_source_options(staged_input_state.form.live_source); - let vod_source_options = cluster_source_options(staged_input_state.form.vod_source); - let series_source_options = cluster_source_options(staged_input_state.form.series_source); - let staged_is_xtream = staged_input_state.form.input_type.is_xtream(); - if !props.allow_write { - return html! { - - { config_field_bool!(staged_input_state.form, translate.t(LABEL_ENABLED), enabled) } - { config_field!(staged_input_state.form, translate.t(LABEL_URL), url) } - { html_if!(staged_is_xtream, { -
- { config_field_optional!(staged_input_state.form, translate.t(LABEL_USERNAME), username) } - { config_field_optional_hide!(staged_input_state.form, translate.t(LABEL_PASSWORD), password) } -
- })} -
- { config_field_custom!(translate.t(LABEL_FETCH_METHOD), staged_input_state.form.method.to_string()) } - { config_field_custom!(translate.t(LABEL_INPUT_TYPE), staged_input_state.form.input_type.to_string()) } -
- { - html_if!(show_cluster_sources, { -
- { config_field_custom!(translate.t(LABEL_LIVE_SOURCE), staged_input_state.form.live_source.map_or_else(String::new, |source| source.to_string())) } - { config_field_custom!(translate.t(LABEL_VOD_SOURCE), staged_input_state.form.vod_source.map_or_else(String::new, |source| source.to_string())) } - { config_field_custom!(translate.t(LABEL_SERIES_SOURCE), staged_input_state.form.series_source.map_or_else(String::new, |source| source.to_string())) } -
- }) - } -
- }; - } - html! { - - { edit_field_bool!(staged_input_state, translate.t(LABEL_ENABLED), enabled, StagedInputFormAction::Enabled) } - { edit_field_text!(staged_input_state, translate.t(LABEL_URL), url, StagedInputFormAction::Url) } - { html_if!(staged_is_xtream, { -
- { edit_field_text_option!(staged_input_state, translate.t(LABEL_USERNAME), username, StagedInputFormAction::Username) } - { edit_field_text_option!(staged_input_state, translate.t(LABEL_PASSWORD), password, StagedInputFormAction::Password, true) } -
- })} -
- { config_field_child!(translate.t(LABEL_FETCH_METHOD), "INPUT_FORM.FETCH_METHOD", { - - html! { - >| { - if let Some(first) = selections.first() { - staged_input_state_1.dispatch(StagedInputFormAction::Method(first.parse::().unwrap_or(InputFetchMethod::GET))); - } - })} - options={&fetch_methods} - selected={staged_method_selection} - /> - }})} - { config_field_child!(translate.t(LABEL_INPUT_TYPE), "INPUT_FORM.INPUT_TYPE", { - html! { - - } - })} - { config_field_child!(translate.t(LABEL_VOD_SOURCE), "INPUT_FORM.VOD_SOURCE", { - html! { - - } - })} -
- }) - } - - //{ edit_field_list!(staged_input_state, translate.t(LABEL_HEADERS), headers, StagedInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) } -
- } - }; - let render_input = || { let providers_state = providers_state.clone(); let input_method_selection = Rc::new(vec![input_form_state.form.method.to_string()]); + let staged_type_selection = Rc::new(vec![input_form_state.form.staged_type.to_string()]); + let staged_type_options = staged_type_options(); + let staged_type_labels = Rc::new(vec![translate.t("LABEL.M3U"), translate.t("LABEL.XTREAM")]); + let staged_clusters = input_form_state.form.staged.as_ref().map(|staged| staged.clusters); let is_csv_batch = input_form_state.form.url.starts_with(BATCH_SCHEME_PREFIX); + let simple_input = staged_input || media_server_input; let input_form_state_disp = input_form_state.clone(); let exp_date_tool_action = if input_form_state.form.input_type.is_xtream() { let services = services.clone(); @@ -1142,27 +1029,35 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { { config_field!(input_form_state.form, translate.t(LABEL_NAME), name) } { config_field_bool!(input_form_state.form, translate.t(LABEL_ENABLED), enabled) }
+ { html_if!(staged_input, { + <> + { config_field_custom!(translate.t(LABEL_TYPE), input_form_state.form.staged_type.to_string()) } + { config_field_custom!(translate.t(LABEL_CLUSTER), staged_clusters.map_or_else(String::new, |clusters| clusters.to_string())) } + + })} { html_if!(!library_input, { <> - { config_field!(input_form_state.form, translate.t(LABEL_URL), url) } + { config_field!(input_form_state.form, translate.t(LABEL_URL), url, Some(input_url_hint_key(simple_input).to_string())) }
- { html_if!(xtream_input && !is_csv_batch, { + { html_if!(credentials_input && !is_csv_batch, { <> { config_field_optional!(input_form_state.form, translate.t(LABEL_USERNAME), username) } { config_field_optional_hide!(input_form_state.form, translate.t(LABEL_PASSWORD), password) } })} - { html_if!(!is_csv_batch, { + { html_if!(!staged_input && !is_csv_batch, { <> { config_field_custom!(translate.t(LABEL_MAX_CONNECTIONS), input_form_state.form.max_connections.to_string()) } { config_field_custom!(translate.t(LABEL_PRIORITY), input_form_state.form.priority.to_string()) } { config_field_custom!(translate.t(LABEL_EXP_DATE), input_form_state.form.exp_date.map_or_else(String::new, |exp_date| exp_date.to_string())) } })} - { config_field_optional!(input_form_state.form, translate.t(LABEL_CACHE_DURATION), cache_duration) } + { html_if!(!staged_input, { + { config_field_optional!(input_form_state.form, translate.t(LABEL_CACHE_DURATION), cache_duration) } + })} { config_field_custom!(translate.t(LABEL_FETCH_METHOD), input_form_state.form.method.to_string()) }
- { config_field_optional!(input_form_state.form, translate.t(LABEL_PERSIST), persist) } + { config_field_optional!(input_form_state.form, translate.t(LABEL_PERSIST), persist, Some(input_persist_hint_key(staged_input).to_string())) } })}
@@ -1174,24 +1069,74 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { { edit_field_text!(input_form_state, translate.t(LABEL_NAME), name, ConfigInputFormAction::Name) } { edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) }
+ { html_if!(staged_input, { + <> + { config_field_child!(translate.t(LABEL_TYPE), "INPUT_FORM.STAGED_TYPE", { + let input_form_state = input_form_state.clone(); + html! { + >| { + input_form_state.dispatch(ConfigInputFormAction::StagedType(staged_type_from_selection(&selections))); + })} + /> + }})} + { config_field_child!(translate.t(LABEL_CLUSTER), "INPUT_FORM.STAGED_CLUSTERS", { + let input_form_state = input_form_state.clone(); + html! { + )| { + // Staged inputs must keep at least one cluster; mirror ConfigInputStagedDto::default() when the user clears the last chip. + let clusters = flags.filter(|f| !f.is_empty()).unwrap_or_else(ClusterFlags::all); + let next = mutate_staged(&input_form_state.form.staged, |staged| { + staged.clusters = clusters; + }); + input_form_state.dispatch(ConfigInputFormAction::Staged(next)); + })} + /> + }})} + + })} { html_if!(!library_input, { <> - { edit_field_text!(input_form_state, translate.t(LABEL_URL), url, ConfigInputFormAction::Url) } +
+ +
- { html_if!(xtream_input && !is_csv_batch, { + { html_if!(credentials_input && !is_csv_batch, { <> { edit_field_text_option!(input_form_state, translate.t(LABEL_USERNAME), username, ConfigInputFormAction::Username) } { edit_field_text_option!(input_form_state, translate.t(LABEL_PASSWORD), password, ConfigInputFormAction::Password, true) } })} - { html_if!(!is_csv_batch, { + { html_if!(!staged_input && !is_csv_batch, { <> { edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) } { edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) } { edit_field_exp_date!(input_form_state, translate.t(LABEL_EXP_DATE), exp_date, ConfigInputFormAction::ExpDate, exp_date_tool_action) } })} - { edit_field_text_option!(input_form_state, translate.t(LABEL_CACHE_DURATION), cache_duration, ConfigInputFormAction::CacheDuration) } + { html_if!(!staged_input, { + { edit_field_text_option!(input_form_state, translate.t(LABEL_CACHE_DURATION), cache_duration, ConfigInputFormAction::CacheDuration) } + })} { config_field_child!(translate.t(LABEL_FETCH_METHOD), "INPUT_FORM.FETCH_METHOD", { html! { Html { /> }})}
- { edit_field_text_option!(input_form_state, translate.t(LABEL_PERSIST), persist, ConfigInputFormAction::Persist) } +
+ +
})}
} }; + let render_media_server = || { + let media_server = input_form_state.form.media_server.clone().unwrap_or_default(); + let token = media_server.token.clone().unwrap_or_default(); + let api_key = media_server.api_key.clone().unwrap_or_default(); + let user_id = media_server.user_id.clone().unwrap_or_default(); + let account_token = media_server.account_token.clone().unwrap_or_default(); + let server_id = media_server.server_id.clone().unwrap_or_default(); + let server_name = media_server.server_name.clone().unwrap_or_default(); + + if !props.allow_write { + return html! { + + + { config_field_child!(translate.t(LABEL_TOKEN), "MEDIA_SERVER.TOKEN", { + html! { } + })} + { config_field_child!(translate.t(LABEL_API_KEY), "MEDIA_SERVER.API_KEY", { + html! { } + })} + { config_field_child!(translate.t(LABEL_USER_ID), "MEDIA_SERVER.USER_ID", { + html! { {user_id} } + })} + { config_field_child!(translate.t(LABEL_ACCOUNT_TOKEN), "MEDIA_SERVER.ACCOUNT_TOKEN", { + html! { } + })} + { config_field_child!(translate.t(LABEL_SERVER_ID), "MEDIA_SERVER.SERVER_ID", { + html! { {server_id} } + })} + { config_field_child!(translate.t(LABEL_SERVER_NAME), "MEDIA_SERVER.SERVER_NAME", { + html! { {server_name} } + })} +
+ { config_field_child!(translate.t(LABEL_PREFER_HTTPS), "MEDIA_SERVER.PREFER_HTTPS", { + html! { } + })} + { config_field_child!(translate.t(LABEL_ALLOW_RELAY), "MEDIA_SERVER.ALLOW_RELAY", { + html! { } + })} +
+
+
+ }; + } + + let dispatch_media = |change: fn(&mut MediaServerInputConfigDto, String)| { + let state = input_form_state.clone(); + Callback::from(move |value: String| { + let next = mutate_media_server(&state.form.media_server, |media_server| change(media_server, value)); + state.dispatch(ConfigInputFormAction::MediaServer(next)); + }) + }; + let dispatch_media_bool = |change: fn(&mut MediaServerInputConfigDto, bool)| { + let state = input_form_state.clone(); + Callback::from(move |value: bool| { + let next = mutate_media_server(&state.form.media_server, |media_server| change(media_server, value)); + state.dispatch(ConfigInputFormAction::MediaServer(next)); + }) + }; + + let on_token = dispatch_media(|media_server, value| { + media_server.token = (!value.is_empty()).then_some(value); + }); + let on_api_key = dispatch_media(|media_server, value| { + media_server.api_key = (!value.is_empty()).then_some(value); + }); + let on_user_id = dispatch_media(|media_server, value| { + media_server.user_id = (!value.is_empty()).then_some(value); + }); + let on_account_token = dispatch_media(|media_server, value| { + media_server.account_token = (!value.is_empty()).then_some(value); + }); + let on_server_id = dispatch_media(|media_server, value| { + media_server.server_id = (!value.is_empty()).then_some(value); + }); + let on_server_name = dispatch_media(|media_server, value| { + media_server.server_name = (!value.is_empty()).then_some(value); + }); + let on_prefer_https = dispatch_media_bool(|media_server, value| media_server.prefer_https = value); + let on_allow_relay = dispatch_media_bool(|media_server, value| media_server.allow_relay = value); + + html! { + + +
+ + + + + + +
+
+ { config_field_child!(translate.t(LABEL_PREFER_HTTPS), "MEDIA_SERVER.PREFER_HTTPS", { + html! { } + })} + { config_field_child!(translate.t(LABEL_ALLOW_RELAY), "MEDIA_SERVER.ALLOW_RELAY", { + html! { } + })} +
+
+
+ } + }; + + let render_media_server_libraries = || { + let media_server = input_form_state.form.media_server.clone().unwrap_or_default(); + let libraries = libraries_to_text(&media_server.libraries); + + if !props.allow_write { + return html! { + + { config_field_child!(translate.t(LABEL_LIBRARIES), "MEDIA_SERVER.LIBRARIES", { + html! { {libraries} } + })} + + }; + } + + let input_form_state = input_form_state.clone(); + let on_libraries = Callback::from(move |value: String| { + let next = mutate_media_server(&input_form_state.form.media_server, |media_server| { + media_server.libraries = libraries_from_text(&value, &media_server.libraries); + }); + input_form_state.dispatch(ConfigInputFormAction::MediaServer(next)); + }); + + html! { + + + + } + }; + let render_alias = || { let aliases = aliases_state.clone(); let show_alias_form = show_alias_form_state.clone(); @@ -1556,7 +1649,6 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let source_editor_ctx = source_editor_ctx.clone(); let input_form_state = input_form_state.clone(); let input_options_state = input_options_state.clone(); - let staged_input_state = staged_input_state.clone(); let headers_state = headers_state.clone(); let epg_sources_state = epg_sources_state.clone(); let epg_smart_match_state = epg_smart_match_state.clone(); @@ -1570,8 +1662,16 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let options = input_options_state.data(); input.options = if options.is_empty() { None } else { Some(options.clone()) }; - let staged_input = staged_input_state.data(); - input.staged = if staged_input.is_empty() { None } else { Some(staged_input.clone()) }; + if input.input_type.is_staged() { + input.staged.get_or_insert_with(ConfigInputStagedDto::default); + } else { + input.staged = None; + } + if input.input_type.is_media_server() { + input.media_server.get_or_insert_with(MediaServerInputConfigDto::default); + } else { + input.media_server = None; + } // Handle Headers input.headers = (*headers_state).clone(); @@ -1627,27 +1727,34 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
- {render_input()} + {render_input()} - { html_if!(!library_input, { + { html_if!(media_server_input, { + + {render_media_server()} + {render_media_server_libraries()} + + })} + { html_if!(!library_input && !staged_input && !media_server_input, { {render_alias()} })} - { html_if!(!library_input, { + { html_if!(options_input, { <> {render_options()} + + })} + { html_if!(!library_input && !staged_input && !media_server_input, { + <> {render_provider()} {render_epg()} - - {render_staged()} - })}
@@ -1662,15 +1769,21 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { html! {
- {html_if!(!library_input, { + {html_if!(media_server_input, { + + })} + {html_if!(!library_input && !staged_input && !media_server_input, { })} - { html_if!(!library_input, { + { html_if!(options_input, { <> + + })} + { html_if!(!library_input && !staged_input && !media_server_input, { + <> - })}
@@ -1698,3 +1811,42 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } } + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{MediaServerLibraryKind, MediaServerLibrarySelectorDetailsDto}; + + #[test] + fn media_server_libraries_page_round_trips() { + assert_eq!(InputFormPage::from_str(InputFormPage::LIBRARIES).ok(), Some(InputFormPage::Libraries)); + assert_eq!(InputFormPage::Libraries.to_string(), InputFormPage::LIBRARIES); + } + + #[test] + fn simple_inputs_use_simple_url_hint() { + assert_eq!(input_url_hint_key(true), "INPUT_FORM.SIMPLE_INPUT.URL"); + assert_eq!(input_url_hint_key(false), "INPUT_FORM.URL"); + } + + #[test] + fn staged_inputs_use_staged_persist_hint() { + assert_eq!(input_persist_hint_key(true), "INPUT_FORM.STAGED_PERSIST"); + assert_eq!(input_persist_hint_key(false), "INPUT_FORM.PERSIST"); + } + + #[test] + fn libraries_from_text_preserves_existing_detailed_selector() { + let detailed = MediaServerLibrarySelector::Detailed(MediaServerLibrarySelectorDetailsDto { + id: Some("id-1".to_string()), + key: Some("key-1".to_string()), + name: Some("Movies".to_string()), + kind: Some(MediaServerLibraryKind::Movies), + }); + let previous = vec![detailed.clone()]; + + let libraries = libraries_from_text("Movies, Kids", &previous); + + assert_eq!(libraries, vec![detailed, MediaServerLibrarySelector::Name("Kids".to_string())]); + } +} diff --git a/frontend/src/app/components/source_editor/layout.rs b/frontend/src/app/components/source_editor/layout.rs index d89a92c98..820e65ee3 100644 --- a/frontend/src/app/components/source_editor/layout.rs +++ b/frontend/src/app/components/source_editor/layout.rs @@ -1,5 +1,5 @@ use crate::app::components::{Block, BlockId, BlockInstance, Connection}; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; const CANVAS_OFFSET: f32 = 10.0; @@ -57,6 +57,8 @@ type EdgeMap = HashMap>; struct InputTargetMaps { input_to_targets: EdgeMap, + /// Staged input -> provider input edges. + input_to_child_inputs: EdgeMap, adjacency: EdgeMap, } @@ -83,28 +85,61 @@ fn push_unique(map: &mut EdgeMap, key: BlockId, value: BlockId) { fn build_input_target_maps(blocks: &[Block], connections: &[Connection]) -> InputTargetMaps { let mut input_to_targets: EdgeMap = HashMap::new(); + let mut input_to_child_inputs: EdgeMap = HashMap::new(); let mut adjacency: EdgeMap = HashMap::new(); for con in connections { let from = &blocks[con.from as usize - 1]; let to = &blocks[con.to as usize - 1]; - let edge = if from.block_type.is_input() && to.block_type.is_target() { - Some((con.from, con.to)) + if from.block_type.is_input() && to.block_type.is_target() { + push_unique(&mut input_to_targets, con.from, con.to); + push_unique(&mut adjacency, con.from, con.to); + push_unique(&mut adjacency, con.to, con.from); } else if from.block_type.is_target() && to.block_type.is_input() { - Some((con.to, con.from)) - } else { - None - }; - - if let Some((input_id, target_id)) = edge { - push_unique(&mut input_to_targets, input_id, target_id); - push_unique(&mut adjacency, input_id, target_id); - push_unique(&mut adjacency, target_id, input_id); + push_unique(&mut input_to_targets, con.to, con.from); + push_unique(&mut adjacency, con.from, con.to); + push_unique(&mut adjacency, con.to, con.from); + } else if from.block_type.is_input() && to.block_type.is_input() { + // Staged input chain: parent input feeds a child (staged) input. + push_unique(&mut input_to_child_inputs, con.from, con.to); + push_unique(&mut adjacency, con.from, con.to); + push_unique(&mut adjacency, con.to, con.from); } } - InputTargetMaps { input_to_targets, adjacency } + InputTargetMaps { input_to_targets, input_to_child_inputs, adjacency } +} + +fn compute_input_columns(input_ids: &[BlockId], input_to_child_inputs: &EdgeMap) -> HashMap { + let input_set: HashSet = input_ids.iter().copied().collect(); + let mut columns: HashMap = input_ids.iter().map(|&id| (id, 0usize)).collect(); + + let max_iterations = input_ids.len() + 1; + for _ in 0..max_iterations { + let mut changed = false; + for (&parent, children) in input_to_child_inputs { + if !input_set.contains(&parent) { + continue; + } + let parent_column = columns.get(&parent).copied().unwrap_or(0); + for &child in children { + if !input_set.contains(&child) { + continue; + } + let candidate = parent_column + 1; + if candidate > columns.get(&child).copied().unwrap_or(0) { + columns.insert(child, candidate); + changed = true; + } + } + } + if !changed { + break; + } + } + + columns } fn build_connected_components( @@ -196,11 +231,14 @@ fn component_sort_key( (min_input_rank.min(min_target_rank), min_target_rank, min_input_rank) } +#[allow(clippy::too_many_arguments)] fn layout_component( component: &LayoutComponent, input_rank: &HashMap, target_rank: &HashMap, input_to_targets: &HashMap>, + input_to_child_inputs: &HashMap>, + input_columns: &HashMap, target_heights: &HashMap, input_steps: &HashMap, input_visual_heights: &HashMap, @@ -219,14 +257,55 @@ fn layout_component( current_top += height + Y_GAP; } - let mut input_y: HashMap = HashMap::new(); - let mut next_input_y = 0.0; + // Desired y per input. Inputs wired to targets align to those targets; staged + // inputs without a direct target align to their provider inputs. Process deepest + // columns first so a parent can read its already-resolved children's y. + let mut by_column_desc = input_ids.clone(); + by_column_desc.sort_by(|a, b| { + let column_a = input_columns.get(a).copied().unwrap_or(0); + let column_b = input_columns.get(b).copied().unwrap_or(0); + column_b.cmp(&column_a) + }); + let mut input_desired: HashMap = HashMap::new(); + for input_id in &by_column_desc { + let desired = average_connected_targets(*input_id, input_to_targets, &target_centers) + .or_else(|| { + let children = input_to_child_inputs.get(input_id)?; + let mut sum = 0.0; + let mut count = 0; + for child in children { + if let Some(&child_y) = input_desired.get(child) { + sum += child_y; + count += 1; + } + } + if count == 0 { + None + } else { + Some(sum / count as f32) + } + }) + .unwrap_or(0.0); + input_desired.insert(*input_id, desired); + } + + // Stack inputs per column so siblings in the same column never overlap while + // still honoring their desired y. + let mut columns: BTreeMap> = BTreeMap::new(); for input_id in &input_ids { - let desired = average_connected_targets(*input_id, input_to_targets, &target_centers).unwrap_or(next_input_y); - let aligned = desired.max(next_input_y); - input_y.insert(*input_id, aligned); - let step = input_steps.get(input_id).copied().unwrap_or(INPUT_VERTICAL_STEP_BASE); - next_input_y = aligned + step; + columns.entry(input_columns.get(input_id).copied().unwrap_or(0)).or_default().push(*input_id); + } + let mut input_y: HashMap = HashMap::new(); + for column_ids in columns.values_mut() { + column_ids.sort_by_key(|id| input_rank.get(id).copied().unwrap_or(usize::MAX)); + let mut next_input_y = 0.0; + for input_id in column_ids.iter() { + let desired = input_desired.get(input_id).copied().unwrap_or(next_input_y); + let aligned = desired.max(next_input_y); + input_y.insert(*input_id, aligned); + let step = input_steps.get(input_id).copied().unwrap_or(INPUT_VERTICAL_STEP_BASE); + next_input_y = aligned + step; + } } let mut target_tops: HashMap = HashMap::new(); @@ -414,10 +493,13 @@ pub fn layout(blocks: &mut [Block], connections: &[Connection]) { input_ids.iter().map(|&id| (id, BLOCK_HEIGHT + input_extra_heights.get(&id).copied().unwrap_or(0.0))).collect(); let maps = build_input_target_maps(blocks, connections); + let input_columns = compute_input_columns(&input_ids, &maps.input_to_child_inputs); + let max_input_column = input_columns.values().copied().max().unwrap_or(0); + let column_stride = BLOCK_WIDTH + X_GAP; let mut components = build_connected_components(&input_ids, &target_ids, &maps.adjacency); components.sort_by_key(|component| component_sort_key(component, &input_rank, &target_rank)); - let start_x = CANVAS_OFFSET + BLOCK_WIDTH + X_GAP; + let start_x = CANVAS_OFFSET + (max_input_column as f32 + 1.0) * column_stride; let mut start_y = CANVAS_OFFSET; for component in components { @@ -426,6 +508,8 @@ pub fn layout(blocks: &mut [Block], connections: &[Connection]) { &input_rank, &target_rank, &maps.input_to_targets, + &maps.input_to_child_inputs, + &input_columns, &target_heights, &input_steps, &input_visual_heights, @@ -445,7 +529,9 @@ pub fn layout(blocks: &mut [Block], connections: &[Connection]) { sorted_inputs.sort_by_key(|id| input_rank.get(id).copied().unwrap_or(usize::MAX)); for input_id in sorted_inputs { if let Some(local_y) = component_input_y.get(&input_id) { - blocks[input_id as usize - 1].position = (CANVAS_OFFSET, start_y + local_y); + let column = input_columns.get(&input_id).copied().unwrap_or(0); + let x = CANVAS_OFFSET + column as f32 * column_stride; + blocks[input_id as usize - 1].position = (x, start_y + local_y); } } diff --git a/frontend/src/app/components/source_editor/output_form.rs b/frontend/src/app/components/source_editor/output_form.rs index 89f64de0d..603cf619d 100644 --- a/frontend/src/app/components/source_editor/output_form.rs +++ b/frontend/src/app/components/source_editor/output_form.rs @@ -28,6 +28,7 @@ pub fn ConfigOutputView(props: &ConfigOutputViewProps) -> Html { | BlockType::InputEmby | BlockType::InputJellyfin | BlockType::InputPlex + | BlockType::InputStaged | BlockType::Target => html! {}, BlockType::OutputM3u => { let output = props.output.as_ref().and_then(|to| { diff --git a/frontend/src/app/components/source_editor/rules.rs b/frontend/src/app/components/source_editor/rules.rs index c42c593c0..2f9d79c70 100644 --- a/frontend/src/app/components/source_editor/rules.rs +++ b/frontend/src/app/components/source_editor/rules.rs @@ -1,7 +1,7 @@ use crate::app::components::{Block, BlockType, Connection}; /// Determines whether two blocks can be connected based on explicit editor rules. -/// Allowed: Input -> Target, Target -> Output. +/// Allowed: Provider Input -> Target, Staged Input -> Provider Input, Target -> Output. /// Target can have multiple Inputs. /// Output can only have one Target. /// Target can connect to: @@ -16,16 +16,26 @@ pub fn can_connect(from_block: &Block, to_block: &Block, connections: &[Connecti } // Identify block categories - let is_input = from_block.block_type.is_input(); + let is_target_input = from_block.block_type.is_input() && !matches!(from_block.block_type, BlockType::InputStaged); + let from_is_staged = matches!(from_block.block_type, BlockType::InputStaged); let is_target = from_block.block_type.is_target(); let to_is_target = to_block.block_type.is_target(); + let to_is_child_input = to_block.block_type.is_chainable_input(); let to_is_output = to_block.block_type.is_output(); - // Only allow Input -> Target OR Target -> Output - let valid_direction = (is_input && to_is_target) || (is_target && to_is_output); + // Only allow Provider Input -> Target OR Staged Input -> Provider Input OR Target -> Output + let valid_direction = + (is_target_input && to_is_target) || (from_is_staged && to_is_child_input) || (is_target && to_is_output); if !valid_direction { return false; } + // A provider input can have only one staged overlay connection. + if from_is_staged && to_is_child_input { + let has_stage_already = connections.iter().any(|c| c.to == to_block.id); + if has_stage_already { + return false; + } + } // Prevent duplicate connection if connections.iter().any(|c| c.from == from_block.id && c.to == to_block.id) { @@ -74,3 +84,37 @@ pub fn can_connect(from_block: &Block, to_block: &Block, connections: &[Connecti // Passed all checks true } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::components::BlockInstance; + use shared::model::{ConfigInputDto, ConfigTargetDto}; + use std::rc::Rc; + + fn block(id: u16, block_type: BlockType) -> Block { + let instance = if block_type.is_target() { + BlockInstance::Target(Rc::new(ConfigTargetDto::default())) + } else { + BlockInstance::Input(Rc::new(ConfigInputDto::default())) + }; + + Block { id, block_type, position: (0.0, 0.0), instance } + } + + #[test] + fn staged_input_cannot_connect_directly_to_target() { + let staged = block(1, BlockType::InputStaged); + let target = block(2, BlockType::Target); + + assert!(!can_connect(&staged, &target, &[], &[staged.clone(), target.clone()])); + } + + #[test] + fn staged_input_can_connect_to_provider_input() { + let staged = block(1, BlockType::InputStaged); + let provider = block(2, BlockType::InputXtream); + + assert!(can_connect(&staged, &provider, &[], &[staged.clone(), provider.clone()])); + } +} diff --git a/frontend/src/app/components/source_editor/sidebar.rs b/frontend/src/app/components/source_editor/sidebar.rs index 7b7f1708d..46416ef67 100644 --- a/frontend/src/app/components/source_editor/sidebar.rs +++ b/frontend/src/app/components/source_editor/sidebar.rs @@ -4,16 +4,32 @@ use crate::{ }; use yew::prelude::*; -pub const BLOCK_TYPES_INPUT: [BlockType; 3] = [ +pub const BLOCK_TYPES_INPUT: [BlockType; 7] = [ BlockType::InputXtream, BlockType::InputM3u, BlockType::InputLibrary, - // Enable when media_server feature fully implemented - // BlockType::InputJellyfin, - // BlockType::InputEmby, - // BlockType::InputPlex, + BlockType::InputStaged, + BlockType::InputJellyfin, + BlockType::InputEmby, + BlockType::InputPlex, ]; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sidebar_exposes_all_first_class_input_types() { + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputXtream)); + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputM3u)); + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputLibrary)); + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputStaged)); + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputEmby)); + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputJellyfin)); + assert!(BLOCK_TYPES_INPUT.contains(&BlockType::InputPlex)); + } +} + pub const BLOCK_TYPES_TARGET: [BlockType; 1] = [BlockType::Target]; pub const BLOCK_TYPES_OUTPUT: [BlockType; 4] = diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index 7e2852ed3..a4d4da9b0 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -10,7 +10,7 @@ use crate::{ }; use shared::{ error::TuliproxError, - model::{ClusterFlags, ConfigTargetDto, ConfigTargetOptions, ConfigTargetShareLiveStreams, ProcessingOrder}, + model::{ClusterFlags, ConfigTargetDto, ConfigTargetOptions, ProcessingOrder}, utils::Internable, }; use std::{fmt::Display, rc::Rc, str::FromStr, sync::Arc}; @@ -104,7 +104,6 @@ impl HasFormData for ConfigTargetOptionsFormState { #[derive(Clone)] pub enum ConfigTargetOptionsFormAction { IgnoreLogo(bool), - ShareLiveStreams(bool), ShareLiveStreamsHls(bool), ShareLiveStreamsMpegTs(bool), RemoveDuplicates(bool), @@ -126,10 +125,6 @@ impl yew::prelude::Reducible for ConfigTargetOptionsFormState { form.ignore_logo = value; modified = true; } - ConfigTargetOptionsFormAction::ShareLiveStreams(value) => { - form.share_live_streams = ConfigTargetShareLiveStreams { hls: value, mpeg_ts: value }; - modified = true; - } ConfigTargetOptionsFormAction::ShareLiveStreamsHls(value) => { form.share_live_streams.hls = value; modified = true; @@ -288,13 +283,9 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
{ config_field_bool!(target_options_state.form, translate.t(LABEL_IGNORE_LOGO), ignore_logo) }
- { target_option_toggle( - translate.t(LABEL_SHARE_LIVE_STREAMS), - "CONFIG_TARGET_OPTIONS.SHARE_LIVE_STREAMS", - target_options_state.form.share_live_any_enabled(), - true, - Callback::noop(), - ) } +
+ { translate.t(LABEL_SHARE_LIVE_STREAMS) } +
{ target_option_toggle( translate.t(LABEL_HLS), @@ -330,12 +321,6 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { } } else { - let share_live_on_change = { - let target_options_state = target_options_state.clone(); - Callback::from(move |value| { - target_options_state.dispatch(ConfigTargetOptionsFormAction::ShareLiveStreams(value)); - }) - }; let share_live_hls_on_change = { let target_options_state = target_options_state.clone(); Callback::from(move |value| { @@ -365,13 +350,9 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
{ edit_field_bool!(target_options_state, translate.t(LABEL_IGNORE_LOGO), ignore_logo, ConfigTargetOptionsFormAction::IgnoreLogo) }
- { target_option_toggle( - translate.t(LABEL_SHARE_LIVE_STREAMS), - "CONFIG_TARGET_OPTIONS.SHARE_LIVE_STREAMS", - target_options_state.form.share_live_any_enabled(), - false, - share_live_on_change, - ) } +
+ { translate.t(LABEL_SHARE_LIVE_STREAMS) } +
{ target_option_toggle( translate.t(LABEL_HLS), diff --git a/frontend/src/app/components/textarea.rs b/frontend/src/app/components/textarea.rs index 7763036ed..bfb1036f5 100644 --- a/frontend/src/app/components/textarea.rs +++ b/frontend/src/app/components/textarea.rs @@ -1,4 +1,4 @@ -use crate::app::components::{resolve_field_id, CollapsePanel, FieldLabel}; +use crate::app::components::{resolve_field_id, CollapsePanel, FieldLabel, FieldWrapper}; use web_sys::{HtmlTextAreaElement, InputEvent, KeyboardEvent}; use yew::{component, html, use_effect_with, Callback, Html, NodeRef, Properties, TargetCast}; @@ -55,13 +55,11 @@ pub fn TextArea(props: &TextAreaProps) -> Html { }) }; - let text_area = html! { -
-