From fc0f1f32aa70688f4954dfb73574790096c4c2df Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:06:57 +0100 Subject: [PATCH] Enhanced Metadata Resolution, Stream Analysis & Improved STRM-Output. Added connection priority management. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Description:** This PR introduces a major overhaul of how streams are analyzed and connections are managed. It integrates `ffprobe` for deep stream analysis, adds a background task queue for metadata resolution to prevent UI blocking, introduces a connection priority system, and adds support for Live TV probing. ### πŸš€ Key Features * **FFprobe Integration & Live TV Probing**: * Automatically probes streams (VOD/Series **and now Live TV**) to determine resolution, codecs (HEVC/AV1), dynamic range (HDR/DV), bit depth, and audio channels. * **Safe Probing**: Probing strictly respects the provider's `max_connections` limit. * Docker images now include static `ffmpeg/ffprobe` binaries. * **Smart Connection Management (Priority System)**: * Introduced a priority system for connections to manage provider limits intelligently. * **Preemption**: Users with higher priority can preempt (kick) lower priority connections when provider slots are full. * **Background Queue**: Metadata resolution and stream analysis run as background tasks (Priority 0). They only run when connection slots are idle and yield immediately if a real user needs to stream. * **Enhanced Metadata Resolution**: * **Fallback Logic**: If a provider is missing the TMDB ID or Release Date, the system resolves them via the TMDB API. * **Smart Title Cleaning**: Implemented a centralized cleaner to strip common IPTV prefixes before attempting metadata matches or generating filenames, significantly improving match rates. * **Improved Output**: * **Quality Tags**: Filenames in STRM files now include detailed tags (e.g., `Movie - [4K HEVC HDR].strm`). * **Flat Grouping**: `flat: true` now correctly groups multi-version movies into single folders based on TMDB ID. * **Global Series Date**: Ensures consistent series folder naming even if episodes have different years. --- CHANGELOG.md | 77 +- Cargo.lock | 10 + README.md | 113 +- backend/Cargo.toml | 2 +- backend/src/api/api_utils.rs | 1 - backend/src/api/config_file.rs | 2 +- backend/src/api/endpoints/v1_api_playlist.rs | 14 +- backend/src/api/endpoints/websocket_api.rs | 5 +- backend/src/api/main_api.rs | 119 +- .../src/api/model/active_provider_manager.rs | 296 ++- backend/src/api/model/app_state.rs | 22 +- .../src/api/model/batch_result_collector.rs | 68 + backend/src/api/model/event_manager.rs | 4 +- .../src/api/model/metadata_update_manager.rs | 1601 +++++++++++++++++ backend/src/api/model/mod.rs | 6 +- .../src/api/model/provider_lineup_manager.rs | 13 +- .../model/streams/provider_stream_factory.rs | 20 +- backend/src/api/scheduler.rs | 10 +- backend/src/library/metadata_resolver.rs | 174 +- backend/src/library/metadata_storage.rs | 19 +- backend/src/library/processor.rs | 48 +- backend/src/library/tmdb_client.rs | 278 ++- backend/src/main.rs | 16 +- backend/src/messaging.rs | 1 + backend/src/model/config/app.rs | 36 +- backend/src/model/config/base.rs | 13 +- backend/src/model/config/input.rs | 4 + backend/src/model/config/library.rs | 24 +- backend/src/model/config/target.rs | 70 +- backend/src/model/config/video_download.rs | 29 +- backend/src/model/mod.rs | 5 - backend/src/model/playlist_categories.rs | 9 - backend/src/model/xtream.rs | 7 +- backend/src/processing/parser/xmltv.rs | 4 +- backend/src/processing/parser/xtream.rs | 23 +- backend/src/processing/playlist_watch.rs | 7 +- backend/src/processing/processor/library.rs | 6 +- backend/src/processing/processor/mod.rs | 65 +- backend/src/processing/processor/playlist.rs | 354 +++- .../src/processing/processor/stream_probe.rs | 318 ++++ backend/src/processing/processor/xtream.rs | 153 +- .../src/processing/processor/xtream_series.rs | 957 +++++++--- .../src/processing/processor/xtream_vod.rs | 752 +++++++- backend/src/repository/bplustree.rs | 14 +- backend/src/repository/bplustree_stress.rs | 14 +- backend/src/repository/library_repository.rs | 214 ++- backend/src/repository/playlist_repository.rs | 4 +- backend/src/repository/storage.rs | 2 +- backend/src/repository/strm_repository.rs | 483 ++--- backend/src/repository/target_id_mapping.rs | 50 + backend/src/repository/user_repository.rs | 1 + backend/src/repository/xtream_repository.rs | 756 +++++++- backend/src/utils/epg_parser.rs | 2 +- backend/src/utils/ffmpeg.rs | 114 ++ backend/src/utils/file/config_reader.rs | 4 +- backend/src/utils/mod.rs | 3 +- backend/src/utils/network/epg.rs | 2 +- backend/src/utils/network/request.rs | 14 +- backend/src/utils/network/xtream.rs | 221 +-- backend/src/utils/telegram.rs | 367 +++- docker/Dockerfile | 22 +- docker/Dockerfile-cross | 19 +- docker/Dockerfile-manual | 10 +- frontend/public/assets/i18n/en.json | 10 +- .../scss/app/components/_titled_card.scss | 26 +- .../source_editor/_source_editor.scss | 9 + frontend/src/app/components/config/macros.rs | 52 +- .../app/components/config/main_config_view.rs | 4 +- .../components/config/video_config_view.rs | 17 +- .../playlist/input/input_options.rs | 10 +- .../playlist/target/xtream_output.rs | 26 +- .../components/source_editor/editor_view.rs | 13 +- .../components/source_editor/input_form.rs | 142 +- .../source_editor/output_xtream_form.rs | 31 +- shared/src/model/config/api_user.rs | 4 +- shared/src/model/config/base.rs | 4 +- shared/src/model/config/input.rs | 31 +- shared/src/model/config/target.rs | 44 +- shared/src/model/config/video_download.rs | 6 + .../src/model/media_properties.rs | 158 +- shared/src/model/mod.rs | 5 +- shared/src/model/playlist.rs | 72 +- shared/src/model/stream_properties.rs | 145 +- shared/src/model/xtream.rs | 2 +- shared/src/utils/bitset.rs | 123 ++ shared/src/utils/constants.rs | 7 +- shared/src/utils/default_utils.rs | 8 + shared/src/utils/mod.rs | 1 + shared/src/utils/string_utils.rs | 20 +- 89 files changed, 7581 insertions(+), 1460 deletions(-) create mode 100644 backend/src/api/model/batch_result_collector.rs create mode 100644 backend/src/api/model/metadata_update_manager.rs delete mode 100644 backend/src/model/playlist_categories.rs create mode 100644 backend/src/processing/processor/stream_probe.rs create mode 100644 backend/src/utils/ffmpeg.rs rename {backend => shared}/src/model/media_properties.rs (60%) create mode 100644 shared/src/utils/bitset.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 085a8395f..7eebc4ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,28 +17,48 @@ - Removed `forced_retry_interval_secs`. - **Input Batch Changes**: `name` attribute is now mandatory for input type batch to ensure stable playlist UUIDs. - **Favorites Redesign**: Replaced implicit `create_alias` with explicit `add_favourite(group_name)` script function. -- **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 - rules: - - target: group - field: group - filter: Input ~ "provider_1" - order: asc - - target: channel - field: caption - filter: Group ~ "!US_TNT_ENTERTAIN!" - order: asc - sequence: - - "!CHAN_SEQ!" - - '(?i)\bHD\b' - - '(?i)\bSD\b' - ``` + - **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 + rules: + - target: group + field: group + filter: Input ~ "provider_1" + order: asc + - target: channel + field: caption + filter: Group ~ "!US_TNT_ENTERTAIN!" + order: asc + sequence: + - "!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. + ```yaml + # Before (deprecated) + output: + - type: xtream + resolve_vod: true + resolve_vod_delay: 500 + resolve_series: true + resolve_series_delay: 2 + ``` + ```yaml + # After (new consolidated) + output: + - type: xtream + resolve_vod: true + resolve_series: true + resolve_delay: 2 # Single delay for all resolution types + ``` ## 🌟 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. +- **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 background task. - **Discord Notifications**: Support for Discord notifications via webhooks with optional Handlebars templates. - **Enhanced REST Messaging**: Support for custom HTTP methods, headers, and Handlebars templating. - **Local Library Module**: Comprehensive local video file scanning and metadata management. @@ -49,6 +69,8 @@ - **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. - **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. +- **Metadata Fallback**: Automatically fetches missing TMDB IDs and release dates via the TMDB API if the provider data is incomplete. - **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. @@ -56,6 +78,21 @@ 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) +### βš™οΈ New Settings +- **config.yml**: + - Added `video.ffprobe_enabled` (default: false) and `video.ffprobe_timeout`. +- **source.yml (input options)**: + - Added `resolve_tmdb`: Triggers TMDB lookup if ID is missing. + - Added `probe_stream`: Triggers ffprobe if technical info is missing. +- **source.yml (target output)**: + - Added `probe_live`: Enables background probing for Live TV streams (default disabled). + - 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 +- **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 - **Slotted Page Architecture**: Improved space utilization and support for variable-length keys. - **Adaptive LZ4 Compression**: Optimized disk footprint for stored values. diff --git a/Cargo.lock b/Cargo.lock index f65fc1f97..bbd4fc877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4053,6 +4053,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -4357,6 +4366,7 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/README.md b/README.md index cfc20e4a5..3b213d8aa 100644 --- a/README.md +++ b/README.md @@ -290,10 +290,14 @@ It has 2 entries `extensions` and `download`. 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. +- `ffprobe_enabled`: _optional_ (default `false`). Enable or disable FFprobe analysis for streams globally. +- `ffprobe_timeout`: _optional_ (default `60`). Timeout in seconds for FFprobe analysis. ```yaml video: web_search: 'https://www.imdb.com/search/title/?title={}' + ffprobe_enabled: true + ffprobe_timeout: 60 extensions: - mkv - mp4 @@ -307,7 +311,45 @@ video: episode_pattern: '.*(?P[Ss]\\d{1,2}(.*?)[Ee]\\d{1,2}).*' ``` -### 1.5 `schedules` +### 1.5a Video Analysis & Metadata Fallback +Tuliprox can automatically analyze streams using `ffprobe` to determine resolution, codecs, and audio channels. It also fetches missing metadata (TMDB ID, Release Date) if the provider does not supply them. + +This feature is enabled globally in `video` configuration but must be activated per input options. + +**Input Config (`source.yml`):** + +```yaml +inputs: + - name: my-provider + type: xtream + url: ... + options: + # Attempts to resolve missing TMDB IDs and Release Date via TMDB API based on title + resolve_tmdb: true + # Probes stream if video/audio info is missing in provider data + probe_stream: true +``` + +**Target Config (`source.yml`):** +If `add_quality_to_filename` is set for STRM output, the analyzed quality tags are used in filenames. + +```yaml +targets: + - name: my-library + output: + - type: strm + directory: /media/strm + style: jellyfin + # Adds tags like [2160p 4K HEVC HDR] to the filename + add_quality_to_filename: true + # Groups different versions of the same movie into one folder (based on TMDB ID) + flat: true +``` + +**Note on Probing:** +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. + +### 1.6 `schedules` For `version < 2.0.11`: Schedule is optional. Format is @@ -333,11 +375,11 @@ schedules: At the given times the update is started. Do not start it every second or minute. You could be banned from your server. Twice a day should be enough. -### 1.6 `reverse_proxy` +### 1.7 `reverse_proxy` This configuration is only used for reverse proxy mode. The Reverse Proxy mode can be activated for each user individually. -#### 1.6.1 `stream` +#### 1.7.1 `stream` Attributes: - `retry` - `buffer` @@ -347,10 +389,10 @@ Attributes: - `grace_period_hold_stream` if set to `true`, the stream will only start after the grace period check has completed. Default is `false`. - `shared_burst_buffer_mb` optional (default `12`). Minimum burst buffer size (in MB) used for shared streams. -##### 1.6.1.1 `retry` +##### 1.7.1.1 `retry` If set to `true` on connection loss to provider, the stream will be reconnected. -##### 1.6.1.2 `buffer` +##### 1.7.1.2 `buffer` Has 2 attributes - `enabled` - `size` @@ -374,7 +416,7 @@ be `1024 Γ— 8 KB`, which is approximately `8 MB` as stated above. - The key difference: the `b.` approach is based on complex stream handling and more memory footprint. -##### 1.6.1.3 `throttle` +##### 1.7.1.3 `throttle` Bandwidth throttle (speed limit). Allowed units are `KB/s`,`MB/s`,`KiB/s`,`MiB/s`,`kbps`,`mbps`,`Mibps`. Default unit is `kbps`. @@ -386,24 +428,24 @@ Default unit is `kbps`. |1080p (1920x1080)| 30 fps | 5.737–12.288 | Full-HD | |4K (3840x2160) | 30 fps | 20.480–49.152 | Ultra-HD | -##### 1.6.1.3 `grace_period_millis` +##### 1.7.1.3 `grace_period_millis` If you have a provider or a user where the max_connection attribute is greater than 0, a grace period can be given during the switchover. If this period is set too short, it may result in access being denied in some cases. The default is 0 milliseconds. If the connection is not throttled, the player will play its buffered content longer than expected. -##### 1.6.1.4 `grace_period_timeout_secs` +##### 1.7.1.4 `grace_period_timeout_secs` How long the grace grant will last, until another grace grant can made. -##### 1.6.1.5 `grace_period_hold_stream` +##### 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`. -#### 1.6.2 `cache` +#### 1.7.2 `cache` LRU-Cache is for resources. If it is `enabled`, the resources/images are persisted in the given `dir`. If the cache size exceeds `size`, In an LRU cache, the least recently used items are evicted to make room for new items if the cache `size`is exceeded. -#### 1.6.3 `resource_rewrite_disabled` +#### 1.7.3 `resource_rewrite_disabled` If you have tuliprox behind a reverse proxy and dont want rewritten resource urls inside responses, you can disable the resource_url rewrite. Default value is false. If you set it `true` `cache` is disabled! Because the cache cant work without rewritten urls. @@ -423,7 +465,7 @@ reverse_proxy: dir: ./cache ``` -#### 1.6.4 `rate_limit` +#### 1.7.4 `rate_limit` Rate limiting per IP. The burst_size defines the initial number of available connections, while period_millis specifies the interval at which one connection is replenished. If behind a proxy `x-forwarded-for`, `x-real-ip` or `forwarded` should be set as header. @@ -436,7 +478,7 @@ reverse_proxy: burst_size: 10 ``` -#### 1.6.5 `disabled_header` +#### 1.7.5 `disabled_header` Controls which headers are removed before tuliprox forwards a request to the upstream provider when acting as a reverse proxy. Use `referer_header` to drop the Referer header, enable `x_header` to strip every header beginning with `X-`, and list any additional headers to remove under `custom_header`. has the following attributes: @@ -455,7 +497,7 @@ reverse_proxy: - my-custom-header ``` -#### 1.6.6 `resource_retry` +#### 1.7.6 `resource_retry` Controls how aggressively tuliprox retries upstream resource (logo, EPG, stream) downloads whenever it proxies requests for clients. It has three attributes: @@ -471,7 +513,7 @@ reverse_proxy: backoff_multiplier: 1.5 ``` -### 1.6.7 `geoip` +### 1.7.7 `geoip` `geoip` is for resolving IP addresses to country names. Disabled by default. @@ -494,7 +536,7 @@ Example: 1.0.4.0,1.0.7.255,AU ``` -#### 1.6.8 `rewrite_secret` +#### 1.7.8 `rewrite_secret` The `rewrite_secret` field is used to keep generated resource URLs stable across application restarts. Some parts of the system generate URLs that include a hashed or signed component based on an internal secret value. Normally, this secret would change after every restart, which would invalidate previously generated URLs. @@ -928,6 +970,8 @@ Each input has the following attributes: + `xtream_skip_series` true or false, series section can be skipped. + `xtream_live_stream_without_extension` default false, if set to true `.ts` extension is not added to the stream link. + `xtream_live_stream_use_prefix` default true, if set to true `/live/` prefix is added to the stream link. + + `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. - `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, @@ -1030,6 +1074,9 @@ inputs: url: 'http://localhost:8080' username: test password: test + options: + resolve_tmdb: true + probe_stream: true ``` Input alias definition for same provider with same content but different credentials. @@ -1357,13 +1404,18 @@ Each format has different properties. #### 'Target types': `xtream` - type: xtream -- skip_live_direct_source: true|false, -- skip_video_direct_source: true|false, -- skip_series_direct_source: true|false, -- resolve_series: true|false, -- resolve_series_delay: seconds, -- resolve_vod: true|false, -- resolve_vod_delay: true|false, +- 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 @@ -1421,7 +1473,7 @@ The processing order (Filter, Rename and Map) can be configured for each target ### 2.2.2.4 `options` Target options are: -- `ignore_logo` logo attributes are ignored to avoid caching logo files on devices. +- `ignore_logo` logo attributes `tvg-logo`and `tvg-logo-small` are ignored to avoid caching logo files on devices for m3u playlists. - `share_live_streams` to share live stream connections in reverse proxy mode. - `remove_duplicates` tries to remove duplicates by `url`. @@ -1435,7 +1487,7 @@ For example, with a buffer size of 2024, memory usage is at least 24 MB for **ea - `style`: Naming style convention for your media player / server (kodi, plex, emby, jellyfin) - `flat`: If `true`, creates flat directory structure with category tags in folder names - `strm_props`: List of stream properties placed within .strm file to configure how Kodi's internal player handles the media stream. -- `add_quality_to_filename`: If `true`, adds media quality tags to the filename (e.g., `Movie Title - [1080p|x265|HDR].strm`). +- `add_quality_to_filename`: If `true`, adds media quality tags to the filename (e.g., `Movie Title - [1080p 4K HEVC HDR].strm`). Supported styles: - Kodi: `Movie Name (Year) {tmdb=ID}/Movie Name (Year).strm` @@ -1481,6 +1533,13 @@ 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). +- `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. @@ -1516,7 +1575,7 @@ You can define a `Trakt` config like This will create 2 new categories with matched entries. ### 2.2.2.5 `filter` -The filter is a string with a filter statement. +The filter is a string with a statement (@see filter statements). The filter can have UnaryExpression `NOT`, BinaryExpression `AND OR`, Regexp Comparison `(Group|Title|Name|Url) ~ "regexp"` and Type Comparsison `Type = vod` or `Type = live` or `Type = series`. Filter fields are `Group`, `Title`, `Name`, `Caption`, `Url`, `Genre`, `Input` and `Type`. @@ -1997,7 +2056,7 @@ User definitions are made for the targets. Each target can have multiple users. user: - target: xc_m3u credentials: - - username: test1 + - username: NewHighPrioUser password: secret1 token: 'token1' proxy: reverse diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6301f8023..9633a426b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -44,7 +44,7 @@ flate2 = "1.1" blake3 = "1.8" bytes = "1.11" tokio-stream = { version = "0.1", features = ["sync"] } -tokio = { version = "1.49", features = ["rt-multi-thread", "parking_lot", "fs"] } +tokio = { version = "1.49", features = ["rt-multi-thread", "parking_lot", "fs", "process"] } #tokio = { version = "1.46", features = ["rt-multi-thread", "parking_lot", "fs", "tracing"] } #console-subscriber = "0" #tracing = "0.1" diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index a2ce14c90..9402518f0 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -331,7 +331,6 @@ async fn get_redirect_alternative_url( redirect_url.clone() } - /// Determines the appropriate streaming strategy for the given input and stream URL. /// /// This function attempts to acquire a connection to a streaming provider, either using a forced provider diff --git a/backend/src/api/config_file.rs b/backend/src/api/config_file.rs index 533d02661..5252bcea1 100644 --- a/backend/src/api/config_file.rs +++ b/backend/src/api/config_file.rs @@ -67,7 +67,7 @@ impl ConfigFile { let config_dto = read_config_file(config_file, true, true)?; let mapping_changed = paths.mapping_file_path.as_ref() != config_dto.mapping_path.as_ref(); let mut config: Config = Config::from(config_dto); - config.prepare(paths.config_path.as_str())?; + config.prepare(paths.config_path.as_str()).await?; update_app_state_config(app_state, config).await?; info!("Loaded config file {config_file}"); if mapping_changed { diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index ed5fe9109..2876c0c09 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -4,7 +4,6 @@ use crate::api::endpoints::extract_accept_header::ExtractAcceptHeader; use crate::api::model::AppState; use crate::auth::create_access_token; use crate::model::{parse_xmltv_for_web_ui_from_url, ConfigInput, ConfigInputOptions}; -use crate::processing::processor::playlist; use axum::response::IntoResponse; use axum::{Router}; use log::{debug, error}; @@ -15,6 +14,7 @@ use std::sync::Arc; use url::Url; use crate::api::endpoints::xmltv_api::{serve_epg_web_ui}; use crate::api::endpoints::xtream_api::xtream_get_stream_info_response; +use crate::processing::processor::exec_processing; use crate::repository::xtream_get_item_for_stream_id; fn create_config_input_for_m3u(url: &str) -> ConfigInput { @@ -30,6 +30,8 @@ fn create_config_input_for_m3u(url: &str) -> ConfigInput { xtream_skip_series: false, xtream_live_stream_without_extension: false, xtream_live_stream_use_prefix: true, + resolve_tmdb: false, + probe_stream: false, }), ..Default::default() } @@ -50,6 +52,8 @@ fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> xtream_skip_series: false, xtream_live_stream_without_extension: false, xtream_live_stream_use_prefix: true, + resolve_tmdb: false, + probe_stream: false, }), ..Default::default() } @@ -68,12 +72,14 @@ async fn playlist_update( let event_manager = Arc::clone(&app_state.event_manager); let playlist_state = Arc::clone(&app_state.playlists); let valid_targets = Arc::new(valid_targets); + let provider_manager = Arc::clone(&app_state.active_provider); let disabled_headers = app_state.get_disabled_headers(); + let metadata_manager = Arc::clone(&app_state.metadata_manager); tokio::spawn({ async move { - playlist::exec_processing(&http_client, app_config, valid_targets, Some(event_manager), - Some(playlist_state), Some(app_state.update_guard.clone()), - disabled_headers).await; + exec_processing(&http_client, app_config, valid_targets, Some(event_manager), + Some(playlist_state), Some(app_state.update_guard.clone()), + disabled_headers, Some(provider_manager), Some(metadata_manager)).await; } }); axum::http::StatusCode::ACCEPTED.into_response() diff --git a/backend/src/api/endpoints/websocket_api.rs b/backend/src/api/endpoints/websocket_api.rs index f9f25df94..77d160533 100644 --- a/backend/src/api/endpoints/websocket_api.rs +++ b/backend/src/api/endpoints/websocket_api.rs @@ -285,6 +285,9 @@ async fn handle_event_message(socket: &mut WebSocket, event: EventMessage, handl .await .map_err(|e| format!("Library scan progress event: {e} "))?; } + EventMessage::InputMetadataUpdatesCompleted(_) => { + // Internal event, ignore for websocket clients + } } } } @@ -331,4 +334,4 @@ async fn handle_user_action(app_state: &Arc, cmd: UserCommand) -> bool app_state.connection_manager.kick_connection(&addr, virtual_id, kick_secs).await } } -} +} \ No newline at end of file diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 075b7c146..fd1004127 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -11,19 +11,25 @@ use crate::api::endpoints::xmltv_api::xmltv_api_register; use crate::api::endpoints::xtream_api::xtream_api_register; use crate::api::hdhomerun_proprietary::spawn_proprietary_tasks; use crate::api::hdhomerun_ssdp::spawn_ssdp_discover_task; -use crate::api::model::{create_cache, create_http_client, create_http_client_no_redirect, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, HdHomerunAppState, PlaylistStorageState, SharedStreamManager, UpdateGuard}; +use crate::api::model::{create_cache, create_http_client, create_http_client_no_redirect, ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, HdHomerunAppState, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard}; +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::processing::processor::playlist; +use crate::processing::processor::exec_processing; +use crate::repository::get_geoip_path; use crate::repository::load_playlists_into_memory_cache; +use crate::utils::{exec_file_lock_prune, GeoIp}; use crate::VERSION; use arc_swap::{ArcSwap, ArcSwapOption}; -use axum::Router; -use axum::{middleware::Next, extract::Request}; use axum::extract::connect_info::ConnectInfo; +use axum::Router; +use axum::{extract::Request, middleware::Next}; use log::{debug, error, info}; +use shared::error::TuliproxError; use shared::utils::{concat_path_leading_slash, sanitize_sensitive_info}; +use shared::{info_err, info_err_res}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::atomic::AtomicI8; @@ -31,12 +37,6 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use tower_governor::key_extractor::SmartIpKeyExtractor; use tower_http::services::ServeDir; -use shared::error::TuliproxError; -use shared::{info_err, info_err_res}; -use crate::api::panel_api::sync_panel_api_exp_dates_on_boot; -use crate::api::sys_usage::exec_system_usage; -use crate::repository::get_geoip_path; -use crate::utils::{exec_file_lock_prune, GeoIp}; fn get_web_dir_path(web_ui_enabled: bool, web_root: &str) -> Result { let web_dir = web_root.to_string(); @@ -88,12 +88,15 @@ async fn create_shared_data( let event_manager = Arc::new(EventManager::new()); let active_provider = Arc::new(ActiveProviderManager::new(app_config, &event_manager)); let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); - let active_users = Arc::new(ActiveUserManager::new(&config,&geoip, &event_manager)); + let active_users = Arc::new(ActiveUserManager::new(&config, &geoip, &event_manager)); let connection_manager = Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager)); let client = create_http_client(app_config)?; let client_no_redirect = create_http_client_no_redirect(app_config)?; + let cancel_tokens = Arc::new(ArcSwap::from_pointee(CancelTokens::default())); + let metadata_manager = Arc::new(MetadataUpdateManager::new(cancel_tokens.load().scheduler.clone())); + Ok(AppState { forced_targets: Arc::new(ArcSwap::new(Arc::clone(forced_targets))), app_config: Arc::clone(app_config), @@ -106,10 +109,11 @@ async fn create_shared_data( active_provider, connection_manager, event_manager, - cancel_tokens: Arc::new(ArcSwap::from_pointee(CancelTokens::default())), + cancel_tokens, playlists: Arc::new(PlaylistStorageState::new()), geoip, update_guard: UpdateGuard::new(), + metadata_manager, }) } @@ -130,8 +134,11 @@ fn exec_update_on_boot( let client = client.clone(); let update_guard = Some(app_state.update_guard.clone()); let disabled_headers = app_state.get_disabled_headers(); + let provider_manager = Arc::clone(&app_state.active_provider); + let metadata_manager = Arc::clone(&app_state.metadata_manager); + tokio::spawn(async move { - playlist::exec_processing(&client, app_config_clone, targets_clone, None, Some(playlist_state), update_guard, disabled_headers).await; + exec_processing(&client, app_config_clone, targets_clone, None, Some(playlist_state), update_guard, disabled_headers, Some(provider_manager), Some(metadata_manager)).await; }); } } @@ -258,6 +265,18 @@ pub async fn start_server( } let app_shared_data = create_shared_data(&app_config, &targets).await?; let app_state = Arc::new(app_shared_data); + + // Initialize metadata manager with weak ref to app_state + // IMPORTANT: clone app_state here to keep it alive for the weak ref, but avoid moving it + app_state.metadata_manager.set_app_state(Arc::downgrade(&app_state)).await; + + // Start event listener for input metadata updates + // Clone app_state first to ensure it lives in the closure + // Explicitly creating a new Arc reference for this closure. + let app_state_for_listener = Arc::clone(&app_state); + exec_input_update_listener(&app_state_for_listener, &targets); + + // Keep using the original `app_state` below, which is valid because `Arc::clone` borrows. let shared_data = Arc::clone(&app_state); let (cancel_token_scheduler, cancel_token_hdhomerun, cancel_token_file_watch) = { @@ -435,3 +454,77 @@ async fn log_req(req: Request, next: Next) -> impl axum::response::IntoResponse debug!("Client request [{method}] -> {safe_uri} from {safe_ip}"); next.run(req).await } + + +fn exec_input_update_listener(_app_state: &Arc, _targets: &Arc) { + // TODO this method starts an playlist update after the metadata is resolved. + // Currently to many playlist updates are started and therefore it is disabled. + + // let app_state = Arc::clone(app_state); + // let targets = Arc::clone(targets); + + // tokio::spawn(async move { + // let mut rx = app_state.event_manager.get_event_channel(); + // let app_state = Arc::clone(&app_state); + // loop { + // match rx.recv().await { + // Ok(EventMessage::InputMetadataUpdatesCompleted(input_name)) => { + // // Find all targets that use this input + // let mut targets_to_update = HashSet::new(); + // let sources = app_state.app_config.sources.load(); + // + // for source in &sources.sources { + // if source.inputs.contains(&input_name) { + // for target in &source.targets { + // // Update strategy "Bundled" explicitly waits for this event. + // // Update strategy "Instant" triggers updates per item, but we ALSO trigger a full + // // update here to ensure everything is consistent (e.g. M3U files, cleaning up temp files). + // // This guarantees that any STRM/M3U files are generated/updated after metadata resolution. + // + // // Check if target matches process targets (CLI args or schedule) + // if let Some(valid_targets) = crate::api::scheduler::get_process_targets(&app_state.app_config, &targets, Some(&vec![target.name.clone()])).as_ref().into() { + // if valid_targets.enabled && !valid_targets.targets.is_empty() { + // targets_to_update.insert(target.name.clone()); + // } + // } + // } + // } + // } + // + // if !targets_to_update.is_empty() { + // let targets_to_update: Vec<_> = targets_to_update.into_iter().collect(); + // // Small delay to ensure any lingering updates or file locks from the background thread are fully released + // tokio::time::sleep(std::time::Duration::from_millis(500)).await; + // + // info!("Triggering playlist update for targets due to metadata change completion: {targets_to_update:?}"); + // + // let client = app_state.http_client.load().as_ref().clone(); + // let app_config = Arc::clone(&app_state.app_config); + // let event_manager = Arc::clone(&app_state.event_manager); + // let playlist_state = Arc::clone(&app_state.playlists); + // let disabled_headers = app_state.get_disabled_headers(); + // + // if let Ok(process_targets) = sources.validate_targets(Some(&targets_to_update)) { + // let proc_targets = Arc::new(process_targets); + // + // let update_guard = app_state.update_guard.clone(); + // + // let app_state_clone = app_state.clone(); + // tokio::spawn(async move { + // exec_processing( + // &client, app_config, proc_targets, Some(event_manager), + // Some(playlist_state), Some(update_guard), + // disabled_headers, + // Some(app_state_clone.active_provider.clone()), Some(app_state_clone.metadata_manager.clone()), + // ).await; + // }); + // } + // } + // } + // Ok(_) + // | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}, + // Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + // } + // } + // }); +} \ No newline at end of file diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index 927e5af09..e748eb4d9 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -1,14 +1,22 @@ use crate::api::model::provider_lineup_manager::{ProviderAllocation, ProviderLineupManager}; use crate::api::model::{EventManager, ProviderConfig}; use crate::model::{AppConfig, ConfigInput, GracePeriodOptions}; -use shared::utils::{sanitize_sensitive_info}; -use log::{error}; -use crate::utils::{debug_if_enabled, trace_if_enabled}; +use crate::utils::debug_if_enabled; +use log::error; +use shared::utils::sanitize_sensitive_info; +// trace_if_enabled removed use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; +use std::time::Instant; use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +const DEFAULT_FORCE_PRIORITY: i8 = -128; +const DEFAULT_USER_PRIORITY: i8 = -1; +const DEFAULT_PROBE_PRIORITY: i8 = 1; +static DUMMY_ADDR: LazyLock = LazyLock::new(|| "127.0.0.1:0".parse::().unwrap()); pub type ClientConnectionId = SocketAddr; type AllocationId = u64; @@ -18,14 +26,22 @@ pub struct ProviderHandle { pub client_id: ClientConnectionId, pub allocation_id: AllocationId, pub allocation: ProviderAllocation, + // Token to cancel the background task (e.g. internal probe) if preempted + pub cancel_token: Option, } impl ProviderHandle { - pub fn new(client_id: ClientConnectionId, allocation_id: AllocationId, allocation: ProviderAllocation) -> Self { + pub fn new( + client_id: ClientConnectionId, + allocation_id: AllocationId, + allocation: ProviderAllocation, + cancel_token: Option, + ) -> Self { Self { client_id, allocation_id, allocation, + cancel_token, } } } @@ -35,6 +51,16 @@ struct SharedAllocation { allocation_id: AllocationId, allocation: ProviderAllocation, connections: HashSet, + priority: i8, +} + +#[derive(Debug, Clone)] +struct ActiveConnectionInfo { + allocation: ProviderAllocation, + priority: i8, + // Used to signal preemption to the consumer of this connection + cancel_token: CancellationToken, + created_at: Instant, } #[derive(Debug, Clone, Default)] @@ -45,8 +71,12 @@ struct SharedConnections { #[derive(Debug, Clone, Default)] struct Connections { - single: HashMap>, + // Map Addr -> AllocationID -> Allocation Info + single: HashMap>, shared: SharedConnections, + // Index to quickly find connections by provider name for preemption + // ProviderName -> Vec<(ClientConnectionId, AllocationId)> + by_provider: HashMap, Vec<(ClientConnectionId, AllocationId)>>, } pub struct ActiveProviderManager { @@ -88,8 +118,8 @@ impl ActiveProviderManager { // Single connections for per_addr in connections.single.values() { - for allocation in per_addr.values() { - if let Some(name) = allocation.get_provider_name() { + for info in per_addr.values() { + if let Some(name) = info.allocation.get_provider_name() { *counts.entry(name).or_insert(0) += 1; } } @@ -112,8 +142,9 @@ impl ActiveProviderManager { addr: &SocketAddr, force: bool, allow_grace_override: Option, + priority: i8, ) -> Option { - // Call the specific acquisition function + // 1. Try to acquire directly let allocation = if force { self.providers.force_exact_acquire_connection(provider_or_input_name).await } else { @@ -127,22 +158,124 @@ impl ActiveProviderManager { } }; - match &allocation { - ProviderAllocation::Exhausted => {} - ProviderAllocation::Available(_) | ProviderAllocation::GracePeriod(_) => { - let provider_name = allocation.get_provider_name().unwrap_or_default(); - let allocation_id = self.next_allocation_id.fetch_add(1, Ordering::Relaxed); - let mut connections = self.connections.write().await; - let per_addr = connections.single.entry(*addr).or_default(); - if !per_addr.is_empty() { - trace_if_enabled!( - "register_connection: address {addr} already has {} provider allocations", - per_addr.len() - ); + if !matches!(allocation, ProviderAllocation::Exhausted) { + return self.register_allocation(allocation, addr, priority).await; + } + + // 2. If exhausted, try preemption (kick lower priority connection) + if !force { + if let Some(preempted_alloc) = self.try_preempt_connection(provider_or_input_name, priority).await { + return self.register_allocation(preempted_alloc, addr, priority).await; + } + } + + None + } + + async fn register_allocation(&self, allocation: ProviderAllocation, addr: &SocketAddr, priority: i8) -> Option { + let provider_name = allocation.get_provider_name().unwrap_or_default(); + let allocation_id = self.next_allocation_id.fetch_add(1, Ordering::Relaxed); + let cancel_token = CancellationToken::new(); + + let mut connections = self.connections.write().await; + let per_addr = connections.single.entry(*addr).or_default(); + + per_addr.insert(allocation_id, ActiveConnectionInfo { + allocation: allocation.clone(), + priority, + cancel_token: cancel_token.clone(), + created_at: Instant::now(), + }); + + connections.by_provider.entry(provider_name.clone()) + .or_default() + .push((*addr, allocation_id)); + + debug_if_enabled!("Added provider connection {provider_name:?} for {} (prio={})", sanitize_sensitive_info(&addr.to_string()), priority); + Some(ProviderHandle::new(*addr, allocation_id, allocation, Some(cancel_token))) + } + + async fn try_preempt_connection(&self, input_name: &Arc, new_priority: i8) -> Option { + // Victim: (addr, alloc_id, priority, created_at, is_shared) + let mut victim: Option<(ClientConnectionId, AllocationId, i8, Instant, bool)> = None; + + { + let connections = self.connections.read().await; + + for (prov_name, active_conns) in &connections.by_provider { + if self.providers.is_provider_for_input(prov_name, input_name) { + for (addr, alloc_id) in active_conns { + // Check single connections + if let Some(conns_map) = connections.single.get(addr) { + if let Some(info) = conns_map.get(alloc_id) { + if info.priority > new_priority { + let is_better = match victim { + None => true, + Some((_, _, v_prio, v_created, _)) => { + info.priority > v_prio || (info.priority == v_prio && info.created_at < v_created) + } + }; + if is_better { + victim = Some((*addr, *alloc_id, info.priority, info.created_at, false)); + } + } + } + } + + // Check shared connections (only if exactly 1 listener) + if let Some(key) = connections.shared.key_by_addr.get(addr) { + if let Some(shared) = connections.shared.by_key.get(key) { + if shared.allocation_id == *alloc_id + && shared.connections.len() == 1 + && shared.priority > new_priority + { + let is_better = match victim { + None => true, + Some((_, _, v_prio, _, _)) => shared.priority > v_prio, + }; + if is_better { + victim = Some((*addr, *alloc_id, shared.priority, Instant::now(), true)); + } + } + } + } + } } - per_addr.insert(allocation_id, allocation.clone()); - debug_if_enabled!("Added provider connection {provider_name:?} for {}", sanitize_sensitive_info(&addr.to_string())); - return Some(ProviderHandle::new(*addr, allocation_id, allocation)); + } + } + + if let Some((addr, alloc_id, v_prio, _, is_shared)) = victim { + debug_if_enabled!("Preempting {} connection from {} (prio={}) for higher priority request (prio={})", + if is_shared { "shared" } else { "single" }, + sanitize_sensitive_info(&addr.to_string()), v_prio, new_priority); + + // Get cancel token (only for single connections) + let cancel_token = if is_shared { + None + } else { + let connections = self.connections.read().await; + connections.single.get(&addr) + .and_then(|map| map.get(&alloc_id)) + .map(|info| info.cancel_token.clone()) + }; + + if let Some(token) = cancel_token { + token.cancel(); + } + + // Release the connection + let handle = ProviderHandle { + client_id: addr, + allocation_id: alloc_id, + allocation: ProviderAllocation::Exhausted, + cancel_token: None, + }; + self.release_handle(&handle).await; + + // Now try acquire again + let allocation = self.providers.acquire_connection(input_name).await; + if !matches!(allocation, ProviderAllocation::Exhausted) { + return Some(allocation); } } @@ -150,22 +283,22 @@ impl ActiveProviderManager { } pub async fn force_exact_acquire_connection(&self, provider_name: &Arc, addr: &SocketAddr) -> Option { - self.acquire_connection_inner(provider_name, addr, true, None).await + // Force acquire always uses max priority (DEFAULT_FORCE_PRIORITY) effectively, but here we just pass DEFAULT_FORCE_PRIORITY to be safe, + // though `force` param in inner overrides checks anyway. + self.acquire_connection_inner(provider_name, addr, true, None, DEFAULT_FORCE_PRIORITY).await } // Returns the next available provider connection pub async fn acquire_connection(&self, input_name: &Arc, addr: &SocketAddr) -> Option { - self.acquire_connection_inner(input_name, addr, false, None).await + self.acquire_connection_inner(input_name, addr, false, None, DEFAULT_USER_PRIORITY).await } /// Acquire a provider connection while optionally disabling provider grace allocations. - pub async fn acquire_connection_with_grace_override( + pub async fn acquire_connection_for_probe( &self, input_name: &Arc, - addr: &SocketAddr, - allow_grace: bool, ) -> Option { - self.acquire_connection_inner(input_name, addr, false, Some(allow_grace)).await + self.acquire_connection_inner(input_name, &DUMMY_ADDR, false, None, DEFAULT_PROBE_PRIORITY).await } // This method is used for redirects to cycle through the provider @@ -182,20 +315,34 @@ impl ActiveProviderManager { } pub async fn release_connection(&self, addr: &SocketAddr) { - // Single connection + // Single connection - all index updates in one lock scope let single_allocations = { let mut connections = self.connections.write().await; - connections.single.remove(addr) + if let Some(allocations) = connections.single.remove(addr) { + // Remove from by_provider index while still holding the lock + for (id, info) in &allocations { + if let Some(name) = info.allocation.get_provider_name() { + if let Some(list) = connections.by_provider.get_mut(&name) { + if let Some(idx) = list.iter().position(|(a, i)| *a == *addr && *i == *id) { + list.remove(idx); + } + } + } + } + Some(allocations) + } else { + None + } }; if let Some(allocations) = single_allocations { - for (_id, allocation) in allocations { - debug_if_enabled!( - "Released provider connection {:?} for {}", - allocation.get_provider_name().unwrap_or_default(), - sanitize_sensitive_info(&addr.to_string()) + for (_, info) in allocations { + debug_if_enabled!( + "Released provider connection {:?} for {}", + info.allocation.get_provider_name().unwrap_or_default(), + sanitize_sensitive_info(&addr.to_string()) ); - allocation.release().await; + info.allocation.release().await; } return; } @@ -246,14 +393,28 @@ impl ActiveProviderManager { let mut released = None; { let mut connections = self.connections.write().await; + + // Try removing from Single if let Some(per_addr) = connections.single.get_mut(&handle.client_id) { - released = per_addr.remove(&handle.allocation_id); - if per_addr.is_empty() { - connections.single.remove(&handle.client_id); + if let Some(info) = per_addr.remove(&handle.allocation_id) { + released = Some(info.allocation); + if per_addr.is_empty() { + connections.single.remove(&handle.client_id); + } + + // Remove from by_provider index + if let Some(name) = released.as_ref().and_then(ProviderAllocation::get_provider_name) { + if let Some(list) = connections.by_provider.get_mut(&name) { + if let Some(idx) = list.iter().position(|(a, i)| *a == handle.client_id && *i == handle.allocation_id) { + list.remove(idx); + } + } + } } } if released.is_none() { + // Try removing from Shared let mut remove_key: Option = None; // TODO O(n) over all keys, maybe better approach ist to use a Hashmap shared_by_allocation_id: HashMap for (key, shared) in &connections.shared.by_key { @@ -283,23 +444,38 @@ impl ActiveProviderManager { let extras = { let mut connections = self.connections.write().await; let mut extras = Vec::new(); - let handle = connections.single.remove(addr).and_then(|m| { + + // Find the allocation to promote (must be single) + // Logic change: we must find the specific allocation if multiple exist, but usually per client only 1 active? + // Existing logic assumes one. + let handle = if let Some(m) = connections.single.get_mut(addr) { if m.is_empty() { - return None; + None + } else { + let mut iter = m.drain(); + if let Some((id, info)) = iter.next() { + + // Collect others as extras to release + for (_, extra_info) in iter { + extras.push(extra_info.allocation); + } + + // Cleanup indices + if let Some(name) = info.allocation.get_provider_name() { + if let Some(list) = connections.by_provider.get_mut(&name) { + list.retain(|(a, _)| *a != *addr); + } + } + + connections.single.remove(addr); // Map is drained/empty now + + Some(ProviderHandle::new(*addr, id, info.allocation, Some(info.cancel_token))) + } else { + None + } } - let mut iter = m.into_iter(); - let (id, allocation) = iter.next().expect("non-empty map"); - for (_extra_id, extra_alloc) in iter { - extras.push(extra_alloc); - } - if !extras.is_empty() { - trace_if_enabled!( - "Shared connection promotion expects a single allocation for {addr}, found {}", - extras.len() + 1 - ); - } - Some(ProviderHandle::new(*addr, id, allocation)) - }); + } else { None }; + if let Some(handle) = &handle { let provider_name = handle.allocation.get_provider_name().unwrap_or_default(); @@ -308,12 +484,18 @@ impl ActiveProviderManager { sanitize_sensitive_info(&provider_name), sanitize_sensitive_info(key) ); + // Get priority from the original single connection info + let priority = connections.single.get(addr) + .and_then(|m| m.values().next()) + .map_or(DEFAULT_USER_PRIORITY, |info| info.priority); + connections.shared.by_key.insert( key.to_string(), SharedAllocation { allocation_id: handle.allocation_id, allocation: handle.allocation.clone(), connections: HashSet::from([*addr]), + priority, }, ); connections.shared.key_by_addr.insert(*addr, key.to_string()); @@ -348,4 +530,4 @@ impl ActiveProviderManager { pub async fn get_provider_connections_count(&self) -> usize { self.providers.active_connection_count().await } -} +} \ No newline at end of file diff --git a/backend/src/api/model/app_state.rs b/backend/src/api/model/app_state.rs index e5755949f..ec9d2d6e3 100644 --- a/backend/src/api/model/app_state.rs +++ b/backend/src/api/model/app_state.rs @@ -23,6 +23,7 @@ use std::time::Duration; use tokio::sync::Mutex; use tokio::task; use tokio_util::sync::CancellationToken; +use crate::api::model::metadata_update_manager::MetadataUpdateManager; macro_rules! cancel_service { ($field: ident, $changes:expr, $cancel_tokens:expr) => { @@ -330,6 +331,7 @@ pub struct AppState { pub playlists: Arc, pub geoip: Arc>, pub update_guard: UpdateGuard, + pub metadata_manager: Arc, } impl AppState { @@ -516,6 +518,24 @@ impl AppState { pub fn get_grace_options(&self) -> GracePeriodOptions { self.app_config.get_grace_options() } + + pub fn should_use_manual_redirects(&self) -> bool { + let config = self.app_config.config.load(); + config.proxy.is_some() || proxy_env_present() + } +} + +fn proxy_env_present() -> bool { + const ENV_KEYS: [&str; 3] = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + ]; + + std::env::vars().any(|(key, value)| { + ENV_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&key)) + && !value.trim().is_empty() + }) } fn schedules_changed(a: &[ScheduleConfig], b: &[ScheduleConfig]) -> bool { @@ -562,4 +582,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/batch_result_collector.rs b/backend/src/api/model/batch_result_collector.rs new file mode 100644 index 000000000..61725157b --- /dev/null +++ b/backend/src/api/model/batch_result_collector.rs @@ -0,0 +1,68 @@ +use std::mem; +use shared::model::{VideoStreamProperties, SeriesStreamProperties, LiveStreamProperties}; +use crate::api::model::ProviderIdType; + +const BATCH_THRESHOLD: usize = 200; + +#[derive(Debug, Default)] +pub struct BatchResultCollector { + pub(crate) vod: Vec<(ProviderIdType, VideoStreamProperties)>, + pub(crate) series: Vec<(ProviderIdType, SeriesStreamProperties)>, + pub(crate) live: Vec<(ProviderIdType, LiveStreamProperties)>, +} + +impl BatchResultCollector { + pub fn new() -> Self { + Self { + vod: Vec::with_capacity(BATCH_THRESHOLD), + series: Vec::with_capacity(BATCH_THRESHOLD), + live: Vec::with_capacity(BATCH_THRESHOLD), + } + } + + pub fn add_vod(&mut self, id: ProviderIdType, props: VideoStreamProperties) { + self.vod.push((id, props)); + } + + pub fn add_series(&mut self, id: ProviderIdType, props: SeriesStreamProperties) { + self.series.push((id, props)); + } + + pub fn add_live(&mut self, id: ProviderIdType, props: LiveStreamProperties) { + self.live.push((id, props)); + } + + pub fn should_flush(&self) -> bool { + self.vod.len() >= BATCH_THRESHOLD || + self.series.len() >= BATCH_THRESHOLD || + self.live.len() >= BATCH_THRESHOLD + } + + pub fn take_vod_updates(&mut self) -> Vec<(ProviderIdType, VideoStreamProperties)> { + if self.vod.is_empty() { + Vec::new() + } else { + mem::take(&mut self.vod) + } + } + + pub fn take_series_updates(&mut self) -> Vec<(ProviderIdType, SeriesStreamProperties)> { + if self.series.is_empty() { + Vec::new() + } else { + mem::take(&mut self.series) + } + } + + pub fn take_live_updates(&mut self) -> Vec<(ProviderIdType, LiveStreamProperties)> { + if self.live.is_empty() { + Vec::new() + } else { + mem::take(&mut self.live) + } + } + + pub fn is_empty(&self) -> bool { + self.vod.is_empty() && self.series.is_empty() && self.live.is_empty() + } +} diff --git a/backend/src/api/model/event_manager.rs b/backend/src/api/model/event_manager.rs index 1bfa992b4..c1d919249 100644 --- a/backend/src/api/model/event_manager.rs +++ b/backend/src/api/model/event_manager.rs @@ -13,6 +13,8 @@ pub enum EventMessage { PlaylistUpdateProgress(String, String), SystemInfoUpdate(SystemInfo), LibraryScanProgress(LibraryScanSummary), + // Triggered when MetadataUpdateManager queue drains for an input + InputMetadataUpdatesCompleted(Arc), } pub struct EventManager { @@ -59,4 +61,4 @@ impl Default for EventManager { fn default() -> Self { Self::new() } -} +} \ 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 new file mode 100644 index 000000000..f176ad19d --- /dev/null +++ b/backend/src/api/model/metadata_update_manager.rs @@ -0,0 +1,1601 @@ +use crate::api::model::ProviderHandle; +use crate::api::model::{AppState, EventMessage}; +use crate::processing::processor::{update_generic_stream_metadata, update_live_stream_metadata, update_series_metadata, update_vod_metadata}; +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 shared::error::TuliproxError; +use shared::model::{InputType, LiveStreamProperties, PlaylistItemType, SeriesStreamProperties, UUIDType, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; +use shared::utils::generate_playlist_uuid; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; + +use crate::api::model::BatchResultCollector; +use crate::repository::{get_input_storage_path, get_target_id_mapping_file, write_playlist_batch_item_upsert, xtream_get_file_path, BPlusTreeQuery}; +use crate::repository::{persist_input_live_info_batch, persist_input_series_info_batch, persist_input_vod_info_batch, TargetIdMapping}; +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); +const MAX_RETRY_BACKOFF_SECS: u64 = 60; + +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); + +/// `PlaylistItemIdType` ID can be either a String (M3U) or u32 (Xtream/TargetDB) +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ProviderIdType { + Text(Arc), + Id(u32), +} + +impl std::fmt::Display for ProviderIdType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderIdType::Text(s) => write!(f, "{s}"), + ProviderIdType::Id(id) => write!(f, "{id}"), + } + } +} + +impl From for ProviderIdType { + fn from(id: u32) -> Self { + ProviderIdType::Id(id) + } +} + +impl From<&str> for ProviderIdType { + fn from(s: &str) -> Self { + ProviderIdType::Text(Arc::from(s)) + } +} + +impl From for ProviderIdType { + fn from(s: String) -> Self { + ProviderIdType::Text(Arc::from(s.as_str())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UpdateTask { + ResolveVod { id: ProviderIdType, reason: ResolveReasonSet, delay: u16 }, + ResolveSeries { id: ProviderIdType, reason: ResolveReasonSet, delay: u16 }, + ProbeLive { id: ProviderIdType, reason: ResolveReasonSet, delay: u16, interval: u64 }, + // Generic probe for M3U/Library/etc. + ProbeStream { probe_scope: Arc, unique_id: String, url: String, item_type: PlaylistItemType, reason: ResolveReasonSet, delay: u16 }, +} + +impl UpdateTask { + pub fn delay(&self) -> u16 { + match self { + UpdateTask::ResolveVod { delay, .. } | + UpdateTask::ResolveSeries { delay, .. } | + UpdateTask::ProbeLive { delay, .. } | + UpdateTask::ProbeStream { delay, .. } => *delay, + } + } +} + +impl std::fmt::Display for UpdateTask { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UpdateTask::ResolveVod { id, reason, delay } => write!(f, "Resolve VOD {id} (Reason: {reason}, Delay: {delay}sec)"), + UpdateTask::ResolveSeries { id, reason, delay } => write!(f, "Resolve Series {id} (Reason: {reason}, Delay: {delay}sec)"), + UpdateTask::ProbeLive { id, reason, delay, interval } => write!(f, "Probe Live {id} (Reason: {reason}, Delay: {delay}sec, Interval: {interval}secs )"), + UpdateTask::ProbeStream { probe_scope, unique_id, reason, delay, .. } => write!(f, "Probe Stream {probe_scope}/{unique_id} (Reason: {reason}, Delay: {delay}sec)"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TaskKey { + Vod(u32), + VodStr(Arc), + Series(u32), + SeriesStr(Arc), + Live(u32), + LiveStr(Arc), + Stream { scope: Arc, id: Arc }, +} + +impl TaskKey { + pub fn from_task(task: &UpdateTask) -> Self { + match task { + UpdateTask::ResolveVod { id, .. } => match id { + ProviderIdType::Id(val) => TaskKey::Vod(*val), + ProviderIdType::Text(val) => TaskKey::VodStr(val.clone()), + }, + UpdateTask::ResolveSeries { id, .. } => match id { + ProviderIdType::Id(val) => TaskKey::Series(*val), + ProviderIdType::Text(val) => TaskKey::SeriesStr(val.clone()), + }, + UpdateTask::ProbeLive { id, .. } => match id { + ProviderIdType::Id(val) => TaskKey::Live(*val), + ProviderIdType::Text(val) => TaskKey::LiveStr(val.clone()), + }, + UpdateTask::ProbeStream { probe_scope, unique_id, url, .. } => { + if unique_id.trim().is_empty() { + TaskKey::Stream { scope: probe_scope.clone(), id: Arc::from(url.as_str()) } + } else { + TaskKey::Stream { scope: probe_scope.clone(), id: Arc::from(unique_id.as_str()) } + } + } + } + } +} + +/// Per-input worker context. Each input has its own worker +/// that processes tasks sequentially with rate limiting. +#[derive(Clone)] +struct InputWorkerContext { + sender: mpsc::Sender, + pending_tasks: Arc>, +} + +struct PendingTask { + task: Mutex, + generation: AtomicU64, +} + +impl PendingTask { + fn new(task: UpdateTask) -> Self { + Self { + task: Mutex::new(task), + generation: AtomicU64::new(0), + } + } +} + +/// Manager for background metadata resolution tasks. +/// +/// Architecture: Per-Input Worker Pattern +/// - Each input gets its own dedicated worker (tokio task) +/// - Tasks for the SAME input are processed sequentially with rate limiting (defined per task) +/// - Tasks for DIFFERENT inputs run in parallel +/// - Workers are spawned on-demand when first task arrives for an input +/// - Workers terminate when their channel is empty and no more senders exist +pub struct MetadataUpdateManager { + /// Per-input worker senders. Worker is spawned when entry is created. + workers: DashMap, InputWorkerContext>, + /// Global application state (weak reference to avoid cycles) + app_state: tokio::sync::Mutex>>, + /// Global cancellation token for shutdown + cancel_token: CancellationToken, +} + +impl Default for MetadataUpdateManager { + fn default() -> Self { + Self::new(CancellationToken::new()) + } +} + +impl MetadataUpdateManager { + pub fn new(cancel_token: CancellationToken) -> Self { + Self { + workers: DashMap::new(), + app_state: tokio::sync::Mutex::new(None), + cancel_token, + } + } + + pub async fn set_app_state(&self, app_state: Weak) { + let mut guard = self.app_state.lock().await; + *guard = Some(app_state); + } + + /// Spawn a background task to queue the update. + /// This is a fire-and-forget method that returns immediately. + pub fn queue_task_background(self: &Arc, input_name: Arc, task: UpdateTask) { + let this = self.clone(); + tokio::spawn(async move { + this.queue_task(input_name, task).await; + }); + } + + /// Queue a task for background processing. + /// + /// If a worker exists for the input, the task is sent to it. + /// If no worker exists, a new one is spawned. + /// + /// # Arguments + /// * `input_name` - The input this task belongs to + /// * `task` - The task to process + pub async fn queue_task(&self, input_name: Arc, task: UpdateTask) { + // Read app state once and reuse for worker creation when needed. + let app_state_weak = { + let guard = self.app_state.lock().await; + guard.clone() + }; + + // Atomically ensure there is exactly one worker context per input. + let mut worker_to_spawn: Option = None; + let ctx = match self.workers.entry(input_name.clone()) { + Entry::Occupied(entry) => entry.get().clone(), + Entry::Vacant(entry) => { + let (tx, rx) = mpsc::channel::(256); + let pending_tasks = Arc::new(DashMap::new()); + + let ctx = InputWorkerContext { + sender: tx.clone(), + pending_tasks: pending_tasks.clone(), + }; + entry.insert(ctx.clone()); + + worker_to_spawn = Some(InputWorker { + input_name: input_name.clone(), + sender: tx, + receiver: rx, + pending_tasks, + app_state_weak, + cancel_token: self.cancel_token.clone(), + batch_buffer: BatchResultCollector::new(), + db_handles: HashMap::new(), + failed_clusters: HashSet::new(), + }); + + ctx + } + }; + + if let Some(worker) = worker_to_spawn { + let workers_ref = self.workers.clone(); + let input_name_for_cleanup = input_name.clone(); + tokio::spawn(async move { + worker.run().await; + // Cleanup: remove self from workers map when done + workers_ref.remove(&input_name_for_cleanup); + }); + } + + Self::submit_task(ctx.sender.clone(), ctx.pending_tasks.clone(), &input_name, task).await; + } + + async fn submit_task( + sender: mpsc::Sender, + pending_tasks: Arc>, + input_name: &str, + task: UpdateTask, + ) { + let key = TaskKey::from_task(&task); + + if let Some(entry) = pending_tasks.get(&key) { + let mut existing = entry.task.lock().await; + let mut merged = false; + // Merge logic + match (&mut *existing, task) { + (UpdateTask::ResolveVod { reason: r1, delay: d1, .. }, UpdateTask::ResolveVod { reason: r2, delay: d2, .. }) + | (UpdateTask::ResolveSeries { reason: r1, delay: d1, .. }, UpdateTask::ResolveSeries { reason: r2, delay: d2, .. }) + | (UpdateTask::ProbeStream { reason: r1, delay: d1, .. }, UpdateTask::ProbeStream { reason: r2, delay: d2, .. }) => { + *r1 = *r1 | r2; + *d1 = min(*d1, d2); + merged = true; + } + (UpdateTask::ProbeLive { reason: r1, delay: d1, interval: i1, .. }, UpdateTask::ProbeLive { reason: r2, delay: d2, interval: i2, .. }) => { + *r1 = *r1 | r2; + *d1 = min(*d1, d2); + *i1 = min(*i1, i2); + merged = true; + } + _ => {} // Mismatched types, should not happen due to TaskKey + } + + if merged { + entry.generation.fetch_add(1, Ordering::Relaxed); + } + return; + } + + if pending_tasks.len() >= MAX_QUEUE_SIZE { + warn!("Metadata queue full for input {input_name}, dropping task"); + return; + } + + pending_tasks.insert(key.clone(), PendingTask::new(task)); + if sender.send(key.clone()).await.is_err() { + pending_tasks.remove(&key); + warn!("Failed to send task signal for input {input_name}"); + } + } + + /// Queue a task using the legacy API (for backward compatibility). + /// Uses default delay of 50ms. + pub async fn queue_task_legacy(&self, input_name: Arc, task: UpdateTask) { + self.queue_task(input_name, task).await; + } + + /// Get the number of active workers (for monitoring/debugging) + pub fn active_worker_count(&self) -> usize { + self.workers.len() + } +} + +struct DbHandle { + _guard: FileReadGuard, + query: BPlusTreeQuery, +} + +struct InputWorker { + input_name: Arc, + sender: mpsc::Sender, + receiver: mpsc::Receiver, + pending_tasks: Arc>, + app_state_weak: Option>, + cancel_token: CancellationToken, + batch_buffer: BatchResultCollector, + db_handles: HashMap, + failed_clusters: HashSet, +} + +impl InputWorker { + #[allow(clippy::too_many_lines)] + async fn run(mut self) { + debug!("Metadata worker started for input {}", &self.input_name); + + let mut processed_vod_count: usize = 0; + let mut processed_series_count: usize = 0; + let mut retry_attempts: HashMap = HashMap::new(); + let mut last_queue_log_at = Instant::now(); + let mut last_progress_log_at = Instant::now(); + let mut queue_cycle_active = false; + + let input_name = self.input_name.clone(); + let app_state_weak = self.app_state_weak.clone(); + + // Keep one prefetched task to minimize channel waits/lock churn. + let mut next_task: Option<(TaskKey, UpdateTask, u64)> = None; + + loop { + let task_data = if let Some(t) = next_task.take() { + Some(t) + } else { + self.recv_task_fast_or_wait().await + }; + + let Some((current_key, current_task, current_generation)) = task_data else { break }; + + if !queue_cycle_active { + // First entry of a new processing cycle. + queue_cycle_active = true; + processed_vod_count = 0; + processed_series_count = 0; + last_progress_log_at = Instant::now(); + // Emit queue logs promptly for the new cycle. + last_queue_log_at = Instant::now() + .checked_sub(QUEUE_LOG_INTERVAL + Duration::from_secs(1)) + .unwrap_or_else(Instant::now); + debug!("Background metadata update queue has entries for input {input_name}; starting processing"); + } + + let delay_secs = current_task.delay(); + + let mut requeue_current = false; + let mut retry_delay_secs = 0_u64; + + match Self::process_task_static( + &input_name, + app_state_weak.as_ref(), + ¤t_task, + &mut self.batch_buffer, + &mut self.db_handles, + &mut self.failed_clusters, + ) + .await + { + Ok(task_changed) => { + if Self::is_vod_task_key(¤t_key) { + processed_vod_count += 1; + } 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})"); + 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; + // subtract it here so "remaining" reflects the post-success queue size. + let (mut remaining_vod, mut remaining_series) = + Self::queue_resolve_counts(&self.pending_tasks); + if Self::is_vod_task_key(¤t_key) { + remaining_vod = remaining_vod.saturating_sub(1); + } else if Self::is_series_task_key(¤t_key) { + remaining_series = remaining_series.saturating_sub(1); + } + + let total_vod = processed_vod_count.saturating_add(remaining_vod); + let total_series = processed_series_count.saturating_add(remaining_series); + let resolved_total = processed_vod_count.saturating_add(processed_series_count); + let total_resolve = total_vod.saturating_add(total_series); + + info!("Background metadata update: {resolved_total} / {total_resolve} resolved for input {input_name} (vod: {processed_vod_count}/{total_vod}, series: {processed_series_count}/{total_series})"); + last_progress_log_at = Instant::now(); + } + } + Err(e) if e.message == TASK_ERR_NO_CONNECTION => { + requeue_current = true; + + let entry = retry_attempts.entry(current_key.clone()).or_insert(0); + *entry = entry.saturating_add(1); + let attempts = *entry; + + // If this is the only queued signal, use exponential backoff. + // Otherwise, push to the back immediately to improve throughput/fairness. + if self.receiver.is_empty() { + retry_delay_secs = Self::compute_retry_backoff_secs(current_task.delay(), attempts); + } + + debug_if_enabled!("No provider connection for task {} on input {}, requeueing (attempt={}, retry_delay={}s, queue_len={})", + current_task, + &input_name, + attempts, + retry_delay_secs, + self.receiver.len() + ); + } + Err(e) => { + retry_attempts.remove(¤t_key); + if e.message != TASK_ERR_PREEMPTED { + error!("Task {current_task} failed for input {input_name}: {e}"); + } + } + } + + // Check and flush batch + if self.batch_buffer.should_flush() { + self.release_db_handles(); + Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; + } + + if requeue_current { + if retry_delay_secs > 0 + && Self::sleep_or_cancel(&self.cancel_token, Duration::from_secs(retry_delay_secs)).await + { + break; + } + + if self.sender.send(current_key.clone()).await.is_err() { + // Channel closed, drop the pending task key to avoid leaks. + self.pending_tasks.remove(¤t_key); + retry_attempts.remove(¤t_key); + warn!("Failed to requeue task {current_task} for input {input_name}"); + } + } else { + self + .finalize_processed_task_success(¤t_key, current_generation, &input_name) + .await; + + // Rate limiting + if delay_secs > 0 + && Self::sleep_or_cancel(&self.cancel_token, Duration::from_secs(u64::from(delay_secs))).await + { + break; + } + } + + // Try to get the next task immediately to keep locks open + if next_task.is_none() { + match self.receiver.try_recv() { + Ok(key) => { + next_task = self.load_task_snapshot(key).await; + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + break; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + } + } + + let queue_has_work = + next_task.is_some() || !self.receiver.is_empty() || !self.pending_tasks.is_empty(); + + if queue_has_work { + // Avoid O(n) queue scans per task; report queue status periodically. + if last_queue_log_at.elapsed() >= QUEUE_LOG_INTERVAL { + let queue_counts = Self::queue_resolve_counts(&self.pending_tasks); + debug!("In queue to resolve vod: {}, series: {} (input: {input_name})", queue_counts.0, queue_counts.1); + last_queue_log_at = Instant::now(); + } + } else { + // Queue is drained: flush remaining batched updates immediately. + if !self.batch_buffer.is_empty() { + self.release_db_handles(); + Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; + } + + if queue_cycle_active { + info!("All pending metadata resolves completed for input {input_name}"); + if let Some(app_state) = app_state_weak.as_ref().and_then(Weak::upgrade) { + app_state + .event_manager + .send_event(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); + } + queue_cycle_active = false; + processed_vod_count = 0; + processed_series_count = 0; + } + } + } + + // Final flush + self.release_db_handles(); + Self::flush_batch_static(&input_name, app_state_weak.as_ref(), &mut self.batch_buffer).await; + + debug!("Metadata worker stopped for input {input_name}"); + } + + async fn finalize_processed_task_success( + &mut self, + current_key: &TaskKey, + current_generation: u64, + input_name: &str, + ) -> bool { + // Atomically remove processed key and reinsert it if it changed while in-flight. + if let Some((_k, removed_task)) = self.pending_tasks.remove(current_key) { + let latest_generation = removed_task.generation.load(Ordering::Relaxed); + if latest_generation != current_generation { + self.pending_tasks.insert(current_key.clone(), removed_task); + if self.sender.send(current_key.clone()).await.is_err() { + self.pending_tasks.remove(current_key); + warn!("Failed to schedule merged task replay for input {input_name}"); + return false; + } + return true; + } + } + false + } + + async fn recv_task_fast_or_wait(&mut self) -> Option<(TaskKey, UpdateTask, u64)> { + match self.receiver.try_recv() { + Ok(key) => { + return self.load_task_snapshot(key).await; + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + return None; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + } + + // When idle, release read handles to avoid writer starvation. + self.release_db_handles(); + + tokio::select! { + biased; + () = self.cancel_token.cancelled() => None, + res = self.receiver.recv() => { + match res { + Some(key) => self.load_task_snapshot(key).await, + None => None, + } + } + } + } + + async fn load_task_snapshot(&self, key: TaskKey) -> Option<(TaskKey, UpdateTask, u64)> { + let entry = self.pending_tasks.get(&key)?; + let generation = entry.generation.load(Ordering::Relaxed); + let task = entry.task.lock().await.clone(); + Some((key, task, generation)) + } + + fn release_db_handles(&mut self) { + if !self.db_handles.is_empty() { + self.db_handles.clear(); + } + if !self.failed_clusters.is_empty() { + self.failed_clusters.clear(); + } + } + + fn compute_retry_backoff_secs(base_delay_secs: u16, attempts: u8) -> u64 { + let base_delay = u64::from(base_delay_secs.max(1)); + let exp = u32::from(attempts.saturating_sub(1).min(6)); + base_delay + .saturating_mul(2_u64.saturating_pow(exp)) + .min(MAX_RETRY_BACKOFF_SECS) + } + + async fn sleep_or_cancel(cancel_token: &CancellationToken, duration: Duration) -> bool { + tokio::select! { + () = cancel_token.cancelled() => true, + () = tokio::time::sleep(duration) => false, + } + } + + fn queue_resolve_counts(pending_tasks: &DashMap) -> (usize, usize) { + let mut vod_count = 0_usize; + let mut series_count = 0_usize; + + for entry in pending_tasks { + match entry.key() { + TaskKey::Vod(_) | TaskKey::VodStr(_) => vod_count += 1, + TaskKey::Series(_) | TaskKey::SeriesStr(_) => series_count += 1, + _ => {} + } + } + + (vod_count, series_count) + } + + #[inline] + fn is_vod_task_key(key: &TaskKey) -> bool { + matches!(key, TaskKey::Vod(_) | TaskKey::VodStr(_)) + } + + #[inline] + fn is_series_task_key(key: &TaskKey) -> bool { + matches!(key, TaskKey::Series(_) | TaskKey::SeriesStr(_)) + } + + // Changed to static method + async fn flush_batch_static( + input_name: &str, + app_state_weak: Option<&Weak>, + batch_buffer: &mut BatchResultCollector, + ) { + if batch_buffer.is_empty() { return; } + + let Some(app_state) = app_state_weak.and_then(Weak::upgrade) else { return }; + let app_config = &app_state.app_config; + let working_dir = &app_config.config.load().working_dir; + let vod_updates = batch_buffer.take_vod_updates(); + let series_updates = batch_buffer.take_series_updates(); + let live_updates = batch_buffer.take_live_updates(); + + if vod_updates.is_empty() && series_updates.is_empty() && live_updates.is_empty() { + return; + } + + if let Ok(storage_path) = get_input_storage_path(input_name, working_dir).await { + if !vod_updates.is_empty() { + let mut updates: Vec<(u32, VideoStreamProperties)> = Vec::with_capacity(vod_updates.len()); + for (id, props) in &vod_updates { + if let ProviderIdType::Id(vid) = id { + updates.push((*vid, props.clone())); + } + } + + if !updates.is_empty() { + if let Err(e) = persist_input_vod_info_batch(app_config, &storage_path, XtreamCluster::Video, input_name, updates).await { + error!("Failed to flush VOD batch for input {input_name}: {e}"); + } + } + } + + if !series_updates.is_empty() { + let mut updates: Vec<(u32, SeriesStreamProperties)> = Vec::with_capacity(series_updates.len()); + for (id, props) in &series_updates { + if let ProviderIdType::Id(vid) = id { + updates.push((*vid, props.clone())); + } + } + + if !updates.is_empty() { + if let Err(e) = persist_input_series_info_batch(app_config, &storage_path, XtreamCluster::Series, input_name, updates).await { + error!("Failed to flush Series batch for input {input_name}: {e}"); + } + } + } + + if !live_updates.is_empty() { + let mut updates: Vec<(u32, LiveStreamProperties)> = Vec::with_capacity(live_updates.len()); + for (id, props) in &live_updates { + if let ProviderIdType::Id(vid) = id { + updates.push((*vid, props.clone())); + } + } + + if !updates.is_empty() { + if let Err(e) = persist_input_live_info_batch(app_config, &storage_path, XtreamCluster::Live, input_name, updates).await { + error!("Failed to flush Live batch for input {input_name}: {e}"); + } + } + } + } + + let cascade_batch = BatchResultCollector { + vod: vod_updates, + series: series_updates, + live: live_updates, + }; + + Self::cascade_updates( + &app_state, + &app_config.config.load(), + input_name, + &cascade_batch, + ).await; + } + + #[allow(clippy::too_many_lines)] + async fn cascade_updates( + app_state: &Arc, + config: &crate::model::Config, + input_name: &str, + batch: &BatchResultCollector, + ) { + if batch.is_empty() { + return; + } + + // Find targets affected by this input. + let targets = { + let sources = app_state.app_config.sources.load(); + let mut affected_targets = Vec::new(); + + for source in &sources.sources { + if source.inputs.iter().any(|i_name| i_name.as_ref() == input_name) { + for t_def in &source.targets { + affected_targets.push(t_def.clone()); + } + } + } + affected_targets + }; + + if targets.is_empty() { return; } + + for target in targets { + let target_name = &target.name; + let Some(target_path) = crate::repository::get_target_storage_path(config, target_name) else { continue; }; + let Some(storage_path) = crate::repository::xtream_get_storage_path(config, target_name) else { continue; }; + let mapping_file = get_target_id_mapping_file(&target_path); + + // Read mapping under lock and release lock immediately after opening. + let mapping = { + let _lock = app_state.app_config.file_locks.read_lock(&mapping_file).await; + match TargetIdMapping::new(&mapping_file, false) { + Ok(m) => m, + Err(e) => { + error!("Failed to open ID mapping for target {target_name}: {e}"); + continue; + } + } + }; + + let mut provider_virtual_ids: HashMap> = HashMap::new(); + let mut uuid_virtual_ids: HashMap> = HashMap::new(); + + let vod_virtual_updates = Self::collect_vod_virtual_updates( + &mapping, + input_name, + batch, + &mut provider_virtual_ids, + &mut uuid_virtual_ids, + ); + Self::apply_vod_cascade_updates( + app_state, + &target, + &storage_path, + vod_virtual_updates, + ).await; + + let series_virtual_updates = Self::collect_series_virtual_updates( + &mapping, + input_name, + batch, + &mut provider_virtual_ids, + &mut uuid_virtual_ids, + ); + Self::apply_series_cascade_updates( + app_state, + &target, + &storage_path, + series_virtual_updates, + ).await; + + let live_virtual_updates = Self::collect_live_virtual_updates( + &mapping, + input_name, + batch, + &mut provider_virtual_ids, + &mut uuid_virtual_ids, + ); + Self::apply_live_cascade_updates( + app_state, + &target, + &storage_path, + live_virtual_updates, + ).await; + } + } + + fn get_cached_uuid_virtual_id( + mapping: &TargetIdMapping, + cache: &mut HashMap>, + uuid: UUIDType, + ) -> Option { + if let Some(cached) = cache.get(&uuid) { + return *cached; + } + let resolved = mapping.get_virtual_id_by_uuid(&uuid); + cache.insert(uuid, resolved); + resolved + } + + fn collect_vod_virtual_updates<'a>( + mapping: &TargetIdMapping, + input_name: &str, + batch: &'a BatchResultCollector, + provider_virtual_ids: &mut HashMap>, + uuid_virtual_ids: &mut HashMap>, + ) -> HashMap { + let mut virtual_updates: HashMap = HashMap::new(); + if batch.vod.is_empty() { + return virtual_updates; + } + + for (pid, props) in &batch.vod { + match pid { + ProviderIdType::Id(provider_id) => { + let virtual_ids = provider_virtual_ids + .entry(*provider_id) + .or_insert_with(|| mapping.find_virtual_ids(*provider_id)); + for virtual_id in virtual_ids { + virtual_updates.insert(*virtual_id, props); + } + } + ProviderIdType::Text(provider_id_text) => { + let uuid = generate_playlist_uuid( + input_name, + provider_id_text, + PlaylistItemType::Video, + props.direct_source.as_ref(), + ); + if let Some(virtual_id) = + Self::get_cached_uuid_virtual_id(mapping, uuid_virtual_ids, uuid) + { + virtual_updates.insert(virtual_id, props); + } + } + } + } + + virtual_updates + } + + fn collect_series_virtual_updates<'a>( + mapping: &TargetIdMapping, + input_name: &str, + batch: &'a BatchResultCollector, + provider_virtual_ids: &mut HashMap>, + uuid_virtual_ids: &mut HashMap>, + ) -> HashMap { + let mut virtual_updates: HashMap = HashMap::new(); + if batch.series.is_empty() { + return virtual_updates; + } + + for (pid, props) in &batch.series { + match pid { + ProviderIdType::Id(provider_id) => { + let virtual_ids = provider_virtual_ids + .entry(*provider_id) + .or_insert_with(|| mapping.find_virtual_ids(*provider_id)); + for virtual_id in virtual_ids { + virtual_updates.insert(*virtual_id, props); + } + } + ProviderIdType::Text(provider_id_text) => { + let uuid = generate_playlist_uuid( + input_name, + provider_id_text, + PlaylistItemType::Series, + "", + ); + if let Some(virtual_id) = + Self::get_cached_uuid_virtual_id(mapping, uuid_virtual_ids, uuid) + { + virtual_updates.insert(virtual_id, props); + } + } + } + } + + virtual_updates + } + + fn collect_live_virtual_updates<'a>( + mapping: &TargetIdMapping, + input_name: &str, + batch: &'a BatchResultCollector, + provider_virtual_ids: &mut HashMap>, + uuid_virtual_ids: &mut HashMap>, + ) -> HashMap { + let mut virtual_updates: HashMap = HashMap::new(); + if batch.live.is_empty() { + return virtual_updates; + } + + for (pid, props) in &batch.live { + match pid { + ProviderIdType::Id(provider_id) => { + let virtual_ids = provider_virtual_ids + .entry(*provider_id) + .or_insert_with(|| mapping.find_virtual_ids(*provider_id)); + for virtual_id in virtual_ids { + virtual_updates.insert(*virtual_id, props); + } + } + ProviderIdType::Text(provider_id_text) => { + let uuid = generate_playlist_uuid( + input_name, + provider_id_text, + PlaylistItemType::Live, + props.direct_source.as_ref(), + ); + if let Some(virtual_id) = + Self::get_cached_uuid_virtual_id(mapping, uuid_virtual_ids, uuid) + { + virtual_updates.insert(virtual_id, props); + } + } + } + } + + virtual_updates + } + + async fn apply_vod_cascade_updates( + app_state: &Arc, + target: &crate::model::ConfigTarget, + storage_path: &std::path::Path, + virtual_updates: HashMap, + ) { + if virtual_updates.is_empty() { + return; + } + + let target_name = target.name.as_str(); + let xtream_path = xtream_get_file_path(storage_path, XtreamCluster::Video); + let updates = { + let _lock_read = app_state.app_config.file_locks.read_lock(&xtream_path).await; + let mut updates = Vec::with_capacity(virtual_updates.len()); + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + for (virtual_id, props) in &virtual_updates { + if let Ok(Some(mut item)) = query.query_zero_copy(virtual_id) { + item.additional_properties = + Some(shared::model::StreamProperties::Video(Box::new((*props).clone()))); + updates.push(item); + } + } + } + updates + }; + + if updates.is_empty() { + return; + } + + if let Err(e) = write_playlist_batch_item_upsert( + &app_state.app_config, + target_name, + XtreamCluster::Video, + &updates, + ).await { + error!("Failed to cascade VOD updates to target {target_name}: {e}"); + return; + } + + if target.use_memory_cache { + Self::update_memory_cache(app_state, target_name, XtreamCluster::Video, updates).await; + } + } + + async fn apply_series_cascade_updates( + app_state: &Arc, + target: &crate::model::ConfigTarget, + storage_path: &std::path::Path, + virtual_updates: HashMap, + ) { + if virtual_updates.is_empty() { + return; + } + + let target_name = target.name.as_str(); + let xtream_path = xtream_get_file_path(storage_path, XtreamCluster::Series); + let updates = { + let _lock_read = app_state.app_config.file_locks.read_lock(&xtream_path).await; + let mut updates = Vec::with_capacity(virtual_updates.len()); + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + for (virtual_id, props) in &virtual_updates { + if let Ok(Some(mut item)) = query.query_zero_copy(virtual_id) { + item.additional_properties = + Some(shared::model::StreamProperties::Series(Box::new((*props).clone()))); + updates.push(item); + } + } + } + updates + }; + + if updates.is_empty() { + return; + } + + if let Err(e) = write_playlist_batch_item_upsert( + &app_state.app_config, + target_name, + XtreamCluster::Series, + &updates, + ).await { + error!("Failed to cascade Series updates to target {target_name}: {e}"); + return; + } + + if target.use_memory_cache { + Self::update_memory_cache(app_state, target_name, XtreamCluster::Series, updates).await; + } + } + + async fn apply_live_cascade_updates( + app_state: &Arc, + target: &crate::model::ConfigTarget, + storage_path: &std::path::Path, + virtual_updates: HashMap, + ) { + if virtual_updates.is_empty() { + return; + } + + let target_name = target.name.as_str(); + let xtream_path = xtream_get_file_path(storage_path, XtreamCluster::Live); + let updates = { + let _lock_read = app_state.app_config.file_locks.read_lock(&xtream_path).await; + let mut updates = Vec::with_capacity(virtual_updates.len()); + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + for (virtual_id, props) in &virtual_updates { + if let Ok(Some(mut item)) = query.query_zero_copy(virtual_id) { + item.additional_properties = + Some(shared::model::StreamProperties::Live(Box::new((*props).clone()))); + updates.push(item); + } + } + } + updates + }; + + if updates.is_empty() { + return; + } + + if let Err(e) = write_playlist_batch_item_upsert( + &app_state.app_config, + target_name, + XtreamCluster::Live, + &updates, + ).await { + error!("Failed to cascade Live updates to target {target_name}: {e}"); + return; + } + + if target.use_memory_cache { + Self::update_memory_cache(app_state, target_name, XtreamCluster::Live, updates).await; + } + } + + async fn update_memory_cache( + app_state: &Arc, + target_name: &str, + cluster: XtreamCluster, + updates: Vec, + ) { + let mut playlists = app_state.playlists.data.write().await; + if let Some(playlist) = playlists.get_mut(target_name) { + if let Some(xtream_storage) = &mut playlist.xtream { + let storage = match cluster { + XtreamCluster::Live => &mut xtream_storage.live, + XtreamCluster::Video => &mut xtream_storage.vod, + XtreamCluster::Series => &mut xtream_storage.series, + }; + for item in updates { + storage.insert(item.virtual_id, item); + } + } + } + } + async fn get_or_open_query<'a>( + input_name: &str, + app_state: &Arc, + cluster: XtreamCluster, + db_handles: &'a mut HashMap, + failed_clusters: &mut HashSet, + ) -> Option<&'a mut BPlusTreeQuery> { + if failed_clusters.contains(&cluster) { + return None; + } + + if let std::collections::hash_map::Entry::Vacant(e) = db_handles.entry(cluster) { + let working_dir = &app_state.app_config.config.load().working_dir; + if let Ok(storage_path) = get_input_storage_path(input_name, working_dir).await { + let file_path = xtream_get_file_path(&storage_path, cluster); + if file_path.exists() { + let lock = app_state.app_config.file_locks.read_lock(&file_path).await; + if let Ok(query) = BPlusTreeQuery::try_new(&file_path) { + e.insert(DbHandle { _guard: lock, query }); + } else { + failed_clusters.insert(cluster); + } + } else { + // File doesn't exist, technically a failure to open but acceptable. + // We don't mark as failure to allow creation if it appears, but for read logic + // we could cache the non-existence if we wanted. + // For now, let's strictly follow the instruction about "failure cache" for *errors* or corruption. + } + } + } + + db_handles.get_mut(&cluster).map(|h| &mut h.query) + } + + // Helper for get_item_name with caching + async fn get_item_name_static( + input_name: &str, + app_state: &Arc, + task: &UpdateTask, + db_handles: &mut HashMap, + failed_clusters: &mut HashSet, + ) -> Option { + let (id, cluster) = match task { + UpdateTask::ResolveVod { id, .. } => (id, XtreamCluster::Video), + UpdateTask::ResolveSeries { id, .. } => (id, XtreamCluster::Series), + UpdateTask::ProbeLive { id, .. } => (id, XtreamCluster::Live), + UpdateTask::ProbeStream { .. } => return None, + }; + + if let ProviderIdType::Id(vid) = id { + if let Some(query) = Self::get_or_open_query(input_name, app_state, cluster, db_handles, failed_clusters).await { + if let Ok(Some(item)) = query.query_zero_copy(vid) { + return Some(if item.title.is_empty() { item.name.to_string() } else { item.title.to_string() }); + } + } + } + None + } + + async fn process_task_static( + input_name: &Arc, + app_state_weak: Option<&Weak>, + task: &UpdateTask, + collector: &mut BatchResultCollector, + db_handles: &mut HashMap, + failed_clusters: &mut HashSet, + ) -> Result { + let app_state = app_state_weak + .and_then(Weak::upgrade) + .ok_or_else(|| shared::error::info_err!("AppState not available"))?; + + let Some(input_base) = app_state.app_config.get_input_by_name(input_name) else { + return Err(shared::error::info_err!("Input {} not found", input_name)); + }; + + if !input_base.enabled { + return Err(shared::error::info_err!("Input {} is disabled", input_name)); + } + + let needs_probe_connection = + Self::task_needs_provider_connection(task, input_base.input_type); + + // Reserve provider capacity only for actual probe work (ffprobe paths). + let provider_handle = if needs_probe_connection { + let Some(handle) = app_state.active_provider.acquire_connection_for_probe(input_name).await else { + debug_if_enabled!("No provider connection available for background task {}, skipping...", task); + return Err(shared::error::info_err!("{}", TASK_ERR_NO_CONNECTION)); + }; + Some(handle) + } else { + None + }; + + let item_title = Self::get_item_name_static(input_name, &app_state, task, db_handles, failed_clusters).await; + + let config_to_use = provider_handle + .as_ref() + .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); + + let pre_vod_updates = collector.vod.len(); + let pre_series_updates = collector.series.len(); + let pre_live_updates = collector.live.len(); + + // Determine input to use (may be alias) + let input_to_use = config_to_use + .filter(|alloc| alloc.name != input_base.name) + .and_then(|alloc| { + input_base.aliases.as_ref()? + .iter() + .find(|a| a.enabled && a.name == alloc.name) + }) + .map(|alias_def| { + let mut temp_input = (*input_base).clone(); + temp_input.url.clone_from(&alias_def.url); + temp_input.username.clone_from(&alias_def.username); + temp_input.password.clone_from(&alias_def.password); + Arc::new(temp_input) + }) + .unwrap_or(input_base); + + let client = if app_state.should_use_manual_redirects() { + app_state.http_client_no_redirect.load() + } else { + app_state.http_client.load() + }; + + // Execute task; probe tasks get a reserved provider handle, resolve tasks don't. + let res = if let Some(handle) = provider_handle.as_ref() { + if let Some(token) = &handle.cancel_token { + tokio::select! { + biased; + + () = token.cancelled() => { + debug_if_enabled!("Metadata update task preempted by user request for input {}", input_name); + Err(shared::error::info_err!("{}", TASK_ERR_PREEMPTED)) + } + + res = Self::execute_task_inner_static(&app_state, &client, &input_to_use, task, item_title.as_deref(), Some(handle), collector, db_handles, failed_clusters) => { + res + } + } + } else { + Self::execute_task_inner_static(&app_state, &client, &input_to_use, task, item_title.as_deref(), Some(handle), collector, db_handles, failed_clusters).await + } + } else { + Self::execute_task_inner_static(&app_state, &client, &input_to_use, task, item_title.as_deref(), None, collector, db_handles, failed_clusters).await + }; + + if provider_handle.is_some() { + app_state.connection_manager.release_provider_handle(provider_handle).await; + } + match res { + Ok(()) => { + let task_changed = match task { + UpdateTask::ResolveVod { .. } => collector.vod.len() > pre_vod_updates, + UpdateTask::ResolveSeries { .. } => collector.series.len() > pre_series_updates, + UpdateTask::ProbeLive { .. } => collector.live.len() > pre_live_updates, + UpdateTask::ProbeStream { .. } => true, + }; + Ok(task_changed) + } + Err(e) => Err(e), + } + } + + fn task_needs_provider_connection(task: &UpdateTask, input_type: InputType) -> bool { + match task { + UpdateTask::ProbeLive { .. } => true, + // Local library probing is fully local and must not depend on provider capacity. + UpdateTask::ProbeStream { .. } => !matches!(input_type, InputType::Library), + UpdateTask::ResolveVod { .. } | UpdateTask::ResolveSeries { .. } => false, + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_task_inner_static( + app_state: &Arc, + client: &reqwest::Client, + input: &Arc, + task: &UpdateTask, + item_title: Option<&str>, + active_handle: Option<&ProviderHandle>, + collector: &mut BatchResultCollector, + db_handles: &mut HashMap, + failed_clusters: &mut HashSet, + ) -> Result<(), TuliproxError> { + match task { + UpdateTask::ResolveVod { id, reason, .. } => { + let fetch_info = reason.contains(ResolveReason::Info); + + let query_opt = Self::get_or_open_query(&input.name, app_state, XtreamCluster::Video, db_handles, failed_clusters).await; + + match update_vod_metadata( + &app_state.app_config, + client, + input, + id.clone(), + active_handle, + &app_state.active_provider, + item_title, + false, // Batch collect + fetch_info, + query_opt, + ).await { + Ok(Some(props)) => { + collector.add_vod(id.clone(), props); + Ok(()) + } + Ok(None) => Ok(()), + Err(e) => Err(e), + } + } + UpdateTask::ResolveSeries { id, reason, .. } => { + let fetch_info = reason.contains(ResolveReason::Info); + + // Get handle for Series + let query_opt = Self::get_or_open_query(&input.name, app_state, XtreamCluster::Series, db_handles, failed_clusters).await; + + match update_series_metadata( + &app_state.app_config, + client, + input, + id.clone(), + &app_state.active_provider, + active_handle, + item_title, + false, // Batch collect + fetch_info, + reason.contains(ResolveReason::Probe), + query_opt, + ).await { + Ok(Some(props)) => { + collector.add_series(id.clone(), props); + Ok(()) + } + Ok(None) => Ok(()), + Err(e) => Err(e), + } + } + UpdateTask::ProbeLive { id, .. } => { + let query_opt = Self::get_or_open_query(&input.name, app_state, XtreamCluster::Live, db_handles, failed_clusters).await; + + match update_live_stream_metadata( + &app_state.app_config, + input, + id.clone(), + false, + query_opt, + ).await { + Ok(Some(props)) => { + collector.add_live(id.clone(), props); + Ok(()) + } + Ok(None) => Ok(()), + Err(e) => Err(e), + } + } + UpdateTask::ProbeStream { unique_id, url, item_type, .. } => { + // Generic probe doesn't support batching yet + update_generic_stream_metadata( + &app_state.app_config, + input.as_ref(), + unique_id, + url, + *item_type, + &app_state.active_provider, + active_handle, + ).await + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc; + + #[tokio::test] + async fn queue_task_creates_single_worker_per_input_under_concurrency() { + let cancel_token = CancellationToken::new(); + let manager = Arc::new(MetadataUpdateManager::new(cancel_token.clone())); + let input_name: Arc = Arc::from("race_input"); + + let mut joins = Vec::new(); + for id in 0..32u32 { + let manager_cloned = manager.clone(); + let input_cloned = input_name.clone(); + joins.push(tokio::spawn(async move { + manager_cloned + .queue_task( + input_cloned, + UpdateTask::ResolveVod { + id: ProviderIdType::Id(id), + reason: ResolveReasonSet::default(), + delay: 0, + }, + ) + .await; + })); + } + + for join in joins { + join.await.expect("queue task spawn should complete"); + } + + // Allow spawned worker startup to settle. + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(manager.active_worker_count(), 1); + + cancel_token.cancel(); + } + + #[tokio::test] + async fn submit_task_merges_existing_task_and_increments_generation() { + let (tx, mut rx) = mpsc::channel::(8); + let pending_tasks = Arc::new(DashMap::new()); + + let task_initial = UpdateTask::ResolveVod { + id: ProviderIdType::Id(42), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Info]), + delay: 10, + }; + MetadataUpdateManager::submit_task(tx.clone(), pending_tasks.clone(), "input_a", task_initial).await; + + let task_merge = UpdateTask::ResolveVod { + id: ProviderIdType::Id(42), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), + delay: 2, + }; + MetadataUpdateManager::submit_task(tx, pending_tasks.clone(), "input_a", task_merge).await; + + let first_signal = rx.try_recv().expect("first signal should be queued"); + assert_eq!(first_signal, TaskKey::Vod(42)); + assert!(matches!( + rx.try_recv(), + Err( + tokio::sync::mpsc::error::TryRecvError::Empty + | tokio::sync::mpsc::error::TryRecvError::Disconnected + ) + )); + + let entry = pending_tasks + .get(&TaskKey::Vod(42)) + .expect("pending entry should exist"); + assert_eq!(entry.generation.load(Ordering::Relaxed), 1); + + let merged = entry.task.lock().await.clone(); + match merged { + UpdateTask::ResolveVod { reason, delay, .. } => { + assert!(reason.contains(ResolveReason::Info)); + assert!(reason.contains(ResolveReason::Probe)); + assert_eq!(delay, 2); + } + other => panic!("unexpected task type after merge: {other:?}"), + } + } + + #[tokio::test] + async fn finalize_processed_task_success_requeues_when_generation_changed() { + let (tx, rx) = mpsc::channel::(8); + let pending_tasks = Arc::new(DashMap::new()); + let key = TaskKey::Vod(7); + + pending_tasks.insert( + key.clone(), + PendingTask::new(UpdateTask::ResolveVod { + id: ProviderIdType::Id(7), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Info]), + delay: 0, + }), + ); + if let Some(entry) = pending_tasks.get(&key) { + entry.generation.store(1, Ordering::Relaxed); + } + + let mut worker = InputWorker { + input_name: Arc::from("input_a"), + sender: tx, + receiver: rx, + pending_tasks: pending_tasks.clone(), + app_state_weak: None, + cancel_token: CancellationToken::new(), + batch_buffer: BatchResultCollector::new(), + db_handles: HashMap::new(), + failed_clusters: HashSet::new(), + }; + + let requeued = worker + .finalize_processed_task_success(&key, 0, "input_a") + .await; + assert!(requeued); + assert!(pending_tasks.contains_key(&key)); + assert_eq!( + worker + .receiver + .try_recv() + .expect("requeued signal should be present"), + key + ); + } + + #[tokio::test] + async fn finalize_processed_task_success_removes_when_unchanged() { + let (tx, rx) = mpsc::channel::(8); + let pending_tasks = Arc::new(DashMap::new()); + let key = TaskKey::Vod(9); + + pending_tasks.insert( + key.clone(), + PendingTask::new(UpdateTask::ResolveVod { + id: ProviderIdType::Id(9), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Info]), + delay: 0, + }), + ); + + let mut worker = InputWorker { + input_name: Arc::from("input_b"), + sender: tx, + receiver: rx, + pending_tasks: pending_tasks.clone(), + app_state_weak: None, + cancel_token: CancellationToken::new(), + batch_buffer: BatchResultCollector::new(), + db_handles: HashMap::new(), + failed_clusters: HashSet::new(), + }; + + let requeued = worker + .finalize_processed_task_success(&key, 0, "input_b") + .await; + assert!(!requeued); + assert!(!pending_tasks.contains_key(&key)); + assert!(matches!( + worker.receiver.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + + #[test] + fn task_needs_provider_connection_skips_library_probe_stream() { + let task = UpdateTask::ProbeStream { + probe_scope: Arc::from("input_a"), + unique_id: "u1".to_string(), + url: "file:///movie.mkv".to_string(), + item_type: PlaylistItemType::LocalVideo, + reason: ResolveReasonSet::from_variants(&[ResolveReason::MissingDetails]), + delay: 0, + }; + + assert!(!InputWorker::task_needs_provider_connection( + &task, + InputType::Library + )); + } + + #[test] + fn task_needs_provider_connection_keeps_non_library_probe_stream() { + let task = UpdateTask::ProbeStream { + probe_scope: Arc::from("input_a"), + unique_id: "u1".to_string(), + url: "http://example.com/stream.m3u8".to_string(), + item_type: PlaylistItemType::Video, + reason: ResolveReasonSet::from_variants(&[ResolveReason::MissingDetails]), + delay: 0, + }; + + assert!(InputWorker::task_needs_provider_connection( + &task, + InputType::M3u + )); + assert!(InputWorker::task_needs_provider_connection( + &task, + InputType::Xtream + )); + } + + #[test] + fn task_needs_provider_connection_keeps_live_probe() { + let task = UpdateTask::ProbeLive { + id: ProviderIdType::Id(1), + reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), + delay: 0, + interval: 60, + }; + + assert!(InputWorker::task_needs_provider_connection( + &task, + InputType::Library + )); + } +} diff --git a/backend/src/api/model/mod.rs b/backend/src/api/model/mod.rs index ac87198c8..b249f7c4c 100644 --- a/backend/src/api/model/mod.rs +++ b/backend/src/api/model/mod.rs @@ -14,8 +14,9 @@ mod playlist_mem_cache; mod provider_lineup_manager; mod connection_manager; mod update_guard; +mod metadata_update_manager; -pub(in crate::api) use self::active_provider_manager::*; +pub use self::active_provider_manager::*; pub(in crate::api) use self::active_user_manager::*; pub use self::app_state::*; pub use self::connection_manager::*; @@ -31,3 +32,6 @@ pub(in crate::api) use self::stream_error::*; pub(crate) use self::streams::*; pub(in crate::api) use self::xtream::*; pub use self::update_guard::*; +pub use self::metadata_update_manager::*; +mod batch_result_collector; +pub use self::batch_result_collector::*; diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index dac9505f8..368a4e43d 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -881,6 +881,17 @@ impl ProviderLineupManager { false } } + + pub fn is_provider_for_input(&self, provider_name: &str, input_name: &str) -> bool { + let lineups = self.providers.load(); + if let Some((lineup, _)) = Self::get_provider_config_by_name(&provider_name.into(), &lineups) { + match lineup { + ProviderLineup::Single(_) => return input_name == provider_name, + ProviderLineup::Multi(m) => return m.name.as_ref() == input_name, + } + } + false + } } @@ -1200,4 +1211,4 @@ mod tests { assert_eq!(available_count.load(Ordering::Acquire), 0); assert_eq!(grace_period_count.load(Ordering::Acquire), 0); } -} +} \ No newline at end of file diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 10e638f24..c2d8619a0 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -347,24 +347,6 @@ fn collect_debug_headers(headers: &HeaderMap) -> Vec<(String, String)> { .collect() } -fn proxy_env_present() -> bool { - const ENV_KEYS: [&str; 3] = [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - ]; - - std::env::vars().any(|(key, value)| { - ENV_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&key)) - && !value.trim().is_empty() - }) -} - -fn should_use_manual_redirects(app_state: &Arc) -> bool { - let config = app_state.app_config.config.load(); - config.proxy.is_some() || proxy_env_present() -} - async fn send_with_manual_redirects( request_client: &reqwest::Client, stream_options: &ProviderStreamFactoryOptions, @@ -427,7 +409,7 @@ async fn provider_stream_request( request_client: &reqwest::Client, stream_options: &ProviderStreamFactoryOptions, ) -> Result, StatusCode> { - let response_result = if should_use_manual_redirects(app_state) { + let response_result = if app_state.should_use_manual_redirects() { let client_no_redirect = app_state.http_client_no_redirect.load(); send_with_manual_redirects(&client_no_redirect, stream_options, app_state).await } else { diff --git a/backend/src/api/scheduler.rs b/backend/src/api/scheduler.rs index 95c8cf635..cfe39d0ad 100644 --- a/backend/src/api/scheduler.rs +++ b/backend/src/api/scheduler.rs @@ -1,7 +1,7 @@ use crate::api::model::AppState; use crate::api::panel_api::sync_panel_api_exp_dates_on_boot; use crate::model::{AppConfig, ProcessTargets, ScheduleConfig}; -use crate::processing::processor::playlist::exec_processing; +use crate::processing::processor::exec_processing; use crate::utils::exit; use chrono::{DateTime, FixedOffset, Local}; use cron::Schedule; @@ -62,10 +62,12 @@ async fn start_scheduler(client: reqwest::Client, expression: &str, app_state: A let app_config = Arc::clone(&app_state.app_config); let event_manager = Arc::clone(&app_state.event_manager); let playlist_state = app_state.playlists.clone(); + let provider_manager = Arc::clone(&app_state.active_provider); let disabled_headers = app_state.get_disabled_headers(); + let metadata_manager = Arc::clone(&app_state.metadata_manager); sync_panel_api_exp_dates_on_boot(&app_state).await; exec_processing(&client, app_config, Arc::clone(&targets), Some(event_manager), - Some(playlist_state), Some(app_state.update_guard.clone()), disabled_headers).await; + Some(playlist_state), Some(app_state.update_guard.clone()), disabled_headers, Some(provider_manager), Some(metadata_manager)).await; } () = cancel.cancelled() => { break; @@ -78,7 +80,7 @@ async fn start_scheduler(client: reqwest::Client, expression: &str, app_state: A } } -fn get_process_targets(cfg: &Arc, process_targets: &Arc, exec_targets: Option<&Vec>) -> Arc { +pub fn get_process_targets(cfg: &Arc, process_targets: &Arc, exec_targets: Option<&Vec>) -> Arc { let sources = cfg.sources.load(); if let Ok(user_targets) = sources.validate_targets(exec_targets) { if user_targets.enabled { @@ -162,4 +164,4 @@ mod tests { assert!(runs.load(Ordering::Acquire) == 6, "Failed to run"); assert!(duration.as_secs() > 4, "Failed time"); } -} +} \ No newline at end of file diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index 692733072..c2f8c8641 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -1,5 +1,5 @@ -use log::{debug, error, info, warn}; -use shared::utils::TMDB_API_KEY; +use log::{debug, error, warn}; +use shared::utils::{clean_playlist_title, TMDB_API_KEY}; use crate::library::metadata::{MediaMetadata, MetadataSource, MovieMetadata, SeriesMetadata}; use crate::library::scanner::ScannedMediaFile; use crate::library::tmdb_client::TmdbClient; @@ -15,48 +15,105 @@ pub struct MetadataResolver { impl MetadataResolver { // Creates a new metadata resolver from configuration - pub fn from_config(config: &LibraryConfig, client: reqwest::Client, storage: MetadataStorage) -> Self { - let tmdb_client = if config.metadata.tmdb.enabled { - let api_key = config.metadata.tmdb.api_key.as_ref().map_or_else(|| TMDB_API_KEY.to_string(), ToString::to_string); - Some(TmdbClient::new(api_key, config.metadata.tmdb.rate_limit_ms, client, storage)) - } else { - None + pub fn new(config: Option<&LibraryConfig>, client: reqwest::Client) -> Self { + let storage = match config { + None => None, + Some(c) => { + let storage_path = std::path::PathBuf::from(&c.metadata.path); + Some(MetadataStorage::new(storage_path)) + } }; + Self::from_config(config, client, storage) + } + + pub fn from_config(config: Option<&LibraryConfig>, client: reqwest::Client, storage: Option) -> Self { + let tmdb_client = config.filter(|c| c.metadata.tmdb.enabled) + .zip(storage) + .map(|(c, s)|{ + let api_key = c.metadata.tmdb.api_key.as_ref().map_or_else(|| TMDB_API_KEY.to_string(), ToString::to_string); + TmdbClient::new(api_key, c.metadata.tmdb.rate_limit_ms, client, s) + }); Self { tmdb_client, - fallback_to_filename: config.metadata.fallback_to_filename, + fallback_to_filename: config.is_some_and(|c|c.metadata.fallback_to_filename), } } - // Resolves metadata for a video file using multiple sources + // Resolves metadata for a video file using multiple sources (Main entry point for Library Scanner) pub async fn resolve(&self, group: &MediaGroup) -> Option { debug!("Resolving metadata for: {group}"); // Step 1: Classify the file - let (movie, Some(file), metadata) = (match group { - MediaGroup::Movie { file, metadata } => (true, Some(file), metadata), + let (is_movie, Some(file), metadata) = (match group { + MediaGroup::Movie { file, metadata } => (true, Some(file), metadata.as_ref()), MediaGroup::Series { show_key: _, episodes } => { let episode = episodes.first()?; - (false, Some(&episode.file), &episode.metadata) + (false, Some(&episode.file), episode.metadata.as_ref()) } }) else { return None }; - // Step 2: Try TMDB if enabled - if let Some(ref tmdb) = self.tmdb_client { - match self.resolve_from_tmdb(movie, file, metadata, tmdb).await { - Ok(Some(metadata)) => { - info!("Found TMDB metadata for: {}", file.file_path); - return Some(metadata); + self.resolve_internal(is_movie, metadata, Some(file), true).await + } + + /// Public helper to resolve metadata purely from a title string (Main entry point for Xtream Processors) + pub async fn resolve_from_title(&self, title: &str, known_tmdb_id: Option, is_movie: bool, resolve_from_tmdb: bool) -> Option { + // If the title is empty or just whitespace, we can't search. + if title.trim().is_empty() { + return None; + } + + // Clean common IPTV prefixes before parsing + let cleaned_title = clean_playlist_title(title); + + let mut metadata = ptt_parse_title(&cleaned_title); + + // Fallback: If PTT parser stripped everything (e.g. unusual formatting), use original title + if metadata.title.is_empty() { + metadata.title = if cleaned_title.trim().is_empty() { + title.to_string() + } else { + cleaned_title + }; + } + + // Inject the known ID if available to prevent unnecessary name search + if known_tmdb_id.is_some() { + metadata.tmdb = known_tmdb_id; + } + self.resolve_internal(is_movie, &metadata, None, resolve_from_tmdb).await + } + + // Internal logic shared by both resolve methods + async fn resolve_internal(&self, is_movie: bool, metadata: &PttMetadata, file: Option<&ScannedMediaFile>, resolve_from_tmdb: bool) -> Option { + if resolve_from_tmdb { + // Try TMDB if enabled + if let Some(ref tmdb) = self.tmdb_client { + // Determine if we should use file-based logic or pure title/stream logic + let result = if let Some(f) = file { + self.resolve_from_tmdb_file(is_movie, metadata, tmdb, f).await + } else { + self.resolve_from_tmdb_title(is_movie, metadata, tmdb).await + }; + + match result { + Ok(Some(metadata)) => { + if let Some(f) = file { + debug!("Found TMDB metadata for: {}", f.file_path); + } else { + debug!("Found TMDB metadata for title: {}", metadata.title()); + } + return Some(metadata); + } + Ok(None) => { + // continue to fallback + } + Err(err) => error!("Error resolving TMDB metadata: {err}"), } - Ok(None) => { - return None; - } - Err(err) => error!("Error resolving TMDB metadata: {err}"), } } - // TODO series implementation missing + // TODO series implementation missing for NFO // if classification == MediaClassification::Movie { // // Step 3: Try to read existing NFO file // if let Some(metadata) = NfoReader::read_metadata(&file.path).await { @@ -67,20 +124,26 @@ impl MetadataResolver { // Step 4: Fallback to filename parsing if self.fallback_to_filename { - info!("Using filename-based metadata for: {}", file.file_path); - Some(Self::resolve_from_filename(movie, metadata)) + if let Some(f) = file { + debug!("Using filename-based metadata for: {}", f.file_path); + } + Some(Self::resolve_from_filename(is_movie, metadata)) } else { - warn!("No metadata found for: {}", file.file_path); + if let Some(f) = file { + warn!("No metadata found for: {}", f.file_path); + } None } } - - // Attempts to resolve metadata from TMDB - async fn resolve_from_tmdb(&self, movie: bool, file: &ScannedMediaFile, metadata: &PttMetadata, tmdb: &TmdbClient) -> Result, String> { + + // Attempts to resolve metadata from TMDB using a local file context (allowing parent directory fallback) + async fn resolve_from_tmdb_file(&self, movie: bool, metadata: &PttMetadata, tmdb: &TmdbClient, file: &ScannedMediaFile) -> Result, String> { if movie { tmdb.search_movie(metadata.tmdb, metadata.title.as_str(), metadata.year).await } else { - let (series_year, tmdb_id) = if metadata.year.is_some() { (metadata.year, metadata.tmdb) } else { + let (series_year, tmdb_id) = if metadata.year.is_some() { + (metadata.year, metadata.tmdb) + } else { // Try to extract year from parent directory if available file.path.parent() .and_then(|p| p.file_name()) @@ -94,6 +157,15 @@ impl MetadataResolver { } } + // Attempts to resolve metadata from TMDB using only the parsed title/metadata (for streams) + async fn resolve_from_tmdb_title(&self, movie: bool, metadata: &PttMetadata, tmdb: &TmdbClient) -> Result, String> { + if movie { + tmdb.search_movie(metadata.tmdb, metadata.title.as_str(), metadata.year).await + } else { + tmdb.search_series(metadata.tmdb, metadata.title.as_str(), metadata.year).await + } + } + // Creates basic metadata from filename parsing fn resolve_from_filename(movie: bool, metadata: &PttMetadata) -> MediaMetadata { let timestamp = chrono::Utc::now().timestamp(); @@ -121,7 +193,6 @@ impl MetadataResolver { } } } - #[cfg(test)] mod tests { use super::*; @@ -131,7 +202,7 @@ mod tests { use shared::utils::Internable; use crate::library::{MediaClassification, MediaClassifier}; - fn create_test_config(tmdb_enabled: bool) -> LibraryConfig { + fn create_test_config(tmdb_enabled: bool, fallback_filename: bool) -> LibraryConfig { LibraryConfig { enabled: true, scan_directories: vec![], @@ -144,7 +215,7 @@ mod tests { plex: true, }, tmdb: LibraryTmdbConfig { - enabled: true, + enabled: tmdb_enabled, api_key: if tmdb_enabled { Some("test_key".to_string()) } else { @@ -154,7 +225,7 @@ mod tests { cache_duration_days: 0, language: "en-US".to_string(), }, - fallback_to_filename: true, + fallback_to_filename: fallback_filename, formats: vec![], }, playlist: LibraryPlaylistConfig { @@ -177,13 +248,13 @@ mod tests { #[tokio::test] async fn test_resolve_from_filename_movie() { - let config = create_test_config(false); + let config = create_test_config(false, true); let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .build() .unwrap_or_else(|_| reqwest::Client::new()); - let resolver = MetadataResolver::from_config(&config, client, MetadataStorage::new(PathBuf::from("/tmp"))); + let resolver = MetadataResolver::from_config(Some(&config), client, Some(MetadataStorage::new(PathBuf::from("/tmp")))); let file = create_test_file("The.Matrix.1999.1080p.mkv"); let metadata = match MediaClassifier::classify(&file) { MediaClassification::Movie { metadata, .. } | MediaClassification::Series { metadata, .. } => metadata, @@ -196,7 +267,29 @@ mod tests { if let Some(MediaMetadata::Movie(movie)) = metadata { assert_eq!(movie.title, "The Matrix"); assert_eq!(movie.year, Some(1999)); - assert_eq!(movie.source, MetadataSource::Tmdb); + assert_eq!(movie.source, MetadataSource::FilenameParsed); + } else { + panic!("Expected movie metadata"); + } + } + + #[tokio::test] + async fn test_resolve_from_title_stream() { + let config = create_test_config(false, true); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let resolver = MetadataResolver::from_config(Some(&config), client, Some(MetadataStorage::new(PathBuf::from("/tmp")))); + + let metadata = resolver.resolve_from_title("Inception.2010", None, true, true).await; + assert!(metadata.is_some()); + + if let Some(MediaMetadata::Movie(movie)) = metadata { + assert_eq!(movie.title, "Inception"); + assert_eq!(movie.year, Some(2010)); + assert_eq!(movie.source, MetadataSource::FilenameParsed); } else { panic!("Expected movie metadata"); } @@ -209,9 +302,8 @@ mod tests { .timeout(Duration::from_secs(30)) .build() .unwrap_or_else(|_| reqwest::Client::new()); - let mut config = create_test_config(false); - config.metadata.fallback_to_filename = false; - let resolver = MetadataResolver::from_config(&config, client, MetadataStorage::new(PathBuf::from("/tmp"))); + let config = create_test_config(false, false); + let resolver = MetadataResolver::from_config(Some(&config), client, Some(MetadataStorage::new(PathBuf::from("/tmp")))); let file = create_test_file("343jfkjh4789dkjfh934z3.Movie.mkv"); let metadata = match MediaClassifier::classify(&file) { MediaClassification::Movie { metadata, .. } | MediaClassification::Series { metadata, .. } => metadata, diff --git a/backend/src/library/metadata_storage.rs b/backend/src/library/metadata_storage.rs index 7191b7caa..c2f9de8e6 100644 --- a/backend/src/library/metadata_storage.rs +++ b/backend/src/library/metadata_storage.rs @@ -5,6 +5,7 @@ use std::fmt::Write; use std::path::PathBuf; use tokio::fs; use tokio::io::AsyncWriteExt; +use path_clean::PathClean; // Metadata storage for local VOD files // Stores metadata as JSON files with UUID-based filenames @@ -32,11 +33,16 @@ impl MetadataStorage { pub async fn store(&self, entry: &MetadataCacheEntry) -> std::io::Result<()> { let file_path = self.get_metadata_file_path(&entry.uuid); - debug!("Storing metadata for {}: {}", entry.file_path, file_path.display()); + debug!("Storing metadata for {}: {}", entry.file_path, file_path.clean().display()); let json = serde_json::to_string_pretty(entry) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + // Ensure parent directory exists - unconditionally to avoid race conditions or false negatives from try_exists + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).await?; + } + let mut file = fs::File::create(&file_path).await?; file.write_all(json.as_bytes()).await?; file.flush().await?; @@ -158,18 +164,23 @@ impl MetadataStorage { // write raw tmdb movie info pub async fn store_tmdb_movie_info(&self, movie_id: u32, content: &[u8]) -> std::io::Result { let file_path = self.get_tmdb_movie_data_file_path(movie_id); - debug!("Storing raw tmdb movie metadata for {}", file_path.display()); + debug!("Storing raw tmdb movie metadata for {}", file_path.clean().display()); self.store_file(content, file_path).await } // write raw tmdb series info pub async fn store_tmdb_series_info(&self, series_id: u32, content: &[u8]) -> std::io::Result { let file_path = self.get_tmdb_series_data_file_path(series_id); - debug!("Storing raw tmdb series metadata for {}", file_path.display()); + debug!("Storing raw tmdb series metadata for {}", file_path.clean().display()); self.store_file(content, file_path).await } async fn store_file(&self, content: &[u8], file_path: PathBuf) -> std::io::Result { + // Ensure parent directory exists - unconditionally + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).await?; + } + let mut file = fs::File::create(&file_path).await?; file.write_all(content).await?; file.flush().await?; @@ -335,4 +346,4 @@ mod tests { let deleted = storage.load_by_uuid(&entry.uuid).await; assert!(deleted.is_none()); } -} +} \ No newline at end of file diff --git a/backend/src/library/processor.rs b/backend/src/library/processor.rs index 9aba28865..1f9810058 100644 --- a/backend/src/library/processor.rs +++ b/backend/src/library/processor.rs @@ -8,6 +8,7 @@ use crate::model::{AppConfig, LibraryConfig}; use log::{debug, error, info, warn}; use shared::model::{LibraryMetadataFormat, LibraryScanResult}; use std::collections::HashMap; +use std::sync::Arc; // Action taken when processing a file #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -23,6 +24,7 @@ pub struct LibraryProcessor { scanner: LibraryScanner, resolver: MetadataResolver, storage: MetadataStorage, + app_config: Option>, // Need access to global config for FFprobe settings } impl LibraryProcessor { @@ -40,13 +42,14 @@ impl LibraryProcessor { let storage_path = std::path::PathBuf::from(&config.metadata.path); let scanner = LibraryScanner::new(config.clone()); let storage = MetadataStorage::new(storage_path); - let resolver = MetadataResolver::from_config(&config, client, storage.clone()); + let resolver = MetadataResolver::from_config(Some(&config), client, Some(storage.clone())); Self { config, scanner, resolver, storage, + app_config: None, } } @@ -80,9 +83,16 @@ impl LibraryProcessor { errors: 0, }; + // Check global ffprobe config + let ffprobe_enabled = if let Some(app_cfg) = &self.app_config { + app_cfg.is_ffprobe_enabled().await + } else { + false + }; + // Process each scanned file for group in &media_groups { - match self.process_group(group, &existing_map, force_rescan).await { + match self.process_group(group, &existing_map, force_rescan, ffprobe_enabled).await { Ok(action) => match action { ProcessAction::Added => result.files_added += 1, ProcessAction::Updated => result.files_updated += 1, @@ -119,20 +129,39 @@ impl LibraryProcessor { Ok(result) } - async fn process_group(&self, group: &MediaGroup, existing_map: &HashMap, force_rescan: bool) -> Result { + async fn process_group(&self, group: &MediaGroup, existing_map: &HashMap, force_rescan: bool, can_probe: bool) -> Result { match group { MediaGroup::Movie { file: _, .. } => { - self.process_movie(group, existing_map, force_rescan).await + self.process_movie(group, existing_map, force_rescan, can_probe).await } MediaGroup::Series { show_key: _, episodes: _ } => { - self.process_series_group(group, existing_map, force_rescan).await + self.process_series_group(group, existing_map, force_rescan, can_probe).await } } } + // TODO: Implement enrich_metadata_with_ffprobe to add technical info (resolution, codecs) from local files + //fn enrich_metadata_with_ffprobe(&self, _metadata: &mut MediaMetadata, _file_path: &str, _can_probe: bool) { + //if !can_probe { return; } + + // let _url = format!("file://{file_path}"); // Simple file URL for ffmpeg + // + // // TODO: Logic for series episodes iteration + // match metadata { + // MediaMetadata::Movie(_movie) => { + // // Currently we don't have fields in MovieMetadata to store tech info + // // But we could add them. For now, let's just log. + // // In the future this should update the metadata struct. + // debug!("Probe logic for local movie {} not yet fully integrated into Metadata struct", file_path); + // } + // MediaMetadata::Series(_) => { + // // Series handle episodes separately + // } + // } + //} + // Processes a single video file - async fn process_movie(&self, group: &MediaGroup, existing_map: &HashMap, force_rescan: bool, - ) -> Result { + async fn process_movie(&self, group: &MediaGroup, existing_map: &HashMap, force_rescan: bool, _can_probe: bool) -> Result { let MediaGroup::Movie { file, .. } = group else { return Err(format!("Expected movie to resolve but got {group}")) }; // Check if file already exists in cache let (cache_entry, status) = if let Some(existing_entry) = existing_map.get(&file.file_path) { @@ -145,6 +174,8 @@ impl LibraryProcessor { debug!("File modified, updating metadata: {}", file.file_path); // Reuse existing UUID let metadata = self.resolve_metadata(group).await?; + //self.enrich_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe); + let entry = MetadataCacheEntry { uuid: existing_entry.uuid.clone(), file_path: file.file_path.clone(), @@ -157,6 +188,7 @@ impl LibraryProcessor { } else { debug!("New file, resolving metadata: {}", file.file_path); let metadata = self.resolve_metadata(group).await?; + //self.enrich_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe); let entry = MetadataCacheEntry::new( file.file_path.clone(), @@ -177,7 +209,7 @@ impl LibraryProcessor { &self, group: &MediaGroup, existing_map: &HashMap, - force_rescan: bool, + force_rescan: bool, _can_probe: bool ) -> Result { let MediaGroup::Series { show_key, episodes } = group else { return Err(format!("Expected series to resolve but got {group}")) }; let series_file_path = episodes diff --git a/backend/src/library/tmdb_client.rs b/backend/src/library/tmdb_client.rs index 3a79bf603..d30fa24eb 100644 --- a/backend/src/library/tmdb_client.rs +++ b/backend/src/library/tmdb_client.rs @@ -2,13 +2,16 @@ use crate::library::metadata::MediaMetadata; use crate::library::tmdb::{TmdbMovieDetails, TmdbSearchResponse, TmdbSeriesInfoDetails, TmdbSeriesInfoSeasonDetails, TmdbTvSearchResponse}; use crate::library::MetadataStorage; use log::{debug, error, warn}; +use serde::de::DeserializeOwned; use serde_json::Value; +use shared::utils::sanitize_sensitive_info; use std::collections::HashSet; use tokio::time::{sleep, Duration}; use url::Url; // TODO make this configurable in Library tmdb config const TMDB_API_BASE_URL: &str = "https://api.themoviedb.org/3"; +const MAX_RETRIES: u32 = 3; // TMDB API client with rate limiting pub struct TmdbClient { @@ -35,27 +38,87 @@ impl TmdbClient { } } - // Searches for a movie by title and optional year - pub async fn search_movie(&self, tmdb_id: Option, title: &str, year: Option) -> Result, String> { - debug!("TMDB search movie: {title}"); - - if let Some(movie_id) = tmdb_id { - return self.fetch_movie_details(movie_id).await; - } + /// Centralized request execution with retry logic, rate limiting, and error handling. + async fn execute_request(&self, url: String) -> Result, String> { + let safe_url = sanitize_sensitive_info(&url); + // Apply rate limiting before request sleep(Duration::from_millis(self.rate_limit_ms)).await; - let url = self.build_movie_search_url(title, year)?; - let response = self.client.get(url).send().await.map_err(|e| format!("TMDB API request failed: {e}"))?; - if !response.status().is_success() { - error!("TMDB API error: {}", response.status()); - return Err(format!("TMDB API error: {}", response.status())); + let mut attempt = 0; + loop { + attempt += 1; + match self.client.get(&url).send().await { + Ok(response) => { + let status = response.status(); + if status.is_success() { + let content = response.bytes().await.map_err(|e| e.to_string())?; + + // Parse JSON + return match serde_json::from_slice::(&content) { + Ok(data) => Ok(Some(data)), + Err(e) => { + warn!("Failed to parse TMDB response from {safe_url}: {e}"); + Err(e.to_string()) + } + }; + } else if status == reqwest::StatusCode::NOT_FOUND { + // 404 is not an error for the retry logic, it just means no data + return Ok(None); + } else if status.is_server_error() && attempt < MAX_RETRIES { + warn!("TMDB API server error ({status}) for {safe_url}, retrying {attempt}/{MAX_RETRIES}"); + sleep(Duration::from_millis(500 * u64::from(attempt))).await; + continue; + } + error!("TMDB API error ({status}) for {safe_url}"); + return Err(format!("TMDB API error: {status}")); + } + Err(e) => { + if attempt < MAX_RETRIES { + warn!("TMDB request failed for {safe_url}: {e}, retrying {attempt}/{MAX_RETRIES}"); + sleep(Duration::from_millis(500 * u64::from(attempt))).await; + } else { + return Err(format!("TMDB API request failed after {MAX_RETRIES} attempts: {e}")); + } + } + } + } + } + + // Searches for a movie by title and optional year + pub async fn search_movie(&self, tmdb_id: Option, title: &str, year: Option) -> Result, String> { + let year_display = year.map_or_else(|| "None".to_string(), |y| y.to_string()); + + // Validate the ID: Treat Some(0) the same as None + let valid_id = tmdb_id.filter(|&id| id > 0); + + if let Some(id) = valid_id { + debug!("TMDB search movie: {title} ({year_display}) [ID: {id}]"); + return self.fetch_movie_details(id).await; } - let search = response.json::().await.map_err(|e| format!("Failed to parse TMDB search response: {e}"))?; + debug!("TMDB search movie: {title} ({year_display})"); - if let Some(movie) = search.results.first() { self.fetch_movie_details(movie.id).await } else { - debug!("No TMDB results for movie: {title}"); + // Build search URL + let url = self.build_movie_search_url(title, year)?; + + // Execute Search + let search_result: Option = self.execute_request(url.to_string()).await?; + + if let Some(search) = search_result { + // If results are found, use the ID of the first result to fetch full metadata + if let Some(movie) = search.results.first() { + if movie.id == 0 { + debug!("TMDB returned ID 0 for movie search: {title}"); + Ok(None) + } else { + self.fetch_movie_details(movie.id).await + } + } else { + debug!("No TMDB results for movie: {title}"); + Ok(None) + } + } else { Ok(None) } } @@ -75,41 +138,87 @@ impl TmdbClient { // Fetches detailed movie information async fn fetch_movie_details(&self, movie_id: u32) -> Result, String> { + if movie_id == 0 { + return Ok(None); + } + if self.fetched_movie_metadata.read().await.contains(&movie_id) { return Ok(None); } - sleep(Duration::from_millis(self.rate_limit_ms)).await; let url = format!("{TMDB_API_BASE_URL}/movie/{movie_id}?api_key={}&append_to_response=credits,videos,external_ids", self.api_key); - let response = self.client.get(&url).send().await.map_err(|err| err.to_string())?; - if !response.status().is_success() { - warn!("TMDB API error fetching movie details: {}", response.status()); - return Err(format!("TMDB API error fetching movie details: {}", response.status())); + + // We use specific raw execution here because we want to cache the raw bytes + // reusing execute_request logic manually to allow side-effect (storage) + + let safe_url = sanitize_sensitive_info(&url); + // Rate limit handled inside loop manually or we rely on logic below + sleep(Duration::from_millis(self.rate_limit_ms)).await; + + let mut attempt = 0; + loop { + attempt += 1; + match self.client.get(&url).send().await { + Ok(response) => { + if !response.status().is_success() { + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + warn!("TMDB Movie ID {movie_id} not found"); + return Ok(None); + } + if status.is_server_error() && attempt < MAX_RETRIES { + warn!("TMDB API server error ({status}): {safe_url}, retrying {attempt}/{MAX_RETRIES}"); + sleep(Duration::from_millis(500 * u64::from(attempt))).await; + continue; + } + return Err(format!("TMDB API error fetching movie details: {status}")); + } + + let content_bytes = response.bytes().await.map_err(|err| err.to_string())?; + + // Attempt to store the raw data + if let Err(err) = self.storage.store_tmdb_movie_info(movie_id, &content_bytes).await { + warn!("Failed to cache TMDB movie info: {err}"); + } + + let details: TmdbMovieDetails = serde_json::from_slice(&content_bytes).map_err(|err| err.to_string())?; + self.fetched_movie_metadata.write().await.insert(movie_id); + + return Ok(Some(MediaMetadata::Movie(details.to_meta_data()))); + } + Err(e) => { + if attempt < MAX_RETRIES { + warn!("TMDB API request failed for {safe_url}: {e}, retrying {attempt}/{MAX_RETRIES}"); + sleep(Duration::from_millis(500 * u64::from(attempt))).await; + } else { + return Err(format!("TMDB API request failed after {MAX_RETRIES} attempts: {e}")); + } + } + } } - - let content_bytes = response.bytes().await.map_err(|err| err.to_string())?; - let _raw_data_path = self.storage.store_tmdb_movie_info(movie_id, &content_bytes).await.map_err(|err| err.to_string())?; - let details: TmdbMovieDetails = serde_json::from_slice(&content_bytes).map_err(|err| err.to_string())?; - self.fetched_movie_metadata.write().await.insert(movie_id); - - Ok(Some(MediaMetadata::Movie(details.to_meta_data()))) } // Searches for a TV series by title and optional year pub async fn search_series(&self, tmdb_id: Option, title: &str, year: Option) -> Result, String> { - debug!("Searching TMDB for series: {title}"); - let key = format!("{title}-{tmdb_id:?}-{year:?}"); + if let Some(id) = tmdb_id { + debug!("Searching TMDB for series: {title} [ID: {id}]"); + } else { + debug!("Searching TMDB for series: {title}"); + } + if self.fetched_series_key.read().await.contains(&key) { return Ok(None); } - let result = if let Some(series_id) = tmdb_id { + // Validate ID is not 0 + let valid_id = tmdb_id.filter(|&id| id > 0); + + let result = if let Some(series_id) = valid_id { self.fetch_series_details(series_id).await } else { - sleep(Duration::from_millis(self.rate_limit_ms)).await; self.search_series_by_title(title, year).await }; @@ -135,23 +244,21 @@ impl TmdbClient { debug!("TMDB search series: {title}"); - let response = self.client - .get(url) - .send() - .await - .map_err(|e| format!("TMDB API request failed: {e}"))?; + let search_result: Option = self.execute_request(url.to_string()).await?; - if !response.status().is_success() { - return Err(format!("TMDB API error: {}", response.status())); - } - - let search = response - .json::() - .await - .map_err(|e| format!("Failed to parse TMDB TV search response: {e}"))?; - - if let Some(series) = search.results.first() { self.fetch_series_details(series.id).await } else { - debug!("No TMDB results for series: {title}"); + if let Some(search) = search_result { + if let Some(series) = search.results.first() { + if series.id == 0 { + debug!("TMDB returned ID 0 for series search: {title}"); + Ok(None) + } else { + self.fetch_series_details(series.id).await + } + } else { + debug!("No TMDB results for series: {title}"); + Ok(None) + } + } else { Ok(None) } } @@ -163,30 +270,46 @@ impl TmdbClient { return Ok(None); } - // Apply rate limit - sleep(Duration::from_millis(self.rate_limit_ms)).await; - // Fetch series info from TMDB API let url = format!( "{TMDB_API_BASE_URL}/tv/{series_id}?api_key={}&append_to_response=credits,videos,external_ids", self.api_key ); - let response = self.client - .get(&url) - .send() - .await - .map_err(|e| format!("TMDB API request failed: {e}"))?; - if !response.status().is_success() { - error!("TMDB API error fetching series details: {}", response.status()); - return Err(format!("TMDB API error fetching series details: {}", response.status())); - } + // Use manual request handling to support caching raw bytes + sleep(Duration::from_millis(self.rate_limit_ms)).await; + let safe_url = sanitize_sensitive_info(&url); - // Read response bytes - let series_content = response - .bytes() - .await - .map_err(|e| format!("Failed to read TMDB response body: {e}"))?; + let mut attempt = 0; + let series_content = loop { + attempt += 1; + match self.client.get(&url).send().await { + Ok(response) => { + if !response.status().is_success() { + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + warn!("TMDB Series ID {series_id} not found"); + return Ok(None); + } + if status.is_server_error() && attempt < MAX_RETRIES { + warn!("TMDB API server error ({status}): {safe_url}, retrying {attempt}/{MAX_RETRIES}"); + sleep(Duration::from_millis(500 * u64::from(attempt))).await; + continue; + } + return Err(format!("TMDB API error fetching series details: {status}")); + } + break response.bytes().await.map_err(|e| format!("Failed to read TMDB response body: {e}"))?.to_vec(); + } + Err(e) => { + if attempt < MAX_RETRIES { + warn!("TMDB API request failed for {safe_url}: {e}, retrying {attempt}/{MAX_RETRIES}"); + sleep(Duration::from_millis(500 * u64::from(attempt))).await; + } else { + return Err(format!("TMDB API request failed after {MAX_RETRIES} attempts: {e}")); + } + } + } + }; // Mark series as fetched self.fetched_series_metadata.write().await.insert(series_id); @@ -202,7 +325,7 @@ impl TmdbClient { // Fetch season details let season_infos = self.fetch_seasons(series_id, season_count).await; if !season_infos.is_empty() { - // Deserialize raw JSON map to update dynamically + // Deserialize a raw JSON map to update dynamically let mut raw_series: serde_json::Map = serde_json::from_slice(&series_content) .map_err(|e| format!("Failed to parse raw series JSON: {e}"))?; @@ -241,7 +364,7 @@ impl TmdbClient { // Serialize updated raw JSON and store if let Ok(raw_series_bytes) = serde_json::to_vec(&raw_series) { if let Err(err) = self.storage.store_tmdb_series_info(series_id, &raw_series_bytes).await { - error!("Failed to store raw TMDB series info: {err}"); + warn!("Failed to cache raw TMDB series info: {err}"); } } @@ -276,14 +399,19 @@ impl TmdbClient { self.api_key ); - let Ok(response) = self.client.get(&url).send().await else { return (None, None) }; - let Ok(bytes) = response.bytes().await else { return (None, None) }; - (match serde_json::from_slice::(&bytes) { - Ok(details) => Some(details), - Err(e) => { - error!("Failed to parse series season details: tmdb-id: {series_id} season: {season} err: {e}"); - None + match self.client.get(&url).send().await { + Ok(response) => { + if response.status().is_success() { + if let Ok(bytes) = response.bytes().await { + match serde_json::from_slice::(&bytes) { + Ok(details) => return (Some(details), Some(bytes)), + Err(e) => error!("Failed to parse season details for {series_id} S{season}: {e}"), + } + } + } + (None, None) } - }, Some(bytes)) + Err(_) => (None, None) + } } -} +} \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index 81c60c40f..df7eda176 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -21,7 +21,7 @@ use crate::library::LibraryProcessor; use crate::model::{ AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets, SourcesConfig, }; -use crate::processing::processor::playlist; +use crate::processing::processor::{exec_processing}; use crate::utils::request::create_client; use crate::utils::{config_file_reader, resolve_env_var}; use crate::utils::{db_viewer, init_logger}; @@ -149,7 +149,7 @@ async fn main() { // Handle Library scan before starting main application if args.scan_library || args.force_library_rescan { info!("Library scan mode requested"); - let app_config = utils::read_initial_app_config(&mut config_paths, true, true, false).await.unwrap_or_else(|err| exit!("{}", err)); + let app_config = Arc::new(utils::read_initial_app_config(&mut config_paths, true, true, false).await.unwrap_or_else(|err| exit!("{}", err))); scan_library_cli(&app_config, args.force_library_rescan).await; return; } @@ -206,6 +206,11 @@ fn print_info(app_config: &AppConfig) { info!("Cache dir: {}", cache.dir); } } + if let Some(lib_config) = config.library.as_ref() { + if lib_config.enabled { + info!("Library Metadata path: {}", lib_config.metadata.path); + } + } if let Some(resource_path) = paths.custom_stream_response_path.as_ref() { info!("Resource path: {resource_path}"); } @@ -234,7 +239,8 @@ async fn start_in_cli_mode(cfg: Arc, targets: Arc) { error!("Failed to build client {err}"); reqwest::Client::new() }); - playlist::exec_processing(&client, cfg, targets, None, None, None, None).await; + // In CLI mode, we don't start background managers for events or providers + exec_processing(&client, cfg, targets, None, None, None, None, None, None).await; } async fn start_in_server_mode(cfg: Arc, targets: Arc) { @@ -243,7 +249,7 @@ async fn start_in_server_mode(cfg: Arc, targets: Arc) } } -async fn scan_library_cli(app_config: &AppConfig, force_rescan: bool) { +async fn scan_library_cli(app_config: &Arc, force_rescan: bool) { info!("Starting Library scan from CLI (force_rescan: {force_rescan})"); let Some(processor) = LibraryProcessor::from_app_config(app_config) else { @@ -297,4 +303,4 @@ async fn healthcheck(config_file: &str) -> bool { false } } -} +} \ No newline at end of file diff --git a/backend/src/messaging.rs b/backend/src/messaging.rs index 1c956c7c4..342ebb015 100644 --- a/backend/src/messaging.rs +++ b/backend/src/messaging.rs @@ -307,6 +307,7 @@ mod tests { custom_stream_response: Arc::new(ArcSwapOption::default()), access_token_secret: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32], encrypt_secret: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16], + ffprobe_available: Arc::default(), }) } diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index d08edaba8..e3b6bca3f 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -1,11 +1,12 @@ use crate::api::model::TransportStreamBuffer; use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, Mappings, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput}; use crate::utils; +use crate::utils::ffmpeg::check_ffprobe_availability; use arc_swap::{ArcSwap, ArcSwapOption}; use log::{error, warn}; use rand::Rng; -use shared::info_err_res; use shared::error::{TuliproxError, TuliproxErrorKind}; +use shared::info_err_res; use shared::model::ConfigPaths; use std::borrow::Cow; use std::collections::HashSet; @@ -13,6 +14,7 @@ use std::fs::File; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::sync::OnceCell; const CHANNEL_UNAVAILABLE: &str = "channel_unavailable.ts"; const USER_CONNECTIONS_EXHAUSTED: &str = "user_connections_exhausted.ts"; @@ -38,6 +40,7 @@ pub struct AppConfig { pub custom_stream_response: Arc>, pub access_token_secret: [u8; 32], pub encrypt_secret: [u8; 16], + pub(crate) ffprobe_available: Arc>, } impl AppConfig { @@ -175,7 +178,7 @@ impl AppConfig { .map(Arc::clone) .collect(); if !result.is_empty() { - return Some(result) + return Some(result); } } None @@ -223,17 +226,17 @@ impl AppConfig { pub fn get_input_by_id(&self, input_id: u16) -> Option> { let sources = self.sources.load(); - for input in &sources.inputs { - if input.id == input_id { - return Some(Arc::clone(input)); - } - if let Some(aliases) = input.aliases.as_ref() { - for alias in aliases { - if alias.id == input_id { - return Some(Arc::new(input.as_input(alias))); - } + for input in &sources.inputs { + if input.id == input_id { + return Some(Arc::clone(input)); + } + if let Some(aliases) = input.aliases.as_ref() { + for alias in aliases { + if alias.id == input_id { + return Some(Arc::new(input.as_input(alias))); } } + } } None } @@ -422,5 +425,16 @@ impl AppConfig { pub fn get_grace_options(&self) -> GracePeriodOptions { self.config.load().get_grace_options() } + + pub async fn is_ffprobe_enabled(&self) -> bool { + let ffprobe_enabled_in_config = self.config.load().video.as_ref().is_some_and(|v| v.ffprobe_enabled); + if !ffprobe_enabled_in_config { + return false; + } + + *self.ffprobe_available.get_or_init(|| async { + check_ffprobe_availability().await + }).await + } } diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index c2201c6aa..1061a6579 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -18,7 +18,8 @@ fn create_directories(cfg: &Config, temp_path: &Path) { cfg.backup_dir.clone(), cfg.user_config_dir.clone(), cfg.video.as_ref().and_then(|v| v.download.as_ref()).map(|d| d.directory.clone()), - cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref().and_then(|c| if c.enabled { Some(c.dir.clone()) } else { None })) + cfg.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref().and_then(|c| if c.enabled { Some(c.dir.clone()) } else { None })), + cfg.library.as_ref().filter(|l| l.enabled).map(|l| l.metadata.path.clone()), ]; let mut paths: Vec = paths_strings.iter() @@ -88,7 +89,7 @@ pub struct Config { } impl Config { - pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { + pub async fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { let work_dir = &self.working_dir; self.working_dir = utils::resolve_directory_path(work_dir); @@ -99,13 +100,17 @@ impl Config { } if let Some(library) = self.library.as_mut() { - library.prepare()?; + library.prepare(&self.working_dir)?; } if let Some(messaging) = self.messaging.as_mut() { messaging.prepare(config_path); } + if let Some(video) = self.video.as_mut() { + video.prepare().await; + } + Ok(()) } @@ -201,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/input.rs b/backend/src/model/config/input.rs index 4613d19fb..554a324d3 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -22,6 +22,8 @@ pub struct ConfigInputOptions { 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, } macros::from_impl!(ConfigInputOptions); @@ -33,6 +35,8 @@ impl From<&ConfigInputOptionsDto> for ConfigInputOptions { 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, } } } diff --git a/backend/src/model/config/library.rs b/backend/src/model/config/library.rs index 48300bf20..6d6f887f3 100644 --- a/backend/src/model/config/library.rs +++ b/backend/src/model/config/library.rs @@ -55,11 +55,21 @@ pub struct LibraryConfig { } impl LibraryConfig { - pub fn prepare(&mut self) -> Result<(), TuliproxError> { + pub fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> { if self.enabled { if self.metadata.path.is_empty() { self.metadata.path = default_metadata_path(); } + + // Resolve metadata path to absolute path based on working_dir + let meta_path = PathBuf::from(&self.metadata.path); + let meta_path = if meta_path.is_relative() { + PathBuf::from(working_dir).join(&meta_path) + } else { + meta_path + }; + self.metadata.path = meta_path.to_string_lossy().to_string(); + for dir in &mut self.scan_directories { match PathBuf::from(&dir.path).canonicalize() { Ok(path_buf) => { @@ -75,11 +85,11 @@ impl LibraryConfig { } } -impl Default for LibraryConfig { - fn default() -> Self { - Self::from(&LibraryConfigDto::default()) - } -} +// impl Default for LibraryConfig { +// fn default() -> Self { +// Self::from(&LibraryConfigDto::default()) +// } +// } macros::from_impl!(LibraryConfig); @@ -126,4 +136,4 @@ impl From<&LibraryConfigDto> for LibraryConfig { }, } } -} +} \ No newline at end of file diff --git a/backend/src/model/config/target.rs b/backend/src/model/config/target.rs index aa0677c1a..cadd63dd3 100644 --- a/backend/src/model/config/target.rs +++ b/backend/src/model/config/target.rs @@ -9,6 +9,10 @@ use shared::model::PlaylistItemType; use std::sync::Arc; use shared::foundation::Filter; use shared::foundation::ValueProvider; +use shared::create_bit_set; + +create_bit_set!(u16, XtreamTargetFlags, SkipLiveDirectSource, SkipVideoDirectSource, SkipSeriesDirectSource, + ResolveBackground, ResolveSeries, ResolveVod, ProbeSeries, ProbeVod, ProbeLive); #[derive(Clone, Debug)] pub struct ProcessTargets { @@ -31,13 +35,9 @@ impl ProcessTargets { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub struct XtreamTargetOutput { - pub skip_live_direct_source: bool, - pub skip_video_direct_source: bool, - pub skip_series_direct_source: bool, - pub resolve_series: bool, - pub resolve_series_delay: u16, - pub resolve_vod: bool, - pub resolve_vod_delay: u16, + pub flags: XtreamTargetFlagsSet, + pub probe_live_interval_hours: u32, + pub resolve_delay: u16, pub trakt: Option, pub filter: Option, } @@ -45,14 +45,21 @@ pub struct XtreamTargetOutput { 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); } + Self { - skip_live_direct_source: dto.skip_live_direct_source, - skip_video_direct_source: dto.skip_video_direct_source, - skip_series_direct_source: dto.skip_series_direct_source, - resolve_series: dto.resolve_series, - resolve_series_delay: dto.resolve_series_delay, - resolve_vod: dto.resolve_vod, - resolve_vod_delay: dto.resolve_vod_delay, + 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(), } @@ -62,13 +69,17 @@ impl From<&XtreamTargetOutputDto> for XtreamTargetOutput { impl From<&XtreamTargetOutput> for XtreamTargetOutputDto { fn from(instance: &XtreamTargetOutput) -> Self { Self { - skip_live_direct_source: instance.skip_live_direct_source, - skip_video_direct_source: instance.skip_video_direct_source, - skip_series_direct_source: instance.skip_series_direct_source, - resolve_series: instance.resolve_series, - resolve_series_delay: instance.resolve_series_delay, - resolve_vod: instance.resolve_vod, - resolve_vod_delay: instance.resolve_vod_delay, + 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(), @@ -119,8 +130,9 @@ pub struct StrmTargetOutput { pub cleanup: bool, pub strm_props: Option>, pub filter: Option, - // boolean flag to enable or disable quality info in filenames. pub add_quality_to_filename: bool, + pub probe_probe_size_bytes: Option, + pub probe_analyze_duration: Option, } macros::from_impl!(StrmTargetOutput); @@ -136,6 +148,8 @@ impl From<&StrmTargetOutputDto> for StrmTargetOutput { 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, } } } @@ -152,6 +166,8 @@ impl From<&StrmTargetOutput> for StrmTargetOutputDto { filter: instance.filter.as_ref().map(ToString::to_string), t_filter: instance.filter.clone(), add_quality_to_filename: instance.add_quality_to_filename, + probe_probe_size_bytes: instance.probe_probe_size_bytes, + probe_analyze_duration: instance.probe_analyze_duration, } } } @@ -253,14 +269,6 @@ impl ConfigTarget { } } - // pub(crate) fn get_strm_output(&self) -> Option<&StrmTargetOutput> { - // if let Some(TargetOutput::Strm(output)) = self.output.iter().find(|o| matches!(o, TargetOutput::Strm(_))) { - // Some(output) - // } else { - // None - // } - // } - pub(crate) fn get_hdhomerun_output(&self) -> Option<&HdHomeRunTargetOutput> { if let Some(TargetOutput::HdHomeRun(output)) = self.output.iter().find(|o| matches!(o, TargetOutput::HdHomeRun(_))) { Some(output) @@ -319,4 +327,4 @@ impl From<&ConfigTargetDto> for ConfigTarget { use_memory_cache: dto.use_memory_cache, } } -} \ No newline at end of file +} diff --git a/backend/src/model/config/video_download.rs b/backend/src/model/config/video_download.rs index bcafcff8d..233a83ac5 100644 --- a/backend/src/model/config/video_download.rs +++ b/backend/src/model/config/video_download.rs @@ -1,8 +1,9 @@ +use crate::model::macros; +use crate::utils::ffmpeg::check_ffprobe_availability; use regex::Regex; +use shared::model::{VideoConfigDto, VideoDownloadConfigDto}; use std::collections::HashMap; use std::sync::Arc; -use shared::model::{VideoConfigDto, VideoDownloadConfigDto}; -use crate::model::macros; #[derive(Debug, Clone)] pub struct VideoDownloadConfig { @@ -20,8 +21,8 @@ impl From<&VideoDownloadConfigDto> for VideoDownloadConfig { directory: dto.directory.as_ref().map_or_else(|| "downloads".to_string(), ToString::to_string), organize_into_directories: dto.organize_into_directories, episode_pattern: dto.episode_pattern.as_ref().and_then(|s| shared::model::REGEX_CACHE.get_or_compile(s) - .map_err(|e| log::warn!("Invalid episode_pattern regex '{s}': {e}")) - .ok()), + .map_err(|e| log::warn!("Invalid episode_pattern regex '{s}': {e}")) + .ok()), } } } @@ -42,7 +43,23 @@ pub struct VideoConfig { pub extensions: Vec, pub download: Option, pub web_search: Option, + pub ffprobe_enabled: bool, + pub ffprobe_timeout: Option, } + +impl VideoConfig { + pub async fn prepare(&mut self) { + if self.ffprobe_enabled { + let is_available = check_ffprobe_availability().await; + + if !is_available { + log::warn!("FFprobe is not available. Disabling video processing."); + self.ffprobe_enabled = false; + } + } + } +} + macros::from_impl!(VideoConfig); impl From<&VideoConfigDto> for VideoConfig { fn from(dto: &VideoConfigDto) -> Self { @@ -50,6 +67,8 @@ impl From<&VideoConfigDto> for VideoConfig { extensions: dto.extensions.clone(), download: dto.download.as_ref().map(Into::into), web_search: dto.web_search.clone(), + ffprobe_enabled: dto.ffprobe_enabled, + ffprobe_timeout: dto.ffprobe_timeout, } } } @@ -60,6 +79,8 @@ impl From<&VideoConfig> for VideoConfigDto { extensions: instance.extensions.clone(), download: instance.download.as_ref().map(Into::into), web_search: instance.web_search.clone(), + ffprobe_enabled: instance.ffprobe_enabled, + ffprobe_timeout: instance.ffprobe_timeout, } } } \ No newline at end of file diff --git a/backend/src/model/mod.rs b/backend/src/model/mod.rs index fc355bb62..03802e036 100644 --- a/backend/src/model/mod.rs +++ b/backend/src/model/mod.rs @@ -3,21 +3,16 @@ mod mapping; mod xmltv; mod xtream; mod healthcheck; -mod playlist_categories; mod config; mod input_source; -pub mod media_properties; pub mod messaging; pub use self::playlist::*; pub use self::mapping::*; -pub use shared::model::{format_elapsed_time, InputStats, PlaylistStats, SourceStats, TargetStats}; pub use self::xmltv::*; pub use self::xtream::*; pub use self::healthcheck::*; -pub use self::playlist_categories::*; pub use shared::model::xtream_const::*; pub use self::config::*; pub use self::input_source::*; -pub use self::media_properties::*; pub use self::messaging::*; \ No newline at end of file diff --git a/backend/src/model/playlist_categories.rs b/backend/src/model/playlist_categories.rs deleted file mode 100644 index 006e16055..000000000 --- a/backend/src/model/playlist_categories.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] -pub struct PlaylistCategories { - #[serde(default)] - pub live: Option>, - #[serde(default)] - pub vod: Option>, - #[serde(default)] - pub series: Option>, -} diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index 316941f93..dae5f71c3 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -6,6 +6,7 @@ use serde_json::Value; use shared::model::{PlaylistItem, PlaylistItemType, PlaylistItemTypeSet, ProxyUserStatus, XtreamMappingOptions}; use shared::utils::{arc_str_serde, deserialize_number_from_string_or_zero}; use enum_iterator::all; +use crate::model::XtreamTargetFlags; #[derive(Debug, Default)] pub struct XtreamLoginInfo { @@ -45,9 +46,9 @@ pub fn xtream_mapping_option_from_target_options(target: &ConfigTarget, target_o } XtreamMappingOptions { - skip_live_direct_source: target_output.skip_live_direct_source, - skip_video_direct_source: target_output.skip_video_direct_source, - skip_series_direct_source: target_output.skip_series_direct_source, + 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(), force_redirect, reverse_item_types, diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index 2e0100882..5a48fb3de 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -1,6 +1,6 @@ use crate::model::{Epg, TVGuide, XmlTag, XmlTagIcon, EPG_ATTRIB_CHANNEL, EPG_ATTRIB_ID, EPG_TAG_CHANNEL, EPG_TAG_DISPLAY_NAME, EPG_TAG_ICON, EPG_TAG_PROGRAMME, EPG_TAG_TV}; use crate::model::{EpgSmartMatchConfig, PersistedEpgSource}; -use crate::processing::processor::epg::EpgIdCache; +use crate::processing::processor::EpgIdCache; use crate::utils::compressed_file_reader_async::CompressedFileReaderAsync; use crate::utils::{async_file_reader, parse_xmltv_time}; use log::error; @@ -687,7 +687,7 @@ mod tests { assert_eq!("odisea.bg", normalize_channel_name("BG | ODISEA ᡁᴴᴰ ³⁸⁴⁰ᴾ", &epg_smart_cfg)); } - use crate::processing::processor::epg::EpgIdCache; + use crate::processing::processor::EpgIdCache; use rphonetic::{Encoder, Metaphone}; use shared::model::{EpgNamePrefix, EpgSmartMatchConfigDto}; use shared::utils::Internable; diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 4fa054225..b3a71f3be 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -52,7 +52,7 @@ async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader }).await.map_err(|e| notify_err!("Mapping xtream streams failed for input {input_name}: {e}"))? } -fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &SeriesStreamDetailEpisodeProperties) -> Arc { +pub fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &SeriesStreamDetailEpisodeProperties) -> Arc { if episode.direct_source.is_empty() { let ext = episode.container_extension.clone(); let stream_base_url = format!("{url}/series/{username}/{password}/{}.{ext}", episode.id); @@ -62,8 +62,10 @@ fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, e } } -pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStreamProperties, - group_title: &str, series_name: &Arc, input: &ConfigInput) -> Option> { +pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStreamProperties, group_title: &str, series_name: &Arc, input: &ConfigInput, + // Add series_release_date parameter + 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); @@ -72,7 +74,14 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre let result: Vec = episodes.iter().map(|episode| { let episode_id = episode.id.to_string(); let episode_url = create_xtream_series_episode_url(url, username, password, episode); - let episode_info = EpisodeStreamProperties::from_series(series_info, episode); + + // Create properties and inject global release date if available + let mut episode_info = EpisodeStreamProperties::from_series(series_info, episode); + if let Some(date) = series_release_date { + episode_info.series_release_date = Some(Arc::clone(date)); + } + + PlaylistItem { header: PlaylistItemHeader { uuid: generate_playlist_uuid(&input.name, &episode_id, PlaylistItemType::Series, &episode_url), @@ -86,7 +95,7 @@ pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStre url: episode_url, item_type: PlaylistItemType::Series, xtream_cluster: XtreamCluster::Series, - additional_properties: Some(StreamProperties::Episode(episode_info)), + additional_properties: Some(StreamProperties::Episode(Box::new(episode_info))), category_id: 0, input_name: input.name.intern(), ..Default::default() @@ -378,8 +387,8 @@ mod tests { use crate::processing::parser::xtream::map_to_xtream_streams; use crate::utils::async_file_reader; use shared::model::{XtreamCluster, XtreamSeriesInfo}; - use std::fs; use shared::utils::Internable; + use std::fs; #[test] fn test_read_json_file_into_struct() { @@ -429,4 +438,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/playlist_watch.rs b/backend/src/processing/playlist_watch.rs index ba1d4da05..dc77345f7 100644 --- a/backend/src/processing/playlist_watch.rs +++ b/backend/src/processing/playlist_watch.rs @@ -75,7 +75,10 @@ async fn load_watch_tree(path: &Path) -> Option>> { } async fn save_watch_tree(path: &Path, tree: &BTreeSet>) -> std::io::Result<()> { + // Ensure the parent directory exists unconditionally + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } let encoded: Vec = binary_serialize(&tree)?; tokio::fs::write(path, encoded).await -} - +} \ No newline at end of file diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 3d306a79a..2871f29bb 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -103,18 +103,19 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &Arc, group_nam item_type: PlaylistItemType::LocalSeries, category_id: 0, input_name: Arc::clone(input_name), - additional_properties: Some(StreamProperties::Episode(EpisodeStreamProperties { + additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { episode_id: episode.id, episode: episode.episode_num, season: episode.season, added: Some(episode.added.clone()), release_date: Some(episode.release_date.clone()), + series_release_date: None, tmdb: episode.tmdb, movie_image: logo, container_extension: container_extension.intern(), audio: None, video: None, - })), + }))), ..Default::default() } }); @@ -287,6 +288,7 @@ pub fn metadata_cache_entry_to_xtream_series_info( direct_source: episode.file_path.clone().into(), tmdb: tmdb_id, release_date: episode_release_date.clone().into(), + series_release_date: None, plot: episode.plot.clone().map(Into::into), crew: Some(Arc::clone(&actor_names)), duration_secs: episode.runtime.map_or(0, |r| r * 60), diff --git a/backend/src/processing/processor/mod.rs b/backend/src/processing/processor/mod.rs index fc970a4ad..e908c26fe 100644 --- a/backend/src/processing/processor/mod.rs +++ b/backend/src/processing/processor/mod.rs @@ -1,29 +1,76 @@ -pub mod playlist; +mod playlist; mod xtream; // mod affix; mod xtream_vod; mod xtream_series; -pub mod epg; +mod epg; mod sort; -pub mod trakt; +mod trakt; mod library; +mod stream_probe; +pub use self::playlist::*; +pub use self::epg::*; +pub use self::xtream::*; +pub use self::xtream_vod::*; +pub use self::xtream_series::*; +pub use self::stream_probe::*; +use shared::create_bit_set; + +create_bit_set!(u16, ResolveOptionsFlags, Resolve, TmdbMissing, Probe, Background); + +pub struct ResolveOptions { + pub flags: ResolveOptionsFlagsSet, + pub resolve_delay: u16, +} + +impl Default for ResolveOptions { + fn default() -> Self { + Self { + flags: ResolveOptionsFlagsSet::from_variants(&[ResolveOptionsFlags::Background]), + resolve_delay: 0, + } + } +} + // -// fn get_resolve__options(target: &ConfigTarget, fpl: &FetchedPlaylist) -> (bool, u16) +// fn get_resolve__options(target: &ConfigTarget, fpl: &FetchedPlaylist) -> bool // #[macro_export] macro_rules! create_resolve_options_function_for_xtream_target { ($cluster:ident) => { paste::paste! { - fn [](target: &ConfigTarget, fpl: &FetchedPlaylist) -> (bool, u16) { + 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) => (xtream_output.[] && fpl.input.input_type == InputType::Xtream, - xtream_output.[]), - None => (false, 0) + Some(xtream_output) => { + 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_tmdb_missing { + flags.add($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 xtream_output.flags.contains($crate::model::XtreamTargetFlags::ResolveBackground) { + flags.add($crate::processing::processor::ResolveOptionsFlags::Background); + } + + $crate::processing::processor::ResolveOptions { + flags, + resolve_delay: xtream_output.resolve_delay, + } + }, + None => $crate::processing::processor::ResolveOptions::default(), } } } }; } use create_resolve_options_function_for_xtream_target; - diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 6a4af8462..4b918f0dd 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -1,4 +1,4 @@ -use crate::model::{AppConfig, ConfigFavourites, ConfigInput, ConfigRename, ReverseProxyDisabledHeaderConfig, TVGuide}; +use crate::model::{AppConfig, ConfigFavourites, ConfigInput, ConfigRename, MessageContent, ReverseProxyDisabledHeaderConfig, TVGuide}; use crate::utils::{log_memory_snapshot, m3u}; use crate::utils::xtream; use crate::utils::{epg, StepMeasureCallback}; @@ -8,15 +8,13 @@ use std::sync::{Arc, Weak}; use tokio::sync::{Mutex, OwnedRwLockWriteGuard, RwLock}; use tokio::task::JoinSet; -use crate::api::model::{EventManager, EventMessage, PlaylistStorageState, UpdateGuard}; -use crate::messaging::send_message; -use crate::model::messaging::MessageContent; +use crate::api::model::{ActiveProviderManager, EventManager, EventMessage, PlaylistStorageState, UpdateGuard, MetadataUpdateManager, ProviderIdType, ResolveReasonSet, ResolveReason, UpdateTask}; +use crate::messaging::{send_message}; use crate::model::FetchedPlaylist; use crate::model::Mapping; -use crate::model::{ConfigTarget, ProcessTargets}; -use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats}; +use crate::model::{ConfigTarget, ProcessTargets, TargetOutput, XtreamTargetFlags}; use crate::processing::input_cache; use crate::processing::input_cache::ClusterState; use crate::processing::parser::xmltv::flatten_tvguide; @@ -39,13 +37,15 @@ 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::model::xtream_const::XTREAM_CLUSTER; -use shared::model::UUIDType; +use shared::model::{InputStats, PlaylistStats, SourceStats, TargetStats, UUIDType}; use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState, - ProcessingOrder, XtreamCluster}; + ProcessingOrder, StreamProperties, XtreamCluster}; use shared::utils::{create_alias_uuid, default_as_default, interner_gc, Internable}; use std::time::Instant; +const DEFAULT_PROBE_STREAM_DELAY: u16 = 50; + fn is_valid(pli: &PlaylistItem, filter: &Filter, match_as_ascii: bool) -> bool { let provider = ValueProvider { pli, match_as_ascii }; filter.filter(&provider) @@ -276,7 +276,30 @@ fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bo (!user_targets.enabled && target.enabled) || (user_targets.enabled && user_targets.has_target(target.id)) } -async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec, bool, bool) { +struct PlaylistDownloadResult { + pub downloaded_playlist:Vec, + pub download_err: Vec, + pub was_cached: bool, + pub persisted: bool, +} + +impl PlaylistDownloadResult { + pub fn new( + downloaded_playlist:Vec, + download_err: Vec, + was_cached: bool, + 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 { let config = &*app_config.config.load(); let working_dir = &config.working_dir; @@ -315,7 +338,7 @@ async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc }; if fully_cached { - return (vec![], vec![], true, false); + return PlaylistDownloadResult::new(vec![], vec![], true, false); } let (playlist, errors, persisted) = match input.input_type { @@ -365,7 +388,7 @@ async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc } } - (playlist, errors, false, persisted) + PlaylistDownloadResult::new(playlist, errors, false, persisted) } async fn process_source(source_idx: usize, ctx: &PlaylistProcessingContext) -> (Vec, Vec, Vec) { @@ -489,7 +512,7 @@ async fn download_input(ctx: &PlaylistProcessingContext, input: &Arc (pl_source, None), Err(e) => (MemoryPlaylistSource::default().boxed(), Some(e)), } } else { debug!("Persisting input '{}' playlist", input.name); - let (pl, err) = persist_input_playlist(&ctx.config, input, downloaded_playlist).await; + let (pl, err) = persist_input_playlist(&ctx.config, input, playlist_download_result.downloaded_playlist).await; (MemoryPlaylistSource::new(pl).boxed(), err) }; - (download_err, playlist, error) + (playlist_download_result.download_err, playlist, error) } fn create_broadcast_callback(event_manager: Option<&Arc>) -> StepMeasureCallback { @@ -562,9 +585,13 @@ pub struct PlaylistProcessingContext { pub disabled_headers: Option, // Coordination - processed_inputs: Arc>>>, + pub processed_inputs: Arc>>>, #[allow(clippy::type_complexity)] - input_locks: Arc, Weak>>>>, + pub input_locks: Arc, Weak>>>>, + + // New field for STRM probes & background updates + pub provider_manager: Option>, + pub metadata_manager: Option>, } impl PlaylistProcessingContext { @@ -754,9 +781,7 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, 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()); processed_fpl.sort_by_provider_ordinal(); - playlist_resolve_series(&ctx.config, &ctx.client, target, errors, &pipe, provider_fpl, &mut processed_fpl).await; - log_memory_snapshot(format!("target '{}' input '{}' after_series_resolve", target.name, provider_fpl.input.name).as_str()); - playlist_resolve_vod(&ctx.config, &ctx.client, target, errors, provider_fpl, &mut processed_fpl).await; + 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()); // stats let input_entry_name = processed_fpl.input.name.clone(); @@ -818,6 +843,203 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext, } } +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 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 is_cluster_probe_enabled_for_target(target: &ConfigTarget, 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) { + return true; + } + + get_xtream_cluster_probe_flag(target, 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 + | 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::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) { + return None; + } + target + .get_xtream_output() + .map(|xtream_output| (xtream_output.resolve_delay, u64::from(xtream_output.probe_live_interval_hours) * 3600)) +} + +fn needs_live_probe(item: &PlaylistItem, cutoff_ts: i64) -> bool { + match item.header.additional_properties.as_ref() { + Some(StreamProperties::Live(props)) => { + if let Some(last_ts) = props.last_probed_timestamp { + last_ts < cutoff_ts + } else { + true + } + } + _ => true, + } +} + +fn provider_id_from_item(item: &PlaylistItem) -> Option { + if let Ok(id) = item.header.id.parse::() { + if id == 0 { + return None; + } + return Some(ProviderIdType::Id(id)); + } + + let raw = item.header.id.trim(); + if raw.is_empty() { + None + } else { + Some(ProviderIdType::from(raw)) + } +} + +async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, fpl: &mut FetchedPlaylist<'_>) { + let Some(mgr) = ctx.metadata_manager.as_ref() else { + return; + }; + let Some(opts) = fpl.input.options.as_ref() else { + return; + }; + if !opts.probe_stream { + return; + } + if !ctx.config.is_ffprobe_enabled().await { + return; + } + + 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 mut queued_probe_keys: HashSet<(Arc, String)> = HashSet::new(); + let mut queued_live_keys: HashSet = HashSet::new(); + let mut queued_live_count = 0usize; + let mut queued_stream_count = 0usize; + + for item in fpl.items() { + if !is_cluster_probe_enabled_for_target(target, item.header.xtream_cluster) { + continue; + } + + if !is_probe_supported_item_type(item.header.item_type) { + continue; + } + + if item.header.item_type.is_live() { + if let Some((resolve_delay, interval_secs, cutoff_ts)) = live_probe_settings { + if needs_live_probe(&item, cutoff_ts) { + if let Some(provider_id) = provider_id_from_item(&item) { + if queued_live_keys.insert(provider_id.clone()) { + let task = UpdateTask::ProbeLive { + id: provider_id, + reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), + delay: resolve_delay, + interval: interval_secs, + }; + mgr.queue_task_background(input_name.clone(), task); + queued_live_count += 1; + } + } + } + continue; + } + } + + // 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. + 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() + }; + + if !queued_probe_keys.insert((probe_scope.clone(), unique_id.clone())) { + continue; + } + + let task = UpdateTask::ProbeStream { + probe_scope, + unique_id, + url: item.header.url.to_string(), + item_type: item.header.item_type, + reason: ResolveReasonSet::from_variants(&[ResolveReason::MissingDetails]), + delay: DEFAULT_PROBE_STREAM_DELAY, + }; + mgr.queue_task_background(input_name.clone(), task); + queued_stream_count += 1; + } + + 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})" + ); + } +} + pub fn process_favourites(playlist: &mut Vec, favourites_cfg: Option<&[ConfigFavourites]>) { if let Some(favourites) = favourites_cfg { let mut fav_groups: IndexMap> = IndexMap::new(); @@ -895,11 +1117,13 @@ 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) { - let _guard = if let Some(guard) = update_guard { + 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 { @@ -925,6 +1149,8 @@ pub async fn exec_processing(client: &reqwest::Client, app_config: Arc {}", first, second, strsim::jaro_winkler(first, second))); -// // println!("jaro {}", strsim::jaro(data.0, data.1)); -// // println!("levenhstein {}", strsim::levenshtein(data.0, data.1)); -// // println!("damerau_levenshtein {:?}", strsim::damerau_levenshtein(data.0, data.1)); -// // println!("osa distance {:?}", strsim::osa_distance(data.0, data.1)); -// // println!("sorensen dice {:?}", strsim::sorensen_dice(data.0, data.1)); -// } +#[cfg(test)] +mod tests { + use super::*; + use shared::utils::Internable; -// } + fn item_with_props(props: StreamProperties) -> PlaylistItem { + let header = shared::model::PlaylistItemHeader { + additional_properties: Some(props), + ..Default::default() + }; + PlaylistItem { header } + } + + #[test] + fn has_probe_details_requires_video_and_audio_for_video() { + let video = shared::model::VideoStreamProperties { + details: Some(shared::model::VideoStreamDetailProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: None, + ..Default::default() + }), + ..Default::default() + }; + let item_missing_audio = item_with_props(StreamProperties::Video(Box::new(video))); + assert!(!has_probe_details(&item_missing_audio)); + + let video_complete = shared::model::VideoStreamProperties { + details: Some(shared::model::VideoStreamDetailProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: Some("{\"codec_name\":\"aac\"}".intern()), + ..Default::default() + }), + ..Default::default() + }; + let item_complete = item_with_props(StreamProperties::Video(Box::new(video_complete))); + assert!(has_probe_details(&item_complete)); + } + + #[test] + fn has_probe_details_requires_video_and_audio_for_live() { + let live_missing_audio = shared::model::LiveStreamProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: None, + ..Default::default() + }; + let item_missing_audio = item_with_props(StreamProperties::Live(Box::new(live_missing_audio))); + assert!(!has_probe_details(&item_missing_audio)); + + let live_complete = shared::model::LiveStreamProperties { + video: Some("{\"codec_name\":\"h264\"}".intern()), + audio: Some("{\"codec_name\":\"aac\"}".intern()), + ..Default::default() + }; + let item_complete = item_with_props(StreamProperties::Live(Box::new(live_complete))); + assert!(has_probe_details(&item_complete)); + } + + #[test] + fn has_probe_details_is_false_for_series() { + let series = shared::model::SeriesStreamProperties::default(); + let item = item_with_props(StreamProperties::Series(Box::new(series))); + assert!(!has_probe_details(&item)); + } +} diff --git a/backend/src/processing/processor/stream_probe.rs b/backend/src/processing/processor/stream_probe.rs new file mode 100644 index 000000000..f97c9fa6f --- /dev/null +++ b/backend/src/processing/processor/stream_probe.rs @@ -0,0 +1,318 @@ +use crate::api::model::ActiveProviderManager; +use crate::model::ConfigInput; +use crate::model::{AppConfig}; +use crate::repository::{get_input_m3u_playlist_file_path, get_input_storage_path, get_input_local_library_playlist_file_path, xtream_get_file_path, BPlusTreeUpdate}; +use crate::utils::{debug_if_enabled, ffmpeg}; +use log::{info, warn}; +use shared::error::TuliproxError; +use shared::model::{EpisodeStreamProperties, InputType, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, LiveStreamProperties, M3uPlaylistItem, XtreamCluster, XtreamPlaylistItem}; +use std::sync::Arc; +use shared::model::UUIDType; + +enum ProbeStorageKind { + M3u, + Library, + Xtream, +} + +fn requires_provider_connection_for_generic_probe(input_type: InputType) -> bool { + !matches!(input_type, InputType::Library) +} + +/// Updates metadata (Probing) for a stream URL (M3U, Xtream, Library) and persists it. +/// - `unique_id`: For M3U this is the `provider_id` (String). For Library this is the `UUID` string. +/// For Xtream this is the numeric provider id as string. +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub async fn update_generic_stream_metadata( + app_config: &Arc, + input: &ConfigInput, + unique_id: &str, + stream_url: &str, + item_type: PlaylistItemType, + active_provider: &Arc, + active_handle: Option<&crate::api::model::ProviderHandle>, +) -> Result<(), TuliproxError> { + let working_dir = &app_config.config.load().working_dir; + + // Check if probing is enabled globally + let ffprobe_enabled = app_config.is_ffprobe_enabled().await; + if !ffprobe_enabled { + return Ok(()); + } + + // Determine storage file path based on input type + let storage_path = get_input_storage_path(&input.name, working_dir).await + .map_err(|e| shared::error::info_err!("Storage path error: {e}"))?; + + let (db_path, storage_kind) = match input.input_type { + InputType::M3u | InputType::M3uBatch => ( + get_input_m3u_playlist_file_path(&storage_path, &input.name), + ProbeStorageKind::M3u, + ), + InputType::Library => ( + get_input_local_library_playlist_file_path(&storage_path, &input.name), + ProbeStorageKind::Library, + ), + InputType::Xtream | InputType::XtreamBatch => { + let cluster = if item_type.is_live() { + XtreamCluster::Live + } else if matches!(item_type, PlaylistItemType::Video | PlaylistItemType::LocalVideo) { + XtreamCluster::Video + } else if matches!(item_type, PlaylistItemType::Series | PlaylistItemType::LocalSeries) { + XtreamCluster::Series + } else { + // Generic probing currently supports live/video/series payload shapes. + return Ok(()); + }; + ( + xtream_get_file_path(&storage_path, cluster), + ProbeStorageKind::Xtream, + ) + } + }; + + if !db_path.exists() { + return Err(shared::error::info_err!("Playlist DB file not found for input {}: {}", input.name, db_path.display())); + } + + let needs_provider_connection = requires_provider_connection_for_generic_probe(input.input_type); + + let acquired_handle = if !needs_provider_connection || active_handle.is_some() { + None + } else { + active_provider.acquire_connection_for_probe(&input.name).await + }; + + if needs_provider_connection && active_handle.is_none() && acquired_handle.is_none() { + warn!("Skipping probe for generic stream {unique_id} due to connection limits"); + return Err(shared::error::info_err!("No connection available")); + } + + let probe_url = stream_url.to_string(); + let config = app_config.config.load(); + let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let user_agent = config.default_user_agent.clone(); + let analyze_duration = 10_000_000; + let probe_size = 10_000_000; + + debug_if_enabled!("Probing Generic Stream '{unique_id}'"); + + let probe_data = ffmpeg::probe_url( + &probe_url, + user_agent.as_deref(), + analyze_duration, + probe_size, + ffprobe_timeout, + ).await; + + if let Some(handle) = acquired_handle { + active_provider.release_handle(&handle).await; + } + + let Some((_quality, raw_video, raw_audio)) = probe_data else { + warn!("Probe failed or timed out for generic stream: {unique_id}"); + return Ok(()); + }; + + // Acquire lock and open a tree for update + let _file_lock = app_config.file_locks.write_lock(&db_path).await; + + // Update the record in BPlusTree + match storage_kind { + ProbeStorageKind::M3u => { + let key: Arc = unique_id.into(); + let mut tree_update = BPlusTreeUpdate::, M3uPlaylistItem>::try_new(&db_path) + .map_err(|e| shared::error::info_err!("Failed to open M3U tree update: {e}"))?; + + if let Some(mut item) = tree_update + .query(&key) + .map_err(|e| shared::error::info_err!("Tree query error: {e}"))? + { + update_properties( + &mut item.additional_properties, + item_type, + &item.name, + item.virtual_id, + raw_video, + raw_audio, + ); + tree_update + .update(&key, item) + .map_err(|e| shared::error::info_err!("Tree update error: {e}"))?; + info!("Successfully updated M3U metadata for: {unique_id}"); + } else { + warn!("Item not found in M3U DB: {unique_id}"); + } + } + ProbeStorageKind::Library => { + let mut tree_update = BPlusTreeUpdate::::try_new(&db_path) + .map_err(|e| shared::error::info_err!("Failed to open Library tree update: {e}"))?; + let uuid = UUIDType::from_valid_uuid(unique_id); + + if let Some(mut item) = tree_update + .query(&uuid) + .map_err(|e| shared::error::info_err!("Tree query error: {e}"))? + { + update_properties( + &mut item.additional_properties, + item_type, + &item.name, + item.virtual_id, + raw_video, + raw_audio, + ); + tree_update + .update(&uuid, item) + .map_err(|e| shared::error::info_err!("Tree update error: {e}"))?; + info!("Successfully updated Library metadata for: {unique_id}"); + } else { + warn!("Item not found in Library DB: {unique_id}"); + } + } + ProbeStorageKind::Xtream => { + let Ok(provider_id) = unique_id.parse::() else { + warn!("Skipping xtream generic probe update with non-numeric id: {unique_id}"); + return Ok(()); + }; + + let mut tree_update = BPlusTreeUpdate::::try_new(&db_path) + .map_err(|e| shared::error::info_err!("Failed to open Xtream tree update: {e}"))?; + + if let Some(mut item) = tree_update + .query(&provider_id) + .map_err(|e| shared::error::info_err!("Tree query error: {e}"))? + { + update_properties( + &mut item.additional_properties, + item_type, + &item.name, + item.virtual_id, + raw_video, + raw_audio, + ); + tree_update + .update(&provider_id, item) + .map_err(|e| shared::error::info_err!("Tree update error: {e}"))?; + info!("Successfully updated Xtream metadata for: {unique_id}"); + } else { + warn!("Item not found in Xtream DB: {unique_id}"); + } + } + } + + Ok(()) +} + +fn update_properties( + props_opt: &mut Option, + item_type: PlaylistItemType, + name: &str, + virtual_id: u32, + raw_video: Option, + raw_audio: Option +) { + if matches!(item_type, PlaylistItemType::Video | PlaylistItemType::LocalVideo) { + let mut props = if let Some(StreamProperties::Video(p)) = props_opt { + *p.clone() + } else { + VideoStreamProperties { + name: name.into(), + stream_id: virtual_id, + container_extension: "".into(), + ..Default::default() + } + }; + + if props.details.is_none() { + props.details = Some(VideoStreamDetailProperties::default()); + } + if let Some(details) = props.details.as_mut() { + if let Some(v) = raw_video { + details.video = Some(v.to_string().into()); + } + if let Some(a) = raw_audio { + details.audio = Some(a.to_string().into()); + } + } + *props_opt = Some(StreamProperties::Video(Box::new(props))); + } + else if matches!(item_type, PlaylistItemType::Series | PlaylistItemType::LocalSeries) { + let mut props = if let Some(StreamProperties::Episode(p)) = props_opt { + *p.clone() + } else { + EpisodeStreamProperties { + episode_id: virtual_id, + episode: 0, + season: 0, + added: None, + release_date: None, + series_release_date: None, + tmdb: None, + movie_image: "".into(), + container_extension: "".into(), + video: None, + audio: None, + } + }; + + if let Some(v) = raw_video { + props.video = Some(v.to_string().into()); + } + if let Some(a) = raw_audio { + props.audio = Some(a.to_string().into()); + } + *props_opt = Some(StreamProperties::Episode(Box::new(props))); + } + else if matches!(item_type, PlaylistItemType::Live | PlaylistItemType::LiveHls | PlaylistItemType::LiveDash) { + let mut props = if let Some(StreamProperties::Live(p)) = props_opt { + *p.clone() + } else { + LiveStreamProperties { + name: name.into(), + stream_id: virtual_id, + ..LiveStreamProperties::default() + } + }; + + if let Some(v) = raw_video { + props.video = Some(v.to_string().into()); + } + if let Some(a) = raw_audio { + props.audio = Some(a.to_string().into()); + } + + let now = chrono::Utc::now().timestamp(); + props.last_probed_timestamp = Some(now); + props.last_success_timestamp = Some(now); + + *props_opt = Some(StreamProperties::Live(Box::new(props))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_probe_does_not_require_provider_connection() { + assert!(!requires_provider_connection_for_generic_probe(InputType::Library)); + } + + #[test] + fn m3u_probe_requires_provider_connection() { + assert!(requires_provider_connection_for_generic_probe(InputType::M3u)); + assert!(requires_provider_connection_for_generic_probe( + InputType::M3uBatch + )); + } + + #[test] + fn xtream_probe_requires_provider_connection() { + assert!(requires_provider_connection_for_generic_probe( + InputType::Xtream + )); + assert!(requires_provider_connection_for_generic_probe( + InputType::XtreamBatch + )); + } +} diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index 3efea32f5..0517aee93 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -1,33 +1,138 @@ use std::sync::Arc; -use shared::error::{info_err}; use shared::error::{TuliproxError}; -use crate::model::{AppConfig, ConfigInput, InputSource}; -use shared::model::{PlaylistEntry, PlaylistItem, XtreamCluster}; -use crate::utils::xtream; +use crate::model::{AppConfig, ConfigInput}; +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}; +use log::{debug, warn}; +use crate::processing::parser::xtream::create_xtream_url; +use crate::api::model::ProviderIdType; -pub(in crate::processing) async fn playlist_resolve_download_playlist_item( +/// Updates metadata for a single Live stream (primarily probing) +pub async fn update_live_stream_metadata( app_config: &Arc, - client: &reqwest::Client, - pli: &PlaylistItem, input: &ConfigInput, - errors: &mut Vec, - resolve_delay: u16, - cluster: XtreamCluster, -) -> Option { - let mut result = None; - let provider_id = pli.get_provider_id()?; - if let Some(info_url) = xtream::get_xtream_player_api_info_url(input, cluster, provider_id) { - let input_source = InputSource::from(input).with_url(info_url); - result = match xtream::get_xtream_stream_info_content(app_config, client, &input_source, true).await { - Ok(content) => Some(content), - Err(err) => { - errors.push(info_err!("{err}")); - None + id: ProviderIdType, + save: bool, + db_query: Option<&mut BPlusTreeQuery>, +) -> Result, TuliproxError> { + let working_dir = &app_config.config.load().working_dir; + let storage_path = get_input_storage_path(&input.name, working_dir).await + .map_err(|e| shared::error::info_err!("Storage path error: {e}"))?; + + // Try to load existing info first to preserve data + let mut props: Option = None; + let mut existing_item: Option = None; + + let stream_id_opt = if let ProviderIdType::Id(vid) = id { Some(vid) } else { None }; + + if let Some(stream_id) = stream_id_opt { + // Use provided query or open new one + if let Some(query) = db_query { + if let Ok(Some(item)) = query.query_zero_copy(&stream_id) { + existing_item = Some(item.clone()); + if let Some(StreamProperties::Live(p)) = item.additional_properties.as_ref() { + props = Some(*p.clone()); + } } - }; + } else { + let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Live); + if xtream_path.exists() { + let _file_lock = app_config.file_locks.read_lock(&xtream_path).await; + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + if let Ok(Some(item)) = query.query_zero_copy(&stream_id) { + existing_item = Some(item.clone()); + if let Some(StreamProperties::Live(p)) = item.additional_properties.as_ref() { + props = Some(*p.clone()); + } + } + } + } + } } - if resolve_delay > 0 { - tokio::time::sleep(std::time::Duration::new(u64::from(resolve_delay), 0)).await; + + // Initialize props if missing + let mut properties = if let Some(p) = props { + p + } else { + LiveStreamProperties { + stream_id: stream_id_opt.unwrap_or(0), + // If item exists but no props, try to recover name + name: existing_item.as_ref().map_or_else(|| "".into(), |i| i.name.clone()), + ..LiveStreamProperties::default() + } + }; + + // 1. Create dummy properties to generate URL for probing + // We construct a temporary property object just for URL generation to ensure we match the stream config + // (prefix/extension) correctly, even if we modify `properties` later. + let temp_stream_prop = StreamProperties::Live(Box::new(properties.clone())); + + 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); + + // We generate the URL to probe directly on the provider + let stream_url = create_xtream_url( + XtreamCluster::Live, + input_url, username, password, + &temp_stream_prop, + use_prefix, no_ext + ); + let config = app_config.config.load(); + let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let user_agent = config.default_user_agent.clone(); + let analyze_duration = 5_000_000; + let probe_size = 5_000_000; + + let display_id = stream_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string()); + debug!("Probing Live Stream ID {} for input {}", display_id, input.name); + + // Update last_probed_timestamp BEFORE probing to ensure we record the attempt even if it crashes/panics (unlikely but safe) + // Actually, update it before persisting. + let now = chrono::Utc::now().timestamp(); + properties.last_probed_timestamp = Some(now); + + let mut success = false; + if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url( + &stream_url, + user_agent.as_deref(), + analyze_duration, + probe_size, + ffprobe_timeout, + ).await { + // 3. Update properties on success + if let Some(v) = raw_video { + properties.video = Some(v.to_string().into()); + } + if let Some(a) = raw_audio { + properties.audio = Some(a.to_string().into()); + } + properties.last_success_timestamp = Some(now); + success = true; + + debug_if_enabled!("Successfully probed Live Stream ID {}", display_id); + } else { + warn!("Probe failed for Live Stream ID {} (Input: {})", display_id, input.name); + // We still persist the updated last_probed_timestamp so we don't retry immediately } - result + + // 4. Persist + if save { + if let Some(stream_id) = stream_id_opt { + persist_input_live_info(app_config, &storage_path, XtreamCluster::Live, &input.name, stream_id, &properties) + .await + .map_err(|e| shared::error::info_err!("Persist error: {e}"))?; + } + } + + if !success { + // Return error to propagate failure up to task manager/logs + return Err(shared::error::info_err!("Probe failed for stream {display_id}")); + } + + Ok(Some(properties)) } diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 8ac92385c..164e7c88a 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -1,260 +1,113 @@ +use crate::api::model::UpdateTask; +use crate::api::model::{ActiveProviderManager, ProviderHandle, ProviderIdType, ResolveReason, ResolveReasonSet}; +use crate::library::MetadataResolver; use crate::model::FetchedPlaylist; use crate::model::{AppConfig, ConfigTarget}; +use crate::model::{ConfigInput, InputSource}; +use crate::processing::parser::xtream::create_xtream_series_episode_url; use crate::processing::parser::xtream::parse_xtream_series_info; -use crate::processing::processor::create_resolve_options_function_for_xtream_target; -use crate::processing::processor::playlist::ProcessingPipe; -use crate::processing::processor::xtream::playlist_resolve_download_playlist_item; -use crate::repository::{get_input_storage_path, persist_input_series_info_batch, MemoryPlaylistSource, PlaylistSource}; -use log::{error, info, log_enabled, Level}; +use crate::processing::processor::playlist::{PlaylistProcessingContext, ProcessingPipe}; +use crate::processing::processor::{create_resolve_options_function_for_xtream_target, ResolveOptions, ResolveOptionsFlags}; +use crate::ptt::ptt_parse_title; +use crate::repository::persists_input_series_info; +use crate::repository::{ + get_input_storage_path, persist_input_series_info_batch, MemoryPlaylistSource, PlaylistSource, +}; +use crate::repository::{xtream_get_file_path, BPlusTreeQuery}; +use crate::utils::{debug_if_enabled, xtream}; +use log::{debug, error, info, log_enabled, trace, warn, Level}; +use serde_json::Value; use shared::error::TuliproxError; -use shared::model::{InputType, PlaylistEntry, SeriesStreamProperties, StreamProperties, XtreamSeriesInfo}; -use shared::model::{PlaylistGroup, PlaylistItemType, UUIDType, XtreamCluster}; -use indexmap::IndexMap; -use std::collections::{HashMap, HashSet}; +use shared::model::{MediaQuality, PlaylistEntry, PlaylistItem, SeriesStreamProperties, StreamProperties, XtreamPlaylistItem, XtreamSeriesInfo}; +use shared::model::{PlaylistGroup, PlaylistItemType, XtreamCluster}; +use std::collections::HashMap; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; create_resolve_options_function_for_xtream_target!(series); const BATCH_SIZE: usize = 100; -#[allow(clippy::too_many_lines)] -async fn playlist_resolve_series_info(app_config: &Arc, client: &reqwest::Client, - errors: &mut Vec, - fpl: &mut FetchedPlaylist<'_>, - resolve_series: bool, - resolve_delay: u16) -> Vec { - - let input = fpl.input; - let working_dir = &app_config.config.load().working_dir; - let storage_path = match get_input_storage_path(&input.name, working_dir).await { - Ok(storage_path) => storage_path, - Err(err) => { - error!("Can't resolve series info, input storage directory for input '{}' failed: {err}", input.name); - return vec![]; - } - }; - - let series_info_count = if resolve_series { - let series_info_count = fpl.get_missing_series_info_count(); - if series_info_count > 0 { - info!("Found {series_info_count} series info to resolve"); - } - series_info_count - } else { - 0 - }; - - let mut last_log_time = Instant::now(); - let mut processed_series_info_count = 0; - let mut group_series: IndexMap = IndexMap::new(); - let mut batch = Vec::with_capacity(BATCH_SIZE); - - let input = fpl.input; - for pli in fpl.items_mut() { - if pli.header.xtream_cluster != XtreamCluster::Series - || pli.header.item_type != PlaylistItemType::SeriesInfo { - continue; - } - - let Some(provider_id) = pli.get_provider_id() else { continue; }; - if provider_id == 0 { - continue; - } - - let should_download = resolve_series && !pli.has_details(); - if should_download { - processed_series_info_count += 1; - if let Some(content) = playlist_resolve_download_playlist_item( - app_config, - client, - pli, - input, - errors, - resolve_delay, - XtreamCluster::Series, - ) - .await - { - if !content.is_empty() { - //tokio::fs::write(&storage_path.join(format!("{provider_id}_series_info.json")), &content).await.ok(); - match serde_json::from_str::(&content) { - Ok(info) => { - let series_stream_props = SeriesStreamProperties::from_info(&info, pli); - - batch.push((provider_id, series_stream_props.clone())); - if batch.len() >= BATCH_SIZE { - if let Err(err) = persist_input_series_info_batch(app_config, &storage_path, XtreamCluster::Series, &input.name, std::mem::take(&mut batch)).await { - error!("Failed to persist batch series info: {err}"); - } - } - - // Update in-memory playlist items with the newly fetched vod info. - // This makes the data available for later processing steps like STRM export. - pli.header.additional_properties = Some(StreamProperties::Series(Box::new(series_stream_props))); - } - Err(err) => { - error!("Failed to parse series info for provider_id {provider_id}: {err}"); - } - } - } - } - } - - // extract episodes from info - if let Some(StreamProperties::Series(properties)) = pli.header.additional_properties.as_ref() { - let (group, series_name) = { - let header = &pli.header; - (header.group.clone(), if header.name.is_empty() { header.title.clone() } else { header.name.clone() }) - }; - if let Some(episodes) = parse_xtream_series_info(&pli.get_uuid(), properties, &group, &series_name, input) { - let group = group_series.entry(pli.header.category_id) - .or_insert_with(|| { - PlaylistGroup { - id: pli.header.category_id, - title: pli.header.group.clone(), - channels: Vec::new(), - xtream_cluster: XtreamCluster::Series, - } - }); - group.channels.extend(episodes.into_iter()); - } - } - - if resolve_series && log_enabled!(Level::Info) && last_log_time.elapsed().as_secs() >= 30 { - info!("resolved {processed_series_info_count}/{series_info_count} series info"); - last_log_time = Instant::now(); - } - } - - if !batch.is_empty() { - if let Err(err) = persist_input_series_info_batch(app_config, &storage_path, XtreamCluster::Series, &input.name, batch).await { - error!("Failed to persist final batch series info: {err}"); - } - } - - if resolve_series { - info!("resolved {processed_series_info_count}/{series_info_count} series info"); - } - group_series.into_values().collect() -} - -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub async fn playlist_resolve_series(cfg: &Arc, - client: &reqwest::Client, - target: &ConfigTarget, - errors: &mut Vec, - pipe: &ProcessingPipe, - provider_fpl: &mut FetchedPlaylist<'_>, - processed_fpl: &mut FetchedPlaylist<'_>, +#[allow(clippy::too_many_arguments)] +pub async fn playlist_resolve_series( + ctx: &PlaylistProcessingContext, + target: &ConfigTarget, + errors: &mut Vec, + pipe: &ProcessingPipe, + provider_fpl: &mut FetchedPlaylist<'_>, + processed_fpl: &mut FetchedPlaylist<'_>, ) { - let (resolve_series, resolve_delay) = get_resolve_series_options(target, processed_fpl); + let resolve_options = get_resolve_series_options(target, processed_fpl); + + let do_probe = resolve_options.flags.contains(ResolveOptionsFlags::Probe) && ctx.config.is_ffprobe_enabled().await; provider_fpl.source.release_resources(XtreamCluster::Series); - let series_playlist = playlist_resolve_series_info(cfg, client, errors, processed_fpl, resolve_series, resolve_delay).await; - provider_fpl.source.obtain_resources().await; - // Keep input playlist as canonical source: - // - Store resolved series details back into provider_fpl items - // - Rebuild base (unmodified) episodes from provider_fpl metadata only + playlist_resolve_series_info( + ctx, + errors, + processed_fpl, + resolve_options, + do_probe, + pipe, + target, + ) + .await; + if provider_fpl.is_memory() { - let mut resolved_series_by_provider_id: HashMap = HashMap::new(); - for pli in processed_fpl.items() { - if pli.header.xtream_cluster != XtreamCluster::Series - || pli.header.item_type != PlaylistItemType::SeriesInfo - { - continue; - } - let Some(provider_id) = pli.get_provider_id() else { continue; }; - if provider_id == 0 { - continue; - } - if let Some(StreamProperties::Series(properties)) = pli.header.additional_properties.as_ref() { - resolved_series_by_provider_id - .entry(provider_id) - .or_insert_with(|| properties.as_ref().clone()); - } - } - - if !resolved_series_by_provider_id.is_empty() { - let mut existing_uuids: HashSet = provider_fpl - .items() - .filter(|pli| { - pli.header.xtream_cluster == XtreamCluster::Series - && pli.header.item_type == PlaylistItemType::Series - }) - .map(|pli| pli.get_uuid()) - .collect(); - - let mut base_series_groups: IndexMap = IndexMap::new(); - let input = provider_fpl.input; - for pli in provider_fpl.items_mut() { - if pli.header.xtream_cluster != XtreamCluster::Series - || pli.header.item_type != PlaylistItemType::SeriesInfo - { - continue; - } - - let Some(provider_id) = pli.get_provider_id() else { continue; }; - if provider_id == 0 { - continue; - } - - if let Some(resolved) = resolved_series_by_provider_id.get(&provider_id) { - pli.header.additional_properties = Some(StreamProperties::Series(Box::new(resolved.clone()))); - } - - let Some(StreamProperties::Series(series_props)) = pli.header.additional_properties.as_ref() else { - continue; - }; - - let (category_id, group_title, series_name) = { - let header = &pli.header; - ( - header.category_id, - header.group.clone(), - if header.name.is_empty() { - header.title.clone() - } else { - header.name.clone() - }, - ) - }; - - if let Some(episodes) = parse_xtream_series_info( - &pli.get_uuid(), - series_props, - &group_title, - &series_name, - input, - ) { - let group = base_series_groups.entry(category_id).or_insert_with(|| PlaylistGroup { - id: category_id, - title: group_title, - channels: Vec::new(), - xtream_cluster: XtreamCluster::Series, - }); - for episode in episodes { - if existing_uuids.insert(episode.get_uuid()) { - group.channels.push(episode); - } - } - } - } - - for group in base_series_groups.values().filter(|g| !g.channels.is_empty()) { - provider_fpl.update_playlist(group).await; - } - } + sync_resolved_series_properties(provider_fpl, processed_fpl); } - if series_playlist.is_empty() { - return; - } + provider_fpl.source.obtain_resources().await; +} - // run the target processing pipe over newly derived episodes - // run the processing pipe over new items - let mut new_playlist = series_playlist; +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +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| { + if pli.header.xtream_cluster != XtreamCluster::Series + || pli.header.item_type != PlaylistItemType::SeriesInfo + { + return false; + } + true + }; + + // Skip if nothing to do + let skip_resolve = !resolve_options.flags.contains(ResolveOptionsFlags::Resolve) && !do_probe && !resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing); + + let resolve_tmdb_enabled = fpl.input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + + let groups_to_add = if resolve_options.flags.contains(ResolveOptionsFlags::Background) && ctx.metadata_manager.is_some() { + queue_background_series_info( + ctx, + fpl, + filter, + &resolve_options, + do_probe, + skip_resolve, + resolve_tmdb_enabled, + ) + } else { + process_immediate_series_info( + ctx, + fpl, + filter, + &resolve_options, + do_probe, + skip_resolve, + resolve_tmdb_enabled, + ) + .await + }; + + // Apply pipe transformations to new groups + let mut new_playlist = groups_to_add; for f in pipe { let mut source = MemoryPlaylistSource::new(new_playlist); if let Some(v) = f(&mut source, target) { @@ -264,8 +117,628 @@ pub async fn playlist_resolve_series(cfg: &Arc, } } - // assign new items to the new playlist - for plg in &new_playlist { - processed_fpl.update_playlist(plg).await; + // Apply resolved episodes to playlist + for group in new_playlist { + fpl.update_playlist(&group).await; } } + +fn sync_resolved_series_properties( + provider_fpl: &mut FetchedPlaylist<'_>, + processed_fpl: &mut FetchedPlaylist<'_>, +) { + let mut resolved_series_by_provider_id: HashMap = HashMap::new(); + + for pli in processed_fpl.items() { + if pli.header.xtream_cluster != XtreamCluster::Series + || pli.header.item_type != PlaylistItemType::SeriesInfo + { + continue; + } + + let Some(provider_id) = pli.get_provider_id() else { + continue; + }; + if provider_id == 0 { + continue; + } + + if let Some(StreamProperties::Series(properties)) = pli.header.additional_properties.as_ref() { + resolved_series_by_provider_id + .entry(provider_id) + .or_insert_with(|| properties.as_ref().clone()); + } + } + + if resolved_series_by_provider_id.is_empty() { + return; + } + + for source_pli in provider_fpl.items_mut() { + if source_pli.header.xtream_cluster != XtreamCluster::Series + || source_pli.header.item_type != PlaylistItemType::SeriesInfo + { + continue; + } + + let Some(provider_id) = source_pli.get_provider_id() else { + continue; + }; + if provider_id == 0 { + continue; + } + + if let Some(resolved) = resolved_series_by_provider_id.get(&provider_id) { + source_pli.header.additional_properties = + Some(StreamProperties::Series(Box::new(resolved.clone()))); + } + } +} + +fn queue_background_series_info( + ctx: &PlaylistProcessingContext, + fpl: &mut FetchedPlaylist<'_>, + filter: impl Fn(&PlaylistItem) -> bool, + resolve_options: &ResolveOptions, + do_probe: bool, + skip_resolve: bool, + resolve_tmdb_enabled: bool, +) -> Vec { + let mut groups_to_add = Vec::new(); + let Some(mgr) = ctx.metadata_manager.as_ref() else { + return groups_to_add; + }; + + let input = fpl.input; + let input_name_arc = input.name.clone(); + + for pli in fpl.items_mut() { + if !filter(pli) { + continue; + } + + let provider_id = if let Ok(uid) = pli.header.id.parse::() { + ProviderIdType::Id(uid) + } else { + ProviderIdType::from(pli.header.id.to_string()) + }; + + if !skip_resolve { + let reasons = check_resolve_reasons(resolve_options, do_probe, resolve_tmdb_enabled, pli); + + if !reasons.is_empty() { + let task = UpdateTask::ResolveSeries { + id: provider_id, + reason: reasons, + delay: resolve_options.resolve_delay, + }; + mgr.queue_task_background(input_name_arc.clone(), task); + } + } + + if let Some(group_obj) = expand_series_item(pli, input) { + groups_to_add.push(group_obj); + } + } + groups_to_add +} + +#[allow(clippy::too_many_lines)] +async fn process_immediate_series_info( + ctx: &PlaylistProcessingContext, + fpl: &mut FetchedPlaylist<'_>, + filter: impl Fn(&PlaylistItem) -> bool, + resolve_options: &ResolveOptions, + do_probe: bool, + skip_resolve: bool, + resolve_tmdb_enabled: bool, +) -> Vec { + let input = fpl.input; + let working_dir = &ctx.config.config.load().working_dir; + let storage_path = match get_input_storage_path(&input.name, working_dir).await { + Ok(path) => path, + Err(err) => { + error!("Can't resolve series, input storage directory for input '{}' failed: {err}", input.name); + return Vec::new(); + } + }; + + // Keep an optional read query open and reopen lazily only when needed. + let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Series); + let mut db_query_holder = None; + let mut _db_lock_holder = None; + + let mut groups_to_add = Vec::new(); + let mut batch: Vec<(ProviderIdType, SeriesStreamProperties)> = Vec::with_capacity(BATCH_SIZE); + let mut processed_count = 0; + let mut last_log_time = Instant::now(); + + for pli in fpl.items_mut() { + if !filter(pli) { + continue; + } + + let provider_id = if let Ok(uid) = pli.header.id.parse::() { + ProviderIdType::Id(uid) + } else { + ProviderIdType::from(&*pli.header.id) + }; + + if !skip_resolve { + let reasons = check_resolve_reasons(resolve_options, do_probe, resolve_tmdb_enabled, pli); + + if !reasons.is_empty() { + if let Some(active_provider) = ctx.provider_manager.as_ref() { + if db_query_holder.is_none() && xtream_path.exists() { + let lock = ctx.config.file_locks.read_lock(&xtream_path).await; + if let Ok(query) = BPlusTreeQuery::::try_new(&xtream_path) { + db_query_holder = Some(query); + _db_lock_holder = Some(lock); + } + } + // Pass the optional reference to the query + let db_query_ref = db_query_holder.as_mut(); + + match update_series_info_immediate(ctx, active_provider, input, pli, provider_id.clone(), &reasons, db_query_ref).await { + Ok(Some(updated_props)) => { + pli.header.additional_properties = Some(StreamProperties::Series(Box::new(updated_props.clone()))); + batch.push((provider_id.clone(), updated_props)); + + if batch.len() >= BATCH_SIZE { + // Release lock before persisting to avoid deadlock (persist needs write lock) + db_query_holder = None; + _db_lock_holder = None; + + let updates: Vec<(u32, SeriesStreamProperties)> = batch.iter() + .filter_map(|(id, props)| { + if let ProviderIdType::Id(vid) = id { + Some((*vid, props.clone())) + } else { + None + } + }) + .collect(); + + if updates.is_empty() { + batch.clear(); + } else { + match persist_input_series_info_batch( + &ctx.config, + &storage_path, + XtreamCluster::Series, + &input.name, + updates, + ).await { + Ok(()) => batch.clear(), + Err(err) => { + error!( + "persist_input_series_info_batch failed for XtreamCluster::Series on input '{}'. batch.clear() skipped. Error: {}", + input.name, err + ); + } + } + } + } + + processed_count += 1; + if resolve_options.resolve_delay > 0 { + tokio::time::sleep(Duration::from_secs(u64::from(resolve_options.resolve_delay))).await; + } + } + Ok(None) => {} + Err(e) => { + error!("Failed to update Series metadata for {}: {e}", pli.header.title); + } + } + } + } + } + + if let Some(group_obj) = expand_series_item(pli, input) { + groups_to_add.push(group_obj); + } + + if log_enabled!(Level::Info) && last_log_time.elapsed().as_secs() >= 30 { + info!("resolved {processed_count} series info"); + last_log_time = Instant::now(); + } + } + + if !batch.is_empty() { + // Release lock before final persist + _db_lock_holder = None; + + let updates: Vec<(u32, SeriesStreamProperties)> = batch.into_iter() + .filter_map(|(id, props)| if let ProviderIdType::Id(vid) = id { Some((vid, props)) } else { None }) + .collect(); + + if !updates.is_empty() { + if let Err(err) = persist_input_series_info_batch( + &ctx.config, + &storage_path, + XtreamCluster::Series, + &input.name, + updates, + ).await { + error!("Failed to persist final batch series info: {err}"); + } + } + } + + if processed_count > 0 { + info!("Processed {processed_count} series info"); + } + + groups_to_add +} + +fn expand_series_item(pli: &PlaylistItem, input: &ConfigInput) -> Option { + if let Some(StreamProperties::Series(properties)) = pli.header.additional_properties.as_ref() { + let global_release_date = properties.release_date.clone(); + let (group, series_name) = { + let header = &pli.header; + (header.group.clone(), header.get_name()) + }; + + if let Some(episodes) = parse_xtream_series_info(&pli.get_uuid(), properties, &group, &series_name, input, global_release_date.as_ref()) { + return Some(PlaylistGroup { + id: pli.header.category_id, + title: pli.header.group.clone(), + channels: episodes, + xtream_cluster: XtreamCluster::Series, + }); + } + } + None +} + +async fn update_series_info_immediate( + ctx: &PlaylistProcessingContext, + active_provider: &Arc, + input: &ConfigInput, + pli: &PlaylistItem, + id: ProviderIdType, + reasons: &ResolveReasonSet, + db_query: Option<&mut BPlusTreeQuery>, +) -> Result, TuliproxError> { + let fetch_info = reasons.contains(ResolveReason::Info); + + update_series_metadata( + &ctx.config, + &ctx.client, + input, + id, + active_provider, + None, // active_handle + Some(&pli.header.title), + false, // save (we batch in caller) + fetch_info, + reasons.contains(ResolveReason::Probe), + db_query, + ).await +} + +fn check_resolve_reasons( + resolve_options: &ResolveOptions, + do_probe: bool, + resolve_tmdb_enabled: bool, + pli: &mut PlaylistItem, +) -> ResolveReasonSet { + let mut reasons = ResolveReasonSet::new(); + + 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); + } + 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(); + if needs_info { + reasons.add(ResolveReason::Info); + } + needs_info +} + +fn check_resolve_tmdb( + resolve_options: &ResolveOptions, + resolve_tmdb_enabled: bool, + pli: &mut PlaylistItem, + needs_info: bool, + reasons: &mut ResolveReasonSet, +) { + if resolve_tmdb_enabled && resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing) { + 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); } + } + } else if needs_info { + // Covered by info + } else { + reasons.add(ResolveReason::MissingDetails); + } + } +} + +fn check_needs_probe(pli: &mut PlaylistItem, reasons: &mut ResolveReasonSet) { + 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() { + for ep in episodes { + 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); + break; + } + } + } + } + } +} + +/// Updates metadata for a single Series (Info + Episodes Probe) and persists it. +/// +/// # Arguments +/// * `save` - If true, persists changes to the input database immediately (Instant strategy). +/// If false, returns the properties so the caller can batch persist them (Bundled strategy). +/// * `fetch_info` - If true, fetches details from Provider API. If false, uses existing/dummy data. +/// * `db_query` - Optional pre-opened DB handle to avoid re-opening file. +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub async fn update_series_metadata( + app_config: &Arc, + client: &reqwest::Client, + input: &ConfigInput, + id: ProviderIdType, + active_provider: &Arc, + active_handle: Option<&ProviderHandle>, + 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; + 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) { + return Ok(None); + } + + // Try to load existing info first + let mut props: Option = None; + let mut existing_item: Option = None; + + let series_id_opt = if let ProviderIdType::Id(vid) = id { Some(vid) } else { None }; + + if let Some(series_id) = series_id_opt { + // Use provided query or open new one + if let Some(query) = db_query { + if let Ok(Some(item)) = query.query_zero_copy(&series_id) { + existing_item = Some(item.clone()); + if let Some(StreamProperties::Series(p)) = item.additional_properties.as_ref() { + props = Some(*p.clone()); + } + } + } else { + let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Series); + if xtream_path.exists() { + let _file_lock = app_config.file_locks.read_lock(&xtream_path).await; + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + if let Ok(Some(item)) = query.query_zero_copy(&series_id) { + existing_item = Some(item.clone()); + if let Some(StreamProperties::Series(p)) = item.additional_properties.as_ref() { + props = Some(*p.clone()); + } + } + } + } + } + } + + let mut fetched_new = false; + let mut properties_updated = false; + + let display_id = series_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string()); + + // Input DB is canonical, but if details are missing we still need one info fetch to complete the record. + let should_fetch_info = fetch_info && props.as_ref().is_none_or(|p| p.details.is_none()); + + // 1. Fetch Info from Provider (only when missing in input DB) + if should_fetch_info { + if let Some(series_id) = series_id_opt { + // Fetch Info from Provider + let info_url = xtream::get_xtream_player_api_info_url(input, XtreamCluster::Series, series_id) + .ok_or_else(|| shared::error::info_err!("Failed to build info URL"))?; + + let input_source = InputSource::from(input).with_url(info_url); + let content = xtream::get_xtream_stream_info_content(app_config, client, &input_source, true) + .await + .map_err(|e| shared::error::info_err!("{e}"))?; + + if !content.is_empty() { + if let Ok(mut json_value) = serde_json::from_str::(&content) { + if let Some(info) = json_value.get_mut("info").and_then(|v| v.as_object_mut()) { + crate::model::normalize_release_date(info); + } + + if let Ok(info) = serde_json::from_value::(json_value) { + if let Some(existing) = &existing_item { + props = Some(SeriesStreamProperties::from_info(&info, existing)); + fetched_new = true; + properties_updated = true; + } + } + } + } + } + } + + // If we don't have props, verify if we can proceed with minimal dummy props + if props.is_none() { + if let Some(title) = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())) { + let new_props = SeriesStreamProperties { + name: title.into(), + series_id: series_id_opt.unwrap_or(0), + ..Default::default() + }; + props = Some(new_props); + } else { + return Err(shared::error::info_err!("No Series properties available and no title found for {display_id}")); + } + } + + let mut properties = props.unwrap(); + + let resolve_tmdb_enabled = input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + + // 2. Resolve TMDB/Date if missing + if 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()); + + let mut meta = None; + let mut tried_title = false; + + // Try extracting year locally first + if properties.release_date.is_none() { + let meta_parse = ptt_parse_title(&properties.name); + if let Some(year) = meta_parse.year { + properties.release_date = Some(format!("{year}-01-01").into()); + properties_updated = true; + debug_if_enabled!("Parsed local year for Series '{}': {}", properties.name, year); + } + } + + // 1. & 2. Playlist Title + let title_candidate = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())); + if let Some(title) = title_candidate { + if !title.is_empty() { + trace!("Resolving TMDB for Series using Playlist Title '{title}' (ID: {display_id})..."); + meta = meta_resolver.resolve_from_title(title, properties.tmdb, false, resolve_tmdb_enabled).await; + tried_title = true; + } + } + + // 3. API Name (fallback) + if (meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none()))) + && !properties.name.is_empty() { + let title_already_tried = if let Some(t) = title_candidate { t == properties.name.as_ref() } else { false }; + if !tried_title || !title_already_tried { + debug!("Fallback to API Name '{}'...", properties.name); + meta = meta_resolver.resolve_from_title(&properties.name, properties.tmdb, false, resolve_tmdb_enabled).await; + } + } + + if let Some(m) = meta { + if properties.tmdb.is_none() { + properties.tmdb = m.tmdb_id(); + properties_updated = true; + } + if properties.release_date.is_none() { + properties.release_date = m.year().map(|y| format!("{y}-01-01").into()); + properties_updated = true; + } + if properties_updated { + let id_display = properties.tmdb.map_or("None".to_string(), |id| id.to_string()); + debug_if_enabled!("Resolved TMDB for Series ID {}: {}", display_id, id_display); + } + } + } + + // 3. Probe Episodes (if enabled) + if do_probe && app_config.is_ffprobe_enabled().await { + if let Some(details) = properties.details.as_mut() { + if let Some(episodes) = details.episodes.as_mut() { + let config = app_config.config.load(); + let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let user_agent = config.default_user_agent.clone(); + let analyze_duration = 10_000_000; + let probe_size = 10_000_000; + + let input_url = input.url.as_str(); + let input_username = input.username.as_deref().unwrap_or(""); + let input_password = input.password.as_deref().unwrap_or(""); + + let mut probed_count = 0; + + for ep in episodes { + 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 { + + // Acquire Connection logic + let temp_handle = if active_handle.is_some() { + None + } else { + active_provider.acquire_connection_for_probe(&input.name).await + }; + + if active_handle.is_some() || temp_handle.is_some() { + let episode_url = create_xtream_series_episode_url(input_url, input_username, input_password, ep); + + // Specific logging for the user to follow + let missing_reason = if missing_video && missing_audio { "video/audio" } else if missing_video { "video" } else { "audio" }; + debug!("Probing Series Episode '{}' (S{}E{}) - Missing {}", ep.title, ep.season, ep.episode_num, missing_reason); + + if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url( + &episode_url, + user_agent.as_deref(), + analyze_duration, + probe_size, + ffprobe_timeout, + ).await { + if let Some(v) = raw_video { + ep.video = Some(v.to_string().into()); + properties_updated = true; + } + if let Some(a) = raw_audio { + ep.audio = Some(a.to_string().into()); + properties_updated = true; + } + probed_count += 1; + } + + if let Some(h) = temp_handle { + active_provider.release_handle(&h).await; + } + } else { + warn!("Skipping probe for series episode {} due to connection limits", ep.title); + } + } + } + if probed_count > 0 { + info!("Probed {probed_count} episodes for Series ID {display_id}"); + } + } + } + } + + // 4. Persist + if properties_updated || fetched_new { + if save { + if let Some(series_id) = series_id_opt { + persists_input_series_info(app_config, &storage_path, XtreamCluster::Series, &input.name, series_id, &properties) + .await + .map_err(|e| shared::error::info_err!("Persist error: {e}"))?; + + debug_if_enabled!("Successfully updated Series metadata for ID {}", series_id); + } + } + return Ok(Some(properties)); + } + + Ok(None) +} diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index 20e6d30f4..08ccbcc6a 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -1,129 +1,713 @@ +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::FetchedPlaylist; +use crate::model::InputSource; use crate::model::{AppConfig, ConfigTarget}; -use crate::processing::processor::create_resolve_options_function_for_xtream_target; -use crate::processing::processor::xtream::playlist_resolve_download_playlist_item; +use crate::processing::processor::playlist::PlaylistProcessingContext; +use crate::processing::processor::{create_resolve_options_function_for_xtream_target, ResolveOptions, ResolveOptionsFlags}; +use crate::ptt::ptt_parse_title; use crate::repository::get_input_storage_path; +use crate::repository::persist_input_vod_info; use crate::repository::persist_input_vod_info_batch; -use log::{error, info, log_enabled, Level}; +use crate::repository::{xtream_get_file_path, BPlusTreeQuery}; +use crate::utils::{trace_if_enabled, debug_if_enabled, xtream}; +use log::{debug, error, info, log_enabled, trace, warn, Level}; +use serde_json::Value; use shared::error::TuliproxError; -use shared::model::{InputType, PlaylistEntry, StreamProperties, VideoStreamProperties, XtreamVideoInfo}; -use shared::model::{PlaylistItemType, XtreamCluster}; +use shared::model::{MediaQuality, PlaylistEntry, PlaylistItem, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamVideoInfo}; use std::collections::HashMap; use std::sync::Arc; -use std::time::Instant; - +use std::time::{Duration, Instant}; create_resolve_options_function_for_xtream_target!(vod); -const BATCH_SIZE: usize = 100; +const BATCH_SIZE: usize = 200; -pub async fn playlist_resolve_vod(app_config: &Arc, - client: &reqwest::Client, - target: &ConfigTarget, - errors: &mut Vec, - provider_fpl: &mut FetchedPlaylist<'_>, - fpl: &mut FetchedPlaylist<'_>) { - let (resolve_movies, resolve_delay) = get_resolve_vod_options(target, fpl); - if !resolve_movies { return; } +#[allow(clippy::too_many_lines)] +pub async fn playlist_resolve_vod( + ctx: &PlaylistProcessingContext, + target: &ConfigTarget, + errors: &mut Vec, + provider_fpl: &mut FetchedPlaylist<'_>, + fpl: &mut FetchedPlaylist<'_>, +) { + 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; + + // Determine if we need to do anything + if !resolve_options.flags.contains(ResolveOptionsFlags::Resolve) && !do_probe && !resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing) { + return; + } + + provider_fpl.source.release_resources(XtreamCluster::Video); + + playlist_resolve_vod_info( + ctx, + errors, + fpl, + resolve_options, + do_probe, + ).await; + + if provider_fpl.is_memory() { + sync_resolved_vod_properties(provider_fpl, fpl); + } + + provider_fpl.source.obtain_resources().await; +} + +fn sync_resolved_vod_properties( + provider_fpl: &mut FetchedPlaylist<'_>, + processed_fpl: &mut FetchedPlaylist<'_>, +) { + let mut resolved_vod_by_provider_id: HashMap = HashMap::new(); + + for pli in processed_fpl.items() { + if pli.header.xtream_cluster != XtreamCluster::Video + || pli.header.item_type != PlaylistItemType::Video + { + continue; + } + + let Some(provider_id) = pli.get_provider_id() else { + continue; + }; + if provider_id == 0 { + continue; + } + + if let Some(StreamProperties::Video(properties)) = pli.header.additional_properties.as_ref() { + resolved_vod_by_provider_id + .entry(provider_id) + .or_insert_with(|| properties.as_ref().clone()); + } + } + + if resolved_vod_by_provider_id.is_empty() { + return; + } + + for source_pli in provider_fpl.items_mut() { + if source_pli.header.xtream_cluster != XtreamCluster::Video + || source_pli.header.item_type != PlaylistItemType::Video + { + continue; + } + + let Some(provider_id) = source_pli.get_provider_id() else { + continue; + }; + if provider_id == 0 { + continue; + } + + if let Some(resolved) = resolved_vod_by_provider_id.get(&provider_id) { + source_pli.header.additional_properties = + Some(StreamProperties::Video(Box::new(resolved.clone()))); + } + } +} + +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +async fn playlist_resolve_vod_info( + ctx: &PlaylistProcessingContext, + _errors: &mut Vec, + fpl: &mut FetchedPlaylist<'_>, + resolve_options: ResolveOptions, + do_probe: bool, +) { + + let filter = |pli: &PlaylistItem| { + if pli.header.xtream_cluster != XtreamCluster::Video + || pli.header.item_type != PlaylistItemType::Video + { + return false; + } + true + }; + + let resolve_tmdb_enabled = fpl.input.options.as_ref().is_some_and(|o| o.resolve_tmdb); + + if resolve_options.flags.contains(ResolveOptionsFlags::Background) && ctx.metadata_manager.is_some() { + queue_background_vod_info( + ctx, + fpl, + filter, + &resolve_options, + do_probe, + resolve_tmdb_enabled, + ); + } else { + process_immediate_vod_info( + ctx, + fpl, + filter, + resolve_options, + do_probe, + resolve_tmdb_enabled, + ) + .await; + } +} + +#[allow(clippy::too_many_lines)] +async fn process_immediate_vod_info(ctx: &PlaylistProcessingContext, fpl: &mut FetchedPlaylist<'_>, + filter: impl Fn(&PlaylistItem) -> bool, + resolve_options: ResolveOptions, + do_probe: bool, + resolve_tmdb_enabled: bool, ) { let input = fpl.input; - let working_dir = &app_config.config.load().working_dir; + let working_dir = &ctx.config.config.load().working_dir; + let storage_path = match get_input_storage_path(&input.name, working_dir).await { - Ok(storage_path) => storage_path, + Ok(path) => path, Err(err) => { error!("Can't resolve vod, input storage directory for input '{}' failed: {err}", input.name); return; } }; - let vod_info_count = fpl.get_missing_vod_info_count(); - info!("Found missing {vod_info_count} vod info to resolve"); + // Keep an optional read query open and reopen lazily only when needed. + let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Video); + let mut db_query_holder = None; + let mut _db_lock_holder = None; + let mut batch: Vec<(ProviderIdType, VideoStreamProperties)> = Vec::with_capacity(BATCH_SIZE); + let mut processed_count = 0; let mut last_log_time = Instant::now(); - let mut processed_vod_info_count = 0; - let mut batch = Vec::with_capacity(BATCH_SIZE); - let sync_canonical_in_memory = provider_fpl.is_memory(); - let mut resolved_vod_by_provider_id: Option> = - sync_canonical_in_memory.then(HashMap::new); - provider_fpl.source.release_resources(XtreamCluster::Video); - - let input = fpl.input; for pli in fpl.items_mut() { - if pli.header.xtream_cluster != XtreamCluster::Video - || pli.header.item_type != PlaylistItemType::Video - || pli.has_details() { + if !filter(pli) { continue; } - let Some(provider_id) = pli.get_provider_id() else { continue; }; - processed_vod_info_count += 1; - if provider_id != 0 { - if let Some(content) = playlist_resolve_download_playlist_item( - app_config, - client, - pli, - input, - errors, - resolve_delay, - XtreamCluster::Video, - ) - .await - { - if content.is_empty() { continue; } - //tokio::fs::write(&storage_path.join(format!("{provider_id}_vod_info.json")), &content).await.ok(); - match serde_json::from_str::(&content) { - Ok(info) => { - let video_stream_props = VideoStreamProperties::from_info(&info, pli); - batch.push((provider_id, video_stream_props.clone())); - if let Some(resolved) = resolved_vod_by_provider_id.as_mut() { - resolved.insert(provider_id, video_stream_props.clone()); - } - if batch.len() >= BATCH_SIZE { - if let Err(err) = persist_input_vod_info_batch(app_config, &storage_path, XtreamCluster::Video, &input.name, std::mem::take(&mut batch)).await { - error!("Failed to persist batch VOD info: {err}"); - } - } + let provider_id = if let Ok(uid) = pli.header.id.parse::() { + ProviderIdType::Id(uid) + } else { + ProviderIdType::from(&*pli.header.id) + }; - // This makes the data available for subsequent processing steps like STRM export. - pli.header.additional_properties = Some(StreamProperties::Video(Box::new(video_stream_props))); + let reasons = check_resolve_reasons(&resolve_options, do_probe, resolve_tmdb_enabled, pli); + + if !reasons.is_empty() { + // Path 2: Internal Update (Inline) + if let Some(prov_mgr) = ctx.provider_manager.as_ref() { + if db_query_holder.is_none() && xtream_path.exists() { + let lock = ctx.config.file_locks.read_lock(&xtream_path).await; + if let Ok(query) = BPlusTreeQuery::::try_new(&xtream_path) { + db_query_holder = Some(query); + _db_lock_holder = Some(lock); } - Err(err) => { - error!("Failed to parse video info for provider {} stream_id {provider_id}: {err} {content}", input.name); + } + // Pass the optional reference to the query + let db_query_ref = db_query_holder.as_mut(); + + match update_vod_info_immediate(ctx, prov_mgr, input, pli, provider_id.clone(), &reasons, db_query_ref).await { + Ok(Some(updated_props)) => { + // Update the current item in fpl + pli.header.additional_properties = Some(StreamProperties::Video(Box::new(updated_props.clone()))); + batch.push((provider_id.clone(), updated_props)); + if batch.len() >= BATCH_SIZE { + // Release lock before persisting to avoid deadlock (persist needs write lock) + db_query_holder = None; + _db_lock_holder = None; + + // Filter for u32 IDs for persistence + let updates: Vec<(u32, VideoStreamProperties)> = batch.iter() + .filter_map(|(id, props)| { + if let ProviderIdType::Id(vid) = id { + Some((*vid, props.clone())) + } else { + None + } + }) + .collect(); + + if !updates.is_empty() { + if let Err(err) = persist_input_vod_info_batch( + &ctx.config, + &storage_path, + XtreamCluster::Video, + &input.name, + updates, + ).await { + error!("Failed to persist batch VOD info: {err}"); + } + } + + // Clear batch + batch.clear(); + } + + processed_count += 1; + + // Resolve delay for inline execution to prevent flooding provider + // Only delay if we actually did something (reason is not empty) - logic implies we did + if resolve_options.resolve_delay > 0 { + tokio::time::sleep(Duration::from_secs(u64::from(resolve_options.resolve_delay))).await; + } + } + Ok(None) => {} + Err(e) => { + error!("Failed to update VOD metadata for {}: {e}", pli.header.title); } } } } + if log_enabled!(Level::Info) && last_log_time.elapsed().as_secs() >= 30 { - info!("resolved {processed_vod_info_count}/{vod_info_count} vod info"); + info!("resolved {processed_count} vod info"); last_log_time = Instant::now(); } } + // Flush the remaining batch if bundled strategy if !batch.is_empty() { - if let Err(err) = persist_input_vod_info_batch(app_config, &storage_path, XtreamCluster::Video, &input.name, batch).await { - error!("Failed to persist final batch VOD info: {err}"); - } - } + // Release lock before final persist + _db_lock_holder = None; - // Keep canonical input playlist updated for subsequent target processing in memory mode. - if let Some(resolved_vod_by_provider_id) = resolved_vod_by_provider_id { - for source_pli in provider_fpl.items_mut() { - if source_pli.header.xtream_cluster != XtreamCluster::Video - || source_pli.header.item_type != PlaylistItemType::Video - { - continue; - } - let Some(provider_id) = source_pli.get_provider_id() else { continue; }; - if provider_id == 0 { - continue; - } - if let Some(resolved) = resolved_vod_by_provider_id.get(&provider_id) { - source_pli.header.additional_properties = Some(StreamProperties::Video(Box::new(resolved.clone()))); + let updates: Vec<(u32, VideoStreamProperties)> = batch.into_iter() + .filter_map(|(id, props)| if let ProviderIdType::Id(vid) = id { Some((vid, props)) } else { None }) + .collect(); + + if !updates.is_empty() { + if let Err(err) = persist_input_vod_info_batch(&ctx.config, &storage_path, XtreamCluster::Video, &input.name, updates).await { + error!("Failed to persist final batch VOD info: {err}"); } } } - provider_fpl.source.obtain_resources().await; - info!("resolved {processed_vod_info_count}/{vod_info_count} vod info"); + if processed_count > 0 { + info!("Processed {processed_count} vod info"); + } +} + +fn check_resolve_reasons( + resolve_options: &ResolveOptions, + do_probe: bool, + resolve_tmdb_enabled: bool, + pli: &PlaylistItem, +) -> ResolveReasonSet { + // Check if we need to do anything for this item + let mut reasons = ResolveReasonSet::default(); + + let needs_info = check_needs_info(resolve_options, pli, &mut reasons); + // TMDB check + check_resolve_tmdb(resolve_options, resolve_tmdb_enabled, pli, needs_info, &mut reasons); + + // Probe check + if do_probe { + check_needs_probe(pli, &mut reasons); + } + reasons +} + +fn check_needs_probe(pli: &PlaylistItem, reasons: &mut ResolveReasonSet) { + let needs_probe = match pli.header.additional_properties.as_ref() { + Some(StreamProperties::Video(props)) => { + let details = props.details.as_ref(); + let missing_video = !MediaQuality::is_valid_media_info(details.and_then(|d| d.video.as_deref())); + let missing_audio = !MediaQuality::is_valid_media_info(details.and_then(|d| d.audio.as_deref())); + missing_video || missing_audio + } + None => { + true + } + _ => false, + }; + if needs_probe { + reasons.add(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); + + if needs_info { + reasons.add(ResolveReason::Info); + } + needs_info +} + +fn check_resolve_tmdb( + resolve_options: &ResolveOptions, + resolve_tmdb_enabled: bool, + pli: &PlaylistItem, + needs_info: bool, + reasons: &mut ResolveReasonSet, +) { + if resolve_tmdb_enabled && resolve_options.flags.contains(ResolveOptionsFlags::TmdbMissing) { + 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); } + } + } else if !needs_info { + // No properties but no download requested? Should be rare + reasons.add(ResolveReason::MissingDetails); + } + } +} + +fn queue_background_vod_info(ctx: &PlaylistProcessingContext, fpl: &mut FetchedPlaylist<'_>, + filter: impl Fn(&PlaylistItem) -> bool, + resolve_options: &ResolveOptions, + do_probe: bool, + resolve_tmdb_enabled: bool, ) { + let Some(mgr) = ctx.metadata_manager.as_ref() else { + return; + }; + + let input = fpl.input; + for pli in fpl.items_mut() { + if !filter(pli) { + continue; + } + + let provider_id = if let Ok(uid) = pli.header.id.parse::() { + ProviderIdType::Id(uid) + } else { + ProviderIdType::from(&*pli.header.id) + }; + + let reasons = check_resolve_reasons(resolve_options, do_probe, resolve_tmdb_enabled, pli); + + if !reasons.is_empty() { + let task = UpdateTask::ResolveVod { + id: provider_id, + reason: reasons, + delay: resolve_options.resolve_delay, + }; + mgr.queue_task_background(input.name.clone(), task); + } + } +} + +async fn update_vod_info_immediate( + ctx: &PlaylistProcessingContext, + active_provider: &Arc, + input: &ConfigInput, + pli: &PlaylistItem, + id: ProviderIdType, + reasons: &ResolveReasonSet, + db_query: Option<&mut BPlusTreeQuery>, +) -> Result, TuliproxError> { + let fetch_info = reasons.contains(ResolveReason::Info); + + update_vod_metadata( + &ctx.config, + &ctx.client, + input, + id, + None, // No active handle, function will acquire one if needed + active_provider, + Some(&pli.header.title), + false, + fetch_info, + db_query, + ).await +} + + +/// Updates metadata for a single VOD item (Info + Probe). +/// +/// # Arguments +/// * `save` - If true, persists changes to the input database immediately (Instant strategy). +/// If false, returns the properties so the caller can batch persist them (Bundled strategy). +/// * `fetch_info` - If true, fetches details from Provider API. If false, uses existing/dummy data. +/// * `db_query` - Optional pre-opened DB handle to avoid re-opening file. +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub async fn update_vod_metadata( + app_config: &Arc, + client: &reqwest::Client, + input: &ConfigInput, + id: ProviderIdType, + active_handle: Option<&ProviderHandle>, + active_provider: &Arc, + playlist_title: Option<&str>, + save: bool, + fetch_info: bool, + db_query: Option<&mut BPlusTreeQuery>, +) -> Result, TuliproxError> { + let working_dir = &app_config.config.load().working_dir; + let storage_path = get_input_storage_path(&input.name, working_dir).await + .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) { + return Ok(None); + } + + // Try to load existing info first to check if we have title/o_name + let mut props: Option = None; + let mut existing_item: Option = None; + + let stream_id_opt = if let ProviderIdType::Id(vid) = id { Some(vid) } else { None }; + + if let Some(stream_id) = stream_id_opt { + // Use provided query or open new one + if let Some(query) = db_query { + if let Ok(Some(item)) = query.query_zero_copy(&stream_id) { + existing_item = Some(item.clone()); + if let Some(StreamProperties::Video(p)) = item.additional_properties.as_ref() { + props = Some(*p.clone()); + } + } + } else { + let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Video); + if xtream_path.exists() { + let _file_lock = app_config.file_locks.read_lock(&xtream_path).await; + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + if let Ok(Some(item)) = query.query_zero_copy(&stream_id) { + existing_item = Some(item.clone()); + if let Some(StreamProperties::Video(p)) = item.additional_properties.as_ref() { + props = Some(*p.clone()); + } + } + } + } + } + } + + let mut fetched_new = false; + let mut properties_updated = false; + + // Determine the title to use for logging and fallback. + // Cloning here to satisfy borrow checker when 'props' is mutated later. + let display_title = playlist_title + .or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())) + .or_else(|| props.as_ref().map(|p| p.name.as_ref())) + .unwrap_or("Unknown") + .to_string(); + + let display_id = stream_id_opt.map_or_else(|| "StringID".to_string(), |v: u32| v.to_string()); + + // Input DB is canonical, but if details are missing we still need one info fetch to complete the record. + let should_fetch_info = fetch_info && props.as_ref().is_none_or(|p| p.details.is_none()); + + // 1. Fetch Info from Provider (only when missing in input DB) + if should_fetch_info { + if let Some(stream_id) = stream_id_opt { + let info_url = xtream::get_xtream_player_api_info_url(input, XtreamCluster::Video, stream_id) + .ok_or_else(|| shared::error::info_err!("Failed to build info URL"))?; + + let input_source = InputSource::from(input).with_url(info_url); + + match xtream::get_xtream_stream_info_content(app_config, client, &input_source, true).await { + Ok(content) => { + if !content.is_empty() { + if let Ok(mut json_value) = serde_json::from_str::(&content) { + if let Some(info) = json_value.get_mut("info").and_then(|v| v.as_object_mut()) { + crate::model::normalize_release_date(info); + } + + if let Ok(info) = serde_json::from_value::(json_value) { + props = Some(if let Some(existing) = &existing_item { + VideoStreamProperties::from_info(&info, existing) + } else { + VideoStreamProperties::from_info_without_existing(&info) + }); + fetched_new = true; + properties_updated = true; + } + } + } + } + Err(e) => { + debug!("Failed to fetch VOD info for {display_title} ({display_id}): {e}"); + } + } + } + } + + // If no props yet, create dummy ones if we have enough info (at least a name/title) + if props.is_none() { + if let Some(title) = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())) { + let mut new_props = VideoStreamProperties { + name: title.into(), + stream_id: stream_id_opt.unwrap_or(0), + container_extension: "".into(), // Will be filled later or by probe + ..Default::default() + }; + // Ensure details struct exists + new_props.details = Some(VideoStreamDetailProperties::default()); + props = Some(new_props); + } else { + // We can't proceed without at least a name + return Err(shared::error::info_err!("No VOD properties available and no title found for {display_id}")); + } + } + + // 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); + + // 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) { + // Try local parsing first + if missing_date && !properties.name.is_empty() { + let meta_parse = ptt_parse_title(&properties.name); + if let Some(year) = meta_parse.year { + if properties.details.is_none() { + properties.details = Some(VideoStreamDetailProperties::default()); + } + if let Some(details) = properties.details.as_mut() { + details.release_date = Some(format!("{year}-01-01").into()); + properties_updated = true; + debug_if_enabled!("Parsed local year for '{}': {}", properties.name, year); + } + } + } + + // Re-check missing date after local parse + let still_missing_date = properties.details.as_ref().and_then(|d| d.release_date.as_ref()).is_none(); + + if missing_tmdb || still_missing_date { + let config = app_config.config.load(); + let library_config = config.library.as_ref(); + let meta_resolver = MetadataResolver::new(library_config, client.clone()); + + let mut meta = None; + let mut tried_title = false; + + // 1. & 2. Playlist Title + let title_candidate = playlist_title.or_else(|| existing_item.as_ref().map(|i| i.title.as_ref())); + if let Some(title) = title_candidate { + if !title.is_empty() { + debug!("Resolving TMDB for VOD using Playlist Title '{title}' (ID: {display_id})..."); + meta = meta_resolver.resolve_from_title(title, properties.tmdb, true, resolve_tmdb_enabled).await; + tried_title = true; + } + } + + // 3. API Name (fallback) + if (meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none()))) + && !properties.name.is_empty() { + let title_already_tried = if let Some(t) = title_candidate { t == properties.name.as_ref() } else { false }; + + if !tried_title || !title_already_tried { + trace!("Fallback to API Name '{}'...", properties.name); + meta = meta_resolver.resolve_from_title(&properties.name, properties.tmdb, true, resolve_tmdb_enabled).await; + } + } + + // 4. API Original Name (fallback) + if meta.is_none() || (meta.as_ref().is_some_and(|m| m.tmdb_id().is_none())) { + if let Some(o_name) = properties.details.as_ref().and_then(|d| d.o_name.as_deref()) { + if !o_name.is_empty() && o_name != properties.name.as_ref() { + trace!("Fallback to API Original Name '{o_name}'..."); + meta = meta_resolver.resolve_from_title(o_name, properties.tmdb, true, resolve_tmdb_enabled).await; + } + } + } + + if let Some(m) = meta { + if properties.tmdb.is_none() { + properties.tmdb = m.tmdb_id(); + properties_updated = true; + } + if let Some(details) = properties.details.as_mut() { + if details.release_date.is_none() { + details.release_date = m.year().map(|y| format!("{y}-01-01").into()); + properties_updated = true; + } + } + if properties_updated { + let id_display = properties.tmdb.map_or("None".to_string(), |id| id.to_string()); + trace_if_enabled!("Resolved TMDB for '{}' (ID: {}): {}", display_title, display_id, id_display); + } + } + } + } + + // 3. Probe (if enabled globally in config) + let ffprobe_enabled = app_config.is_ffprobe_enabled().await; + if ffprobe_enabled { + // Ensure details struct exists before probing + if properties.details.is_none() { + properties.details = Some(VideoStreamDetailProperties::default()); + } + + let details = properties.details.as_ref().unwrap(); + let missing_video = !MediaQuality::is_valid_media_info(details.video.as_deref()); + let missing_audio = !MediaQuality::is_valid_media_info(details.audio.as_deref()); + + if missing_video || missing_audio { + let input_url = input.url.as_str(); + let username = input.username.as_deref().unwrap_or(""); + let password = input.password.as_deref().unwrap_or(""); + let stream_url = crate::processing::parser::xtream::create_xtream_url( + XtreamCluster::Video, input_url, username, password, + &StreamProperties::Video(Box::new(properties.clone())), + true, true, + ); + + let config = app_config.config.load(); + let ffprobe_timeout = config.video.as_ref().and_then(|v| v.ffprobe_timeout).unwrap_or(60); + let user_agent = config.default_user_agent.clone(); + let analyze_duration = 10_000_000; + let probe_size = 10_000_000; + + // Acquire Connection logic + let temp_handle = if active_handle.is_some() { + None // No new handle needed + } else { + active_provider.acquire_connection_for_probe(&input.name).await + }; + + if active_handle.is_some() || temp_handle.is_some() { + debug_if_enabled!("Probing VOD '{}' (ID: {})", display_title, display_id); + if let Some((_quality, raw_video, raw_audio)) = crate::utils::ffmpeg::probe_url( + &stream_url, + user_agent.as_deref(), + analyze_duration, + probe_size, + ffprobe_timeout, + ).await { + if let Some(details) = properties.details.as_mut() { + if let Some(v) = raw_video { + details.video = Some(v.to_string().into()); + properties_updated = true; + } + if let Some(a) = raw_audio { + details.audio = Some(a.to_string().into()); + properties_updated = true; + } + } + } + if let Some(h) = temp_handle { + active_provider.release_handle(&h).await; + } + } else { + warn!("Skipping probe for VOD {display_id} due to connection limits"); + } + } + } + + // 4. Persist if updated + if properties_updated || fetched_new { + if save { + if let Some(stream_id) = stream_id_opt { + persist_input_vod_info(app_config, &storage_path, XtreamCluster::Video, &input.name, stream_id, &properties) + .await + .map_err(|e| shared::error::info_err!("Persist error: {e}"))?; + } + + debug_if_enabled!("Successfully updated VOD metadata for '{}' (ID: {})", display_title, display_id); + } + return Ok(Some(properties)); + } + + Ok(None) } diff --git a/backend/src/repository/bplustree.rs b/backend/src/repository/bplustree.rs index f396f095c..c32e8d653 100644 --- a/backend/src/repository/bplustree.rs +++ b/backend/src/repository/bplustree.rs @@ -5218,11 +5218,11 @@ mod tests { let size_before = std::fs::metadata(&filepath)?.len(); let mut updater = BPlusTreeUpdate::::try_new(&filepath)?; - let updates = [ + let batch_updates = [ (0u32, "X".repeat(6_000)), (u32::MAX, "missing".to_string()), ]; - let refs: Vec<(&u32, &String)> = updates.iter().map(|(k, v)| (k, v)).collect(); + let refs: Vec<(&u32, &String)> = batch_updates.iter().map(|(k, v)| (k, v)).collect(); let result = updater.update_batch(&refs); assert!(matches!(result, Err(BPlusTreeError::KeyNotFound))); drop(updater); @@ -5256,16 +5256,16 @@ mod tests { tree.store(&filepath)?; drop(tree); - let mut updater = BPlusTreeUpdate::::try_new(&filepath)?; - let updates = [ + let mut batch_updater = BPlusTreeUpdate::::try_new(&filepath)?; + let update_items = [ (1u32, updated.clone()), (u32::MAX, "missing".to_string()), ]; - let refs: Vec<(&u32, &String)> = updates.iter().map(|(k, v)| (k, v)).collect(); + let refs: Vec<(&u32, &String)> = update_items.iter().map(|(k, v)| (k, v)).collect(); - let result = updater.update_batch(&refs); + let result = batch_updater.update_batch(&refs); assert!(matches!(result, Err(BPlusTreeError::KeyNotFound))); - drop(updater); + drop(batch_updater); // On failed batch, key 1 must still have original value. let mut query = BPlusTreeQuery::::try_new(&filepath)?; diff --git a/backend/src/repository/bplustree_stress.rs b/backend/src/repository/bplustree_stress.rs index ad8d540f8..74194dad4 100644 --- a/backend/src/repository/bplustree_stress.rs +++ b/backend/src/repository/bplustree_stress.rs @@ -25,6 +25,11 @@ fn lcg_next(state: &mut u64) -> u64 { *state } +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] fn percentile_nearest_rank(sorted_values: &[f64], percentile: f64) -> f64 { if sorted_values.is_empty() { return 0.0; @@ -109,12 +114,7 @@ fn stress_test_bplustree() { let insert_max = *sorted_insert_throughputs.last().unwrap_or(&0.0); writeln!( log_file, - "Insert Throughput Summary (ops/sec): mean={:.0}, median={:.0}, p95={:.0}, min={:.0}, max={:.0}", - insert_mean, - insert_median, - insert_p95, - insert_min, - insert_max + "Insert Throughput Summary (ops/sec): mean={insert_mean:.0}, median={insert_median:.0}, p95={insert_p95:.0}, min={insert_min:.0}, max={insert_max:.0}" ) .unwrap(); let mut tree = final_tree.expect("final insert run should produce a tree"); @@ -305,7 +305,7 @@ fn stress_test_bplustree() { for _ in 0..writer_batch_size { let key = (lcg_next(&mut prng_state) as u32) % num_items_u32; let variant = lcg_next(&mut prng_state); - let payload = if (variant & 0b1111) == 0 { + let payload = if variant.trailing_zeros() >= 4 { &large_payload_local } else { &small_payload_local diff --git a/backend/src/repository/library_repository.rs b/backend/src/repository/library_repository.rs index c029fb37f..3219d71f6 100644 --- a/backend/src/repository/library_repository.rs +++ b/backend/src/repository/library_repository.rs @@ -1,23 +1,38 @@ use crate::model::AppConfig; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery}; use shared::error::{notify_err_res, TuliproxError}; -use shared::model::{PlaylistGroup, PlaylistItem, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{PlaylistGroup, PlaylistItem, StreamProperties, XtreamCluster, XtreamPlaylistItem}; use std::path::Path; use std::sync::Arc; use indexmap::IndexMap; use shared::model::UUIDType; use crate::repository::xtream_repository::CategoryKey; use crate::utils::file_exists_async; +use std::collections::HashMap; pub async fn persist_input_library_playlist(app_config: &Arc, library_path: &Path, playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> { if playlist.is_empty() { return Ok(()); } let _file_lock = app_config.file_locks.write_lock(library_path).await; + + // Keep previously probed technical metadata for unchanged local files. + let mut existing_by_uuid: HashMap = HashMap::new(); + if library_path.exists() { + if let Ok(mut query) = BPlusTreeQuery::::try_new(library_path) { + for (uuid, item) in query.iter() { + existing_by_uuid.insert(uuid, item); + } + } + } + let mut tree = BPlusTree::new(); for pg in playlist { for item in &pg.channels { - let xtream = XtreamPlaylistItem::from(item); + let mut xtream = XtreamPlaylistItem::from(item); + if let Some(existing) = existing_by_uuid.get(&item.header.uuid) { + preserve_local_probe_state_if_unchanged(&mut xtream, existing); + } tree.insert(item.header.uuid, xtream); } } @@ -27,6 +42,42 @@ pub async fn persist_input_library_playlist(app_config: &Arc, library } } +fn preserve_local_probe_state_if_unchanged(new_item: &mut XtreamPlaylistItem, old_item: &XtreamPlaylistItem) { + if new_item.url != old_item.url { + return; + } + + match (new_item.additional_properties.as_mut(), old_item.additional_properties.as_ref()) { + (Some(StreamProperties::Video(new_props)), Some(StreamProperties::Video(old_props))) => { + // For local movies we store file mtime in `added`; changed mtime means file changed. + if new_props.added != old_props.added { + return; + } + if let (Some(new_details), Some(old_details)) = (new_props.details.as_mut(), old_props.details.as_ref()) { + if new_details.video.is_none() { + new_details.video.clone_from(&old_details.video); + } + if new_details.audio.is_none() { + new_details.audio.clone_from(&old_details.audio); + } + } + } + (Some(StreamProperties::Episode(new_props)), Some(StreamProperties::Episode(old_props))) => { + // For local series episodes we store file mtime in `added`. + if new_props.added != old_props.added { + return; + } + if new_props.video.is_none() { + new_props.video.clone_from(&old_props.video); + } + if new_props.audio.is_none() { + new_props.audio.clone_from(&old_props.audio); + } + } + _ => {} + } +} + pub async fn load_input_local_library_playlist(app_config: &Arc, lib_path: &Path) -> Result, TuliproxError> { let mut groups: IndexMap = IndexMap::new(); @@ -56,3 +107,162 @@ pub async fn load_input_local_library_playlist(app_config: &Arc, lib_ Ok(groups.into_values().collect()) } + +#[cfg(test)] +mod tests { + use super::*; + use shared::utils::Internable; + use shared::model::{EpisodeStreamProperties, VideoStreamDetailProperties, VideoStreamProperties}; + + fn video_item(url: &str, added: &str, video: Option<&str>, audio: Option<&str>) -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: 1, + provider_id: 1, + name: "movie".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "Movies".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: url.intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + added: added.intern(), + details: Some(VideoStreamDetailProperties { + video: video.map(Internable::intern), + audio: audio.map(Internable::intern), + ..Default::default() + }), + ..Default::default() + }))), + item_type: shared::model::PlaylistItemType::LocalVideo, + category_id: 0, + input_name: "lib".intern(), + channel_no: 0, + source_ordinal: 0, + } + } + + fn episode_item(url: &str, added: Option<&str>, video: Option<&str>, audio: Option<&str>) -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: 2, + provider_id: 2, + name: "ep".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "Series".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: url.intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Series, + additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { + episode_id: 0, + episode: 0, + season: 0, + added: added.map(Internable::intern), + release_date: None, + series_release_date: None, + tmdb: None, + movie_image: "".intern(), + container_extension: "".intern(), + video: video.map(Internable::intern), + audio: audio.map(Internable::intern), + }))), + item_type: shared::model::PlaylistItemType::LocalSeries, + category_id: 0, + input_name: "lib".intern(), + channel_no: 0, + source_ordinal: 0, + } + } + + #[test] + fn keeps_movie_probe_when_file_unchanged() { + let mut new_item = video_item("file:///media/a.mkv", "100", None, None); + let old_item = video_item( + "file:///media/a.mkv", + "100", + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + ); + + preserve_local_probe_state_if_unchanged(&mut new_item, &old_item); + + match new_item.additional_properties { + Some(StreamProperties::Video(v)) => { + let details = v.details.expect("details expected"); + assert_eq!(details.video, Some("{\"codec_name\":\"h264\"}".intern())); + assert_eq!(details.audio, Some("{\"codec_name\":\"aac\"}".intern())); + } + _ => panic!("expected video properties"), + } + } + + #[test] + fn does_not_keep_movie_probe_when_mtime_changed() { + let mut new_item = video_item("file:///media/a.mkv", "200", None, None); + let old_item = video_item( + "file:///media/a.mkv", + "100", + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + ); + + preserve_local_probe_state_if_unchanged(&mut new_item, &old_item); + + match new_item.additional_properties { + Some(StreamProperties::Video(v)) => { + let details = v.details.expect("details expected"); + assert!(details.video.is_none()); + assert!(details.audio.is_none()); + } + _ => panic!("expected video properties"), + } + } + + #[test] + fn keeps_episode_probe_when_file_unchanged() { + let mut new_item = episode_item("file:///media/s01e01.mkv", Some("100"), None, None); + let old_item = episode_item( + "file:///media/s01e01.mkv", + Some("100"), + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + ); + + preserve_local_probe_state_if_unchanged(&mut new_item, &old_item); + + match new_item.additional_properties { + Some(StreamProperties::Episode(e)) => { + assert_eq!(e.video, Some("{\"codec_name\":\"h264\"}".intern())); + assert_eq!(e.audio, Some("{\"codec_name\":\"aac\"}".intern())); + } + _ => panic!("expected episode properties"), + } + } + + #[test] + fn does_not_keep_episode_probe_when_mtime_changed() { + let mut new_item = episode_item("file:///media/s01e01.mkv", Some("200"), None, None); + let old_item = episode_item( + "file:///media/s01e01.mkv", + Some("100"), + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + ); + + preserve_local_probe_state_if_unchanged(&mut new_item, &old_item); + + match new_item.additional_properties { + Some(StreamProperties::Episode(e)) => { + assert!(e.video.is_none()); + assert!(e.audio.is_none()); + } + _ => panic!("expected episode properties"), + } + } +} diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index ae5445cd5..ec122b7c4 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -1,7 +1,7 @@ use crate::api::model::{AppState, PlaylistM3uStorage, PlaylistStorage, PlaylistStorageState, PlaylistXtreamStorage}; use crate::model::Epg; use crate::model::{AppConfig, ConfigInput, ConfigTarget, TargetOutput}; -use crate::processing::processor::playlist::{apply_filter_to_playlist, PlaylistProcessingContext}; +use crate::processing::processor::{apply_filter_to_playlist, PlaylistProcessingContext}; use crate::repository::epg_write_for_target; use crate::repository::write_strm_playlist; use crate::repository::FILE_SUFFIX_DB; @@ -468,7 +468,7 @@ pub fn get_input_m3u_playlist_file_path(storage_path: &Path, input_name: &Arc) -> PathBuf { +pub fn get_input_local_library_playlist_file_path(storage_path: &Path, input_name: &Arc) -> PathBuf { let sanitized_input_name: String = input_name.chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); diff --git a/backend/src/repository/storage.rs b/backend/src/repository/storage.rs index 79cd67768..9cda3ac57 100644 --- a/backend/src/repository/storage.rs +++ b/backend/src/repository/storage.rs @@ -6,7 +6,7 @@ use shared::error::TuliproxError; use std::path::{Path, PathBuf}; use shared::{concat_string, notify_err_res}; -pub(in crate::repository) fn get_target_id_mapping_file(target_path: &Path) -> PathBuf { +pub fn get_target_id_mapping_file(target_path: &Path) -> PathBuf { // Join directly with &str to avoid an intermediate PathBuf allocation target_path.join(storage_const::FILE_ID_MAPPING) } diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 034878633..eb91091c4 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -1,5 +1,3 @@ -// Import the new MediaQuality struct -use crate::model::MediaQuality; use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, StrmTargetOutput}; use crate::repository::storage::ensure_target_storage_path; @@ -9,11 +7,10 @@ use crate::utils::{async_file_reader, async_file_writer, normalize_string_path, use chrono::Datelike; use filetime::{set_file_times, FileTime}; use log::{error, trace}; -use regex::Regex; use serde::Serialize; use shared::error::{info_err_res, TuliproxError}; -use shared::model::{ClusterFlags, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle}; -use shared::utils::{arc_str_option_serde, arc_str_serde, extract_extension_from_url, hash_bytes, +use shared::model::{ClusterFlags, MediaQuality, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle}; +use shared::utils::{arc_str_option_serde, arc_str_serde, clean_playlist_title, extract_extension_from_url, hash_bytes, hash_string_as_hex, is_blank_optional_arc_str, truncate_string, ExportStyleConfig, CONSTANTS}; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; @@ -21,6 +18,7 @@ use std::sync::Arc; use tokio::fs::{create_dir_all, remove_dir, remove_file, File}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; use shared::model::UUIDType; +use std::borrow::Cow; /// Sanitizes a string to be safe for use as a file or directory name by /// following a strict "allow-list" approach and discarding invalid characters. @@ -69,35 +67,40 @@ fn sanitize_for_filename(text: &str, underscore_whitespace: bool) -> String { } } -/// Finds and removes the first regex match, returning the modified string and the match. -fn extract_match(name: &str, pattern: &Regex) -> (String, Option) { - pattern.find(name).map_or_else( - || (name.to_string(), None), - |m| { - let matched = String::from(&name[m.start()..m.end()]); - let new_name = format!("{}{}", &name[0..m.start()], &name[m.end()..]); - (new_name, Some(matched)) - }, - ) -} - -/// Extracts and formats year information from media titles +/// Extracts and formats year information from media titles. +/// Prioritizes metadata `release_date`. If present, it cleans the year from the title to avoid duplication. +/// If absent, it attempts to parse the year from the title. fn style_rename_year<'a>( name: &'a str, style: &ExportStyleConfig, release_date: Option<&Arc>, -) -> (std::borrow::Cow<'a, str>, Option) { - let mut years = Vec::new(); +) -> (Cow<'a, str>, Option) { + // 1. Try to get year from metadata first (most reliable) + let meta_year = release_date.and_then(|rd| { + // Expected format YYYY-MM-DD or just YYYY + rd.split('-').next().and_then(|y| y.parse::().ok()) + }); let cur_year = u32::try_from(chrono::Utc::now().year()).unwrap_or(0); + + // Check if we need to clean the title (remove year if present) or extract year from title + // We iterate matches to either find the year (if meta_year is None) or remove it (if meta_year is Some) let mut new_name = String::with_capacity(name.len()); let mut last_index = 0; + let mut extracted_year = None; for caps in style.year.captures_iter(name) { if let Some(year_match) = caps.get(1) { if let Ok(year) = year_match.as_str().parse::() { - if (1900..=cur_year).contains(&year) { - years.push(year); + if (1900..=cur_year + 5).contains(&year) { // Allow slightly future years + // Found a valid year in title + if extracted_year.is_none() { + extracted_year = Some(year); + } + + // We remove the year from the title in two cases: + // A) We have a metadata year (clean up title to avoid "Movie (2000) (2000)") + // B) We don't have metadata year (we extract it and remove it from title to re-append consistently later) if let Some(matched) = caps.get(0) { let match_start = matched.start(); let match_end = matched.end(); @@ -108,20 +111,26 @@ fn style_rename_year<'a>( } } } + new_name.push_str(&name[last_index..]); - let smallest_year = years.into_iter().min(); - if smallest_year.is_none() { - if let Some(rel_date) = release_date { - if let Some(year) = extract_match(rel_date, &style.year) - .1 - .and_then(|y| y.parse::().ok()) - { - return (std::borrow::Cow::Borrowed(name), Some(year)); - } - } - } - (std::borrow::Cow::Owned(new_name), smallest_year) + // Use metadata year if available, otherwise the one extracted from title + let final_year = meta_year.or(extracted_year); + + // If we modified the string, trim it and return Owned + if last_index > 0 { + // Clean up potential double spaces or trailing punctuation left by removal + // Remove trailing " -", ".", or "_" which might have been separators before the year + let cleaned = new_name.trim().trim_end_matches(|c| " -_.".contains(c)).trim().to_string(); + + // Ensure we didn't make the name empty + if cleaned.is_empty() { + return (Cow::Borrowed(name), final_year); + } + (Cow::Owned(cleaned), final_year) + } else { + (Cow::Borrowed(name), final_year) + } } pub fn strm_get_file_paths(file_prefix: &str, target_path: &Path) -> PathBuf { @@ -147,6 +156,8 @@ struct StrmItemInfo { series_name: Option>, #[serde(with = "arc_str_option_serde", skip_serializing_if = "is_blank_optional_arc_str")] release_date: Option>, + #[serde(with = "arc_str_option_serde", skip_serializing_if = "is_blank_optional_arc_str")] + series_release_date: Option>, // Global series release date #[serde(default, skip_serializing_if = "Option::is_none")] season: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -165,44 +176,68 @@ impl StrmItemInfo { fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { let header = &mut pli.header; + // Clone necessary fields cheaply (Arc) let group = header.group.clone(); - let title = header.title.clone(); let item_type = header.item_type; let provider_id = header.get_provider_id(); let virtual_id = header.virtual_id; let input_name = header.input_name.clone(); let url = header.url.clone(); - let (series_name, release_date, added, tmdb_id, season, episode) = match header.item_type { + + // Extract properties based on type + // We prioritize name/title from additional_properties if available (e.g. from TMDB) + let (title, series_name, release_date, series_release_date, added, tmdb_id, season, episode) = match header.item_type { PlaylistItemType::Series | PlaylistItemType::LocalSeries => { - let series_name = Some(header.name.clone()); - let (release_date, added, tmdb_id, season, episode) = match header.additional_properties.as_ref() { - None => (None, None, None, None, None), + let (prop_name, release_date, series_release_date, added, tmdb_id, season, episode) = match header.additional_properties.as_ref() { + None => (None, None, None, None, None, None, None), Some(props) => ( + // If series props are available, check if we have a valid name there + if let StreamProperties::Series(s) = props { + if s.name.is_empty() { None } else { Some(s.name.clone()) } + } else { None }, + props.get_release_date(), + // Extract series-level release date from Episode properties + if let StreamProperties::Episode(ep) = props { ep.series_release_date.clone() } else { None }, props.get_added(), props.get_tmdb_id().filter(|&id| id != 0), props.get_season(), props.get_episode(), ) }; - (series_name, release_date, added, tmdb_id, season, episode) + + // For series title, we prefer the one from metadata (prop_name), then header.name, then header.title + let final_series_name = prop_name.unwrap_or_else(|| { + if header.name.is_empty() { header.title.clone() } else { header.name.clone() } + }); + + // Episode title relies on header.title unless we want to look deeper into props + let ep_title = header.title.clone(); + + (ep_title, Some(final_series_name), release_date, series_release_date, added, tmdb_id, season, episode) } PlaylistItemType::Video | PlaylistItemType::LocalVideo => { - let name = Some(header.name.clone()); - let (release_date, added, tmdb_id) = match header.additional_properties.as_ref() { - None => (None, None, None), + let (prop_name, release_date, added, tmdb_id) = match header.additional_properties.as_ref() { + None => (None, None, None, None), Some(props) => ( + if let StreamProperties::Video(v) = props { + if v.name.is_empty() { None } else { Some(v.name.clone()) } + } else { None }, props.get_release_date(), props.get_added(), props.get_tmdb_id().filter(|&id| id != 0), ) }; - (name, release_date, added, tmdb_id, None, None) + + let final_title = prop_name.unwrap_or_else(|| header.title.clone()); + + (final_title, None, release_date, None, added, tmdb_id, None, None) } - _ => (None, None, None, None, None, None), + _ => (header.title.clone(), None, None, None, None, None, None, None), }; + StrmItemInfo { group, title, @@ -213,6 +248,7 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo { url, series_name, release_date, + series_release_date, season, episode, added: added.as_ref().map_or_else(|| Some(0), |a| a.parse::().ok()), @@ -314,6 +350,57 @@ struct StrmFile { strm_info: StrmItemInfo, } +// Helper struct to hold common filename parts to avoid repetition +struct FilenameParts { + sanitized_name: String, + // removed year_string from struct as requested + id_string: String, + category: String, + base_name: String, +} + +fn prepare_filename_parts( + strm_item_info: &StrmItemInfo, + tmdb_id: u32, + separator: &str, + id_format: &str, // e.g. "{tmdb={}}" or "[tmdbid={}]" +) -> FilenameParts { + let id_string = if tmdb_id > 0 { + id_format.replace("{}", &tmdb_id.to_string()) + } else { + String::new() + }; + + // Determine source name and date based on type + let (raw_name, raw_date) = match strm_item_info.item_type { + PlaylistItemType::Series | PlaylistItemType::LocalSeries => ( + strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title), + strm_item_info.series_release_date.as_ref() + ), + _ => ( + &strm_item_info.title, + strm_item_info.release_date.as_ref() + ) + }; + + // Use clean_playlist_title to remove IPTV garbage BEFORE parsing years + let cleaned_name = clean_playlist_title(raw_name); + + let (name_cow, year) = style_rename_year(&cleaned_name, &CONSTANTS.export_style_config, raw_date); + let sanitized_name = sanitize_for_filename(name_cow.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); + let base_name = format!("{sanitized_name}{year_string}"); + let category = sanitize_for_filename(&strm_item_info.group, false); + + FilenameParts { + sanitized_name, + // year_string not needed in public struct + id_string, + category, + base_name, + } +} + /// Formats names according to the official Kodi documentation, with `TMDb` ID for better matching. /// Movie: /Movie Name (Year) {tmdb=XXXXX}/Movie Name (Year).strm /// Series: /Show Name (Year) {tmdb=XXXXX}/Season 01/Show Name S01E01.strm @@ -322,51 +409,47 @@ fn format_for_kodi( tmdb_id: u32, separator: &str, flat: bool, + flat_dedup_paths: &mut HashMap, ) -> (PathBuf, String) { + // Kodi ID format: {tmdb=12345} + let parts = prepare_filename_parts(strm_item_info, tmdb_id, separator, &format!("{separator}{{tmdb={{}}}}")); let mut dir_path = PathBuf::new(); - let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video - | PlaylistItemType::LocalVideo => { - let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb={tmdb_id}}}") } else { String::new() }; - let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_title = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let base_name = format!("{sanitized_title}{year_string}"); - let folder_name = format!("{base_name}{id_string}"); - let final_filename = base_name; + PlaylistItemType::Video | PlaylistItemType::LocalVideo => { + let folder_name = format!("{}{}", parts.base_name, parts.id_string); + let final_filename = parts.base_name; if flat { - dir_path.push(format!("{folder_name}{separator}[{category}]")); + if tmdb_id > 0 { + if let Some(path) = flat_dedup_paths.get(&tmdb_id) { + dir_path.clone_from(path); + } else { + dir_path.push(&folder_name); + flat_dedup_paths.insert(tmdb_id, dir_path.clone()); + } + } else { + dir_path.push(format!("{folder_name}{separator}[{}]", parts.category)); + } } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(folder_name); } (dir_path, final_filename) } - PlaylistItemType::Series - | PlaylistItemType::LocalSeries => { - let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb={tmdb_id}}}") } else { String::new() }; - let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); - let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_series_name = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); + PlaylistItemType::Series | PlaylistItemType::LocalSeries => { + let series_folder_name = format!("{}{}", parts.base_name, parts.id_string); + let season_num = strm_item_info.season.unwrap_or(1); + let episode_num = strm_item_info.episode.unwrap_or(1); - let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - - let season_num=strm_item_info.season.unwrap_or(1u32); - let episode_num = strm_item_info.episode.unwrap_or(1u32); - - let final_filename = format!("{sanitized_series_name}{separator}S{season_num:02}E{episode_num:02}"); + let final_filename = format!("{}{separator}S{season_num:02}E{episode_num:02}", parts.sanitized_name); let season_folder = format!("Season{separator}{season_num:02}"); if flat { - dir_path.push(format!("{series_folder_name}{separator}[{category}]")); + dir_path.push(format!("{series_folder_name}{separator}[{}]", parts.category)); dir_path.push(season_folder); } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(series_folder_name); dir_path.push(season_folder); } @@ -384,52 +467,48 @@ fn format_for_plex( tmdb_id: u32, separator: &str, flat: bool, + flat_dedup_paths: &mut HashMap, ) -> (PathBuf, String) { + // Plex ID format: {tmdb-12345} + let parts = prepare_filename_parts(strm_item_info, tmdb_id, separator, &format!("{separator}{{tmdb-{{}}}}")); let mut dir_path = PathBuf::new(); - let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video - | PlaylistItemType::LocalVideo => { - let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb-{tmdb_id}}}") } else { String::new() }; - let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_title = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let base_name = format!("{sanitized_title}{year_string}"); - let folder_name = format!("{base_name}{id_string}"); - let final_filename = base_name; + PlaylistItemType::Video | PlaylistItemType::LocalVideo => { + let folder_name = format!("{}{}", parts.base_name, parts.id_string); + let final_filename = parts.base_name; if flat { - dir_path.push(format!("{folder_name}{separator}[{category}]")); + if tmdb_id > 0 { + if let Some(path) = flat_dedup_paths.get(&tmdb_id) { + dir_path.clone_from(path); + } else { + dir_path.push(&folder_name); + flat_dedup_paths.insert(tmdb_id, dir_path.clone()); + } + } else { + dir_path.push(format!("{folder_name}{separator}[{}]", parts.category)); + } } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(folder_name); } (dir_path, final_filename) } - PlaylistItemType::Series - | PlaylistItemType::LocalSeries => { - let id_string = if tmdb_id > 0 { format!("{separator}{{tmdb-{tmdb_id}}}") } else { String::new() }; - let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); - let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_series_name = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - + PlaylistItemType::Series | PlaylistItemType::LocalSeries => { + let series_folder_name = format!("{}{}", parts.base_name, parts.id_string); let season_num = strm_item_info.season.unwrap_or(1); let episode_num = strm_item_info.episode.unwrap_or(1); // Plex standard: lowercase 's' and hyphens as separators. - let final_filename = format!("{sanitized_series_name} - s{season_num:02}e{episode_num:02}"); + let final_filename = format!("{} - s{season_num:02}e{episode_num:02}", parts.sanitized_name); let season_folder = format!("Season{separator}{season_num:02}"); if flat { - dir_path.push(format!("{series_folder_name}{separator}[{category}]")); + dir_path.push(format!("{series_folder_name}{separator}[{}]", parts.category)); dir_path.push(season_folder); } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(series_folder_name); dir_path.push(season_folder); } @@ -447,54 +526,50 @@ fn format_for_emby( tmdb_id: u32, separator: &str, flat: bool, + flat_dedup_paths: &mut HashMap, ) -> (PathBuf, String) { + // Emby ID format: [tmdbid=12345] + let parts = prepare_filename_parts(strm_item_info, tmdb_id, separator, &format!("{separator}[tmdbid={{}}]")); let mut dir_path = PathBuf::new(); - let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video - | PlaylistItemType::LocalVideo => { - // Emby prefers the ID in the filename. - let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid={tmdb_id}]") } else { String::new() }; - let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_title = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let base_name = format!("{sanitized_title}{year_string}"); - let folder_name = base_name.clone(); // Folder name does not contain the ID. - let final_filename = format!("{base_name}{id_string}"); + PlaylistItemType::Video | PlaylistItemType::LocalVideo => { + // Emby prefers the ID in the filename for movies, folder optional + let folder_name = parts.base_name.clone(); // Folder name does not contain the ID usually, but can + let final_filename = format!("{}{}", parts.base_name, parts.id_string); if flat { - dir_path.push(format!("{folder_name}{separator}[{category}]")); + if tmdb_id > 0 { + if let Some(path) = flat_dedup_paths.get(&tmdb_id) { + dir_path.clone_from(path); + } else { + dir_path.push(&folder_name); + flat_dedup_paths.insert(tmdb_id, dir_path.clone()); + } + } else { + dir_path.push(format!("{folder_name}{separator}[{}]", parts.category)); + } } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(folder_name); } (dir_path, final_filename) } - PlaylistItemType::Series - | PlaylistItemType::LocalSeries => { - // For series, the ID goes in the folder name. - let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid={tmdb_id}]") } else { String::new() }; - let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); - let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_series_name = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - + PlaylistItemType::Series | PlaylistItemType::LocalSeries => { + // For series, the ID goes in the folder name. + let series_folder_name = format!("{}{}", parts.base_name, parts.id_string); let season_num = strm_item_info.season.unwrap_or(1); let episode_num = strm_item_info.episode.unwrap_or(1); // Emby/Jellyfin standard: uppercase 'S' and hyphens. - let final_filename = format!("{sanitized_series_name} - S{season_num:02}E{episode_num:02}"); + let final_filename = format!("{} - S{season_num:02}E{episode_num:02}", parts.sanitized_name); let season_folder = format!("Season{separator}{season_num:02}"); if flat { - dir_path.push(format!("{series_folder_name}{separator}[{category}]")); + dir_path.push(format!("{series_folder_name}{separator}[{}]", parts.category)); dir_path.push(season_folder); } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(series_folder_name); dir_path.push(season_folder); } @@ -505,59 +580,55 @@ fn format_for_emby( } /// Formats names according to the official Jellyfin documentation. -/// Movie: /Movie Name (Year) [tmdbid-XXXXX]/Movie Name (Year).strm +/// Movie: /Movie Name (Year) [tmdbid-XXXXX]/Movie Name (Year) [tmdbid-XXXXX].strm /// Series: /Show Name (Year) [tmdbid-XXXXX]/Season 01/Show Name - S01E01.strm fn format_for_jellyfin( strm_item_info: &StrmItemInfo, tmdb_id: u32, separator: &str, flat: bool, + flat_dedup_paths: &mut HashMap, ) -> (PathBuf, String) { + // Jellyfin ID format: [tmdbid-12345] + let parts = prepare_filename_parts(strm_item_info, tmdb_id, separator, &format!("{separator}[tmdbid-{{}}]")); let mut dir_path = PathBuf::new(); - let category = sanitize_for_filename(&strm_item_info.group, false); match strm_item_info.item_type { - PlaylistItemType::Video - | PlaylistItemType::LocalVideo => { - let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid-{tmdb_id}]") } else { String::new() }; - let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_title = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let base_name = format!("{sanitized_title}{year_string}"); - let folder_name = format!("{base_name}{id_string}"); - let final_filename = base_name; + PlaylistItemType::Video | PlaylistItemType::LocalVideo => { + // Jellyfin requirement: file name MUST start with parent folder name to detect versions + let folder_name = format!("{}{}", parts.base_name, parts.id_string); + let final_filename = folder_name.clone(); if flat { - dir_path.push(format!("{folder_name}{separator}[{category}]")); + if tmdb_id > 0 { + if let Some(path) = flat_dedup_paths.get(&tmdb_id) { + dir_path.clone_from(path); + } else { + dir_path.push(&folder_name); + flat_dedup_paths.insert(tmdb_id, dir_path.clone()); + } + } else { + dir_path.push(format!("{folder_name}{separator}[{}]", parts.category)); + } } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(folder_name); } (dir_path, final_filename) } - PlaylistItemType::Series - | PlaylistItemType::LocalSeries => { - let id_string = if tmdb_id > 0 { format!("{separator}[tmdbid-{tmdb_id}]") } else { String::new() }; - let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); - let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); - let sanitized_series_name = sanitize_for_filename(name.trim(), false); - let year_string = year.map_or(String::new(), |y| format!("{separator}({y})")); - - let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}"); - + PlaylistItemType::Series | PlaylistItemType::LocalSeries => { + let series_folder_name = format!("{}{}", parts.base_name, parts.id_string); let season_num = strm_item_info.season.unwrap_or(1); let episode_num = strm_item_info.episode.unwrap_or(1); - // Emby/Jellyfin standard: uppercase 'S' and hyphens. - let final_filename = format!("{sanitized_series_name} - S{season_num:02}E{episode_num:02}"); + let final_filename = format!("{} - S{season_num:02}E{episode_num:02}", parts.sanitized_name); let season_folder = format!("Season{separator}{season_num:02}"); if flat { - dir_path.push(format!("{series_folder_name}{separator}[{category}]")); + dir_path.push(format!("{series_folder_name}{separator}[{}]", parts.category)); dir_path.push(season_folder); } else { - dir_path.push(category); + dir_path.push(parts.category); dir_path.push(series_folder_name); dir_path.push(season_folder); } @@ -575,6 +646,7 @@ fn style_based_rename( style: StrmExportStyle, underscore_whitespace: bool, flat: bool, + flat_dedup_paths: &mut HashMap, ) -> (PathBuf, String) { let separator = if underscore_whitespace { "_" } else { " " }; @@ -583,32 +655,33 @@ fn style_based_rename( // Dispatch the call to the responsible function based on the style. match style { - StrmExportStyle::Kodi => format_for_kodi(strm_item_info, tmdb_id, separator, flat), - StrmExportStyle::Plex => format_for_plex(strm_item_info, tmdb_id, separator, flat), - StrmExportStyle::Emby => format_for_emby(strm_item_info, tmdb_id, separator, flat), - StrmExportStyle::Jellyfin => format_for_jellyfin(strm_item_info, tmdb_id, separator, flat), + StrmExportStyle::Kodi => format_for_kodi(strm_item_info, tmdb_id, separator, flat, flat_dedup_paths), + StrmExportStyle::Plex => format_for_plex(strm_item_info, tmdb_id, separator, flat, flat_dedup_paths), + StrmExportStyle::Emby => format_for_emby(strm_item_info, tmdb_id, separator, flat, flat_dedup_paths), + StrmExportStyle::Jellyfin => format_for_jellyfin(strm_item_info, tmdb_id, separator, flat, flat_dedup_paths), } } fn prepare_strm_files( - _app_config: &AppConfig, new_playlist: &mut [PlaylistGroup], - _root_path: &Path, strm_target_output: &StrmTargetOutput, ) -> Vec { let channel_count = new_playlist .iter() .map(|g| g.filter_count(filter_strm_item)) .sum(); - // contains all filenames to detect collisions - let mut all_filenames = HashSet::with_capacity(channel_count); - // contains only collision filenames - let mut collisions: HashSet> = HashSet::new(); + // contains all paths (dir + filename) to detect collisions + let mut all_filenames: HashSet = HashSet::with_capacity(channel_count); + // contains only collision filenames (PathBuf) + let mut collisions: HashSet = HashSet::new(); let mut result = Vec::with_capacity(channel_count); + let mut flat_dedup_paths = HashMap::new(); + // first we create the names to identify name collisions for pg in new_playlist.iter_mut() { for pli in pg.channels.iter_mut().filter(|c| filter_strm_item(c)) { + let strm_item_info = extract_item_info(pli); let (dir_path, strm_file_name) = style_based_rename( @@ -617,21 +690,33 @@ fn prepare_strm_files( strm_target_output.style, strm_target_output.underscore_whitespace, strm_target_output.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 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 cat = sanitize_for_filename(&strm_item_info.group, false); + format!("{separator}[{cat}]") + } else { + String::new() + }; - let final_filename = format!("{strm_file_name}{quality_string}"); + let final_filename = format!("{strm_file_name}{quality_string}{category_suffix}"); let filename = Arc::new(final_filename); + + // Construct the full relative path for collision checking + let full_relative_path = dir_path.join(filename.as_str()); - if all_filenames.contains(&filename) { - collisions.insert(Arc::clone(&filename)); + if all_filenames.contains(&full_relative_path) { + collisions.insert(full_relative_path.clone()); } - all_filenames.insert(Arc::clone(&filename)); + all_filenames.insert(full_relative_path); result.push(StrmFile { - file_name: Arc::clone(&filename), + file_name: filename, dir_path, strm_info: strm_item_info, }); @@ -640,42 +725,33 @@ fn prepare_strm_files( if !collisions.is_empty() { // This separator is specifically for the multi-version naming convention. - // According to the docs (Plex, Jellyfin), this should be " - " (space-hyphen-space). - // The user's `underscore_whitespace` setting should not apply to this structural separator. - let version_separator = " - "; + let version_separator = " "; let separator = if strm_target_output.underscore_whitespace { "_" } else { " " }; - result - .iter_mut() - .filter(|s| collisions.contains(&s.file_name)) - .for_each(|s| { - // Create a descriptive and unique identifier for this version. - let version_label = format!("Version{}id#{}", separator, s.strm_info.virtual_id); - // The base filename is the part that is identical for all versions. - let base_filename = &s.file_name; + for s in &mut result { + { + let full_relative_path = s.dir_path.join(s.file_name.as_str()); + if collisions.contains(&full_relative_path) { + // Create a descriptive and unique identifier for this version. + let version_label = format!("Version{}id#{}", separator, s.strm_info.virtual_id); - // Apply the specific multi-version naming convention for the selected style. - let new_filename = match strm_target_output.style { - // Plex, Emby, and Kodi all follow the `Filename - Suffix` pattern. - StrmExportStyle::Plex | StrmExportStyle::Emby | StrmExportStyle::Kodi => { - format!("{base_filename}{version_separator}{version_label}") - } + // The base filename is the part that is identical for all versions. + let base_filename = &s.file_name; - // Jellyfin also follows this pattern, but explicitly shows an option for " - [Label]". - // Using brackets makes the version distinct and is a clean implementation. - StrmExportStyle::Jellyfin => { - format!("{base_filename}{version_separator}[{version_label}]") - } - }; + // Apply the specific multi-version naming convention for the selected style. + let new_filename = format!("{base_filename}{version_separator}[{version_label}]"); - s.file_name = Arc::new(new_filename); - }); + s.file_name = Arc::new(new_filename); + } + } + } } result } fn get_quality(strm_target_output: &StrmTargetOutput, pli: &PlaylistItem, separator: &str) -> String { if strm_target_output.add_quality_to_filename { + // Use `additional_properties` which are populated by metadata_update_manager/probe let (audio, video) = match pli.header.additional_properties.as_ref() { None => (None, None), Some(props) => { @@ -740,11 +816,10 @@ pub async fn write_strm_playlist( let target_force_redirect = target.options.as_ref().and_then(|o| o.force_redirect.as_ref()); let strm_files = prepare_strm_files( - app_config, new_playlist, - &root_path, target_output, ); + for strm_file in strm_files { // file paths let output_path = truncate_filename(&root_path.join(&strm_file.dir_path), 255); @@ -1079,16 +1154,4 @@ async fn delete_empty_dirs_from_tree(root_path: &Path, tree_nodes: HashMap bool { !self.pending_virtual_id_upserts.is_empty() || !self.pending_uuid_upserts.is_empty() } + + pub fn find_virtual_ids(&self, provider_id: u32) -> Vec { + self.mem_by_virtual_id.values() + .filter(|record| record.provider_id == provider_id) + .map(|record| record.virtual_id) + .collect() + } + + pub fn get_virtual_id_by_uuid(&self, uuid: &UUIDType) -> Option { + self.mem_by_uuid.get(uuid).copied() + } + + pub fn prune_expired_records(&mut self, retention_days: i64) -> usize { + let expiration_threshold = Local::now().timestamp() - (retention_days * 86400); + let mut expired_keys = Vec::new(); + + // Identify expired records + for (vid, record) in &self.mem_by_virtual_id { + if record.last_updated < expiration_threshold { + expired_keys.push(*vid); + } + } + + let count = expired_keys.len(); + if count > 0 { + // Remove from memory + for vid in &expired_keys { + if let Some(record) = self.mem_by_virtual_id.remove(vid) { + self.mem_by_uuid.remove(&record.uuid); + // Mark for deletion in pending batch + self.pending_virtual_id_upserts.remove(vid); // If it was pending upsert, remove it + self.pending_uuid_upserts.remove(&record.uuid); + + // We don't have a "delete" op in BPlusTreeUpdate yet? + // Checking BPlusTreeUpdate capabilities... + // Assuming we might need to handle deletions. For now, we just remove from memory and let subsequent compact/re-write handle it? + // Or BPlusTreeUpdate needs a delete method. + // Let's check BPlusTreeUpdate. + } + } + // TODO: Implement actual deletion persistence. BPlusTreeUpdate might need delete support. + // For B+Tree, "delete" is often complex. + // Current persistence logic uses `upsert_batch`. + // If BPlusTreeUpdate doesn't support delete, we might need a `deleted_virtual_ids` set to purge from disk on next full rewrite or add a "tombstone" logic. + // Pruning from MEMORY is the most important part for performance. + // Let's assume for now we remove from memory and we will need to verify if we can delete from disk. + } + + count + } } impl Drop for TargetIdMapping { diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index c2aea888c..663492694 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -523,6 +523,7 @@ mod tests { custom_stream_response: Arc::new(ArcSwapAny::default()), access_token_secret: Default::default(), encrypt_secret: Default::default(), + ffprobe_available: Arc::default(), }; let target_user = vec![user]; let _ = store_api_user(&cfg, &target_user).await; diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index dfddae4a3..8c0761b22 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -1,32 +1,36 @@ use crate::api::model::AppState; -use crate::model::{ConfigInput, PlaylistXtreamCategory}; +use crate::model::XtreamCategory; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{Config, ConfigTarget}; -use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; +use crate::model::{ConfigInput, PlaylistXtreamCategory}; +use crate::processing::parser::xtream; +use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate, FlushPolicy}; use crate::repository::playlist_scratch::PlaylistScratch; -use crate::repository::storage::{get_input_storage_path, get_target_id_mapping_file, get_target_storage_path}; +use crate::repository::storage::{ensure_input_storage_path, get_input_storage_path, get_target_id_mapping_file, get_target_storage_path}; use crate::repository::storage_const; use crate::repository::target_id_mapping::VirtualIdRecord; use crate::repository::xtream_playlist_iterator::XtreamPlaylistJsonIterator; -use crate::utils::{file_exists_async, file_reader}; use crate::utils::json_write_documents_to_file; +use crate::utils::request::DynReader; use crate::utils::FileReadGuard; +use crate::utils::{file_exists_async, file_reader}; use bytes::Bytes; +use fs2::FileExt; use futures::{stream, Stream, StreamExt}; use indexmap::IndexMap; -use log::error; +use log::{error}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use shared::error::{info_err_res, notify_err, string_to_io_error, TuliproxError}; use shared::model::xtream_const::XTREAM_CLUSTER; -use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemType, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemType, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{arc_str_serde, get_u32_from_serde_value, Internable}; +use shared::{concat_string, notify_err_res}; use std::collections::HashMap; use std::fs::File; use std::io::{Error, ErrorKind}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use shared::{concat_string, notify_err_res}; macro_rules! cant_write_result { ($path:expr, $err:expr) => { @@ -92,10 +96,10 @@ async fn write_playlists_to_file( continue; } let xtream_path = xtream_get_file_path(storage_path, cluster); - + // Acquire FileLockManager lock (async, in-process coordination) let file_lock = app_config.file_locks.write_lock(&xtream_path).await; - + // Move all B+Tree building and I/O to spawn_blocking // We take ownership of `playlist` here (no cloning needed) let path_clone = xtream_path.clone(); @@ -115,9 +119,9 @@ async fn write_playlists_to_file( } Ok(()) }) - .await - .map_err(|e| notify_err!("Blocking task failed: {e}"))? - .map_err(|err| cant_write_result!(&xtream_path, err))?; + .await + .map_err(|e| notify_err!("Blocking task failed: {e}"))? + .map_err(|err| cant_write_result!(&xtream_path, err))?; } Ok(()) } @@ -151,10 +155,10 @@ pub async fn write_playlist_item_update( tree.upsert_batch_encoded(prepared_items)?; Ok(()) }) - .await - .map_err(|e| notify_err!("Blocking task failed: {e}"))? - .map_err(|err| cant_write_result!(&xtream_path, err))?; - + .await + .map_err(|e| notify_err!("Blocking task failed: {e}"))? + .map_err(|err| cant_write_result!(&xtream_path, err))?; + Ok(()) } @@ -167,7 +171,7 @@ pub async fn write_playlist_batch_item_upsert( if pli_list.is_empty() { return Ok(()); } - + let storage_path = { let config = app_config.config.load(); ensure_xtream_storage_path(&config, target_name).await? @@ -196,10 +200,10 @@ pub async fn write_playlist_batch_item_upsert( tree.upsert_batch_encoded(prepared_items)?; Ok(()) }) - .await - .map_err(|e| notify_err!("Blocking task failed: {e}"))? - .map_err(|err| cant_write_result!(&xtream_path, err))?; - + .await + .map_err(|e| notify_err!("Blocking task failed: {e}"))? + .map_err(|err| cant_write_result!(&xtream_path, err))?; + Ok(()) } @@ -361,7 +365,7 @@ pub async fn xtream_write_playlist( StorageKey::VirtualId, vec![(cluster, data)], ) - .await + .await { errors.push(format!("Persisting collection failed:{err}")); } @@ -649,7 +653,7 @@ where return None; } - let iter: Box + Send> = { + let iter: Box + Send> = { match BPlusTreeQuery::::try_new(xtream_path) { Ok(tree) => match tree.disk_iter_sorted::() { Ok(sorted_iter) => Box::new(sorted_iter.filter_map(Result::ok).map(|(_, v)| v)), @@ -708,6 +712,295 @@ pub(crate) async fn xtream_get_playlist_categories(config: &Config, target_name: None } +const BATCH_SIZE: usize = 1000; + +fn preserve_details_input_xtream_playlist_cluster_to_disk( + old_path: &Path, + tmp_path: &Path, +) -> Result<(), TuliproxError> { + + 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(()) + }; + + let mut updates: Vec<(u32, XtreamPlaylistItem)> = Vec::with_capacity(BATCH_SIZE); + for (_, old_item) in old_tree.iter() { + if let Some(old_props) = old_item.additional_properties.as_ref() { + 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) { + if 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(); + new_tree.update_batch(&refs).map_err(|e| notify_err!("Failed to update tmp tree during merge: {e}"))?; + updates.clear(); + } + } + } + }; + }; + } + } + } + + if !updates.is_empty() { + let refs: Vec<(&u32, &XtreamPlaylistItem)> = + updates.iter().map(|(id, pli)| (id, pli)).collect(); + new_tree.update_batch(&refs) + .map_err(|e| notify_err!("Failed to update tmp tree during merge: {e}"))?; + } + + new_tree.commit() + .map_err(|e| notify_err!("Failed to commit tmp tree merge: {e}"))?; + + Ok(()) +} + +#[allow(clippy::too_many_lines)] +pub async fn persist_input_xtream_playlist_cluster_to_disk( + app_config: &Arc, + input: &ConfigInput, + cluster: XtreamCluster, + categories: DynReader, + streams: DynReader, +) -> Result<(), TuliproxError> { + let cfg = app_config.config.load(); + let storage_path = ensure_input_storage_path(&cfg, &input.name).await?; + let xtream_path = xtream_get_file_path(&storage_path, cluster); + + // Channel for transferring items from Parser (Async Task) to Consumer (Blocking Task) + let (tx, mut rx) = tokio::sync::mpsc::channel::(BATCH_SIZE * 2); + let input_clone = input.clone(); + + // 1. Parser Task: Runs the async parsing logic + // We move the readers into this task. + let parse_task = tokio::spawn(async move { + let tx_for_closure = tx.clone(); + let res = xtream::parse_xtream_streaming( + &input_clone, + cluster, + categories, + streams, + move |item| { + // Copy needed data before moving the item into the channel. + let item_id = item.virtual_id; + + // We use blocking_send because the closure provided by the parser library is synchronous. + // This is safe here because it runs within its own tokio::spawn task. + if let Err(e) = tx_for_closure.blocking_send(item) { + error!("Channel closed while processing {cluster} for item {item_id}: {e}"); + return notify_err_res!("Channel closed while processing {cluster}"); + } + Ok(()) + }, + ) + .await; + + // CRITICAL: Explicitly drop the sender to signal rx.blocking_recv() to stop. + // This prevents the consumer from waiting forever if the parser fails. + drop(tx); + res + }); + + // 2. Consumer Task: Handles heavy Disk I/O (BPlusTree updates) + let xtream_path_for_consumer = xtream_path.clone(); + let consumer_task = tokio::task::spawn_blocking(move || { + let tmp_xtream_path = xtream_path_for_consumer.with_extension("tmp"); + + BPlusTree::::new() + .store(&tmp_xtream_path) + .map_err(|e| { + error!( + "Failed to initialize ghost BPlusTree at {}: {e}", + tmp_xtream_path.display() + ); + notify_err!("Init tree error {e}") + })?; + + let mut tree: BPlusTreeUpdate = + BPlusTreeUpdate::try_new_with_backoff(&tmp_xtream_path).map_err(|e| { + error!("Failed to open ghost tree at {}: {e}", tmp_xtream_path.display()); + notify_err!("Failed to open tree {e}") + })?; + tree.set_flush_policy(FlushPolicy::Batch); + + let mut buffer = Vec::with_capacity(BATCH_SIZE); + + // This loop exits when all 'tx' clones are dropped (signaling end of stream) + while let Some(item) = rx.blocking_recv() { + buffer.push(item); + if buffer.len() >= BATCH_SIZE { + let batch: Vec<(&u32, &XtreamPlaylistItem)> = + buffer.iter().map(|i| (&i.provider_id, i)).collect(); + let prepared = BPlusTreeUpdate::::prepare_upsert_batch(&batch) + .map_err(|e| { + error!("Batch prepare failed for cluster {cluster}: {e}"); + notify_err!("Prepare failed {e}") + })?; + tree.upsert_batch_encoded(prepared).map_err(|e| { + error!("Batch upsert failed for cluster {cluster}: {e}"); + notify_err!("Upsert failed {e}") + })?; + buffer.clear(); + } + } + + // Final batch processing + if !buffer.is_empty() { + let batch: Vec<(&u32, &XtreamPlaylistItem)> = + buffer.iter().map(|i| (&i.provider_id, i)).collect(); + let prepared = BPlusTreeUpdate::::prepare_upsert_batch(&batch) + .map_err(|e| { + error!("Final batch prepare failed for cluster {cluster}: {e}"); + notify_err!("Prepare failed {e}") + })?; + tree.upsert_batch_encoded(prepared).map_err(|e| { + error!("Final batch upsert failed for cluster {cluster}: {e}"); + notify_err!("Upsert failed {e}") + })?; + } + + tree.commit().map_err(|e| { + error!("Commit failed for cluster {cluster}: {e}"); + notify_err!("Commit failed {e}") + })?; + Ok::<(), TuliproxError>(()) + }); + + // 3. Robust Joining of both tasks + // try_join! returns immediately if any task returns an error or panics. + let (parse_res, consumer_res) = tokio::try_join!(parse_task, consumer_task) + .map_err(|e| notify_err!("Task join error during cluster {cluster} update: {e}"))?; + + // Handle internal errors from the tasks + let parsed_categories = parse_res?; + consumer_res?; + + // --- Post-Processing & Atomic Swap --- + + let col_path = match cluster { + XtreamCluster::Live => get_live_cat_collection_path(&storage_path), + XtreamCluster::Video => get_vod_cat_collection_path(&storage_path), + XtreamCluster::Series => get_series_cat_collection_path(&storage_path), + }; + + let tmp_col_path = col_path.with_extension("tmp"); + + // Use the parsed_categories returned from the task, not the 'categories' reader + save_xtream_categories_to_file(&tmp_col_path, &parsed_categories).await?; + + let tmp_xtream_path = xtream_path.with_extension("tmp"); + + // Acquire file lock to ensure atomic operations + let swap_lock = app_config.file_locks.write_lock(&xtream_path).await; + + { + let old_path = xtream_path.clone(); + let new_tmp_path = tmp_xtream_path.clone(); + tokio::task::spawn_blocking(move || preserve_details_input_xtream_playlist_cluster_to_disk(&old_path, &new_tmp_path)) + .await + .map_err(|e| notify_err!("Merge task join error during cluster {cluster} update: {e}"))??; + } + + // Optional compaction to optimize the newly created database file. + // Run on blocking pool because B+Tree lock backoff uses std::thread::sleep. + let compact_path = tmp_xtream_path.clone(); + match tokio::task::spawn_blocking(move || -> Result<(), std::io::Error> { + if let Ok(mut tree_update) = BPlusTreeUpdate::::try_new_with_backoff(&compact_path) { + tree_update.compact(&compact_path)?; + } + Ok(()) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(e)) => { + error!( + "Failed to compact temporary database for {cluster} at {:?}: {e}", + tmp_xtream_path.display() + ); + } + Err(e) => { + error!( + "Compaction task join failed for {cluster} at {:?}: {e}", + tmp_xtream_path.display() + ); + } + } + + // Atomic Swap: Replace old database with new one + if let Err(e) = crate::utils::rename_or_copy(&tmp_xtream_path, &xtream_path, false) { + error!( + "Failed to swap xtream database for {cluster} (from {:?} to {:?}): {e}", + tmp_xtream_path.display(), + xtream_path.display() + ); + return notify_err_res!("Failed to swap database: {e}"); + } + + // Atomic Swap: Replace old categories with new ones + if let Err(e) = crate::utils::rename_or_copy(&tmp_col_path, &col_path, false) { + error!( + "Failed to swap xtream categories for {cluster} (from {:?} to {:?}): {e}", + tmp_col_path.display(), + col_path.display() + ); + return notify_err_res!("Failed to swap categories: {e}"); + } + + // Cleanup: Remove temporary files if they still exist (defensive) + let _ = tokio::fs::remove_file(&tmp_xtream_path).await; + let _ = tokio::fs::remove_file(&tmp_col_path).await; + + // Explicitly release the lock (though RAII would handle it too) + drop(swap_lock); + + log::debug!("Cluster {cluster} updated and swapped successfully."); + Ok(()) +} + +async fn save_xtream_categories_to_file( + col_path: &Path, + categories: &[XtreamCategory], +) -> Result<(), TuliproxError> { + let col_path_buf = col_path.to_path_buf(); + let cat_entries: Vec = categories + .iter() + .map(|c| CategoryEntry { + category_id: c.category_id, + category_name: c.category_name.clone(), + parent_id: 0, + }) + .collect(); + + tokio::task::spawn_blocking(move || { + if let Ok(file) = File::create(&col_path_buf) { + if let Err(e) = file.lock_exclusive() { + log::warn!( + "Could not acquire exclusive lock for {}: {e}, proceeding without lock", + col_path_buf.display() + ); + } + serde_json::to_writer(&file, &cat_entries).map_err(|e| { + error!("Failed to write categories to file {}: {e}", col_path_buf.display()); + notify_err!("Write failed: {e}") + })?; + let _ = file.unlock(); + } else { + return notify_err_res!("Failed to create category file {}", col_path_buf.display()); + } + Ok(()) + }) + .await + .map_err(|e| notify_err!("Spawn error {e}"))? +} + #[allow(clippy::too_many_lines)] pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_path: &Path, playlist: Vec) -> (Vec, Option) { @@ -750,16 +1043,8 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_ if let Ok(provider_id) = pli.header.id.parse::() { if let Some(stored_pli) = stored_col.get_mut(&provider_id) { if let (Some(new_stream_props), Some(old_stream_props)) = (&mut pli.header.additional_properties, stored_pli.additional_properties.take()) { - if !needs_update_info_details(new_stream_props, &old_stream_props) { - match (new_stream_props, old_stream_props) { - (StreamProperties::Video(value_1), StreamProperties::Video(value_2)) => { - value_1.details = value_2.details; - } - (StreamProperties::Series(value_1), StreamProperties::Series(value_2)) => { - value_1.details = value_2.details; - } - _ => {} - } + if needs_preserved_stream_property_merge(new_stream_props, &old_stream_props) { + merge_preserved_stream_properties(new_stream_props, &old_stream_props); } } } @@ -840,16 +1125,107 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_ } // Checks if the info has changed after the last update -fn needs_update_info_details( +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(); match (new_modified, old_modified) { (Some(new_ts), Some(old_ts)) => new_ts > old_ts, - (None, Some(_)) => true, + (Some(_), None) => true, + _ => false, + } +} + +fn needs_preserved_stream_property_merge( + new_stream_props: &StreamProperties, + old_stream_props: &StreamProperties, +) -> bool { + let preserve_info_details = old_stream_props.has_details() && !needs_update_info_details(new_stream_props, old_stream_props); + + 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 + } + (StreamProperties::Series(s_new), StreamProperties::Series(s_old)) + 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()) + || (l_new.last_probed_timestamp.is_none() && l_old.last_probed_timestamp.is_some()) + || (l_new.last_success_timestamp.is_none() && l_old.last_success_timestamp.is_some()) + } + _ => false, + } +} + +/// Merges persisted fields from old stream properties into freshly fetched properties. +/// +/// This keeps long-lived metadata stable across full playlist rewrites: +/// - VOD/Series `details` are preserved when incoming provider metadata is not newer. +/// - Live probe fields (`video`, `audio`, `last_probed_timestamp`, `last_success_timestamp`) +/// are copied only when missing in the incoming payload. +pub(crate) fn merge_preserved_stream_properties( + new_stream_props: &mut StreamProperties, + old_stream_props: &StreamProperties, +) -> bool { + let preserve_info_details = + old_stream_props.has_details() && !needs_update_info_details(new_stream_props, old_stream_props); + + match (new_stream_props, old_stream_props) { + (StreamProperties::Video(v_new), StreamProperties::Video(v_old)) + if preserve_info_details && v_old.details.is_some() => + { + if v_new.details == v_old.details { + false + } else { + v_new.details.clone_from(&v_old.details); + true + } + } + (StreamProperties::Series(s_new), StreamProperties::Series(s_old)) + if preserve_info_details && s_old.details.is_some() => + { + if s_new.details == s_old.details { + false + } else { + s_new.details.clone_from(&s_old.details); + true + } + } + (StreamProperties::Live(l_new), StreamProperties::Live(l_old)) => { + let mut changed = false; + + if l_new.video.is_none() && l_old.video.is_some() { + l_new.video.clone_from(&l_old.video); + changed = true; + } + + if l_new.audio.is_none() && l_old.audio.is_some() { + l_new.audio.clone_from(&l_old.audio); + changed = true; + } + + if l_new.last_probed_timestamp.is_none() && l_old.last_probed_timestamp.is_some() { + l_new.last_probed_timestamp = l_old.last_probed_timestamp; + changed = true; + } + + if l_new.last_success_timestamp.is_none() && l_old.last_success_timestamp.is_some() { + l_new.last_success_timestamp = l_old.last_success_timestamp; + changed = true; + } + + changed + } _ => false, } } @@ -896,8 +1272,14 @@ pub async fn persist_input_info_batch(app_config: &Arc, storage_path: let mut tree: BPlusTreeUpdate = BPlusTreeUpdate::try_new_with_backoff(&xtream_path_clone) .map_err(|err| Error::other(format!("failed to open BPlusTree for input {input_name_owned}: {err}")))?; - let mut updated_plis = Vec::with_capacity(updates.len()); + // Keep only the latest update per provider id to avoid duplicate reads/writes. + let mut deduped_updates: HashMap = HashMap::with_capacity(updates.len()); for (provider_id, props) in updates { + deduped_updates.insert(provider_id, props); + } + + let mut updated_plis = Vec::with_capacity(deduped_updates.len()); + for (provider_id, props) in deduped_updates { match tree.query(&provider_id) { Ok(Some(mut pli)) => { pli.additional_properties = Some(props); @@ -931,6 +1313,21 @@ pub async fn persist_input_vod_info(app_config: &Arc, storage_path: & persist_input_info(app_config, storage_path, cluster, input_name, provider_id, StreamProperties::Video(Box::new(props.clone()))).await } +pub async fn persist_input_live_info(app_config: &Arc, storage_path: &Path, + cluster: XtreamCluster, input_name: &str, provider_id: u32, + props: &LiveStreamProperties) -> Result<(), Error> { + persist_input_info(app_config, storage_path, cluster, input_name, provider_id, StreamProperties::Live(Box::new(props.clone()))).await +} + +pub async fn persist_input_live_info_batch(app_config: &Arc, storage_path: &Path, + cluster: XtreamCluster, input_name: &str, + updates: Vec<(u32, LiveStreamProperties)>) -> Result<(), Error> { + let batch = updates.into_iter() + .map(|(id, props)| (id, StreamProperties::Live(Box::new(props)))) + .collect(); + persist_input_info_batch(app_config, storage_path, cluster, input_name, batch).await +} + pub async fn persist_input_vod_info_batch(app_config: &Arc, storage_path: &Path, cluster: XtreamCluster, input_name: &str, updates: Vec<(u32, VideoStreamProperties)>) -> Result<(), Error> { @@ -1003,3 +1400,290 @@ pub async fn load_input_xtream_playlist(app_config: &Arc, storage_pat Ok(groups.into_values().collect()) } + +#[cfg(test)] +mod tests { + use super::{merge_preserved_stream_properties, needs_update_info_details, preserve_details_input_xtream_playlist_cluster_to_disk}; + use crate::repository::{BPlusTreeQuery, BPlusTreeUpdate}; + use shared::model::{ + LiveStreamProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties, + XtreamCluster, XtreamPlaylistItem, + }; + use shared::utils::Internable; + use std::path::Path; + use tempfile::tempdir; + + #[test] + fn keeps_existing_details_when_new_timestamp_is_missing() { + let new_props = StreamProperties::Video(Box::new(VideoStreamProperties { + added: "".into(), + ..VideoStreamProperties::default() + })); + let old_props = StreamProperties::Video(Box::new(VideoStreamProperties { + added: "1700000000".into(), + ..VideoStreamProperties::default() + })); + + assert!(!needs_update_info_details(&new_props, &old_props)); + } + + #[test] + fn updates_details_when_new_timestamp_is_newer() { + let new_props = StreamProperties::Series(Box::new(SeriesStreamProperties { + last_modified: Some("200".into()), + ..SeriesStreamProperties::default() + })); + let old_props = StreamProperties::Series(Box::new(SeriesStreamProperties { + last_modified: Some("100".into()), + ..SeriesStreamProperties::default() + })); + + assert!(needs_update_info_details(&new_props, &old_props)); + } + + #[test] + fn does_not_update_details_when_new_timestamp_is_older() { + let new_props = StreamProperties::Series(Box::new(SeriesStreamProperties { + last_modified: Some("100".into()), + ..SeriesStreamProperties::default() + })); + let old_props = StreamProperties::Series(Box::new(SeriesStreamProperties { + last_modified: Some("200".into()), + ..SeriesStreamProperties::default() + })); + + assert!(!needs_update_info_details(&new_props, &old_props)); + } + + #[test] + fn keeps_old_details_when_new_timestamp_is_newer_but_new_has_no_details() { + let new_props = StreamProperties::Video(Box::new(VideoStreamProperties { + added: "200".into(), + details: None, + ..VideoStreamProperties::default() + })); + let old_props = StreamProperties::Video(Box::new(VideoStreamProperties { + added: "100".into(), + details: Some(shared::model::VideoStreamDetailProperties::default()), + ..VideoStreamProperties::default() + })); + + assert!(!needs_update_info_details(&new_props, &old_props)); + } + + #[test] + fn merge_preserves_missing_live_probe_timestamps() { + let mut new_props = StreamProperties::Live(Box::new(LiveStreamProperties { + stream_id: 1, + ..LiveStreamProperties::default() + })); + let old_props = StreamProperties::Live(Box::new(LiveStreamProperties { + stream_id: 1, + last_probed_timestamp: Some(1_700_000_000), + last_success_timestamp: Some(1_700_000_100), + ..LiveStreamProperties::default() + })); + + let changed = merge_preserved_stream_properties(&mut new_props, &old_props); + assert!(changed); + + match new_props { + StreamProperties::Live(live) => { + assert_eq!(live.last_probed_timestamp, Some(1_700_000_000)); + assert_eq!(live.last_success_timestamp, Some(1_700_000_100)); + } + _ => panic!("expected live properties"), + } + } + + #[test] + fn merge_does_not_override_existing_live_probe_timestamps() { + let mut new_props = StreamProperties::Live(Box::new(LiveStreamProperties { + stream_id: 1, + last_probed_timestamp: Some(1_800_000_000), + last_success_timestamp: Some(1_800_000_100), + ..LiveStreamProperties::default() + })); + let old_props = StreamProperties::Live(Box::new(LiveStreamProperties { + stream_id: 1, + last_probed_timestamp: Some(1_700_000_000), + last_success_timestamp: Some(1_700_000_100), + ..LiveStreamProperties::default() + })); + + let changed = merge_preserved_stream_properties(&mut new_props, &old_props); + assert!(!changed); + + match new_props { + StreamProperties::Live(live) => { + assert_eq!(live.last_probed_timestamp, Some(1_800_000_000)); + assert_eq!(live.last_success_timestamp, Some(1_800_000_100)); + } + _ => panic!("expected live properties"), + } + } + + fn make_live_item( + provider_id: u32, + video: Option<&str>, + audio: Option<&str>, + last_probed_timestamp: Option, + last_success_timestamp: Option, + ) -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: provider_id, + provider_id, + name: "live".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: "group".intern(), + title: "".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: "http://example.com/live.ts".intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Live, + additional_properties: Some(StreamProperties::Live(Box::new(LiveStreamProperties { + video: video.map(Internable::intern), + audio: audio.map(Internable::intern), + last_probed_timestamp, + last_success_timestamp, + ..Default::default() + }))), + item_type: shared::model::PlaylistItemType::Live, + category_id: 1, + input_name: "input_a".intern(), + channel_no: 0, + source_ordinal: 0, + } + } + + fn write_single_item(path: &Path, item: &XtreamPlaylistItem) { + crate::repository::BPlusTree::::new() + .store(path) + .expect("tree creation should succeed"); + let mut tree = BPlusTreeUpdate::::try_new_with_backoff(path) + .expect("tree open should succeed"); + let batch: Vec<(&u32, &XtreamPlaylistItem)> = vec![(&item.provider_id, item)]; + let prepared = BPlusTreeUpdate::::prepare_upsert_batch(&batch) + .expect("batch preparation should succeed"); + tree.upsert_batch_encoded(prepared) + .expect("batch upsert should succeed"); + tree.commit().expect("tree commit should succeed"); + } + + fn read_live_props(path: &Path, provider_id: u32) -> LiveStreamProperties { + let mut query = + BPlusTreeQuery::::try_new(path).expect("query open should succeed"); + let item = query + .query_zero_copy(&provider_id) + .expect("query should succeed") + .expect("item should exist"); + match item.additional_properties { + Some(StreamProperties::Live(live)) => *live, + other => panic!("expected live stream properties, got {other:?}"), + } + } + + #[test] + fn preserve_details_for_disk_cluster_copies_missing_live_probe_fields() { + let dir = tempdir().expect("temp dir should be created"); + let old_path = dir.path().join("old_live.db"); + let tmp_path = dir.path().join("tmp_live.db"); + let provider_id = 100_u32; + + write_single_item( + &old_path, + &make_live_item( + provider_id, + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + Some(1_700_000_000), + Some(1_700_000_100), + ), + ); + write_single_item(&tmp_path, &make_live_item(provider_id, None, None, None, None)); + + preserve_details_input_xtream_playlist_cluster_to_disk(&old_path, &tmp_path).expect("merge should succeed"); + + let merged = read_live_props(&tmp_path, provider_id); + assert_eq!(merged.video, Some("{\"codec_name\":\"h264\"}".intern())); + assert_eq!(merged.audio, Some("{\"codec_name\":\"aac\"}".intern())); + assert_eq!(merged.last_probed_timestamp, Some(1_700_000_000)); + assert_eq!(merged.last_success_timestamp, Some(1_700_000_100)); + } + + #[test] + fn preserve_details_for_disk_cluster_does_not_override_existing_live_probe_fields() { + let dir = tempdir().expect("temp dir should be created"); + let old_path = dir.path().join("old_live_existing.db"); + let tmp_path = dir.path().join("tmp_live_existing.db"); + let provider_id = 200_u32; + + write_single_item( + &old_path, + &make_live_item( + provider_id, + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + Some(1_700_000_000), + Some(1_700_000_100), + ), + ); + write_single_item( + &tmp_path, + &make_live_item( + provider_id, + Some("{\"codec_name\":\"hevc\"}"), + Some("{\"codec_name\":\"ac3\"}"), + Some(1_800_000_000), + Some(1_800_000_100), + ), + ); + + preserve_details_input_xtream_playlist_cluster_to_disk(&old_path, &tmp_path).expect("merge should succeed"); + + let merged = read_live_props(&tmp_path, provider_id); + assert_eq!(merged.video, Some("{\"codec_name\":\"hevc\"}".intern())); + assert_eq!(merged.audio, Some("{\"codec_name\":\"ac3\"}".intern())); + assert_eq!(merged.last_probed_timestamp, Some(1_800_000_000)); + assert_eq!(merged.last_success_timestamp, Some(1_800_000_100)); + } + + #[test] + fn preserve_details_for_disk_cluster_fills_only_missing_live_probe_fields() { + let dir = tempdir().expect("temp dir should be created"); + let old_path = dir.path().join("old_live_partial.db"); + let tmp_path = dir.path().join("tmp_live_partial.db"); + let provider_id = 300_u32; + + write_single_item( + &old_path, + &make_live_item( + provider_id, + Some("{\"codec_name\":\"h264\"}"), + Some("{\"codec_name\":\"aac\"}"), + Some(1_700_000_000), + Some(1_700_000_100), + ), + ); + write_single_item( + &tmp_path, + &make_live_item( + provider_id, + Some("{\"codec_name\":\"hevc\"}"), + None, + Some(1_800_000_000), + None, + ), + ); + + preserve_details_input_xtream_playlist_cluster_to_disk(&old_path, &tmp_path).expect("merge should succeed"); + + let merged = read_live_props(&tmp_path, provider_id); + assert_eq!(merged.video, Some("{\"codec_name\":\"hevc\"}".intern())); + assert_eq!(merged.audio, Some("{\"codec_name\":\"aac\"}".intern())); + assert_eq!(merged.last_probed_timestamp, Some(1_800_000_000)); + assert_eq!(merged.last_success_timestamp, Some(1_700_000_100)); + } +} diff --git a/backend/src/utils/epg_parser.rs b/backend/src/utils/epg_parser.rs index c68c8b9e5..9d9caff5f 100644 --- a/backend/src/utils/epg_parser.rs +++ b/backend/src/utils/epg_parser.rs @@ -246,7 +246,7 @@ mod tests { assert_eq!(shifted, "2026-02-08-12-30"); // Timestamp - let ts = 1770550200; // 2026-02-08 11:30:00 UTC + let ts = 1_770_550_200; // 2026-02-08 11:30:00 UTC let date_str = ts.to_string(); let shifted = apply_timeshift(&date_str, &shift); // +1 hour = +3600 seconds diff --git a/backend/src/utils/ffmpeg.rs b/backend/src/utils/ffmpeg.rs new file mode 100644 index 000000000..af5c71f6e --- /dev/null +++ b/backend/src/utils/ffmpeg.rs @@ -0,0 +1,114 @@ +use log::{debug, warn}; +use tokio::process::Command; +use std::time::Duration; +use serde_json::Value; +use shared::model::MediaQuality; +use shared::utils::sanitize_sensitive_info; + +// Checks if ffprobe is available in the system path +pub async fn check_ffprobe_availability() -> bool { + match Command::new("ffprobe").arg("-version").output().await { + Ok(output) => output.status.success(), + Err(_) => false, + } +} + +pub async fn probe_url(url: &str, user_agent: Option<&str>, analyze_duration: u64, probe_size: u64, timeout_secs: u64) -> Option<(MediaQuality, Option, Option)> { + // Determine timeout: Ensure it's at least as long as the analyze duration + buffer, + // but respect the user setting if it's longer. + let analyze_overhead = Duration::from_micros(analyze_duration) + Duration::from_secs(5); + let config_timeout = Duration::from_secs(timeout_secs); + let timeout_val = std::cmp::max(analyze_overhead, config_timeout); + + let mut command = Command::new("ffprobe"); + + // Ensure the child process is killed if this future is dropped (e.g. by connection preemption) + command.kill_on_drop(true); + + command + .arg("-v").arg("error") + .arg("-show_streams") // Get all streams info + .arg("-of").arg("json") + // Optimization for network streams + .arg("-analyzeduration").arg(analyze_duration.to_string()) + .arg("-probesize").arg(probe_size.to_string()); + + if let Some(ua) = user_agent { + command.arg("-user_agent").arg(ua); + } + + command.arg(url); + + let output_result = tokio::time::timeout(timeout_val, command.output()).await; + + match output_result { + Ok(Ok(output)) => { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + debug!("ffprobe failed for {}: {}", sanitize_sensitive_info(url), sanitize_sensitive_info(&stderr)); + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if let Ok(json) = serde_json::from_str::(&stdout) { + let streams = json.get("streams").and_then(|s| s.as_array()); + + if let Some(stream_list) = streams { + let mut video_info: Option = None; + let mut audio_info: Option = None; + let mut raw_video_json: Option = None; + let mut raw_audio_json: Option = None; + + for stream in stream_list { + // Check codec_type if available + let codec_type = stream.get("codec_type").and_then(|s| s.as_str()); + + // We prefer the first video/audio stream we find + if codec_type == Some("video") && video_info.is_none() { + video_info = Some(stream.to_string()); + raw_video_json = Some(stream.clone()); + } else if codec_type == Some("audio") && audio_info.is_none() { + audio_info = Some(stream.to_string()); + raw_audio_json = Some(stream.clone()); + } + } + + // Fallback heuristic if codec_type missing + if video_info.is_none() { + for stream in stream_list { + if (stream.get("width").is_some() || stream.get("height").is_some()) && video_info.is_none() { + video_info = Some(stream.to_string()); + raw_video_json = Some(stream.clone()); + } + } + } + if audio_info.is_none() { + for stream in stream_list { + if (stream.get("channels").is_some() || stream.get("channel_layout").is_some()) && audio_info.is_none() { + audio_info = Some(stream.to_string()); + raw_audio_json = Some(stream.clone()); + } + } + } + + if video_info.is_some() || audio_info.is_some() { + let mq = MediaQuality::from_ffprobe_info(audio_info.as_deref(), video_info.as_deref()); + if let Some(quality) = mq { + return Some((quality, raw_video_json, raw_audio_json)); + } + } + } + } else { + warn!("Failed to parse ffprobe json output for {}", sanitize_sensitive_info(url)); + } + } + Ok(Err(e)) => { + warn!("ffprobe execution failed for {}: {}", sanitize_sensitive_info(url), e); + } + Err(_) => { + warn!("ffprobe timed out after {:?} for {}", timeout_val, sanitize_sensitive_info(url)); + } + } + + None +} \ No newline at end of file diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 51ca4249e..82dd54ff0 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -21,6 +21,7 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; +use tokio::sync::OnceCell; use shared::concat_string; use crate::utils::request::{is_uri}; use url::Url; @@ -284,7 +285,7 @@ pub async fn read_initial_app_config( prepare_sources_batch(&mut sources_dto, include_computed).await?; let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; let mut config: Config = Config::from(config_dto); - config.prepare(config_path)?; + config.prepare(config_path).await?; config.update_runtime(); if paths.mapping_file_path.is_none() { @@ -308,6 +309,7 @@ pub async fn read_initial_app_config( custom_stream_response: Arc::new(ArcSwapAny::default()), access_token_secret: Default::default(), encrypt_secret: Default::default(), + ffprobe_available: Arc::new(OnceCell::new()) }; app_config.prepare(include_computed)?; //print_info(&app_config); diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index 7183020eb..85efabb87 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -12,6 +12,7 @@ mod telegram; mod geoip; mod db_viewer; mod epg_parser; +pub mod ffmpeg; pub use self::binary_utils::*; pub use self::logging::*; @@ -74,4 +75,4 @@ pub use self::compression::*; pub use self::file::*; pub use self::network::*; pub use self::crypto_utils::*; -pub use self::step_measure::*; +pub use self::step_measure::*; \ No newline at end of file diff --git a/backend/src/utils/network/epg.rs b/backend/src/utils/network/epg.rs index 6e9b39f24..ecea36de7 100644 --- a/backend/src/utils/network/epg.rs +++ b/backend/src/utils/network/epg.rs @@ -1,6 +1,6 @@ use crate::model::{ConfigInput, PersistedEpgSource}; use crate::model::{TVGuide}; -use crate::processing::processor::playlist::PlaylistProcessingContext; +use crate::processing::processor::PlaylistProcessingContext; use crate::repository::get_input_storage_path; use crate::repository::storage_const; use crate::utils::{add_prefix_to_filename, prepare_file_path, request}; diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index fe087bff1..c6e447bc2 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -1,7 +1,9 @@ use crate::api::model::persist_pipe_stream::tee_dyn_reader; use crate::api::model::{AppState, STREAM_IDLE_TIMEOUT}; -use crate::model::{format_elapsed_time, AppConfig, Config, ConfigProvider, InputSource, ReverseProxyDisabledHeaderConfig}; -use crate::model::{resolve_provider_scheme_url_with_provider, ConfigInput, ResourceRetryConfig}; +use crate::model::{ + resolve_provider_scheme_url_with_provider, AppConfig, Config, ConfigInput, ConfigProvider, + InputSource, ResourceRetryConfig, ReverseProxyDisabledHeaderConfig, +}; use crate::utils::compression::compression_utils::{is_deflate, is_gzip}; use crate::utils::{async_file_reader, async_file_writer, debug_if_enabled}; use crate::utils::{get_file_path, persist_file}; @@ -12,9 +14,11 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::redirect::Policy; use reqwest::StatusCode; use shared::error::{notify_err_res, string_to_io_error, TuliproxError}; -use shared::model::{InputFetchMethod, DEFAULT_USER_AGENT}; -use shared::utils::{filter_request_header, human_readable_byte_size, - sanitize_sensitive_info, ENCODING_DEFLATE, ENCODING_GZIP}; +use shared::model::{format_elapsed_time, InputFetchMethod, DEFAULT_USER_AGENT}; +use shared::utils::{ + filter_request_header, human_readable_byte_size, sanitize_sensitive_info, ENCODING_DEFLATE, + ENCODING_GZIP, +}; use std::collections::HashMap; use std::io::{Error, ErrorKind}; use std::path::{Path, PathBuf}; diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index f64d65cfb..033bebd2f 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -5,15 +5,13 @@ use crate::model::{is_input_expired, xtream_mapping_option_from_target_options, use crate::model::{InputSource, ProxyUserCredentials}; use crate::processing::parser::xtream; use crate::processing::parser::xtream::parse_xtream_series_info; -use crate::repository::{BPlusTreeUpdate, FlushPolicy}; use crate::repository::VirtualIdRecord; -use crate::repository::{ensure_input_storage_path, get_input_storage_path, get_target_storage_path}; -use crate::repository::{get_live_cat_collection_path, get_series_cat_collection_path, get_vod_cat_collection_path, xtream_get_file_path, CategoryEntry}; +use crate::repository::{get_input_storage_path, get_target_storage_path}; use crate::repository::{get_target_id_mapping, rewrite_provider_series_info_episode_virtual_id, ProviderEpisodeKey}; -use crate::repository::{persist_input_vod_info, persists_input_series_info, write_playlist_batch_item_upsert, write_playlist_item_update}; +use crate::repository::{persist_input_xtream_playlist_cluster_to_disk, persist_input_vod_info, persists_input_series_info, write_playlist_batch_item_upsert, write_playlist_item_update}; use crate::utils::request; use chrono::{DateTime, Utc}; -use log::{debug, error, info, warn}; +use log::{error, info, warn}; use shared::error::TuliproxError; use shared::model::{PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, @@ -21,16 +19,11 @@ use shared::model::{PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamP use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info, Internable, PROVIDER_SCHEME_PREFIX}; use std::collections::HashMap; use std::io::Error; -use std::path::Path; use std::str::FromStr; -use crate::model::XtreamCategory; -use crate::utils::request::DynReader; -use fs2::FileExt; -use std::fs::File; use std::sync::Arc; -use shared::{info_err, notify_err, notify_err_res}; +use shared::info_err; const THREE_DAYS_IN_SECS: i64 = 3 * 24 * 60 * 60; @@ -148,7 +141,11 @@ pub async fn get_xtream_stream_info(client: &reqwest::Client, error!("Failed to persist series info for input {}: {err}", &input.name); } } - if let Some(mut episodes) = parse_xtream_series_info(&pli.get_uuid(), &series_stream_props, &group, &series_name, input) { + + // Capture release date for children + let series_release_date = series_stream_props.release_date.clone(); + + if let Some(mut episodes) = parse_xtream_series_info(&pli.get_uuid(), &series_stream_props, &group, &series_name, input, series_release_date.as_ref()) { let config = &app_state.app_config.config.load(); match get_target_storage_path(config, target.name.as_str()) { None => { @@ -400,11 +397,11 @@ pub async fn download_xtream_playlist(app_config: &Arc, client: &reqw (Ok(category_content), Ok(stream_content)) => { if cfg.disk_based_processing { // trace!("Using disk input playlist optimization for cluster {}", xtream_cluster); - if let Err(err) = process_xtream_cluster_to_disk(app_config, input, *xtream_cluster, category_content, stream_content).await { - error!("process_xtream_cluster_to_disk failed: {err}"); + if let Err(err) = persist_input_xtream_playlist_cluster_to_disk(app_config, input, *xtream_cluster, category_content, stream_content).await { + error!("persist_input_xtream_playlist_cluster_to_disk failed: {err}"); errors.push(err); - } else { - // trace!("process_xtream_cluster_to_disk succeeded for cluster {}", xtream_cluster); + //} else { + // trace!("persist_input_xtream_playlist_cluster_to_disk succeeded for cluster {}", xtream_cluster); } } else { // trace!("Using in-memory playlist parsing for cluster {}", xtream_cluster); @@ -513,195 +510,3 @@ pub fn create_vod_info_from_item(target: &ConfigTarget, user: &ProxyUserCredenti serde_json::to_string(&doc).unwrap_or(String::new()) } - -const BATCH_SIZE: usize = 1000; - -#[allow(clippy::too_many_lines)] -async fn process_xtream_cluster_to_disk( - app_config: &Arc, - input: &ConfigInput, - cluster: XtreamCluster, - categories: DynReader, - streams: DynReader, -) -> Result<(), TuliproxError> { - let cfg = app_config.config.load(); - let storage_path = ensure_input_storage_path(&cfg, &input.name).await?; - let xtream_path = xtream_get_file_path(&storage_path, cluster); - - // Channel for transferring items from Parser (Async Task) to Consumer (Blocking Task) - let (tx, mut rx) = tokio::sync::mpsc::channel::(BATCH_SIZE * 2); - let input_clone = input.clone(); - - // 1. Parser Task: Runs the async parsing logic - // We move the readers into this task. - let parse_task = tokio::spawn(async move { - let tx_for_closure = tx.clone(); - let res = xtream::parse_xtream_streaming(&input_clone, cluster, categories, streams, move |item| { - // FIX: Copy/Clone needed data BEFORE moving the 'item' into the channel - let item_id = item.virtual_id; - - // We use blocking_send because the closure provided by the parser library is synchronous. - // This is safe here because it runs within its own tokio::spawn task. - if let Err(e) = tx_for_closure.blocking_send(item) { - error!("Channel closed while processing {cluster} for item {item_id}: {e}"); - return notify_err_res!("Channel closed while processing {cluster}"); - } - Ok(()) - }).await; - - // CRITICAL: Explicitly drop the sender to signal rx.blocking_recv() to stop. - // This prevents the consumer from waiting forever if the parser fails. - drop(tx); - res - }); - - // 2. Consumer Task: Handles heavy Disk I/O (BPlusTree updates) - let xtream_path_for_consumer = xtream_path.clone(); - let consumer_task = tokio::task::spawn_blocking(move || { - let tmp_xtream_path = xtream_path_for_consumer.with_extension("tmp"); - - crate::repository::BPlusTree::::new() - .store(&tmp_xtream_path) - .map_err(|e| { - error!("Failed to initialize ghost BPlusTree at {}: {e}", tmp_xtream_path.display()); - notify_err!("Init tree error {e}") - })?; - - let mut tree: BPlusTreeUpdate = BPlusTreeUpdate::try_new_with_backoff(&tmp_xtream_path) - .map_err(|e| { - error!("Failed to open ghost tree at {}: {e}", tmp_xtream_path.display()); - notify_err!("Failed to open tree {e}") - })?; - tree.set_flush_policy(FlushPolicy::Batch); - - let mut buffer = Vec::with_capacity(BATCH_SIZE); - - // This loop exits when all 'tx' clones are dropped (signaling end of stream) - while let Some(item) = rx.blocking_recv() { - buffer.push(item); - if buffer.len() >= BATCH_SIZE { - let batch: Vec<(&u32, &XtreamPlaylistItem)> = buffer.iter().map(|i| (&i.provider_id, i)).collect(); - let prepared = BPlusTreeUpdate::::prepare_upsert_batch(&batch).map_err(|e| { - error!("Batch prepare failed for cluster {cluster}: {e}"); - notify_err!("Prepare failed {e}") - })?; - tree.upsert_batch_encoded(prepared).map_err(|e| { - error!("Batch upsert failed for cluster {cluster}: {e}"); - notify_err!("Upsert failed {e}") - })?; - buffer.clear(); - } - } - - // Final batch processing - if !buffer.is_empty() { - let batch: Vec<(&u32, &XtreamPlaylistItem)> = buffer.iter().map(|i| (&i.provider_id, i)).collect(); - let prepared = BPlusTreeUpdate::::prepare_upsert_batch(&batch).map_err(|e| { - error!("Final batch prepare failed for cluster {cluster}: {e}"); - notify_err!("Prepare failed {e}") - })?; - tree.upsert_batch_encoded(prepared).map_err(|e| { - error!("Final batch upsert failed for cluster {cluster}: {e}"); - notify_err!("Upsert failed {e}") - })?; - } - - tree.commit().map_err(|e| { - error!("Commit failed for cluster {cluster}: {e}"); - notify_err!("Commit failed {e}") - })?; - Ok::<(), TuliproxError>(()) - }); - - // 3. Robust Joining of both tasks - // try_join! returns immediately if any task returns an error or panics. - let (parse_res, consumer_res) = tokio::try_join!(parse_task, consumer_task) - .map_err(|e| notify_err!("Task join error during cluster {cluster} update: {e}"))?; - - // Handle internal errors from the tasks - let parsed_categories = parse_res?; - consumer_res?; - - // --- Post-Processing & Atomic Swap --- - - let col_path = match cluster { - XtreamCluster::Live => get_live_cat_collection_path(&storage_path), - XtreamCluster::Video => get_vod_cat_collection_path(&storage_path), - XtreamCluster::Series => get_series_cat_collection_path(&storage_path), - }; - - let tmp_col_path = col_path.with_extension("tmp"); - - // Use the parsed_categories returned from the task, not the 'categories' reader - save_xtream_categories_to_file(&tmp_col_path, &parsed_categories).await?; - - let tmp_xtream_path = xtream_path.with_extension("tmp"); - - // Acquire file lock to ensure atomic operations - let swap_lock = app_config.file_locks.write_lock(&xtream_path).await; - - // Optional compaction to optimize the newly created database file. - // Run on blocking pool because B+Tree lock backoff uses std::thread::sleep. - let compact_path = tmp_xtream_path.clone(); - match tokio::task::spawn_blocking(move || -> Result<(), std::io::Error> { - if let Ok(mut tree_update) = BPlusTreeUpdate::::try_new_with_backoff(&compact_path) { - tree_update.compact(&compact_path)?; - } - Ok(()) - }).await { - Ok(Ok(())) => {} - Ok(Err(e)) => { - error!("Failed to compact temporary database for {cluster} at {:?}: {e}", tmp_xtream_path.display()); - } - Err(e) => { - error!("Compaction task join failed for {cluster} at {:?}: {e}", tmp_xtream_path.display()); - } - } - - // Atomic Swap: Replace old database with new one - if let Err(e) = crate::utils::rename_or_copy(&tmp_xtream_path, &xtream_path, false) { - error!("Failed to swap xtream database for {cluster} (from {:?} to {:?}): {e}", tmp_xtream_path.display(), xtream_path.display()); - return notify_err_res!("Failed to swap database: {e}"); - } - - // Atomic Swap: Replace old categories with new ones - if let Err(e) = crate::utils::rename_or_copy(&tmp_col_path, &col_path, false) { - error!("Failed to swap xtream categories for {cluster} (from {:?} to {:?}): {e}", tmp_col_path.display(), col_path.display()); - return notify_err_res!("Failed to swap categories: {e}"); - } - - // Cleanup: Remove temporary files if they still exist (defensive) - let _ = tokio::fs::remove_file(&tmp_xtream_path).await; - let _ = tokio::fs::remove_file(&tmp_col_path).await; - - // Explicitly release the lock (though RAII would handle it too) - drop(swap_lock); - - debug!("Cluster {cluster} updated and swapped successfully."); - Ok(()) -} - -async fn save_xtream_categories_to_file(col_path: &Path, categories: &[XtreamCategory]) -> Result<(), TuliproxError> { - let col_path_buf = col_path.to_path_buf(); - let cat_entries: Vec = categories.iter().map(|c| CategoryEntry { - category_id: c.category_id, - category_name: c.category_name.clone(), - parent_id: 0, - }).collect(); - - tokio::task::spawn_blocking(move || { - if let Ok(file) = File::create(&col_path_buf) { - if let Err(e) = file.lock_exclusive() { - warn!("Could not acquire exclusive lock for {}: {e}, proceeding without lock", col_path_buf.display()); - } - serde_json::to_writer(&file, &cat_entries).map_err(|e| { - error!("Failed to write categories to file {}: {e}", col_path_buf.display()); - notify_err!("Write failed: {e}") - })?; - let _ = file.unlock(); - } else { - return notify_err_res!("Failed to create category file {}", col_path_buf.display()); - } - Ok(()) - }).await.map_err(|e| notify_err!("Spawn error {e}"))? -} diff --git a/backend/src/utils/telegram.rs b/backend/src/utils/telegram.rs index aa832b147..2a1fdc2f1 100644 --- a/backend/src/utils/telegram.rs +++ b/backend/src/utils/telegram.rs @@ -1,7 +1,10 @@ -use std::sync::Arc; -use log::{debug, error}; -use url::Url; use crate::model::AppConfig; +use log::{debug, error}; +use std::sync::Arc; +use url::Url; +use shared::utils::CONSTANTS; + +const MAX_MESSAGE_LENGTH: usize = 4000; /// Requests will be sent according to bot instance. #[derive(Clone)] @@ -22,6 +25,7 @@ struct TelegramErrorResult { } /// Parse mode for `sendMessage` API +#[derive(Clone, Copy, Debug, PartialEq)] pub enum SendMessageParseMode { MarkdownV2, HTML, @@ -32,7 +36,7 @@ pub struct SendMessageOption { pub parse_mode: SendMessageParseMode, } -fn get_send_message_parse_mode_str(mode: &SendMessageParseMode) -> &'static str { +fn get_send_message_parse_mode_str(mode: SendMessageParseMode) -> &'static str { match mode { SendMessageParseMode::MarkdownV2 => "MarkdownV2", SendMessageParseMode::HTML => "HTML", @@ -80,34 +84,343 @@ pub async fn telegram_send_message( } }; - let request_json_obj = RequestObj { - chat_id: instance.chat_id.clone(), - message_thread_id: instance.message_thread_id.clone(), - text: msg.to_string(), - parse_mode: options - .map(|o| get_send_message_parse_mode_str(&o.parse_mode)) - .map(ToString::to_string), - }; + let parse_mode = options.map(|o| o.parse_mode); + let chunks = chunk_message(msg, parse_mode); - let result = client - .post(url) - .json(&request_json_obj) - .send() - .await; + for (i, chunk_text) in chunks.iter().enumerate() { + let request_json_obj = RequestObj { + chat_id: instance.chat_id.clone(), + message_thread_id: instance.message_thread_id.clone(), + text: chunk_text.clone(), + parse_mode: options + .map(|o| get_send_message_parse_mode_str(o.parse_mode)) + .map(ToString::to_string), + }; - match result { - Ok(response) => { - if response.status().is_success() { - debug!("Message sent successfully to {chat_id} telegram api"); - } else { - match response.json::().await { - Ok(json) => error!("Message wasn't sent to {chat_id} telegram api because of: {}", json.description), - Err(_) => error!("Message wasn't sent to {chat_id} telegram api. Telegram response could not be parsed!"), + let result = client + .post(url.clone()) + .json(&request_json_obj) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await; + + match result { + Ok(response) => { + if response.status().is_success() { + debug!("Message chunk {}/{} sent successfully to {chat_id} telegram api", i + 1, chunks.len()); + } else { + match response.json::().await { + Ok(json) => error!("Message chunk {}/{} wasn't sent to {chat_id} telegram api because of: {}", i + 1, chunks.len(), json.description), + Err(_) => error!("Message chunk {}/{} wasn't sent to {chat_id} telegram api. Telegram response could not be parsed!", i + 1, chunks.len()), + } } } - }, - Err(e) => error!("Message wasn't sent to {chat_id} telegram api because of: {e}"), + Err(e) => error!("Message chunk {}/{} wasn't sent to {chat_id} telegram api because of: {e}", i + 1, chunks.len()), + } + + // Small delay between chunks to be polite to the API + if i < chunks.len() - 1 { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } } } +/// Chunks the message respecting the parse mode and `MAX_MESSAGE_LENGTH`. +fn chunk_message(text: &str, parse_mode: Option) -> Vec { + match parse_mode { + Some(SendMessageParseMode::HTML) => chunk_html(text, MAX_MESSAGE_LENGTH), + Some(SendMessageParseMode::MarkdownV2) => chunk_markdown_v2(text, MAX_MESSAGE_LENGTH), + None => chunk_plain_text(text, MAX_MESSAGE_LENGTH), + } +} +fn chunk_plain_text(text: &str, limit: usize) -> Vec { + let chars: Vec = text.chars().collect(); + chars.chunks(limit) + .map(|chunk| chunk.iter().collect::()) + .collect() +} + +// Just a basic list of void tags that don't need closing. +const HTML_VOID_TAGS: &[&str] = &["br", "hr", "img", "input", "meta", "area", "base", "col", "embed", "link", "param", "source", "track", "wbr"]; + +fn chunk_html(text: &str, limit: usize) -> Vec { + let mut chunks = Vec::new(); + let mut current_chunk = String::new(); + let mut open_tags: Vec<(String, String)> = Vec::new(); // (tag_name, full_opening_tag) + + let mut last_pos = 0; + + for cap in CONSTANTS.re_html_tag.captures_iter(text) { + let m = cap.get(0).unwrap(); + let tag_name = cap.get(1).unwrap().as_str().to_lowercase(); + let full_tag = m.as_str(); + let start = m.start(); + let end = m.end(); + + // Append preceding text + let pre_text = &text[last_pos..start]; + append_text_checking_limit(&mut chunks, &mut current_chunk, pre_text, limit, &mut open_tags, close_html_tags, open_html_tags); + + // Process tag + let is_closing = full_tag.starts_with(" + } + if current_chunk.len() + full_tag.len() + closing_overhead > limit { + // Force split before tag + chunks.push(finalize_chunk(&mut current_chunk, &open_tags, close_html_tags)); + current_chunk = open_html_tags(&open_tags); + } + + current_chunk.push_str(full_tag); + + if !is_void { + if is_closing { + // Remove matching from stack (scanning backwards) + if let Some(pos) = open_tags.iter().rposition(|(t, _)| *t == tag_name) { + open_tags.remove(pos); + } + } else { + // Open tag + open_tags.push((tag_name, full_tag.to_string())); + } + } + + last_pos = end; + } + + // Append remaining text + let remaining_text = &text[last_pos..]; + append_text_checking_limit(&mut chunks, &mut current_chunk, remaining_text, limit, &mut open_tags, close_html_tags, open_html_tags); + + if !current_chunk.is_empty() { + // Close any remaining open tags for robustness (empty if input was valid) + chunks.push(finalize_chunk(&mut current_chunk, &open_tags, close_html_tags)); + } + + chunks +} + +fn close_html_tags(tags: &[(String, String)]) -> String { + tags.iter().rev().fold(String::new(), |mut acc, (name, _)| { + acc.push_str("'); + acc + }) +} + +fn open_html_tags(tags: &[(String, String)]) -> String { + tags.iter().map(|(_, full)| full.clone()).collect() +} + +fn calculate_html_closing_overhead(tags: &[(String, String)]) -> usize { + tags.iter().map(|(name, _)| name.len() + 3).sum() // +} + +// MarkdownV2 markers +// *bold*, _italic_, __underline__, ~strikethrough~, ||spoiler||, `code`, ```pre```, [link](url) +// We need to track * _ __ ~ || ` ``` [ +// Escaping is important: \char +fn chunk_markdown_v2(text: &str, limit: usize) -> Vec { + // This is a naive state machine parser for splitting. + // It detects special markers that are NOT escaped. + + let mut chunks = Vec::new(); + let mut current_chunk = String::new(); + // Stack of open markers: string representation of the marker + let mut open_markers: Vec = Vec::new(); + + let mut chars = text.chars().peekable(); + + while let Some(c) = chars.next() { + let mut token = String::new(); + token.push(c); + + if c == '\\' { + // Escaped content, take next char too if exists + if let Some(next) = chars.next() { + token.push(next); + } + // Escaped chars are just text, no special meaning + } else { + // Check for potential markers + // We need to lookahead for multi-char markers: __, ||, ``` + // And [ for links. + // This is simplified. Links [text](url) are hard to split. We treat [ as open, ] as close? + // Actually, splitting inside valid Markdown structure is dangerous. + // We mainly care about style markers: *, _, ~, ||, ` + + // Handle multi-char markers + let mut handled = false; + if c == '`' { + let mut lookahead = chars.clone(); + if lookahead.next() == Some('`') && lookahead.next() == Some('`') { + chars.next(); + chars.next(); + token = "```".to_string(); + handled = true; + } + } + if !handled { + if let Some(&next_c) = chars.peek() { + let double = format!("{c}{next_c}"); + if double == "__" || double == "||" { + chars.next(); // consume next + token = double; + } + } + } + + // Logic to update `open_markers` + let is_marker = matches!(token.as_str(), "*" | "_" | "~" | "__" | "||" | "`" | "```"); + if is_marker { + if let Some(last) = open_markers.last() { + if last == &token { + open_markers.pop(); + } else { + open_markers.push(token.clone()); + } + } else { + open_markers.push(token.clone()); + } + } + } + + // Logic to check limit + let closing_overhead: usize = open_markers.iter().rev().map(std::string::String::len).sum(); + if current_chunk.len() + token.len() + closing_overhead > limit { + chunks.push(finalize_chunk(&mut current_chunk, &open_markers, |tags| tags.iter().rev().cloned().collect())); + current_chunk = open_markers.iter().cloned().collect(); // Reopen + } + + current_chunk.push_str(&token); + } + + if !current_chunk.is_empty() { + chunks.push(current_chunk); + } + + chunks +} + +// Generic helper to append text handling splits +fn append_text_checking_limit( + chunks: &mut Vec, + current_chunk: &mut String, + text: &str, + limit: usize, + open_tags: &mut [T], + close_fn: FClose, + open_fn: FOpen, +) where + FClose: Fn(&[T]) -> String, + FOpen: Fn(&[T]) -> String, +{ + let mut remaining_text = text; + while !remaining_text.is_empty() { + let closing_overhead = close_fn(open_tags).len(); + let space_left = if limit > (current_chunk.len() + closing_overhead) { + limit - (current_chunk.len() + closing_overhead) + } else { + 0 + }; + + if remaining_text.len() <= space_left { + current_chunk.push_str(remaining_text); + break; + } + // Take as much as fits + let (take, rest) = split_at_char_boundary(remaining_text, space_left); + if take.is_empty() && !current_chunk.is_empty() { + // Cannot fit even one char, force split + chunks.push(finalize_chunk(current_chunk, open_tags, &close_fn)); + *current_chunk = open_fn(open_tags); + continue; + } + + current_chunk.push_str(take); + chunks.push(finalize_chunk(current_chunk, open_tags, &close_fn)); + *current_chunk = open_fn(open_tags); + remaining_text = rest; + } +} + +fn split_at_char_boundary(s: &str, mut idx: usize) -> (&str, &str) { + if idx >= s.len() { + return (s, ""); + } + while !s.is_char_boundary(idx) { + idx -= 1; + } + s.split_at(idx) +} + +fn finalize_chunk(chunk: &mut String, open_tags: &[T], close_fn: F) -> String +where + F: Fn(&[T]) -> String, +{ + let closers = close_fn(open_tags); + chunk.push_str(&closers); + let result = chunk.clone(); + chunk.clear(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chunk_plain_text() { + let text = "abcdefgh"; + let chunks = chunk_plain_text(text, 3); + assert_eq!(chunks, vec!["abc", "def", "gh"]); + } + + #[test] + fn test_chunk_html_simple_split() { + let text = "Hello World"; + // Limit 10. + // "Hello " is 8 chars. Closing "" is 4. overhead 4. + // "Hel" (6) + overhead(4) = 10 <= 10. + // So chunk 1: "Hel" (10 chars exactly) + // intermediate text splits... + // The final closing tag results in an extra empty bold tag because the previous text split forced a close/reopen. + let chunks = chunk_html(text, 10); + // Correct behavior check + assert!(chunks.iter().all(|c| c.len() <= 10)); + assert_eq!(chunks.join(""), "Hello World"); + // Wait, join won't match exactly because we introduced extra tags. + // But visually it should be equivalent content. + } + + #[test] + fn test_chunk_html_nested() { + let text = "BoldItalic"; + // Limit 15 + let chunks = chunk_html(text, 15); + assert!(chunks.iter().all(|c| c.len() <= 15)); + // Expect split preserving nesting + } + + #[test] + fn test_chunk_markdown_v2_simple() { + let text = "*bold*"; + let chunks = chunk_markdown_v2(text, 3); + // *b* + // *o* + // *l* + // *d* ?? + // open overhead is 1 (*). close overhead 1. + // *b* is 3. + assert!(chunks.iter().all(|c| c.len() <= 3)); + } +} \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e03f227f..109482fa4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,6 +4,12 @@ # # ================================================================= +# ----------------------------------------------------------------- +# Stage 0: Fetch static FFmpeg binaries +# ----------------------------------------------------------------- +# We use a static build to ensure it works in the scratch image without libraries +FROM mwader/static-ffmpeg:7.1 AS ffmpeg-static + # ----------------------------------------------------------------- # Stage 1: Build the Rust binary for production # ----------------------------------------------------------------- @@ -85,7 +91,7 @@ WORKDIR /src/frontend RUN trunk build --release # ----------------------------------------------------------------- -# Stage 3: Build video resources with ffmpeg +# Stage 3: Build video resources with ffmpeg (from linuxserver used for build time only) # ----------------------------------------------------------------- FROM linuxserver/ffmpeg:latest AS resource-build @@ -136,10 +142,11 @@ ARG TZ=UTC ENV RUST_TARGET=${RUST_TARGET} ENV TZ=${TZ} -# Install build dependencies and debugger for Alpine. +# Install build dependencies, debugger and ffmpeg for Alpine. # 'build-base' contains gcc, make, etc. # 'gdb' is required for the debugger (lldb is often more complicated in Alpine repos) -RUN apk add --no-cache bash build-base openssl-dev perl lldb gdb +# 'ffmpeg' is required for the probe feature +RUN apk add --no-cache bash build-base openssl-dev perl lldb gdb ffmpeg # Update Rust toolchain and add the necessary target RUN rustup update && rustup target add $RUST_TARGET @@ -178,6 +185,10 @@ COPY --from=tz-prep /output/etc/localtime /etc/localtime # Copy SSL certificates from a builder stage that has them COPY --from=rust-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +# Copy static ffmpeg/ffprobe binaries +COPY --from=ffmpeg-static /ffmpeg /usr/local/bin/ +COPY --from=ffmpeg-static /ffprobe /usr/local/bin/ + # App WORKDIR /app COPY --from=rust-build /src/target/${RUST_TARGET}/release/tuliprox ./tuliprox @@ -196,7 +207,8 @@ ARG RUST_TARGET=x86_64-unknown-linux-musl ARG TZ=UTC ENV TZ=${TZ} -RUN apk add --no-cache bash curl ca-certificates tini +# Install dependencies including ffmpeg +RUN apk add --no-cache bash curl ca-certificates tini ffmpeg # Copy timezone data and localtime from tz-prep COPY --from=tz-prep /output/usr/share/zoneinfo /usr/share/zoneinfo @@ -214,4 +226,4 @@ COPY --from=trunk-build /src/frontend/dist ./web COPY --from=resource-build /src/resources ./resources ENTRYPOINT ["/sbin/tini", "--", "/app/tuliprox"] -CMD ["-s", "-p", "/app/config"] +CMD ["-s", "-p", "/app/config"] \ No newline at end of file diff --git a/docker/Dockerfile-cross b/docker/Dockerfile-cross index 8fcb2c54f..651a1b4a7 100644 --- a/docker/Dockerfile-cross +++ b/docker/Dockerfile-cross @@ -7,6 +7,13 @@ # # ================================================================= +# ----------------------------------------------------------------- +# Stage 0: Fetch static FFmpeg binaries +# ----------------------------------------------------------------- +# We use a static build to ensure it works in the scratch image without libraries +# Static ffmpeg image is multi-arch, so it pulls correct arch automatically +FROM mwader/static-ffmpeg:7.1 AS ffmpeg-static + # ----------------------------------------------------------------- # Stage 1: Build the Rust binary for production # ----------------------------------------------------------------- @@ -156,7 +163,8 @@ ENV TZ=${TZ} # Install build dependencies and the lldb debugger for Alpine # Added 'perl' which is required to build the native OpenSSL library. -RUN apk add --no-cache bash build-base openssl-dev lldb gdb perl +# Added ffmpeg for probing +RUN apk add --no-cache bash build-base openssl-dev lldb gdb perl ffmpeg # Update Rust toolchain and add the necessary target RUN rustup update && rustup target add $RUST_TARGET @@ -193,6 +201,10 @@ COPY --from=tz-prep /output/etc/localtime /etc/localtime # Certificates COPY --from=rust-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +# Copy static ffmpeg/ffprobe binaries +COPY --from=ffmpeg-static /ffmpeg /usr/local/bin/ +COPY --from=ffmpeg-static /ffprobe /usr/local/bin/ + # RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime # App @@ -213,7 +225,8 @@ ARG RUST_TARGET ARG TZ=UTC ENV TZ=${TZ} -RUN apk add --no-cache bash curl ca-certificates tini +# Install dependencies including ffmpeg +RUN apk add --no-cache bash curl ca-certificates tini ffmpeg COPY --from=rust-build /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=rust-build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ @@ -227,4 +240,4 @@ COPY --from=trunk-build /src/frontend/dist ./web COPY --from=resource-build /src/resources ./resources ENTRYPOINT ["/sbin/tini", "--", "/app/tuliprox"] -CMD ["-s", "-p", "/app/config"] +CMD ["-s", "-p", "/app/config"] \ No newline at end of file diff --git a/docker/Dockerfile-manual b/docker/Dockerfile-manual index ce65c1adf..73be5990e 100644 --- a/docker/Dockerfile-manual +++ b/docker/Dockerfile-manual @@ -11,6 +11,9 @@ RUN apk add --no-cache tzdata \ && mkdir -p /output/etc/ssl/certs \ && cp /etc/ssl/certs/ca-certificates.crt /output/etc/ssl/certs/ca-certificates.crt +# Fetch static FFmpeg binaries +FROM mwader/static-ffmpeg:7.1 AS ffmpeg-static + # Binary selector stage FROM alpine:latest AS binary-selector @@ -39,6 +42,10 @@ COPY --from=tz-prep /output/usr/share/zoneinfo /usr/share/zoneinfo COPY --from=tz-prep /output/etc/localtime /etc/localtime COPY --from=tz-prep /output/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +# Copy static ffmpeg/ffprobe binaries +COPY --from=ffmpeg-static /ffmpeg /usr/local/bin/ +COPY --from=ffmpeg-static /ffprobe /usr/local/bin/ + # RUN ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime WORKDIR /app @@ -56,7 +63,8 @@ FROM alpine:latest AS alpine-final ARG TZ=UTC ENV TZ=${TZ} -RUN apk add --no-cache bash curl strace tcpdump bind-tools nano ca-certificates tini +# Install dependencies including ffmpeg +RUN apk add --no-cache bash curl strace tcpdump bind-tools nano ca-certificates tini ffmpeg # Copy timezone data and localtime from tz-prep COPY --from=tz-prep /output/usr/share/zoneinfo /usr/share/zoneinfo diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 47a1daf31..56ee6b21c 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -224,6 +224,8 @@ "SKIP": "Skip", "LIVE_STREAM_USE_PREFIX": "Live use prefix", "LIVE_STREAM_WITHOUT_EXTENSION": "Live w/o ext.", + "RESOLVE_TMDB": "Resolve TMDB", + "PROBE_STREAM": "Probe Stream", "ALIAS": "Alias", "INPUTS": "Inputs", "NEW_USER": "New User", @@ -364,6 +366,9 @@ "ADD_WATCH": "Add Watch", "ADD_MAPPING": "Add Mapping", "RESOLVE_DELAY_SEC": "Resolve Delay (sec)", + "RESOLVE_BACKGROUND": "Resolve in Background", + "PROBE": "Probe", + "PROBE_LIVE_INTERVAL_HOURS": "Live Interval (hour)", "COPY_CREDENTIALS": "Copy Credentials", "EXPORT_STYLE": "Export Style", "STRM_PROPS": "Properties", @@ -419,9 +424,10 @@ "API_PROXY_CONFIG": "Api Proxy Config", "USE_USER_DB": "Use User DB", "ADD_SERVER": "Add Server", + "FFPROBE_ENABLED": "FFprobe Enabled", + "FFPROBE_TIMEOUT": "FFprobe Timeout (sec)", "FAILOVER_REDIRECT_PATTERNS": "Failover Redirect Patterns", "ADD_PATTERN": "Add Pattern" - }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" @@ -819,4 +825,4 @@ "ZM": "Zambia", "ZW": "Zimbabwe" } -} \ No newline at end of file +} diff --git a/frontend/scss/app/components/_titled_card.scss b/frontend/scss/app/components/_titled_card.scss index c36731c31..9996a2d24 100644 --- a/frontend/scss/app/components/_titled_card.scss +++ b/frontend/scss/app/components/_titled_card.scss @@ -3,15 +3,21 @@ padding: 1em 0.5em 0.5em 0.5em; border: 1px solid var(--card-border-color); border-radius: var(--border-radius); -} + &__title { + position: absolute; + top: -0.7em; + left: 15px; + padding: 0 4px; + z-index: 2; + font-size: 14px; + line-height: 1.2; + background: linear-gradient(to bottom, #00000000 50%, var(--card-background-color) 50%); + } -.tp__titled-card__title { - position: absolute; - top: -0.7em; - left: 15px; - padding: 0 4px; - z-index: 2; - font-size: 14px; - line-height: 1.2; - background: linear-gradient(to bottom, #00000000 50%, var(--card-background-color) 50%); + &__content { + display: flex; + flex-flow: column; + gap: var(--gap-default); + box-sizing: border-box; + } } diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index ecde03532..d5b014b50 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -136,3 +136,12 @@ .clickable { cursor: pointer; } + +.tp__form-list__item-arrow-btn { + svg { + transform: scale(2); + } +} +.tp__form-list__item-placeholder-btn { + width: 28px; +} \ No newline at end of file diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index 50423268e..cf6d926b0 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -361,6 +361,31 @@ macro_rules! edit_field_number_i16 { }}; } +#[macro_export] +macro_rules! edit_field_number_u32 { + ($instance:expr, $label:expr, $field:ident, $action:path) => {{ + let instance = $instance.clone(); + html! { +
+ <$crate::app::components::number_input::NumberInput + label={$label} + name={stringify!($field)} + value={instance.form.$field as i64} + on_change={Callback::from(move |value: Option| { + match value { + Some(value) => { + let val = u32::try_from(value).unwrap_or(0); + instance.dispatch($action(val)) + }, + None => instance.dispatch($action(0)), + } + })} + /> +
+ } + }}; +} + #[macro_export] macro_rules! edit_field_number_u64 { ($instance:expr, $label:expr, $field:ident, $action:path) => {{ @@ -412,7 +437,7 @@ macro_rules! edit_field_number_usize { } #[macro_export] -macro_rules! edit_field_number_option { +macro_rules! edit_field_number_option_u32 { ($instance:expr, $label:expr, $field:ident, $action:path) => {{ let instance = $instance.clone(); html! { @@ -436,6 +461,31 @@ macro_rules! edit_field_number_option { }}; } +#[macro_export] +macro_rules! edit_field_number_option_u64 { + ($instance:expr, $label:expr, $field:ident, $action:path) => {{ + let instance = $instance.clone(); + html! { +
+ <$crate::app::components::number_input::NumberInput + label={$label} + name={stringify!($field)} + value={instance.form.$field.map(|v:u64| v.min(i64::MAX as u64) as i64)} + on_change={Callback::from(move |value: Option| { + match value { + Some(value) => match u64::try_from(value) { + Ok(val) => instance.dispatch($action(Some(val))), + Err(_) => return, + }, + None => instance.dispatch($action(None)), + } + })} + /> +
+ } + }}; +} + #[macro_export] macro_rules! edit_field_date { ($instance:expr, $label:expr, $field:ident, $action:path) => {{ diff --git a/frontend/src/app/components/config/main_config_view.rs b/frontend/src/app/components/config/main_config_view.rs index 77303e07c..89b33d26c 100644 --- a/frontend/src/app/components/config/main_config_view.rs +++ b/frontend/src/app/components/config/main_config_view.rs @@ -5,7 +5,7 @@ use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::config::config_page::{ConfigForm, LABEL_MAIN_CONFIG}; use crate::{config_field_optional, config_field_bool, config_field, edit_field_text_option, edit_field_bool, - generate_form_reducer, edit_field_number, edit_field_number_option, edit_field_text}; + generate_form_reducer, edit_field_number, edit_field_number_option_u32, edit_field_text}; const LABEL_UPDATE_ON_BOOT: &str = "LABEL.UPDATE_ON_BOOT"; const LABEL_CONFIG_HOT_RELOAD: &str = "LABEL.CONFIG_HOT_RELOAD"; @@ -109,7 +109,7 @@ pub fn MainConfigView() -> Html { { edit_field_text_option!(form_state, translate.t(LABEL_MAPPING_PATH), mapping_path, MainConfigFormAction::MappingPath) } { edit_field_text_option!(form_state, translate.t(LABEL_BACKUP_DIR), backup_dir, MainConfigFormAction::BackupDir) } { edit_field_text_option!(form_state, translate.t(LABEL_USER_CONFIG_DIR), user_config_dir, MainConfigFormAction::UserConfigDir) } - { edit_field_number_option!(form_state, translate.t(LABEL_SLEEP_TIMER_MINS), sleep_timer_mins, MainConfigFormAction::SleepTimerMins) } + { edit_field_number_option_u32!(form_state, translate.t(LABEL_SLEEP_TIMER_MINS), sleep_timer_mins, MainConfigFormAction::SleepTimerMins) } { edit_field_number!(form_state, translate.t(LABEL_CONNECT_TIMEOUT_SECS), connect_timeout_secs, MainConfigFormAction::ConnectTimeoutSecs) } { edit_field_text_option!(form_state, translate.t(LABEL_CUSTOM_STREAM_RESPONSE_PATH), custom_stream_response_path, MainConfigFormAction::CustomStreamResponsePath) } diff --git a/frontend/src/app/components/config/video_config_view.rs b/frontend/src/app/components/config/video_config_view.rs index 9cde0265f..cc4c74ba7 100644 --- a/frontend/src/app/components/config/video_config_view.rs +++ b/frontend/src/app/components/config/video_config_view.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use crate::app::components::{Card, Chip, KeyValueEditor}; use crate::app::context::ConfigContext; -use crate::{config_field_bool, config_field_child, config_field_optional, edit_field_bool, edit_field_text_option, - edit_field_list, generate_form_reducer}; +use crate::{config_field_bool, config_field_child, config_field_optional, edit_field_bool, + edit_field_text_option, edit_field_list, generate_form_reducer, edit_field_number_option_u64}; use shared::model::{VideoDownloadConfigDto, VideoConfigDto}; use yew::prelude::*; use yew_i18n::use_translation; @@ -17,6 +17,9 @@ const LABEL_HEADERS: &str = "LABEL.HEADERS"; const LABEL_EXTENSIONS: &str = "LABEL.EXTENSIONS"; const LABEL_WEB_SEARCH: &str = "LABEL.WEB_SEARCH"; const LABEL_ADD_EXTENSION: &str = "LABEL.ADD_EXTENSION"; +const LABEL_FFPROBE_ENABLED: &str = "LABEL.FFPROBE_ENABLED"; +const LABEL_FFPROBE_TIMEOUT: &str = "LABEL.FFPROBE_TIMEOUT"; + generate_form_reducer!( state: VideoDownloadConfigFormState { form: VideoDownloadConfigDto }, @@ -35,6 +38,8 @@ generate_form_reducer!( fields { WebSearch => web_search: Option, Extensions => extensions: Vec, + FfprobeEnabled => ffprobe_enabled: bool, + FfprobeTimeout => ffprobe_timeout: Option, } ); @@ -124,6 +129,10 @@ pub fn VideoConfigView() -> Html {
{ config_field_optional!(video_state.form, translate.t(LABEL_WEB_SEARCH), web_search) }
+
+ { config_field_bool!(video_state.form, translate.t(LABEL_FFPROBE_ENABLED), ffprobe_enabled) } + { config_field_optional!(video_state.form, translate.t(LABEL_FFPROBE_TIMEOUT), ffprobe_timeout) } +
{ render_extensions(&video_state.form.extensions) } { render_download_view() } @@ -138,6 +147,10 @@ pub fn VideoConfigView() -> Html {
{ edit_field_text_option!(video_state, translate.t(LABEL_WEB_SEARCH), web_search, VideoConfigFormAction::WebSearch) }
+
+ { edit_field_bool!(video_state, translate.t(LABEL_FFPROBE_ENABLED), ffprobe_enabled, VideoConfigFormAction::FfprobeEnabled) } + { edit_field_number_option_u64!(video_state, translate.t(LABEL_FFPROBE_TIMEOUT), ffprobe_timeout, VideoConfigFormAction::FfprobeTimeout) } +
{ edit_field_list!(video_state, translate.t(LABEL_EXTENSIONS), extensions, VideoConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) } diff --git a/frontend/src/app/components/playlist/input/input_options.rs b/frontend/src/app/components/playlist/input/input_options.rs index 2602fcc4c..225f46769 100644 --- a/frontend/src/app/components/playlist/input/input_options.rs +++ b/frontend/src/app/components/playlist/input/input_options.rs @@ -22,13 +22,17 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html { vec![ (false, "LABEL.LIVE_STREAM_USE_PREFIX"), (false, "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"), + (false, "LABEL.RESOLVE_TMDB"), + (false, "LABEL.PROBE_STREAM"), ]), 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_without_extension; + || options.xtream_live_stream_without_extension + || options.resolve_tmdb + || options.probe_stream; (has_options, vec![ (options.xtream_skip_live, "LABEL.LIVE"), @@ -37,6 +41,8 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html { vec![ (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"), ]) } }; @@ -61,4 +67,4 @@ pub fn InputOptions(props: &InputOptionsProps) -> Html {
} -} \ No newline at end of file +} diff --git a/frontend/src/app/components/playlist/target/xtream_output.rs b/frontend/src/app/components/playlist/target/xtream_output.rs index 59328f691..1f971822a 100644 --- a/frontend/src/app/components/playlist/target/xtream_output.rs +++ b/frontend/src/app/components/playlist/target/xtream_output.rs @@ -33,9 +33,23 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html { use_memo(output, move |output| { vec![ Rc::new(Tag { class: convert_bool_to_chip_style(output.resolve_series), - label: format!("{} / {}s", translate.t("LABEL.SERIES"), output.resolve_series_delay) }), + label: format!("{} / {}ms", translate.t("LABEL.SERIES"), output.resolve_delay) }), Rc::new(Tag { class: convert_bool_to_chip_style(output.resolve_vod), - label: format!("{} / {}s", translate.t("LABEL.VOD"), output.resolve_vod_delay)}), + 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() }), ] }) }; @@ -44,7 +58,9 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html {
{ html_if!(props.output.t_filter.is_some(), {
- })}> + })}> + +
}) }
@@ -55,6 +71,10 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html { {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 ff2c2d89a..37b09f687 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -2,8 +2,7 @@ use crate::app::components::source_editor::layout::layout; use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar, TextButton, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, BLOCK_WIDTH}; use crate::app::{ConfigContext, PlaylistContext}; use crate::hooks::use_service_context; -use shared::model::{ConfigInputDto, ConfigSourceDto, ConfigTargetDto, HdHomeRunTargetOutputDto, - M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; +use shared::model::{ConfigInputDto, ConfigSourceDto, ConfigTargetDto, HdHomeRunTargetOutputDto, InputType, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; @@ -191,11 +190,11 @@ impl EditorState { fn create_instance(block_type: BlockType) -> BlockInstance { match block_type { - BlockType::InputXtream => BlockInstance::Input(Rc::new(ConfigInputDto::default())), - BlockType::InputM3u => BlockInstance::Input(Rc::new(ConfigInputDto::default())), - BlockType::InputLibrary => BlockInstance::Input(Rc::new(ConfigInputDto::default())), + BlockType::InputXtream => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::Xtream))), + BlockType::InputM3u => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::M3u))), + BlockType::InputLibrary => BlockInstance::Input(Rc::new(ConfigInputDto::new_with_type(InputType::Library))), BlockType::Target => { - let dto = ConfigTargetDto { name: String::new(), ..Default::default() }; + let dto = ConfigTargetDto { name: String::new(), ..ConfigTargetDto::default() }; BlockInstance::Target(Rc::new(dto)) } BlockType::OutputM3u => BlockInstance::Output(Rc::new(TargetOutputDto::M3u(M3uTargetOutputDto::default()))), @@ -549,6 +548,7 @@ pub fn SourceEditor() -> Html { let handle_connection_drop = { let editor_state_ref = editor_state_ref.clone(); + let force_update = force_update.clone(); Callback::from(move |to_id: BlockId| { let pending_connection = editor_state_ref.borrow().pending_connection; if let Some(from_id) = pending_connection { @@ -575,6 +575,7 @@ pub fn SourceEditor() -> Html { } { editor_state_ref.borrow_mut().clear_pending(); + force_update.set(*force_update + 1); } }) }; diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index 01f439eb7..d510a93a9 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -2,7 +2,9 @@ use crate::app::components::config::HasFormData; use crate::app::components::key_value_editor::KeyValueEditor; use crate::app::components::select::Select; use crate::app::components::{AliasItemForm, BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, EpgSourceItemForm, IconButton, Panel, RadioButtonGroup, SourceEditorContext, TextButton, TitledCard}; -use crate::{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}; +use crate::{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}; +use shared::error::TuliproxError; +use shared::info_err_res; use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, EpgConfigDto, EpgSourceDto, InputFetchMethod, InputType, StagedInputDto}; use std::collections::HashMap; use std::fmt::Display; @@ -11,8 +13,6 @@ use std::str::FromStr; use web_sys::MouseEvent; use yew::{function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; -use shared::error::TuliproxError; -use shared::info_err_res; const LABEL_NAME: &str = "LABEL.NAME"; const LABEL_INPUT_TYPE: &str = "LABEL.INPUT_TYPE"; @@ -36,6 +36,9 @@ const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.VOD"; 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_METADATA: &str = "LABEL.METADATA"; const LABEL_CACHE_DURATION: &str = "LABEL.CACHE_DURATION"; const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG"; @@ -51,7 +54,7 @@ enum InputFormPage { Options, Staged, Advanced, - Alias + Alias, } impl InputFormPage { @@ -98,6 +101,8 @@ generate_form_reducer!( XtreamSkipSeries => xtream_skip_series: bool, XtreamLiveStreamUsePrefix => xtream_live_stream_use_prefix: bool, XtreamLiveStreamWithoutExtension => xtream_live_stream_without_extension: bool, + ResolveTmdb => resolve_tmdb: bool, + ProbeStream => probe_stream: bool, } ); @@ -219,10 +224,16 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let aliases_state = aliases_state.clone(); let headers_state = headers_state.clone(); - let config_input = props.input.clone(); - - use_effect_with(config_input, move |cfg| { + let deps = (props.block_id, props.input.clone()); + let view_visible = view_visible.clone(); + use_effect_with(deps, move |(_, cfg)| { if let Some(input) = cfg { + if input.input_type.is_library() + && matches!(*view_visible, InputFormPage::Staged | InputFormPage::Advanced) + { + view_visible.set(InputFormPage::Main); + } + input_form_state.dispatch(ConfigInputFormAction::SetAll(input.as_ref().clone())); input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll( @@ -354,6 +365,36 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }; + let handle_move_alias_up = { + let alias_list = aliases_state.clone(); + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + if let Ok(index) = idx.parse::() { + let mut items = (*alias_list).clone(); + if index > 0 && index < items.len() { + items.swap(index, index - 1); + alias_list.set(items); + } + } + }) + }; + + let handle_move_alias_down = { + let alias_list = aliases_state.clone(); + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + if let Ok(index) = idx.parse::() { + let mut items = (*alias_list).clone(); + if index + 1 < items.len() { + items.swap(index, index + 1); + alias_list.set(items); + } + } + }) + }; + let handle_remove_epg_source = { let epg_list = epg_sources_state.clone(); Callback::from(move |(idx, e): (String, MouseEvent)| { @@ -372,15 +413,24 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let render_options = || { html! { - -
- { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), xtream_skip_live, ConfigInputOptionsFormAction::XtreamSkipLive) } - { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), xtream_skip_vod, ConfigInputOptionsFormAction::XtreamSkipVod) } - { 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) } + { html_if!(input_form_state.form.input_type.is_xtream(), { + <> + +
+ { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), xtream_skip_live, ConfigInputOptionsFormAction::XtreamSkipLive) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), xtream_skip_vod, ConfigInputOptionsFormAction::XtreamSkipVod) } + { 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_RESOLVE_TMDB), resolve_tmdb, ConfigInputOptionsFormAction::ResolveTmdb) } + { edit_field_bool!(input_options_state, translate.t(LABEL_PROBE_STREAM), probe_stream, ConfigInputOptionsFormAction::ProbeStream) } +
} }; @@ -492,6 +542,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } else { { config_field_child!(translate.t(LABEL_ALIASES), { let aliases_list = aliases.clone(); + let alias_count = aliases_list.len(); html! {
@@ -500,6 +551,26 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { html! {
+ if idx > 0 { + + } else if alias_count > 2 { + + } + if idx + 1 < alias_count { + + } else if alias_count > 2 { + + } Html { />
} - })} - } - + }) + }} + } }; @@ -676,6 +747,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }; + let library_input = input_form_state.form.input_type.is_library(); + + let render_edit_mode = || { html! {
@@ -708,18 +782,24 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { {render_input()} - - {render_alias()} - + { html_if!(!library_input, { + + {render_alias()} + + })} {render_options()} - - {render_staged()} - - - {render_advanced()} - + { html_if!(!library_input, { + <> + + {render_staged()} + + + {render_advanced()} + + + })}
} @@ -729,10 +809,16 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { html! {
+ {html_if!(!library_input, { + })} + { html_if!(!library_input, { + <> + + })}
} }; 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 a10bed11b..c5e448037 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -1,6 +1,6 @@ use crate::app::components::config::HasFormData; use crate::app::components::{BlockId, BlockInstance, Card, EditMode, IconButton, Panel, SourceEditorContext, TextButton, TraktListItemForm, TitledCard, FilterInput}; -use crate::{config_field_child, edit_field_bool, edit_field_number_u16, edit_field_text, generate_form_reducer}; +use crate::{config_field_child, edit_field_bool, edit_field_number_u16, edit_field_number_u32, edit_field_text, generate_form_reducer}; use shared::model::{TargetOutputDto, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto, XtreamTargetOutputDto}; use std::fmt::Display; use std::rc::Rc; @@ -16,7 +16,10 @@ 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"; @@ -88,9 +91,13 @@ generate_form_reducer!( SkipVideoDirectSource => skip_video_direct_source: bool, SkipSeriesDirectSource => skip_series_direct_source: bool, ResolveSeries => resolve_series: bool, - ResolveSeriesDelay => resolve_series_delay: u16, ResolveVod => resolve_vod: bool, - ResolveVodDelay => resolve_vod_delay: u16, + 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, } ); @@ -224,15 +231,23 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
-
+
{ 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_VOD), resolve_vod_delay, XtreamTargetOutputFormAction::ResolveVodDelay) } - { edit_field_number_u16!(output_form_state, translate.t(LABEL_SERIES), resolve_series_delay, XtreamTargetOutputFormAction::ResolveSeriesDelay) } + { 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), { diff --git a/shared/src/model/config/api_user.rs b/shared/src/model/config/api_user.rs index 9a31749b4..700e4ea2b 100644 --- a/shared/src/model/config/api_user.rs +++ b/shared/src/model/config/api_user.rs @@ -81,6 +81,4 @@ impl ProxyUserCredentialsDto { } true } -} - - +} \ No newline at end of file diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index cb1664325..276a35a5c 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -224,6 +224,8 @@ impl ConfigDto { extensions: default_supported_video_extensions(), download: None, web_search: None, + ffprobe_enabled: false, + ffprobe_timeout: None, }); } Some(video) => { @@ -290,4 +292,4 @@ impl ConfigDto { self.library.as_ref().is_some_and(|l| l.enabled) } -} +} \ No newline at end of file diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index b9fb5922f..12f73e9c9 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -51,8 +51,7 @@ macro_rules! apply_batch_aliases { }}; } -#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, - PartialEq, Eq, Default)] +#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)] pub enum InputType { #[serde(rename = "m3u")] #[default] @@ -74,6 +73,16 @@ impl InputType { const M3U_BATCH: &'static str = "m3u_batch"; const XTREAM_BATCH: &'static str = "xtream_batch"; const LIBRARY: &'static str = "library"; + pub fn is_xtream(&self) -> bool { + matches!(self, Self::Xtream | Self::XtreamBatch) + } + pub fn is_m3u(&self) -> bool { + matches!(self, Self::M3u | Self::M3uBatch) + } + + pub fn is_library(&self) -> bool { + matches!(self, Self::Library) + } } impl Display for InputType { @@ -168,6 +177,10 @@ pub struct ConfigInputOptionsDto { pub xtream_live_stream_use_prefix: bool, #[serde(default, skip_serializing_if = "is_false")] pub xtream_live_stream_without_extension: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub resolve_tmdb: bool, + #[serde(default, alias = "analyze_stream", skip_serializing_if = "is_false")] + pub probe_stream: bool, } impl Default for ConfigInputOptionsDto { @@ -178,6 +191,8 @@ impl Default for ConfigInputOptionsDto { xtream_skip_series: false, xtream_live_stream_use_prefix: default_as_true(), xtream_live_stream_without_extension: false, + resolve_tmdb: false, + probe_stream: false, } } } @@ -189,6 +204,8 @@ impl ConfigInputOptionsDto { && !self.xtream_skip_series && self.xtream_live_stream_use_prefix && !self.xtream_live_stream_without_extension + && !self.resolve_tmdb + && !self.probe_stream } pub fn clean(&mut self) { @@ -197,6 +214,8 @@ impl ConfigInputOptionsDto { self.xtream_skip_series = false; self.xtream_live_stream_use_prefix = default_as_true(); self.xtream_live_stream_without_extension = false; + self.resolve_tmdb = false; + self.probe_stream = false; } } @@ -360,6 +379,14 @@ impl Default for ConfigInputDto { } impl ConfigInputDto { + + pub fn new_with_type(input_type: InputType) -> Self { + Self { + input_type, + ..Self::default() + } + } + #[allow(clippy::cast_possible_truncation)] pub fn prepare(&mut self, index: u16, _include_computed: bool, provider_names: &HashSet) -> Result { self.name = self.name.trim().intern(); diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index b5d405aad..7b544c1a2 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -1,12 +1,13 @@ -use crate::utils::is_blank_optional_string; -use log::warn; use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::{info_err_res, handle_tuliprox_error_result_list}; use crate::foundation::{get_filter, Filter}; use crate::model::{ClusterFlags, ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, HdHomeRunDeviceOverview, PatternTemplate, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto}; use crate::utils::{is_true, is_false, default_as_true, default_resolve_delay_secs, default_as_default, - is_default_resolve_delay_secs, is_zero_u16, is_config_target_options_empty, is_default_processing_order}; + is_blank_optional_string, + is_default_resolve_delay_secs, is_zero_u16, is_config_target_options_empty, is_default_processing_order, + default_probe_live_interval, is_default_probe_live_interval}; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigTargetOptions { @@ -39,14 +40,22 @@ pub struct XtreamTargetOutputDto { pub skip_video_direct_source: bool, #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub skip_series_direct_source: bool, + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] + pub resolve_background: bool, #[serde(default, skip_serializing_if = "is_false")] pub resolve_series: bool, - #[serde(default = "default_resolve_delay_secs", skip_serializing_if = "is_default_resolve_delay_secs")] - pub resolve_series_delay: u16, #[serde(default, skip_serializing_if = "is_false")] pub resolve_vod: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub probe_series: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub probe_vod: bool, #[serde(default = "default_resolve_delay_secs", skip_serializing_if = "is_default_resolve_delay_secs")] - pub resolve_vod_delay: u16, + pub resolve_delay: u16, + #[serde(default, alias = "resolve_live", skip_serializing_if = "is_false")] + pub probe_live: bool, + #[serde(default = "default_probe_live_interval", alias = "resolve_live_interval_hours", skip_serializing_if = "is_default_probe_live_interval")] + pub probe_live_interval_hours: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub trakt: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] @@ -61,10 +70,14 @@ 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_series_delay: default_resolve_delay_secs(), resolve_vod: false, - resolve_vod_delay: default_resolve_delay_secs(), + 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, @@ -87,8 +100,12 @@ 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() } @@ -146,6 +163,13 @@ pub struct StrmTargetOutputDto { pub filter: Option, #[serde(default, skip_serializing_if = "is_false")] pub add_quality_to_filename: bool, + + // New Fields for Metadata and Probe + #[serde(default, skip_serializing_if = "Option::is_none")] + pub probe_probe_size_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub probe_analyze_duration: Option, + #[serde(skip)] pub t_filter: Option, } @@ -366,7 +390,7 @@ impl ConfigTargetDto { if let Some(hdhr_devices) = hdhr_config { if !hdhr_devices.enabled { - warn!("You have defined an HDHomeRun output, but HDHomeRun devices are disabled."); + log::warn!("You have defined an HDHomeRun output, but HDHomeRun devices are disabled."); } } } @@ -400,4 +424,4 @@ impl ConfigTargetDto { Err(err) => Err(err), } } -} \ No newline at end of file +} diff --git a/shared/src/model/config/video_download.rs b/shared/src/model/config/video_download.rs index 96e8936a3..9cc61dcb2 100644 --- a/shared/src/model/config/video_download.rs +++ b/shared/src/model/config/video_download.rs @@ -37,12 +37,18 @@ pub struct VideoConfigDto { pub download: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub web_search: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub ffprobe_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ffprobe_timeout: Option, } impl VideoConfigDto { pub fn is_empty(&self) -> bool { self.extensions.is_empty() && is_blank_optional_str(self.web_search.as_deref()) && (self.download.is_none() || self.download.as_ref().is_some_and(|d| d.is_empty())) + && !self.ffprobe_enabled + && self.ffprobe_timeout.is_none() } pub fn clean(&mut self) { diff --git a/backend/src/model/media_properties.rs b/shared/src/model/media_properties.rs similarity index 60% rename from backend/src/model/media_properties.rs rename to shared/src/model/media_properties.rs index c5e5b7158..560d87897 100644 --- a/backend/src/model/media_properties.rs +++ b/shared/src/model/media_properties.rs @@ -10,6 +10,7 @@ pub enum VideoResolution { SD, P720, P1080, + P1440, P2160, // 4K P4320, // 8K } @@ -18,11 +19,12 @@ impl fmt::Display for VideoResolution { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { VideoResolution::SD => write!(f, "SD"), - VideoResolution::P720 => write!(f, "720p"), - VideoResolution::P1080 => write!(f, "1080p"), - VideoResolution::P2160 => write!(f, "4K"), - VideoResolution::P4320 => write!(f, "8K"), - VideoResolution::Unknown => write!(f, "Unknown"), + VideoResolution::P720 => write!(f, "720p HD"), + VideoResolution::P1080 => write!(f, "1080p FHD"), + VideoResolution::P1440 => write!(f, "1440p QHD"), + VideoResolution::P2160 => write!(f, "2160p 4K"), + VideoResolution::P4320 => write!(f, "4320p 8K"), + VideoResolution::Unknown => write!(f, ""), } } } @@ -36,16 +38,18 @@ pub enum VideoCodec { H265, MPEG4, VC1, + AV1, } impl fmt::Display for VideoCodec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - VideoCodec::H264 => write!(f, "x264"), - VideoCodec::H265 => write!(f, "x265"), + VideoCodec::H264 => write!(f, "H.264"), // or AVC + VideoCodec::H265 => write!(f, "HEVC"), // or x265 VideoCodec::MPEG4 => write!(f, "MPEG4"), VideoCodec::VC1 => write!(f, "VC-1"), - VideoCodec::Other => write!(f, "Other"), + VideoCodec::AV1 => write!(f, "AV1"), + VideoCodec::Other => write!(f, ""), } } } @@ -68,11 +72,11 @@ impl fmt::Display for AudioCodec { match self { AudioCodec::AAC => write!(f, "AAC"), AudioCodec::AC3 => write!(f, "AC3"), - AudioCodec::EAC3 => write!(f, "E-AC3"), + AudioCodec::EAC3 => write!(f, "EAC3"), AudioCodec::DTS => write!(f, "DTS"), AudioCodec::TrueHD => write!(f, "TrueHD"), AudioCodec::FLAC => write!(f, "FLAC"), - AudioCodec::Other => write!(f, "Other"), + AudioCodec::Other => write!(f, ""), } } } @@ -95,12 +99,12 @@ impl fmt::Display for AudioChannels { AudioChannels::Stereo => write!(f, "2.0"), AudioChannels::Surround51 => write!(f, "5.1"), AudioChannels::Surround71 => write!(f, "7.1"), - AudioChannels::Unknown => write!(f, "Unknown"), + AudioChannels::Unknown => write!(f, ""), } } } -// NEW: Enum for Video Dynamic Range +// Enum for Video Dynamic Range #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum VideoDynamicRange { #[default] @@ -114,7 +118,7 @@ pub enum VideoDynamicRange { impl fmt::Display for VideoDynamicRange { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - VideoDynamicRange::SDR => write!(f, "SDR"), + VideoDynamicRange::SDR => write!(f, ""), // Don't explicitly tag SDR VideoDynamicRange::HDR => write!(f, "HDR"), VideoDynamicRange::HDR10 => write!(f, "HDR10"), VideoDynamicRange::HLG => write!(f, "HLG"), @@ -123,18 +127,36 @@ impl fmt::Display for VideoDynamicRange { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum VideoBitDepth { + #[default] + Eight, + Ten, +} + +impl fmt::Display for VideoBitDepth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VideoBitDepth::Eight => write!(f, ""), + VideoBitDepth::Ten => write!(f, "10bit"), + } + } +} + + /// A struct that holds all classified media quality features. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MediaQuality { pub resolution: VideoResolution, pub video_codec: VideoCodec, pub dynamic_range: VideoDynamicRange, + pub bit_depth: VideoBitDepth, pub audio_codec: AudioCodec, pub audio_channels: AudioChannels, } impl MediaQuality { - /// Formats the quality features into a string suitable for filenames, e.g., "1080p | x265 | DTS | 5.1". + /// Formats the quality features into a string suitable for filenames, e.g., "1080p FHD H264 AAC 2.0". /// Returns an empty string if no relevant features are available to display. pub fn format_for_filename(&self, separator: &str) -> String { let mut parts = Vec::new(); @@ -145,25 +167,33 @@ impl MediaQuality { if self.video_codec != VideoCodec::Other { parts.push(self.video_codec.to_string()); } - // Only show dynamic range if it's not standard SDR. + + if self.bit_depth != VideoBitDepth::Eight { + parts.push(self.bit_depth.to_string()); + } + if self.dynamic_range != VideoDynamicRange::SDR { parts.push(self.dynamic_range.to_string()); } + if self.audio_codec != AudioCodec::Other { parts.push(self.audio_codec.to_string()); } if self.audio_channels != AudioChannels::Unknown { parts.push(self.audio_channels.to_string()); } + + // Filter empty strings from Display impls + let valid_parts: Vec<&str> = parts.iter().map(std::string::String::as_str).filter(|s| !s.is_empty()).collect(); - parts.join(separator) + valid_parts.join(separator) } fn from_ffprobe_info_audio(audio: Option<&str>) -> Option<(AudioCodec, AudioChannels)> { // Assuming the first audio stream is the primary one. let audio_info = audio.and_then(|v| serde_json::from_str::>(v).ok())?; - // 4. Classify audio codec + // Audio codec let audio_codec = get_value(&audio_info, &["codec_name"]) .and_then(|v| v.as_str().map(str::to_lowercase)) .map_or(AudioCodec::default(), |name| match name.as_str() { @@ -176,29 +206,30 @@ impl MediaQuality { _ => AudioCodec::default(), }); - // 5. Classify audio channels - let audio_channels = get_value(&audio_info, &["channel_layout"]) - .and_then(|v| v.as_str().map(str::to_lowercase)) - .map_or(AudioChannels::default(), |layout| match layout.as_str() { - l if l.starts_with("7.1") => AudioChannels::Surround71, - l if l.starts_with("5.1") => AudioChannels::Surround51, - "stereo" => AudioChannels::Stereo, - "mono" => AudioChannels::Mono, + // Audio channels + let audio_channels = get_value(&audio_info, &["channels"]) + .and_then(|v| v.as_i64()) + .map_or(AudioChannels::default(), |ch| match ch { + 8 => AudioChannels::Surround71, + 6 => AudioChannels::Surround51, + 2 => AudioChannels::Stereo, + 1 => AudioChannels::Mono, _ => AudioChannels::default(), }); Some((audio_codec, audio_channels)) } - fn from_ffprobe_info_video(video: Option<&str>) -> Option<(VideoResolution, VideoCodec, VideoDynamicRange)> { + fn from_ffprobe_info_video(video: Option<&str>) -> Option<(VideoResolution, VideoCodec, VideoDynamicRange, VideoBitDepth)> { let video_info = video.and_then(|v| serde_json::from_str::>(v).ok())?; - // 1. Classify video resolution from width + // 1. Classify video resolution from height let resolution = get_value(&video_info, &["height", "coded_height"]) .and_then(|v| v.as_u64()) .map_or(VideoResolution::default(), |h| match h { _ if h >= 4300 => VideoResolution::P4320, _ if h >= 2100 => VideoResolution::P2160, + _ if h >= 1400 => VideoResolution::P1440, _ if h >= 1000 => VideoResolution::P1080, _ if h >= 700 => VideoResolution::P720, _ => VideoResolution::SD, @@ -212,6 +243,7 @@ impl MediaQuality { "hevc" => VideoCodec::H265, "mpeg4" => VideoCodec::MPEG4, "vc1" => VideoCodec::VC1, + "av1" => VideoCodec::AV1, _ => VideoCodec::default(), }); @@ -226,33 +258,71 @@ impl MediaQuality { get_value(&video_info, &["color_transfer"]) .and_then(|v| v.as_str().map(str::to_lowercase)) .map_or(VideoDynamicRange::SDR, |ct| match ct.as_str() { - "smpte2084" => VideoDynamicRange::HDR10, + "smpte2084" => VideoDynamicRange::HDR, // Generic HDR/HDR10 "arib-std-b67" => VideoDynamicRange::HLG, _ => VideoDynamicRange::SDR, }) } }; - Some((resolution, video_codec, dynamic_range)) + // 4. Classify bit depth + let bit_depth = get_value(&video_info, &["pix_fmt"]) + .and_then(|v| v.as_str().map(ToString::to_string)) + .map_or(VideoBitDepth::Eight, |fmt| { + if fmt.contains("10le") || fmt.contains("10be") { + VideoBitDepth::Ten + } else { + VideoBitDepth::Eight + } + }); + + Some((resolution, video_codec, dynamic_range, bit_depth)) } /// Extracts media quality information from an `ffprobe` info block. - /// The `info_block` is expected to be a `serde_json::Value` object. + /// The `info_block` is expected to be a `serde_json::Value` object encoded as string. pub fn from_ffprobe_info(audio: Option<&str>, video: Option<&str>) -> Option { - match (Self::from_ffprobe_info_video(video), Self::from_ffprobe_info_audio(audio)) { - (Some((resolution, video_codec, dynamic_range)), - Some((audio_codec, audio_channels))) => { - Some(Self { - resolution, - video_codec, - dynamic_range, - audio_codec, - audio_channels, - }) - } - // TODO to restrictive, maybe only video or audio could be processed - _ => None, + // We attempt to parse whatever is available. If both missing, None. + if audio.is_none() && video.is_none() { + return None; } + + let (resolution, video_codec, dynamic_range, bit_depth) = Self::from_ffprobe_info_video(video) + .unwrap_or((VideoResolution::default(), VideoCodec::default(), VideoDynamicRange::default(), VideoBitDepth::default())); + + let (audio_codec, audio_channels) = Self::from_ffprobe_info_audio(audio) + .unwrap_or((AudioCodec::default(), AudioChannels::default())); + + Some(Self { + resolution, + video_codec, + dynamic_range, + bit_depth, + audio_codec, + audio_channels, + }) + } + + /// Validates if the provided JSON string contains meaningful media information. + /// Returns true if the string is valid JSON object and contains at least codec or dimension information. + /// Returns false for empty arrays "[]" or objects without specific keys. + pub fn is_valid_media_info(info: Option<&str>) -> bool { + if let Some(json_str) = info { + if let Ok(json) = serde_json::from_str::(json_str) { + // API often returns [] for empty info + if let Some(arr) = json.as_array() { + return !arr.is_empty(); + } + if let Some(obj) = json.as_object() { + // Check for minimal necessary fields + // For video: codec_name, width, height + // For audio: codec_name, channels + // We check generically if it looks populated + return obj.contains_key("codec_name") || obj.contains_key("width") || obj.contains_key("channels"); + } + } + } + false } } @@ -267,4 +337,4 @@ fn get_value(obj: &Map, fields: &[&str]) -> Option { } } None -} +} \ No newline at end of file diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index 4288b79aa..8580def05 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -33,6 +33,8 @@ mod uuidtype; mod short_epg; mod stats; mod ui_playlist_item; +mod media_properties; + pub use self::cluster_flags::*; pub use self::playlist::*; pub use self::messaging::*; @@ -64,4 +66,5 @@ pub use self::xtream::*; pub use self::playlist_info_document::*; pub use self::regex_cache::*; pub use self::uuidtype::*; -pub use self::ui_playlist_item::*; \ No newline at end of file +pub use self::ui_playlist_item::*; +pub use self::media_properties::*; \ No newline at end of file diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index b376a195b..4f25483e6 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -1,14 +1,14 @@ -use crate::utils::{arc_str_option_serde, arc_str_serde, extract_extension_from_url, generate_playlist_uuid, - get_provider_id, Internable}; +use crate::model::UUIDType; use crate::model::{xtream_const, ClusterFlags, CommonPlaylistItem, ConfigTargetOptions, EpisodeStreamProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamInfoDocument}; +use crate::utils::{arc_str_option_serde, arc_str_serde, extract_extension_from_url, generate_playlist_uuid, + get_provider_id, Internable}; use enum_iterator::Sequence; use serde::{Deserialize, Serialize}; use std::fmt::Write; use std::fmt::{Display, Formatter}; use std::str::FromStr; use std::sync::Arc; -use crate::model::UUIDType; // https://de.wikipedia.org/wiki/M3U // https://siptv.eu/howto/playlist.html @@ -232,10 +232,8 @@ pub struct PlaylistItemHeader { pub uuid: UUIDType, // calculated #[serde(with = "arc_str_serde")] pub id: Arc, // provider id - pub virtual_id: VirtualId, // virtual id #[serde(with = "arc_str_serde")] pub name: Arc, - pub chno: u32, #[serde(with = "arc_str_serde")] pub logo: Arc, #[serde(with = "arc_str_serde")] @@ -256,16 +254,23 @@ pub struct PlaylistItemHeader { pub url: Arc, #[serde(default, with = "arc_str_option_serde")] pub epg_channel_id: Option>, - pub xtream_cluster: XtreamCluster, - pub additional_properties: Option, - #[serde(default)] - pub item_type: PlaylistItemType, - #[serde(default)] - pub category_id: u32, #[serde(with = "arc_str_serde")] pub input_name: Arc, #[serde(default)] + pub additional_properties: Option, + + // 4-byte aligned + pub virtual_id: VirtualId, // virtual id + pub chno: u32, + #[serde(default)] + pub category_id: u32, + #[serde(default)] pub source_ordinal: u32, + + // 1-byte aligned + pub xtream_cluster: XtreamCluster, + #[serde(default)] + pub item_type: PlaylistItemType, } impl Default for PlaylistItemHeader { @@ -297,9 +302,12 @@ impl Default for PlaylistItemHeader { } impl PlaylistItemHeader { + #[inline] pub fn gen_uuid(&mut self) { self.uuid = generate_playlist_uuid(&self.input_name, &self.id, self.item_type, &self.url); } + + #[inline] pub const fn get_uuid(&self) -> &UUIDType { &self.uuid } @@ -314,6 +322,16 @@ impl PlaylistItemHeader { } } + #[inline] + pub fn get_name(&self) -> Arc { + if self.title.is_empty() { + Arc::clone(&self.name) + } else { + Arc::clone(&self.title) + } + } + + #[inline] pub fn get_container_extension(&self) -> Option> { self.additional_properties.as_ref().and_then(|a| a.get_container_extension()) } @@ -458,6 +476,8 @@ pub struct M3uPlaylistItem { pub t_resource_url: Option, #[serde(default)] pub source_ordinal: u32, + #[serde(default)] + pub additional_properties: Option, } impl M3uPlaylistItem { @@ -511,7 +531,7 @@ impl M3uPlaylistItem { item_type: self.item_type, epg_channel_id: self.epg_channel_id.clone(), xtream_cluster: XtreamCluster::try_from(self.item_type).ok(), - additional_properties: None, + additional_properties: self.additional_properties.clone(), category_id: None, } } @@ -531,7 +551,7 @@ impl PlaylistEntry for M3uPlaylistItem { None } #[inline] - fn get_provider_url(&self) -> Arc { + fn get_provider_url(&self) -> Arc { Arc::clone(&self.url) } @@ -564,13 +584,12 @@ impl PlaylistEntry for M3uPlaylistItem { } #[inline] fn get_additional_properties(&self) -> Option<&StreamProperties> { - None + self.additional_properties.as_ref() } #[inline] fn get_additional_properties_mut(&mut self) -> Option<&mut StreamProperties> { - None + self.additional_properties.as_mut() } - } macro_rules! generate_field_accessor_impl_for_m3u_playlist_item { @@ -671,6 +690,7 @@ pub struct XtreamPlaylistItem { #[serde(default, with = "arc_str_option_serde")] pub epg_channel_id: Option>, pub xtream_cluster: XtreamCluster, + #[serde(default)] pub additional_properties: Option, pub item_type: PlaylistItemType, pub category_id: u32, @@ -682,7 +702,6 @@ pub struct XtreamPlaylistItem { } impl XtreamPlaylistItem { - pub fn to_common(&self) -> CommonPlaylistItem { CommonPlaylistItem { virtual_id: self.virtual_id, @@ -752,7 +771,7 @@ impl PlaylistEntry for XtreamPlaylistItem { Some(self.category_id) } #[inline] - fn get_provider_url(&self) -> Arc { + fn get_provider_url(&self) -> Arc { Arc::clone(&self.url) } @@ -893,7 +912,6 @@ pub struct PlaylistItem { generate_field_accessor_impl_for_xtream_playlist_item!(group, title, name, logo, logo_small, parent_code, rec, url;); impl PlaylistItem { - fn get_additional_properties(header: &PlaylistItemHeader) -> Option { match &header.additional_properties { Some(props) => Some(props.clone()), @@ -924,18 +942,19 @@ impl PlaylistItem { if header.item_type == PlaylistItemType::Series { let container_extension = extract_extension_from_url(&header.url).map(|e| e.strip_prefix('.').unwrap_or(&e).to_string()).unwrap_or_default(); // TODO maybe from link ? like s01e02 or something like this - Some(StreamProperties::Episode(EpisodeStreamProperties { + Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { episode_id: 0, episode: 0, season: 0, added: None, release_date: None, + series_release_date: None, tmdb: None, movie_image: "".intern(), container_extension: container_extension.intern(), audio: None, video: None, - })) + }))) } else if header.item_type == PlaylistItemType::SeriesInfo { Some(StreamProperties::Series(Box::new(SeriesStreamProperties { name: header.name.clone(), @@ -1026,6 +1045,7 @@ impl From<&PlaylistItem> for M3uPlaylistItem { t_stream_url: Arc::clone(&header.url), t_resource_url: None, source_ordinal: header.source_ordinal, + additional_properties: header.additional_properties.clone(), } } } @@ -1114,7 +1134,7 @@ impl From<&M3uPlaylistItem> for PlaylistItem { chno: item.chno, audio_track: item.audio_track.clone(), time_shift: item.time_shift.clone(), - additional_properties: None, + additional_properties: item.additional_properties.clone(), source_ordinal: item.source_ordinal, }; @@ -1142,7 +1162,7 @@ impl PlaylistEntry for PlaylistItem { } #[inline] - fn get_provider_url(&self) -> Arc { + fn get_provider_url(&self) -> Arc { Arc::clone(&self.header.url) } @@ -1164,11 +1184,7 @@ impl PlaylistEntry for PlaylistItem { #[inline] fn get_name(&self) -> Arc { - if self.header.title.is_empty() { - Arc::clone(&self.header.name) - } else { - Arc::clone(&self.header.title) - } + self.header.get_name() } fn get_resolved_info_document(&self, options: &XtreamMappingOptions) -> Option { diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 13f021dd3..31219322f 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use log::{warn}; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct LiveStreamProperties { #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub name: Arc, @@ -35,6 +35,15 @@ pub struct LiveStreamProperties { pub tv_archive_duration: Option, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub is_adult: i32, + // New fields for probing + #[serde(default, serialize_with = "serialize_json_as_opt_string", deserialize_with = "deserialize_json_as_opt_string")] + pub video: Option>, + #[serde(default, serialize_with = "serialize_json_as_opt_string", deserialize_with = "deserialize_json_as_opt_string")] + pub audio: Option>, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub last_probed_timestamp: Option, + #[serde(default, deserialize_with = "deserialize_number_from_string")] + pub last_success_timestamp: Option, } #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -170,6 +179,8 @@ pub struct SeriesStreamDetailEpisodeProperties { #[serde(default, deserialize_with = "arc_str_none_default_on_null")] pub release_date: Arc, #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] + pub series_release_date: Option>, // Global series release date + #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] pub plot: Option>, #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] pub crew: Option>, @@ -257,6 +268,8 @@ pub struct EpisodeStreamProperties { pub added: Option>, #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] pub release_date: Option>, + #[serde(default, deserialize_with = "deserialize_as_option_arc_str")] + pub series_release_date: Option>, // Global series release date #[serde(default, deserialize_with = "deserialize_number_from_string")] pub tmdb: Option, #[serde(default, deserialize_with = "arc_str_none_default_on_null")] @@ -282,7 +295,7 @@ pub enum StreamProperties { Live(Box), Video(Box), Series(Box), - Episode(EpisodeStreamProperties), + Episode(Box), } impl StreamProperties { @@ -290,8 +303,13 @@ impl StreamProperties { match self { StreamProperties::Video(video) => video.details.is_some(), StreamProperties::Series(series) => series.details.is_some(), - StreamProperties::Live(_) - | StreamProperties::Episode(_) => false, + StreamProperties::Live(live) => { + live.video.is_some() + || live.audio.is_some() + || live.last_probed_timestamp.is_some() + || live.last_success_timestamp.is_some() + }, + StreamProperties::Episode(_) => false, } } @@ -590,11 +608,8 @@ fn parse_season_episode_field(s: &str) -> Option<(u32, u32, String)> { impl VideoStreamProperties { - pub fn from_info

(info: &XtreamVideoInfo, pli: &P) -> VideoStreamProperties - where - P: PlaylistEntry, - { - let mut props = VideoStreamProperties { + fn from_info_base(info: &XtreamVideoInfo) -> VideoStreamProperties { + VideoStreamProperties { name: info.info.name.clone(), category_id: info.movie_data.category_id, stream_id: info.movie_data.stream_id, @@ -605,7 +620,7 @@ impl VideoStreamProperties { container_extension: info.movie_data.container_extension.clone(), rating: None, // from PlaylistItem rating_5based: None, // from PlaylistItem - stream_type: None, // from PlaylistItem + stream_type: Some("movie".intern()), trailer: info.info.youtube_trailer.clone(), tmdb: info.info.tmdb_id.parse::().ok(), is_adult: 0, // from PlaylistItem @@ -636,7 +651,14 @@ impl VideoStreamProperties { runtime: info.info.runtime.clone(), status: info.info.status.clone(), }), - }; + } + } + + pub fn from_info

(info: &XtreamVideoInfo, pli: &P) -> VideoStreamProperties + where + P: PlaylistEntry, + { + let mut props = VideoStreamProperties::from_info_base(info); if let Some(StreamProperties::Video(video)) = pli.get_additional_properties() { props.rating = video.rating; @@ -649,12 +671,14 @@ impl VideoStreamProperties { props.trailer = video.trailer.clone(); } props.is_adult = video.is_adult; - } else { - props.stream_type = Some("movie".intern()); } props } + + pub fn from_info_without_existing(info: &XtreamVideoInfo) -> VideoStreamProperties { + VideoStreamProperties::from_info_base(info) + } } impl SeriesStreamProperties { @@ -711,6 +735,7 @@ impl SeriesStreamProperties { direct_source: e.direct_source.clone(), tmdb: info.info.tmdb, release_date: e.info.as_ref().map(|i| i.air_date.clone()).unwrap_or_default(), + series_release_date: None, plot: None, crew: e.info.as_ref().map(|i| i.crew.clone()), duration_secs: e.info.as_ref().map(|i| i.duration_secs).unwrap_or_default(), @@ -794,6 +819,7 @@ impl SeriesStreamProperties { direct_source: e.direct_source.clone(), tmdb, release_date: e.info.air_date.clone(), + series_release_date: None, plot: None, crew: e.info.crew.clone(), duration_secs: e.info.duration_secs, @@ -820,6 +846,8 @@ impl EpisodeStreamProperties { season: episode.season, added: Some(episode.added.clone()), release_date: Some(episode.release_date.clone()), + // Inherit global release date from the Series object if available + series_release_date: series.release_date.clone(), tmdb: episode.tmdb.or(series.tmdb), movie_image: episode.movie_image.clone(), container_extension: episode.container_extension.clone(), @@ -831,4 +859,93 @@ impl EpisodeStreamProperties { fn non_empty_arc(s: &Arc) -> Option> { if s.is_empty() { None } else { Some(Arc::clone(s)) } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; + use serde_json::json; + + fn sample_video_info(tmdb_id: &str) -> XtreamVideoInfo { + serde_json::from_value(json!({ + "info": { + "name": "Fetched Movie", + "tmdb_id": tmdb_id, + "movie_image": "https://img.example/movie.jpg", + "video": { "codec": "h264" }, + "audio": { "codec": "aac" } + }, + "movie_data": { + "name": "Fetched Movie", + "category_id": "99", + "stream_id": "4242", + "direct_source": "https://cdn.example/stream.mkv", + "added": "1700000000", + "container_extension": "mkv" + } + })) + .expect("sample XtreamVideoInfo should deserialize") + } + + fn existing_video_item() -> XtreamPlaylistItem { + XtreamPlaylistItem { + virtual_id: 1, + provider_id: 1001, + name: "Existing".into(), + logo: "".into(), + logo_small: "".into(), + group: "".into(), + title: "Existing title".into(), + parent_code: "".into(), + rec: "".into(), + url: "https://provider.example/stream".into(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Video, + additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties { + rating: Some(7.5), + rating_5based: Some(3.7), + stream_type: Some("legacy-type".into()), + trailer: Some("legacy-trailer".into()), + tmdb: Some(7788), + is_adult: 1, + ..Default::default() + }))), + item_type: PlaylistItemType::Video, + category_id: 99, + input_name: "input".into(), + channel_no: 0, + source_ordinal: 0, + } + } + + #[test] + fn from_info_without_existing_keeps_fetched_metadata() { + let info = sample_video_info("12345"); + + let props = VideoStreamProperties::from_info_without_existing(&info); + let details = props.details.expect("details should be present"); + + assert_eq!(props.name.as_ref(), "Fetched Movie"); + assert_eq!(props.stream_id, 4242); + assert_eq!(props.tmdb, Some(12345)); + assert_eq!(props.stream_type.as_deref(), Some("movie")); + assert!(details.video.is_some()); + assert!(details.audio.is_some()); + } + + #[test] + fn from_info_with_existing_keeps_existing_overrides() { + let info = sample_video_info(""); + let existing = existing_video_item(); + + let props = VideoStreamProperties::from_info(&info, &existing); + + assert_eq!(props.rating, Some(7.5)); + assert_eq!(props.rating_5based, Some(3.7)); + assert_eq!(props.stream_type.as_deref(), Some("legacy-type")); + assert_eq!(props.trailer.as_deref(), Some("legacy-trailer")); + assert_eq!(props.tmdb, Some(7788)); + assert_eq!(props.is_adult, 1); + } +} diff --git a/shared/src/model/xtream.rs b/shared/src/model/xtream.rs index 0b6240a38..89bb9b4bf 100644 --- a/shared/src/model/xtream.rs +++ b/shared/src/model/xtream.rs @@ -120,7 +120,7 @@ pub struct XtreamSeriesInfoSeason { #[allow(non_snake_case)] pub struct XtreamSeriesInfoInfo { #[serde(default, with = "arc_str_serde")] - pub(crate) name: Arc, + pub name: Arc, #[serde(default, with = "arc_str_serde")] pub cover: Arc, #[serde(default, with = "arc_str_serde")] diff --git a/shared/src/utils/bitset.rs b/shared/src/utils/bitset.rs new file mode 100644 index 000000000..f71d31782 --- /dev/null +++ b/shared/src/utils/bitset.rs @@ -0,0 +1,123 @@ +#[macro_export] +macro_rules! create_bit_set { + ($storage:ty, $enum_name:ident, $($variant:ident),*) => { + paste::paste! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + #[repr($storage)] + pub enum $enum_name { + $($variant),* + } + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + #[repr(transparent)] + #[serde(transparent)] + pub struct [<$enum_name Set>](pub $storage); + + impl [<$enum_name Set>] { + // COMPILE-TIME CHECK: + // This ensures at compile time that the largest enum variant + // doesn't exceed the storage capacity. Zero runtime cost. + const _CAPACITY_CHECK: () = { + let mut max_val = 0; + $( if ($enum_name::$variant as usize) > max_val { max_val = $enum_name::$variant as usize; } )* + if max_val >= <$storage>::BITS as usize { + panic!("BitSet storage too small for enum variants!"); + } + }; + + #[inline(always)] + pub const fn new() -> Self { + Self(0) + } + + // New: Constructor for multiple variants + #[inline] + pub fn from_variants(variants: &[$enum_name]) -> Self { + let mut set = Self::new(); + for &v in variants { + set.add(v); + } + set + } + + #[inline(always)] + pub fn add(&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) { + self.0 &= !(1 << (variant as $storage)); + } + + #[inline(always)] + pub fn union(&mut self, other: Self) { + self.0 |= other.0; + } + + #[inline(always)] + pub fn intersect(&mut self, other: Self) { + self.0 &= other.0; + } + + #[inline(always)] + pub fn contains(&self, variant: $enum_name) -> bool { + // Becomes a single 'bt' or 'test' instruction + (self.0 & (1 << (variant as $storage))) != 0 + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.0 == 0 + } + + #[inline] + pub fn clear(&mut self) { + self.0 = 0; + } + } + + impl std::fmt::Display for [<$enum_name Set>] { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0 == 0 { return Ok(()); } + let mut first = true; + $( + if self.contains($enum_name::$variant) { + if !first { write!(f, ",")?; } + write!(f, "{}", stringify!($variant).to_lowercase())?; + first = false; + } + )* + Ok(()) + } + } + + // Implementation of BitOrAssign for better performance in loops + impl std::ops::BitOrAssign for [<$enum_name Set>] { + #[inline(always)] + fn bitor_assign(&mut self, rhs: Self) { + self.0 |= rhs.0; + } + } + + impl std::ops::BitOr for [<$enum_name Set>] { + type Output = Self; + #[inline(always)] + fn bitor(mut self, rhs: Self) -> Self { + self.0 |= rhs.0; + self + } + } + + impl std::ops::BitAnd for [<$enum_name Set>] { + type Output = Self; + fn bitand(self, rhs: Self) -> Self { + let mut new = self.clone(); + new.intersect(rhs); + new + } + } + + } + }; +} diff --git a/shared/src/utils/constants.rs b/shared/src/utils/constants.rs index 851a127c5..fc8127a08 100644 --- a/shared/src/utils/constants.rs +++ b/shared/src/utils/constants.rs @@ -104,11 +104,13 @@ pub struct Constants { pub re_classifier_moviedb_id: Regex, pub re_classifier_camel_case: Regex, pub re_classifier_brackets_info: Regex, + pub re_clean_title: Regex, + pub re_html_tag: Regex, } pub static CONSTANTS: LazyLock = LazyLock::new(|| Constants { - re_credentials: Regex::new(r"((username|password|token)=)[^&]*").unwrap(), + re_credentials: Regex::new(r"((username|password|token|api_key)=)[^&]*").unwrap(), re_ipv4: Regex::new(r"\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b").unwrap(), re_ipv6: Regex::new(r"\b([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\b|\b([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b|\b::ffff:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b").unwrap(), re_stream_url: Regex::new(r"(?i)^(?Phttps?://)[^/]+/(?Plive|video|movie|series|m3u-stream|resource)/[^/]+/[^/]+/").unwrap(), @@ -162,5 +164,8 @@ pub static CONSTANTS: LazyLock = LazyLock::new(|| re_classifier_moviedb_id: Regex::new(r"(?i)\b(tmdb|tvdb|imdb)[\s._=-]?(\d+)\b").unwrap(), re_classifier_camel_case: Regex::new(r"([a-z])([A-Z])").unwrap(), re_classifier_brackets_info: Regex::new(r"[\[\{\(].*?[\]\}\)]").unwrap(), + + re_clean_title: Regex::new(r"^(?:\s*[\[\(┃|].*?[\]\)┃|]\s*)+").unwrap(), // Matches patterns like "┃DE┃", "[US]", "(FR)", "|EN|" at the start of the string + re_html_tag: Regex::new(r"]*>").unwrap() } ); diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index 30727996d..27d4c1f2e 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -217,6 +217,14 @@ pub fn is_default_processing_order(p: &ProcessingOrder) -> bool { *p == ProcessingOrder::default() } +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() +} + ////////////////////////////// // HDHomerun Device Defaults ////////////////////////////// diff --git a/shared/src/utils/mod.rs b/shared/src/utils/mod.rs index 1037f365d..cfe03d3f5 100644 --- a/shared/src/utils/mod.rs +++ b/shared/src/utils/mod.rs @@ -12,6 +12,7 @@ mod hdhomerun_utils; mod net_utils; mod number_utils; mod string_interner; +mod bitset; use std::fmt::Display; pub use self::default_utils::*; diff --git a/shared/src/utils/string_utils.rs b/shared/src/utils/string_utils.rs index 849120227..24d6ec0bb 100644 --- a/shared/src/utils/string_utils.rs +++ b/shared/src/utils/string_utils.rs @@ -1,6 +1,12 @@ use std::borrow::Cow; -use std::sync::Arc; +use std::sync::{Arc}; use deunicode::deunicode_with_tofu_cow; +use crate::utils::CONSTANTS; + +/// Cleans a playlist title by removing common IPTV prefixes (e.g., "[US]", "┃DE┃"). +pub fn clean_playlist_title(title: &str) -> String { + CONSTANTS.re_clean_title.replace(title, "").trim().to_string() +} pub trait Capitalize { fn capitalize(&self) -> String; @@ -171,6 +177,7 @@ mod test { use crate::utils::Capitalize; use super::generate_random_string; use crate as shared; // allow path-based macro call in tests + use super::clean_playlist_title; #[test] fn test_generate_random_string() { @@ -202,4 +209,13 @@ mod test { assert_eq!(s, "abc/123"); } -} + #[test] + fn test_clean_playlist_title() { + assert_eq!(clean_playlist_title("┃DE┃ Movie Title"), "Movie Title"); + assert_eq!(clean_playlist_title("[US] Movie Title"), "Movie Title"); + assert_eq!(clean_playlist_title("|EN| Movie Title"), "Movie Title"); + assert_eq!(clean_playlist_title("(FR) Movie Title"), "Movie Title"); + assert_eq!(clean_playlist_title("[US] (FR) Movie Title"), "Movie Title"); + assert_eq!(clean_playlist_title("Movie Title"), "Movie Title"); + } +} \ No newline at end of file