From a32a503e2022ce5dced964ae3a47489e60bd7455 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:43:41 +0100 Subject: [PATCH] Fix: local media path (#669) * Fix: local media path * Fix: xmltv api post request --- backend/src/api/endpoints/m3u_api.rs | 3 +- backend/src/api/endpoints/user_api.rs | 3 +- backend/src/api/endpoints/v1_api_config.rs | 3 +- backend/src/api/endpoints/xmltv_api.rs | 37 ++- backend/src/api/endpoints/xtream_api.rs | 6 +- backend/src/library/metadata_resolver.rs | 21 +- backend/src/processing/processor/library.rs | 4 +- docs/src/configuration/reverse-proxy.md | 254 +++++++++++++++----- 8 files changed, 242 insertions(+), 89 deletions(-) diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 5e5355dea..e2bdbd271 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -367,8 +367,7 @@ macro_rules! register_m3u_api_routes { ($router:expr, [$($path:expr),*]) => {{ $router $( - .route(&format!("/{}", $path), axum::routing::get(m3u_api_get)) - .route(&format!("/{}", $path), axum::routing::post(m3u_api_post)) + .route(&format!("/{}", $path), axum::routing::get(m3u_api_get).post(m3u_api_post)) // $cfg.service(web::resource(format!("/{}", $path)).route(web::get().to(m3u_api_get)).route(web::post().to(m3u_api_post))); )* }}; diff --git a/backend/src/api/endpoints/user_api.rs b/backend/src/api/endpoints/user_api.rs index 6c67f42bb..018c022ef 100644 --- a/backend/src/api/endpoints/user_api.rs +++ b/backend/src/api/endpoints/user_api.rs @@ -174,8 +174,7 @@ pub fn user_api_register(app_state: &Arc, web_ui_path: &str) -> axum:: &concat_path_leading_slash(web_ui_path, "/api/v1/user"), axum::Router::new() .route("/playlist/categories", axum::routing::get(playlist_categories)) - .route("/playlist/bouquet", axum::routing::get(playlist_bouquet)) - .route("/playlist/bouquet", axum::routing::post(save_playlist_bouquet)) + .route("/playlist/bouquet", axum::routing::get(playlist_bouquet).post(save_playlist_bouquet)) .route_layer(axum::middleware::from_fn_with_state(Arc::clone(app_state), validator_api_user)), ) diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 88758aa68..d75a20415 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -614,8 +614,7 @@ pub fn v1_api_config_register(router: Router>) -> axum::Router) -> Router> { let base_read = Router::new() diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 3f4d8d0c1..eb8ac7e89 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -459,19 +459,16 @@ pub async fn serve_short_epg( /// let router = xmltv_api_register(); /// // A GET request to /xmltv.php with valid query parameters will invoke this handler. /// ``` -async fn xmltv_api( - axum::extract::Query(api_req): axum::extract::Query, - axum::extract::State(app_state): axum::extract::State>, -) -> impl IntoResponse + Send { +async fn xmltv_api(api_req: UserApiRequest, app_state: &Arc) -> impl IntoResponse + Send { let auth_status = app_state.app_config.get_auth_error_status(); let (user, target) = try_option_forbidden!( - get_user_target(&api_req, &app_state), + get_user_target(&api_req, app_state), auth_status, false, format!("Could not find any user for xmltv api {}", api_req.username) ); - if user.permission_denied(&app_state) { + if user.permission_denied(app_state) { return axum::http::StatusCode::FORBIDDEN.into_response(); } @@ -482,7 +479,27 @@ async fn xmltv_api( return get_empty_epg_response(); }; - serve_epg(&app_state, &epg_path, &user, &target, None).await + serve_epg(app_state, &epg_path, &user, &target, None).await +} + +async fn xmltv_api_get( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Query(api_req): axum::extract::Query, +) -> impl IntoResponse + Send { + xmltv_api(api_req, &app_state).await +} + +async fn xmltv_api_post( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Query(api_query_req): axum::extract::Query, + api_form_req: Result, axum::extract::rejection::FormRejection>, +) -> impl IntoResponse + Send { + if let Err(ref rejection) = api_form_req { + debug!("xmltv_api_post: form parsing failed: {rejection:?}"); + } + let form_req = api_form_req.as_ref().ok().map(|form| &form.0); + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req); + xmltv_api(api_req, &app_state).await } async fn epg_api_resource( @@ -522,9 +539,9 @@ async fn epg_api_resource( /// ``` pub fn xmltv_api_register() -> axum::Router> { axum::Router::new() - .route("/xmltv.php", axum::routing::get(xmltv_api)) - .route("/epg", axum::routing::get(xmltv_api)) - .route("/update/epg.php", axum::routing::get(xmltv_api)) + .route("/xmltv.php", axum::routing::get(xmltv_api_get).post(xmltv_api_post)) + .route("/epg", axum::routing::get(xmltv_api_get).post(xmltv_api_post)) + .route("/update/epg.php", axum::routing::get(xmltv_api_get).post(xmltv_api_post)) .route( &format!("/{}/{{username}}/{{password}}/{{resource}}", storage_const::EPG_RESOURCE_PATH), axum::routing::get(epg_api_resource), diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 2b146e13b..e1321ff6d 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -1325,8 +1325,7 @@ macro_rules! register_xtream_api { ($router:expr, [$($path:expr),*]) => {{ $router $( - .route($path, axum::routing::get(xtream_player_api_get)) - .route($path, axum::routing::post(xtream_player_api_post)) + .route($path, axum::routing::get(xtream_player_api_get).post(xtream_player_api_post)) // $router.service(web::resource($path).route(web::get().to(xtream_player_api_get)).route(web::post().to(xtream_player_api_post))) )* }}; @@ -1356,8 +1355,7 @@ macro_rules! register_xtream_api_timeshift { ($router:expr, [$($path:expr),*]) => {{ $router $( - .route($path, axum::routing::get(xtream_player_api_timeshift_query_stream)) - .route($path, axum::routing::post(xtream_player_api_timeshift_query_stream)) + .route($path, axum::routing::get(xtream_player_api_timeshift_query_stream).post(xtream_player_api_timeshift_query_stream)) //$cfg.service(web::resource($path).route(web::get().to(xtream_player_api_timeshift_stream)).route(web::post().to(xtream_player_api_timeshift_stream))); )* }}; diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index 667dfeb77..8416188b4 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -140,7 +140,7 @@ impl MetadataResolver { if let Some(f) = file { debug!("Using filename-based metadata for: {}", f.file_path); } - Some(Self::resolve_from_filename(is_movie, metadata)) + Some(Self::resolve_from_filename(is_movie, metadata, file)) } else { if let Some(f) = file { warn!("No metadata found for: {}", f.file_path); @@ -179,13 +179,24 @@ impl MetadataResolver { } } - // Creates basic metadata from filename parsing - fn resolve_from_filename(movie: bool, metadata: &PttMetadata) -> MediaMetadata { + // Creates basic metadata from filename parsing. + // Falls back to the file stem when the parsed title is empty or purely numeric + // (e.g. date-based filenames like "2026-02-15 10-21-20.mkv" where PTT extracts "15"). + fn resolve_from_filename(movie: bool, metadata: &PttMetadata, file: Option<&ScannedMediaFile>) -> MediaMetadata { let timestamp = chrono::Utc::now().timestamp(); + let title = if metadata.title.is_empty() || metadata.title.chars().all(|c| c.is_ascii_digit()) { + file.map_or_else(|| metadata.title.clone(), |f| f.path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&f.file_name) + .to_string()) + } else { + metadata.title.clone() + }; + if movie { MediaMetadata::Movie(MovieMetadata { - title: metadata.title.clone(), + title, year: metadata.year, tmdb_id: metadata.tmdb, tvdb_id: metadata.tvdb, @@ -195,7 +206,7 @@ impl MetadataResolver { }) } else { MediaMetadata::Series(SeriesMetadata { - title: metadata.title.clone(), + title, year: metadata.year, tmdb_id: metadata.tmdb, tvdb_id: metadata.tvdb, diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 89ce7b903..93df1f19b 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,4 +1,5 @@ use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; +use crate::library::resolve_metadata_storage_path; use crate::model::{AppConfig, ConfigInput}; use shared::concat_string; use shared::error::TuliproxError; @@ -14,8 +15,7 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) }; if !library_config.enabled { return (vec![], vec![]); } - let storage_path = config.metadata_update.as_ref() - .map_or_else(|| std::path::PathBuf::from(shared::utils::default_metadata_path()), |m| std::path::PathBuf::from(&m.cache_path)) + let storage_path = resolve_metadata_storage_path(config.metadata_update.as_ref(), &config.storage_dir) .join("library"); let mut metadata_iter = MetadataAsyncIter::new(&storage_path).await; let mut group_movies = PlaylistGroup { diff --git a/docs/src/configuration/reverse-proxy.md b/docs/src/configuration/reverse-proxy.md index 2dc323817..87e24d99f 100644 --- a/docs/src/configuration/reverse-proxy.md +++ b/docs/src/configuration/reverse-proxy.md @@ -1,10 +1,11 @@ # 🌊 Reverse Proxy (Streaming, Caching & Rate Limits) -This section documents the `reverse_proxy:` block inside `config.yml`. It is the most critical block for determining runtime -behavior when Tuliprox actively proxies video streams to clients (Reverse Proxy Mode), rather than just redirecting them. +This section documents the `reverse_proxy:` block inside `config.yml`. It is the most critical block +for determining runtime behavior when Tuliprox actively proxies video streams to clients +(Reverse Proxy Mode), rather than just redirecting them. -It manages how Tuliprox establishes upstream connections, buffers video frames, handles sudden client disconnects, and caches -static resources like EPG images and channel logos. +It manages how Tuliprox establishes upstream connections, buffers video frames, handles sudden +client disconnects, and caches static resources like EPG images and channel logos. ## Top-level entries @@ -20,12 +21,22 @@ reverse_proxy: geoip: ``` +> **Note:** Reverse Proxy mode can be activated for each user individually. + ### General Parameters | Parameter | Type | Default | Technical Impact & Background | | :--- | :--- | :--- | :--- | | `resource_rewrite_disabled` | Bool | `false` | Normally, Tuliprox rewrites all image URLs in playlists to point to itself (e.g., `http://tuliprox:8901/resource/...`). If set to `true`, original URLs are kept (clients load images directly from the provider). **Warning:** Local caching will stop working if this is enabled! | -| `rewrite_secret` | String | `""` | A 32-character Hex string (16 bytes). Tuliprox encrypts/signs the original image URLs during the rewrite process. To prevent image URLs from becoming invalid after a server restart, you MUST enter a static secret here (generate via `openssl rand -hex 16`). | +| `rewrite_secret` | String | `""` | A 32-character Hex string (16 bytes). Tuliprox encrypts/signs the original image URLs during the rewrite process. To prevent image URLs from becoming invalid after a server restart, you MUST enter a static secret here. | + +> **Note**: You can generate a random secret using: + +```bash +openssl rand -hex 16 +# or +node -e "console.log(require('crypto').randomBytes(16).toString('hex').toUpperCase())" +``` --- @@ -59,7 +70,7 @@ reverse_proxy: | `buffer.size` | Int | `0` | The size of the buffer in *Chunks* (1 Chunk = 8192 Bytes). A value of `1024` equals approximately 8 Megabytes of RAM per active stream. | | `throttle_kbps` | Int | `0` | **Background:** Some players download VODs (Movies) at maximum line speed ("Bursting"). Providers often view this as abuse or scraping and will ban the IP. By throttling (e.g., to `12500` kbps), you force the download into a constant, inconspicuous flow. Supports units like `KB/s`, `MB/s`, `kbps`, `Mibps`. | | `metrics_enabled` | Bool | `false` | **Monitoring:** If active, Tuliprox samples the live bandwidth (in kbps) and transferred bytes for every active reverse-proxied stream and pushes them via WebSockets to the Web UI. It adds a tiny bit of CPU overhead but is invaluable for debugging buffering issues. | -| `grace_period_millis` | Int | `2000` | The exact time window in ms where a temporary over-allocation is allowed (see notes on [The VLC Seek Problem](#the-vlc-seek-problem--grace-periods) for details). | +| `grace_period_millis` | Int | `2000` | The exact time window in ms where a temporary over-allocation is allowed (see notes on [The VLC Seek Problem](#31-the-vlc-seek-problem--grace-periods) for details). | | `grace_period_timeout_secs` | Int | `4` | A hard timeout limit for overlapping "ghost sessions" to expire. | | `grace_period_hold_stream` | Bool | `true` | Tuliprox artificially holds back the video data to the client, waiting for the grace check to finish, so it doesn't trigger the provider prematurely. | | `hls_session_ttl_secs` | Int | `15` | Keeps the virtual provider slot open between HLS segment (`.ts`) requests to prevent provider bans for "Account Hopping". | @@ -68,10 +79,60 @@ reverse_proxy: --- +### 1.1 `retry` & `buffer` (Deep Dive) + +Tuliprox handles streams differently based on these settings: + +* **Option A:** Both `retry: false` and `buffer.enabled: false` âž” The provider stream is piped + directly to the client with minimal overhead. +* **Option B:** Either `retry: true` or `buffer.enabled: true` âž” Tuliprox uses complex stream + handling with a higher memory footprint to ensure stability. + +#### Ring-Buffer Calculation + +`buffer.size` is defined in chunks of **8192 bytes (8 KB)**. + +* A size of `1024` equals approx. **8 MB** of RAM per active stream. +* **Shared Streams Impact:** If `share_live_streams` is enabled, each channel consumes at least + **12 MB** regardless of client count. Increasing `size` above 1024 (e.g., 2048) increases this + **24 MB** per shared channel. + +### 1.2 `throttle_kbps` + +Prevents provider bans by limiting "bursting" players. Supported units: `KB/s`, `MB/s`, `KiB/s`, `MiB/s`, `kbps`, `mbps`, `Mibps`. + +**Reference Table for Throttling:** + +| Resolution | Framerate | Bitrate (kbps) | Quality | +| :--- | :--- | :--- | :--- | +| 480p (854x480) | 30 fps | 819 – 2,457 | Low-Quality | +| 720p (1280x720) | 30 fps | 2,457 – 5,737 | HD-Streams | +| 1080p (1920x1080) | 30 fps | 5,737 – 12,288 | Full-HD | +| 4K (3840x2160) | 30 fps | 20,480 – 49,152 | Ultra-HD | + +### 1.3 `grace_period` (The VLC Seek Problem) + +If `max_connections` is > 0, seeking can trigger a 509/401 error because the old connection isn't closed yet. + +* `grace_period_millis` (Default: `2000`): Grants a temporary over-allocation during switchover. +* `grace_period_timeout_secs` (Default: `4`): How long a grace grant lasts before a new one can be made. +* `grace_period_hold_stream`: If `true`, Tuliprox waits for the check to complete before sending data. + +### 1.4 HLS & Catchup Session TTLs + +HLS/Catchup clients connect and disconnect repeatedly. Tuliprox uses a **Virtual Reservation** to maintain account affinity: + +* **HLS (`15s`):** The real provider slot is only held during active requests. + The reservation keeps the provider account stable between segment fetches. +* **Catchup (`45s`):** Keeps the reservation alive during seeking and reconnects. +* **Note:** Channel switches from the same client immediately take over the reservation. + +--- + ## 2. Resource Caching (`cache`) -Tuliprox caches channel logos, posters, and EPG images on your disk so your clients don't stress the provider's servers on every -playlist reload. +Tuliprox maintains a local disk cache for channel logos, posters, and EPG images. This reduces +the load on provider servers and significantly speeds up playlist loading times for your clients. ```yaml reverse_proxy: @@ -81,13 +142,31 @@ reverse_proxy: directory: ./cache ``` -An LRU (Least Recently Used) disk cache. If it hits the limit (e.g., `1GB`), Tuliprox automatically deletes the oldest images. -Note: Fails if `resource_rewrite_disabled` is true. +### Parameter Details + +| Parameter | Type | Default | Technical Impact | +| :--- | :--- | :--- | :--- | +| `enabled` | Bool | `false` | Global switch for resource caching. **Note:** Requires `resource_rewrite_disabled: false`. | +| `size` | Size | `1GB` | Maximum disk space allocation. Supported units: `KB`, `MB`, `GB`, `TB`. | +| `directory` | String | `./cache` | Storage location. Relative paths are resolved against the `storage_dir`. | + +--- + +### Technical Background + +* **LRU Logic:** Operates as a **Least Recently Used** cache. When the `size` limit is reached, + Tuliprox automatically evicts the oldest accessible images to make room for new content. +* **Encryption:** Cached resources are indexed using a hash derived from the `rewrite_secret`. +* **Client Delivery:** Instead of the client downloading directly from the provider, Tuliprox + serves the local file, acting as a high-speed CDN for your media metadata. --- ## 3. Rate Limiting (`rate_limit`) +This block implements an IP-based **Token-Bucket** rate limiter. It protects your Tuliprox instance +and upstream providers from DDoS attacks or aggressive players. + ```yaml reverse_proxy: rate_limit: @@ -96,32 +175,57 @@ reverse_proxy: burst_size: 10 ``` -Implements an IP-based Token-Bucket rate limiter. In this example, an IP can fire 10 requests immediately (`burst_size`). After -that, it receives exactly one new token every 500ms (`period_millis`). This prevents DDOS attacks from malfunctioning scrapers. -*(Ensure your upstream Nginx/Traefik passes `X-Forwarded-For` for this to work correctly!)* +### Parameter Details + +| Parameter | Type | Default | Technical Impact | +| :--- | :--- | :--- | :--- | +| `enabled` | Bool | `false` | Global switch for the rate limiting engine. | +| `period_millis` | Int | `500` | The refill rate. Defines how many milliseconds it takes to replenish exactly one request token. | +| `burst_size` | Int | `10` | The bucket capacity. Allows a client to send this many requests instantly before the rate limit kicks in. | + +### Technical Background + +* **Mechanism:** A client starts with a full "bucket" of `burst_size` tokens. Every request consumes + one token. Once empty, the client must wait `period_millis` for a new token to be generated. +* **IP-Detection:** Tuliprox identifies clients by their IP address. If you are behind a proxy, + ensure headers like `X-Forwarded-For` or `X-Real-IP` are passed correctly. +* **Behavior:** When a client exceeds the limit, Tuliprox returns an `HTTP 429`. --- ## 4. Header Stripping (`disabled_header`) -When Tuliprox makes requests to the upstream provider, it can strip revealing headers that might expose which player you are -actually using or the fact that you are proxying traffic. +This block controls which HTTP headers Tuliprox removes before forwarding a client request to the +upstream provider. Stripping headers is essential to prevent proxy detection. ```yaml reverse_proxy: disabled_header: - referer_header: true # Removes the 'Referer' header - x_header: true # Removes all 'X-*' headers (like X-Real-IP) - cloudflare_header: true# Removes 'CF-*' headers - custom_header: - - my-custom-tracker + referer_header: true # Removes 'Referer' (prevents leaking your Tuliprox URL/Domain) + x_header: true # Removes all 'X-*' headers (e.g., X-Forwarded-For, X-Real-IP) + cloudflare_header: true # Removes 'CF-*' headers (hides Cloudflare origin details) + custom_header: # List of additional specific headers to be dropped + - "X-Powered-By" + - "my-custom-tracker" ``` +### Parameter Details + +| Parameter | Type | Default | Technical Impact | +| :--- | :--- | :--- | :--- | +| `referer_header` | Bool | `false` | Suppresses the source of the request. Prevents your Tuliprox from being logged at the provider. | +| `x_header` | Bool | `false` | **Critical:** Wildcard-strips all headers starting with `X-`. These are the most common indicators for proxy detection. | +| `cloudflare_header` | Bool | `false` | Removes Cloudflare-specific headers. Essential if Tuliprox itself is running behind a Cloudflare proxy. | +| `custom_header` | List | `[]` | Manual blacklist for headers not covered by the automatic toggles above (e.g., vendor-specific trackers). | + --- +> **Security Note:** Many providers analyze headers for "restreaming" patterns. Combining +> `x_header: true` with a neutral `user_agent` in your `source.yml` provides the best protection. ## 5. Resource Retries (`resource_retry`) -Defines how aggressively Tuliprox tries to retry failed logo or EPG downloads when proxying them from the provider. +This block defines the retry behavior when Tuliprox proxies static resources (logos, EPG images). +It ensures transient network issues or temporary provider timeouts don't result in broken images. ```yaml reverse_proxy: @@ -133,16 +237,26 @@ reverse_proxy: - "service-abuse" ``` -* **Retries:** After the first error, Tuliprox waits 250ms. After the second error, it waits `250 * 1.5 = 375ms`, and so on. -* **`failover_redirect_patterns`:** A list of Regex patterns. If an upstream resource responds with an HTTP Redirect (302) - containing these patterns (e.g., pointing to a "service-abuse" warning image from the provider), Tuliprox treats it as a - failure instead of blindly serving the abuse image to your clients. +### Parameter Details ---- +| Parameter | Type | Default | Technical Impact | +| :--- | :--- | :--- | :--- | +| `max_attempts` | Int (u8) | `3` | Maximum number of download tries before giving up. Minimum is `1`. | +| `backoff_millis` | Duration | `250` | Initial wait time (ms) after the first failure. | +| `backoff_multiplier` | Float | `1.5` | Factor by which the delay grows. `> 1.0` creates an exponential backoff. | +| `failover_redirect_patterns` | List | `[]` | Regex patterns to identify "Abuse" or "Blocked" redirect URLs from providers. | + +### Technical Background + +* **Exponential Backoff:** The wait time for each attempt is calculated as: + $$ \text{delay} = \text{backoff\_millis} \times (\text{backoff\_multiplier}^{\text{attempt}-1}) $$ +* **Failover Logic:** Providers sometimes redirect blocked requests (HTTP 302) to an abuse image. + If a redirect URL matches a pattern in `failover_redirect_patterns`, Tuliprox triggers a retry. +* **Smart Handling:** If an upstream server sends a `Retry-After` header, Tuliprox prioritizes that value over the local backoff calculation. ## 6. GeoIP Resolution (`geoip`) -To see the country flags of connected clients in the Web UI ("Active Streams" tab), Tuliprox can resolve IP addresses locally. +This block enables local IP-to-Country mapping. It allows for country flags in the Web UI. ```yaml reverse_proxy: @@ -151,8 +265,21 @@ reverse_proxy: url: "https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn-country/asn-country-ipv4.csv" ``` -The CSV file must have exactly 3 columns: `range_start,range_end,country_code`. (The DB is periodically updated via the -`schedules` block using the `GeoIpUpdate` task type). +### Parameter Details + +| Parameter | Type | Default | Technical Impact | +| :--- | :--- | :--- | :--- | +| `enabled` | Bool | `false` | Global switch for GeoIP resolution. | +| `url` | String | *(Optional)* | Source URL for the GeoIP CSV database. | + +### Technical Background + +* **Data Format:** Tuliprox requires a CSV format with exactly three columns: + `range_start, range_end, country_code` + *Example:* `1.0.0.0, 1.0.0.255, AU` + +The CSV file must have exactly 3 columns: `range_start,range_end,country_code`. +(The DB is periodically updated via the `schedules` block using the `GeoIpUpdate` task type). --- @@ -160,43 +287,46 @@ The CSV file must have exactly 3 columns: `range_start,range_end,country_code`. ## Additional Information -## The "VLC Seek Problem" & Grace Periods +  -When a user fast-forwards or rewinds a VOD, the player calculates the new byte offset, drops the old TCP connection, and -immediately fires a new HTTP GET request (with a `Range` header) to Tuliprox. +### Session TTLs for HLS (`.m3u8`) & Catchup -**The Problem:** It takes milliseconds to seconds for the upstream provider to realize the old connection is dead. If you have -a `max_connections: 1` limit at the provider, they will view this new seek-request as a *second concurrent stream* and reject -it with an HTTP 509 (Bandwidth Exceeded) or HTTP 401 error. +HLS streams do not consist of an endless TCP pipe. Instead, the player downloads small `.ts` segments every few seconds (e.g., `seg1.ts`, `seg2.ts`). + +If Tuliprox released and re-acquired the slot for every single segment, providers would block +the account for "Account Hopping". Tuliprox simulates a continuous session: + +* `hls_session_ttl_secs: 15`: After a segment finishes downloading, the physical slot is closed, + but the "Virtual Slot" remains reserved for 15 seconds. +* The same principle applies to Archive/Catchup TV (`catchup_session_ttl_secs: 45`). + +--- + +### Shared Live Streams + +Tuliprox can share a live-stream (`share_live_streams: true`). If 5 users watch the same channel, +Tuliprox pulls the stream 1x and multicasts the bytes to 5 clients. +To ensure synchronization, Tuliprox keeps the last X MB (`shared_burst_buffer_mb`, default `12`) in +RAM and fires it at new subscribers. + +--- + +### 3.1 The "VLC Seek Problem" & Grace Periods + +When a user fast-forwards or rewinds a VOD, the player calculates the new byte offset, drops the old TCP connection, +and immediately fires a new HTTP GET request (with a `Range` header) to Tuliprox. + +**The Problem:** It takes milliseconds to seconds for the upstream provider to realize the old connection is dead. +If you have a `max_connections: 1` limit at the provider, they will view this new seek-request +as a *second concurrent stream* and reject it with an HTTP 509 (Bandwidth Exceeded) or HTTP 401 error. **The Tuliprox Solution:** * `grace_period_millis: 2000`: Tuliprox grants the user a temporary over-allocation (Grace) for exactly this duration. -* `grace_period_hold_stream: true`: Tuliprox artificially holds back the video data to the client, waiting for the grace check - to finish, so it doesn't trigger the provider prematurely. -* After the milliseconds expire, Tuliprox checks internally: Is the old connection truly gone now? If Yes âž” Data flows. - If No âž” The new connection is hard-killed (serving the `user_connections_exhausted.ts` video) because the user is actually - illegally watching twice. +* `grace_period_hold_stream: true`: Tuliprox artificially holds back the video data to the client, + waiting for the grace check to finish, so it doesn't trigger the provider prematurely. +* After the milliseconds expire, Tuliprox checks internally: Is the old connection truly gone now? + * If Yes âž” Data flows. + * If No âž” The new connection is hard-killed (serving the `user_connections_exhausted.ts` video) + because the user is actually illegally watching twice. * `grace_period_timeout_secs: 4`: A hard timeout limit for overlapping "ghost sessions" to expire. - -## Session TTLs for HLS (`.m3u8`) & Catchup - -HLS streams do not consist of an endless TCP pipe. Instead, the player downloads small `.ts` segments every few seconds -(e.g., `seg1.ts`, `seg2.ts`). - -If Tuliprox released and re-acquired the provider slot for every single segment, providers would block the account for "Account -Hopping" or spam. Tuliprox simulates a continuous session: - -* `hls_session_ttl_secs: 15`: After a `.ts` segment finishes downloading, the physical slot to the provider is closed, but the - "Virtual Slot" for this specific user remains reserved for 15 seconds. No other user can steal this slot during this window. - Channel switches from the same client can immediately take over the reservation. -* The same principle applies to Archive/Catchup TV (`catchup_session_ttl_secs: 45`), which shares the same fragmentation and - seeking issues. - -## Shared Live Streams - -Tuliprox can share a live stream (`share_live_streams: true` in the target options of `source.yml`). If 5 users watch the same -Live-TV channel, Tuliprox pulls the stream only 1x from the provider and multicasts the bytes locally to 5 clients. -To ensure a user who tunes in 10 seconds later doesn't get player errors due to missing I-Frames/Keyframes, Tuliprox -continuously keeps the last X Megabytes (`shared_burst_buffer_mb`, default `12`) in RAM. It fires this burst buffer at new -subscribers so their decoders can instantly synchronize.