From 7b8bdaa49bc70537d08297119b2dba7f75fa181d Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:52:35 +0100 Subject: [PATCH] Feature/stream probe (#585) Stream probe --- .github/workflows/ci.yaml | 4 +- CHANGELOG.md | 309 +++++--- Cargo.lock | 546 +++++++++----- README.md | 283 ++++--- backend/Cargo.toml | 6 +- backend/src/api/api_utils.rs | 689 ++++++++---------- backend/src/api/endpoints/hdhomerun_api.rs | 14 +- backend/src/api/endpoints/v1_api_playlist.rs | 28 +- backend/src/api/endpoints/xtream_api.rs | 25 +- backend/src/api/hdhomerun_proprietary.rs | 6 +- backend/src/api/hdhomerun_ssdp.rs | 6 +- backend/src/api/main_api.rs | 15 +- backend/src/api/model/app_state.rs | 68 +- .../src/api/model/metadata_update_manager.rs | 12 +- .../model/streams/provider_stream_factory.rs | 55 +- backend/src/api/model/xtream.rs | 12 +- backend/src/api/panel_api.rs | 75 +- backend/src/model/config/app.rs | 8 +- backend/src/model/config/base.rs | 6 +- backend/src/model/config/hdhomerun.rs | 31 +- backend/src/model/config/input.rs | 133 +++- backend/src/model/config/target.rs | 61 +- backend/src/model/xtream.rs | 35 +- backend/src/processing/parser/xtream.rs | 41 +- backend/src/processing/processor/mod.rs | 75 +- backend/src/processing/processor/playlist.rs | 467 +++++++----- backend/src/processing/processor/xtream.rs | 7 +- .../src/processing/processor/xtream_series.rs | 68 +- .../src/processing/processor/xtream_vod.rs | 69 +- backend/src/ptt/handlers.rs | 682 ++++++++--------- backend/src/ptt/parser.rs | 76 +- .../src/repository/m3u_playlist_iterator.rs | 76 +- backend/src/repository/strm_repository.rs | 46 +- backend/src/repository/xtream_repository.rs | 29 +- backend/src/utils/network/request.rs | 5 +- backend/src/utils/network/xtream.rs | 36 +- config/source.yml | 2 - frontend/Cargo.toml | 4 +- frontend/public/assets/i18n/en.json | 5 +- .../scss/app/components/_collapse_panel.scss | 2 +- .../source_editor/_source_editor_block.scss | 5 + .../source_editor/_source_editor_form.scss | 4 +- .../playlist/input/input_options.rs | 90 ++- .../playlist/target/xtream_output.rs | 45 -- .../components/source_editor/editor_view.rs | 2 +- .../components/source_editor/input_form.rs | 53 +- .../source_editor/output_xtream_form.rs | 36 +- shared/Cargo.toml | 5 +- shared/src/model/config/input.rs | 59 +- shared/src/model/config/target.rs | 41 +- shared/src/model/playlist.rs | 43 +- shared/src/model/playlist_document.rs | 18 +- shared/src/model/playlist_info_document.rs | 6 +- shared/src/utils/bitset.rs | 19 +- shared/src/utils/default_utils.rs | 3 + 55 files changed, 2629 insertions(+), 1917 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 645239232..4f6c629d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,8 +1,8 @@ name: CI on: - push: - branches: [develop] + # push: + # branches: [] pull_request: branches: [develop] diff --git a/CHANGELOG.md b/CHANGELOG.md index 21832c433..d01d0ad3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog -## 3.3.0 (2026-01-03) +## Unreleased -### ⚠️ Breaking Changes +## ⚠️ Breaking Changes -- **Global Input Definitions**: To align input definitions with the SourceEditor, inputs are now defined globally in the `inputs` section of the config - file. Each source can reference one or more inputs by their name in the `inputs` attribute. +- **Global Input Definitions**: To align input definitions with the SourceEditor, inputs are now defined globally in the `inputs` section of the + config file. Each source can reference one or more inputs by their name in the `inputs` attribute. - **Data Format Migration**: Due to heavy refactoring, the old data format is invalid. You need to clean your `data` folder and update the playlists. - **B+Tree Storage Format**: Storage format has changed to a more efficient Slotted Page architecture. - **Index optimization**: Added index to B+Tree to accelerate queries without tree traversal. @@ -20,7 +20,7 @@ - **Favorites Redesign**: Replaced implicit `create_alias` with explicit `add_favourite(group_name)` script function. - **EpgSmartMatch**: Field `name_prefix` syntax needs to be changed from `name_prefix: !suffix "."` to `name_prefix: { suffix: "." }`. - **Sort**: Sort can now use filter to sort specific entries. - + ```yaml sort: match_as_ascii: true @@ -37,12 +37,14 @@ - "!CHAN_SEQ!" - '(?i)\bHD\b' - '(?i)\bSD\b' - ``` - + ``` + - Trakt api config field `key` is now `api_key`. Added `user_agent` field to Trakt api config - - resolve_vod_delay and resolve_series_delay are now merged as resolve_delay, added `probe_live` and `probe_live_interval_hours` for live stream probing. - + - resolve_vod_delay and resolve_series_delay are now merged as resolve_delay, added `probe_live` and `probe_live_interval_hours` for live stream + probing. + ```yaml + # Before (deprecated) output: - type: xtream @@ -51,8 +53,9 @@ resolve_series: true resolve_series_delay: 2 ``` - + ```yaml + # After (new consolidated) output: - type: xtream @@ -61,10 +64,10 @@ resolve_delay: 2 # Single delay for all resolution types ``` -### 🌟 New Features +## 🌟 New Features -- **Smart Connection Priority**: Introduced a priority system for connections. Users with higher priority can preempt (kick) lower priority connections - (e.g., background tasks or standard users) when provider slots are full. +- **Smart Connection Priority**: Introduced a priority system for connections. Users with higher priority can preempt (kick) lower priority + connections (e.g., background tasks or standard users) when provider slots are full. - **Background Metadata Queue**: Metadata resolution (VOD/Series) and stream analysis are now queued per input and processed in the background when provider connections are idle. This prevents "No Connections" errors for active users during playlist updates. - **Live TV Probing**: Added support for probing Live TV streams (`probe_live`) to determine codecs and resolution. This runs as a low-priority @@ -75,10 +78,11 @@ - Recursive scanning, automatic classification, and NFO/TMDB metadata resolution. - Incremental scanning and virtual ID management. - **Panel API Integration**: Optional integration to renew expired input accounts or provision new accounts to ensure a minimum valid input accounts. -- **Playlist Caching**: Added `cache_duration` to inputs, allowing configurable provider playlist cache times during subsequent updates - (e.g., `60s`, `5m` `12h`, `1d`). +- **Playlist Caching**: Added `cache_duration` to inputs, allowing configurable provider playlist cache times during subsequent updates (e.g., `60s`, + `5m` `12h`, `1d`). - **Database Viewer**: New CLI flags `--dbx` and `--dbm` to inspect internal database content. -- **Added `disk_based_processing`**: (boolean, default `false`) to `config.yml`. When enabled, input playlists are processed from disk instead of memory. +- **Added `disk_based_processing`**: (boolean, default `false`) to `config.yml`. When enabled, input playlists are processed from disk instead of + memory. - **User-Agent `default_user_agent`**: Ensures that outgoing requests always pass a default user agent. - **FFprobe Integration**: Added capability to probe streams for codec, resolution, HDR (HDR10/HLG/DV), and audio channels using `ffprobe`. Probing strictly respects provider connection limits. If no slot is available (considering user limits), the item is skipped to prevent provider bans. @@ -86,13 +90,13 @@ - **Streaming**: Added `grace_period_hold_stream` configuration option to delay stream output until grace period connection checks are completed. - **Provider Failover & Rotation**: Tuliprox supports robust failover mechanisms for streaming providers. You can use the special `provider:///...` URL scheme in your configurations. Tuliprox will automatically resolve this to the current - active URL of the specified provider. +active URL of the specified provider. If the current URL fails (e.g., 5xx error, timeout), Tuliprox automatically rotates to the next available URL for that provider. It tracks failures and prevents infinite loops by limiting attempts to the number of available URLs. -- Added `epg_request_timeshift: [-+]hh:mm or TimeZone`, example `Europe/Paris`, `America/New_York`, `-2:30`(-2h30m), `+0:15` (15m), `2` (2h), - `:30` (30m), `:3` (3m) +- Added `epg_request_timeshift: [-+]hh:mm or TimeZone`, example `Europe/Paris`, `America/New_York`, `-2:30`(-2h30m), `+0:15` (15m), `2` (2h), `:30` + (30m), `:3` (3m) -### βš™οΈ New Settings +## βš™οΈ New Settings - **config.yml**: - Added `video.ffprobe_enabled` (default: false) and `video.ffprobe_timeout`. @@ -104,13 +108,13 @@ - Added `probe_live_interval_hours`: Sets the frequency for re-probing Live TV streams. - Added `resolve_background`: Toggles background metadata resolution (default `true`). Set to `false` for blocking, immediate resolution. -### πŸ›  Optimizations +## πŸ›  Optimizations - **Quality Tagging**: Generates enhanced filename tags (e.g., `[2160p 4K HEVC HDR TrueHD 7.1]`) for STRM files based on analysis results. - **Flat Grouping**: When `flat: true` option for STRM output is active, multiple versions (e.g., 4K and 1080p) of the same movie are now safely merged over all categories into a single folder based on TMDB ID, compatible with Jellyfin/Emby "Multi-Version" features. -### βš™οΈ Engine & Storage Optimizations +## βš™οΈ Engine & Storage Optimizations - **Slotted Page Architecture**: Improved space utilization and support for variable-length keys. - **Adaptive LZ4 Compression**: Optimized disk footprint for stored values. @@ -128,16 +132,18 @@ - **String Interning**: Implemented `Arc` string interning for playlist items to further reduce memory footprint. - **Zero-Copy B+Tree Scan**: Implemented zero-copy scanning for B+Tree internal nodes, significantly reducing heap allocations and improving random read throughput (up to 96k ops/sec). -- **Optimized Key Lookups**: `XtreamRepository` and `M3uRepository` now use zero-copy queries for `u32` keys, enhancing performance for high-traffic endpoints. +- **Optimized Key Lookups**: `XtreamRepository` and `M3uRepository` now use zero-copy queries for `u32` keys, enhancing performance for high-traffic + endpoints. -### πŸ” Mapping & Filtering Enhancements +## πŸ” Mapping & Filtering Enhancements - **Accent-Independent Matching**: Integrated `match_as_ascii` flag for robust text matching (e.g., "Cinema" matches "CinΓ©ma"). - **Deunicoding Support**: `ValueProvider` and `ValueAccessor` now support on-the-fly deunicoding. - **Flexible Sorting**: Added `order: none` support to retain source order in mappings. -- **Mapper Loop enhancement**: Updated `for_each` syntax to `variable.for_each((key, value) => { ... })`. Added support for `_` ignored variables in loop. +- **Mapper Loop enhancement**: Updated `for_each` syntax to `variable.for_each((key, value) => { ... })`. Added support for `_` ignored variables in + loop. -### πŸ’» WebUI & API +## πŸ’» WebUI & API - **Source Editor Integration**: Redesigned UI for global input management and hot-reloading. - **Messaging Config View**: New UI for configuring Discord and enhanced REST settings. @@ -149,7 +155,7 @@ - Added **Stream Buffer** settings (Enabled, Size) to Reverse Proxy configuration UI. - Added **TMDB** settings (Rate Limit, Cache Duration, Language) and **Metadata Formats** (NFO support) to Library configuration UI. -### πŸš€ Performance & Stability +## πŸš€ Performance & Stability - **Deadlock Resolution**: Fixed a potential deadlock in `ProviderLineupManager::reconcile_connections` by refactoring `DashMap` iterations to use snapshots, preventing internal shard locks from being held during async lock acquisition. @@ -187,7 +193,7 @@ - **EPG**: Fixed XMLTV timeshift to correctly apply user-defined timezone offsets in the generated XML output. - **407 Proxy Authentication Required** fix. -### βš™οΈ Messaging Refactoring +## βš™οΈ Messaging Refactoring - **Structured Messaging**: Transitioned from JSON-string-based notifications to a strictly typed messaging pipeline. - **Backend Model Migration**: Moved complex messaging models (`WatchChanges`, `ProcessingStats`) from the shared crate to the backend to reduce @@ -208,7 +214,8 @@ - Fixed SSL certificate field binding in configuration view - More robust connection-state and provider-handle management - Streamlined event notifications and provider-count reporting -- Added configurable `reverse_proxy.resource_retry` (UI + server) to tune max attempts, base delay, and exponential backoff multiplier for proxied resources. +- Added configurable `reverse_proxy.resource_retry` (UI + server) to tune max attempts, base delay, and exponential backoff multiplier for proxied + resources. - Multi Strm outputs with same type is now allowed. - Added new mapper function `pad(text | number, number, char, optional position: "<" | ">" | "^")` - Added new mapper function `format` for simple in-text replacement like `format("Hello {}! Hello {}!", "Bob", "World")` @@ -229,7 +236,9 @@ - Added `use_memory_cache` attribute to target definition to hold playlist in memory to reduce disc access. Placing playlist into memory causes more RAM usage but reduces disk access. - Added optional `filter` attribute to Output (except HDHomerun-Output). - Output filters are applied after all transformations have been performed, therefore, all filter contents must refer to the final state of the playlist. + Output filters are applied after all transformations have been performed, therefore, all filter contents must refer to the final state of the +playlist. + - Added burst buffer to shared stream - Telegram message thread support. thread id can now be appended to chat-id like `chat-id:thread-id`. - Telegram supports markdown generation for structured json messages. simply set `markdown: true` in telegram config. @@ -238,8 +247,9 @@ - Added standardized SSDP (Simple Service Discovery Protocol) and the Proprietary HDHomeRun UDP Discovery Protocol (Port 65001) - Fixed some session handling issue - added `reverse_proxy.disabled_header` configuration - Allows removing selected headers before forwarding requests when acting as a reverse proxy. Configure removal of the referer header, - all `X-*` headers, and additional custom headers. + Allows removing selected headers before forwarding requests when acting as a reverse proxy. Configure removal of the referer header, all `X-*` +headers, and additional custom headers. + - !BREAKING_CHANGE! `disble_referer_header` is now part of `reverse_proxy.disabled_header` configuration - UserTable: Copy credentials to clipboard from user table - UserTable: Kick user action from streams table @@ -259,7 +269,8 @@ - WebSocket now reconnects on disconnect; added WebSocket connection status icon in Web UI - Added Playlist EPG view with timeline, channels, `now` line, and program details - EPG data can now be fetched from selected targets and custom URLs -- Faster, more reliable EPG loading via streaming and asynchronous processing, with reduced memory usage and better support for large or compressed guides. +- Faster, more reliable EPG loading via streaming and asynchronous processing, with reduced memory usage and better support for large or compressed + guides. - Invalid EPG text data fix - Added new sidebar entry and icon for quick EPG access - Added CBOR (binary JSON) support for large API data @@ -295,9 +306,11 @@ - added sleep timer `sleep_timer_mins` to config.yml - added mapper script builtin function `template` to access template definitions. - ```toml - station_prefix = template(concat("US_", station, "_PREFIX")), - ``` +```text + + station_prefix = template(concat("US_", station, "_PREFIX")), + +``` If we assume the variable `station` contains the value `WINK`, this script receives the template with the concatenated name `US_WINK_PREFIX` which should be defined in `templates` section, @@ -308,7 +321,7 @@ and assigns it to the variable `station_prefix`. - Optional flat directory structure via 'flat' parameter (nested folder structures are not supported by some media scanners). - Added Trakt support for XC targets - ```yaml +```yaml - name: iptv-trakt-example output: - type: xtream @@ -332,7 +345,7 @@ and assigns it to the variable `station_prefix`. category_name: "πŸ“Ί Latest TV Shows" content_type: "series" fuzzy_match_threshold: 80 - ``` +``` ## 3.1.3 (2025-06-06) @@ -341,21 +354,23 @@ and assigns it to the variable `station_prefix`. - Mapper script can have blocks now. For example, you want to write a `if then else` block - ```txt - # Maybe there is no station - station = @Caption ~ "(ABC)" - match { - station => { - # if block - # station exists - } - # optional any match as else block - _ => { - # else block - # station does not exists - } - } - ``` +```text + + # Maybe there is no station + station = @Caption ~ "(ABC)" + match { + station => { + # if block + # station exists + } + # optional any match as else block + _ => { + # else block + # station does not exists + } + } + +``` - New BuiltIn Mapper function `first`. When you use Regular expressions it could be that your match contains multiple results. The builtin function `first` returns the first match. @@ -384,7 +399,6 @@ and assigns it to the variable `station_prefix`. - channel_unavailable.ts `user_account_expired.ts`: Tuliprox will return a 403 Forbidden response for any playlist request if the user is expired. So this screen will only ever appear if someone tries to directly access a stream URL after their account has expired. - - !BREAKING_CHANGE! epg refactored - url config is now renamed to sources - Added `priority`, priority is `optional` @@ -424,9 +438,10 @@ epg: You can now place multiple mapping files inside a directory like `mapping.d` and specify it using the `-m` option, for example: `-m /home/tuliprox/config/mapping.d` The files are loaded in **alphanumeric** order. - **Note:** This is a lexicographic sort β€” so `m_10.yml` comes before `m_2.yml` unless you name files carefully (e.g., `m_01.yml`, `m_02.yml`, ..., `m_10.yml`). -- Added `mapping_path` to `config.yml`. + **Note:** This is a lexicographic sort β€” so `m_10.yml` comes before `m_2.yml` unless you name files carefully (e.g., `m_01.yml`, `m_02.yml`, ..., +`m_10.yml`). +- Added `mapping_path` to `config.yml`. - Added list template for sequences. List templates can only be used for sequences. ```yaml @@ -455,10 +470,11 @@ The template can now be used for sequence - added `disable_referer_header` to `reverse_proxy` config This option, when set to `true`, prevents tuliprox from sending the Referer header in requests made when acting as a reverse proxy. This can be - particularly useful when dealing with certain Xtream Codes providers that might restrict or behave differently based on the Referer header. - Default is `false`. +particularly useful when dealing with certain Xtream Codes providers that might restrict or behave differently based on the Referer header. Default is +`false`. ```yaml + reverse_proxy: disable_referer_header: false ``` @@ -476,6 +492,7 @@ reverse_proxy: - !BREAKING_CHANGE! `web_ui config` restructured and added `user_ui_enabled` attribute ```yaml + web_ui: enabled: true user_ui_enabled: true @@ -485,6 +502,7 @@ web_ui: issuer: tuliprox secret: ef9ab256a8c0abe5de92c2e05ca92baa810472ab702ff1674e9248308ceeec92 userfile: user.txt + ``` - `grace_period_millis` default set to 300 milliseconds. @@ -500,11 +518,13 @@ web_ui: The configuration below allows up to 10 connections initially and then replenishes 1 connection every 500 milliseconds. ```yaml + reverse_proxy: rate_limit: enabled: true period_millis: 500 burst_size: 10 + ``` - Multi epg processing/optimization, auto guessing/assigning epg id's @@ -512,10 +532,13 @@ reverse_proxy: - Added `force_redirect` to target config options. valid options are `live`, `vod`, `series` ```yaml + options: {ignore_logo: false, share_live_streams: false, force_redirect: [vod, series]} + ``` ```yaml + epg: url: ['http://localhost:3001/xmltv.php?epg_id=1', 'http://localhost:3001/xmltv.php?epg_id=2'] smart_match: @@ -527,6 +550,7 @@ epg: name_prefix_separator: [':', '|', '-'] strip : ["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw"] normalize_regex: '[^a-zA-Z0-9\-]' + ``` `match_threshold`is optional and if not set 80. @@ -536,18 +560,27 @@ epg: `normalize_regex: [^a-zA-Z0-9\-]` is the default ```yaml + # single epg + url: 'https://localhost.com/epg.xml' + ``` ```yaml + # multi local file epg + url: ['file:///${env:TULIPROX_HOME}/epg.xml', 'file:///${env:TULIPROX_HOME}/epg2.xml'] + ``` ```yaml + # multi url epg + url: ['http://localhost:3001/xmltv.php?epg_id=1', 'http://localhost:3001/xmltv.php?epg_id=2'] + ``` - Added `strip` to input for auto epg matching, if not given `["3840p", "uhd", "fhd", "hd", "sd", "4k", "plus", "raw"]` is default @@ -563,13 +596,15 @@ url: ['http://localhost:3001/xmltv.php?epg_id=1', 'http://localhost:3001/xmltv.p - hdhomerun supports now basic auth like you need to enable auth in config - ```yaml - hdhomerun: - enabled: true - auth: true - devices: - - name: hdhr1 - ``` +```yaml + +hdhomerun: + enabled: true + auth: true + devices: + - name: hdhr1 + +``` - A new filter field `caption` has been added. This field is used to bypass the `title/name` issue. If `caption` is provided, its value is read from `title` if available, otherwise from `name`. @@ -577,35 +612,37 @@ url: ['http://localhost:3001/xmltv.php?epg_id=1', 'http://localhost:3001/xmltv.p - Counter has now an attribute `padding`. Which fills the number like 001. - Added proxy configuration for all outgoing requests in `config.yml`. supported http, https, socks5 proxies. - ```yaml - proxy: - url: socks5://192.168.1.6:8123 - username: uname # <- optional basic auth - password: secret # <- optional basic auth - ``` +```yaml + +proxy: + url: socks5://192.168.1.6:8123 + username: uname # <- optional basic auth + password: secret # <- optional basic auth + +``` - Added support for regular expression-based sequence sorting. You can now sort both groups and channels using custom regex sequences. - ```yaml - sort: - groups: - order: asc - sequence: - - '^Freetv' - - '^Shopping' - - '^Entertainment' - - '^Sunrise' - channels: - - field: caption - group_pattern: '^Freetv' - order: asc - sequence: - - '(?P.*?)\bUHD\b' - - '(?P.*?)\bFHD\b' - - '(?P.*?)\bHD\b' - - '(?P.*?)\bSD\b' - ``` +```yaml +sort: + groups: + order: asc + sequence: + - '^Freetv' + - '^Shopping' + - '^Entertainment' + - '^Sunrise' + channels: + - field: caption + group_pattern: '^Freetv' + order: asc + sequence: + - '(?P.*?)\bUHD\b' + - '(?P.*?)\bFHD\b' + - '(?P.*?)\bHD\b' + - '(?P.*?)\bSD\b' +``` In the example above, groups are sorted based on the specified sequence. Channels within the `Freetv` group are first sorted by `quality` (as matched by the regex sequence), and then by the `captured prefix`. @@ -621,8 +658,10 @@ The numeric suffix indicates the priority: `c1` is evaluated first, followed by - pattern_ipv6 # Optional regex pattern to extract IPv6 ```yaml + ipcheck: url_ipv4: https://ipinfo.io/ip + ``` ## 2.2.5 (2025-03-27) @@ -655,20 +694,22 @@ ipcheck: - hls reverse proxy implemented - dash redirect implemented (reverse proxy not supported) - !BREAKING CHANGE! `channel_unavailable_file` is now under `custom_stream_response`, -- New custom streams `user_connections_exhausted` and `provider_connections_exhausted` added. +- New custom streams `user_connections_exhausted` and `provider_connections_exhausted`added. ```yaml + custom_stream_response: channel_unavailable: /home/tuliprox/resources/channel_unavailable.ts user_connections_exhausted: /home/tuliprox/resources/user_connections_exhausted.ts provider_connections_exhausted: /home/tuliprox/resources/provider_connections_exhausted.ts + ``` - input alias definition for same provider with same content but different credentials ```yaml - sources: -- inputs: + - inputs: - type: xtream name: my_provider url: 'http://provider.net' @@ -679,8 +720,8 @@ custom_stream_response: url: 'http://provider.net' username: abcd password: secret2 - targets: - - name: test + targets: + - name: test ``` Input aliases can be defined as batches in csv files with `;` separator. @@ -689,34 +730,38 @@ There are 2 batch input types `xtream_batch` and `m3u_batch`. ```yaml - sources: -- inputs: + - inputs: - type: xtream_batch url: 'file:///home/tuliprox/config/my_provider_batch.csv' - targets: - - name: test + targets: + - name: test ``` ```csv + #name;username;password;url;max_connections;priority my_provider_1;user1;password1;http://my_provider_1.com:80;1;0 my_provider_2;user2;password2;http://my_provider_2.com:8080;1;0 + ``` `M3uBatch`: ```yaml - sources: -- inputs: + - inputs: - type: m3u_batch url: 'file:///home/tuliprox/config/my_provider_batch.csv' - targets: + targets: - name: test ``` ```csv + #url;max_connections;priority http://my_provider_1.com:80/get_php?username=user1&password=password1;1;0 http://my_provider_2.com:8080/get_php?username=user2&password=password2;1;0 + ``` The Fields `max_connections` and `priority`are optional. @@ -786,11 +831,13 @@ targets: options: {ignore_logo: false, share_live_streams: true, remove_duplicates: false} ``` -- The Web UI now includes a login feature for playlist users, allowing them to set their groups for filtering and managing their own bouquet of groups. +- The Web UI now includes a login feature for playlist users, allowing them to set their groups for filtering and managing their own bouquet of + groups. The playlist user can login with his credentials and can select the desired groups for his playlist. - Added `user_config_dir` to `config.yml`. It is the storage path for user configurations (f.e. bouquets). - New Filter field `input` can be used along `name`, `group`, `title`, `url` and `type`. Input is a `regexp` filter. `input ~ "provider\-\d+"` -- New option `use_user_db` in `api-proxy.yml`. The Playlist Users are stored inside the config file `api-proxy.yml`. When you set this option to `true` +- New option `use_user_db` in `api-proxy.yml`. The Playlist Users are stored inside the config file `api-proxy.yml`. When you set this option to + `true` the user are stored in a db file. This is a better choice if you have a lot of users. If you have only a few let it default to `false` - WebUI playlist browser with tree and gallery mode. Explore self hosted and provider playlists in browser. - Added HdHomeRun tuner target for use with Plex/Emby/Jellyfin @@ -805,7 +852,8 @@ targets: ## 2.2.0 (2025-02-11) -- !BREAKING CHANGE! unique `input` `name` is now mandatory, because rearranging the `source.yml` could lead to wrong results without a playlist update. +- !BREAKING CHANGE! unique `input` `name` is now mandatory, because rearranging the `source.yml` could lead to wrong results without a playlist + update. - !BREAKING_CHANGE! `log_sanitize_sensitive_info` is now under `log` section as `sanitize_sensitive_info` - !BREAKING_CHANGE! uuid generation for entries changed to `input.name` + `stream_id`. Virtual id mapping changed. The new Virtual id is not a sequence anymore. @@ -834,18 +882,22 @@ server: - Added `log_level` to `log` config. Priority: CLI-Argument, Env-Var, Config, Default(`info`) ```yaml + log: sanitize_sensitive_info: false active_clients: true log_level: debug update_on_boot: false web_ui_enabled: true + ``` -- Added new option to `input` `xtream_live_stream_without_extension`. Default is `false`. Some providers don't like `.ts` extension, - some providers need it. Now you can disable or enable it for a provider. -- Aded new option to `input` `xtream_live_stream_use_prefix`.. Default is `true`. Some providers don't like `/live/` prefix for streams, - some providers need it. Now you can disable or enable it for a provider. +- Added new option to `input` `xtream_live_stream_without_extension`. Default is `false`. Some providers don't like `.ts` extension, some providers + need it. + Now you can disable or enable it for a provider. +- Aded new option to `input` `xtream_live_stream_use_prefix`.. Default is `true`. Some providers don't like `/live/` prefix for streams, some + providers need it. + Now you can disable or enable it for a provider. - Added `path` to `api-proxy.yml` server config for simpler front reverse-proxy configuration (like nginx) - added `hlsr` handling. - fixed mapper counter not incrementing. @@ -859,9 +911,11 @@ web_ui_enabled: true - Added option `channel_unavailable_file` in `config.yml`. If a provider stream is not available this file content is send instead. ```yaml + update_on_boot: false web_ui_enabled: true channel_unavailable_file: /freeze_frame.ts + ``` ## 2.1.3 (2025-01-26) @@ -879,11 +933,13 @@ channel_unavailable_file: /freeze_frame.ts - Added `pushover.net` config to messaging. ```yaml + messaging: pushover: token: _required_ user: _required_ url: `optional`, default is https://api.pushover.net/1/messages.json + ``` ## 2.1.1 (2025-01-19) @@ -897,7 +953,8 @@ messaging: ## 2.1.0 (2025-01-17) -- Watch files are now moved inside the `target` folder. Move them manually from `watch__.bin` to `/watch_.bin` +- Watch files are now moved inside the `target` folder. Move them manually from `watch__.bin` to + `/watch_.bin` - No error log for xtream api when content is skipped with options `xtream_skip_[live|vod|series]` - _experimental_: added live channel connection sharing in reverse proxy mode. To activate set `share_live_streams` in target options. - Added `info` and `tmdb-id` caching for vod and series with options `xtream_resolve_(series|vod)`. @@ -928,6 +985,7 @@ schedules: - `config.yml` `backup_dir` is now default `backup`. If you want to keep the old name set `backup_dir: .backup` ```yaml + reverse_proxy: stream: retry: true @@ -939,11 +997,13 @@ reverse_proxy: size: 500MB enabled: true dir: ./cache + ``` ## 2.0.10 (2024-12-03) -- added Target Output Option `m3u_include_type_in_url`, default false. This adds `live`, `movie`, `series` to the url of the stream in reverse proxy mode. +- added Target Output Option `m3u_include_type_in_url`, default false. This adds `live`, `movie`, `series` to the url of the stream in reverse proxy + mode. - added Target Output Option `m3u_mask_redirect_url`, default false. The urls are pointed to tuliprox in redirect mode. In stream request a redirect response is send. Usefully if you want to track calls in redirect mode. - fixed xtream api redirect url problem. @@ -961,7 +1021,8 @@ reverse_proxy: ## 2.0.7 (2024-11-05) -- EPG is now first downloaded to disk instead of directly into memory, then processed using a SAX parser (slower but reduces memory usage from up to 2GB). +- EPG is now first downloaded to disk instead of directly into memory, then processed using a SAX parser (slower but reduces memory usage from up to + 2GB). - Various code optimizations have been applied. - Regular expression matching in log output is now set to trace level to prevent flooding the debug log. - Processing stats now include a `took` field indicating the processing time. @@ -973,12 +1034,15 @@ reverse_proxy: - api responses are now streamed directly from disk to avoid memory allocation. - fixed scheduler implementation to only wake up on scheduled times. +- + ## 2.0.5(2024-10-16) - input url supports now scheme `file://...` (which is not necessary because file paths are supported). Gzip files are also supported. - sort takes now a sequence for channel values which has higher priority than sort order - fixed error handling in filter parsing -- `NOT` filter is now `non greedy`. `NOT Name ~ "A" AND Group ~ "B"` was `NOT (Name ~ "A" AND Group ~ "B")`. Now it is `(NOT Name ~ "A") AND Group ~ "B"` +- `NOT` filter is now `non greedy`. `NOT Name ~ "A" AND Group ~ "B"` was `NOT (Name ~ "A" AND Group ~ "B")`. Now it is `(NOT Name ~ "A") AND Group ~ + "B"` - Implemented workaround for missing tvg-ID ## 2.0.4(2024-09-19) @@ -999,18 +1063,18 @@ reverse_proxy: - added `chno` to Playlist attributes. - `epg_channel_id` mapping fixed -## 2.0.2(2024-05-28) +## v2.0.2(2024-05-28) - Added Encoding handling: gzip,deflate - Fixed panic when `tvg-id` is not set. -## 2.0.1(2024-05-24) +## v2.0.1(2024-05-24) - m3u playlists are not saved as plainfile, therefor m3u output filename is not mandatory, if given the plain m3u playlist is stored. - Added `--healthcheck` argument for docker - Added `catch-up`/`timeshift` api for `xtream` -## 2.0.0(2024-05-10) +## v2.0.0(2024-05-10) - major version change due to massive changes - `update_on_boot` for config, default is false, if true an update is run on start @@ -1027,7 +1091,8 @@ reverse_proxy: - Generating xtream stream urls from m3u input. - Reverse proxy implementation for m3u playlist. - Mapper can now set `epg_channel_id`. -- Added environment variables for User Credentials `username`, `password` and `token` in format `${env:}` where `` should be replaced. +- Added environment variables for User Credentials `username`, `password` and `token` in format `${env:}` where `` should be + replaced. - Added `web_ui_enabled` to `config.yml`. Default is `true`. Set to `false` to disable webui. - Added `web_auth` to `config.yml` struct for web-ui-authentication is optional. - `enabled`: default true @@ -1084,7 +1149,7 @@ reverse_proxy: - `organize_into_directories` - `episode_pattern` - Web-UI - Download View with multi download support -- Added WebSearch Url `web_search: 'https://www.imdb.com/search/title/?title={}'` under video configuration. +- Added WebSearch Url `web_search: '<\1> under video configuration. ## v1.1.2(2023-11-03) @@ -1142,6 +1207,7 @@ user: - target: pl1 credentials: - {username: x3452, password: ztrhgrGZrt83hjerter} + ``` ## v1.0.1(2023-09-07) @@ -1228,11 +1294,13 @@ user: - MRF default is FMR - Added mapping parameter `match_as_ascii`. Default is `false`. - If `true` before regexp matching the matching text will be converted to ascii. [unidecode](https://chowdhurya.github.io/rust-unidecode/unidecode/index.html) + If `true` before regexp matching the matching text will be converted to ascii. +[unidecode](https://chowdhurya.github.io/rust-unidecode/unidecode/index.html) Added regexp templates to mapper: ```yaml + mappings: - id: France tag: "" @@ -1257,7 +1325,7 @@ mappings: - `mapping` attribute for target is now a list. You can assign multiple mapper to a target. -```yaml +```text mapping: - France - Belgium @@ -1268,6 +1336,7 @@ mapping: - Added mappings. Mappings are defined in a file named ```mapping.yml``` or can be given by command line option ```-m```. ```target``` has now an optional field ```mapping``` which has the id of the mapping configuration. + - rename is now optional ## v0.9.3(2022-04-21) @@ -1282,8 +1351,8 @@ mapping: ## v0.9.1(2022-04-05) - There are two types of targets ```m3u``` and ```strm```. This can be set by the ```output``` attribute to ```Strm``` or ```M3u```. - If the attribute is not specified ```M3u``` is created by default. ```Strm``` output has an additional option ```underscore_whitespace```. - This replaces all whitespaces with ```_``` in the path. + If the attribute is not specified ```M3u``` is created by default. ```Strm``` output has an additional option + ```underscore_whitespace```. This replaces all whitespaces with ```_``` in the path. ## v0.9.0(2022-04-04) @@ -1325,7 +1394,7 @@ mapping: ## v0.2.0(2021-10-07) - Added simple WEB-UI -- Start in server mode + - Start in server mode ## v0.1.0(2021-10-01) diff --git a/Cargo.lock b/Cargo.lock index 400c896f0..f1df49c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,7 +106,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -117,14 +117,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "anymap2" @@ -180,7 +180,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -259,7 +259,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -276,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bincode" @@ -312,9 +312,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "blake2b_simd" @@ -396,9 +396,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.51" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -420,9 +420,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -499,7 +499,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -652,9 +652,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -680,7 +680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -707,7 +707,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -731,7 +731,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -742,7 +742,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -778,9 +778,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", ] @@ -803,7 +803,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -813,7 +813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -834,7 +834,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -869,7 +869,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -996,7 +996,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -1035,7 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1055,7 +1055,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -1088,9 +1088,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" @@ -1168,7 +1168,7 @@ dependencies = [ "futures-signals", "gloo-render 0.2.0", "gloo-storage 0.3.0", - "gloo-timers 0.2.6", + "gloo-timers 0.3.0", "gloo-utils 0.2.0", "implicit-clone", "indexmap", @@ -1180,7 +1180,7 @@ dependencies = [ "serde", "serde_json", "shared", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -1267,7 +1267,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -1324,9 +1324,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1341,15 +1341,15 @@ checksum = "913dce4c5f06c2ea40fc178c06f777ac89fc6b1383e90c254fafb1abe4ba3c82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", "uuid", ] [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1372,6 +1372,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gloo" version = "0.8.1" @@ -1542,7 +1555,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "gloo-events 0.2.0", "gloo-utils 0.2.0", "serde", @@ -1692,8 +1705,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" dependencies = [ - "futures-channel", - "futures-core", "js-sys", "wasm-bindgen", ] @@ -1800,7 +1811,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -1880,7 +1891,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2113,9 +2124,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2123,7 +2134,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -2216,6 +2227,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2260,14 +2277,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" dependencies = [ "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -2281,7 +2298,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "inotify-sys", "libc", ] @@ -2353,7 +2370,7 @@ checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -2368,9 +2385,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2378,13 +2395,13 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.2.0" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", "ed25519-dalek", - "getrandom 0.2.16", + "getrandom 0.2.17", "hmac", "js-sys", "p256", @@ -2429,16 +2446,22 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.179" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -2446,7 +2469,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "redox_syscall 0.7.0", ] @@ -2490,7 +2513,7 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ce8821eadcb5cb5c64dd0c9876a90f2676424020b41272e36c1dd04d20c59" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cssparser", "encoding_rs", @@ -2499,7 +2522,7 @@ dependencies = [ "mime", "precomputed-hash", "selectors", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2525,9 +2548,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -2642,7 +2665,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fsevent-sys", "inotify", "kqueue", @@ -2675,7 +2698,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2706,9 +2729,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2780,7 +2803,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2817,7 +2840,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2834,7 +2857,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -2979,7 +3002,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -3041,7 +3064,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -3079,7 +3102,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -3184,7 +3207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -3232,9 +3255,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3276,7 +3299,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -3319,7 +3342,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3340,7 +3363,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3362,9 +3385,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -3422,7 +3445,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -3440,7 +3463,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -3469,7 +3492,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -3478,14 +3501,14 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3495,9 +3518,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3506,9 +3529,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwasm" @@ -3585,7 +3608,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3644,9 +3667,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", @@ -3705,11 +3728,11 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3813,7 +3836,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -3836,7 +3859,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09975d3195f34dce9c7b381cb0f00c3c13381d4d3735c0f1a9c894b283b302ab" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cssparser", "derive_more", "log", @@ -3923,14 +3946,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "indexmap", "itoa", @@ -4020,7 +4043,7 @@ name = "shared" version = "3.2.86" dependencies = [ "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "blake3", "bytes", "chrono", @@ -4035,6 +4058,7 @@ dependencies = [ "js-sys", "log", "lz4_flex", + "paste", "path-clean", "pest", "pest_derive", @@ -4080,13 +4104,13 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -4180,9 +4204,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.112" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -4206,7 +4230,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -4229,7 +4253,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "system-configuration-sys", ] @@ -4246,15 +4270,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4268,11 +4292,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4283,25 +4307,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -4309,22 +4333,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4380,7 +4404,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -4513,7 +4537,7 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -4558,7 +4582,7 @@ dependencies = [ "governor", "http 1.4.0", "pin-project", - "thiserror 2.0.17", + "thiserror 2.0.18", "tonic", "tower", "tracing", @@ -4584,7 +4608,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -4691,7 +4715,7 @@ dependencies = [ "log", "rand 0.9.2", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.18", "utf-8", ] @@ -4721,9 +4745,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-width" @@ -4731,6 +4755,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -4792,9 +4822,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" -version = "9.0.6" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" dependencies = [ "anyhow", "derive_builder", @@ -4805,9 +4835,9 @@ dependencies = [ [[package]] name = "vergen-lib" -version = "0.1.6" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" dependencies = [ "anyhow", "derive_builder", @@ -4851,14 +4881,23 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4871,11 +4910,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4884,9 +4924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4894,31 +4934,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.56" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e90e66d265d3a1efc0e72a54809ab90b9c0c515915c67cdf658689d2c22c6c" +checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" dependencies = [ "async-trait", "cast", @@ -4933,17 +4973,34 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", ] [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.56" +version = "0.3.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7150335716dce6028bead2b848e72f47b45e7b9422f64cccdc23bedca89affc1" +checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", ] [[package]] @@ -4957,6 +5014,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -4971,10 +5040,22 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.83" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -5021,7 +5102,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5037,7 +5118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -5049,7 +5130,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -5065,26 +5146,13 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] @@ -5097,7 +5165,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -5108,7 +5176,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -5129,7 +5197,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] @@ -5378,6 +5446,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.115", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" @@ -5447,7 +5603,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -5477,7 +5633,7 @@ checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -5499,28 +5655,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] @@ -5540,7 +5696,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", "synstructure", ] @@ -5580,14 +5736,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.112", + "syn 2.0.115", ] [[package]] name = "zmij" -version = "1.0.8" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/README.md b/README.md index 3288e3cca..caa574785 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# **tuliprox** - A Powerful IPTV Proxy & Playlist Processor +## **tuliprox** - A Powerful IPTV Proxy & Playlist Processor **tuliprox** is a lightweight, high-performance IPTV proxy and playlist processor written in Rust πŸ¦€ It supports M3U and M3U8 formats, Xtream Codes API, HDHomeRun and STRM, making it easy to filter, merge, and serve IPTV streams for Plex, Jellyfin, @@ -9,7 +9,8 @@ Emby, and other media servers. ## πŸ”§ Core Features - **Advanced Playlist Processing**: Filter, rename, map, and sort entries with ease. -- **Flexible Proxy Support**: Acts as a reverse/redirect proxy for EXTM3U, Xtream Codes, HDHomeRun, and STRM formats (Kodi, Plex, Emby, Jellyfin) with: +- **Flexible Proxy Support**: Acts as a reverse/redirect proxy for EXTM3U, Xtream Codes, HDHomeRun, and STRM formats (Kodi, Plex, Emby, Jellyfin) + with: - app-specific naming conventions - flat directory structure option (for compatibility reasons of some media scanners) - **Local Media Library Management**: Scan and serve local video files with automatic metadata resolution: @@ -55,6 +56,7 @@ Define complex filters using expressive logic, e.g.: ## Command line Arguments ```shell + Usage: tuliprox [OPTIONS] Options: @@ -72,9 +74,11 @@ Options: --healthcheck Healthcheck for docker --scan-library Scan library directories --force-library-rescan Force full library rescan - --dbx Database file type: xtream - --dbm Database file type: m3u - --dbe Database file type: epg + --dbx Dump database with content type: xtream + --dbm Dump database with content type: m3u + --dbe Dump database with content type: epg + --dbv Dump database with content type: taregt id mapping + ``` ## 1. `config.yml` @@ -208,7 +212,8 @@ Messaging is Opt-In, you need to set the `notify_on` message types which are: `telegram`, `rest` and `pushover.net` configurations are optional. `telegram` supports markdown generation for structured json messages. -`telegram` supports `message_thread_id` for group chats. Simply put thread_id behind chat_id separated by `:`. `':'` +`telegram` supports `message_thread_id` for group chats. Simply put thread_id behind chat_id separated by `:`. `':'` ```yaml messaging: @@ -235,7 +240,6 @@ messaging: - 'Content-Type: application/json' templates: error: '{"text": "Error: {{message}}"}' - pushover: token: user: @@ -270,6 +274,7 @@ Templates can be provided in two ways: **Example Multi-Source Telegram Template**: ```handlebars + *πŸ”„ Playlist Update Report* {{#each stats}} @@ -280,11 +285,13 @@ Templates can be provided in two ways: πŸ“Š `{{raw.groups}}`/`{{raw.channels}}` βž” *`{{processed.groups}}`*/*`{{processed.channels}}`* {{/each}} {{/each}} + ``` **Example Discord Template (Complex Embed)**: ```handlebars + { "content": "Tuliprox Notification", "embeds": [{ @@ -299,6 +306,7 @@ Templates can be provided in two ways: "footer": { "text": "Reported at {{timestamp}}" } }] } + ``` For more information: [Telegram bots](https://core.telegram.org/bots/tutorial) @@ -311,7 +319,6 @@ It has 2 entries `extensions` and `download`. - `extensions` are a list of video file extensions like `mp4`, `avi`, `mkv`. When you have input `m3u` and output `xtream` the url's with the matching endings will be categorized as `video`. - - `download` is _optional_ and is only necessary if you want to download the video files from the ui to a specific directory. if defined, the download button from the `ui` is available. - `headers` _optional_, download headers @@ -319,8 +326,8 @@ It has 2 entries `extensions` and `download`. - `episode_pattern` _optional_ if you download episodes, the suffix like `S01.E01` should be removed to place all files into one folder. The named capture group `episode` is mandatory. Example: `.*(?P[Ss]\\d{1,2}(.*?)[Ee]\\d{1,2}).*` -- `web_search` is _optional_, example: `https://www.imdb.com/search/title/?title={}`, - define `download.episode_pattern` to remove episode suffix from titles. +- `web_search` is _optional_ +- `download.episode_pattern` to remove episode suffix from titles. - `ffprobe_enabled`: _optional_ (default `false`). Enable or disable FFprobe analysis for streams globally. - `ffprobe_timeout`: _optional_ (default `60`). Timeout in seconds for FFprobe analysis. @@ -455,7 +462,6 @@ be `1024 Γ— 8 KB`, which is approximately `8 MB` as stated above. - _a._ if `retry` is `false` and `buffer.enabled` is `false` the provider stream is piped as is to the client. - _b._ if `retry` is `true` or `buffer.enabled` is `true` the provider stream is processed and send to the client. - - The key difference: the `b.` approach is based on complex stream handling and more memory footprint. ##### 1.7.1.3 `throttle` @@ -485,8 +491,10 @@ How long the grace grant will last, until another grace grant can made. ##### 1.7.1.5 `grace_period_hold_stream` -If set to `true`, tuliprox will wait until the grace period check (defined by `grace_period_millis`) is finished before sending any data to the client. -This is useful for players that might time out or error if they receive data and then a "connections exhausted" stream switch occurs. Default is `false`. +If set to `true`, tuliprox will wait until the grace period check (defined by `grace_period_millis`) is finished before sending any data to the +client. +This is useful for players that might time out or error if they receive data and then a "connections exhausted" stream switch occurs. Default is +`false`. #### 1.7.2 `cache` @@ -583,15 +591,17 @@ It has 2 attributes: url: ``` -The `url` is optional; default value: `https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn-country/asn-country-ipv4.csv` +The `url` is optional; The format is CSV with 3 columns: `range_start,range_end,country_code`. Example: ```csv + 1.0.0.0,1.0.0.255,AU 1.0.1.0,1.0.3.255,CN 1.0.4.0,1.0.7.255,AU + ``` #### 1.7.8 `rewrite_secret` @@ -616,9 +626,13 @@ reverse_proxy: You can generate a random secret using: ```bash + openssl rand -hex 16 + # or + node -e "console.log(require('crypto').randomBytes(16).toString('hex').toUpperCase())" + ``` ### 1.7 `backup_dir` @@ -705,13 +719,17 @@ nobody: $argon2id$v=$argon2id$v=19$m=19456,t=2,p=1$Y2FROE83ZDQ1c2VaYmJ4VU9YdHpuZ The password can be generated with ```shell + ./tuliprox --genpwd` + ``` or with docker ```shell + docker container exec -it tuliprox ./tuliprox --genpwd + ``` The encrypted pasword needs to be added manually into the users file. @@ -831,7 +849,8 @@ targets: - If left empty, a valid, random ID will be generated. - If you provide a value that is not a valid HDHomeRun ID, `tuliprox` will automatically correct it by calculating the proper checksum. - `device_udn`: (Optional) The Unique Device Name (UDN) for the standard UPnP/SSDP discovery protocol (Port 1900). It must be a UUID. - - It is recommended to leave this at its default value. `tuliprox` will automatically append a suffix to ensure it's unique for each device you define. + - It is recommended to leave this at its default value. `tuliprox` will automatically append a suffix to ensure it's unique for each device you + define. - `manufacturer`, `model_name`, `model_number`, `firmware_name`, `firmware_version`: (Optional) These fields allow you to customize the device information that is reported to clients. It is safe to leave them at their default values, which mimic a real HDTC-2US model. @@ -876,12 +895,16 @@ If you are using a bind mount, the file watcher may report the original source p For example, if you have a bind mount like this: ```fstab + /config /home/tuliprox/config none bind 0 0 + ``` ```text + /config ← your configured mount point /home/tuliprox/config ← original source directory + ``` and you use `/config` in your configuration files, the file watcher will still report events using `/home/tuliprox/config`. @@ -914,7 +937,6 @@ library: path: "/projects/media" content_type: auto recursive: true - supported_extensions: - "mp4" - "mkv" @@ -923,50 +945,49 @@ library: - "ts" - "m4v" - "webm" - metadata: path: "${env:TULIPROX_HOME}/library_metadata" read_existing: kodi: true jellyfin: false plex: false - tmdb: enabled: true # api_key: "4219e299c89411838049ab0dab19ebd5" # Get your API key from https://www.themoviedb.org/settings/api rate_limit_ms: 250 # Milliseconds between API calls (default: 250ms) - fallback_to_filename: true - formats: - "nfo" # Optionally write Kodi-compatible NFO files - playlist: movie_category: "Local Movies" series_category: "Local Series" - ``` **CLI Usage**: ```bash + # Scan VOD directories + ./tuliprox --scan-library # Force full rescan (ignores modification timestamps) + ./tuliprox --force-library-rescan # Show db content + ./tuliprox --dbx /opt/tuliprox/data/all_channels/xtream/video.db ./tuliprox --dbm /opt/tuliprox/data/all_channels/m3u.db ./tuliprox --dbe /opt/tuliprox/data/all_channels/xtream/epg.db + ``` **API Endpoints**: - `POST /api/v1/library/scan` - Trigger library scan - ```json + ```json {"force_rescan": false} ``` @@ -979,7 +1000,6 @@ inputs: - name: local-movies type: library # New input type enabled: true - sources: - inputs: - local-movies @@ -1051,8 +1071,9 @@ Each input has the following attributes: - `name` is mandatory, it must be unique. - `type` is optional, default is `m3u`. Valid values are `m3u` and `xtream` - `enabled` is optional, default is true, if you disable the processing is skipped -- `persist` is optional, you can skip or leave it blank to avoid persisting the input file. The `{}` in the filename is filled with the current timestamp. -- `url` for type `m3u` is the download url or a local filename (can be gzip) of the input-source. For type `xtream`it is `http://:` +- `persist` is optional, you can skip or leave it blank to avoid persisting the input file. The `{}` in the filename is filled with the current + timestamp. +- `url` for type `m3u` is the download url or a local filename (can be gzip) of the input-source. For type `xtream`it is `http://:` - `epg` _optional_ xmltv epg configuration - `headers` is optional - `method` can be `GET` or `POST` @@ -1074,6 +1095,14 @@ Each input has the following attributes: - `resolve_tmdb`: `true`|`false` Attempts to resolve missing TMDB IDs via TMDB API based on title. - `probe_stream`: `true`|`false` Probes stream if video/audio info is missing in provider data. Probing respects the `max_connections` limit of your provider input. If no connection slot is available, the item is skipped and retried during the next update. + - `resolve_background`: `true`|`false` (default `true`). If `false`, metadata resolve/probe runs blocking during update. + - `resolve_series`: `true`|`false` (default `false`) for Xtream series metadata resolution. + - `resolve_vod`: `true`|`false` (default `false`) for Xtream VOD metadata resolution. + - `probe_series`: `true`|`false` (default `false`) for series probing (requires `probe_stream` + ffprobe). + - `probe_vod`: `true`|`false` (default `false`) for VOD probing (requires `probe_stream` + ffprobe). + - `probe_live`: `true`|`false` (default `false`) for background live probing. + - `probe_live_interval_hours`: number (default `120`) re-probe interval for live channels. + - `resolve_delay`: seconds (default `2`) shared delay for resolve/probe metadata requests. - `aliases` for alias definitions for the same provider with different credentials - `staged` for side loading processed playlists. If you already have a provider configured but want to load the playlist from a different source β€” for example, @@ -1092,7 +1121,7 @@ the list. All streaming, proxying, and metadata still come from the provider’s `staged` has the following properties: - `type` is optional, default is `m3u`. Valid values are `m3u` and `xtream` -- `url` for type `m3u` is the download url or a local filename (can be gzip) of the input-source. For type `xtream`it is `http://:` +- `url` for type `m3u` is the download url or a local filename (can be gzip) of the input-source. For type `xtream`it is `http://:` - `headers` is optional - `method` can be `GET` or `POST` - `username` only mandatory for type `xtream` @@ -1225,9 +1254,11 @@ sources: ``` ```csv + #name;username;password;url;max_connections;priority;exp_date my_provider_1;user1;password1;http://my_provider_1.com:80;1;0;2028-11-23 12:34:23 my_provider_2;user2;password2;http://my_provider_2.com:8080;1;0;2028-11-23 12:34:23 + ``` Important: the first alias is renamed with the `name` from input definition. `my_provider_1` gets `my_provider`. @@ -1247,9 +1278,11 @@ sources: ``` ```csv + #url;max_connections;priority http://my_provider_1.com:80/get_php?username=user1&password=password1;1;0 http://my_provider_2.com:8080/get_php?username=user2&password=password2;1;0 + ``` The Fields `max_connections` and `priority`are optional. @@ -1283,7 +1316,8 @@ Optional alias pool controls: - `number`: keep at least this many valid (not expired) accounts beyond the defined offset on boot/update. Must be greater `0` and <= `max` (when `max` is a number). Default `1`. - `auto`: uses the number of enabled tuliprox users (Active/Trial and not expired) for targets in the same source. If below, tuliprox tries to renew - expired accounts first and then creates new accounts until the amount of enabled users is met during boot/update + offset. User add/update triggers + expired accounts first and then creates new accounts until the amount of enabled users is met during boot/update + offset. User add/update +triggers only when `max` is also `auto`. - `alias_pool.size.max`: `number` or `auto`. - `number`: upper bound for valid accounts when provisioning is triggered by provider exhaustion. When the maximum is reached, provisioning @@ -1306,7 +1340,9 @@ Provisioning settings: - Default `10` - `panel_api.provisioning.cooldown_sec`: `number` - Extra wait time (seconds) after a successful probe before continuing boot/update provisioning. If you continue to see a 5XX message during the - boot/update process despite a successful probe, gradually increase the cooldown time to give the provider enough time to provision the new root account. + boot/update process despite a successful probe, gradually increase the cooldown time to give the provider enough time to provision the new root +account. + - Default `0` - `panel_api.provisioning.offset`: e.g.: `15m` | `5h` | `1d` - Optional pre-expiry window for boot/update renewal of input accounts with `exp_date`; if `now + offset > exp_date`, tuliprox tries `client_renew`, @@ -1445,7 +1481,9 @@ Tuliprox evaluates Panel API responses as JSON with the following logic, dependi For `client_new`, the Panel API call would look like this in the example shown: ```text + https://panel.example.tld/api.php?action=new&type=m3u&sub=1&api_key=1234567890 + ``` ### 2.3. `sources` @@ -1473,6 +1511,7 @@ Has the following top level entries: - `mapping` _optional_ - `watch` _optional_ - `use_memory_cache`, default is false. If set to `true` playlist is cached into memory to reduce disc access. + Placing playlist into memory causes more RAM usage but reduces disk access. ### 2.2.2.1 `sort` @@ -1541,18 +1580,12 @@ the playlist. - skip_live_direct_source: true|false (default true), - skip_video_direct_source: true|false (default true), - skip_series_direct_source: true|false (default true), -- resolve_background: true|false (default true). If set to false, metadata (TMDB/Probe) resolution happens immediately and blocks the import process. -- resolve_series: true|false (default false), -- resolve_vod: true|false (default false), -- probe_series: true|false (default false), -- probe_vod: true|false (default false), -- probe_live: true|false (default false), -- probe_live_interval_hours: number (default 24), -- resolve_delay: seconds (default 2s), - update_strategy: instant|bundled (default instant), - trakt: Trakt Configuration - filter: optional filter +`Note:` `resolve_*` / `probe_*` options are configured on `inputs[].options` (xtream input), not on output/target. + `m3u` - type: m3u @@ -1656,52 +1689,48 @@ The options `skip_live_direct_source`, `skip_video_direct_source` and`skip_serie are default `true` to avoid this problem. You can set them fo `false`to keep the direct-source attribute. -Because xtream api delivers only the metadata to series, we need to fetch the series and resolve them. But be aware, -each series info entry needs to be fetched one by one and the provider can ban you if you are doing request too frequently. +Because xtream api delivers only partial metadata to series and VOD, Tuliprox can resolve and probe missing details. +These settings are configured on the xtream input (`inputs[].options`), not on target/output. +Legacy target-level resolve/probe fields are no longer supported. -- `resolve_series` if is set to `true` and you have xtream input and m3u output, the series are fetched and resolved. - This can cause a lot of requests to the provider. Be cautious when using this option. -- `resolve_series_delay` to avoid a provider ban you can set the seconds between series_info_request's. Default is 2 seconds. - But be aware that the more series entries there are, the longer the process takes. +- `resolve_series`: If `true` and you have xtream input with m3u output, series metadata is resolved. +- `resolve_vod`: If `true` and you have xtream input, VOD metadata is resolved. +- `resolve_delay`: Delay in seconds between metadata requests (default `2`) to reduce provider ban risk. +- `resolve_background`: If `true` (default), metadata jobs are queued in background. If `false`, they run blocking. -For `resolve_(vod|series)` the files are only fetched one for each input and cached. Only new and modified ones are updated. +For `resolve_(vod|series)` data is cached per input; only new/changed entries are updated. -The `kodi` format for movies can contain the `tmdb-id` (_optional_). Because xtream api delivers the data only on request, -we need to fetch this info for each movie entry. But be aware the provider can ban you if you are doing request too frequently. - -- `xtream` `resolve_vod` if is set to `true` and you have xtream input, the movies info are fetched and stored. - This can cause a lot of requests to the provider. Be cautious when using this option. -- `xtream` `resolve_vod_delay` to avoid a provider ban you can set the seconds between vod_info_request's. Default is 2 seconds. - But be aware that the more series entries there are, the longer the process takes. - Unlike `series info` `movie info` is only fetched once for each movie. If the data is stored locally there will be no update. - -There is a difference for `resolve_vod` and `resolve_series`. -`resolve_series` works only when input: `xtream` and output: `m3u`. -`resolve_vod` works only when input: `xtream`. - -- `probe_series`: If set to `true`, series entries are probed to enrich technical metadata (requires `probe_stream` and ffprobe). -- `probe_vod`: If set to `true`, VOD entries are probed to enrich technical metadata (requires `probe_stream` and ffprobe). -- `probe_live`: If set to `true`, live streams are analyzed (probed) in the background during idle times to determine codecs and resolution. -- `probe_live_interval_hours`: Defines how often (in hours) a live stream should be re-probed (default: 24h). +- `probe_series`: If `true`, series entries are probed to enrich technical metadata (requires `probe_stream` and ffprobe). +- `probe_vod`: If `true`, VOD entries are probed to enrich technical metadata (requires `probe_stream` and ffprobe). +- `probe_live`: If `true`, live streams are analyzed (probed) in the background during idle times to determine codecs and resolution. +- `probe_live_interval_hours`: Defines how often (in hours) a live stream should be re-probed (default: `120`). - `update_strategy`: - `instant` (default): Writes changes to the output files immediately after a stream is resolved/probed. - `bundled`: Queues updates and writes them in batches to reduce disk I/O operations. - - `xtream` `trakt`: + Trakt.tv is an online platform that helps you track, manage, and discover TV shows and movies. Think of it like Goodreads for TV and film. You can add trakt list matches into your playlist. You can define a `Trakt` config like ```yaml +inputs: + - name: my_xtream_input + type: xtream + options: + resolve_series: false + resolve_vod: false +sources: + - inputs: + - my_xtream_input + targets: - name: iptv-trakt-example output: - type: xtream skip_live_direct_source: true skip_video_direct_source: true skip_series_direct_source: true - resolve_series: false - resolve_vod: false trakt: api: api_key: "your api key" @@ -1766,7 +1795,8 @@ In the above example each entry starting with `DE` will be prefixed with `1.`. Mapping can be defined in a file, or multiple mapping files can be stored in the mapping path. If you use a mapping path, you need to set `mapping_path` in `config.yml` The files are loaded in **alphanumeric** order. -**Note:** This is a lexicographic sort β€” so `m_10.yml` comes before `m_2.yml` unless you name files carefully (e.g., `m_01.yml`, `m_02.yml`, ..., `m_10.yml`). +**Note:** This is a lexicographic sort β€” so `m_10.yml` comes before `m_2.yml` unless you name files carefully (e.g., `m_01.yml`, `m_02.yml`, ..., +`m_10.yml`). The filename or path can be given as `-m` argument. (See Mappings section) @@ -1845,7 +1875,8 @@ Allows you to explicitly add items to a favorite group based on a filter. This i - `cluster`: can be Series, Movie or Live. - `group`: The name of the group to add the favorite items to. - `filter`: A filter statement to select the original items. -- `match_as_ascii`: _optional_ (default `false`). If `true`, the filter matching will be case-insensitive and normalized (e.g., "Cinema" matches "CinΓ©ma"). +- `match_as_ascii`: _optional_ (default `false`). If `true`, the filter matching will be case-insensitive and normalized (e.g., "Cinema" matches + "CinΓ©ma"). Example: @@ -1895,7 +1926,8 @@ Instead of using a single `mapping.yml` file, you can use multiple mapping files when you set `mapping_path` in `config.yml` to a directory. The files are loaded in **alphanumeric** order. -**Note:** This is a lexicographic sort β€” so `m_10.yml` comes before `m_2.yml` unless you name files carefully (e.g., `m_01.yml`, `m_02.yml`, ..., `m_10.yml`). +**Note:** This is a lexicographic sort β€” so `m_10.yml` comes before `m_2.yml` unless you name files carefully (e.g., `m_01.yml`, `m_02.yml`, ..., +`m_10.yml`). The filename or path can be given as `-m` argument. (See Mappings section) @@ -2005,8 +2037,10 @@ Example `print(uppercase("hello"))`. output is only visible in `trace` log level - Assignment assigns an expression result. variable or field. ```dsl + @Title = uppercase("hello") hello = concat(capitalize("hello"), " ", capitalize("world")) + ``` - Match block evaluates expressions based on multiple matching cases. @@ -2014,12 +2048,14 @@ Example `print(uppercase("hello"))`. output is only visible in `trace` log level Note: **The order of the cases are important.** ```dsl + result = match { (var1, var2) => result1, <- only executed when both variables set var2 => result2, <- only executed when var2 variable is set var3 => result3, <- only executed when var3 variable is set _ => default <- matches anything. } + ``` - Map block assigns expression results to a variable or field @@ -2028,6 +2064,7 @@ Mapping over text It is possible to define multiple keys with `|` seperated for one case. ```dsl + result = map variable_name { "key1" => result1, "key2" => result2, @@ -2038,11 +2075,13 @@ result = map variable_name { "key1" | "key2" => result1, _ => null } + ``` Mapping over number ranges ```dsl + year_text = @Caption ~ "(\d{4})\)?$" year = number(year_text) @@ -2053,11 +2092,13 @@ Mapping over number ranges 2025.. => "> 2025", _ => year_text, } + ``` Example `if then else` block ```dsl + # Maybe there is no station station = @Caption ~ "ABC" match { @@ -2071,6 +2112,7 @@ Example `if then else` block # station does not exists } } + ``` Example `for each` block @@ -2088,6 +2130,7 @@ You can use `_` for parameters you want to ignore (e.g., `(_, value)` or `(key, 2. **Regex with capture groups**: keys are group names (or indices), values are the captured matches. ```dsl + # 1. Using split() # Split the genre string into a Named result (index as key, genre as value) genres = split(@Genre, "[,/&]") @@ -2107,6 +2150,7 @@ You can use `_` for parameters you want to ignore (e.g., `(_, value)` or `(key, # v will be "Inception" then "2010" print(concat("Found ", k, ": ", v)) }) + ``` Example of removing prefix @@ -2120,23 +2164,17 @@ mappings: # Template to match and capture different qualities in the caption (FHD, HD, SD, UHD) - name: QUALITY value: '(?i)\b([FUSL]?HD|SD|4K|1080p|720p|3840p)\b' - - name: COAST value: '(?i)\b(EAST|WEST)\b' - - name: USA_TNT_FILTER value: 'Caption ~ "(?i)^(US|USA|United States).*?TNT"' - - name: US_TNT_PREFIX value: "US: TNT" - - name: US_TNT_ENTERTAIN_GROUP value: "United States - Entertainment" - # Template to capture the group name for US TNT channels - name: US_TNT_ENTERTAIN value: 'Group ~ "^United States - Entertainment"' - mapping: # Mapping rules for all channels - id: all_channels @@ -2290,7 +2328,6 @@ be migrated to the corresponding file (`false` β†’ `api_proxy.yml`, `true` β†’ ` If you set `use_user_db` to `true` you need to use the `Web-UI` to `edit`/`add`/`remove` users. To access the api for: - - `xtream` use url like `http://192.169.1.2/player_api.php?username={}&password={}` - `m3u` use url `http://192.169.1.2/get.php?username={}&password={}` or with token @@ -2357,6 +2394,7 @@ If you use a reverse proxy in front of Tuliprox, don’t forget to forward: Now you can do `nginx` configuration like ```config + location /tuliprox { rewrite ^/tuliprox/(.*)$ /$1 break; proxy_set_header X-Real-IP $remote_addr; @@ -2371,17 +2409,20 @@ Now you can do `nginx` configuration like tcp_nopush on; tcp_nodelay on; } + ``` When you use nginx be sure to have ```nginx + proxy_redirect off; proxy_buffering off; proxy_request_buffering off; proxy_cache off; tcp_nopush on; tcp_nodelay on; + ``` because without this config you could get very high cpu peaks. @@ -2396,19 +2437,15 @@ for web-ui and api-proxy must be different. In this short example used paths are labels: # ----- Service ----- - "traefik.enable=true" - # ----- HTTP (Port 80) ----- - "traefik.http.routers.tuliprox.entrypoints=web" - "traefik.http.routers.tuliprox.rule=Host(`tv.my-domain.io`) && (PathPrefix(`/tv`) || PathPrefix(`/tuliprox`))" # 1. path: api-proxy endpoint || 2. path: web-ui endpoint - # ----- HTTPS (Port 443) ----- - "traefik.http.routers.tuliprox-secure.entrypoints=websecure" - "traefik.http.routers.tuliprox-secure.rule=Host(`tv.my-domain.io`) && (PathPrefix(`/tv`) || PathPrefix(`/tuliprox`))" # 1. path: api-proxy endpoint || 2. path: web-ui endpoint - "traefik.http.routers.tuliprox-secure.service=tuliprox" - # ----- Serviceport ----- - "traefik.http.services.tuliprox.loadbalancer.server.port=8901" - # ----- Middlewares ----- - "traefik.http.middlewares.tuliprox-strip.stripprefix.prefixes=/tv" # <-- important for api-proxy endpoint - "traefik.http.routers.tuliprox.middlewares=forward-real-ip@file,tuliprox-strip@docker" @@ -2470,7 +2507,9 @@ and configure their playlist. Change into the root directory and run: ```shell + docker build --rm -f docker/Dockerfile -t tuliprox . + ``` This will build the complete project and create a docker image. @@ -2485,22 +2524,28 @@ But you need to change `image: ghcr.io/euzu/tuliprox:latest` to `image: tuliprox Ease way to compile is a docker toolchain `cross` ```shell + rust install cross env RUSTFLAGS="--remap-path-prefix $HOME=~" cross build -p tuliprox --release --target x86_64-unknown-linux-musl + ``` #### Manual compile - install prerequisites ```shell + rustup update sudo apt-get install pkg-config musl-tools libssl-dev rustup target add x86_64-unknown-linux-musl + ``` #### Build statically linked binary ```shell + cargo build -p tuliprox --target x86_64-unknown-linux-musl --release + ``` #### Dockerize @@ -2515,16 +2560,20 @@ Targets are - `alpine-final` ```shell + # Build for a specific architecture + docker build --rm -f docker/Dockerfile -t tuliprox --target scratch-final --build-arg RUST_TARGET=x86_64-unknown-linux-musl . docker build --rm -f docker/Dockerfile -t tuliprox --target scratch-final --build-arg RUST_TARGET=aarch64-unknown-linux-musl . docker build --rm -f docker/Dockerfile -t tuliprox --target scratch-final --build-arg RUST_TARGET=armv7-unknown-linux-musleabihf . docker build --rm -f docker/Dockerfile -t tuliprox --target scratch-final --build-arg RUST_TARGET=x86_64-apple-darwin . + ``` ##### docker-compose.yml ```docker + version: '3' services: tuliprox: @@ -2533,15 +2582,25 @@ services: user: "133:144" working_dir: /app volumes: + - /opt/tuliprox/config:/app/config + - /opt/tuliprox/data:/app/data + - /opt/tuliprox/backup:/app/backup + - /opt/tuliprox/downloads:/app/downloads + environment: + - TZ=Europe/Paris + ports: + - "8901:8901" + restart: unless-stopped + ``` This example is for the local image, the official can be found under `ghcr.io/euzu/tuliprox:latest` @@ -2549,12 +2608,14 @@ This example is for the local image, the official can be found under `ghcr.io/eu If you want to use tuliprox with docker-compose, there is a `--healthcheck` argument for healthchecks ```docker + healthcheck: test: ["CMD", "/app/tuliprox", "-p", "/app/config" "--healthcheck"] interval: 30s timeout: 10s retries: 3 start_period: 10s + ``` #### Installing in LXC Container (Alpine) @@ -2562,6 +2623,7 @@ If you want to use tuliprox with docker-compose, there is a `--healthcheck` argu To get it started in a Alpine 3.19 LXC ```shell + apk update apk add nano git yarn bash cargo perl-local-lib perl-module-build make cd /opt @@ -2576,11 +2638,13 @@ ln -s /opt/tuliprox/frontend/build /web ln -s /opt/tuliprox/config /config mkdir /data mkdir /backup + ``` ### Creating a service, create /etc/init.d/tuliprox ```shell + #!/sbin/openrc-run name=tuliprox command="/bin/tuliprox" @@ -2599,12 +2663,15 @@ start_pre() { checkpath --directory --owner $command_user:$command_user --mode 0775 \ /run/tuliprox /var/log/tuliprox } + ``` #### then add it to boot ```shell + rc-update add tuliprox default + ``` ### Cross compile for windows on linux @@ -2616,20 +2683,26 @@ If you want to compile this project on linux for windows, you need to do the fol For ubuntu type: ```shell + sudo apt-get install gcc-mingw-w64 + ``` #### Install mingw support for rust ```shell + rustup target add x86_64-pc-windows-gnu rustup toolchain install stable-x86_64-pc-windows-gnu + ``` Compile it with: ```shell + cargo build -p tuliprox --release --target x86_64-pc-windows-gnu + ``` ### Cross compile for raspberry pi 2/3/4 @@ -2637,8 +2710,10 @@ cargo build -p tuliprox --release --target x86_64-pc-windows-gnu Ease way to compile is a docker toolchain `cross` ```shell + rust install cross env RUSTFLAGS="--remap-path-prefix $HOME=~" cross build -p tuliprox --release --target armv7-unknown-linux-musleabihf + ``` ## Different Scenarios @@ -2652,12 +2727,12 @@ todo. You have a provider who supports the xtream api. The provider gives you: - - the url: `http://fantastic.provider.xyz:8080` - username: `tvjunkie` - password: `junkie.secret` - epg_url: `http://fantastic.provider.xyz:8080/xmltv.php?username=tvjunkie&password=junkie.secret` + To use `tuliprox` you need to create the configuration. The configuration consist of 4 files. @@ -2678,7 +2753,8 @@ update_on_boot: true This configuration starts `tuliprox`and listens on the 8901 port. The downloaded playlists are stored inside the `data`-folder in the current working directory. -The property `update_on_boot` is optional and can be helpful in the beginning until you have found a working configuration. I prefer to set it to false. +The property `update_on_boot` is optional and can be helpful in the beginning until you have found a working configuration. I prefer to set it to +false. Now we have to define the sources we want to import. We do this inside `source.yml` @@ -2686,7 +2762,6 @@ Now we have to define the sources we want to import. We do this inside `source.y templates: - name: ALL_CHAN value: 'Group ~ ".*"' - inputs: - type: xtream name: my_provider @@ -2745,17 +2820,19 @@ access requires port forwarding on your router and an SSL terminator proxy such not have a static IP address (this is outside the scope of this manual). The next section of the `api-proxy.yml` contains the user definition. We can define users for each `target` from the `source.yml`. -This means that each `user` can only access one `target` from `source.yml`. We have named our target `all_channels` in `source.yml` and used this name +This means that each `user` can only access one `target` from `source.yml`. We have named our target `all_channels` in `source.yml` and used this +name for the user definition. We have defined 2 users, one for local access and one for external access. -We have set the proxy type to `redirect`, which means that the client will be redirected to the original provider URL when opening a stream. If you set -the proxy type to `reverse`, the stream will be streamed from the provider through `tuliprox`. Based on the hardware you are running `tuliprox` on, you +We have set the proxy type to `redirect`, which means that the client will be redirected to the original provider URL when opening a stream. If you +set +the proxy type to `reverse`, the stream will be streamed from the provider through `tuliprox`. Based on the hardware you are running `tuliprox` on, +you can opt for the proxy type `reverse`. But you should start with `redirect` first until everything works well. If no server is specified for a user, the default one is taken. To access a xtream api from our IPTV-application we need at least 3 information the `url`, `username` and `password`. All this information are now defined in `api-proxy.yml`. - - url: `http://192.168.1.41:8901` - username: `xt` - password: `xt.secret` @@ -2764,7 +2841,8 @@ Start `tuliprox`, fire up your IPTV-Application, enter credentials and watch. ## It works well, but I don't need all the channels, how can I filter -You need to understand regular expressions to define filters. A good site for learning and testing regular expressions is [regex101.com](https://regex101.com). +You need to understand regular expressions to define filters. A good site for learning and testing regular expressions is +[regex101.com](https://regex101.com). Don't forget to set FLAVOR on the left side to Rust. To adjust the filter, you must change the `source.yml` file. @@ -2777,7 +2855,7 @@ templates: inputs: - type: xtream name: my_provider - ... + # ... sources: - inputs: - my_provider @@ -2786,7 +2864,6 @@ sources: output: - type: xtream filter: "!ALL_CHAN!" - ``` We use templates to make the filters easier to maintain and read. @@ -2827,11 +2904,10 @@ templates: value: 'Group ~ "^FR: .*"' - name: MY_CHANNELS value: '!NO_SHOOPING! AND (!GERMAN_CHANNELS! OR !FRENCH_CHANNELS!)' - inputs: - type: xtream name: my_provider - ... + # ... sources: - inputs: - my_provider @@ -2840,7 +2916,7 @@ sources: output: - type: xtream filter: "!MY_CHANNELS!" - ... + # ... ``` The resulting playlist contains all French and German channels except Shopping. @@ -2898,7 +2974,7 @@ in the group name. We wwant to group the channels inside NEWS. NATIONAL, SATELLITE by their quality. The other groups should get a number prefix for ordering. -```toml +```dsl group = Group ~ "(EU|SATELLITE|NATIONAL|NEWS|MUSIC|SPORT|RELIGION|FILM|KIDS|DOCU)" quality = Caption ~ "\b([F]?HD[i]?)\b" title_match = Caption ~ "(.*?)\:\s*(.*)" @@ -2943,12 +3019,14 @@ The other groups should get a number prefix for ordering. @Group = name @Caption = title_name + ``` The transformation logic processes each entry and modifies two key fields: - `Group`: The group or category the stream belongs to. - `Caption`: The title or name of the stream. + It extracts data from these fields and applies structured transformations. This extracts a known group keyword from `Group` @@ -2962,12 +3040,14 @@ You get something like - title_prefix = 'FR' - title_name = 'TV5Monde' + from "FR: TV5Monde" -```toml +```dsl title_match = @Caption ~ "(.*?)\:\s*(.*)" title_prefix = title_match.1 title_name = title_match.2 + ``` We will later merge 3 groups together and want to keep the quality for the group name. @@ -2975,19 +3055,23 @@ For example all channels from the groups "NEWS", "NATIONAL" amd "SATELLITE" will into new groups named by the previously extracted quality. ```dsl + quality = map group { "NEWS" | "NATIONAL" | "SATELLITE" => quality, _ => null, } + ``` is equivalent to ```python + if group in ["NEWS", "NATIONAL", "SATELLITE"]: keep quality else: quality = null + ``` Generate prefix. We have later 3 new groups named by the quality. @@ -2998,6 +3082,7 @@ if the current plalyist item has one of the qualities we set the prefix accordin otherwise we use the group category. ```dsl + prefix = map quality { "HD" => "01.", "FHD" => "02.", @@ -3016,27 +3101,32 @@ otherwise we use the group category. _ => group }, } + ``` Final name construction ```dsl + name = match { quality => concat(prefix, " FR [", quality, "]"), group => concat(prefix, " FR [", group, "]"), _ => prefix } + ``` is equivalent to ```python + if quality is set: name = prefix + " FR [" + quality + "]" elif group is set: name = prefix + " FR [" + group + "]" else: name = prefix + ``` Update the playlist item @@ -3045,8 +3135,10 @@ Update the playlist item - Caption is overwritten with the cleaned-up title name. ```dsl + @Group = name @Caption = title_name + ``` ## Grouping by release year @@ -3055,15 +3147,19 @@ We want to automatically group these channels by their release year, using the f - All movies released before 2020 should be grouped together under one label. - Movies from 2020 onward should each be grouped by their specific year. + Example title: "Master Movie (2020)" The result should look like + - FR | Movies < 2020 - FR | Movies 2020 - FR | Movies 2021 - FR | Movies … ```dsl + - filter: 'Group ~ "^FR" AND Caption ~ "\(?\d{4}\)?$"' + script: | year_text = @Caption ~ "(\d{4})\)?$" year = number(year_text) @@ -3072,6 +3168,7 @@ The result should look like _ => year_text, } @Group = concat("FR | MOVIES ", year_group) + ``` Filter: Matches channels where the Group starts with "FR" and the Caption ends in a 4-digit year (optionally inside parentheses). diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4477dc0a5..360b6595f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -24,7 +24,7 @@ axum = { version = "0.8" , features = ["macros", "default", "ws"]} tower = "0.5" tower-http = { version = "0.6", features = ["cors", "auth", "fs", "compression-full", "trace"] } tower_governor = { version = "0.8", features = ["axum"] } -jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } +jsonwebtoken = { version = "10.3", features = ["rust_crypto"] } rust-argon2 = "3" futures = "0.3" path-clean = "1.0" @@ -50,7 +50,7 @@ tokio = { version = "1.49", features = ["rt-multi-thread", "parking_lot", "fs", #tracing = "0.1" #tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tokio-util = { version = "0.7", features = ["io", "io-util"] } -tempfile = "3.24" +tempfile = "3.25" filetime = "0.2" strsim = "0.11" rphonetic = "3.0" @@ -89,7 +89,7 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_ # tikv-jemallocator = { version = "*", features = ["profiling", "unprefixed_malloc_on_supported_platforms"] } [build-dependencies] -vergen = { version = "9.0", features = ["build"] } +vergen = { version = "9.1", features = ["build"] } [dev-dependencies] http-body-util = "0.1" diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 8eeb9cd0e..0d05e03a7 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1,19 +1,20 @@ use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}; -use crate::api::model::{create_channel_unavailable_stream, create_custom_video_stream_response, - create_provider_connections_exhausted_stream, create_provider_stream, - get_stream_response_with_headers, create_active_client_stream, AppState, - CustomVideoStreamType, ProviderStreamFactoryOptions, - SharedStreamManager, StreamError, ThrottledStream, UserApiRequest}; +use crate::api::model::{ + create_active_client_stream, create_channel_unavailable_stream, create_custom_video_stream_response, + create_provider_connections_exhausted_stream, create_provider_stream, get_stream_response_with_headers, AppState, + CustomVideoStreamType, ProviderStreamFactoryOptions, SharedStreamManager, StreamError, ThrottledStream, + UserApiRequest, +}; use crate::api::model::{tee_stream, UserSession}; use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; use crate::auth::Fingerprint; -use crate::model::{ConfigInput}; +use crate::model::ConfigInput; use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::tools::lru_cache::LRUResourceCache; +use crate::utils::request; use crate::utils::request::{content_type_from_ext, parse_range, send_with_retry_and_provider}; use crate::utils::{async_file_reader, async_file_writer, create_new_file_for_write, get_file_extension}; use crate::utils::{debug_if_enabled, trace_if_enabled}; -use crate::utils::request; use crate::BUILD_TIMESTAMP; use arc_swap::ArcSwapOption; @@ -26,14 +27,15 @@ use futures::{stream, StreamExt, TryStreamExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use log::{debug, error, info, log_enabled, trace, warn}; use serde::Serialize; -use shared::{concat_string}; -use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, ProxyType, StreamChannel, TargetType, UserConnectionPermission, VirtualId, XtreamCluster}; -use shared::utils::{bin_serialize, human_readable_kbps, trim_slash, Internable, CONTENT_TYPE_CBOR}; -use shared::utils::{ - extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT, +use shared::concat_string; +use shared::model::{ + Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, ProxyType, StreamChannel, TargetType, + UserConnectionPermission, VirtualId, XtreamCluster, }; +use shared::utils::{bin_serialize, human_readable_kbps, trim_slash, Internable, CONTENT_TYPE_CBOR}; +use shared::utils::{extract_extension_from_url, replace_url_extension, sanitize_sensitive_info, DASH_EXT, HLS_EXT}; use std::borrow::Cow; -use std::collections::{HashMap}; +use std::collections::HashMap; use std::convert::Infallible; use std::io::SeekFrom; use std::path::{Path, PathBuf}; @@ -76,10 +78,8 @@ macro_rules! internal_server_error { #[macro_export] macro_rules! try_unwrap_body { ($body:expr) => { - $body.map_or_else( - |_| axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), - |resp| resp.into_response(), - ) + $body + .map_or_else(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(), |resp| resp.into_response()) }; } @@ -109,37 +109,34 @@ macro_rules! try_result_or_status { #[macro_export] macro_rules! try_result_bad_request { ($option:expr, $msg_is_error:expr, $msg:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST, $msg_is_error, $msg) + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST, $msg_is_error, $msg) }; ($option:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST) + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::BAD_REQUEST) }; } #[macro_export] macro_rules! try_result_not_found { ($option:expr, $msg_is_error:expr, $msg:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND, $msg_is_error, $msg) + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND, $msg_is_error, $msg) }; ($option:expr) => { - $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND) + $crate::api::api_utils::try_result_or_status!($option, axum::http::StatusCode::NOT_FOUND) }; } -pub use try_result_or_status; +use crate::api::panel_api::{can_provision_on_exhausted, create_panel_api_provisioning_stream_details}; +pub use internal_server_error; +use shared::error::TuliproxError; pub use try_option_bad_request; pub use try_result_bad_request; pub use try_result_not_found; +pub use try_result_or_status; pub use try_unwrap_body; -pub use internal_server_error; -use shared::error::TuliproxError; -use crate::api::panel_api::{can_provision_on_exhausted, create_panel_api_provisioning_stream_details}; pub fn get_server_time() -> String { - chrono::offset::Local::now() - .with_timezone(&chrono::Local) - .format("%Y-%m-%d %H:%M:%S %Z") - .to_string() + chrono::offset::Local::now().with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S %Z").to_string() } pub fn get_build_time() -> Option { @@ -166,12 +163,10 @@ pub async fn serve_file(file_path: &Path, mime_type: String, cache_control: Opti match tokio::fs::File::open(file_path).await { Ok(file) => { - let last_modified = file.metadata().await.ok() - .and_then(|m| m.modified().ok()) - .map(|m| { - let dt: DateTime = m.into(); - dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string() - }); + let last_modified = file.metadata().await.ok().and_then(|m| m.modified().ok()).map(|m| { + let dt: DateTime = m.into(); + dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string() + }); let reader = async_file_reader(file); let stream = tokio_util::io::ReaderStream::new(reader); @@ -180,10 +175,7 @@ pub async fn serve_file(file_path: &Path, mime_type: String, cache_control: Opti let mut builder = axum::response::Response::builder() .status(axum::http::StatusCode::OK) .header(axum::http::header::CONTENT_TYPE, mime_type) - .header( - axum::http::header::CACHE_CONTROL, - cache_control.unwrap_or("no-cache") - ); + .header(axum::http::header::CACHE_CONTROL, cache_control.unwrap_or("no-cache")); if let Some(lm) = last_modified { builder = builder.header(axum::http::header::LAST_MODIFIED, lm); @@ -265,30 +257,15 @@ fn get_stream_options(app_state: &Arc) -> StreamOptions { .as_ref() .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) .map_or((false, false, 0), |stream| { - let (buffer_enabled, buffer_size) = stream - .buffer - .as_ref() - .map_or((false, 0), |buffer| (buffer.enabled, buffer.size)); - ( - stream.retry, - buffer_enabled, - buffer_size, - ) + let (buffer_enabled, buffer_size) = + stream.buffer.as_ref().map_or((false, 0), |buffer| (buffer.enabled, buffer.size)); + (stream.retry, buffer_enabled, buffer_size) }); let pipe_provider_stream = !stream_retry && !buffer_enabled; - StreamOptions { - stream_retry, - buffer_enabled, - buffer_size, - pipe_provider_stream, - } + StreamOptions { stream_retry, buffer_enabled, buffer_size, pipe_provider_stream } } -pub fn get_stream_alternative_url( - stream_url: &str, - input: &ConfigInput, - alias_input: &Arc, -) -> String { +pub fn get_stream_alternative_url(stream_url: &str, input: &ConfigInput, alias_input: &Arc) -> String { let Some(input_user_info) = input.get_user_info() else { return stream_url.to_string(); }; @@ -307,17 +284,12 @@ async fn get_redirect_alternative_url( input: &ConfigInput, ) -> Arc { if let Some((base_url, username, password)) = input.get_matched_config_by_url(redirect_url) { - if let Some(provider_cfg) = app_state - .active_provider - .get_next_provider(&input.name) - .await - { + if let Some(provider_cfg) = app_state.active_provider.get_next_provider(&input.name).await { let mut new_url = redirect_url.replacen(base_url, provider_cfg.url.as_str(), 1); if let (Some(old_username), Some(old_password)) = (username, password) { - if let (Some(new_username), Some(new_password)) = ( - provider_cfg.username.as_ref(), - provider_cfg.password.as_ref(), - ) { + if let (Some(new_username), Some(new_password)) = + (provider_cfg.username.as_ref(), provider_cfg.password.as_ref()) + { new_url = new_url.replacen(old_username, new_username, 1); new_url = new_url.replacen(old_password, new_password, 1); return new_url.into(); @@ -362,49 +334,46 @@ async fn resolve_streaming_strategy( // panel_api provisioning/loading is handled later in the stream creation flow - let stream_response_params = - if let Some(allocation) = provider_connection_handle.as_ref().map(|ph| &ph.allocation) { - match allocation { - ProviderAllocation::Exhausted => { - debug!("Provider {} is exhausted. No connections allowed.", input.name); - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - ProviderStreamState::Custom(stream) - } - ProviderAllocation::Available(ref provider_cfg) - | ProviderAllocation::GracePeriod(ref provider_cfg) => { - // force_stream_provider means we keep the url and the provider. - // If force_stream_provider or the input is the same as the config we don't need to get new url - let (selected_provider_name, url) = if force_provider.is_some() || provider_cfg.id == input.id { - (input.name.clone(), stream_url.to_string()) - } else { - (provider_cfg.name.clone(), get_stream_alternative_url(stream_url, input, provider_cfg)) - }; + let stream_response_params = if let Some(allocation) = provider_connection_handle.as_ref().map(|ph| &ph.allocation) + { + match allocation { + ProviderAllocation::Exhausted => { + debug!("Provider {} is exhausted. No connections allowed.", input.name); + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + ProviderStreamState::Custom(stream) + } + ProviderAllocation::Available(ref provider_cfg) | ProviderAllocation::GracePeriod(ref provider_cfg) => { + // force_stream_provider means we keep the url and the provider. + // If force_stream_provider or the input is the same as the config we don't need to get new url + let (selected_provider_name, url) = if force_provider.is_some() || provider_cfg.id == input.id { + (input.name.clone(), stream_url.to_string()) + } else { + (provider_cfg.name.clone(), get_stream_alternative_url(stream_url, input, provider_cfg)) + }; - debug_if_enabled!( - "provider session: input={} provider_cfg={} user={} allocation={} stream_url={}", - sanitize_sensitive_info(&input.name), - sanitize_sensitive_info(&provider_cfg.name), - sanitize_sensitive_info(provider_cfg.get_user_info().as_ref().map_or_else(|| "?", |u| u.username.as_str())), - allocation.short_key(), - sanitize_sensitive_info(&url) - ); + debug_if_enabled!( + "provider session: input={} provider_cfg={} user={} allocation={} stream_url={}", + sanitize_sensitive_info(&input.name), + sanitize_sensitive_info(&provider_cfg.name), + sanitize_sensitive_info( + provider_cfg.get_user_info().as_ref().map_or_else(|| "?", |u| u.username.as_str()) + ), + allocation.short_key(), + sanitize_sensitive_info(&url) + ); - match allocation { - ProviderAllocation::Exhausted => { - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - ProviderStreamState::Custom(stream) - } - ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(selected_provider_name.intern()), url.intern()), - ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(selected_provider_name.intern()), url.intern()), - } + if matches!(allocation, ProviderAllocation::Available(_)) { + ProviderStreamState::Available(Some(selected_provider_name.intern()), url.intern()) + } else { + ProviderStreamState::GracePeriod(Some(selected_provider_name.intern()), url.intern()) } } - } else { - debug!("Provider {} is exhausted. No connections allowed.", input.name); - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - ProviderStreamState::Custom(stream) - }; - + } + } else { + debug!("Provider {} is exhausted. No connections allowed.", input.name); + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + ProviderStreamState::Custom(stream) + }; StreamingStrategy { provider_handle: provider_connection_handle, @@ -420,10 +389,10 @@ fn get_grace_period_millis( ) -> u64 { if config_grace_period_millis > 0 && ( - matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period + matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period || connection_permission == UserConnectionPermission::GracePeriod - // user grace period - ) + // user grace period + ) { config_grace_period_millis } else { @@ -445,8 +414,8 @@ async fn create_stream_response_details( force_provider: Option<&Arc>, virtual_id: VirtualId, ) -> Result { - - let mut streaming_strategy = resolve_streaming_strategy(app_state, stream_url, fingerprint, input, force_provider).await; + let mut streaming_strategy = + resolve_streaming_strategy(app_state, stream_url, fingerprint, input, force_provider).await; let mut grace_period_options = app_state.get_grace_options(); grace_period_options.period_millis = get_grace_period_millis( connection_permission, @@ -454,19 +423,14 @@ async fn create_stream_response_details( grace_period_options.period_millis, ); - let guard_provider_name = streaming_strategy - .provider_handle - .as_ref() - .and_then(|guard| guard.allocation.get_provider_name()); + let guard_provider_name = + streaming_strategy.provider_handle.as_ref().and_then(|guard| guard.allocation.get_provider_name()); if matches!(streaming_strategy.provider_stream_state, ProviderStreamState::Custom(_)) && can_provision_on_exhausted(app_state, input) { if let Some(handle) = streaming_strategy.provider_handle.take() { - app_state - .connection_manager - .release_provider_handle(Some(handle)) - .await; + app_state.connection_manager.release_provider_handle(Some(handle)).await; } debug_if_enabled!( "panel_api: provider connections exhausted; sending provisioning stream for input {}", @@ -523,7 +487,7 @@ async fn create_stream_response_details( &app_state.http_client.load(), provider_stream_factory_options, ) - .await + .await { None => (None, None), Some((stream, info)) => (Some(stream), info), @@ -537,9 +501,7 @@ async fn create_stream_response_details( if let Some((headers, status_code, response_url, _custom_video_type)) = stream_info.as_ref() { debug!( "Responding stream request {} with status {}, headers {:?}", - sanitize_sensitive_info( - response_url.as_ref().map_or(stream_url, |s| s.as_str()) - ), + sanitize_sensitive_info(response_url.as_ref().map_or(stream_url, |s| s.as_str())), status_code, headers ); @@ -590,12 +552,8 @@ where P: PlaylistEntry, { pub fn get_query_path(&self, provider_id: u32, url: &str) -> String { - let extension = self.stream_ext.map_or_else( - || { - extract_extension_from_url(url).unwrap_or_default() - }, - ToString::to_string, - ); + let extension = + self.stream_ext.map_or_else(|| extract_extension_from_url(url).unwrap_or_default(), ToString::to_string); // if there is an action_path (like for timeshift duration/start), it will be added in front of the stream_id if self.action_path.is_empty() { @@ -616,12 +574,10 @@ where let item_type = params.item.get_item_type(); let provider_url = params.item.get_provider_url(); - let redirect_request = - params.user.proxy.is_redirect(item_type) || params.target.is_force_redirect(item_type); - let is_hls_request = - item_type == PlaylistItemType::LiveHls || params.stream_ext == Some(HLS_EXT); - let is_dash_request = (!is_hls_request && item_type == PlaylistItemType::LiveDash) - || params.stream_ext == Some(DASH_EXT); + let redirect_request = params.user.proxy.is_redirect(item_type) || params.target.is_force_redirect(item_type); + let is_hls_request = item_type == PlaylistItemType::LiveHls || params.stream_ext == Some(HLS_EXT); + let is_dash_request = + (!is_hls_request && item_type == PlaylistItemType::LiveDash) || params.stream_ext == Some(DASH_EXT); if params.target_type == TargetType::M3u { if redirect_request || is_dash_request { @@ -630,17 +586,10 @@ where } else { provider_url.clone() }; - let redirect_url = if is_dash_request { - replace_url_extension(&redirect_url, DASH_EXT).into() - } else { - redirect_url - }; let redirect_url = - get_redirect_alternative_url(app_state, &redirect_url, params.input).await; - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(&redirect_url) - ); + if is_dash_request { replace_url_extension(&redirect_url, DASH_EXT).into() } else { redirect_url }; + let redirect_url = get_redirect_alternative_url(app_state, &redirect_url, params.input).await; + debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(&redirect_url)); return Some(redirect(&redirect_url).into_response()); } } else if params.target_type == TargetType::Xtream { @@ -653,14 +602,11 @@ where if params.cluster == XtreamCluster::Series { let ext = params.stream_ext.unwrap_or_default(); let url = params.input.url.as_str(); - let username = params.input.username.as_ref().map_or("", |v| v); - let password = params.input.password.as_ref().map_or("", |v| v); + let (username, password) = + (params.input.username.as_deref().unwrap_or(""), params.input.password.as_deref().unwrap_or("")); // TODO do i need action_path like for timeshift ? let stream_url = format!("{url}/series/{username}/{password}/{provider_id}{ext}"); - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(&stream_url) - ); + debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(&stream_url)); return Some(redirect(&stream_url).into_response()); } @@ -673,21 +619,16 @@ where &provider_url, ) { None => { - error!("Can't find stream url for target {target_name}, context {}, stream_id {virtual_id}", params.req_context); + error!( + "Can't find stream url for target {target_name}, context {}, stream_id {virtual_id}", + params.req_context + ); return Some(StatusCode::BAD_REQUEST.into_response()); } - Some(url) => { - match app_state - .active_provider - .get_next_provider(¶ms.input.name) - .await - { - Some(provider_cfg) => { - get_stream_alternative_url(&url, params.input, &provider_cfg) - } - None => url.to_string(), - } - } + Some(url) => match app_state.active_provider.get_next_provider(¶ms.input.name).await { + Some(provider_cfg) => get_stream_alternative_url(&url, params.input, &provider_cfg), + None => url.to_string(), + }, }; // hls or dash redirect @@ -697,17 +638,11 @@ where } else { &replace_url_extension(&stream_url, DASH_EXT) }; - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(redirect_url) - ); + debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(redirect_url)); return Some(redirect(redirect_url).into_response()); } - debug_if_enabled!( - "Redirecting stream request to {}", - sanitize_sensitive_info(&stream_url) - ); + debug_if_enabled!("Redirecting stream request to {}", sanitize_sensitive_info(&stream_url)); return Some(redirect(&stream_url).into_response()); } } @@ -729,13 +664,9 @@ fn is_throttled_stream(item_type: PlaylistItemType, throttle_kbps: usize) -> boo ) } -fn prepare_body_stream( - app_state: &Arc, - item_type: PlaylistItemType, - stream: S, -) -> axum::body::Body +fn prepare_body_stream(app_state: &Arc, item_type: PlaylistItemType, stream: S) -> axum::body::Body where - S: futures::Stream> + Send + 'static, + S: futures::Stream> + Send + 'static, { let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); let body_stream = if is_throttled_stream(item_type, throttle_kbps) { @@ -779,7 +710,8 @@ pub async fn force_provider_stream_response( Some(&user_session.provider), stream_channel.virtual_id, ) - .await { + .await + { Ok(stream_details) => stream_details, Err(err) => { error!("Failed to stream: {err}"); @@ -788,21 +720,23 @@ pub async fn force_provider_stream_response( }; if stream_details.has_stream() { - let provider_response = stream_details - .stream_info - .as_ref() - .map(|(h, sc, url, cvt)| (h.clone(), *sc, url.clone(), *cvt)); - app_state - .active_users - .update_session_addr(&user.username, &user_session.token, &fingerprint.addr) - .await; + let provider_response = + stream_details.stream_info.as_ref().map(|(h, sc, url, cvt)| (h.clone(), *sc, url.clone(), *cvt)); + app_state.active_users.update_session_addr(&user.username, &user_session.token, &fingerprint.addr).await; stream_channel.shared = share_stream; - let stream = - create_active_client_stream(stream_details, app_state, user, connection_permission, fingerprint, stream_channel, Some(&user_session.token), req_headers) - .await; + let stream = create_active_client_stream( + stream_details, + app_state, + user, + connection_permission, + fingerprint, + stream_channel, + Some(&user_session.token), + req_headers, + ) + .await; - let (status_code, header_map) = - get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); + let (status_code, header_map) = get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); let mut response = axum::response::Response::builder().status(status_code); for (key, value) in &header_map { response = response.header(key, value); @@ -817,12 +751,13 @@ pub async fn force_provider_stream_response( } app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; - if let (Some(stream), _stream_info) = create_channel_unavailable_stream( - &app_state.app_config, - &[], - StatusCode::SERVICE_UNAVAILABLE, - ) { - app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable).await; + if let (Some(stream), _stream_info) = + create_channel_unavailable_stream(&app_state.app_config, &[], StatusCode::SERVICE_UNAVAILABLE) + { + app_state + .connection_manager + .update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable) + .await; debug!("Streaming custom stream"); try_unwrap_body!(axum::response::Response::builder() .status(StatusCode::OK) @@ -855,8 +790,9 @@ pub async fn stream_response( app_state, &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, - ).await - .into_response(); + ) + .await + .into_response(); } let virtual_id = stream_channel.virtual_id; @@ -866,8 +802,17 @@ pub async fn stream_response( let _shared_lock = if share_stream { let write_lock = app_state.app_config.file_locks.write_lock_str(stream_url).await; - if let Some(value) = - try_shared_stream_response_if_any(app_state, stream_url, fingerprint, user, connection_permission, stream_channel.clone(), session_token, req_headers).await + if let Some(value) = try_shared_stream_response_if_any( + app_state, + stream_url, + fingerprint, + user, + connection_permission, + stream_channel.clone(), + session_token, + req_headers, + ) + .await { return value.into_response(); } @@ -876,8 +821,17 @@ pub async fn stream_response( // Opportunistic cross-target sharing: if another target already runs a shared stream // for the same provider URL, subscribe to it instead of opening a separate connection. if item_type == PlaylistItemType::Live { - if let Some(value) = - try_shared_stream_response_if_any(app_state, stream_url, fingerprint, user, connection_permission, stream_channel.clone(), session_token, req_headers).await + if let Some(value) = try_shared_stream_response_if_any( + app_state, + stream_url, + fingerprint, + user, + connection_permission, + stream_channel.clone(), + session_token, + req_headers, + ) + .await { debug_if_enabled!("Opportunistic shared stream reuse for {}", sanitize_sensitive_info(stream_url)); return value.into_response(); @@ -899,7 +853,9 @@ pub async fn stream_response( connection_permission, None, stream_channel.virtual_id, - ).await{ + ) + .await + { Ok(stream_details) => stream_details, Err(err) => { error!("Failed to stream: {err}"); @@ -918,18 +874,10 @@ pub async fn stream_response( if let Some((headers, status, _response_url, Some(CustomVideoStreamType::Provisioning))) = stream_details.stream_info.as_ref() { - debug_if_enabled!( - "panel_api provisioning response to client: status={} headers={:?}", - status, - headers - ); + debug_if_enabled!("panel_api provisioning response to client: status={} headers={:?}", status, headers); } - let provider_handle = if share_stream { - stream_details.provider_handle.take() - } else { - None - }; + let provider_handle = if share_stream { stream_details.provider_handle.take() } else { None }; let mut is_stream_shared = share_stream; if let Some((_header, _status_code, _url, Some(_custom_video))) = stream_details.stream_info.as_ref() { @@ -939,15 +887,21 @@ pub async fn stream_response( } stream_channel.shared = is_stream_shared; - let stream = - create_active_client_stream(stream_details, app_state, user, connection_permission, fingerprint, stream_channel, Some(session_token), req_headers) - .await; + let stream = create_active_client_stream( + stream_details, + app_state, + user, + connection_permission, + fingerprint, + stream_channel, + Some(session_token), + req_headers, + ) + .await; let stream_resp = if is_stream_shared { - debug_if_enabled!("Streaming shared stream request from {}",sanitize_sensitive_info(stream_url)); + debug_if_enabled!("Streaming shared stream request from {}", sanitize_sensitive_info(stream_url)); // Shared Stream response - let shared_headers = provider_response - .as_ref() - .map_or_else(Vec::new, |(h, _, _, _)| h.clone()); + let shared_headers = provider_response.as_ref().map_or_else(Vec::new, |(h, _, _, _)| h.clone()); if let Some((broadcast_stream, _shared_provider)) = SharedStreamManager::register_shared_stream( app_state, @@ -958,7 +912,7 @@ pub async fn stream_response( stream_options.buffer_size, provider_handle, ) - .await + .await { let (status_code, header_map) = get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); @@ -983,21 +937,24 @@ pub async fn stream_response( // preventing the session from being "poisoned" by a temporary redirect. // For everything else (Live): It continues to work as before, using the redirected URL if available, // which is often desirable for live streams to stay on the same edge server. - let session_url = if matches!(item_type, PlaylistItemType::Catchup | PlaylistItemType::Video | PlaylistItemType::LocalVideo | PlaylistItemType::Series | PlaylistItemType::LocalSeries) { + let session_url = if matches!( + item_type, + PlaylistItemType::Catchup + | PlaylistItemType::Video + | PlaylistItemType::LocalVideo + | PlaylistItemType::Series + | PlaylistItemType::LocalSeries + ) { Cow::Borrowed(stream_url) } else { provider_response .as_ref() .and_then(|(_, _, u, _)| u.as_ref()) - .map_or_else( - || Cow::Borrowed(stream_url), - |url| Cow::Owned(url.to_string()), - ) + .map_or_else(|| Cow::Borrowed(stream_url), |url| Cow::Owned(url.to_string())) }; if log_enabled!(log::Level::Debug) { if session_url.eq(&stream_url) { - debug!("Streaming stream request from {}", sanitize_sensitive_info(stream_url) - ); + debug!("Streaming stream request from {}", sanitize_sensitive_info(stream_url)); } else { debug!( "Streaming stream request for {} from {}", @@ -1074,17 +1031,9 @@ async fn try_shared_stream_response_if_any( if let Some((stream, provider)) = SharedStreamManager::subscribe_shared_stream(app_state, stream_url, &fingerprint.addr).await { - debug_if_enabled!("Using shared stream {}", sanitize_sensitive_info(stream_url) - ); - if let Some(headers) = app_state - .shared_stream_manager - .get_shared_state_headers(stream_url) - .await - { - let (status_code, header_map) = get_stream_response_with_headers(Some(( - headers.clone(), - StatusCode::OK, - ))); + debug_if_enabled!("Using shared stream {}", sanitize_sensitive_info(stream_url)); + if let Some(headers) = app_state.shared_stream_manager.get_shared_state_headers(stream_url).await { + let (status_code, header_map) = get_stream_response_with_headers(Some((headers.clone(), StatusCode::OK))); let mut grace_period_options = app_state.get_grace_options(); if connect_permission != UserConnectionPermission::GracePeriod { grace_period_options.period_millis = 0; @@ -1093,10 +1042,18 @@ async fn try_shared_stream_response_if_any( stream_details.provider_name = provider; stream_channel.shared = true; - let stream = - create_active_client_stream(stream_details, app_state, user, connect_permission, fingerprint, stream_channel, Some(session_token), req_headers) - .await - .boxed(); + let stream = create_active_client_stream( + stream_details, + app_state, + user, + connect_permission, + fingerprint, + stream_channel, + Some(session_token), + req_headers, + ) + .await + .boxed(); let mut response = axum::response::Response::builder().status(status_code); for (key, value) in &header_map { response = response.header(key, value); @@ -1128,7 +1085,9 @@ pub async fn local_stream_response( app_state, &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, - ).await.into_response(); + ) + .await + .into_response(); } let path = PathBuf::from(pli.url.strip_prefix("file://").unwrap_or(&pli.url)); @@ -1143,14 +1102,13 @@ pub async fn local_stream_response( }; if check_path { - let Some(library_paths) = app_state.app_config.config.load() - .library.as_ref() - .map(|lib| { - lib.scan_directories - .iter() - .map(|dir| dir.path.clone()) - .collect::>() - }) + let Some(library_paths) = app_state + .app_config + .config + .load() + .library + .as_ref() + .map(|lib| lib.scan_directories.iter().map(|dir| dir.path.clone()).collect::>()) else { return StatusCode::NOT_FOUND.into_response(); }; @@ -1166,10 +1124,7 @@ pub async fn local_stream_response( let Ok(metadata) = file.metadata().await else { return internal_server_error!() }; let file_size = metadata.len(); - let range = req_headers - .get("range") - .and_then(|v| v.to_str().ok()) - .and_then(parse_range); + let range = req_headers.get("range").and_then(|v| v.to_str().ok()).and_then(parse_range); let (start, end) = if let Some((req_start, req_end)) = range { if file_size == 0 || req_start >= file_size { @@ -1209,15 +1164,12 @@ pub async fn local_stream_response( } let stream = ReaderStream::new(file.take(content_length)); - let body_stream = prepare_body_stream::<_>(app_state, pli.item_type, stream.map_err(|err| StreamError::Stream(err.to_string()))); + let body_stream = + prepare_body_stream::<_>(app_state, pli.item_type, stream.map_err(|err| StreamError::Stream(err.to_string()))); let mut response = Response::new(body_stream); - *response.status_mut() = if range.is_some() { - StatusCode::PARTIAL_CONTENT - } else { - StatusCode::OK - }; + *response.status_mut() = if range.is_some() { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; let headers = response.headers_mut(); if let Some(ext) = get_file_extension(&pli.url) { @@ -1251,17 +1203,11 @@ fn is_path_within_allowed_directories(sub_path: &Path, root_paths: &[String]) -> pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool { (item_type == PlaylistItemType::Live/* || item_type == PlaylistItemType::LiveHls */) - && target - .options - .as_ref() - .is_some_and(|opt| opt.share_live_streams) + && target.options.as_ref().is_some_and(|opt| opt.share_live_streams) } pub type HeaderFilter = Option bool + Send>>; -pub fn get_headers_from_request( - req_headers: &HeaderMap, - filter: &HeaderFilter, -) -> HashMap> { +pub fn get_headers_from_request(req_headers: &HeaderMap, filter: &HeaderFilter) -> HashMap> { req_headers .iter() .filter(|(k, _)| match &filter { @@ -1296,13 +1242,11 @@ fn get_add_cache_content( fn get_mime_type(headers: &HeaderMap, resource_url: &str) -> Option { headers .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) // Option<&str> - .map(ToString::to_string) // Option + .and_then(|v| v.to_str().ok()) // Option<&str> + .map(ToString::to_string) // Option .or_else(|| { // fallback to guess - mime_guess::from_path(resource_url) - .first_raw() - .map(ToString::to_string) + mime_guess::from_path(resource_url).first_raw().map(ToString::to_string) }) } @@ -1313,8 +1257,7 @@ async fn build_resource_stream_response( ) -> axum::response::Response { let sanitized_resource_url = sanitize_sensitive_info(resource_url); let status = response.status(); - let mut response_builder = - axum::response::Response::builder().status(status); + let mut response_builder = axum::response::Response::builder().status(status); let mime_type = get_mime_type(response.headers(), resource_url); let has_content_range = response.headers().contains_key(header::CONTENT_RANGE); for (key, value) in response.headers() { @@ -1339,13 +1282,11 @@ async fn build_resource_stream_response( response_builder = response_builder.header(header::CACHE_CONTROL, "public, max-age=14400"); } - let byte_stream = response - .bytes_stream() - .map_err(|err| StreamError::reqwest(&err)); + let byte_stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); // Cache only complete responses (200 OK without Content-Range) let can_cache = status == StatusCode::OK && !has_content_range; if can_cache { - debug!( "Caching eligible resource stream {sanitized_resource_url}"); + debug!("Caching eligible resource stream {sanitized_resource_url}"); let cache_resource_path = if let Some(cache) = app_state.cache.load().as_ref() { Some(cache.lock().await.store_path(resource_url, mime_type.as_deref())) } else { @@ -1361,7 +1302,10 @@ async fn build_resource_stream_response( return try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(tee))); } Err(err) => { - warn!("Failed to create cache file {} for {sanitized_resource_url}: {err}", resource_path.display()); + warn!( + "Failed to create cache file {} for {sanitized_resource_url}: {err}", + resource_path.display() + ); } } } else { @@ -1386,12 +1330,8 @@ async fn fetch_resource_with_retry( let disabled_headers = app_state.get_disabled_headers(); let provider_config = input.and_then(|i| i.get_resolve_provider(url.as_str())); - let Ok(response) = send_with_retry_and_provider( - &app_state.app_config, - url, - provider_config.as_ref(), - false, - |resolved_url| { + let Ok(response) = + send_with_retry_and_provider(&app_state.app_config, url, provider_config.as_ref(), false, |resolved_url| { request::get_client_request( &app_state.http_client.load(), input.map_or(InputFetchMethod::GET, |i| i.method), @@ -1401,38 +1341,31 @@ async fn fetch_resource_with_retry( disabled_headers.as_ref(), default_user_agent.as_deref(), ) - }, - ).await else { return None }; + }) + .await + else { + return None; + }; let status = response.status(); if status.is_success() { - return Some( - build_resource_stream_response(app_state, resource_url, response).await, - ); + return Some(build_resource_stream_response(app_state, resource_url, response).await); } // Non-retriable Status β†’ Upstream Response incl. Body - debug_if_enabled!( - "Failed to open resource got status {status} for {}", - sanitize_sensitive_info(resource_url) - ); + debug_if_enabled!("Failed to open resource got status {status} for {}", sanitize_sensitive_info(resource_url)); let mut response_builder = axum::response::Response::builder().status(status); for (key, value) in response.headers() { response_builder = response_builder.header(key, value); } - let stream = response - .bytes_stream() - .map_err(|err| StreamError::reqwest(&err)); + let stream = response.bytes_stream().map_err(|err| StreamError::reqwest(&err)); - Some(try_unwrap_body!( - response_builder.body(axum::body::Body::from_stream(stream)) - )) + Some(try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(stream)))) } - /// # Panics pub async fn resource_response( app_state: &Arc, @@ -1443,26 +1376,24 @@ pub async fn resource_response( if resource_url.is_empty() { return StatusCode::NO_CONTENT.into_response(); } - let filter: HeaderFilter = Some(Box::new(|key| { - key != "if-none-match" && key != "if-modified-since" - })); + let filter: HeaderFilter = Some(Box::new(|key| key != "if-none-match" && key != "if-modified-since")); let req_headers = get_headers_from_request(req_headers, &filter); if let Some(cache) = app_state.cache.load().as_ref() { let mut guard = cache.lock().await; if let Some((resource_path, mime_type)) = guard.get_content(resource_url) { trace_if_enabled!("Responding resource from cache {}", sanitize_sensitive_info(resource_url)); - return serve_file(&resource_path, mime_type.unwrap_or_else(|| mime::APPLICATION_OCTET_STREAM.to_string()), Some("public, max-age=14400")) - .await - .into_response(); + return serve_file( + &resource_path, + mime_type.unwrap_or_else(|| mime::APPLICATION_OCTET_STREAM.to_string()), + Some("public, max-age=14400"), + ) + .await + .into_response(); } } - trace_if_enabled!( - "Try to fetch resource {}", - sanitize_sensitive_info(resource_url) - ); + trace_if_enabled!("Try to fetch resource {}", sanitize_sensitive_info(resource_url)); if let Ok(url) = Url::parse(resource_url) { - if let Some(resp) = - fetch_resource_with_retry(app_state, &url, resource_url, &req_headers, input).await { + if let Some(resp) = fetch_resource_with_retry(app_state, &url, resource_url, &req_headers, input).await { return resp; } // Upstream failure after retries @@ -1492,20 +1423,11 @@ pub fn empty_json_list_response() -> axum::response::Response { } pub fn get_username_from_auth_header(token: &str, app_state: &Arc) -> Option { - if let Some(web_auth_config) = &app_state - .app_config - .config - .load() - .web_ui - .as_ref() - .and_then(|c| c.auth.as_ref()) - { + if let Some(web_auth_config) = &app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()) { let secret_key: &[u8] = web_auth_config.secret.as_ref(); - if let Ok(token_data) = decode::( - token, - &DecodingKey::from_secret(secret_key), - &Validation::new(Algorithm::HS256), - ) { + if let Ok(token_data) = + decode::(token, &DecodingKey::from_secret(secret_key), &Validation::new(Algorithm::HS256)) + { return Some(token_data.claims.username); } } @@ -1526,10 +1448,7 @@ pub async fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> } // seek requests contains range header - let range = req_headers - .get("range") - .and_then(|h| h.to_str().ok()) - .map(ToString::to_string); + let range = req_headers.get("range").and_then(|h| h.to_str().ok()).map(ToString::to_string); if let Some(range) = range { if range.starts_with("bytes=") { @@ -1541,10 +1460,7 @@ pub async fn is_seek_request(cluster: XtreamCluster, req_headers: &HeaderMap) -> pub fn bin_response(data: &T) -> impl IntoResponse + Send { match bin_serialize(data) { - Ok(body) => ( - [(header::CONTENT_TYPE, CONTENT_TYPE_CBOR)], - body, - ).into_response(), + Ok(body) => ([(header::CONTENT_TYPE, CONTENT_TYPE_CBOR)], body).into_response(), Err(_) => internal_server_error!(), } } @@ -1560,7 +1476,10 @@ pub fn json_or_bin_response(accept: Option<&str>, data: &T) -> imp json_response(data).into_response() } -pub fn stream_json_or_bin_response

(accept: Option<&str>, data: Box + Send>) -> axum::response::Response +pub fn stream_json_or_bin_response

( + accept: Option<&str>, + data: Box + Send>, +) -> axum::response::Response where P: serde::Serialize + Send + 'static, { @@ -1574,78 +1493,66 @@ pub fn create_session_fingerprint(fingerprint: &Fingerprint, username: &str, vir concat_string!(&fingerprint.key, "|", username, "|", &virtual_id.to_string()) } -pub fn stream_json_array

(iter: Box + Send>) -> axum::response::Response +pub fn stream_json_array

(iter: Box + Send>) -> axum::response::Response where P: serde::Serialize + Send + 'static, { - let stream = stream::unfold( - (iter, true), - |(mut iter, first)| async move { - match iter.next() { - Some(item) => { - let mut json = String::new(); - if !first { - json.push(','); - } - let element = serde_json::to_string(&item).ok()?; - json.push_str(&element); - Some((Ok::(Bytes::from(json)), (iter, false))) + let stream = stream::unfold((iter, true), |(mut iter, first)| async move { + match iter.next() { + Some(item) => { + let mut json = String::new(); + if !first { + json.push(','); } - None => None, + let element = serde_json::to_string(&item).ok()?; + json.push_str(&element); + Some((Ok::(Bytes::from(json)), (iter, false))) } - }, - ); + None => None, + } + }); let body = Body::from_stream( stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"[")) }) .chain(stream) - .chain(stream::once(async { - Ok::<_, Infallible>(Bytes::from_static(b"]")) - })), + .chain(stream::once(async { Ok::<_, Infallible>(Bytes::from_static(b"]")) })), ); - try_unwrap_body!(Response::builder() - .header(header::CONTENT_TYPE, "application/json") - .body(body)) + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, "application/json").body(body)) } -pub fn stream_bin_array

(iter: Box + Send>) -> axum::response::Response +pub fn stream_bin_array

(iter: Box + Send>) -> axum::response::Response where P: serde::Serialize + Send + 'static, { - let stream = stream::unfold( - iter, - |mut iter| async move { - match iter.next() { - Some(item) => { - match bin_serialize(&item) { - Ok(buf) => Some((Ok::(Bytes::from(buf)), iter)), - Err(err) => { - warn!("CBOR serialization error in stream: {err}"); - Some((Ok::(Bytes::new()), iter)) // skip errors, continue - } + let stream = stream::unfold(iter, |mut iter| async move { + match iter.next() { + Some(item) => { + match bin_serialize(&item) { + Ok(buf) => Some((Ok::(Bytes::from(buf)), iter)), + Err(err) => { + warn!("CBOR serialization error in stream: {err}"); + Some((Ok::(Bytes::new()), iter)) // skip errors, continue } } - None => None, } - }, - ); + None => None, + } + }); let body = Body::from_stream( stream::once(async { // CBOR: start indefinite-length array Ok::<_, Infallible>(Bytes::from_static(&[0x9f])) }) - .chain(stream) - .chain(stream::once(async { - // CBOR: end indefinite-length array - Ok::<_, Infallible>(Bytes::from_static(&[0xff])) - })), + .chain(stream) + .chain(stream::once(async { + // CBOR: end indefinite-length array + Ok::<_, Infallible>(Bytes::from_static(&[0xff])) + })), ); - try_unwrap_body!(Response::builder() - .header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR) - .body(body)) + try_unwrap_body!(Response::builder().header(header::CONTENT_TYPE, CONTENT_TYPE_CBOR).body(body)) } pub fn create_api_proxy_user(app_state: &Arc) -> ProxyUserCredentials { @@ -1677,20 +1584,14 @@ pub fn create_api_proxy_user(app_state: &Arc) -> ProxyUserCredentials pub fn empty_json_response_as_object() -> axum::http::Result { axum::response::Response::builder() .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - mime::APPLICATION_JSON.to_string(), - ) + .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) .body(axum::body::Body::from("{}".as_bytes())) } pub fn empty_json_response_as_array() -> axum::http::Result { axum::response::Response::builder() .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - mime::APPLICATION_JSON.to_string(), - ) + .header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) .body(axum::body::Body::from("[]".as_bytes())) } diff --git a/backend/src/api/endpoints/hdhomerun_api.rs b/backend/src/api/endpoints/hdhomerun_api.rs index cb9024b27..6768c21db 100644 --- a/backend/src/api/endpoints/hdhomerun_api.rs +++ b/backend/src/api/endpoints/hdhomerun_api.rs @@ -1,7 +1,7 @@ use crate::api::api_utils::{try_unwrap_body, internal_server_error}; use crate::api::model::HdHomerunAppState; use crate::auth::AuthBasic; -use crate::model::{AppConfig, ConfigTarget, ProxyUserCredentials}; +use crate::model::{AppConfig, ConfigInputFlags, ConfigTarget, ProxyUserCredentials}; use crate::utils::arc_str_serde; use crate::processing::parser::xtream::get_xtream_url; use crate::repository::{iter_raw_m3u_target_playlist, M3uPlaylistIterator}; @@ -103,13 +103,13 @@ where match channels { Some(chans) => { let mapped = chans.map(move |(item, has_next)| { - let input_options = cfg.get_input_options_by_name(&item.input_name); - let (live_stream_use_prefix, live_stream_without_extension) = input_options + let input = cfg.get_input_by_name(&item.input_name); + let (live_stream_use_prefix, live_stream_without_extension) = input .as_ref() - .map_or((true, false), |o| { + .map_or((true, false), |i| { ( - o.xtream_live_stream_use_prefix, - o.xtream_live_stream_without_extension, + i.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix), + i.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension), ) }); let container_extension = item.get_container_extension(); @@ -498,4 +498,4 @@ pub fn hdhr_api_register(basic_auth: bool) -> axum::Router ConfigInput { url: String::from(url), enabled: true, options: Some(ConfigInputOptions { - xtream_skip_live: false, - xtream_skip_vod: false, - xtream_skip_series: false, - xtream_live_stream_without_extension: false, - xtream_live_stream_use_prefix: true, - resolve_tmdb: false, - probe_stream: false, + flags: ConfigInputFlagsSet::from_variants(&[ + ConfigInputFlags::XtreamLiveStreamUsePrefix, + ConfigInputFlags::ResolveBackground, + ]), + resolve_delay: 2, + probe_live_interval_hours: 120, }), ..Default::default() } @@ -47,13 +46,12 @@ fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> password: Some(String::from(password)), enabled: true, options: Some(ConfigInputOptions { - xtream_skip_live: false, - xtream_skip_vod: false, - xtream_skip_series: false, - xtream_live_stream_without_extension: false, - xtream_live_stream_use_prefix: true, - resolve_tmdb: false, - probe_stream: false, + flags: ConfigInputFlagsSet::from_variants(&[ + ConfigInputFlags::XtreamLiveStreamUsePrefix, + ConfigInputFlags::ResolveBackground, + ]), + resolve_delay: 2, + probe_live_interval_hours: 120, }), ..Default::default() } diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 9d14903be..174b93377 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -10,7 +10,7 @@ use crate::api::model::XtreamAuthorizationResponse; use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType}; use crate::auth::Fingerprint; use crate::model::{xtream_mapping_option_from_target_options, ConfigTarget}; -use crate::model::{Config, ConfigInput}; +use crate::model::{Config, ConfigInput, ConfigInputFlags}; use crate::model::{InputSource, ProxyUserCredentials}; use crate::repository::get_target_storage_path; use crate::repository::storage_const; @@ -153,10 +153,7 @@ pub(in crate::api) fn get_xtream_player_api_stream_url( if let Some(input_user_info) = input.get_user_info() { let ctx = match context { ApiStreamContext::LiveAlt | ApiStreamContext::Live => { - let use_prefix = input - .options - .as_ref() - .is_none_or(|o| o.xtream_live_stream_use_prefix); + let use_prefix = input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix); String::from(if use_prefix { "live" } else { "" }) } ApiStreamContext::Movie | ApiStreamContext::Series | ApiStreamContext::Timeshift => { @@ -404,8 +401,8 @@ async fn xtream_player_api_stream( fn get_query_path(action_path: &str, stream_ext: Option<&String>, pli: &XtreamPlaylistItem, app_state: &Arc) -> (String, String) { let discard_extension = if pli.item_type.is_live() { app_state.app_config.sources.load().get_input_by_name(&pli.input_name) - .as_ref().and_then(|i| i.options.as_ref()) - .is_some_and(|o| o.xtream_live_stream_without_extension) + .as_ref() + .is_some_and(|i| i.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension)) } else { false }; @@ -1185,14 +1182,12 @@ async fn xtream_player_api( let (skip_live, skip_vod, skip_series) = if let Some(inputs) = app_state.app_config.get_inputs_for_target(&target.name) { inputs.iter().fold((true, true, true), |acc, i| { - let (l, v, s) = acc; - i.options.as_ref().map_or((false, false, false), |o| { - ( - l && o.xtream_skip_live, - v && o.xtream_skip_vod, - s && o.xtream_skip_series, - ) - }) + let (live, vod, series) = acc; + ( + live && i.has_flag(ConfigInputFlags::XtreamSkipLive), + vod && i.has_flag(ConfigInputFlags::XtreamSkipVod), + series && i.has_flag(ConfigInputFlags::XtreamSkipSeries), + ) }) } else { (false, false, false) diff --git a/backend/src/api/hdhomerun_proprietary.rs b/backend/src/api/hdhomerun_proprietary.rs index dea641365..e79d93738 100644 --- a/backend/src/api/hdhomerun_proprietary.rs +++ b/backend/src/api/hdhomerun_proprietary.rs @@ -1,5 +1,5 @@ use crate::api::model::AppState; -use crate::model::{AppConfig, HdHomeRunDeviceConfig}; +use crate::model::{AppConfig, HdHomeRunDeviceConfig, HdHomeRunFlags}; use bytes::{Buf, BufMut, BytesMut}; use log::{error, info, trace}; use std::collections::HashMap; @@ -236,7 +236,7 @@ async fn proprietary_discover_loop( trace!("Received proprietary HDHomeRun discover from {remote_addr}"); let hdhomerun_guard = app_config.hdhomerun.load(); if let Some(hd_config) = &*hdhomerun_guard { - if hd_config.enabled { + if hd_config.flags.contains(HdHomeRunFlags::Enabled) { for device in &hd_config.devices { if device.t_enabled { let dev_id = u32::from_str_radix(&device.device_id, 16).unwrap_or(0); @@ -473,4 +473,4 @@ pub fn spawn_proprietary_tasks( // TCP Get/Set Task tokio::spawn(proprietary_tcp_listener_loop(app_state, cancel_token)); -} \ No newline at end of file +} diff --git a/backend/src/api/hdhomerun_ssdp.rs b/backend/src/api/hdhomerun_ssdp.rs index c71264f4d..823b1fd5f 100644 --- a/backend/src/api/hdhomerun_ssdp.rs +++ b/backend/src/api/hdhomerun_ssdp.rs @@ -1,4 +1,4 @@ -use crate::model::{AppConfig, HdHomeRunDeviceConfig}; +use crate::model::{AppConfig, HdHomeRunDeviceConfig, HdHomeRunFlags}; use socket2::{Domain, Protocol, Socket, Type}; use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; use std::sync::Arc; @@ -61,7 +61,7 @@ async fn ssdp_task_loop(socket: UdpSocket, app_config: Arc, server_ho trace!("Received HDHomeRun M-SEARCH from {remote_addr}"); let hdhomerun_guard = app_config.hdhomerun.load(); if let Some(hd_config) = &*hdhomerun_guard { - if hd_config.enabled { + if hd_config.flags.contains(HdHomeRunFlags::Enabled) { for device in &hd_config.devices { if device.t_enabled { let response = create_ssdp_response(device, &server_host); @@ -116,4 +116,4 @@ pub fn spawn_ssdp_discover_task(app_config: Arc, server_host: String, Err(e) => error!("Failed to create tokio UdpSocket for SSDP discovery: {e}"), } }); -} \ No newline at end of file +} diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index fd1004127..74c61ac6f 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -16,7 +16,7 @@ use crate::api::panel_api::sync_panel_api_exp_dates_on_boot; use crate::api::scheduler::{exec_interner_prune, exec_scheduler}; use crate::api::serve::serve; use crate::api::sys_usage::exec_system_usage; -use crate::model::{AppConfig, Config, Healthcheck, ProcessTargets, RateLimitConfig}; +use crate::model::{AppConfig, Config, HdHomeRunFlags, Healthcheck, ProcessTargets, RateLimitConfig}; use crate::processing::processor::exec_processing; use crate::repository::get_geoip_path; use crate::repository::load_playlists_into_memory_cache; @@ -182,8 +182,8 @@ pub(in crate::api) fn start_hdhomerun( let host = config.api.host.clone(); let guard = app_config.hdhomerun.load(); if let Some(hdhomerun) = &*guard { - if hdhomerun.enabled { - if hdhomerun.ssdp_discovery { + if hdhomerun.flags.contains(HdHomeRunFlags::Enabled) { + if hdhomerun.flags.contains(HdHomeRunFlags::SsdpDiscovery) { info!("HDHomeRun SSDP discovery is enabled."); spawn_ssdp_discover_task( Arc::clone(app_config), @@ -194,7 +194,10 @@ pub(in crate::api) fn start_hdhomerun( info!("HDHomeRun SSDP discovery is disabled."); } - if hdhomerun.proprietary_discovery { + if hdhomerun + .flags + .contains(HdHomeRunFlags::ProprietaryDiscovery) + { info!("HDHomeRun proprietary discovery is enabled."); spawn_proprietary_tasks( Arc::clone(app_state), @@ -211,7 +214,7 @@ pub(in crate::api) fn start_hdhomerun( let app_host = host.clone(); let port = device.port; let device_clone = Arc::new(device.clone()); - let basic_auth = hdhomerun.auth; + let basic_auth = hdhomerun.flags.contains(HdHomeRunFlags::Auth); infos.push(format!( "HdHomeRun Server '{}' running: http://{host}:{port}", device.name @@ -527,4 +530,4 @@ fn exec_input_update_listener(_app_state: &Arc, _targets: &Arc { - if $changes.$field { + ($field: ident, $flag:expr, $changes:expr, $cancel_tokens:expr) => { + if $changes.flags.contains($flag) { $cancel_tokens.$field.cancel(); CancellationToken::default() } else { @@ -58,18 +59,22 @@ struct TargetChanges { target: Arc, } -#[allow(clippy::struct_excessive_bools)] +create_bitset!(u8, UpdateChangesFlags, Scheduler, Hdhomerun, FileWatch, Geoip); + pub(in crate::api) struct UpdateChanges { - scheduler: bool, - hdhomerun: bool, - file_watch: bool, - geoip: bool, + flags: UpdateChangesFlagsSet, targets: Option>, } impl UpdateChanges { pub(in crate::api) fn modified(&self) -> bool { - self.scheduler || self.hdhomerun || self.file_watch || self.geoip + !self.flags.is_empty() + } + + fn set_flag_if(&mut self, condition: bool, flag: UpdateChangesFlags) { + if condition { + self.flags.set(flag); + } } } @@ -142,9 +147,9 @@ fn cancel_services(app_state: &Arc, changes: &UpdateChanges) { } let cancel_tokens = app_state.cancel_tokens.load(); - let scheduler = cancel_service!(scheduler, changes, cancel_tokens); - let hdhomerun = cancel_service!(hdhomerun, changes, cancel_tokens); - let file_watch = cancel_service!(file_watch, changes, cancel_tokens); + let scheduler = cancel_service!(scheduler, UpdateChangesFlags::Scheduler, changes, cancel_tokens); + let hdhomerun = cancel_service!(hdhomerun, UpdateChangesFlags::Hdhomerun, changes, cancel_tokens); + let file_watch = cancel_service!(file_watch, UpdateChangesFlags::FileWatch, changes, cancel_tokens); let tokens = CancelTokens { scheduler, @@ -159,7 +164,7 @@ fn start_services(app_state: &Arc, changes: &UpdateChanges) { if !changes.modified() { return; } - if changes.scheduler { + if changes.flags.contains(UpdateChangesFlags::Scheduler) { exec_scheduler( &Arc::clone(&app_state.http_client.load()), app_state, @@ -168,7 +173,7 @@ fn start_services(app_state: &Arc, changes: &UpdateChanges) { ); } - if changes.hdhomerun && app_state.app_config.api_proxy.load().is_some() { + if changes.flags.contains(UpdateChangesFlags::Hdhomerun) && app_state.app_config.api_proxy.load().is_some() { let mut infos = Vec::new(); crate::api::main_api::start_hdhomerun( &app_state.app_config, @@ -178,7 +183,7 @@ fn start_services(app_state: &Arc, changes: &UpdateChanges) { ); } - if changes.file_watch { + if changes.flags.contains(UpdateChangesFlags::FileWatch) { exec_config_watch(app_state, &app_state.cancel_tokens.load().file_watch); } } @@ -347,7 +352,7 @@ impl AppState { self.active_provider.update_config(&self.app_config).await; self.update_config().await?; - if changes.geoip { + if changes.flags.contains(UpdateChangesFlags::Geoip) { let new_geoip = if use_geoip { let path = get_geoip_path(&working_dir); let _file_lock = self.app_config.file_locks.read_lock(&path).await; @@ -429,13 +434,15 @@ impl AppState { let geoip_enabled = config.is_geoip_enabled(); let geoip_enabled_old = old_config.is_geoip_enabled(); - UpdateChanges { - scheduler: changed_schedules, - hdhomerun: changed_hdhomerun, - file_watch: changed_file_watch, + let mut changes = UpdateChanges { + flags: UpdateChangesFlagsSet::new(), targets: None, - geoip: geoip_enabled != geoip_enabled_old, - } + }; + changes.set_flag_if(changed_schedules, UpdateChangesFlags::Scheduler); + changes.set_flag_if(changed_hdhomerun, UpdateChangesFlags::Hdhomerun); + changes.set_flag_if(changed_file_watch, UpdateChangesFlags::FileWatch); + changes.set_flag_if(geoip_enabled != geoip_enabled_old, UpdateChangesFlags::Geoip); + changes } fn detect_changes_for_sources(&self, sources: &SourcesConfig) -> UpdateChanges { @@ -498,13 +505,12 @@ impl AppState { (file_watch_changed, target_changes) }; - UpdateChanges { - scheduler: false, - hdhomerun: false, - file_watch: file_watch_changed, - geoip: false, + let mut changes = UpdateChanges { + flags: UpdateChangesFlagsSet::new(), targets: Some(target_changes), - } + }; + changes.set_flag_if(file_watch_changed, UpdateChangesFlags::FileWatch); + changes } pub async fn cache_playlist(&self, target_name: &str, playlist: PlaylistStorage) { @@ -560,11 +566,7 @@ fn schedules_changed(a: &[ScheduleConfig], b: &[ScheduleConfig]) -> bool { } fn hdhomerun_changed(a: &HdHomeRunConfig, b: &HdHomeRunConfig) -> bool { - if a.enabled != b.enabled - || a.auth != b.auth - || a.ssdp_discovery != b.ssdp_discovery - || a.proprietary_discovery != b.proprietary_discovery - { + if a.flags != b.flags { return true; } if !small_vecs_equal_unordered(a.devices.as_ref(), b.devices.as_ref()) { @@ -582,4 +584,4 @@ pub struct HdHomerunAppState { pub app_state: Arc, pub device: Arc, pub hd_scan_state: Arc, -} \ No newline at end of file +} diff --git a/backend/src/api/model/metadata_update_manager.rs b/backend/src/api/model/metadata_update_manager.rs index b60b88058..82831a6de 100644 --- a/backend/src/api/model/metadata_update_manager.rs +++ b/backend/src/api/model/metadata_update_manager.rs @@ -4,8 +4,8 @@ use crate::processing::processor::{update_generic_stream_metadata, update_live_s use crate::utils::debug_if_enabled; use dashmap::mapref::entry::Entry; use dashmap::DashMap; -use log::{debug, error, info, trace, warn}; -use shared::create_bit_set; +use log::{debug, error, info, warn}; +use shared::create_bitset; use shared::error::TuliproxError; use shared::model::{InputType, LiveStreamProperties, PlaylistItemType, SeriesStreamProperties, UUIDType, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; use shared::utils::generate_playlist_uuid; @@ -21,7 +21,6 @@ use crate::repository::{persist_input_live_info_batch, persist_input_series_info use crate::utils::FileReadGuard; use std::cmp::min; use std::collections::{HashMap, HashSet}; -use crate::utils::trace_if_enabled; const QUEUE_LOG_INTERVAL: Duration = Duration::from_secs(30); const PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(15); @@ -31,7 +30,7 @@ const MAX_QUEUE_SIZE: usize = 100_000; const TASK_ERR_NO_CONNECTION: &str = "No connection available"; const TASK_ERR_PREEMPTED: &str = "Task preempted"; -create_bit_set!(u32, ResolveReason, Info, Tmdb, Date, Probe, MissingDetails); +create_bitset!(u8, ResolveReason, Info, Tmdb, Date, Probe, MissingDetails); /// `PlaylistItemIdType` ID can be either a String (M3U) or u32 (Xtream/TargetDB) #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -398,7 +397,7 @@ impl InputWorker { } else if Self::is_series_task_key(¤t_key) { processed_series_count += 1; } - trace!("Processed metadata task for input {input_name}: {current_task} (changed={task_changed})"); + debug!("Processed metadata task for input {input_name}: {current_task} (changed={task_changed})"); retry_attempts.remove(¤t_key); if last_progress_log_at.elapsed() >= PROGRESS_LOG_INTERVAL { // current_key is removed from pending_tasks later in this loop iteration; @@ -1203,7 +1202,7 @@ impl InputWorker { .and_then(|handle| handle.allocation.get_provider_config()); let name_display = item_title.as_deref().map_or(String::new(), |n| format!(" \"{n}\"")); - trace_if_enabled!("Processing task for {}: {}{}", input_name, task, name_display); + debug!("Processing task for {input_name}: {task}{name_display}"); let pre_vod_updates = collector.vod.len(); let pre_series_updates = collector.series.len(); @@ -1308,6 +1307,7 @@ impl InputWorker { item_title, false, // Batch collect fetch_info, + reason.contains(ResolveReason::Probe), query_opt, ).await { Ok(Some(props)) => { diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index c2d8619a0..64e1201ba 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -12,6 +12,7 @@ use futures::{StreamExt, TryStreamExt}; use log::{debug, log_enabled, warn}; use reqwest::header::{HeaderMap, RANGE}; use reqwest::StatusCode; +use shared::create_bitset; use shared::model::{PlaylistItemType, DEFAULT_USER_AGENT}; use shared::utils::{filter_request_header, sanitize_sensitive_info}; use std::collections::HashMap; @@ -26,21 +27,26 @@ use crate::api::model::streams::client_stream::ClientStream; const RETRY_SECONDS: u64 = 5; const ERR_MAX_RETRY_COUNT: u32 = 5; -#[allow(clippy::struct_excessive_bools)] +create_bitset!( + u8, + ProviderStreamFactoryFlags, + ReconnectEnabled, + BufferEnabled, + ShareStream, + PipeStream, + RangeRequested +); + #[derive(Debug, Clone)] pub struct ProviderStreamFactoryOptions { addr: SocketAddr, // item_type: PlaylistItemType, - reconnect_enabled: bool, - buffer_enabled: bool, + flags: ProviderStreamFactoryFlagsSet, buffer_size: usize, - share_stream: bool, - pipe_stream: bool, url: Url, headers: HeaderMap, default_user_agent: Option, range_bytes: Arc>, - range_requested: bool, reconnect_flag: Arc, provider: Option>, } @@ -89,21 +95,33 @@ impl ProviderStreamFactoryOptions { } else { Arc::new(Some(AtomicUsize::new(requested_range.unwrap_or(0)))) }; + let mut flags = ProviderStreamFactoryFlagsSet::new(); + if stream_options.stream_retry { + flags.set(ProviderStreamFactoryFlags::ReconnectEnabled); + } + if stream_options.pipe_provider_stream { + flags.set(ProviderStreamFactoryFlags::PipeStream); + } + if stream_options.buffer_enabled { + flags.set(ProviderStreamFactoryFlags::BufferEnabled); + } + if share_stream { + flags.set(ProviderStreamFactoryFlags::ShareStream); + } + if requested_range.is_some() { + flags.set(ProviderStreamFactoryFlags::RangeRequested); + } Self { // item_type, addr, - reconnect_enabled: stream_options.stream_retry, - pipe_stream: stream_options.pipe_provider_stream, - buffer_enabled: stream_options.buffer_enabled, + flags, buffer_size, - share_stream, reconnect_flag: Arc::new(AtomicOnceFlag::new()), url, headers, default_user_agent, range_bytes, - range_requested: requested_range.is_some(), provider: None, } } @@ -118,17 +136,17 @@ impl ProviderStreamFactoryOptions { #[inline] fn is_piped(&self) -> bool { - self.pipe_stream + self.flags.contains(ProviderStreamFactoryFlags::PipeStream) } #[inline] fn is_buffer_enabled(&self) -> bool { - self.buffer_enabled + self.flags.contains(ProviderStreamFactoryFlags::BufferEnabled) } #[inline] fn is_shared_stream(&self) -> bool { - self.share_stream + self.flags.contains(ProviderStreamFactoryFlags::ShareStream) } #[inline] @@ -158,7 +176,8 @@ impl ProviderStreamFactoryOptions { #[inline] pub fn should_reconnect(&self) -> bool { - self.reconnect_enabled + self.flags + .contains(ProviderStreamFactoryFlags::ReconnectEnabled) } #[inline] @@ -190,7 +209,7 @@ impl ProviderStreamFactoryOptions { #[inline] pub fn was_range_requested(&self) -> bool { - self.range_requested + self.flags.contains(ProviderStreamFactoryFlags::RangeRequested) } } @@ -612,9 +631,9 @@ pub async fn create_provider_stream( Ok(Some((init_stream, info))) => { let is_media_stream_or_not_piped = if let Some((headers, _, _, _custom_video_type)) = &info { // if it is piped or no video stream, then we don't reconnect - !stream_options.pipe_stream && classify_content_type(headers) == MimeCategory::Video + !stream_options.is_piped() && classify_content_type(headers) == MimeCategory::Video } else { - !stream_options.pipe_stream // don't know what it is but lets assume it is something + !stream_options.is_piped() // don't know what it is but lets assume it is something }; let continue_signal = stream_options.get_reconnect_flag_clone(); diff --git a/backend/src/api/model/xtream.rs b/backend/src/api/model/xtream.rs index fbf940dbb..69b5c3c00 100644 --- a/backend/src/api/model/xtream.rs +++ b/backend/src/api/model/xtream.rs @@ -87,8 +87,16 @@ impl XtreamAuthorizationResponse { }, server_info: XtreamServerInfoResponse { url: server_info.host.clone(), - port: if server_info.protocol == "http" { server_info.port.as_ref().map_or("80", |v| v.as_str()).to_string() } else { String::from("80") }, - https_port: if server_info.protocol == "https" { server_info.port.as_ref().map_or("443", |v| v.as_str()).to_string() } else { String::from("443") }, + port: if server_info.protocol == "http" { + server_info.port.as_deref().unwrap_or("80").to_string() + } else { + String::from("80") + }, + https_port: if server_info.protocol == "https" { + server_info.port.as_deref().unwrap_or("443").to_string() + } else { + String::from("443") + }, server_protocol: server_info.protocol.clone(), rtmp_port: String::new(), timezone: server_info.timezone.clone(), diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 1986b9a43..712cf2eb4 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -20,6 +20,7 @@ use log::{error, warn}; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared::concat_string; +use shared::create_bitset; use shared::error::{info_err, info_err_res, TuliproxError}; use shared::model::{ ConfigInputAliasDto, InputType, PanelApiAliasPoolSizeValue, @@ -328,45 +329,53 @@ fn validate_client_adult_content_params( Ok(()) } -#[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Clone, Copy)] -struct PanelApiOptionalFlags { - account_info: bool, - client_new: bool, - client_renew: bool, - adult_content: bool, -} +create_bitset!( + u8, + PanelApiOptionalFlags, + AccountInfo, + ClientNew, + ClientRenew, + AdultContent +); fn resolve_panel_api_optional_flags( cfg: &PanelApiConfig, input_name: &str, -) -> PanelApiOptionalFlags { - let flags = PanelApiOptionalFlags { - account_info: !cfg.query_parameter.account_info.is_empty(), - client_new: !cfg.query_parameter.client_new.is_empty(), - client_renew: !cfg.query_parameter.client_renew.is_empty(), - adult_content: !cfg.query_parameter.client_adult_content.is_empty(), - }; +) -> PanelApiOptionalFlagsSet { + let mut flags = PanelApiOptionalFlagsSet::new(); + if !cfg.query_parameter.account_info.is_empty() { + flags.set(PanelApiOptionalFlags::AccountInfo); + } + if !cfg.query_parameter.client_new.is_empty() { + flags.set(PanelApiOptionalFlags::ClientNew); + } + if !cfg.query_parameter.client_renew.is_empty() { + flags.set(PanelApiOptionalFlags::ClientRenew); + } + if !cfg.query_parameter.client_adult_content.is_empty() { + flags.set(PanelApiOptionalFlags::AdultContent); + } + let name = sanitize_sensitive_info(input_name); - if !flags.client_renew { + if !flags.contains(PanelApiOptionalFlags::ClientRenew) { debug_if_enabled!( "panel_api request for client_renew disabled due to missing arguments for {}", name ); } - if !flags.client_new { + if !flags.contains(PanelApiOptionalFlags::ClientNew) { debug_if_enabled!( "panel_api request for client_new disabled due to missing arguments for {}", name ); } - if !flags.adult_content { + if !flags.contains(PanelApiOptionalFlags::AdultContent) { debug_if_enabled!( "panel_api request for client_adult_content disabled due to missing arguments for {}", name ); } - if !flags.account_info { + if !flags.contains(PanelApiOptionalFlags::AccountInfo) { debug_if_enabled!( "panel_api request for account_info disabled due to missing arguments for {}", name @@ -1798,12 +1807,12 @@ async fn try_renew_expired_account( is_batch: bool, sources_path: &Path, treat_missing_exp_date_as_expired: bool, - optional: PanelApiOptionalFlags, + optional: PanelApiOptionalFlagsSet, ) -> Option { - if !optional.client_renew { + if !optional.contains(PanelApiOptionalFlags::ClientRenew) { return None; } - let adult_enabled = optional.adult_content; + let adult_enabled = optional.contains(PanelApiOptionalFlags::AdultContent); let mut candidates = collect_accounts(input); for acct in &mut candidates { if treat_missing_exp_date_as_expired && acct.exp_date.is_none() { @@ -1937,12 +1946,12 @@ async fn try_create_new_account( panel_cfg: &PanelApiConfig, is_batch: bool, sources_path: &Path, - optional: PanelApiOptionalFlags, + optional: PanelApiOptionalFlagsSet, ) -> Option { - if !optional.client_new { + if !optional.contains(PanelApiOptionalFlags::ClientNew) { return None; } - let adult_enabled = optional.adult_content; + let adult_enabled = optional.contains(PanelApiOptionalFlags::AdultContent); match panel_client_new(app_state, panel_cfg).await { Ok((username, password, base_url_from_resp)) => { let base_url = base_url_from_resp.unwrap_or_else(|| input.url.clone()); @@ -2154,15 +2163,15 @@ async fn ensure_alias_pool_min( sources_yml_patches: &mut Vec, time_ctx: Option<&PanelApiTimeContext>, effective_now: u64, - optional: PanelApiOptionalFlags, + optional: PanelApiOptionalFlagsSet, ) -> (bool, u16) { if min_pool == 0 { return (false, 0); } - let renew_enabled = optional.client_renew; - let new_enabled = optional.client_new; - let adult_enabled = optional.adult_content; + let renew_enabled = optional.contains(PanelApiOptionalFlags::ClientRenew); + let new_enabled = optional.contains(PanelApiOptionalFlags::ClientNew); + let adult_enabled = optional.contains(PanelApiOptionalFlags::AdultContent); if !renew_enabled && !new_enabled { return (false, 0); } @@ -2620,9 +2629,9 @@ async fn sync_panel_api_for_input_on_boot( .unwrap_or(0); let min_pool = resolve_alias_pool_min(app_state.as_ref(), &input.name, panel_cfg); - let renew_enabled = optional.client_renew; - let new_enabled = optional.client_new; - let adult_enabled = optional.adult_content; + let renew_enabled = optional.contains(PanelApiOptionalFlags::ClientRenew); + let new_enabled = optional.contains(PanelApiOptionalFlags::ClientNew); + let adult_enabled = optional.contains(PanelApiOptionalFlags::AdultContent); let provisioning_enabled = renew_enabled || new_enabled; // Refresh/provision credentials on boot/update. // Root is handled first (may affect desired_aliases and avoids over-provisioning). @@ -3592,7 +3601,7 @@ async fn sync_panel_api_for_input_on_boot( } } - if optional.account_info { + if optional.contains(PanelApiOptionalFlags::AccountInfo) { let creds = accounts .first() .map(|acct| (acct.username.as_str(), acct.password.as_str())); diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index e3b6bca3f..bac496d2d 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -1,5 +1,5 @@ use crate::api::model::TransportStreamBuffer; -use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, Mappings, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput}; +use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput}; use crate::utils; use crate::utils::ffmpeg::check_ffprobe_availability; use arc_swap::{ArcSwap, ArcSwapOption}; @@ -98,7 +98,10 @@ impl AppConfig { let check_homerun = { let config = self.config.load(); self.hdhomerun.store(config.hdhomerun.as_ref().map(|h| Arc::new(h.clone()))); - config.hdhomerun.as_ref().is_some_and(|h| h.enabled) + config + .hdhomerun + .as_ref() + .is_some_and(|h| h.flags.contains(HdHomeRunFlags::Enabled)) }; let sources = self.sources.load(); for source in &sources.sources { @@ -437,4 +440,3 @@ impl AppConfig { }).await } } - diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index 1061a6579..99c6b3dbb 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -1,5 +1,5 @@ use crate::model::{macros, ConfigApi, LibraryConfig, ReverseProxyConfig, ReverseProxyDisabledHeaderConfig, ScheduleConfig}; -use crate::model::{HdHomeRunConfig, IpCheckConfig, LogConfig, MessagingConfig, ProxyConfig, VideoConfig, WebUiConfig}; +use crate::model::{HdHomeRunConfig, HdHomeRunFlags, IpCheckConfig, LogConfig, MessagingConfig, ProxyConfig, VideoConfig, WebUiConfig}; use crate::utils; use log::{error, info}; use path_clean::PathClean; @@ -146,7 +146,7 @@ impl Config { pub fn get_hdhr_device_overview(&self) -> Option { self.hdhomerun.as_ref().map(|hdhr| HdHomeRunDeviceOverview { - enabled: hdhr.enabled, + enabled: hdhr.flags.contains(HdHomeRunFlags::Enabled), devices: hdhr.devices.iter().map(|d| d.name.clone()).collect::>(), }) } @@ -206,4 +206,4 @@ impl From<&ConfigDto> for Config { library: dto.library.as_ref().map(Into::into), } } -} \ No newline at end of file +} diff --git a/backend/src/model/config/hdhomerun.rs b/backend/src/model/config/hdhomerun.rs index aae3cb4d5..379ed78df 100644 --- a/backend/src/model/config/hdhomerun.rs +++ b/backend/src/model/config/hdhomerun.rs @@ -1,5 +1,6 @@ use shared::model::{HdHomeRunConfigDto, HdHomeRunDeviceConfigDto}; use crate::model::macros; +use shared::{apply_flags, create_bitset}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct HdHomeRunDeviceConfig { @@ -41,25 +42,35 @@ impl From<&HdHomeRunDeviceConfigDto> for HdHomeRunDeviceConfig { } } +create_bitset!( + u8, + HdHomeRunFlags, + Enabled, + Auth, + SsdpDiscovery, + ProprietaryDiscovery +); + #[derive(Debug, Clone, PartialEq, Eq)] -#[allow(clippy::struct_excessive_bools)] pub struct HdHomeRunConfig { - pub enabled: bool, - pub auth: bool, - pub ssdp_discovery: bool, - pub proprietary_discovery: bool, + pub flags: HdHomeRunFlagsSet, pub devices: Vec, } macros::from_impl!(HdHomeRunConfig); impl From<&HdHomeRunConfigDto> for HdHomeRunConfig { fn from(dto: &HdHomeRunConfigDto) -> Self { + let mut flags = HdHomeRunFlagsSet::new(); + apply_flags!( + dto, flags, HdHomeRunFlags; + (enabled, Enabled), + (auth, Auth), + (ssdp_discovery, SsdpDiscovery), + (proprietary_discovery, ProprietaryDiscovery) + ); Self { - enabled: dto.enabled, - auth: dto.auth, - ssdp_discovery: dto.ssdp_discovery, - proprietary_discovery: dto.proprietary_discovery, + flags, devices: dto.devices.iter().map(Into::into).collect(), } } -} \ No newline at end of file +} diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 554a324d3..e383d0f3d 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -2,6 +2,7 @@ use crate::model::{macros, ConfigProvider, EpgConfig, PanelApiConfig}; use crate::repository::get_csv_file_path; use chrono::Utc; use log::warn; +use shared::{apply_flags, create_bitset}; use shared::error::TuliproxError; use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto}; use shared::utils::{get_credentials_from_url, parse_provider_scheme_url_parts, sanitize_sensitive_info, Internable, PROVIDER_SCHEME_PREFIX}; @@ -14,29 +15,101 @@ use std::path::PathBuf; use std::sync::Arc; use url::Url; -#[allow(clippy::struct_excessive_bools)] +create_bitset!( + u16, + ConfigInputFlags, + XtreamSkipLive, + XtreamSkipVod, + XtreamSkipSeries, + XtreamLiveStreamUsePrefix, + XtreamLiveStreamWithoutExtension, + ResolveTmdb, + ResolveBackground, + ResolveSeries, + ResolveVod, + ProbeSeries, + ProbeVod, + ProbeLive +); + +impl ConfigInputFlagsSet { + #[inline] + pub fn contains_any(&self, other: Self) -> bool { + (self.0 & other.0) != 0 + } + + #[inline] + pub fn contains_all(&self, other: Self) -> bool { + (self.0 & other.0) == other.0 + } +} + +impl std::ops::BitOr for ConfigInputFlags { + type Output = ConfigInputFlagsSet; + + #[inline] + fn bitor(self, rhs: Self) -> Self::Output { + ConfigInputFlagsSet::from_variants(&[self, rhs]) + } +} + +impl std::ops::BitOr for ConfigInputFlagsSet { + type Output = Self; + + #[inline] + fn bitor(mut self, rhs: ConfigInputFlags) -> Self::Output { + self.set(rhs); + self + } +} + #[derive(Debug, Clone)] pub struct ConfigInputOptions { - pub xtream_skip_live: bool, - pub xtream_skip_vod: bool, - pub xtream_skip_series: bool, - pub xtream_live_stream_use_prefix: bool, - pub xtream_live_stream_without_extension: bool, - pub resolve_tmdb: bool, - pub probe_stream: bool, + pub flags: ConfigInputFlagsSet, + pub resolve_delay: u16, + pub probe_live_interval_hours: u32, } macros::from_impl!(ConfigInputOptions); +impl ConfigInputOptions { + #[inline] + pub fn has_flag(&self, flag: ConfigInputFlags) -> bool { + self.flags.contains(flag) + } + + #[inline] + pub fn has_any_flags(&self, flags: ConfigInputFlagsSet) -> bool { + self.flags.contains_any(flags) + } + + #[inline] + pub fn has_all_flags(&self, flags: ConfigInputFlagsSet) -> bool { + self.flags.contains_all(flags) + } +} impl From<&ConfigInputOptionsDto> for ConfigInputOptions { fn from(dto: &ConfigInputOptionsDto) -> Self { + let mut flags = ConfigInputFlagsSet::new(); + apply_flags!( + dto, flags, ConfigInputFlags; + (xtream_skip_live, XtreamSkipLive), + (xtream_skip_vod, XtreamSkipVod), + (xtream_skip_series, XtreamSkipSeries), + (xtream_live_stream_use_prefix, XtreamLiveStreamUsePrefix), + (xtream_live_stream_without_extension, XtreamLiveStreamWithoutExtension), + (resolve_tmdb, ResolveTmdb), + (resolve_background, ResolveBackground), + (resolve_series, ResolveSeries), + (resolve_vod, ResolveVod), + (probe_series, ProbeSeries), + (probe_vod, ProbeVod), + (probe_live, ProbeLive) + ); + Self { - xtream_skip_live: dto.xtream_skip_live, - xtream_skip_vod: dto.xtream_skip_vod, - xtream_skip_series: dto.xtream_skip_series, - xtream_live_stream_use_prefix: dto.xtream_live_stream_use_prefix, - xtream_live_stream_without_extension: dto.xtream_live_stream_without_extension, - resolve_tmdb: dto.resolve_tmdb, - probe_stream: dto.probe_stream, + flags, + resolve_delay: dto.resolve_delay, + probe_live_interval_hours: dto.probe_live_interval_hours, } } } @@ -159,6 +232,36 @@ pub struct ConfigInput { } impl ConfigInput { + #[inline] + pub fn has_flag(&self, flag: ConfigInputFlags) -> bool { + self.options.as_ref().is_some_and(|o| o.has_flag(flag)) + } + + #[inline] + pub fn has_flag_or(&self, flag: ConfigInputFlags, default: bool) -> bool { + self.options.as_ref().map_or(default, |o| o.has_flag(flag)) + } + + #[inline] + pub fn has_any_flags(&self, flags: ConfigInputFlagsSet) -> bool { + self.options.as_ref().is_some_and(|o| o.has_any_flags(flags)) + } + + #[inline] + pub fn has_any_flags_or(&self, flags: ConfigInputFlagsSet, default: bool) -> bool { + self.options.as_ref().map_or(default, |o| o.has_any_flags(flags)) + } + + #[inline] + pub fn has_all_flags(&self, flags: ConfigInputFlagsSet) -> bool { + self.options.as_ref().is_some_and(|o| o.has_all_flags(flags)) + } + + #[inline] + pub fn has_all_flags_or(&self, flags: ConfigInputFlagsSet, default: bool) -> bool { + self.options.as_ref().map_or(default, |o| o.has_all_flags(flags)) + } + pub fn prepare(&mut self, provider_configs: &[Arc]) -> Result, TuliproxError> { let mut used_provider_configs: Vec> = vec![]; let batch_file_path = self.prepare_batch(); diff --git a/backend/src/model/config/target.rs b/backend/src/model/config/target.rs index cadd63dd3..befe56aac 100644 --- a/backend/src/model/config/target.rs +++ b/backend/src/model/config/target.rs @@ -9,10 +9,10 @@ use shared::model::PlaylistItemType; use std::sync::Arc; use shared::foundation::Filter; use shared::foundation::ValueProvider; -use shared::create_bit_set; +use shared::{apply_flags, create_bitset}; -create_bit_set!(u16, XtreamTargetFlags, SkipLiveDirectSource, SkipVideoDirectSource, SkipSeriesDirectSource, - ResolveBackground, ResolveSeries, ResolveVod, ProbeSeries, ProbeVod, ProbeLive); +create_bitset!(u8, XtreamTargetFlags, SkipLiveDirectSource, SkipVideoDirectSource, SkipSeriesDirectSource); +create_bitset!(u8, StrmTargetFlags, Flat, UnderscoreWhitespace, Cleanup, AddQualityToFilename); #[derive(Clone, Debug)] pub struct ProcessTargets { @@ -32,12 +32,9 @@ impl ProcessTargets { } } -#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub struct XtreamTargetOutput { pub flags: XtreamTargetFlagsSet, - pub probe_live_interval_hours: u32, - pub resolve_delay: u16, pub trakt: Option, pub filter: Option, } @@ -46,20 +43,15 @@ macros::from_impl!(XtreamTargetOutput); impl From<&XtreamTargetOutputDto> for XtreamTargetOutput { fn from(dto: &XtreamTargetOutputDto) -> Self { let mut flags = XtreamTargetFlagsSet::new(); - if dto.skip_live_direct_source { flags.add(XtreamTargetFlags::SkipLiveDirectSource); } - if dto.skip_video_direct_source { flags.add(XtreamTargetFlags::SkipVideoDirectSource); } - if dto.skip_series_direct_source { flags.add(XtreamTargetFlags::SkipSeriesDirectSource); } - if dto.resolve_background { flags.add(XtreamTargetFlags::ResolveBackground); } - if dto.resolve_series { flags.add(XtreamTargetFlags::ResolveSeries); } - if dto.resolve_vod { flags.add(XtreamTargetFlags::ResolveVod); } - if dto.probe_series { flags.add(XtreamTargetFlags::ProbeSeries); } - if dto.probe_vod { flags.add(XtreamTargetFlags::ProbeVod); } - if dto.probe_live { flags.add(XtreamTargetFlags::ProbeLive); } + apply_flags!( + dto, flags, XtreamTargetFlags; + (skip_live_direct_source, SkipLiveDirectSource), + (skip_video_direct_source, SkipVideoDirectSource), + (skip_series_direct_source, SkipSeriesDirectSource) + ); Self { flags, - resolve_delay: dto.resolve_delay, - probe_live_interval_hours: dto.probe_live_interval_hours, trakt: dto.trakt.as_ref().map(Into::into), filter: dto.t_filter.clone(), } @@ -72,14 +64,6 @@ impl From<&XtreamTargetOutput> for XtreamTargetOutputDto { skip_live_direct_source: instance.flags.contains(XtreamTargetFlags::SkipLiveDirectSource), skip_video_direct_source: instance.flags.contains(XtreamTargetFlags::SkipVideoDirectSource), skip_series_direct_source: instance.flags.contains(XtreamTargetFlags::SkipSeriesDirectSource), - resolve_background: instance.flags.contains(XtreamTargetFlags::ResolveBackground), - resolve_series: instance.flags.contains(XtreamTargetFlags::ResolveSeries), - resolve_vod: instance.flags.contains(XtreamTargetFlags::ResolveVod), - probe_series: instance.flags.contains(XtreamTargetFlags::ProbeSeries), - probe_vod: instance.flags.contains(XtreamTargetFlags::ProbeVod), - probe_live: instance.flags.contains(XtreamTargetFlags::ProbeLive), - resolve_delay: instance.resolve_delay, - probe_live_interval_hours: instance.probe_live_interval_hours, trakt: instance.trakt.as_ref().map(TraktConfigDto::from), filter: instance.filter.as_ref().map(ToString::to_string), t_filter: instance.filter.clone(), @@ -120,17 +104,13 @@ impl From<&M3uTargetOutput> for M3uTargetOutputDto { #[derive(Debug, Clone)] -#[allow(clippy::struct_excessive_bools)] pub struct StrmTargetOutput { pub directory: String, pub username: Option, pub style: StrmExportStyle, - pub flat: bool, - pub underscore_whitespace: bool, - pub cleanup: bool, + pub flags: StrmTargetFlagsSet, pub strm_props: Option>, pub filter: Option, - pub add_quality_to_filename: bool, pub probe_probe_size_bytes: Option, pub probe_analyze_duration: Option, } @@ -138,16 +118,21 @@ pub struct StrmTargetOutput { macros::from_impl!(StrmTargetOutput); impl From<&StrmTargetOutputDto> for StrmTargetOutput { fn from(dto: &StrmTargetOutputDto) -> Self { + let mut flags = StrmTargetFlagsSet::new(); + apply_flags!( + dto, flags, StrmTargetFlags; + (flat, Flat), + (underscore_whitespace, UnderscoreWhitespace), + (cleanup, Cleanup), + (add_quality_to_filename, AddQualityToFilename) + ); Self { directory: dto.directory.clone(), username: dto.username.clone(), style: dto.style, - flat: dto.flat, - underscore_whitespace: dto.underscore_whitespace, - cleanup: dto.cleanup, + flags, strm_props: dto.strm_props.clone(), filter: dto.t_filter.clone(), - add_quality_to_filename: dto.add_quality_to_filename, probe_probe_size_bytes: dto.probe_probe_size_bytes, probe_analyze_duration: dto.probe_analyze_duration, } @@ -159,13 +144,13 @@ impl From<&StrmTargetOutput> for StrmTargetOutputDto { directory: instance.directory.clone(), username: instance.username.clone(), style: instance.style, - flat: instance.flat, - underscore_whitespace: instance.underscore_whitespace, - cleanup: instance.cleanup, + flat: instance.flags.contains(StrmTargetFlags::Flat), + underscore_whitespace: instance.flags.contains(StrmTargetFlags::UnderscoreWhitespace), + cleanup: instance.flags.contains(StrmTargetFlags::Cleanup), strm_props: instance.strm_props.clone(), filter: instance.filter.as_ref().map(ToString::to_string), t_filter: instance.filter.clone(), - add_quality_to_filename: instance.add_quality_to_filename, + add_quality_to_filename: instance.flags.contains(StrmTargetFlags::AddQualityToFilename), probe_probe_size_bytes: instance.probe_probe_size_bytes, probe_analyze_duration: instance.probe_analyze_duration, } diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index dae5f71c3..bb2cdc60c 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -3,7 +3,10 @@ use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, XtreamTargetOutput}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use shared::model::{PlaylistItem, PlaylistItemType, PlaylistItemTypeSet, ProxyUserStatus, XtreamMappingOptions}; +use shared::model::{ + PlaylistItem, PlaylistItemType, PlaylistItemTypeSet, ProxyUserStatus, XtreamMappingFlags, + XtreamMappingFlagsSet, XtreamMappingOptions, +}; use shared::utils::{arc_str_serde, deserialize_number_from_string_or_zero}; use enum_iterator::all; use crate::model::XtreamTargetFlags; @@ -45,11 +48,31 @@ pub fn xtream_mapping_option_from_target_options(target: &ConfigTarget, target_o } } + let mut flags = XtreamMappingFlagsSet::new(); + if target_output + .flags + .contains(XtreamTargetFlags::SkipLiveDirectSource) + { + flags.set(XtreamMappingFlags::SkipLiveDirectSource); + } + if target_output + .flags + .contains(XtreamTargetFlags::SkipVideoDirectSource) + { + flags.set(XtreamMappingFlags::SkipVideoDirectSource); + } + if target_output + .flags + .contains(XtreamTargetFlags::SkipSeriesDirectSource) + { + flags.set(XtreamMappingFlags::SkipSeriesDirectSource); + } + if cfg.is_reverse_proxy_resource_rewrite_enabled() { + flags.set(XtreamMappingFlags::RewriteResourceUrl); + } + XtreamMappingOptions { - skip_live_direct_source: target_output.flags.contains(XtreamTargetFlags::SkipLiveDirectSource), - skip_video_direct_source: target_output.flags.contains(XtreamTargetFlags::SkipVideoDirectSource), - skip_series_direct_source: target_output.flags.contains(XtreamTargetFlags::SkipSeriesDirectSource), - rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(), + flags, force_redirect, reverse_item_types, username: user.username.clone(), @@ -84,4 +107,4 @@ pub struct PlaylistXtreamCategory { pub id: u32, #[serde(alias = "category_name")] pub name: String, -} \ No newline at end of file +} diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index b3a71f3be..bb047b669 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -1,4 +1,4 @@ -use crate::model::ConfigInput; +use crate::model::{ConfigInput, ConfigInputFlags}; use crate::model::XtreamCategory; use crate::utils::request::DynReader; use crate::utils::xtream::get_xtream_stream_url_base; @@ -67,8 +67,10 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre series_release_date: Option<&Arc>, ) -> Option> { let url = input.url.as_str(); - let username = input.username.as_ref().map_or("", |v| v); - let password = input.password.as_ref().map_or("", |v| v); + let (username, password) = ( + input.username.as_deref().unwrap_or(""), + input.password.as_deref().unwrap_or(""), + ); if let Some(episodes) = series_info.details.as_ref().and_then(|d| d.episodes.as_ref()) { let result: Vec = episodes.iter().map(|episode| { @@ -149,8 +151,10 @@ pub async fn parse_xtream(input: &ConfigInput, Ok(xtream_categories) => { let input_name = input.name.clone(); let url = input.url.as_str(); - let username = input.username.as_ref().map_or("", |v| v); - let password = input.password.as_ref().map_or("", |v| v); + let (username, password) = ( + input.username.as_deref().unwrap_or(""), + input.password.as_deref().unwrap_or(""), + ); match map_to_xtream_streams(xtream_cluster, streams, &input.name).await { Ok(xtream_streams) => { @@ -162,8 +166,10 @@ pub async fn parse_xtream(input: &ConfigInput, channels: vec![], }; - let (live_stream_use_prefix, live_stream_without_extension) = input.options.as_ref() - .map_or((true, false), |o| (o.xtream_live_stream_use_prefix, o.xtream_live_stream_without_extension)); + let (live_stream_use_prefix, live_stream_without_extension) = ( + input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix), + input.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension), + ); // Re-implement the loop to add ordinal let mut ord_counter: u32 = 1; @@ -234,9 +240,19 @@ where // 2. Prepare for Stream Parsing let input_name = input.name.clone(); let url = input.url.as_str().to_string(); - let username = input.username.as_ref().map_or("", |v| v).to_string(); - let password = input.password.as_ref().map_or("", |v| v).to_string(); - let options = input.options.clone(); + let (username, password) = ( + input.username.as_deref().unwrap_or("").to_string(), + input.password.as_deref().unwrap_or("").to_string(), + ); + let (live_stream_use_prefix, live_stream_without_extension) = input.options.as_ref().map_or( + (false, false), + |o| { + ( + o.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix), + o.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension), + ) + }, + ); // Map categories for lookup let group_map: IndexMap> = xtream_categories.iter().map(|c| (c.category_id, c.category_name.clone())).collect(); @@ -246,9 +262,6 @@ where let reader = tokio_util::io::SyncIoBridge::new(streams); let mut deserializer = serde_json::Deserializer::from_reader(reader); - let (live_stream_use_prefix, live_stream_without_extension) = options.as_ref() - .map_or((true, false), |o| (o.xtream_live_stream_use_prefix, o.xtream_live_stream_without_extension)); - let mut source_ordinal = 0u32; match xtream_cluster { @@ -438,4 +451,4 @@ mod tests { serde::Deserializer::deserialize_any(&mut deserializer, visitor).unwrap(); assert_eq!(count, 1); } -} \ No newline at end of file +} diff --git a/backend/src/processing/processor/mod.rs b/backend/src/processing/processor/mod.rs index e908c26fe..1124cd2f8 100644 --- a/backend/src/processing/processor/mod.rs +++ b/backend/src/processing/processor/mod.rs @@ -14,20 +14,31 @@ pub use self::xtream::*; pub use self::xtream_vod::*; pub use self::xtream_series::*; pub use self::stream_probe::*; -use shared::create_bit_set; +use shared::create_bitset; -create_bit_set!(u16, ResolveOptionsFlags, Resolve, TmdbMissing, Probe, Background); +create_bitset!(u8, ResolveOptionsFlags, Resolve, TmdbMissing, Probe, Background); pub struct ResolveOptions { - pub flags: ResolveOptionsFlagsSet, + flags: ResolveOptionsFlagsSet, pub resolve_delay: u16, } +impl ResolveOptions { + #[inline] + pub fn has_flag(&self, flag: ResolveOptionsFlags) -> bool { + self.flags.contains(flag) + } + #[inline] + pub fn unset_flag(&mut self, flag: ResolveOptionsFlags) { + self.flags.unset(flag); + } +} + impl Default for ResolveOptions { fn default() -> Self { Self { flags: ResolveOptionsFlagsSet::from_variants(&[ResolveOptionsFlags::Background]), - resolve_delay: 0, + resolve_delay: default_resolve_delay_secs(), } } } @@ -41,30 +52,55 @@ macro_rules! create_resolve_options_function_for_xtream_target { ($cluster:ident) => { paste::paste! { fn [](target: &ConfigTarget, fpl: &FetchedPlaylist) -> $crate::processing::processor::ResolveOptions { - // Get input options - let (resolve_tmdb_missing, probe_requested) = fpl.input.options.as_ref().map_or_else(||(false, false), |o| (o.resolve_tmdb, o.probe_stream)); match target.get_xtream_output() { - Some(xtream_output) => { + Some(_) => { + let input_options = fpl.input.options.as_ref(); + let input_is_xtream = fpl.input.input_type.is_xtream(); + + let ( + resolve_tmdb_missing, + input_resolve_enabled, + input_probe_enabled, + input_resolve_delay, + resolve_background + ) = if let Some(options) = input_options { + ( + options.has_flag($crate::model::ConfigInputFlags::ResolveTmdb), + options.has_flag($crate::model::ConfigInputFlags::[]), + options.has_flag($crate::model::ConfigInputFlags::[]), + options.resolve_delay, + options.has_flag($crate::model::ConfigInputFlags::ResolveBackground) + ) + } else { + ( + false, + false, + false, + shared::utils::default_resolve_delay_secs(), + shared::utils::default_resolve_background(), + ) + }; + + let resolve_enabled = input_resolve_enabled; + let probe_enabled = input_probe_enabled; + let resolve_delay = input_resolve_delay; + let mut flags = $crate::processing::processor::ResolveOptionsFlagsSet::new(); - // Map config flags to resolve options flags - if xtream_output.flags.contains($crate::model::XtreamTargetFlags::[]) && fpl.input.input_type.is_xtream() { - flags.add($crate::processing::processor::ResolveOptionsFlags::Resolve); + if resolve_enabled && input_is_xtream { + flags.set($crate::processing::processor::ResolveOptionsFlags::Resolve); } if resolve_tmdb_missing { - flags.add($crate::processing::processor::ResolveOptionsFlags::TmdbMissing); + flags.set($crate::processing::processor::ResolveOptionsFlags::TmdbMissing); } - if probe_requested - && fpl.input.input_type.is_xtream() - && xtream_output.flags.contains($crate::model::XtreamTargetFlags::[]) { - flags.add($crate::processing::processor::ResolveOptionsFlags::Probe); + if input_is_xtream && probe_enabled { + flags.set($crate::processing::processor::ResolveOptionsFlags::Probe); } - if xtream_output.flags.contains($crate::model::XtreamTargetFlags::ResolveBackground) { - flags.add($crate::processing::processor::ResolveOptionsFlags::Background); + if resolve_background { + flags.set($crate::processing::processor::ResolveOptionsFlags::Background); } - $crate::processing::processor::ResolveOptions { flags, - resolve_delay: xtream_output.resolve_delay, + resolve_delay, } }, None => $crate::processing::processor::ResolveOptions::default(), @@ -74,3 +110,4 @@ macro_rules! create_resolve_options_function_for_xtream_target { }; } use create_resolve_options_function_for_xtream_target; +use shared::utils::default_resolve_delay_secs; diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 4b918f0dd..a3dbd8aa3 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -1,20 +1,24 @@ -use crate::model::{AppConfig, ConfigFavourites, ConfigInput, ConfigRename, MessageContent, ReverseProxyDisabledHeaderConfig, TVGuide}; -use crate::utils::{log_memory_snapshot, m3u}; +use crate::model::{ + AppConfig, ConfigFavourites, ConfigInput, ConfigRename, MessageContent, ReverseProxyDisabledHeaderConfig, TVGuide, +}; use crate::utils::xtream; use crate::utils::{epg, StepMeasureCallback}; +use crate::utils::{log_memory_snapshot, m3u}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::{Arc, Weak}; use tokio::sync::{Mutex, OwnedRwLockWriteGuard, RwLock}; use tokio::task::JoinSet; -use crate::api::model::{ActiveProviderManager, EventManager, EventMessage, PlaylistStorageState, UpdateGuard, MetadataUpdateManager, ProviderIdType, ResolveReasonSet, ResolveReason, UpdateTask}; -use crate::messaging::{send_message}; - +use crate::api::model::{ + ActiveProviderManager, EventManager, EventMessage, MetadataUpdateManager, PlaylistStorageState, ProviderIdType, + ResolveReason, ResolveReasonSet, UpdateGuard, UpdateTask, +}; +use crate::messaging::send_message; use crate::model::FetchedPlaylist; use crate::model::Mapping; -use crate::model::{ConfigTarget, ProcessTargets, TargetOutput, XtreamTargetFlags}; +use crate::model::{ConfigInputFlags, ConfigInputOptions, ConfigTarget, ProcessTargets, TargetOutput}; use crate::processing::input_cache; use crate::processing::input_cache::ClusterState; use crate::processing::parser::xmltv::flatten_tvguide; @@ -34,14 +38,18 @@ use indexmap::IndexMap; use log::{debug, error, info, log_enabled, warn, Level}; use shared::concat_string; use shared::error::{get_errors_notify_message, notify_err, TuliproxError}; -use shared::foundation::{get_field_value, set_field_value, ValueAccessor, ValueProvider}; use shared::foundation::Filter; +use shared::foundation::{get_field_value, set_field_value, ValueAccessor, ValueProvider}; use shared::model::xtream_const::XTREAM_CLUSTER; +use shared::model::{ + CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, PlaylistGroup, PlaylistItem, + PlaylistItemType, PlaylistUpdateState, ProcessingOrder, StreamProperties, XtreamCluster, +}; use shared::model::{InputStats, PlaylistStats, SourceStats, TargetStats, UUIDType}; -use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, - PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState, - ProcessingOrder, StreamProperties, XtreamCluster}; -use shared::utils::{create_alias_uuid, default_as_default, interner_gc, Internable}; +use shared::utils::{ + create_alias_uuid, default_as_default, default_probe_live_interval, default_resolve_delay_secs, interner_gc, + Internable, +}; use std::time::Instant; const DEFAULT_PROBE_STREAM_DELAY: u16 = 50; @@ -60,18 +68,24 @@ pub fn apply_filter_to_source(source: &mut dyn PlaylistSource, filter: &Filter) let cat_id = pli.header.category_id; let normalized_group = shared::utils::deunicode_string(&group_title).to_lowercase().intern(); let key = (cluster, normalized_group); - groups.entry(key) + groups + .entry(key) .or_insert_with(|| PlaylistGroup { id: cat_id, title: group_title, channels: vec![], xtream_cluster: cluster, }) - .channels.push(pli); + .channels + .push(pli); } } - if groups.is_empty() { None } else { Some(groups.into_values().collect()) } + if groups.is_empty() { + None + } else { + Some(groups.into_values().collect()) + } } fn filter_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Option> { @@ -81,8 +95,8 @@ fn filter_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Op pub fn apply_filter_to_playlist(playlist: &mut [PlaylistGroup], filter: &Filter) -> Option> { let mut new_playlist = Vec::with_capacity(128); for pg in playlist.iter_mut() { - let channels = pg.channels.iter() - .filter(|&pli| is_valid(pli, filter, false)).cloned().collect::>(); + let channels = + pg.channels.iter().filter(|&pli| is_valid(pli, filter, false)).cloned().collect::>(); if !channels.is_empty() { new_playlist.push(PlaylistGroup { id: pg.id, @@ -92,14 +106,16 @@ pub fn apply_filter_to_playlist(playlist: &mut [PlaylistGroup], filter: &Filter) }); } } - if new_playlist.is_empty() { None } else { Some(new_playlist) } + if new_playlist.is_empty() { + None + } else { + Some(new_playlist) + } } fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { - let assigned_chnos: HashSet = new_playlist.iter().flat_map(|g| &g.channels) - .filter(|c| c.header.chno != 0) - .map(|c| c.header.chno) - .collect(); + let assigned_chnos: HashSet = + new_playlist.iter().flat_map(|g| &g.channels).filter(|c| c.header.chno != 0).map(|c| c.header.chno).collect(); let mut chno = 1; for group in new_playlist { for chan in &mut group.channels { @@ -150,22 +166,23 @@ fn rename_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Op let group_title = pli.header.group.clone(); let cluster = pli.header.xtream_cluster; let cat_id = pli.header.category_id; - groups.entry((cluster, group_title.clone())) + groups + .entry((cluster, group_title.clone())) .or_insert_with(|| PlaylistGroup { id: cat_id, title: group_title, channels: vec![], xtream_cluster: cluster, }) - .channels.push(pli); + .channels + .push(pli); } Some(groups.into_values().collect()) } - _ => None + _ => None, } } - fn map_channel(mut channel: PlaylistItem, mapping: &Mapping) -> (PlaylistItem, Vec, bool) { let mut matched = false; let mut virtual_items = vec![]; @@ -179,7 +196,11 @@ fn map_channel(mut channel: PlaylistItem, mapping: &Mapping) -> (PlaylistItem, V let provider = ValueProvider { pli: ref_chan, match_as_ascii: mapping.match_as_ascii }; if filter.filter(&provider) { matched = true; - let mut accessor = ValueAccessor { pli: ref_chan, virtual_items: vec![], match_as_ascii: mapping.match_as_ascii }; + let mut accessor = ValueAccessor { + pli: ref_chan, + virtual_items: vec![], + match_as_ascii: mapping.match_as_ascii, + }; script.eval(&mut accessor, templates); virtual_items.extend(accessor.virtual_items.into_iter().map(|(_, pli)| pli)); } @@ -214,17 +235,14 @@ fn map_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget) -> Optio for channel in mapped_iter { let group_title = channel.header.group.clone(); let cluster = channel.header.xtream_cluster; - next_groups.entry((cluster, group_title.clone())) + next_groups + .entry((cluster, group_title.clone())) .or_insert_with(|| { grp_id += 1; - PlaylistGroup { - id: grp_id, - title: group_title, - channels: Vec::new(), - xtream_cluster: cluster, - } + PlaylistGroup { id: grp_id, title: group_title, channels: Vec::new(), xtream_cluster: cluster } }) - .channels.push(channel); + .channels + .push(channel); } Some(next_groups.into_values().collect()) @@ -249,7 +267,10 @@ fn map_playlist_counter(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) { let new_value = if counter.modifier == CounterModifier::Assign { padded_cntval } else { - let value = channel.header.get_field(&counter.field).map_or_else(String::new, |field_value| field_value.to_string()); + let value = channel + .header + .get_field(&counter.field) + .map_or_else(String::new, |field_value| field_value.to_string()); if counter.modifier == CounterModifier::Suffix { format!("{value}{}{padded_cntval}", counter.concat) } else { @@ -277,7 +298,7 @@ fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bo } struct PlaylistDownloadResult { - pub downloaded_playlist:Vec, + pub downloaded_playlist: Vec, pub download_err: Vec, pub was_cached: bool, pub persisted: bool, @@ -285,21 +306,20 @@ struct PlaylistDownloadResult { impl PlaylistDownloadResult { pub fn new( - downloaded_playlist:Vec, + downloaded_playlist: Vec, download_err: Vec, was_cached: bool, - persisted: bool) -> Self { - - Self { - downloaded_playlist, - download_err, - was_cached, - persisted, - } + persisted: bool, + ) -> Self { + Self { downloaded_playlist, download_err, was_cached, persisted } } } -async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> PlaylistDownloadResult { +async fn playlist_download_from_input( + client: &reqwest::Client, + app_config: &Arc, + input: &ConfigInput, +) -> PlaylistDownloadResult { let config = &*app_config.config.load(); let working_dir = &config.working_dir; @@ -346,7 +366,9 @@ async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc let (p, e) = m3u::download_m3u_playlist(app_config, client, config, input).await; (p, e, false) } - InputType::Xtream => xtream::download_xtream_playlist(app_config, client, input, clusters_to_download.as_deref()).await, + InputType::Xtream => { + xtream::download_xtream_playlist(app_config, client, input, clusters_to_download.as_deref()).await + } InputType::M3uBatch | InputType::XtreamBatch => (vec![], vec![], false), InputType::Library => { let (p, e) = library::download_library_playlist(client, app_config, input).await; @@ -391,7 +413,11 @@ async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc PlaylistDownloadResult::new(playlist, errors, false, persisted) } -async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> (Vec, Vec, Vec) { +#[allow(clippy::too_many_lines)] +async fn process_source( + source_idx: usize, + ctx: &PlaylistProcessingContext, +) -> (Vec, Vec, Vec) { log_memory_snapshot(format!("source[{source_idx}] start").as_str()); let sources = ctx.config.sources.load(); let mut errors = vec![]; @@ -419,7 +445,10 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( let (mut download_err, playlist, error) = download_input(ctx, input).await; if let Some(err) = error { - broadcast_step("Playlist download", &format!("Failed to persist/load input '{}' playlist", input.name)); + broadcast_step( + "Playlist download", + &format!("Failed to persist/load input '{}' playlist", input.name), + ); error!("Failed to persist input playlist {}", input.name); download_err.push(err); } @@ -443,30 +472,32 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( info!("Source is empty {input_name}"); errors.push(notify_err!("Source is empty {input_name}")); } else { - source_playlists.push( - FetchedPlaylist { - input, - source: playlist_groups, - epg: tvguide, - } + source_playlists.push(FetchedPlaylist { input, source: playlist_groups, epg: tvguide }); + log_memory_snapshot( + format!("source[{source_idx}] input '{}' after_source_push", input.name).as_str(), ); - log_memory_snapshot(format!("source[{source_idx}] input '{}' after_source_push", input.name).as_str()); } let elapsed = start_time.elapsed().as_secs(); - input_stats.insert(input_name.clone(), create_input_stat(group_count, channel_count, errors.len(), - input.input_type, input_name, elapsed)); + input_stats.insert( + input_name.clone(), + create_input_stat(group_count, channel_count, errors.len(), input.input_type, input_name, elapsed), + ); } } if source_downloaded { if source_playlists.is_empty() { debug!("Source at index {source_idx} is empty"); - errors.push(notify_err!("Source at index {source_idx} is empty: {}", source.inputs.iter().map(Clone::clone).collect::>>().join(", "))); + errors.push(notify_err!( + "Source at index {source_idx} is empty: {}", + source.inputs.iter().map(Clone::clone).collect::>>().join(", ") + )); } else { - debug_if_enabled!("Source has {} groups", source_playlists.iter_mut().map(FetchedPlaylist::get_channel_count).sum::()); - let enabled_targets: Vec<_> = source.targets - .iter() - .filter(|target| is_target_enabled(target, &ctx.user_targets)) - .collect(); + debug_if_enabled!( + "Source has {} groups", + source_playlists.iter_mut().map(FetchedPlaylist::get_channel_count).sum::() + ); + let enabled_targets: Vec<_> = + source.targets.iter().filter(|target| is_target_enabled(target, &ctx.user_targets)).collect(); for (idx, target) in enabled_targets.iter().enumerate() { let consume_input_source = idx + 1 == enabled_targets.len(); @@ -474,9 +505,19 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( "Processing target '{}' (use_memory_cache={}, consume_input_source={})", target.name, target.use_memory_cache, consume_input_source ); - log_memory_snapshot(format!("source[{source_idx}] target '{}' before_process", target.name).as_str()); - match process_playlist_for_target(ctx, &mut source_playlists, target, - &mut input_stats, &mut errors, consume_input_source).await { + log_memory_snapshot( + format!("source[{source_idx}] target '{}' before_process", target.name).as_str(), + ); + match process_playlist_for_target( + ctx, + &mut source_playlists, + target, + &mut input_stats, + &mut errors, + consume_input_source, + ) + .await + { Ok(()) => { target_stats.push(TargetStats::success(&target.name)); } @@ -485,7 +526,9 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( errors.append(&mut err); } } - log_memory_snapshot(format!("source[{source_idx}] target '{}' after_process", target.name).as_str()); + log_memory_snapshot( + format!("source[{source_idx}] target '{}' after_process", target.name).as_str(), + ); } } } @@ -494,8 +537,11 @@ async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> ( (input_stats.into_values().collect(), target_stats, errors) } -async fn download_input_epg(ctx: &PlaylistProcessingContext, input: &Arc, - error_list: &mut Vec) -> Option { +async fn download_input_epg( + ctx: &PlaylistProcessingContext, + input: &Arc, + error_list: &mut Vec, +) -> Option { // Download epg for input let (tvguide, mut tvguide_errors) = if error_list.is_empty() { let working_dir = &ctx.config.config.load().working_dir; @@ -507,12 +553,14 @@ async fn download_input_epg(ctx: &PlaylistProcessingContext, input: &Arc) - -> (Vec, Box, Option) { +async fn download_input( + ctx: &PlaylistProcessingContext, + input: &Arc, +) -> (Vec, Box, Option) { // Coordination Logic let need_download = !ctx.is_input_downloaded(&input.name).await; - let playlist_download_result= if need_download { + let playlist_download_result = if need_download { // Acquire named lock to prevent thundering herd on same input let _input_lock = ctx.get_input_lock(&input.name).await; // Check again after lock @@ -557,24 +605,24 @@ fn create_broadcast_callback(event_manager: Option<&Arc>) -> StepM } } -fn create_input_stat(group_count: usize, channel_count: usize, error_count: usize, input_type: InputType, input_name: &str, secs_took: u64) -> InputStats { +fn create_input_stat( + group_count: usize, + channel_count: usize, + error_count: usize, + input_type: InputType, + input_name: &str, + secs_took: u64, +) -> InputStats { InputStats { name: input_name.to_string(), input_type, error_count, - raw_stats: PlaylistStats { - group_count, - channel_count, - }, - processed_stats: PlaylistStats { - group_count: 0, - channel_count: 0, - }, + raw_stats: PlaylistStats { group_count, channel_count }, + processed_stats: PlaylistStats { group_count: 0, channel_count: 0 }, secs_took, } } - #[derive(Clone)] pub struct PlaylistProcessingContext { pub client: reqwest::Client, @@ -607,13 +655,11 @@ impl PlaylistProcessingContext { pub async fn get_input_lock(&self, input_name: &Arc) -> OwnedRwLockWriteGuard<()> { let mut locks = self.input_locks.lock().await; // Try to upgrade the existing weak reference - let lock = locks.get(input_name) - .and_then(Weak::upgrade) - .unwrap_or_else(|| { - let new_lock = Arc::new(RwLock::new(())); - locks.insert(input_name.clone(), Arc::downgrade(&new_lock)); - new_lock - }); + let lock = locks.get(input_name).and_then(Weak::upgrade).unwrap_or_else(|| { + let new_lock = Arc::new(RwLock::new(())); + locks.insert(input_name.clone(), Arc::downgrade(&new_lock)); + new_lock + }); // Clean up stale references periodically locks.retain(|_, weak| weak.strong_count() > 0); @@ -654,8 +700,7 @@ async fn process_sources(processing_ctx: &PlaylistProcessingContext) -> (Vec (Vec ProcessingPipe { ProcessingOrder::Rfm => vec![rename_playlist, filter_playlist, map_playlist], ProcessingOrder::Rmf => vec![rename_playlist, map_playlist, filter_playlist], ProcessingOrder::Mfr => vec![map_playlist, filter_playlist, rename_playlist], - ProcessingOrder::Mrf => vec![map_playlist, rename_playlist, filter_playlist] + ProcessingOrder::Mrf => vec![map_playlist, rename_playlist, filter_playlist], } } -fn execute_pipe<'a>(target: &ConfigTarget, pipe: &ProcessingPipe, fpl: &mut FetchedPlaylist<'a>, - duplicates: &mut HashSet, consume_source: bool) -> FetchedPlaylist<'a> { +fn execute_pipe<'a>( + target: &ConfigTarget, + pipe: &ProcessingPipe, + fpl: &mut FetchedPlaylist<'a>, + duplicates: &mut HashSet, + consume_source: bool, +) -> FetchedPlaylist<'a> { let source = if consume_source { if fpl.is_memory() { MemoryPlaylistSource::new(fpl.source.take_groups()).boxed() @@ -709,11 +758,7 @@ fn execute_pipe<'a>(target: &ConfigTarget, pipe: &ProcessingPipe, fpl: &mut Fetc fpl.clone_source() }; - let mut new_fpl = FetchedPlaylist { - input: fpl.input, - source, - epg: fpl.epg.clone(), - }; + let mut new_fpl = FetchedPlaylist { input: fpl.input, source, epg: fpl.epg.clone() }; if target.options.as_ref().is_some_and(|opt| opt.remove_duplicates) { new_fpl.deduplicate(duplicates); } @@ -756,12 +801,13 @@ fn flatten_groups(playlistgroups: Vec) -> Vec { } #[allow(clippy::too_many_arguments)] -async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, - playlists: &mut [FetchedPlaylist<'_>], - target: &ConfigTarget, - stats: &mut HashMap, InputStats>, - errors: &mut Vec, - consume_input_source: bool, +async fn process_playlist_for_target( + ctx: &PlaylistProcessingContext, + playlists: &mut [FetchedPlaylist<'_>], + target: &ConfigTarget, + stats: &mut HashMap, InputStats>, + errors: &mut Vec, + consume_input_source: bool, ) -> Result<(), Vec> { debug_if_enabled!("Processing order is {}", &target.processing_order); log_memory_snapshot(format!("target '{}' start", target.name).as_str()); @@ -776,13 +822,19 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, let pipe = get_processing_pipe(target); let mut step = StepMeasure::new(&target.name, broadcast_step); for provider_fpl in playlists.iter_mut() { - log_memory_snapshot(format!("target '{}' input '{}' before_pipe", target.name, provider_fpl.input.name).as_str()); + log_memory_snapshot( + format!("target '{}' input '{}' before_pipe", target.name, provider_fpl.input.name).as_str(), + ); step.broadcast("Executing transformations on '{}' playlist", &target.name); let mut processed_fpl = execute_pipe(target, &pipe, provider_fpl, &mut duplicates, consume_input_source); - log_memory_snapshot(format!("target '{}' input '{}' after_pipe", target.name, provider_fpl.input.name).as_str()); + log_memory_snapshot( + format!("target '{}' input '{}' after_pipe", target.name, provider_fpl.input.name).as_str(), + ); processed_fpl.sort_by_provider_ordinal(); playlist_resolve(ctx, target, errors, &pipe, provider_fpl, &mut processed_fpl).await; - log_memory_snapshot(format!("target '{}' input '{}' after_vod_resolve", target.name, provider_fpl.input.name).as_str()); + log_memory_snapshot( + format!("target '{}' input '{}' after_vod_resolve", target.name, provider_fpl.input.name).as_str(), + ); // stats let input_entry_name = processed_fpl.input.name.clone(); let group_count = processed_fpl.get_group_count(); @@ -792,9 +844,13 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, stat.processed_stats.channel_count = channel_count; } process_playlist_epg(&mut processed_fpl, &mut new_epg).await; - log_memory_snapshot(format!("target '{}' input '{}' after_epg_apply", target.name, processed_fpl.input.name).as_str()); + log_memory_snapshot( + format!("target '{}' input '{}' after_epg_apply", target.name, processed_fpl.input.name).as_str(), + ); new_playlist.extend(processed_fpl.source.take_groups()); - log_memory_snapshot(format!("target '{}' input '{}' after_take_groups", target.name, processed_fpl.input.name).as_str()); + log_memory_snapshot( + format!("target '{}' input '{}' after_take_groups", target.name, processed_fpl.input.name).as_str(), + ); tokio::task::yield_now().await; } step.tick("filter rename map + epg"); @@ -836,78 +892,103 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, step.tick("group watches"); log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str()); } - let result = persist_playlist(&ctx.config, &mut flat_new_playlist, flatten_tvguide(new_epg).as_ref(), target, ctx.playlist_state.as_ref()).await; + let result = persist_playlist( + &ctx.config, + &mut flat_new_playlist, + flatten_tvguide(new_epg).as_ref(), + target, + ctx.playlist_state.as_ref(), + ) + .await; step.stop("Persisting playlists"); log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str()); result } } -async fn playlist_resolve(ctx: &PlaylistProcessingContext, - target: &ConfigTarget, - errors: &mut Vec, - pipe: &ProcessingPipe, - provider_fpl: &mut FetchedPlaylist<'_>, - processed_fpl: &mut FetchedPlaylist<'_>, +async fn playlist_resolve( + ctx: &PlaylistProcessingContext, + target: &ConfigTarget, + errors: &mut Vec, + pipe: &ProcessingPipe, + provider_fpl: &mut FetchedPlaylist<'_>, + processed_fpl: &mut FetchedPlaylist<'_>, ) { - playlist_resolve_series(ctx, target, errors, pipe, provider_fpl, processed_fpl).await; playlist_resolve_vod(ctx, target, errors, provider_fpl, processed_fpl).await; playlist_probe(ctx, target, processed_fpl).await; } -fn is_xtream_only_output_target(target: &ConfigTarget) -> bool { - target.output.len() == 1 && matches!(target.output.first(), Some(TargetOutput::Xtream(_))) +fn has_target_m3u_output(target: &ConfigTarget) -> bool { + for output in &target.output { + if matches!(output, TargetOutput::M3u(_)) { + return true; + } + } + false } -fn get_xtream_cluster_probe_flag(target: &ConfigTarget, cluster: XtreamCluster) -> Option { - target.get_xtream_output().map(|xtream_output| match cluster { - XtreamCluster::Live => xtream_output.flags.contains(XtreamTargetFlags::ProbeLive), - XtreamCluster::Video => xtream_output.flags.contains(XtreamTargetFlags::ProbeVod), - XtreamCluster::Series => xtream_output.flags.contains(XtreamTargetFlags::ProbeSeries), +fn get_xtream_cluster_probe_flag( + target: &ConfigTarget, + input_options: Option<&ConfigInputOptions>, + cluster: XtreamCluster, +) -> Option { + target.get_xtream_output().map(|_| match cluster { + XtreamCluster::Live => input_options.is_some_and(|options| options.has_flag(ConfigInputFlags::ProbeLive)), + XtreamCluster::Video => input_options.is_some_and(|options| options.has_flag(ConfigInputFlags::ProbeVod)), + XtreamCluster::Series => input_options.is_some_and(|options| options.has_flag(ConfigInputFlags::ProbeSeries)), }) } -fn is_cluster_probe_enabled_for_target(target: &ConfigTarget, cluster: XtreamCluster) -> bool { +fn is_cluster_probe_enabled_for_target( + target: &ConfigTarget, + input_options: Option<&ConfigInputOptions>, + cluster: XtreamCluster, +) -> bool { // Probe flags are only authoritative when the target is exactly one xtream output. - // For mixed outputs (xtream + m3u/strm/...), probing remains enabled because non-xtream outputs have no probe flags. - if !is_xtream_only_output_target(target) { + // For mixed outputs (xtream + m3u), probing remains enabled because non-xtream outputs have no probe flags. + if has_target_m3u_output(target) { return true; } - get_xtream_cluster_probe_flag(target, cluster).unwrap_or(true) + get_xtream_cluster_probe_flag(target, input_options, cluster).unwrap_or(true) } fn is_probe_supported_item_type(item_type: PlaylistItemType) -> bool { matches!( - item_type, - PlaylistItemType::Live // we skip other live streams because hls and dash have multiple resolutions + item_type, + PlaylistItemType::Live // we skip other live streams because hls and dash have multiple resolutions | PlaylistItemType::Video | PlaylistItemType::LocalVideo | PlaylistItemType::Series | PlaylistItemType::LocalSeries - ) + ) } fn has_probe_details(item: &PlaylistItem) -> bool { match item.header.additional_properties.as_ref() { - Some(StreamProperties::Video(v)) => v - .details - .as_ref() - .is_some_and(|d| d.video.is_some() && d.audio.is_some()), + Some(StreamProperties::Video(v)) => v.details.as_ref().is_some_and(|d| d.video.is_some() && d.audio.is_some()), Some(StreamProperties::Live(l)) => l.video.is_some() && l.audio.is_some(), Some(StreamProperties::Episode(e)) => e.video.is_some() && e.audio.is_some(), Some(StreamProperties::Series(_)) | None => false, } } -fn get_live_probe_interval_settings(target: &ConfigTarget, input_type: InputType) -> Option<(u16, u64)> { - if !matches!(input_type, InputType::Xtream | InputType::XtreamBatch) { +fn get_live_probe_interval_settings( + target: &ConfigTarget, + input_type: InputType, + input_options: Option<&ConfigInputOptions>, +) -> Option<(u16, u64)> { + if !(input_type.is_xtream() || input_type.is_m3u()) { return None; } - target - .get_xtream_output() - .map(|xtream_output| (xtream_output.resolve_delay, u64::from(xtream_output.probe_live_interval_hours) * 3600)) + target.get_xtream_output().map(|_| { + let (resolve_delay, input_probe_live_interval_hours) = input_options.map_or( + (default_resolve_delay_secs(), default_probe_live_interval()), + |options| (options.resolve_delay, options.probe_live_interval_hours), + ); + (resolve_delay, u64::from(input_probe_live_interval_hours) * 3600) + }) } fn needs_live_probe(item: &PlaylistItem, cutoff_ts: i64) -> bool { @@ -946,7 +1027,7 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, let Some(opts) = fpl.input.options.as_ref() else { return; }; - if !opts.probe_stream { + if !opts.has_any_flags(ConfigInputFlags::ProbeLive | ConfigInputFlags::ProbeVod | ConfigInputFlags::ProbeSeries) { return; } if !ctx.config.is_ffprobe_enabled().await { @@ -955,12 +1036,12 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, let input_name = fpl.input.name.clone(); let input_type = fpl.input.input_type; - let xtream_only_output = is_xtream_only_output_target(target); - let live_probe_settings = get_live_probe_interval_settings(target, input_type).map(|(delay, interval_secs)| { - let interval_signed = i64::try_from(interval_secs).unwrap_or(i64::MAX); - let cutoff_ts = chrono::Utc::now().timestamp().saturating_sub(interval_signed); - (delay, interval_secs, cutoff_ts) - }); + let live_probe_settings = + get_live_probe_interval_settings(target, input_type, Some(opts)).map(|(delay, interval_secs)| { + let interval_signed = i64::try_from(interval_secs).unwrap_or(i64::MAX); + let cutoff_ts = chrono::Utc::now().timestamp().saturating_sub(interval_signed); + (delay, interval_secs, cutoff_ts) + }); let mut queued_probe_keys: HashSet<(Arc, String)> = HashSet::new(); let mut queued_live_keys: HashSet = HashSet::new(); @@ -968,7 +1049,7 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, let mut queued_stream_count = 0usize; for item in fpl.items() { - if !is_cluster_probe_enabled_for_target(target, item.header.xtream_cluster) { + if !is_cluster_probe_enabled_for_target(target, Some(opts), item.header.xtream_cluster) { continue; } @@ -996,26 +1077,18 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, } } - // For xtream-only targets, dedicated xtream resolve/probe processors handle non-live paths. - if xtream_only_output && matches!(input_type, InputType::Xtream | InputType::XtreamBatch) { - continue; - } - if has_probe_details(&item) { continue; } - // For M3U, ID is provider id; for Library, ID is UUID. + // For M3U, ID is a provider id; for Library, ID is UUID. let unique_id = if input_type == InputType::Library { item.header.uuid.to_valid_uuid() } else { item.header.id.to_string() }; - let probe_scope = if item.header.input_name.is_empty() { - input_name.clone() - } else { - item.header.input_name.clone() - }; + let probe_scope = + if item.header.input_name.is_empty() { input_name.clone() } else { item.header.input_name.clone() }; if !queued_probe_keys.insert((probe_scope.clone(), unique_id.clone())) { continue; @@ -1034,9 +1107,7 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, } if queued_live_count > 0 || queued_stream_count > 0 { - info!( - "Queued probe tasks for input {input_name} (live_interval={queued_live_count}, generic={queued_stream_count})" - ); + info!("Queued probe tasks for input {input_name} (live_interval={queued_live_count}, generic={queued_stream_count})"); } } @@ -1046,7 +1117,9 @@ pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Opt for pg in playlist.iter() { for pli in &pg.channels { // series episodes can't be included in favourites - if pli.header.item_type == PlaylistItemType::Series || pli.header.item_type == PlaylistItemType::LocalSeries { + if pli.header.item_type == PlaylistItemType::Series + || pli.header.item_type == PlaylistItemType::LocalSeries + { continue; } for fav in favourites { @@ -1055,10 +1128,7 @@ pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Opt channel.header.group.clone_from(&fav.group); // Update UUID to be an alias of the original channel.header.uuid = create_alias_uuid(&pli.header.uuid, &fav.group); - fav_groups - .entry((fav.cluster, fav.group.clone())) - .or_default() - .push(channel); + fav_groups.entry((fav.cluster, fav.group.clone())).or_default().push(channel); } } } @@ -1067,18 +1137,18 @@ pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Opt for (fav_group, channels) in fav_groups { if !channels.is_empty() { let (xtream_cluster, group_name) = fav_group; - playlist.push(PlaylistGroup { - id: 0, - title: group_name, - channels, - xtream_cluster, - }); + playlist.push(PlaylistGroup { id: 0, title: group_name, channels, xtream_cluster }); } } } } -async fn trakt_playlist(client: &reqwest::Client, target: &ConfigTarget, errors: &mut Vec, playlist: &mut Vec) -> bool { +async fn trakt_playlist( + client: &reqwest::Client, + target: &ConfigTarget, + errors: &mut Vec, + playlist: &mut Vec, +) -> bool { match process_trakt_categories_for_target(client, playlist, target).await { Ok(Some(trakt_categories)) => { if !trakt_categories.is_empty() { @@ -1097,7 +1167,12 @@ async fn trakt_playlist(client: &reqwest::Client, target: &ConfigTarget, errors: true } -async fn process_watch(app_config: &Arc, client: &reqwest::Client, target: &ConfigTarget, new_playlist: &[PlaylistGroup]) -> bool { +async fn process_watch( + app_config: &Arc, + client: &reqwest::Client, + target: &ConfigTarget, + new_playlist: &[PlaylistGroup], +) -> bool { if let Some(watches) = &target.watch { if default_as_default().eq_ignore_ascii_case(&target.name) { error!("can't watch a target with no unique name"); @@ -1108,8 +1183,10 @@ async fn process_watch(app_config: &Arc, client: &reqwest::Client, ta new_playlist .iter() .filter(|pl| watches.iter().any(|r| r.is_match(&pl.title))) - .map(|pl| process_group_watch(app_config, client, &target.name, pl)) - ).for_each_concurrent(16, |f| f).await; + .map(|pl| process_group_watch(app_config, client, &target.name, pl)), + ) + .for_each_concurrent(16, |f| f) + .await; true } else { @@ -1118,12 +1195,18 @@ async fn process_watch(app_config: &Arc, client: &reqwest::Client, ta } #[allow(clippy::too_many_arguments)] -pub async fn exec_processing(client: &reqwest::Client, app_config: Arc, targets: Arc, - event_manager: Option>, playlist_state: Option>, - update_guard: Option, disabled_headers: Option, - provider_manager: Option>, - metadata_manager: Option>) { - let _guard = if let Some(guard) = update_guard { +pub async fn exec_processing( + client: &reqwest::Client, + app_config: Arc, + targets: Arc, + event_manager: Option>, + playlist_state: Option>, + update_guard: Option, + disabled_headers: Option, + provider_manager: Option>, + metadata_manager: Option>, +) { + let _guard = if let Some(guard) = update_guard { if let Some(permit) = guard.try_playlist() { Some(permit) } else { @@ -1184,7 +1267,10 @@ pub async fn exec_processing(client: &reqwest::Client, app_config: Arc PlaylistItem { - let header = shared::model::PlaylistItemHeader { - additional_properties: Some(props), - ..Default::default() - }; + let header = shared::model::PlaylistItemHeader { additional_properties: Some(props), ..Default::default() }; PlaylistItem { header } } diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index 0517aee93..704d52d34 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use shared::error::{TuliproxError}; -use crate::model::{AppConfig, ConfigInput}; +use crate::model::{AppConfig, ConfigInput, ConfigInputFlags}; use shared::model::{LiveStreamProperties, StreamProperties, XtreamCluster, XtreamPlaylistItem}; use crate::repository::{get_input_storage_path, persist_input_live_info, BPlusTreeQuery, xtream_get_file_path}; use crate::utils::{debug_if_enabled}; @@ -71,9 +71,8 @@ pub async fn update_live_stream_metadata( let input_url = input.url.as_str(); let username = input.username.as_deref().unwrap_or(""); let password = input.password.as_deref().unwrap_or(""); - let opts = input.options.as_ref(); - let use_prefix = opts.is_none_or(|o| o.xtream_live_stream_use_prefix); - let no_ext = opts.is_some_and(|o| o.xtream_live_stream_without_extension); + let use_prefix = input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix); + let no_ext = input.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension); // We generate the URL to probe directly on the provider let stream_url = create_xtream_url( diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 164e7c88a..9e261e26a 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -3,7 +3,7 @@ use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType, R use crate::library::MetadataResolver; use crate::model::FetchedPlaylist; use crate::model::{AppConfig, ConfigTarget}; -use crate::model::{ConfigInput, InputSource}; +use crate::model::{ConfigInput, ConfigInputFlags, InputSource}; use crate::processing::parser::xtream::create_xtream_series_episode_url; use crate::processing::parser::xtream::parse_xtream_series_info; use crate::processing::processor::playlist::{PlaylistProcessingContext, ProcessingPipe}; @@ -37,10 +37,16 @@ pub async fn playlist_resolve_series( provider_fpl: &mut FetchedPlaylist<'_>, processed_fpl: &mut FetchedPlaylist<'_>, ) { - let resolve_options = get_resolve_series_options(target, processed_fpl); + // Skip-flag is the kill-switch: no resolve, no probe, no iteration. + if processed_fpl.input.has_flag(ConfigInputFlags::XtreamSkipSeries) { + return; + } - let do_probe = resolve_options.flags.contains(ResolveOptionsFlags::Probe) && ctx.config.is_ffprobe_enabled().await; + let mut resolve_options = get_resolve_series_options(target, processed_fpl); + if !ctx.config.is_ffprobe_enabled().await { + resolve_options.unset_flag(ResolveOptionsFlags::Probe); + } provider_fpl.source.release_resources(XtreamCluster::Series); playlist_resolve_series_info( @@ -48,7 +54,6 @@ pub async fn playlist_resolve_series( errors, processed_fpl, resolve_options, - do_probe, pipe, target, ) @@ -66,7 +71,6 @@ async fn playlist_resolve_series_info(ctx: &PlaylistProcessingContext, _errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>, resolve_options: ResolveOptions, - do_probe: bool, pipe: &ProcessingPipe, target: &ConfigTarget) { let filter = |pli: &PlaylistItem| { @@ -79,17 +83,18 @@ async fn playlist_resolve_series_info(ctx: &PlaylistProcessingContext, }; // Skip if nothing to do - let skip_resolve = !resolve_options.flags.contains(ResolveOptionsFlags::Resolve) && !do_probe && !resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing); + let skip_resolve = !resolve_options.has_flag(ResolveOptionsFlags::Resolve) + && !resolve_options.has_flag(ResolveOptionsFlags::Probe) + && !resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing); - let resolve_tmdb_enabled = fpl.input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + let resolve_tmdb_enabled = fpl.input.has_flag(ConfigInputFlags::ResolveTmdb); - let groups_to_add = if resolve_options.flags.contains(ResolveOptionsFlags::Background) && ctx.metadata_manager.is_some() { + let groups_to_add = if resolve_options.has_flag(ResolveOptionsFlags::Background) && ctx.metadata_manager.is_some() { queue_background_series_info( ctx, fpl, filter, &resolve_options, - do_probe, skip_resolve, resolve_tmdb_enabled, ) @@ -99,7 +104,6 @@ async fn playlist_resolve_series_info(ctx: &PlaylistProcessingContext, fpl, filter, &resolve_options, - do_probe, skip_resolve, resolve_tmdb_enabled, ) @@ -180,7 +184,6 @@ fn queue_background_series_info( fpl: &mut FetchedPlaylist<'_>, filter: impl Fn(&PlaylistItem) -> bool, resolve_options: &ResolveOptions, - do_probe: bool, skip_resolve: bool, resolve_tmdb_enabled: bool, ) -> Vec { @@ -204,7 +207,7 @@ fn queue_background_series_info( }; if !skip_resolve { - let reasons = check_resolve_reasons(resolve_options, do_probe, resolve_tmdb_enabled, pli); + let reasons = check_resolve_reasons(resolve_options, resolve_tmdb_enabled, pli); if !reasons.is_empty() { let task = UpdateTask::ResolveSeries { @@ -229,7 +232,6 @@ async fn process_immediate_series_info( fpl: &mut FetchedPlaylist<'_>, filter: impl Fn(&PlaylistItem) -> bool, resolve_options: &ResolveOptions, - do_probe: bool, skip_resolve: bool, resolve_tmdb_enabled: bool, ) -> Vec { @@ -265,7 +267,7 @@ async fn process_immediate_series_info( }; if !skip_resolve { - let reasons = check_resolve_reasons(resolve_options, do_probe, resolve_tmdb_enabled, pli); + let reasons = check_resolve_reasons(resolve_options, resolve_tmdb_enabled, pli); if !reasons.is_empty() { if let Some(active_provider) = ctx.provider_manager.as_ref() { @@ -420,7 +422,6 @@ async fn update_series_info_immediate( fn check_resolve_reasons( resolve_options: &ResolveOptions, - do_probe: bool, resolve_tmdb_enabled: bool, pli: &mut PlaylistItem, ) -> ResolveReasonSet { @@ -429,16 +430,16 @@ fn check_resolve_reasons( let needs_info = check_needs_info(resolve_options, pli, &mut reasons); check_resolve_tmdb(resolve_options, resolve_tmdb_enabled, pli, needs_info, &mut reasons); - if do_probe { - check_needs_probe(pli, &mut reasons); + if resolve_options.has_flag(ResolveOptionsFlags::Probe) { + check_needs_probe(pli, needs_info, &mut reasons); } reasons } fn check_needs_info(resolve_options: &ResolveOptions, pli: &mut PlaylistItem, reasons: &mut ResolveReasonSet) -> bool { - let needs_info = resolve_options.flags.contains(ResolveOptionsFlags::Resolve) && !pli.has_details(); + let needs_info = resolve_options.has_flag(ResolveOptionsFlags::Resolve) && !pli.has_details(); if needs_info { - reasons.add(ResolveReason::Info); + reasons.set(ResolveReason::Info); } needs_info } @@ -450,25 +451,26 @@ fn check_resolve_tmdb( needs_info: bool, reasons: &mut ResolveReasonSet, ) { - if resolve_tmdb_enabled && resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing) { + if resolve_tmdb_enabled + && resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) + && needs_info { if let Some(StreamProperties::Series(series_stream_props)) = pli.header.additional_properties.as_ref() { let has_tmdb = series_stream_props.tmdb.is_some(); let has_date = series_stream_props.release_date.is_some(); let title_present = !series_stream_props.name.is_empty() || !pli.header.title.is_empty(); if title_present && (!has_tmdb || !has_date) { - if !has_tmdb { reasons.add(ResolveReason::Tmdb); } - if !has_date { reasons.add(ResolveReason::Date); } + if !has_tmdb { reasons.set(ResolveReason::Tmdb); } + if !has_date { reasons.set(ResolveReason::Date); } } - } else if needs_info { - // Covered by info - } else { - reasons.add(ResolveReason::MissingDetails); } } } -fn check_needs_probe(pli: &mut PlaylistItem, reasons: &mut ResolveReasonSet) { +fn check_needs_probe(pli: &mut PlaylistItem, needs_info: bool, reasons: &mut ResolveReasonSet) { + if !needs_info { + return; + } if let Some(StreamProperties::Series(series_stream_props)) = pli.header.additional_properties.as_ref() { if let Some(details) = series_stream_props.details.as_ref() { if let Some(episodes) = details.episodes.as_ref() { @@ -476,7 +478,7 @@ fn check_needs_probe(pli: &mut PlaylistItem, reasons: &mut ResolveReasonSet) { let missing_video = !MediaQuality::is_valid_media_info(ep.video.as_deref()); let missing_audio = !MediaQuality::is_valid_media_info(ep.audio.as_deref()); if missing_video || missing_audio { - reasons.add(ResolveReason::Probe); + reasons.set(ResolveReason::Probe); break; } } @@ -510,8 +512,7 @@ pub async fn update_series_metadata( let storage_path = get_input_storage_path(&input.name, working_dir).await .map_err(|e| shared::error::info_err!("Storage path error: {e}"))?; - let opts = input.options.as_ref(); - if opts.is_some_and(|o| o.xtream_skip_series) { + if input.has_flag(ConfigInputFlags::XtreamSkipSeries) { return Ok(None); } @@ -600,10 +601,13 @@ pub async fn update_series_metadata( let mut properties = props.unwrap(); - let resolve_tmdb_enabled = input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + let resolve_tmdb_enabled = input.has_flag(ConfigInputFlags::ResolveTmdb); // 2. Resolve TMDB/Date if missing - if resolve_tmdb_enabled && (properties.tmdb.is_none() || properties.release_date.is_none()) && !properties.name.is_empty() { + if fetch_info + && resolve_tmdb_enabled + && (properties.tmdb.is_none() || properties.release_date.is_none()) + && !properties.name.is_empty() { let config = app_config.config.load(); let library_config = config.library.as_ref(); let meta_resolver = MetadataResolver::new(library_config, client.clone()); diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index 08ccbcc6a..bf14c2545 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -1,7 +1,7 @@ use crate::api::model::{ActiveProviderManager, ProviderHandle}; use crate::api::model::{ResolveReason, ResolveReasonSet, UpdateTask, ProviderIdType}; use crate::library::MetadataResolver; -use crate::model::ConfigInput; +use crate::model::{ConfigInput, ConfigInputFlags}; use crate::model::FetchedPlaylist; use crate::model::InputSource; use crate::model::{AppConfig, ConfigTarget}; @@ -33,13 +33,20 @@ pub async fn playlist_resolve_vod( provider_fpl: &mut FetchedPlaylist<'_>, fpl: &mut FetchedPlaylist<'_>, ) { + // Skip-flag is the kill-switch: no resolve, no probe, no iteration. + if fpl.input.has_flag(ConfigInputFlags::XtreamSkipVod) { + return; + } + let resolve_options = get_resolve_vod_options(target, fpl); let app_config: &Arc = &ctx.config; - let do_probe = resolve_options.flags.contains(ResolveOptionsFlags::Probe) && app_config.is_ffprobe_enabled().await; + let do_probe = resolve_options.has_flag(ResolveOptionsFlags::Probe) && app_config.is_ffprobe_enabled().await; // Determine if we need to do anything - if !resolve_options.flags.contains(ResolveOptionsFlags::Resolve) && !do_probe && !resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing) { + if !resolve_options.has_flag(ResolveOptionsFlags::Resolve) + && !do_probe + && !resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) { return; } @@ -130,9 +137,9 @@ async fn playlist_resolve_vod_info( true }; - let resolve_tmdb_enabled = fpl.input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + let resolve_tmdb_enabled = fpl.input.has_flag(ConfigInputFlags::ResolveTmdb); - if resolve_options.flags.contains(ResolveOptionsFlags::Background) && ctx.metadata_manager.is_some() { + if resolve_options.has_flag(ResolveOptionsFlags::Background) && ctx.metadata_manager.is_some() { queue_background_vod_info( ctx, fpl, @@ -227,20 +234,25 @@ async fn process_immediate_vod_info(ctx: &PlaylistProcessingContext, fpl: &mut F }) .collect(); - if !updates.is_empty() { - if let Err(err) = persist_input_vod_info_batch( + if updates.is_empty() { + batch.clear(); + } else { + match persist_input_vod_info_batch( &ctx.config, &storage_path, XtreamCluster::Video, &input.name, updates, ).await { - error!("Failed to persist batch VOD info: {err}"); + Ok(()) => batch.clear(), + Err(err) => { + error!( + "persist_input_vod_info_batch failed for XtreamCluster::Video on input '{}'. batch.clear() skipped. Error: {err}", + input.name + ); + } } } - - // Clear batch - batch.clear(); } processed_count += 1; @@ -301,12 +313,15 @@ fn check_resolve_reasons( // Probe check if do_probe { - check_needs_probe(pli, &mut reasons); + check_needs_probe(pli, needs_info, &mut reasons); } reasons } -fn check_needs_probe(pli: &PlaylistItem, reasons: &mut ResolveReasonSet) { +fn check_needs_probe(pli: &PlaylistItem, needs_info: bool, reasons: &mut ResolveReasonSet) { + if !needs_info { + return; + } let needs_probe = match pli.header.additional_properties.as_ref() { Some(StreamProperties::Video(props)) => { let details = props.details.as_ref(); @@ -320,15 +335,15 @@ fn check_needs_probe(pli: &PlaylistItem, reasons: &mut ResolveReasonSet) { _ => false, }; if needs_probe { - reasons.add(ResolveReason::Probe); + reasons.set(ResolveReason::Probe); } } fn check_needs_info(resolve_options: &ResolveOptions, pli: &PlaylistItem, reasons: &mut ResolveReasonSet) -> bool { - let needs_info = !pli.has_details() && resolve_options.flags.contains(ResolveOptionsFlags::Resolve); + let needs_info = !pli.has_details() && resolve_options.has_flag(ResolveOptionsFlags::Resolve); if needs_info { - reasons.add(ResolveReason::Info); + reasons.set(ResolveReason::Info); } needs_info } @@ -340,17 +355,16 @@ fn check_resolve_tmdb( needs_info: bool, reasons: &mut ResolveReasonSet, ) { - if resolve_tmdb_enabled && resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing) { + if resolve_tmdb_enabled + && resolve_options.has_flag(ResolveOptionsFlags::TmdbMissing) + && needs_info { if let Some(StreamProperties::Video(video_stream_props)) = pli.header.additional_properties.as_ref() { let has_tmdb = video_stream_props.tmdb.is_some(); let has_date = video_stream_props.details.as_ref().and_then(|d| d.release_date.as_ref()).is_some(); if !has_tmdb || !has_date { - if !has_tmdb { reasons.add(ResolveReason::Tmdb); } - if !has_date { reasons.add(ResolveReason::Date); } + if !has_tmdb { reasons.set(ResolveReason::Tmdb); } + if !has_date { reasons.set(ResolveReason::Date); } } - } else if !needs_info { - // No properties but no download requested? Should be rare - reasons.add(ResolveReason::MissingDetails); } } } @@ -410,6 +424,7 @@ async fn update_vod_info_immediate( Some(&pli.header.title), false, fetch_info, + reasons.contains(ResolveReason::Probe), db_query, ).await } @@ -433,6 +448,7 @@ pub async fn update_vod_metadata( playlist_title: Option<&str>, save: bool, fetch_info: bool, + do_probe: bool, db_query: Option<&mut BPlusTreeQuery>, ) -> Result, TuliproxError> { let working_dir = &app_config.config.load().working_dir; @@ -440,8 +456,7 @@ pub async fn update_vod_metadata( .map_err(|e| shared::error::info_err!("Storage path error: {e}"))?; // Check if we should skip based on input options - let opts = input.options.as_ref(); - if opts.is_some_and(|o| o.xtream_skip_vod) { + if input.has_flag(ConfigInputFlags::XtreamSkipVod) { return Ok(None); } @@ -548,13 +563,13 @@ pub async fn update_vod_metadata( // Now it's safe to unwrap because we handled the None case above let mut properties = props.unwrap(); - let resolve_tmdb_enabled = input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + let resolve_tmdb_enabled = input.has_flag(ConfigInputFlags::ResolveTmdb); // 2. Resolve TMDB/Date if missing and explicitly enabled for this input let missing_tmdb = properties.tmdb.is_none(); let missing_date = properties.details.as_ref().and_then(|d| d.release_date.as_ref()).is_none(); - if resolve_tmdb_enabled && (missing_tmdb || missing_date) { + if fetch_info && resolve_tmdb_enabled && (missing_tmdb || missing_date) { // Try local parsing first if missing_date && !properties.name.is_empty() { let meta_parse = ptt_parse_title(&properties.name); @@ -633,7 +648,7 @@ pub async fn update_vod_metadata( // 3. Probe (if enabled globally in config) let ffprobe_enabled = app_config.is_ffprobe_enabled().await; - if ffprobe_enabled { + if do_probe && ffprobe_enabled { // Ensure details struct exists before probing if properties.details.is_none() { properties.details = Some(VideoStreamDetailProperties::default()); diff --git a/backend/src/ptt/handlers.rs b/backend/src/ptt/handlers.rs index a2df2c5ad..696c34f35 100644 --- a/backend/src/ptt/handlers.rs +++ b/backend/src/ptt/handlers.rs @@ -1,4 +1,4 @@ -use crate::ptt::parser::{HandlerOptions, MatchInfo, ParseContext, PttParser}; +use crate::ptt::parser::{handler_options, HandlerOptions, MatchInfo, ParseContext, PttParser}; use crate::ptt::transformers::{boolean, date, first_uinteger, lowercase, none, range_i32, range_u32, transform_resolution, uinteger, uppercase, value}; use fancy_regex::Regex as FancyRegex; @@ -36,7 +36,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.tmdb = Some(v); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -51,7 +51,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.tvdb = Some(v); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -68,7 +68,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.quality = Some("SCR".to_string()); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -83,7 +83,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -99,7 +99,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.quality = Some("TeleSync".to_string()); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -115,7 +115,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.year = Some(v); } }, - HandlerOptions { + handler_options! { remove: false, ..Default::default() }, @@ -126,7 +126,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(?:19|20)\d{2}\s*[-]\s*(?:(?:19|20)\d{2}|\d{2})\b").unwrap(), none, |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -137,7 +137,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b100[ .-]*years?[ .-]*quest\b").unwrap(), none, |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -147,7 +147,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\[?(\+.)?Extras\]?").unwrap(), none, |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -157,7 +157,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(\+Movies)?\+Specials").unwrap(), none, |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -167,7 +167,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"-?EDGE2020").unwrap(), |_| "EDGE2020".to_string(), |meta, val| meta.group = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -177,7 +177,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)TV Money").unwrap(), none, |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -196,7 +196,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"\.torrent$").unwrap(), boolean, |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -207,7 +207,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"\b(XXX|xxx|Xxx)\b").unwrap(), boolean, |meta, val| meta.adult = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -228,7 +228,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bOVA\b").unwrap(), |_| "OVA".to_string(), |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -239,7 +239,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bOVA\b").unwrap(), |_| "OVA".to_string(), |_, _| {}, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -250,7 +250,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\[?\]?3840x\d{4}[\])?]?").unwrap(), |_| "2160p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -260,7 +260,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\[?\]?1920x\d{3,4}[\])?]?").unwrap(), |_| "1080p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -270,7 +270,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\[?\]?1280x\d{3}[\])?]?").unwrap(), |_| "720p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -280,7 +280,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\[?\]?(\d{3,4}x\d{3,4})[\])?]?p?").unwrap(), |val: &str| format!("{val}p"), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -290,7 +290,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(480|720|1080)0[pi]").unwrap(), |val: &str| format!("{val}p"), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -300,7 +300,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(?:QHD|QuadHD|WQHD|2560(\d+)?x(\d+)?1440p?)").unwrap(), |_| "1440p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -310,7 +310,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(?:Full HD|FHD|1920(\d+)?x(\d+)?1080p?)").unwrap(), |_| "1080p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -320,7 +320,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(?:BD|HD|M)(2160p?|4k)").unwrap(), |_| "2160p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -330,7 +330,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(?:BD|HD|M)1080p?").unwrap(), |_| "1080p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -340,7 +340,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(?:BD|HD|M)720p?").unwrap(), |_| "720p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -350,7 +350,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(?:BD|HD|M)480p?").unwrap(), |_| "480p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -361,7 +361,7 @@ pub fn add_defaults(parser: &mut PttParser) { .unwrap(), transform_resolution, |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -371,7 +371,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b4k|21600?[pi]\b").unwrap(), |_| "2160p".to_string(), |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -381,7 +381,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(\d{3,4}[pi])").unwrap(), lowercase, |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -391,7 +391,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)(240|360|480|576|720|1080|2160|3840)[pi]").unwrap(), lowercase, |meta, val| meta.resolution = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -402,7 +402,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"[\[\()]([A-Fa-f0-9]{8})[\]\)]").unwrap(), uppercase, |meta, val| meta.episode_code = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -412,7 +412,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"[\[\()]([0-9]{8})[\]\)]").unwrap(), uppercase, |meta, val| meta.episode_code = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -424,7 +424,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(?:H[DQ][ .-]*)?(?().unwrap_or(0)], |meta, val| meta.seasons.extend(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -2370,7 +2370,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2451,7 +2451,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -2462,7 +2462,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)[st]\d{1,2}[. ]?[xΡ…-]?[. ]?(?:e|x|Ρ…|ep|-|\.)[. ]?(\d{1,4})(?:[abc]|v0?[1-4]|\D|$)").unwrap(), |val| Some(vec![val.parse::().unwrap_or(0)]), |meta, val| if let Some(v) = val { meta.episodes.extend(v) }, - HandlerOptions { remove: true, ..Default::default() }, + handler_options! { remove: true, ..Default::default() }, ); parser.add_handler( "episodes", @@ -2528,7 +2528,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -2567,7 +2567,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -2699,7 +2699,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -2714,7 +2714,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -2728,7 +2728,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: false, skip_if_already_found: true, ..Default::default() @@ -2743,7 +2743,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.episodes.extend(v); } }, - HandlerOptions { + handler_options! { remove: false, skip_if_already_found: true, ..Default::default() @@ -2767,7 +2767,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -2782,7 +2782,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2796,7 +2796,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2810,7 +2810,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2825,7 +2825,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push("en".to_string()); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2839,7 +2839,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2853,7 +2853,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2867,7 +2867,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2881,7 +2881,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -2896,7 +2896,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2910,7 +2910,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -2925,7 +2925,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -2940,7 +2940,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -2955,7 +2955,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_first: true, skip_if_already_found: false, @@ -2971,7 +2971,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -2985,7 +2985,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -3000,7 +3000,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, remove: true, @@ -3016,7 +3016,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3031,7 +3031,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3045,7 +3045,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3059,7 +3059,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3074,7 +3074,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3089,7 +3089,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3104,7 +3104,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3119,7 +3119,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3133,7 +3133,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: false, skip_if_already_found: false, ..Default::default() @@ -3148,7 +3148,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: false, skip_if_already_found: false, ..Default::default() @@ -3163,7 +3163,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, remove: true, ..Default::default() @@ -3178,7 +3178,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, remove: true, ..Default::default() @@ -3194,7 +3194,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3208,7 +3208,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3222,7 +3222,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3236,7 +3236,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3250,7 +3250,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3265,7 +3265,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -3280,7 +3280,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3295,7 +3295,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -3310,7 +3310,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3325,7 +3325,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3340,7 +3340,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -3355,7 +3355,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3370,7 +3370,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3385,7 +3385,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3400,7 +3400,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3415,7 +3415,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3429,7 +3429,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3444,7 +3444,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3458,7 +3458,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3472,7 +3472,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3486,7 +3486,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3501,7 +3501,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3515,7 +3515,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3530,7 +3530,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3544,7 +3544,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3558,7 +3558,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3573,7 +3573,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3588,7 +3588,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3602,7 +3602,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3617,7 +3617,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3631,7 +3631,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3646,7 +3646,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3660,7 +3660,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3675,7 +3675,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3690,7 +3690,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -3705,7 +3705,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -3720,7 +3720,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, skip_if_first: true, @@ -3736,7 +3736,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -3751,7 +3751,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3765,7 +3765,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -3780,7 +3780,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3794,7 +3794,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -3809,7 +3809,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -3824,7 +3824,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3838,7 +3838,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3852,7 +3852,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -3867,7 +3867,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3882,7 +3882,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3897,7 +3897,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3912,7 +3912,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3927,7 +3927,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -3942,7 +3942,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -3957,7 +3957,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -3971,7 +3971,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -3986,7 +3986,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4000,7 +4000,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -4015,7 +4015,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -4105,7 +4105,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4120,7 +4120,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4135,7 +4135,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4150,7 +4150,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4165,7 +4165,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4180,7 +4180,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_first: true, skip_if_already_found: false, @@ -4196,7 +4196,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4211,7 +4211,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4225,7 +4225,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4239,7 +4239,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4253,7 +4253,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -4268,7 +4268,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_from_title: true, skip_if_already_found: false, ..Default::default() @@ -4283,7 +4283,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -4298,7 +4298,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4312,7 +4312,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.languages.push(val); } }, - HandlerOptions { + handler_options! { skip_if_first: true, skip_if_already_found: false, ..Default::default() @@ -4330,7 +4330,7 @@ pub fn add_defaults(parser: &mut PttParser) { } }, |meta, val| meta.quality = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4341,7 +4341,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bSAT(?:Rip)?\b").unwrap(), |_| "SATRip".to_string(), |meta, val| meta.quality = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4358,7 +4358,7 @@ pub fn add_defaults(parser: &mut PttParser) { } }, |meta, val| meta.quality = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4369,7 +4369,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bPPVRip\b").unwrap(), |_| "PPVRip".to_string(), |meta, val| meta.quality = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4380,7 +4380,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bWEBMux\b").unwrap(), |_| "WEBMux".to_string(), |meta, val| meta.quality = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4391,7 +4391,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(?:HDRip|MicroHD)\b").unwrap(), |_| "HDRip".to_string(), |meta, val| meta.quality = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4410,7 +4410,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.quality = Some("REMUX".to_string()); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4427,7 +4427,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.quality = Some("CAM".to_string()); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4443,7 +4443,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.quality = Some("TeleCine".to_string()); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4455,7 +4455,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bmulti(?:ple)?[ .-]*(?:su?$|sub\w*|dub\w*)\b|msub").unwrap(), boolean, |meta, val| meta.subbed = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4465,7 +4465,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(?:Official.*?|Dual-?)?sub(s|bed)?\b").unwrap(), boolean, |meta, val| meta.subbed = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4477,7 +4477,7 @@ pub fn add_defaults(parser: &mut PttParser) { .unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4487,7 +4487,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\btri(?:ple)?[ .-]*(?:audio|dub\w*)\b").unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4497,7 +4497,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bdual[ .-]*(?:au?$|[aΓ‘]udio|line)\b").unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4507,7 +4507,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bdual\b(?![ .-]*sub)").unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { skip_if_already_found: false, ..Default::default() }, @@ -4517,7 +4517,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(fan\s?dub)\b").unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { remove: true, skip_from_title: true, ..Default::default() @@ -4528,7 +4528,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(Fan.*)?(?:DUBBED|dublado|dubbing|DUBS?)\b").unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4541,7 +4541,7 @@ pub fn add_defaults(parser: &mut PttParser) { .unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4551,7 +4551,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bMULTi\b").unwrap(), boolean, |meta, val| meta.dubbed = val, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4562,7 +4562,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b3D\b").unwrap(), boolean, |meta, val| meta.is_3d = val, - HandlerOptions { + handler_options! { remove: false, skip_if_first: true, ..Default::default() @@ -4574,7 +4574,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\b(\d+(\.\d+)?\s?(MB|GB|TB))\b").unwrap(), |val| val.replace(' ', "").to_uppercase(), |meta, val| meta.size = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4584,7 +4584,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)[-\s](\d+(?:\.\d+)?(?:MB|GB|TB))[-\s]").unwrap(), |val| val.replace(' ', "").to_uppercase(), |meta, val| meta.size = Some(val), - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: true, ..Default::default() @@ -4598,7 +4598,7 @@ pub fn add_defaults(parser: &mut PttParser) { ).unwrap(), value, |meta, val| meta.site = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4608,7 +4608,7 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\bwww?.?[\w.-]+\.(?:link|world|cam|xyz|info|club)\b").unwrap(), value, |meta, val| meta.site = Some(val), - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4620,7 +4620,7 @@ pub fn add_defaults(parser: &mut PttParser) { |meta, val| { meta.site = Some(val); }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4636,7 +4636,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.networks.push(val); } }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4650,7 +4650,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.networks.push(val); } }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4664,7 +4664,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.networks.push(val); } }, - HandlerOptions { + handler_options! { remove: true, skip_if_already_found: false, ..Default::default() @@ -4679,7 +4679,7 @@ pub fn add_defaults(parser: &mut PttParser) { meta.networks.push(val); } }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4692,7 +4692,7 @@ pub fn add_defaults(parser: &mut PttParser) { |meta, val| { meta.trash = val; }, - HandlerOptions { + handler_options! { remove: true, ..Default::default() }, @@ -4703,6 +4703,6 @@ pub fn add_defaults(parser: &mut PttParser) { FancyRegex::new(r"(?i)\.(3g2|3gp|avi|flv|mkv|mk3d|mov|mp2|mp4|m4v|mpe|mpeg|mpg|mpv|webm|wmv|ogm|divx|ts|m2ts|iso|vob|sub|idx|ttxt|txt|smi|srt|ssa|ass|vtt|nfo|html)$").unwrap(), |val| val.to_lowercase().trim_start_matches('.').to_string(), |meta, val| meta.extension = Some(val), - HandlerOptions { remove: true, ..Default::default() }, + handler_options! { remove: true, ..Default::default() }, ); -} \ No newline at end of file +} diff --git a/backend/src/ptt/parser.rs b/backend/src/ptt/parser.rs index 961b70c1c..3a176dc46 100644 --- a/backend/src/ptt/parser.rs +++ b/backend/src/ptt/parser.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use fancy_regex::{Regex as FancyRegex, Match}; use crate::ptt::constants::{BRACKETS, PTT_CONSTANTS}; use crate::ptt::models::PttMetadata; +use shared::create_bitset; #[derive(Debug, Clone)] pub struct MatchInfo { @@ -20,6 +21,15 @@ pub struct ParseContext { pub type HandlerFn = Box Option + Send + Sync>; +create_bitset!( + u8, + HandlerOptionFlags, + SkipIfAlreadyFound, + SkipFromTitle, + SkipIfFirst, + Remove +); + #[derive(Default)] pub struct PttParser { handlers: Vec<(String, HandlerFn)>, @@ -51,7 +61,11 @@ impl PttParser { let name_clone = name.to_string(); let handler = Box::new(move |context: &mut ParseContext| -> Option { - if options.skip_if_already_found && context.matched.contains_key(&name_owned) { + if options + .flags + .contains(HandlerOptionFlags::SkipIfAlreadyFound) + && context.matched.contains_key(&name_owned) + { return None; } @@ -85,11 +99,12 @@ impl PttParser { false }; - let current_skip_from_title = before_title_matched || options.skip_from_title; + let current_skip_from_title = before_title_matched + || options.flags.contains(HandlerOptionFlags::SkipFromTitle); let transformed_value = transformer(&clean_match); - if options.skip_if_first { + if options.flags.contains(HandlerOptionFlags::SkipIfFirst) { let other_matches_exist = !context.matched.is_empty(); if other_matches_exist { let current_start = match_index; @@ -108,7 +123,7 @@ impl PttParser { let info = MatchInfo { raw_match, match_index, - remove: options.remove, + remove: options.flags.contains(HandlerOptionFlags::Remove), skip_from_title: current_skip_from_title, }; context.matched.insert(name_owned.clone(), info.clone()); @@ -229,21 +244,56 @@ pub fn clean_title(raw_title: &str) -> String { title } -#[allow(clippy::struct_excessive_bools)] pub struct HandlerOptions { - pub skip_if_already_found: bool, - pub skip_from_title: bool, - pub skip_if_first: bool, - pub remove: bool, + pub flags: HandlerOptionFlagsSet, +} + +impl HandlerOptions { + pub fn set_flag(&mut self, flag: HandlerOptionFlags, enabled: bool) { + if enabled { + self.flags.set(flag); + } else { + self.flags.unset(flag); + } + } } impl Default for HandlerOptions { fn default() -> Self { + let mut flags = HandlerOptionFlagsSet::new(); + flags.set(HandlerOptionFlags::SkipIfAlreadyFound); Self { - skip_if_already_found: true, - skip_from_title: false, - skip_if_first: false, - remove: false, + flags, } } } + +macro_rules! handler_options_set_field { + ($opts:ident, skip_if_already_found, $value:expr) => { + $opts.set_flag($crate::ptt::parser::HandlerOptionFlags::SkipIfAlreadyFound, $value); + }; + ($opts:ident, skip_from_title, $value:expr) => { + $opts.set_flag($crate::ptt::parser::HandlerOptionFlags::SkipFromTitle, $value); + }; + ($opts:ident, skip_if_first, $value:expr) => { + $opts.set_flag($crate::ptt::parser::HandlerOptionFlags::SkipIfFirst, $value); + }; + ($opts:ident, remove, $value:expr) => { + $opts.set_flag($crate::ptt::parser::HandlerOptionFlags::Remove, $value); + }; +} +pub(crate) use handler_options_set_field; + +macro_rules! handler_options { + ($($field:ident : $value:expr),* , ..Default::default() $(,)?) => {{ + let mut options = $crate::ptt::parser::HandlerOptions::default(); + $($crate::ptt::parser::handler_options_set_field!(options, $field, $value);)* + options + }}; + ($($field:ident : $value:expr),* $(,)?) => {{ + let mut options = $crate::ptt::parser::HandlerOptions::default(); + $($crate::ptt::parser::handler_options_set_field!(options, $field, $value);)* + options + }}; +} +pub(crate) use handler_options; diff --git a/backend/src/repository/m3u_playlist_iterator.rs b/backend/src/repository/m3u_playlist_iterator.rs index 4fdbf2aaf..5fede5db0 100644 --- a/backend/src/repository/m3u_playlist_iterator.rs +++ b/backend/src/repository/m3u_playlist_iterator.rs @@ -2,6 +2,7 @@ use shared::error::info_err; use shared::error::{TuliproxError}; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget}; +use shared::create_bitset; use shared::model::{ConfigTargetOptions, M3uPlaylistItem, PlaylistItemType, ProxyType, TargetType, XtreamCluster}; use crate::repository::{BPlusTreeQuery, PlaylistIteratorReader}; use crate::repository::m3u_get_file_path_for_db; @@ -14,22 +15,33 @@ use std::iter::Peekable; use log::error; use shared::utils::{extract_extension_from_url, Internable}; -#[allow(clippy::struct_excessive_bools)] -pub struct M3uPlaylistIterator { +create_bitset!( + u8, + M3uPlaylistIteratorFlags, + MaskRedirectUrl, + IncludeTypeInUrl, + RewriteResource +); + + +struct M3uPlaylistCursor { reader: Peekable>, + lookup_item: Option, +} + +pub struct M3uPlaylistIterator { + cursor: Box, base_url: String, username: String, password: String, target_options: Option, - mask_redirect_url: bool, - include_type_in_url: bool, - rewrite_resource: bool, + flags: M3uPlaylistIteratorFlagsSet, proxy_type: ProxyType, filter: Option>, - lookup_item: Option<(M3uPlaylistItem, bool)>, _file_lock: FileReadGuard, } + impl M3uPlaylistIterator { pub async fn new( cfg: &AppConfig, @@ -66,21 +78,31 @@ impl M3uPlaylistIterator { }.peekable(); let filter = user_get_bouquet_filter(&config, &user.username, None, TargetType::M3u, XtreamCluster::Live).await; + let mut flags = M3uPlaylistIteratorFlagsSet::new(); + if m3u_output.include_type_in_url { + flags.set(M3uPlaylistIteratorFlags::IncludeTypeInUrl); + } + if m3u_output.mask_redirect_url { + flags.set(M3uPlaylistIteratorFlags::MaskRedirectUrl); + } + if cfg.is_reverse_proxy_resource_rewrite_enabled() { + flags.set(M3uPlaylistIteratorFlags::RewriteResource); + } let server_info = cfg.get_user_server_info(user); Ok(Self { - reader, + cursor: Box::new(M3uPlaylistCursor { + reader, + lookup_item: None, + }), base_url: server_info.get_base_url(), username: user.username.clone(), password: user.password.clone(), target_options: target.options.clone(), - include_type_in_url: m3u_output.include_type_in_url, - mask_redirect_url: m3u_output.mask_redirect_url, + flags, filter, proxy_type: user.proxy, _file_lock: file_lock, // Save lock inside struct - rewrite_resource: cfg.is_reverse_proxy_resource_rewrite_enabled(), - lookup_item: None, }) } @@ -150,26 +172,26 @@ impl M3uPlaylistIterator { fn get_next(&mut self) -> Option<(M3uPlaylistItem, bool)> { let entry = if let Some(set) = &self.filter { - if let Some((current_item, _)) = self.lookup_item.take() { + if let Some(current_item) = self.cursor.lookup_item.take() { // Avoid cloning strings while filtering - let next_valid = Self::find_next_matching(&mut self.reader, set); - self.lookup_item = next_valid.map(|v| (v, true)); // has_next handled by iterator usually, but here we just need the item - let has_next = self.lookup_item.is_some(); + let next_valid = Self::find_next_matching(&mut self.cursor.reader, set); + self.cursor.lookup_item = next_valid; + let has_next = self.cursor.lookup_item.is_some(); Some((current_item, has_next)) } else { - let current_item = Self::find_next_matching(&mut self.reader, set); + let current_item = Self::find_next_matching(&mut self.cursor.reader, set); if let Some(item) = current_item { - self.lookup_item = Self::find_next_matching(&mut self.reader, set).map(|item| (item, true)); - let has_next = self.lookup_item.is_some(); + self.cursor.lookup_item = Self::find_next_matching(&mut self.cursor.reader, set); + let has_next = self.cursor.lookup_item.is_some(); Some((item, has_next)) } else { None } } } else { - match self.reader.next() { + match self.cursor.reader.next() { Some(Ok((_, v))) => { - let has_next = matches!(self.reader.peek(), Some(Ok(_))); + let has_next = matches!(self.cursor.reader.peek(), Some(Ok(_))); Some((v, has_next)) }, Some(Err(e)) => { @@ -188,11 +210,19 @@ impl M3uPlaylistIterator { .as_ref() .and_then(|o| o.force_redirect.as_ref()) .is_some_and(|f| f.has_cluster(m3u_pli.item_type)); - let should_rewrite_urls = if is_redirect { self.mask_redirect_url } else { true }; + let should_rewrite_urls = if is_redirect { + self.flags.contains(M3uPlaylistIteratorFlags::MaskRedirectUrl) + } else { + true + }; if should_rewrite_urls { - let stream_url = self.get_stream_url(&m3u_pli, self.include_type_in_url); - let resource_url = if self.rewrite_resource { + let stream_url = self.get_stream_url( + &m3u_pli, + self.flags + .contains(M3uPlaylistIteratorFlags::IncludeTypeInUrl), + ); + let resource_url = if self.flags.contains(M3uPlaylistIteratorFlags::RewriteResource) { Some(self.get_resource_url(&m3u_pli)) } else { None diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index eb91091c4..8d1e753bf 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -1,5 +1,5 @@ use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials}; -use crate::model::{ConfigTarget, StrmTargetOutput}; +use crate::model::{ConfigTarget, StrmTargetFlags, StrmTargetOutput}; use crate::repository::storage::ensure_target_storage_path; use crate::repository::storage_const; use crate::utils::{async_file_reader, async_file_writer, normalize_string_path, truncate_filename, @@ -688,17 +688,29 @@ fn prepare_strm_files( &strm_item_info, pli.get_tmdb_id(), strm_target_output.style, - strm_target_output.underscore_whitespace, - strm_target_output.flat, + strm_target_output + .flags + .contains(StrmTargetFlags::UnderscoreWhitespace), + strm_target_output.flags.contains(StrmTargetFlags::Flat), &mut flat_dedup_paths, ); // Conditionally generate the quality string based on the new config flag - let separator = if strm_target_output.underscore_whitespace { "_" } else { " " }; + let separator = if strm_target_output + .flags + .contains(StrmTargetFlags::UnderscoreWhitespace) + { + "_" + } else { + " " + }; let quality_string = get_quality(strm_target_output, pli, separator); // Add category suffix for flat movie structure to avoid collisions - let category_suffix = if strm_target_output.flat && pli.get_tmdb_id().is_some() && pli.header.item_type == PlaylistItemType::Video { + let category_suffix = if strm_target_output.flags.contains(StrmTargetFlags::Flat) + && pli.get_tmdb_id().is_some() + && pli.header.item_type == PlaylistItemType::Video + { let cat = sanitize_for_filename(&strm_item_info.group, false); format!("{separator}[{cat}]") } else { @@ -726,7 +738,14 @@ fn prepare_strm_files( if !collisions.is_empty() { // This separator is specifically for the multi-version naming convention. let version_separator = " "; - let separator = if strm_target_output.underscore_whitespace { "_" } else { " " }; + let separator = if strm_target_output + .flags + .contains(StrmTargetFlags::UnderscoreWhitespace) + { + "_" + } else { + " " + }; for s in &mut result { { @@ -750,7 +769,10 @@ fn prepare_strm_files( } fn get_quality(strm_target_output: &StrmTargetOutput, pli: &PlaylistItem, separator: &str) -> String { - if strm_target_output.add_quality_to_filename { + if strm_target_output + .flags + .contains(StrmTargetFlags::AddQualityToFilename) + { // Use `additional_properties` which are populated by metadata_update_manager/probe let (audio, video) = match pli.header.additional_properties.as_ref() { None => (None, None), @@ -867,7 +889,13 @@ pub async fn write_strm_playlist( } if let Err(err) = - cleanup_strm_output_directory(target_output.cleanup, &root_path, &existing_strm, &processed_strm).await + cleanup_strm_output_directory( + target_output.flags.contains(StrmTargetFlags::Cleanup), + &root_path, + &existing_strm, + &processed_strm, + ) + .await { failed.push(err); } @@ -1154,4 +1182,4 @@ async fn delete_empty_dirs_from_tree(root_path: &Path, tree_nodes: HashMap Result<(), TuliproxError> { - let Ok(mut old_tree) = BPlusTreeQuery::::try_new(old_path) else { - return Ok(()) - }; + + let Ok(mut old_tree) = BPlusTreeQuery::::try_new(old_path) else { + return Ok(()) + }; let Ok(mut new_tree) = BPlusTreeUpdate::::try_new_with_backoff(tmp_path) else { return Ok(()) @@ -732,7 +733,8 @@ fn preserve_details_input_xtream_playlist_cluster_to_disk( if old_props.has_details() { if let Ok(Some(mut new_item)) = new_tree.query(&old_item.provider_id) { if let Some(new_props) = new_item.additional_properties.as_mut() { - if needs_preserved_stream_property_merge(new_props, old_props) && merge_preserved_stream_properties(new_props, old_props) { + if needs_preserved_stream_property_merge(new_props, old_props) + && merge_preserved_stream_properties(new_props, old_props) { updates.push((new_item.provider_id, new_item)); if updates.len() >= BATCH_SIZE { let refs: Vec<(&u32, &XtreamPlaylistItem)> = updates.iter().map(|(id, pli)| (id, pli)).collect(); @@ -1126,6 +1128,7 @@ pub(crate) fn needs_update_info_details( new_stream_props: &StreamProperties, old_stream_props: &StreamProperties, ) -> bool { + let new_modified = new_stream_props.get_last_modified(); let old_modified = old_stream_props.get_last_modified(); @@ -1144,15 +1147,15 @@ fn needs_preserved_stream_property_merge( match (new_stream_props, old_stream_props) { (StreamProperties::Video(v_new), StreamProperties::Video(v_old)) - if preserve_info_details && v_old.details.is_some() => - { - v_new.details != v_old.details - } + if preserve_info_details && v_old.details.is_some() => + { + v_new.details != v_old.details + } (StreamProperties::Series(s_new), StreamProperties::Series(s_old)) - if preserve_info_details && s_old.details.is_some() => - { - s_new.details != s_old.details - } + if preserve_info_details && s_old.details.is_some() => + { + s_new.details != s_old.details + } (StreamProperties::Live(l_new), StreamProperties::Live(l_old)) => { (l_new.video.is_none() && l_old.video.is_some()) || (l_new.audio.is_none() && l_old.audio.is_some()) diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index c6e447bc2..e8894e1c3 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -200,8 +200,9 @@ pub async fn send_with_retry_and_provider( // Record the starting URL index for full-cycle detection. // This allows us to try all URLs even when starting from a non-zero index. - let start_index = provider.as_ref().map_or(0, |p| p.get_current_index()); - let max_provider_attempts = provider.as_ref().map_or(0, |p| p.urls.len()); + let (start_index, max_provider_attempts) = provider + .as_ref() + .map_or((0, 0), |p| (p.get_current_index(), p.urls.len())); let mut provider_attempts = usize::from(max_provider_attempts > 0); 'provider_loop: loop { diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 033bebd2f..00a62e36c 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -1,7 +1,7 @@ use crate::api::model::AppState; use crate::messaging::send_message; use crate::model::{is_input_expired, xtream_mapping_option_from_target_options, AppConfig, - ConfigInput, ConfigTarget, MessageContent, XtreamLoginInfo, XtreamTargetOutput}; + ConfigInput, ConfigInputFlags, ConfigTarget, MessageContent, XtreamLoginInfo, XtreamTargetOutput}; use crate::model::{InputSource, ProxyUserCredentials}; use crate::processing::parser::xtream; use crate::processing::parser::xtream::parse_xtream_series_info; @@ -23,7 +23,7 @@ use std::str::FromStr; use std::sync::Arc; -use shared::info_err; +use shared::{concat_string, info_err}; const THREE_DAYS_IN_SECS: i64 = 3 * 24 * 60 * 60; @@ -245,16 +245,14 @@ fn xtream_resolve_stream_info(app_state: &Arc, user: &ProxyUserCredent fn get_skip_cluster(input: &ConfigInput) -> Vec { let mut skip_cluster = vec![]; - if let Some(input_options) = &input.options { - if input_options.xtream_skip_live { - skip_cluster.push(XtreamCluster::Live); - } - if input_options.xtream_skip_vod { - skip_cluster.push(XtreamCluster::Video); - } - if input_options.xtream_skip_series { - skip_cluster.push(XtreamCluster::Series); - } + if input.has_flag(ConfigInputFlags::XtreamSkipLive) { + skip_cluster.push(XtreamCluster::Live); + } + if input.has_flag(ConfigInputFlags::XtreamSkipVod) { + skip_cluster.push(XtreamCluster::Video); + } + if input.has_flag(ConfigInputFlags::XtreamSkipSeries) { + skip_cluster.push(XtreamCluster::Series); } if skip_cluster.len() == 3 { info!("You have skipped all sections from xtream input {}", &input.name); @@ -346,8 +344,10 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw } }; - let username = input_source.username.as_ref().map_or("", |v| v); - let password = input_source.password.as_ref().map_or("", |v| v); + let (username, password) = ( + input_source.username.as_deref().unwrap_or(""), + input_source.password.as_deref().unwrap_or(""), + ); let is_provider_url = input_source.url.starts_with(PROVIDER_SCHEME_PREFIX); let base_input_url = if is_provider_url { @@ -383,12 +383,12 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw for (xtream_cluster, category, stream) in &ACTIONS { let is_requested = clusters.is_none_or(|c| c.contains(xtream_cluster)); if is_requested && !skip_cluster.contains(xtream_cluster) { - let input_source_category = input_source.with_url(format!("{base_url}&action={category}")); - let input_source_stream = input_source.with_url(format!("{base_url}&action={stream}")); + let input_source_category = input_source.with_url(concat_string!(&base_url, "&action=", category)); + let input_source_stream = input_source.with_url(concat_string!(&base_url, "&action=", stream)); let category_file_path = crate::utils::prepare_file_path(input.persist.as_deref(), - working_dir, format!("{category}_").as_str()); + working_dir, concat_string!(category, "_").as_str()); let stream_file_path = crate::utils::prepare_file_path(input.persist.as_deref(), - working_dir, format!("{stream}_").as_str()); + working_dir, concat_string!(stream, "_").as_str()); match futures::join!( request::get_input_json_content_as_stream(app_config, client, &input_source_category, category_file_path), diff --git a/config/source.yml b/config/source.yml index efde9b01d..118dcaf74 100644 --- a/config/source.yml +++ b/config/source.yml @@ -33,8 +33,6 @@ sources: skip_live_direct_source: true skip_video_direct_source: true skip_series_direct_source: true - resolve_series: false - resolve_vod: false trakt: lists: - user: "linaspurinis" diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 846e66acc..d828a6566 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -29,11 +29,11 @@ futures = "0.3" prost = "0" wasm-bindgen-futures = "0" bytes = "1" -regex = "1.12.2" +regex = "1.12.3" base64 = "0.22.1" cron = "0.15" fastrand = "2.3.0" -indexmap = "2.12.1" +indexmap = "2.13.0" [dependencies.web-sys] version = "0.3" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 56ee6b21c..f7d712e34 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -222,8 +222,9 @@ "PATTERN": "Pattern", "NEW_NAME": "New Name", "SKIP": "Skip", - "LIVE_STREAM_USE_PREFIX": "Live use prefix", - "LIVE_STREAM_WITHOUT_EXTENSION": "Live w/o ext.", + "LIVE_STREAMS": "Live streams", + "LIVE_STREAM_USE_PREFIX": "Use prefix", + "LIVE_STREAM_WITHOUT_EXTENSION": "Without ext.", "RESOLVE_TMDB": "Resolve TMDB", "PROBE_STREAM": "Probe Stream", "ALIAS": "Alias", diff --git a/frontend/scss/app/components/_collapse_panel.scss b/frontend/scss/app/components/_collapse_panel.scss index 2c6031f29..fe8d575e1 100644 --- a/frontend/scss/app/components/_collapse_panel.scss +++ b/frontend/scss/app/components/_collapse_panel.scss @@ -18,7 +18,7 @@ &:hover { color: var(--menu-item-hover-color); fill: var(--menu-item-hover-color); - background-color: var(--menu-item-hover-background-color); + font-weight: bold; } &-title { flex: 1 1 auto; 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 7d91f6a20..2a586f62a 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_block.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_block.scss @@ -17,6 +17,11 @@ $port-size: 10px; width: $block-width; box-sizing: border-box; + /* Safari Fixes */ + will-change: transform; + -webkit-transform-style: preserve-3d; + backface-visibility: hidden; + &-editing { .tp__source-editor__block-content { border-color: red !important; diff --git a/frontend/scss/app/components/source_editor/_source_editor_form.scss b/frontend/scss/app/components/source_editor/_source_editor_form.scss index 66fb16098..42526b36a 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_form.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_form.scss @@ -20,8 +20,8 @@ $form-width: 480px; } .tp__source-editor-form { - - padding: 0 var(--padding-micro); + padding: 0 var(--padding-mini) var(--padding-mini) var(--padding-mini); + border-radius: var(--border-radius); background-color: var(--card-background-color); &__body { diff --git a/frontend/src/app/components/playlist/input/input_options.rs b/frontend/src/app/components/playlist/input/input_options.rs index 5e070f142..bff02b003 100644 --- a/frontend/src/app/components/playlist/input/input_options.rs +++ b/frontend/src/app/components/playlist/input/input_options.rs @@ -1,5 +1,8 @@ -use crate::app::components::{make_tags, CollapsePanel, Tag, TagList}; -use shared::model::ConfigInputDto; +use crate::app::components::{chip::convert_bool_to_chip_style, make_tags, CollapsePanel, Tag, TagList}; +use shared::{ + model::ConfigInputDto, + utils::{default_probe_live_interval, default_resolve_delay_secs}, +}; use std::rc::Rc; use yew::prelude::*; use yew_i18n::use_translation; @@ -13,7 +16,7 @@ pub struct InputOptionsProps { pub fn InputOptions(props: &InputOptionsProps) -> Html { let translate = use_translation(); let tags = use_memo(props.input.clone(), |input| { - let (has_options, options1, options2) = match input.options.as_ref() { + let (has_options, options1, options2, resolve_tags, probe_tags) = match input.options.as_ref() { None => ( false, vec![(false, "LABEL.LIVE"), (false, "LABEL.VOD"), (false, "LABEL.SERIES")], @@ -21,17 +24,51 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html { (false, "LABEL.LIVE_STREAM_USE_PREFIX"), (false, "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"), (false, "LABEL.RESOLVE_TMDB"), - (false, "LABEL.PROBE_STREAM"), + ], + vec![ + Rc::new(Tag { + class: convert_bool_to_chip_style(false), + label: format!("{} / {}s", translate.t("LABEL.SERIES"), default_resolve_delay_secs()), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(false), + label: format!("{} / {}s", translate.t("LABEL.VOD"), default_resolve_delay_secs()), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(true), + label: translate.t("LABEL.RESOLVE_BACKGROUND").to_string(), + }), + ], + vec![ + Rc::new(Tag { + class: convert_bool_to_chip_style(false), + label: format!("{} / {}h", translate.t("LABEL.LIVE"), default_probe_live_interval()), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(false), + label: translate.t("LABEL.VOD").to_string(), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(false), + label: translate.t("LABEL.SERIES").to_string(), + }), ], ), Some(options) => { let has_options = options.xtream_skip_live || options.xtream_skip_vod || options.xtream_skip_series - || options.xtream_live_stream_use_prefix + || !options.xtream_live_stream_use_prefix || options.xtream_live_stream_without_extension || options.resolve_tmdb - || options.probe_stream; + || !options.resolve_background + || options.resolve_series + || options.resolve_vod + || options.probe_series + || options.probe_vod + || options.probe_live + || options.resolve_delay != default_resolve_delay_secs() + || options.probe_live_interval_hours != default_probe_live_interval(); ( has_options, @@ -44,17 +81,46 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html { (options.xtream_live_stream_use_prefix, "LABEL.LIVE_STREAM_USE_PREFIX"), (options.xtream_live_stream_without_extension, "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"), (options.resolve_tmdb, "LABEL.RESOLVE_TMDB"), - (options.probe_stream, "LABEL.PROBE_STREAM"), + ], + vec![ + Rc::new(Tag { + class: convert_bool_to_chip_style(options.resolve_series), + label: format!("{} / {}s", translate.t("LABEL.SERIES"), options.resolve_delay), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(options.resolve_vod), + label: format!("{} / {}s", translate.t("LABEL.VOD"), options.resolve_delay), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(options.resolve_background), + label: translate.t("LABEL.RESOLVE_BACKGROUND").to_string(), + }), + ], + vec![ + Rc::new(Tag { + class: convert_bool_to_chip_style(options.probe_live), + label: format!("{} / {}h", translate.t("LABEL.LIVE"), options.probe_live_interval_hours), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(options.probe_vod), + label: translate.t("LABEL.VOD").to_string(), + }), + Rc::new(Tag { + class: convert_bool_to_chip_style(options.probe_series), + label: translate.t("LABEL.SERIES").to_string(), + }), ], ) } }; - (has_options, make_tags(&options1, &translate), make_tags(&options2, &translate)) + (has_options, make_tags(&options1, &translate), make_tags(&options2, &translate), resolve_tags, probe_tags) }); let has_options = tags.0; let opts1: Vec> = tags.1.clone(); let opts2: Vec> = tags.2.clone(); + let resolve: Vec> = tags.3.clone(); + let probe: Vec> = tags.4.clone(); html! { Html {

+
+ {translate.t("LABEL.RESOLVE")} + +
+
+ {translate.t("LABEL.PROBE")} + +
{translate.t("LABEL.SKIP")} diff --git a/frontend/src/app/components/playlist/target/xtream_output.rs b/frontend/src/app/components/playlist/target/xtream_output.rs index 60dfdda2f..6c393a63f 100644 --- a/frontend/src/app/components/playlist/target/xtream_output.rs +++ b/frontend/src/app/components/playlist/target/xtream_output.rs @@ -36,43 +36,6 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html { ] }) }; - let tags_resolve = { - let output = props.output.clone(); - let translate = translator.clone(); - use_memo(output, move |output| { - vec![ - Rc::new(Tag { - class: convert_bool_to_chip_style(output.resolve_series), - label: format!("{} / {}ms", translate.t("LABEL.SERIES"), output.resolve_delay), - }), - Rc::new(Tag { - class: convert_bool_to_chip_style(output.resolve_vod), - label: format!("{} / {}ms", translate.t("LABEL.VOD"), output.resolve_delay), - }), - ] - }) - }; - let tags_probe = { - let output = props.output.clone(); - let translate = translator.clone(); - use_memo(output, move |output| { - vec![ - Rc::new(Tag { - class: convert_bool_to_chip_style(output.probe_live), - label: format!("{} / {}h", translate.t("LABEL.LIVE"), output.probe_live_interval_hours), - }), - Rc::new(Tag { - class: convert_bool_to_chip_style(output.probe_vod), - label: translate.t("LABEL.VOD").to_string(), - }), - Rc::new(Tag { - class: convert_bool_to_chip_style(output.probe_series), - label: translate.t("LABEL.SERIES").to_string(), - }), - ] - }) - }; - html! {
{ html_if!(props.output.t_filter.is_some(), { @@ -86,14 +49,6 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html { {translator.t("LABEL.SKIP_DIRECT_SOURCE")}
-
- {translator.t("LABEL.RESOLVE")} - -
-
- {translator.t("LABEL.PROBE")} - -
} } diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index 6126cfd48..3d17d28bd 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -779,7 +779,7 @@ pub fn SourceEditor() -> Html { div.style() .set_property( "transform", - &format!("translate({}px,{}px)", x + canvas_ox, y + canvas_oy), + &format!("translate3d({}px,{}px, 0)", x + canvas_ox, y + canvas_oy), ) .unwrap(); } diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index 6f1e16284..b83b12282 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -5,7 +5,7 @@ use crate::{ SourceEditorContext, TextButton, TitledCard, }, config_field_child, edit_field_bool, edit_field_date, edit_field_number_i16, edit_field_number_u16, - edit_field_text, edit_field_text_option, generate_form_reducer, html_if, + edit_field_number_u32, edit_field_text, edit_field_text_option, generate_form_reducer, html_if, }; use shared::{ error::TuliproxError, @@ -46,15 +46,19 @@ const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SERIES"; const LABEL_XTREAM_LIVE_STREAM_USE_PREFIX: &str = "LABEL.LIVE_STREAM_USE_PREFIX"; const LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION: &str = "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"; const LABEL_RESOLVE_TMDB: &str = "LABEL.RESOLVE_TMDB"; -const LABEL_PROBE_STREAM: &str = "LABEL.PROBE_STREAM"; +const LABEL_RESOLVE: &str = "LABEL.RESOLVE"; +const LABEL_PROBE: &str = "LABEL.PROBE"; +const LABEL_RESOLVE_DELAY_SEC: &str = "LABEL.RESOLVE_DELAY_SEC"; +const LABEL_RESOLVE_BACKGROUND: &str = "LABEL.RESOLVE_BACKGROUND"; +const LABEL_PROBE_LIVE_INTERVAL_HOURS: &str = "LABEL.PROBE_LIVE_INTERVAL_HOURS"; 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_ADVANCED: &str = "LABEL.ADVANCED"; const LABEL_ALIAS: &str = "LABEL.ALIAS"; +const LABEL_LIVE_STREAMS: &str = "LABEL.LIVE_STREAMS"; #[derive(Copy, Clone, PartialEq, Eq)] enum InputFormPage { @@ -114,7 +118,14 @@ generate_form_reducer!( XtreamLiveStreamUsePrefix => xtream_live_stream_use_prefix: bool, XtreamLiveStreamWithoutExtension => xtream_live_stream_without_extension: bool, ResolveTmdb => resolve_tmdb: bool, - ProbeStream => probe_stream: bool, + ResolveBackground => resolve_background: bool, + ResolveSeries => resolve_series: bool, + ResolveVod => resolve_vod: bool, + ResolveDelay => resolve_delay: u16, + ProbeLive => probe_live: bool, + ProbeVod => probe_vod: bool, + ProbeSeries => probe_series: bool, + ProbeLiveIntervalHours => probe_live_interval_hours: u32, } ); @@ -418,15 +429,37 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), xtream_skip_series, ConfigInputOptionsFormAction::XtreamSkipSeries) } - { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_USE_PREFIX), xtream_live_stream_use_prefix, ConfigInputOptionsFormAction::XtreamLiveStreamUsePrefix) } - { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION), xtream_live_stream_without_extension, ConfigInputOptionsFormAction::XtreamLiveStreamWithoutExtension) } + +
+ { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_USE_PREFIX), xtream_live_stream_use_prefix, ConfigInputOptionsFormAction::XtreamLiveStreamUsePrefix) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION), xtream_live_stream_without_extension, ConfigInputOptionsFormAction::XtreamLiveStreamWithoutExtension) } +
+
+ +
+ { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), resolve_vod, ConfigInputOptionsFormAction::ResolveVod) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), resolve_series, ConfigInputOptionsFormAction::ResolveSeries) } +
+
+ { edit_field_number_u16!(input_options_state, translate.t(LABEL_RESOLVE_DELAY_SEC), resolve_delay, ConfigInputOptionsFormAction::ResolveDelay) } +
+ { edit_field_bool!(input_options_state, translate.t(LABEL_RESOLVE_BACKGROUND), resolve_background, ConfigInputOptionsFormAction::ResolveBackground) } +
+ +
+ { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), probe_live, ConfigInputOptionsFormAction::ProbeLive) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), probe_vod, ConfigInputOptionsFormAction::ProbeVod) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), probe_series, ConfigInputOptionsFormAction::ProbeSeries) } +
+
+ { edit_field_number_u32!(input_options_state, translate.t(LABEL_PROBE_LIVE_INTERVAL_HOURS), probe_live_interval_hours, ConfigInputOptionsFormAction::ProbeLiveIntervalHours) } +
+
- }) - } + })} { edit_field_bool!(input_options_state, translate.t(LABEL_RESOLVE_TMDB), resolve_tmdb, ConfigInputOptionsFormAction::ResolveTmdb) } - { edit_field_bool!(input_options_state, translate.t(LABEL_PROBE_STREAM), probe_stream, ConfigInputOptionsFormAction::ProbeStream) } - + } }; diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index debb7d01c..c062ccd56 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -3,8 +3,7 @@ use crate::{ config::HasFormData, BlockId, BlockInstance, Card, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton, TitledCard, TraktListItemForm, }, - config_field_child, edit_field_bool, edit_field_number_u16, edit_field_number_u32, edit_field_text, - generate_form_reducer, + config_field_child, edit_field_bool, edit_field_text, generate_form_reducer, }; use shared::{ error::TuliproxError, @@ -25,11 +24,6 @@ const LABEL_SKIP_DIRECT_SOURCE: &str = "LABEL.SKIP_DIRECT_SOURCE"; const LABEL_LIVE: &str = "LABEL.LIVE"; const LABEL_VOD: &str = "LABEL.VOD"; const LABEL_SERIES: &str = "LABEL.SERIES"; -const LABEL_RESOLVE: &str = "LABEL.RESOLVE"; -const LABEL_PROBE: &str = "LABEL.PROBE"; -const LABEL_RESOLVE_DELAY_SEC: &str = "LABEL.RESOLVE_DELAY_SEC"; -const LABEL_RESOLVE_BACKGROUND: &str = "LABEL.RESOLVE_BACKGROUND"; -const LABEL_PROBE_LIVE_INTERVAL_HOURS: &str = "LABEL.PROBE_LIVE_INTERVAL_HOURS"; const LABEL_FILTER: &str = "LABEL.FILTER"; const LABEL_TRAKT_API_KEY: &str = "LABEL.API_KEY"; const LABEL_TRAKT_API_VERSION: &str = "LABEL.API_VERSION"; @@ -104,14 +98,6 @@ generate_form_reducer!( SkipLiveDirectSource => skip_live_direct_source: bool, SkipVideoDirectSource => skip_video_direct_source: bool, SkipSeriesDirectSource => skip_series_direct_source: bool, - ResolveSeries => resolve_series: bool, - ResolveVod => resolve_vod: bool, - ProbeSeries => probe_series: bool, - ProbeVod => probe_vod: bool, - ProbeLive => probe_live: bool, - ResolveDelay => resolve_delay: u16, - ResolveBackground => resolve_background: bool, - ProbeLiveIntervalHours => probe_live_interval_hours: u32, Filter => filter: Option, } ); @@ -235,26 +221,6 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { { edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), skip_series_direct_source, XtreamTargetOutputFormAction::SkipSeriesDirectSource) } - -
- { edit_field_bool!(output_form_state, translate.t(LABEL_VOD), resolve_vod, XtreamTargetOutputFormAction::ResolveVod) } - { edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), resolve_series, XtreamTargetOutputFormAction::ResolveSeries) } -
-
- { edit_field_number_u16!(output_form_state, translate.t(LABEL_RESOLVE_DELAY_SEC), resolve_delay, XtreamTargetOutputFormAction::ResolveDelay) } -
- { edit_field_bool!(output_form_state, translate.t(LABEL_RESOLVE_BACKGROUND), resolve_background, XtreamTargetOutputFormAction::ResolveBackground) } -
- -
- { edit_field_bool!(output_form_state, translate.t(LABEL_LIVE), probe_live, XtreamTargetOutputFormAction::ProbeLive) } - { edit_field_bool!(output_form_state, translate.t(LABEL_VOD), probe_vod, XtreamTargetOutputFormAction::ProbeVod) } - { edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), probe_series, XtreamTargetOutputFormAction::ProbeSeries) } -
-
- { edit_field_number_u32!(output_form_state, translate.t(LABEL_PROBE_LIVE_INTERVAL_HOURS), probe_live_interval_hours, XtreamTargetOutputFormAction::ProbeLiveIntervalHours) } -
-
{ config_field_child!(translate.t(LABEL_FILTER), { html! { , #[serde(default, skip_serializing_if = "is_blank_optional_string")] @@ -80,14 +59,6 @@ impl Default for XtreamTargetOutputDto { skip_live_direct_source: default_as_true(), skip_video_direct_source: default_as_true(), skip_series_direct_source: default_as_true(), - resolve_background: default_as_true(), - resolve_series: false, - resolve_vod: false, - probe_series: false, - probe_vod: false, - resolve_delay: default_resolve_delay_secs(), - probe_live: false, - probe_live_interval_hours: default_probe_live_interval(), trakt: None, filter: None, t_filter: None, @@ -110,12 +81,6 @@ impl XtreamTargetOutputDto { self.skip_live_direct_source || self.skip_video_direct_source || self.skip_series_direct_source - || self.resolve_background - || self.resolve_series - || self.resolve_vod - || self.probe_series - || self.probe_vod - || self.probe_live || self.trakt.is_some() || self.filter.is_some() } diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 3d8fac4bc..1bdb85db0 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -1,4 +1,5 @@ use crate::{ + create_bitset, model::{ xtream_const, ClusterFlags, CommonPlaylistItem, ConfigTargetOptions, EpisodeStreamProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamProperties, XtreamInfoDocument, @@ -617,12 +618,17 @@ impl From for CommonPlaylistItem { fn from(item: M3uPlaylistItem) -> Self { item.to_common() } } -#[allow(clippy::struct_excessive_bools)] +create_bitset!( + u8, + XtreamMappingFlags, + SkipLiveDirectSource, + SkipVideoDirectSource, + SkipSeriesDirectSource, + RewriteResourceUrl +); + pub struct XtreamMappingOptions { - pub skip_live_direct_source: bool, - pub skip_video_direct_source: bool, - pub skip_series_direct_source: bool, - pub rewrite_resource_url: bool, + pub flags: XtreamMappingFlagsSet, pub force_redirect: Option, pub reverse_item_types: PlaylistItemTypeSet, pub username: String, @@ -641,19 +647,20 @@ impl XtreamMappingOptions { virtual_id: VirtualId, ) -> Option { let is_reverse = self.is_reverse(item_type); - let resource_url = if is_reverse && self.rewrite_resource_url && self.base_url.is_some() { - let resource_url = format!( - "{}/resource/{}/{}/{}/{}", - self.base_url.as_ref().map_or_else(String::new, |b| b.clone()), - xtream_cluster.as_stream_type(), - self.username, - self.password, - virtual_id - ); - Some(resource_url) - } else { - None - }; + let resource_url = + if is_reverse && self.flags.contains(XtreamMappingFlags::RewriteResourceUrl) && self.base_url.is_some() { + let resource_url = format!( + "{}/resource/{}/{}/{}/{}", + self.base_url.as_ref().map_or_else(String::new, |b| b.clone()), + xtream_cluster.as_stream_type(), + self.username, + self.password, + virtual_id + ); + Some(resource_url) + } else { + None + }; resource_url } } diff --git a/shared/src/model/playlist_document.rs b/shared/src/model/playlist_document.rs index 1da6f583b..9bf5adb35 100644 --- a/shared/src/model/playlist_document.rs +++ b/shared/src/model/playlist_document.rs @@ -1,9 +1,9 @@ use crate::{ model::{ info_doc_utils::InfoDocUtils, LiveStreamProperties, SeriesStreamProperties, StreamProperties, - VideoStreamProperties, XtreamCluster, XtreamEmptyDoc, XtreamInfoDocument, XtreamMappingOptions, - XtreamPlaylistItem, XtreamSeriesInfoData, XtreamSeriesInfoDoc, XtreamVideoInfoData, XtreamVideoInfoDoc, - XtreamVideoMovieData, + VideoStreamProperties, XtreamCluster, XtreamEmptyDoc, XtreamInfoDocument, XtreamMappingFlags, + XtreamMappingOptions, XtreamPlaylistItem, XtreamSeriesInfoData, XtreamSeriesInfoDoc, XtreamVideoInfoData, + XtreamVideoInfoDoc, XtreamVideoMovieData, }, utils::{arc_str_option_serde, arc_str_serde, arc_str_vec_serde, Internable}, }; @@ -273,7 +273,11 @@ impl XtreamPlaylistItem { category_ids: vec![self.category_id], container_extension: video.container_extension.clone(), custom_sid: video.custom_sid.clone(), - direct_source: if options.skip_video_direct_source { empty_str } else { video.direct_source.clone() }, + direct_source: if options.flags.contains(XtreamMappingFlags::SkipVideoDirectSource) { + empty_str + } else { + video.direct_source.clone() + }, }) } @@ -294,7 +298,11 @@ impl XtreamPlaylistItem { category_ids: vec![self.category_id], custom_sid: live.custom_sid.clone(), tv_archive: live.tv_archive.unwrap_or_default(), - direct_source: if options.skip_live_direct_source { empty_str } else { live.direct_source.clone() }, + direct_source: if options.flags.contains(XtreamMappingFlags::SkipLiveDirectSource) { + empty_str + } else { + live.direct_source.clone() + }, tv_archive_duration: live.tv_archive_duration.unwrap_or_default(), }) } diff --git a/shared/src/model/playlist_info_document.rs b/shared/src/model/playlist_info_document.rs index 13f04fa2b..8e0f0e1cb 100644 --- a/shared/src/model/playlist_info_document.rs +++ b/shared/src/model/playlist_info_document.rs @@ -3,7 +3,7 @@ use crate::{ model::{ info_doc_utils::InfoDocUtils, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties, VirtualId, - XtreamCluster, XtreamMappingOptions, + XtreamCluster, XtreamMappingFlags, XtreamMappingOptions, }, utils::{arc_str_option_serde, arc_str_serde, arc_str_vec_serde, Internable}, }; @@ -423,7 +423,7 @@ impl StreamProperties { category_ids: vec![category_id], container_extension: Arc::clone(&video.container_extension), custom_sid: video.custom_sid.as_ref().map(Arc::clone), - direct_source: if options.skip_video_direct_source { + direct_source: if options.flags.contains(XtreamMappingFlags::SkipVideoDirectSource) { Arc::clone(&empty_str) } else { Arc::clone(&video.direct_source) @@ -516,7 +516,7 @@ impl StreamProperties { custom_sid: ep.custom_sid.as_ref().map(Arc::clone), added: Arc::clone(&ep.added), season: ep.season, - direct_source: if options.skip_series_direct_source { + direct_source: if options.flags.contains(XtreamMappingFlags::SkipSeriesDirectSource) { Arc::clone(&empty_str) } else { Arc::clone(&ep.direct_source) diff --git a/shared/src/utils/bitset.rs b/shared/src/utils/bitset.rs index f71d31782..b1eb0a03d 100644 --- a/shared/src/utils/bitset.rs +++ b/shared/src/utils/bitset.rs @@ -1,5 +1,5 @@ #[macro_export] -macro_rules! create_bit_set { +macro_rules! create_bitset { ($storage:ty, $enum_name:ident, $($variant:ident),*) => { paste::paste! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] @@ -34,19 +34,19 @@ macro_rules! create_bit_set { pub fn from_variants(variants: &[$enum_name]) -> Self { let mut set = Self::new(); for &v in variants { - set.add(v); + set.set(v); } set } #[inline(always)] - pub fn add(&mut self, variant: $enum_name) { + pub fn set(&mut self, variant: $enum_name) { // LLVM can optimize this to a single 'bts' or 'or' instruction self.0 |= 1 << (variant as $storage); } #[inline(always)] - pub fn remove(&mut self, variant: $enum_name) { + pub fn unset(&mut self, variant: $enum_name) { self.0 &= !(1 << (variant as $storage)); } @@ -121,3 +121,14 @@ macro_rules! create_bit_set { } }; } + +#[macro_export] +macro_rules! apply_flags { + ($dto:expr, $set:expr, $enum_type:path; $(($field:ident, $variant:ident)),* $(,)?) => { + $( + if $dto.$field { + $set.set(<$enum_type>::$variant); + } + )* + }; +} diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index d8bf8fc0c..339cca137 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -116,6 +116,9 @@ pub const fn default_probe_live_interval() -> u32 { 120 } pub const fn is_default_probe_live_interval(v: &u32) -> bool { *v == default_probe_live_interval() } +pub const fn default_resolve_background() -> bool { true } +pub const fn default_xtream_live_stream_use_prefix() -> bool { true } + ////////////////////////////// // HDHomerun Device Defaults //////////////////////////////