mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-25 01:02:29 +02:00
Merge pull request #485 from euzu/feature/refactoring_stream_properties
Breaking Changes - Data format changed — you must clear your data folder and refresh playlists. - Batch inputs now require a non-empty name (affects playlist UUIDs). - New DB viewer options expose on-disk DB content (see CLI). New Features - Rich, typed stream metadata and improved info documents for Live/Video/Series. - New PTT title parser extracting detailed title metadata (year, TMDB, episodes). - CLI DB viewer: --dbx and --dbm to inspect local DB files. Improvements - Numeric channel numbers (tvg-chno), better playlist download/persistence, STRM naming/quality. - MIME-aware resource caching and more permissive config handling for missing fields.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
# Changelog
|
||||
# 3.3.0 (2025-11-xx)
|
||||
- !BREAKING CHANGE! Due to some heavy refactoring, the old data format is invalid. You need to clean your data folder and update the playlists
|
||||
- !BREAKING CHANGE! config.yml threads attribute is now renamed to process_parallel and is a boolean (true or false).
|
||||
- !BREAKING CHANGE! config.yml adds a reverse proxy config field rewrite_secret to keep resource URLs valid after restart.
|
||||
- !BREAKING CHANGE! removed `forced_retry_interval_secs`.
|
||||
- !BREAKING CHANGE! `name` attribute is mandatory for input type batch. The name attribute is used for playlist `uuid` and needs to be stable. The first alias is renamed with the input `name` attribute.
|
||||
- Avoid blocking the runtime when warming the cache.
|
||||
- Normalize FileLockManager paths so aliases share the same lock.
|
||||
- Use async file operations for playlist persistence to avoid blocking the async runtime.
|
||||
@@ -52,6 +54,9 @@
|
||||
- Made cache storage more robust. Incomplete downloads will be deleted from cache.
|
||||
- `kick_secs` added to config.yml `web_ui` config. Default 90 seconds, if a user is kicked from the `web_ui`, they can't connect for this duration.
|
||||
This setting is also used for sleep-timed streams.
|
||||
- Added new db-viewer options to print db content.
|
||||
`tuliprox --dbx /opt/tuliprox/data/all_channels/xtream/video.db`
|
||||
`tuliprox --dbm /opt/tuliprox/data/all_channels/m3u.db`
|
||||
|
||||
# 3.2.0 (2025-11-14)
|
||||
- Added `name` attribute to Staged Input.
|
||||
|
||||
Generated
+64
-41
@@ -258,24 +258,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "2.0.1"
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bincode_derive",
|
||||
"serde",
|
||||
"unty",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bincode_derive"
|
||||
version = "2.0.1"
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
|
||||
dependencies = [
|
||||
"virtue",
|
||||
]
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
@@ -995,6 +990,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1096,7 +1102,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "frontend"
|
||||
version = "3.2.24"
|
||||
version = "3.2.25"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -1672,7 +1678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a"
|
||||
dependencies = [
|
||||
"anymap2",
|
||||
"bincode 1.3.3",
|
||||
"bincode",
|
||||
"gloo-console 0.2.3",
|
||||
"gloo-utils 0.1.7",
|
||||
"js-sys",
|
||||
@@ -1688,7 +1694,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400"
|
||||
dependencies = [
|
||||
"bincode 1.3.3",
|
||||
"bincode",
|
||||
"futures",
|
||||
"gloo-utils 0.2.0",
|
||||
"gloo-worker-macros",
|
||||
@@ -1707,7 +1713,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d"
|
||||
dependencies = [
|
||||
"bincode 1.3.3",
|
||||
"bincode",
|
||||
"futures",
|
||||
"gloo-utils 0.2.0",
|
||||
"gloo-worker-macros",
|
||||
@@ -1825,6 +1831,12 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
@@ -2396,6 +2408,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -3415,6 +3436,25 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "route-recognizer"
|
||||
version = "0.3.1"
|
||||
@@ -3558,15 +3598,6 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
@@ -3793,7 +3824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shared"
|
||||
version = "3.2.24"
|
||||
version = "3.2.25"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.10.0",
|
||||
@@ -3803,9 +3834,11 @@ dependencies = [
|
||||
"ciborium",
|
||||
"enum-iterator",
|
||||
"fastrand",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"js-sys",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"path-clean",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
@@ -3813,6 +3846,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -4356,13 +4390,12 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tuliprox"
|
||||
version = "3.2.24"
|
||||
version = "3.2.25"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-compression",
|
||||
"axum",
|
||||
"base64",
|
||||
"bincode 2.0.1",
|
||||
"blake3",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -4374,6 +4407,7 @@ dependencies = [
|
||||
"deunicode",
|
||||
"enum-iterator",
|
||||
"env_logger",
|
||||
"fancy-regex",
|
||||
"fastrand",
|
||||
"filetime",
|
||||
"flate2",
|
||||
@@ -4386,6 +4420,7 @@ dependencies = [
|
||||
"log",
|
||||
"lol_html",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"notify",
|
||||
"openssl",
|
||||
"paste",
|
||||
@@ -4397,10 +4432,10 @@ dependencies = [
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"rpassword",
|
||||
"rphonetic",
|
||||
"rust-argon2",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -4481,12 +4516,6 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "unty"
|
||||
version = "0.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.7"
|
||||
@@ -4570,12 +4599,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "virtue"
|
||||
version = "0.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -66,6 +66,8 @@ Options:
|
||||
--healthcheck Healtcheck for docker
|
||||
--scan-library Scan library directories
|
||||
--force-library-rescan Force full library rescan
|
||||
--dbx Database file type: xtream
|
||||
--dbm Database file type: m3u
|
||||
```
|
||||
|
||||
## 1. `config.yml`
|
||||
@@ -684,13 +686,6 @@ library:
|
||||
formats:
|
||||
- "nfo" # Optionally write Kodi-compatible NFO files
|
||||
|
||||
classification:
|
||||
series_patterns:
|
||||
- "(?i)s\\d+e\\d+" # S01E02 format
|
||||
- "(?i)\\d+x\\d+" # 1x02 format
|
||||
- "(?i)season[\\s._-]*\\d+" # "Season 1" format
|
||||
- "(?i)episode[\\s._-]*\\d+" # "Episode 1" format
|
||||
|
||||
playlist:
|
||||
movie_category: "Local Movies"
|
||||
series_category: "Local Series"
|
||||
@@ -704,6 +699,10 @@ library:
|
||||
|
||||
# Force full rescan (ignores modification timestamps)
|
||||
./tuliprox --force-library-rescan
|
||||
|
||||
# Show db content
|
||||
./tuliprox --dbx /opt/tuliprox/data/all_channels/xtream/video.db
|
||||
./tuliprox --dbm /opt/tuliprox/data/all_channels/m3u.db
|
||||
```
|
||||
|
||||
**API Endpoints**:
|
||||
@@ -907,7 +906,7 @@ Input alias definition for same provider with same content but different credent
|
||||
- sources:
|
||||
- inputs:
|
||||
- type: xtream
|
||||
name: my_provider
|
||||
name: my_provider # Mandatory: used for playlist UUID generation
|
||||
url: 'http://provider.net'
|
||||
username: xyz
|
||||
password: secret1
|
||||
@@ -930,6 +929,7 @@ There are 2 batch input types `xtream_batch` and `m3u_batch`.
|
||||
- sources:
|
||||
- inputs:
|
||||
- type: xtream_batch
|
||||
name: my_provider # Mandatory: used for playlist UUID generation
|
||||
url: 'file:///home/tuliprox/config/my_provider_batch.csv'
|
||||
targets:
|
||||
- name: test
|
||||
@@ -940,6 +940,8 @@ There are 2 batch input types `xtream_batch` and `m3u_batch`.
|
||||
my_provider_1;user1;password1;http://my_provider_1.com:80;1;0;2028-11-23 12:34:23
|
||||
my_provider_2;user2;password2;http://my_provider_2.com:8080;1;0;2028-11-23 12:34:23
|
||||
```
|
||||
Important: the first alias is renamed with the `name` from input definition. `my_provider_1` gets `my_provider`.
|
||||
This is necessary because of playlist uuid generation and assigning same channel numbers on each update.
|
||||
|
||||
##### `M3uBatch`
|
||||
```yaml
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuliprox"
|
||||
version = "3.2.24"
|
||||
version = "3.2.25"
|
||||
edition = "2021"
|
||||
rust-version = "1.87.0"
|
||||
|
||||
@@ -35,7 +35,7 @@ deunicode = "1.6.2"
|
||||
mime = "0.3"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
bincode = { version = "2.0", features = ["std", "serde"] }
|
||||
rmp-serde = "1"
|
||||
rand = "0.9"
|
||||
fastrand = "2"
|
||||
rpassword = "7.4"
|
||||
@@ -50,7 +50,6 @@ tokio = { version = "1.48", features = ["rt-multi-thread", "parking_lot", "fs"]
|
||||
#tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
tokio-util = { version = "0.7", features = ["io", "io-util"] }
|
||||
tempfile = "3.23"
|
||||
ruzstd = "0.8"
|
||||
filetime = "0.2"
|
||||
strsim = "0.11"
|
||||
rphonetic = "3.0"
|
||||
@@ -74,6 +73,8 @@ chrono-tz = "0.10.4"
|
||||
zeroize = "1.8.2"
|
||||
sysinfo = { version = "0.37.2", default-features = false, features = ["system"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
fancy-regex = "0.17.0"
|
||||
mime_guess = "2"
|
||||
# [target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
# tikv-jemallocator = { version = "*", features = ["profiling", "unprefixed_malloc_on_supported_platforms"] }
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::BUILD_TIMESTAMP;
|
||||
use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext};
|
||||
use crate::api::model::{
|
||||
create_channel_unavailable_stream, create_custom_video_stream_response,
|
||||
@@ -13,10 +14,12 @@ use crate::api::panel_api::try_provision_account_on_exhausted;
|
||||
use crate::model::{ConfigInput, ResourceRetryConfig};
|
||||
use crate::model::{ConfigTarget, ProxyUserCredentials};
|
||||
use crate::tools::lru_cache::LRUResourceCache;
|
||||
use crate::utils::request;
|
||||
use crate::utils::{request};
|
||||
use crate::utils::{async_file_reader, async_file_writer, create_new_file_for_write, get_file_extension};
|
||||
use crate::utils::{debug_if_enabled, trace_if_enabled};
|
||||
use crate::BUILD_TIMESTAMP;
|
||||
use crate::auth::Fingerprint;
|
||||
use crate::utils::request::{content_type_from_ext, parse_range};
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
@@ -125,8 +128,6 @@ pub use try_option_bad_request;
|
||||
pub use try_result_bad_request;
|
||||
pub use try_result_not_found;
|
||||
pub use try_unwrap_body;
|
||||
use crate::auth::Fingerprint;
|
||||
use crate::utils::request::{content_type_from_ext, parse_range};
|
||||
|
||||
pub fn get_server_time() -> String {
|
||||
chrono::offset::Local::now()
|
||||
@@ -144,7 +145,7 @@ pub fn get_build_time() -> Option<String> {
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_panics_doc)]
|
||||
pub async fn serve_file(file_path: &Path, mime_type: mime::Mime) -> impl IntoResponse + Send {
|
||||
pub async fn serve_file(file_path: &Path, mime_type: String) -> impl IntoResponse + Send {
|
||||
match tokio::fs::try_exists(file_path).await {
|
||||
Ok(exists) => {
|
||||
if !exists {
|
||||
@@ -165,7 +166,7 @@ pub async fn serve_file(file_path: &Path, mime_type: mime::Mime) -> impl IntoRes
|
||||
|
||||
try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(axum::http::header::CONTENT_TYPE, mime_type.to_string())
|
||||
.header(axum::http::header::CONTENT_TYPE, mime_type)
|
||||
.header(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::header::HeaderValue::from_static("no-cache")
|
||||
@@ -614,7 +615,7 @@ where
|
||||
P: PlaylistEntry,
|
||||
{
|
||||
let item_type = params.item.get_item_type();
|
||||
let provider_url = ¶ms.item.get_provider_url();
|
||||
let provider_url = params.item.get_provider_url();
|
||||
|
||||
let redirect_request =
|
||||
params.user.proxy.is_redirect(item_type) || params.target.is_force_redirect(item_type);
|
||||
@@ -626,17 +627,17 @@ where
|
||||
if params.target_type == TargetType::M3u {
|
||||
if redirect_request || is_dash_request {
|
||||
let redirect_url = if is_hls_request {
|
||||
&replace_url_extension(provider_url, HLS_EXT)
|
||||
Cow::Owned(replace_url_extension(&provider_url, HLS_EXT))
|
||||
} else {
|
||||
provider_url
|
||||
};
|
||||
let redirect_url = if is_dash_request {
|
||||
&replace_url_extension(redirect_url, DASH_EXT)
|
||||
Cow::Owned(replace_url_extension(&redirect_url, DASH_EXT))
|
||||
} else {
|
||||
redirect_url
|
||||
};
|
||||
let redirect_url =
|
||||
get_redirect_alternative_url(app_state, redirect_url, params.input).await;
|
||||
get_redirect_alternative_url(app_state, &redirect_url, params.input).await;
|
||||
debug_if_enabled!(
|
||||
"Redirecting stream request to {}",
|
||||
sanitize_sensitive_info(&redirect_url)
|
||||
@@ -669,8 +670,8 @@ where
|
||||
let stream_url = match get_xtream_player_api_stream_url(
|
||||
params.input,
|
||||
params.req_context,
|
||||
¶ms.get_query_path(provider_id, provider_url),
|
||||
provider_url,
|
||||
¶ms.get_query_path(provider_id, &provider_url),
|
||||
&provider_url,
|
||||
) {
|
||||
None => {
|
||||
error!("Cant find stream url for target {target_name}, context {}, stream_id {virtual_id}", params.req_context);
|
||||
@@ -1208,25 +1209,39 @@ pub fn get_headers_from_request(
|
||||
|
||||
fn get_add_cache_content(
|
||||
res_url: &str,
|
||||
mime_type: Option<String>,
|
||||
cache: &Arc<ArcSwapOption<Mutex<LRUResourceCache>>>,
|
||||
) -> Arc<dyn Fn(usize) + Send + Sync> {
|
||||
let resource_url = String::from(res_url);
|
||||
let cache = Arc::clone(cache);
|
||||
let add_cache_content: Arc<dyn Fn(usize) + Send + Sync> = Arc::new(move |size| {
|
||||
let res_url = resource_url.clone();
|
||||
|
||||
let mime_type = mime_type.clone();
|
||||
// todo spawn, replace with unboundchannel
|
||||
let cache = Arc::clone(&cache);
|
||||
tokio::spawn(async move {
|
||||
if let Some(cache) = cache.load().as_ref() {
|
||||
let _ = cache.lock().await.add_content(&res_url, size);
|
||||
let _ = cache.lock().await.add_content(&res_url, mime_type, size);
|
||||
}
|
||||
});
|
||||
});
|
||||
add_cache_content
|
||||
}
|
||||
|
||||
async fn build_stream_response(
|
||||
fn get_mime_type(headers: &axum::http::HeaderMap, resource_url: &str) -> Option<String> {
|
||||
headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok()) // Option<&str>
|
||||
.map(ToString::to_string) // Option<String>
|
||||
.or_else(|| {
|
||||
// fallback to guess
|
||||
mime_guess::from_path(resource_url)
|
||||
.first_raw()
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_resource_stream_response(
|
||||
app_state: &Arc<AppState>,
|
||||
resource_url: &str,
|
||||
response: reqwest::Response,
|
||||
@@ -1235,6 +1250,7 @@ async fn build_stream_response(
|
||||
let status = response.status();
|
||||
let mut response_builder =
|
||||
axum::response::Response::builder().status(status);
|
||||
let mime_type = get_mime_type(response.headers(), resource_url);
|
||||
let has_content_range = response.headers().contains_key(axum::http::header::CONTENT_RANGE);
|
||||
for (key, value) in response.headers() {
|
||||
let name = key.as_str();
|
||||
@@ -1261,7 +1277,7 @@ async fn build_stream_response(
|
||||
if can_cache {
|
||||
debug!( "Caching eligible resource stream {sanitized_resource_url}");
|
||||
let cache_resource_path = if let Some(cache) = app_state.cache.load().as_ref() {
|
||||
Some(cache.lock().await.store_path(resource_url))
|
||||
Some(cache.lock().await.store_path(resource_url, mime_type.as_deref()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1270,7 +1286,7 @@ async fn build_stream_response(
|
||||
Ok(file) => {
|
||||
debug!("Persisting resource stream {sanitized_resource_url} to {}", resource_path.display());
|
||||
let writer = async_file_writer(file);
|
||||
let add_cache_content = get_add_cache_content(resource_url, &app_state.cache);
|
||||
let add_cache_content = get_add_cache_content(resource_url, mime_type, &app_state.cache);
|
||||
let tee = tee_stream(byte_stream, writer, &resource_path, add_cache_content);
|
||||
return try_unwrap_body!(response_builder.body(axum::body::Body::from_stream(tee)));
|
||||
}
|
||||
@@ -1313,7 +1329,7 @@ async fn fetch_resource_with_retry(
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Some(
|
||||
build_stream_response(app_state, resource_url, response).await,
|
||||
build_resource_stream_response(app_state, resource_url, response).await,
|
||||
);
|
||||
}
|
||||
// Retry only for 408, 425, 429 and all 5xx statuses
|
||||
@@ -1405,9 +1421,9 @@ pub async fn resource_response(
|
||||
let req_headers = get_headers_from_request(req_headers, &filter);
|
||||
if let Some(cache) = app_state.cache.load().as_ref() {
|
||||
let mut guard = cache.lock().await;
|
||||
if let Some(resource_path) = guard.get_content(resource_url) {
|
||||
if let Some((resource_path, mime_type)) = guard.get_content(resource_url) {
|
||||
trace_if_enabled!("Responding resource from cache {}", sanitize_sensitive_info(resource_url));
|
||||
return serve_file(&resource_path, mime::APPLICATION_OCTET_STREAM)
|
||||
return serve_file(&resource_path, mime_type.unwrap_or_else(|| mime::APPLICATION_OCTET_STREAM.to_string()))
|
||||
.await
|
||||
.into_response();
|
||||
}
|
||||
@@ -1513,7 +1529,7 @@ pub fn json_response<T: Serialize>(data: &T) -> impl IntoResponse + Send {
|
||||
(axum::http::StatusCode::OK, axum::Json(data)).into_response()
|
||||
}
|
||||
|
||||
pub fn json_or_bin_response<T: Serialize>(accept: Option<&String>, data: &T) -> impl IntoResponse + Send {
|
||||
pub fn json_or_bin_response<T: Serialize>(accept: Option<&str>, data: &T) -> impl IntoResponse + Send {
|
||||
if accept.is_some_and(|a| a.contains(CONTENT_TYPE_BIN)) {
|
||||
return bin_response(data).into_response();
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ fn group_playlist_groups_by_cluster(playlist: Vec<PlaylistGroup>) -> (Vec<Playli
|
||||
let mut series = Vec::new();
|
||||
for group in playlist {
|
||||
let channels = group.channels.iter()
|
||||
.map(shared::model::PlaylistItem::to_common)
|
||||
.map(CommonPlaylistItem::from)
|
||||
.collect();
|
||||
let grp = PlaylistResponseGroup {
|
||||
id: group.id,
|
||||
@@ -115,7 +115,7 @@ async fn grouped_channels(
|
||||
))
|
||||
}
|
||||
|
||||
pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig, accept: Option<&String>) -> impl axum::response::IntoResponse + Send {
|
||||
pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Option<&ConfigTarget>, cfg: &AppConfig, accept: Option<&str>) -> impl axum::response::IntoResponse + Send {
|
||||
if let Some(target) = cfg_target {
|
||||
if target.has_output(TargetType::Xtream) {
|
||||
let live_channels = grouped_channels(cfg, target, XtreamCluster::Live).await;
|
||||
@@ -144,13 +144,13 @@ pub(in crate::api::endpoints) async fn get_playlist_for_target(cfg_target: Optio
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid Arguments"}))).into_response()
|
||||
}
|
||||
|
||||
pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cfg_input: Option<&Arc<ConfigInput>>, cfg: &Arc<Config>, accept: Option<&String>) -> impl IntoResponse + Send {
|
||||
pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cfg_input: Option<&Arc<ConfigInput>>, cfg: &Arc<Config>, accept: Option<&str>) -> impl IntoResponse + Send {
|
||||
match cfg_input {
|
||||
Some(input) => {
|
||||
let (result, errors) =
|
||||
match input.input_type {
|
||||
InputType::M3u | InputType::M3uBatch => m3u::get_m3u_playlist(client, cfg, input).await,
|
||||
InputType::Xtream | InputType::XtreamBatch => xtream::get_xtream_playlist(cfg, client, input).await,
|
||||
InputType::M3u | InputType::M3uBatch => m3u::download_m3u_playlist(client, cfg, input).await,
|
||||
InputType::Xtream | InputType::XtreamBatch => xtream::download_xtream_playlist(cfg, client, input).await,
|
||||
InputType::Library => {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({ "error": "Library inputs are not supported on this endpoint"}))).into_response();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde_json::json;
|
||||
use shared::model::{
|
||||
M3uPlaylistItem, PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem,
|
||||
};
|
||||
use shared::utils::{concat_path, get_string_from_serde_value};
|
||||
use shared::utils::{concat_path};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
@@ -112,9 +112,7 @@ where
|
||||
o.xtream_live_stream_without_extension,
|
||||
)
|
||||
});
|
||||
let container_extension = item
|
||||
.get_additional_property("container_extension")
|
||||
.map(|v| get_string_from_serde_value(&v).unwrap_or_default());
|
||||
let container_extension = item.get_container_extension();
|
||||
let stream_url = match &base_url {
|
||||
None => item.url.clone(),
|
||||
Some(url) => get_xtream_url(
|
||||
@@ -123,7 +121,7 @@ where
|
||||
&credentials.username,
|
||||
&credentials.password,
|
||||
item.virtual_id,
|
||||
container_extension.as_ref(),
|
||||
container_extension.as_deref(),
|
||||
live_stream_use_prefix,
|
||||
live_stream_without_extension,
|
||||
),
|
||||
|
||||
@@ -174,7 +174,7 @@ pub(in crate::api) async fn handle_hls_stream_request(
|
||||
|
||||
async fn get_stream_channel(app_state: &Arc<AppState>, target: &Arc<ConfigTarget>, virtual_id: u32) -> Option<StreamChannel> {
|
||||
if target.has_output(TargetType::Xtream) {
|
||||
if let Ok((pli, _)) = xtream_repository::xtream_get_item_for_stream_id(virtual_id, app_state, target, None).await {
|
||||
if let Ok(pli) = xtream_repository::xtream_get_item_for_stream_id(virtual_id, app_state, target, None).await {
|
||||
return Some(pli.to_stream_channel(target.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use shared::utils::{concat_path_leading_slash};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::{Cursor};
|
||||
use std::sync::Arc;
|
||||
use log::error;
|
||||
use log::{error, info};
|
||||
use crate::api::endpoints::extract_accept_header::ExtractAcceptHeader;
|
||||
use crate::api::endpoints::v1_api_config::v1_api_config_register;
|
||||
use crate::api::endpoints::library_api::library_api_register;
|
||||
@@ -71,7 +71,7 @@ async fn status(axum::extract::State(app_state): axum::extract::State<Arc<AppSta
|
||||
async fn streams(ExtractAcceptHeader(accept): ExtractAcceptHeader,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>) -> axum::response::Response {
|
||||
let streams = app_state.active_users.active_streams().await;
|
||||
json_or_bin_response(accept.as_ref(), &streams).into_response()
|
||||
json_or_bin_response(accept.as_deref(), &streams).into_response()
|
||||
}
|
||||
|
||||
async fn geoip_update(axum::extract::State(app_state): axum::extract::State<Arc<AppState>>) -> axum::response::Response {
|
||||
@@ -106,6 +106,7 @@ async fn geoip_update(axum::extract::State(app_state): axum::extract::State<Arc<
|
||||
|
||||
return match result {
|
||||
(Some(_), None) => {
|
||||
info!("GeoIp db updated");
|
||||
app_state.geoip.store(Some(Arc::new(geoip)));
|
||||
axum::http::StatusCode::OK.into_response()
|
||||
},
|
||||
|
||||
@@ -80,7 +80,6 @@ async fn playlist_update(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn playlist_content(
|
||||
ExtractAcceptHeader(accept): ExtractAcceptHeader,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
@@ -90,16 +89,16 @@ async fn playlist_content(
|
||||
let client = app_state.http_client.load();
|
||||
match playlist_req {
|
||||
PlaylistRequest::Target(target_id) => {
|
||||
get_playlist_for_target(app_state.app_config.get_target_by_id(target_id).as_deref(), &app_state.app_config, accept.as_ref()).await.into_response()
|
||||
get_playlist_for_target(app_state.app_config.get_target_by_id(target_id).as_deref(), &app_state.app_config, accept.as_deref()).await.into_response()
|
||||
}
|
||||
PlaylistRequest::Input(input_id) => {
|
||||
get_playlist(client.as_ref(), app_state.app_config.get_input_by_id(input_id).as_ref(), &config, accept.as_ref()).await.into_response()
|
||||
get_playlist(client.as_ref(), app_state.app_config.get_input_by_id(input_id).as_ref(), &config, accept.as_deref()).await.into_response()
|
||||
}
|
||||
PlaylistRequest::CustomXtream(xtream) => {
|
||||
match Url::parse(&xtream.url) {
|
||||
Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {
|
||||
let input = Arc::new(create_config_input_for_xtream(&xtream.username, &xtream.password, &xtream.url));
|
||||
get_playlist(client.as_ref(), Some(&input), &config, accept.as_ref()).await.into_response()
|
||||
get_playlist(client.as_ref(), Some(&input), &config, accept.as_deref()).await.into_response()
|
||||
}
|
||||
_ => {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response()
|
||||
@@ -110,7 +109,7 @@ async fn playlist_content(
|
||||
match Url::parse(&m3u.url) {
|
||||
Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => {
|
||||
let input = Arc::new(create_config_input_for_m3u(&m3u.url));
|
||||
get_playlist(client.as_ref(), Some(&input), &config, accept.as_ref()).await.into_response()
|
||||
get_playlist(client.as_ref(), Some(&input), &config, accept.as_deref()).await.into_response()
|
||||
}
|
||||
_ => {
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url scheme; only http/https are allowed"}))).into_response()
|
||||
@@ -143,7 +142,7 @@ async fn playlist_epg(
|
||||
let config = &app_state.app_config.config.load();
|
||||
if let Some(epg_path) = crate::api::endpoints::xmltv_api::get_epg_path_for_target(config, &target) {
|
||||
if let Ok(epg) = parse_xmltv_for_web_ui_from_file(&epg_path).await {
|
||||
return json_or_bin_response(accept.as_ref(), &epg).into_response();
|
||||
return json_or_bin_response(accept.as_deref(), &epg).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +160,7 @@ async fn playlist_epg(
|
||||
}
|
||||
PlaylistEpgRequest::Custom(url) => {
|
||||
if let Ok(epg) = parse_xmltv_for_web_ui_from_url(&app_state, &url).await {
|
||||
return json_or_bin_response(accept.as_ref(), &epg).into_response();
|
||||
return json_or_bin_response(accept.as_deref(), &epg).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ async fn index(
|
||||
error!("Failed to read web ui index.html: {err}");
|
||||
}
|
||||
}
|
||||
serve_file(&path, mime::TEXT_HTML_UTF_8).await.into_response()
|
||||
serve_file(&path, mime::TEXT_HTML_UTF_8.to_string()).await.into_response()
|
||||
}
|
||||
|
||||
async fn index_config(
|
||||
@@ -279,7 +279,7 @@ async fn index_config(
|
||||
}
|
||||
}
|
||||
}
|
||||
serve_file(&path, mime::APPLICATION_JSON).await.into_response()
|
||||
serve_file(&path, mime::APPLICATION_JSON.to_string()).await.into_response()
|
||||
}
|
||||
|
||||
pub fn index_register_without_path(web_dir_path: &Path) -> axum::Router<Arc<AppState>> {
|
||||
|
||||
@@ -305,7 +305,7 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc<AppState>, auth_req
|
||||
maybe_msg = socket.recv() => {
|
||||
if let Some(msg) = maybe_msg {
|
||||
if let Err(e) = handle_incoming_message(msg, &mut socket, &mut handler, &app_state, auth_required, secret_key.as_ref()).await {
|
||||
error!("WebSocket message handling error: {e}");
|
||||
trace!("WebSocket message handling error: {e}");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -315,7 +315,7 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc<AppState>, auth_req
|
||||
|
||||
Ok(event) = event_rx.recv() => {
|
||||
if let Err(e) = handle_event_message(&mut socket, event, &handler).await {
|
||||
error!("Failed to send ws event: {e}");
|
||||
trace!("Failed to send ws event: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ pub(in crate::api) fn get_epg_path_for_target(config: &Config, target: &ConfigTa
|
||||
/// or a timezone name (e.g. "`Europe/Berlin`", "`UTC`", "`America/New_York`").
|
||||
///
|
||||
/// Returns the total offset in minutes (i32).
|
||||
fn parse_timeshift(time_shift: Option<&String>) -> Option<i32> {
|
||||
fn parse_timeshift(time_shift: Option<&str>) -> Option<i32> {
|
||||
time_shift.and_then(|offset| {
|
||||
// Try to parse as timezone name first
|
||||
if let Ok(tz) = offset.parse::<Tz>() {
|
||||
@@ -164,7 +164,7 @@ pub async fn serve_epg(
|
||||
let rewrite_urls = !redirect && rewrite_resources;
|
||||
|
||||
// Use 0 for timeshift if None
|
||||
let timeshift = parse_timeshift(user.epg_timeshift.as_ref()).unwrap_or(0);
|
||||
let timeshift = parse_timeshift(user.epg_timeshift.as_deref()).unwrap_or(0);
|
||||
|
||||
return if timeshift != 0 || rewrite_urls || filter.is_some() {
|
||||
let server_info = app_state.app_config.get_user_server_info(user);
|
||||
@@ -174,7 +174,7 @@ pub async fn serve_epg(
|
||||
serve_epg_with_rewrites(epg_path, timeshift, rewrite_urls, &encrypt_secret, &base_url, filter).await
|
||||
} else {
|
||||
// Neither timeshift nor rewrite needed, serve original file
|
||||
serve_file(epg_path, mime::TEXT_XML).await.into_response()
|
||||
serve_file(epg_path, mime::TEXT_XML.to_string()).await.into_response()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// https://github.com/tellytv/go.xtream-codes/blob/master/structs.go
|
||||
|
||||
use crate::api::api_utils;
|
||||
use crate::api::api_utils::{create_session_fingerprint, local_stream_response, try_unwrap_body};
|
||||
use crate::api::api_utils::{
|
||||
@@ -15,7 +14,7 @@ use crate::api::model::UserApiRequest;
|
||||
use crate::api::model::XtreamAuthorizationResponse;
|
||||
use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType};
|
||||
use crate::auth::Fingerprint;
|
||||
use crate::model::{AppConfig, ConfigTarget};
|
||||
use crate::model::{xtream_mapping_option_from_target_options, ConfigTarget};
|
||||
use crate::model::{Config, ConfigInput};
|
||||
use crate::model::{InputSource, ProxyUserCredentials};
|
||||
use crate::repository::playlist_repository::get_target_id_mapping;
|
||||
@@ -33,15 +32,15 @@ use futures::Stream;
|
||||
use log::{debug, error, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use shared::error::create_tuliprox_error_result;
|
||||
use shared::error::info_err;
|
||||
use shared::error::create_tuliprox_error_result;
|
||||
use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind};
|
||||
use shared::model::{create_stream_channel_with_type, get_backdrop_path_value, FieldGetAccessor, PlaylistEntry, PlaylistItemType, ProxyType, TargetType, UserConnectionPermission, XtreamCluster, XtreamPlaylistItem};
|
||||
use shared::model::{create_stream_channel_with_type, PlaylistEntry, PlaylistItemType, ProxyType,
|
||||
TargetType, UserConnectionPermission, XtreamCluster};
|
||||
use shared::utils::{
|
||||
extract_extension_from_url, generate_playlist_uuid, get_u32_from_serde_value, hex_encode,
|
||||
sanitize_sensitive_info, trim_slash, HLS_EXT,
|
||||
extract_extension_from_url, generate_playlist_uuid, sanitize_sensitive_info,
|
||||
trim_slash, HLS_EXT,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::Path;
|
||||
@@ -67,12 +66,12 @@ impl ApiStreamContext {
|
||||
impl Display for ApiStreamContext {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}",
|
||||
match self {
|
||||
Self::Live | Self::LiveAlt => Self::LIVE,
|
||||
Self::Movie => Self::MOVIE,
|
||||
Self::Series => Self::SERIES,
|
||||
Self::Timeshift => Self::TIMESHIFT,
|
||||
}
|
||||
match self {
|
||||
Self::Live | Self::LiveAlt => Self::LIVE,
|
||||
Self::Movie => Self::MOVIE,
|
||||
Self::Series => Self::SERIES,
|
||||
Self::Timeshift => Self::TIMESHIFT,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -247,7 +246,7 @@ async fn xtream_player_api_stream(
|
||||
|
||||
let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id);
|
||||
let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse());
|
||||
let (pli, mapping) = try_result_not_found!(
|
||||
let pli = try_result_not_found!(
|
||||
xtream_repository::xtream_get_item_for_stream_id(req_virtual_id, app_state, &target, None).await,
|
||||
true,
|
||||
format!("Failed to read xtream item for stream id {req_virtual_id}")
|
||||
@@ -347,7 +346,7 @@ async fn xtream_player_api_stream(
|
||||
|
||||
let redirect_params = RedirectParams {
|
||||
item: &pli,
|
||||
provider_id: Some(mapping.provider_id),
|
||||
provider_id: pli.get_provider_id(),
|
||||
cluster,
|
||||
target_type: TargetType::Xtream,
|
||||
target: &target,
|
||||
@@ -436,7 +435,7 @@ async fn xtream_player_api_stream_with_token(
|
||||
}
|
||||
let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id);
|
||||
let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse());
|
||||
let (pli, _mapping) = try_result_bad_request!(
|
||||
let pli = try_result_bad_request!(
|
||||
xtream_repository::xtream_get_item_for_stream_id(
|
||||
req_virtual_id,
|
||||
app_state,
|
||||
@@ -565,163 +564,6 @@ async fn xtream_player_api_stream_with_token(
|
||||
}
|
||||
}
|
||||
|
||||
fn get_doc_id_and_field_name(input: &str) -> Option<(u32, &str)> {
|
||||
if let Some(pos) = input.find('_') {
|
||||
let (number_part, rest) = input.split_at(pos);
|
||||
let field = &rest[1..]; // cut _
|
||||
if let Ok(number) = number_part.parse::<u32>() {
|
||||
return Some((number, field));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_doc_resource_field_value<'a>(
|
||||
field: &'a str,
|
||||
doc: Option<&'a Value>,
|
||||
) -> Option<Cow<'a, str>> {
|
||||
if let Some(Value::Object(info_data)) = doc {
|
||||
if field.starts_with(crate::model::XC_PROP_BACKDROP_PATH) {
|
||||
return get_backdrop_path_value(
|
||||
field,
|
||||
info_data.get(crate::model::XC_PROP_BACKDROP_PATH),
|
||||
);
|
||||
} else if let Some(Value::String(url)) = info_data.get(field) {
|
||||
return Some(Cow::Borrowed(url));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn xtream_get_info_resource_url<'a>(
|
||||
config: &'a AppConfig,
|
||||
pli: &'a XtreamPlaylistItem,
|
||||
target: &'a ConfigTarget,
|
||||
resource: &'a str,
|
||||
) -> Result<Option<Cow<'a, str>>, serde_json::Error> {
|
||||
let info_content = match pli.xtream_cluster {
|
||||
XtreamCluster::Video => xtream_repository::xtream_load_vod_info(
|
||||
config,
|
||||
target.name.as_str(),
|
||||
pli.get_virtual_id(),
|
||||
).await,
|
||||
XtreamCluster::Series => xtream_repository::xtream_load_series_info(
|
||||
config,
|
||||
target.name.as_str(),
|
||||
pli.get_virtual_id(),
|
||||
).await,
|
||||
XtreamCluster::Live => None,
|
||||
};
|
||||
if let Some(content) = info_content {
|
||||
let doc: Map<String, Value> = serde_json::from_str(&content)?;
|
||||
let (field, possible_episode_id) = if let Some(field_name_with_episode_id) =
|
||||
resource.strip_prefix(crate::model::XC_INFO_RESOURCE_PREFIX_EPISODE)
|
||||
{
|
||||
if let Some((episode_id, field_name)) =
|
||||
get_doc_id_and_field_name(field_name_with_episode_id)
|
||||
{
|
||||
(field_name, Some(episode_id))
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
(
|
||||
&resource[crate::model::XC_INFO_RESOURCE_PREFIX.len()..],
|
||||
None,
|
||||
)
|
||||
};
|
||||
let info_doc = match pli.xtream_cluster {
|
||||
XtreamCluster::Video | XtreamCluster::Series => {
|
||||
if let Some(episode_id) = possible_episode_id {
|
||||
get_episode_info_doc(&doc, episode_id)
|
||||
} else {
|
||||
doc.get(crate::model::XC_TAG_INFO_DATA)
|
||||
}
|
||||
}
|
||||
XtreamCluster::Live => None,
|
||||
};
|
||||
|
||||
if let Some(value) = get_doc_resource_field_value(field, info_doc) {
|
||||
return Ok(Some(Cow::Owned(value.into_owned())));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn get_episode_info_doc(doc: &Map<String, Value>, episode_id: u32) -> Option<&Value> {
|
||||
let episodes = doc.get(crate::model::XC_TAG_EPISODES)?.as_object()?;
|
||||
for season_episodes in episodes.values() {
|
||||
if let Value::Array(episode_list) = season_episodes {
|
||||
for episode in episode_list {
|
||||
if let Value::Object(episode_doc) = episode {
|
||||
if let Some(episode_id_value) = episode_doc.get(crate::model::XC_TAG_ID) {
|
||||
if let Some(doc_episode_id) = get_u32_from_serde_value(episode_id_value) {
|
||||
if doc_episode_id == episode_id {
|
||||
return episode_doc.get(crate::model::XC_TAG_INFO_DATA);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_season_info_doc(doc: &Vec<Value>, season_id: u32) -> Option<&Value> {
|
||||
for season in doc {
|
||||
if let Value::Object(season_doc) = season {
|
||||
if let Some(season_id_value) = season_doc.get(crate::model::XC_TAG_ID) {
|
||||
if let Some(doc_season_id) = get_u32_from_serde_value(season_id_value) {
|
||||
if doc_season_id == season_id {
|
||||
return Some(season);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn xtream_get_season_resource_url<'a>(
|
||||
config: &'a AppConfig,
|
||||
pli: &'a XtreamPlaylistItem,
|
||||
target: &'a ConfigTarget,
|
||||
resource: &'a str,
|
||||
) -> Result<Option<Cow<'a, str>>, serde_json::Error> {
|
||||
let info_content = match pli.xtream_cluster {
|
||||
XtreamCluster::Series => xtream_repository::xtream_load_series_info(
|
||||
config,
|
||||
target.name.as_str(),
|
||||
pli.get_virtual_id(),
|
||||
).await,
|
||||
XtreamCluster::Video | XtreamCluster::Live => None,
|
||||
};
|
||||
if let Some(content) = info_content {
|
||||
let doc: Map<String, Value> = serde_json::from_str(&content)?;
|
||||
|
||||
if let Some(field_name_with_season_id) =
|
||||
resource.strip_prefix(crate::model::XC_SEASON_RESOURCE_PREFIX)
|
||||
{
|
||||
if let Some((season_id, field)) = get_doc_id_and_field_name(field_name_with_season_id) {
|
||||
let seasons_doc = match pli.xtream_cluster {
|
||||
XtreamCluster::Series => doc.get(crate::model::XC_TAG_SEASONS_DATA),
|
||||
XtreamCluster::Video | XtreamCluster::Live => None,
|
||||
};
|
||||
|
||||
if let Some(Value::Array(seasons)) = seasons_doc {
|
||||
if let Some(value) =
|
||||
get_doc_resource_field_value(field, get_season_info_doc(seasons, season_id))
|
||||
{
|
||||
return Ok(Some(Cow::Owned(value.into_owned())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn xtream_player_api_resource(
|
||||
req_headers: &HeaderMap,
|
||||
api_req: &UserApiRequest,
|
||||
@@ -751,7 +593,7 @@ async fn xtream_player_api_resource(
|
||||
}
|
||||
let req_virtual_id: u32 = try_result_bad_request!(resource_req.stream_id.trim().parse());
|
||||
let resource = resource_req.action_path.trim();
|
||||
let (pli, _) = try_result_bad_request!(
|
||||
let pli = try_result_bad_request!(
|
||||
xtream_repository::xtream_get_item_for_stream_id(
|
||||
req_virtual_id,
|
||||
app_state,
|
||||
@@ -761,23 +603,8 @@ async fn xtream_player_api_resource(
|
||||
true,
|
||||
format!("Failed to read xtream item for stream id {req_virtual_id}")
|
||||
);
|
||||
let stream_url = if resource.starts_with(crate::model::XC_INFO_RESOURCE_PREFIX) {
|
||||
try_result_bad_request!(xtream_get_info_resource_url(
|
||||
&app_state.app_config,
|
||||
&pli,
|
||||
&target,
|
||||
resource
|
||||
).await)
|
||||
} else if resource.starts_with(crate::model::XC_SEASON_RESOURCE_PREFIX) {
|
||||
try_result_bad_request!(xtream_get_season_resource_url(
|
||||
&app_state.app_config,
|
||||
&pli,
|
||||
&target,
|
||||
resource
|
||||
).await)
|
||||
} else {
|
||||
pli.get_field(resource)
|
||||
};
|
||||
|
||||
let stream_url = pli.resolve_resource_url(resource);
|
||||
|
||||
match stream_url {
|
||||
None => axum::http::StatusCode::NOT_FOUND.into_response(),
|
||||
@@ -790,9 +617,7 @@ async fn xtream_player_api_resource(
|
||||
redirect(&url).into_response()
|
||||
} else {
|
||||
trace_if_enabled!("Resource request to {}", sanitize_sensitive_info(&url));
|
||||
resource_response(app_state, &url, req_headers, None)
|
||||
.await
|
||||
.into_response()
|
||||
resource_response(app_state, &url, req_headers, None).await.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,6 +802,16 @@ async fn xtream_player_api_timeshift_query_stream(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn empty_json_response() -> axum::http::Result<axum::response::Response> {
|
||||
axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
mime::APPLICATION_JSON.to_string(),
|
||||
)
|
||||
.body(axum::body::Body::from("{}".as_bytes()))
|
||||
}
|
||||
|
||||
async fn xtream_get_stream_info_response(
|
||||
app_state: &Arc<AppState>,
|
||||
user: &ProxyUserCredentials,
|
||||
@@ -989,31 +824,26 @@ async fn xtream_get_stream_info_response(
|
||||
Err(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
if let Ok((pli, virtual_record)) = xtream_repository::xtream_get_item_for_stream_id(
|
||||
if let Ok(pli) = xtream_repository::xtream_get_item_for_stream_id(
|
||||
virtual_id,
|
||||
app_state,
|
||||
target,
|
||||
Some(cluster),
|
||||
).await {
|
||||
if pli.item_type.is_local() {
|
||||
if let Some(additional_properties) = pli.additional_properties.as_ref() {
|
||||
let bytes = additional_properties.as_bytes().to_vec();
|
||||
return try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
mime::APPLICATION_JSON.as_ref()
|
||||
)
|
||||
.body(axum::body::Body::from(bytes)));
|
||||
}
|
||||
let Ok(xtream_output) = target.get_xtream_output().ok_or_else(|| info_err!(format!("Unexpected: xtream output required for target {}", target.name))) else {
|
||||
return try_unwrap_body!(empty_json_response());
|
||||
};
|
||||
|
||||
let server_info = app_state.app_config.get_user_server_info(user);
|
||||
let options = xtream_mapping_option_from_target_options(target, xtream_output, &app_state.app_config, user, Some(server_info.get_base_url().as_str()));
|
||||
return axum::Json(pli.to_info_document(&options)).into_response();
|
||||
}
|
||||
|
||||
if pli.provider_id > 0 {
|
||||
let input_name = &pli.input_name;
|
||||
if let Some(input) = app_state.app_config.get_input_by_name(input_name.as_str()) {
|
||||
if let Some(info_url) =
|
||||
xtream::get_xtream_player_api_info_url(&input, cluster, pli.provider_id)
|
||||
{
|
||||
if let Some(info_url) = xtream::get_xtream_player_api_info_url(&input, cluster, pli.provider_id) {
|
||||
// Redirect is only possible for live streams, vod and series info needs to be modified
|
||||
if user.proxy == ProxyType::Redirect && cluster == XtreamCluster::Live {
|
||||
return redirect(&info_url).into_response();
|
||||
@@ -1026,8 +856,7 @@ async fn xtream_get_stream_info_response(
|
||||
&pli,
|
||||
info_url.as_str(),
|
||||
cluster,
|
||||
)
|
||||
.await
|
||||
).await
|
||||
{
|
||||
return try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
@@ -1044,7 +873,7 @@ async fn xtream_get_stream_info_response(
|
||||
return match cluster {
|
||||
XtreamCluster::Video => {
|
||||
let content =
|
||||
create_vod_info_from_item(target, user, &pli, virtual_record.last_updated);
|
||||
create_vod_info_from_item(target, user, &pli);
|
||||
try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(
|
||||
@@ -1064,13 +893,7 @@ async fn xtream_get_stream_info_response(
|
||||
}
|
||||
};
|
||||
}
|
||||
try_unwrap_body!(axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
mime::APPLICATION_JSON.to_string()
|
||||
)
|
||||
.body(axum::body::Body::from("{}".as_bytes())))
|
||||
try_unwrap_body!(empty_json_response())
|
||||
}
|
||||
|
||||
async fn xtream_get_short_epg(
|
||||
@@ -1087,7 +910,7 @@ async fn xtream_get_short_epg(
|
||||
Err(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
if let Ok((pli, _)) = xtream_repository::xtream_get_item_for_stream_id(
|
||||
if let Ok(pli) = xtream_repository::xtream_get_item_for_stream_id(
|
||||
virtual_id,
|
||||
app_state,
|
||||
target,
|
||||
@@ -1236,7 +1059,7 @@ async fn xtream_player_api_handle_content_action(
|
||||
);
|
||||
}
|
||||
return Some(
|
||||
serve_file(&file_path, mime::APPLICATION_JSON)
|
||||
serve_file(&file_path, mime::APPLICATION_JSON.to_string())
|
||||
.await
|
||||
.into_response(),
|
||||
);
|
||||
@@ -1258,7 +1081,7 @@ async fn xtream_get_catchup_response(
|
||||
end: &str,
|
||||
) -> impl IntoResponse + Send {
|
||||
let req_virtual_id: u32 = try_result_bad_request!(FromStr::from_str(stream_id));
|
||||
let (pli, _) = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(
|
||||
let pli = try_result_bad_request!(xtream_repository::xtream_get_item_for_stream_id(
|
||||
req_virtual_id,
|
||||
app_state,
|
||||
target,
|
||||
@@ -1302,10 +1125,10 @@ async fn xtream_get_catchup_response(
|
||||
.and_then(|id| id.parse::<u32>().ok())
|
||||
{
|
||||
let uuid = generate_playlist_uuid(
|
||||
&hex_encode(&pli.get_uuid()),
|
||||
&pli.get_uuid().to_string(),
|
||||
&catchup_provider_id.to_string(),
|
||||
pli.item_type,
|
||||
&pli.url,
|
||||
&pli.input_name,
|
||||
);
|
||||
let virtual_id = target_id_mapping.get_and_update_virtual_id(
|
||||
&uuid,
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn create_shared_data(
|
||||
Arc::new(ArcSwapOption::from(Some(Arc::new(db))))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to load GeoIp db: {err}");
|
||||
info!("No GeoIp db found: {err}");
|
||||
Arc::new(ArcSwapOption::from(None))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use shared::model::{M3uPlaylistItem, XtreamPlaylistItem};
|
||||
use shared::model::{M3uPlaylistItem, PlaylistItem, XtreamCluster, XtreamPlaylistItem};
|
||||
use crate::model::ConfigTarget;
|
||||
use crate::repository::bplustree::BPlusTree;
|
||||
use crate::repository::target_id_mapping::{VirtualIdRecord};
|
||||
@@ -50,6 +50,38 @@ impl PlaylistStorageState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_playlist_items(&self, target: &ConfigTarget, pli_list: Vec<&XtreamPlaylistItem>) {
|
||||
if target.use_memory_cache {
|
||||
if let Some(storage) = self.data.write().await.get_mut(&target.name) {
|
||||
if let Some(xtream) = storage.xtream.as_mut() {
|
||||
for pli in pli_list {
|
||||
match pli.xtream_cluster {
|
||||
XtreamCluster::Live => &mut xtream.live,
|
||||
XtreamCluster::Video => &mut xtream.vod,
|
||||
XtreamCluster::Series => &mut xtream.series,
|
||||
}.insert(pli.virtual_id, pli.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert_playlist_items(&self, target: &ConfigTarget, pli_list: Vec<PlaylistItem>) {
|
||||
if target.use_memory_cache {
|
||||
if let Some(storage) = self.data.write().await.get_mut(&target.name) {
|
||||
if let Some(xtream) = storage.xtream.as_mut() {
|
||||
for pli in pli_list {
|
||||
match pli.header.xtream_cluster {
|
||||
XtreamCluster::Live => &mut xtream.live,
|
||||
XtreamCluster::Video => &mut xtream.vod,
|
||||
XtreamCluster::Series => &mut xtream.series,
|
||||
}.insert(pli.header.virtual_id, XtreamPlaylistItem::from(&pli));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cache_playlist(&self, target_name: &str, playlist: PlaylistStorage) {
|
||||
match playlist {
|
||||
PlaylistStorage::M3uPlaylist(m3u_playlist) => {
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
use log::debug;
|
||||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
use shared::utils::CONSTANTS;
|
||||
use crate::model::LibraryConfig;
|
||||
use crate::library::scanner::ScannedMediaFile;
|
||||
use crate::library::{SeriesEpisodeFile, SeriesKey};
|
||||
use crate::library::{SeriesKey};
|
||||
use crate::ptt::{ptt_parse_title, PttMetadata};
|
||||
|
||||
fn clear_filename(file_name: &str) -> String {
|
||||
// Remove quality indicators (1080p, 720p, BluRay, etc.)
|
||||
let mut cleaned = CONSTANTS.re_quality.replace(file_name, "").to_string();
|
||||
|
||||
// Clean up special characters
|
||||
cleaned = cleaned.replace(['.', '_', '-'], " ").trim().to_string();
|
||||
|
||||
// remove brackets
|
||||
cleaned = CONSTANTS.re_classifier_brackets_info.replace_all(&cleaned, "").to_string();
|
||||
|
||||
// put space between camel case
|
||||
cleaned = CONSTANTS.re_classifier_camel_case.replace_all(&cleaned, "$1 $2").to_string();
|
||||
|
||||
// Remove multiple spaces
|
||||
CONSTANTS.re_whitespace.replace_all(&cleaned, " ").trim().to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)]
|
||||
pub enum MovieDbId {
|
||||
Tmdb(u32),
|
||||
Tvdb(u32),
|
||||
}
|
||||
|
||||
impl MovieDbId {
|
||||
fn get_id<F>(ids: Option<&Vec<MovieDbId>>, f: F) -> Option<u32>
|
||||
where
|
||||
F: Fn(&MovieDbId) -> Option<u32>,
|
||||
{
|
||||
ids.as_ref()?.iter().find_map(f)
|
||||
}
|
||||
|
||||
pub fn get_tmdb_id(ids: Option<&Vec<MovieDbId>>) -> Option<u32> {
|
||||
Self::get_id(ids, |id| {
|
||||
if let MovieDbId::Tmdb(val) = id { Some(*val) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_tvdb_id(ids: Option<&Vec<MovieDbId>>) -> Option<u32> {
|
||||
Self::get_id(ids, |id| {
|
||||
if let MovieDbId::Tvdb(val) = id { Some(*val) } else { None }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification result for a video file
|
||||
// Classification result for a video file
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MediaClassification {
|
||||
Movie,
|
||||
Movie {
|
||||
metadata: PttMetadata
|
||||
},
|
||||
Series {
|
||||
season: Option<u32>,
|
||||
episode: Option<u32>,
|
||||
key: SeriesKey,
|
||||
episode: u32,
|
||||
season: u32,
|
||||
metadata: PttMetadata,
|
||||
},
|
||||
}
|
||||
|
||||
impl MediaClassification {
|
||||
pub fn is_movie(&self) -> bool {
|
||||
matches!(self, MediaClassification::Movie)
|
||||
matches!(self, MediaClassification::Movie { .. })
|
||||
}
|
||||
|
||||
pub fn is_series(&self) -> bool {
|
||||
@@ -72,171 +28,29 @@ impl MediaClassification {
|
||||
|
||||
/// Classifier for determining if a video file is a movie or series
|
||||
pub struct MediaClassifier {
|
||||
series_patterns: Vec<Regex>,
|
||||
}
|
||||
|
||||
impl MediaClassifier {
|
||||
/// Creates a new classifier from the VOD configuration
|
||||
pub fn from_config(config: &LibraryConfig) -> Self {
|
||||
Self {
|
||||
series_patterns: config.classification.series_patterns.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// Classifies a video file as either Movie or Series
|
||||
pub fn classify(&self, file: &ScannedMediaFile) -> MediaClassification {
|
||||
pub fn classify(file: &ScannedMediaFile) -> MediaClassification {
|
||||
let file_name = &file.file_name;
|
||||
let parent_path = file.path.parent().and_then(|p| p.to_str()).unwrap_or("");
|
||||
|
||||
// Check if any series pattern matches
|
||||
for pattern in &self.series_patterns {
|
||||
if pattern.is_match(file_name) || pattern.is_match(parent_path) {
|
||||
debug!("File '{file_name}' matched series pattern: {pattern}");
|
||||
return Self::extract_series_info(file_name, parent_path);
|
||||
let ptt_metadata = ptt_parse_title(file_name);
|
||||
|
||||
if let (Some(episode), Some(season)) = (ptt_metadata.episodes.first(), ptt_metadata.seasons.first()) {
|
||||
MediaClassification::Series {
|
||||
key: SeriesKey {
|
||||
title: ptt_metadata.title.clone(),
|
||||
year: ptt_metadata.year,
|
||||
tmdb_id: ptt_metadata.tmdb,
|
||||
},
|
||||
episode: *episode,
|
||||
season: *season,
|
||||
metadata: ptt_metadata,
|
||||
}
|
||||
} else {
|
||||
MediaClassification::Movie {metadata: ptt_metadata }
|
||||
}
|
||||
|
||||
// If no series pattern matches, classify as movie
|
||||
debug!("File '{file_name}' classified as Movie (no series pattern match)");
|
||||
MediaClassification::Movie
|
||||
}
|
||||
|
||||
pub fn classify_series_file(file: &ScannedMediaFile) -> Option<(SeriesKey, u32, u32)> {
|
||||
let file_name = Path::new(&file.file_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&file.file_name);
|
||||
|
||||
let (ids, show_name) = Self::extract_show_name(file);
|
||||
|
||||
let parent_path = file.path.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let classification = Self::extract_series_info(file_name, parent_path);
|
||||
|
||||
if let MediaClassification::Series { season, episode } = classification {
|
||||
if let (Some(season), Some(episode)) = (season, episode) {
|
||||
let tmdb_id = MovieDbId::get_tmdb_id(ids.as_ref());
|
||||
let year = None; // TODO extract from somewhere
|
||||
let key = SeriesKey {
|
||||
title: show_name,
|
||||
tmdb_id,
|
||||
year,
|
||||
};
|
||||
return Some((key, season, episode));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extracts season and episode information from file name or path
|
||||
fn extract_series_info(file_name: &str, parent_path: &str) -> MediaClassification {
|
||||
let combined = format!("{parent_path} {file_name}");
|
||||
|
||||
// Try to extract season and episode numbers
|
||||
// Common patterns: S01E02, s01e02, 1x02, Season 1 Episode 2, etc.
|
||||
let season = Self::extract_season(&combined);
|
||||
let episode = Self::extract_episode(&combined);
|
||||
|
||||
if season.is_some() || episode.is_some() {
|
||||
debug!("Extracted series info - Season: {season:?}, Episode: {episode:?}");
|
||||
}
|
||||
|
||||
MediaClassification::Series { season, episode }
|
||||
}
|
||||
|
||||
/// Extracts season number from text using common patterns
|
||||
fn extract_season(text: &str) -> Option<u32> {
|
||||
// Patterns: S01, s01, Season 1, season 1, Season1, etc.
|
||||
CONSTANTS.re_classifier_season
|
||||
.captures(text)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
/// Extracts episode number from text using common patterns
|
||||
fn extract_episode(text: &str) -> Option<u32> {
|
||||
// Patterns: E02, e02, Episode 2, episode 2, x02, etc.
|
||||
CONSTANTS.re_classifier_episode
|
||||
.captures(text)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
/// Extracts show name from file path for series
|
||||
/// Removes season/episode patterns and cleans up the name
|
||||
pub fn extract_show_name(file: &ScannedMediaFile) -> (Option<Vec<MovieDbId>>, String) {
|
||||
let file_name = Path::new(&file.file_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&file.file_name);
|
||||
|
||||
let moviedb_ids: Vec<MovieDbId> = CONSTANTS.re_classifier_moviedb_id.captures_iter(file_name)
|
||||
.filter_map(|caps| {
|
||||
let id = caps[2].parse::<u32>().ok()?;
|
||||
match &caps[1].to_lowercase()[..] {
|
||||
"tmdb" => Some(MovieDbId::Tmdb(id)),
|
||||
"tvdb" => Some(MovieDbId::Tvdb(id)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
// Remove common series patterns
|
||||
let cleaned = CONSTANTS.re_classifier_cleanup.replace(file_name, "").trim().to_string();
|
||||
// Clean up remaining special characters
|
||||
((!moviedb_ids.is_empty()).then_some(moviedb_ids), clear_filename(&cleaned))
|
||||
}
|
||||
|
||||
/// Extracts movie title from file path
|
||||
/// Attempts to extract tmdb-id, tvdb-id, year and remove quality tags
|
||||
pub fn extract_movie_search_info(file: &ScannedMediaFile) -> (Option<Vec<MovieDbId>>, String, Option<u32>) {
|
||||
let file_name = Path::new(&file.file_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&file.file_name);
|
||||
|
||||
let moviedb_ids: Vec<MovieDbId> = CONSTANTS.re_classifier_moviedb_id.captures_iter(file_name)
|
||||
.filter_map(|caps| {
|
||||
let id = caps[2].parse::<u32>().ok()?;
|
||||
match &caps[1].to_lowercase()[..] {
|
||||
"tmdb" => Some(MovieDbId::Tmdb(id)),
|
||||
"tvdb" => Some(MovieDbId::Tvdb(id)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Try to extract year (4 digits in parentheses or standalone)
|
||||
let year = CONSTANTS.re_classifier_year
|
||||
.captures(file_name)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse::<u32>().ok())
|
||||
.filter(|&y| (1900..=2100).contains(&y)); // Validate year range
|
||||
|
||||
// Remove year and everything after it, and quality tags
|
||||
let mut title = file_name.to_string();
|
||||
if let Some(mat) = CONSTANTS.re_classifier_year.find(file_name) {
|
||||
title = file_name[..mat.start()].to_string();
|
||||
}
|
||||
|
||||
title = clear_filename(&title);
|
||||
|
||||
((!moviedb_ids.is_empty()).then_some(moviedb_ids), title, year)
|
||||
}
|
||||
|
||||
pub fn build_series_key(episodes: &[SeriesEpisodeFile]) -> SeriesKey {
|
||||
if let Some(first) = episodes.first() {
|
||||
if let Some((key, _season, _episode)) = Self::classify_series_file(&first.file) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback, not really helpful
|
||||
SeriesKey {title: "Unknown".to_string(), tmdb_id: None, year: None}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,72 +70,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_season_extraction() {
|
||||
assert_eq!(MediaClassifier::extract_season("S01E02"), Some(1));
|
||||
assert_eq!(MediaClassifier::extract_season("s03e05"), Some(3));
|
||||
assert_eq!(MediaClassifier::extract_season("Season 2 Episode 1"), Some(2));
|
||||
assert_eq!(MediaClassifier::extract_season("season_05_episode_03"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_episode_extraction() {
|
||||
assert_eq!(MediaClassifier::extract_episode("S01E02"), Some(2));
|
||||
assert_eq!(MediaClassifier::extract_episode("s03e05"), Some(5));
|
||||
assert_eq!(MediaClassifier::extract_episode("Episode 12"), Some(12));
|
||||
assert_eq!(MediaClassifier::extract_episode("1x15"), Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_show_name() {
|
||||
let file = create_test_file("Breaking.Bad.S01E01.mkv", "/tv/Breaking.Bad");
|
||||
let (_, show_name) = MediaClassifier::extract_show_name(&file);
|
||||
assert_eq!(show_name, "Breaking Bad");
|
||||
let classification = MediaClassifier::classify(&file);
|
||||
match classification {
|
||||
MediaClassification::Movie { .. } => {
|
||||
|
||||
}
|
||||
MediaClassification::Series { key, episode, season, metadata, .. } => {
|
||||
assert_eq!(key.title, "Breaking Bad");
|
||||
assert_eq!(episode, 1);
|
||||
assert_eq!(season, 1);
|
||||
assert_eq!(metadata.extension, Some("mkv".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_movie_title() {
|
||||
let file = create_test_file("The.Matrix.1999.1080p.BluRay.mkv", "/movies");
|
||||
let (_moviedb_id, title, year) = MediaClassifier::extract_movie_search_info(&file);
|
||||
assert_eq!(title, "The Matrix");
|
||||
assert_eq!(year, Some(1999));
|
||||
let classification = MediaClassifier::classify(&file);
|
||||
match classification {
|
||||
MediaClassification::Movie { metadata } => {
|
||||
assert_eq!(metadata.title, "The Matrix");
|
||||
assert_eq!(metadata.year, Some(1999));
|
||||
assert_eq!(metadata.extension, Some("mkv".to_string()));
|
||||
|
||||
}
|
||||
MediaClassification::Series { .. } => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_movie_title_without_year() {
|
||||
let file = create_test_file("Inception.1080p.BluRay.mkv", "/movies");
|
||||
let (tmdbid, title, year) = MediaClassifier::extract_movie_search_info(&file);
|
||||
assert_eq!(tmdbid, None);
|
||||
assert_eq!(title, "Inception");
|
||||
assert_eq!(year, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_series() {
|
||||
let series_pattern = Regex::new(r"(?i)s\d+e\d+").unwrap();
|
||||
let classifier = MediaClassifier {
|
||||
series_patterns: vec![series_pattern],
|
||||
};
|
||||
|
||||
let file = create_test_file("Breaking.Bad.S01E01.mkv", "/tv");
|
||||
let classification = classifier.classify(&file);
|
||||
assert!(classification.is_series());
|
||||
|
||||
if let MediaClassification::Series { season, episode } = classification {
|
||||
assert_eq!(season, Some(1));
|
||||
assert_eq!(episode, Some(1));
|
||||
let classification = MediaClassifier::classify(&file);
|
||||
match classification {
|
||||
MediaClassification::Movie { metadata } => {
|
||||
assert_eq!(metadata.title, "Inception");
|
||||
assert_eq!(metadata.year, None);
|
||||
assert_eq!(metadata.extension, Some("mkv".to_string()));
|
||||
}
|
||||
MediaClassification::Series { .. } => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_movie() {
|
||||
let series_pattern = Regex::new(r"(?i)s\d+e\d+").unwrap();
|
||||
let classifier = MediaClassifier {
|
||||
series_patterns: vec![series_pattern],
|
||||
};
|
||||
|
||||
let file = create_test_file("The.Matrix.1999.mkv", "/movies");
|
||||
let classification = classifier.classify(&file);
|
||||
assert!(classification.is_movie());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,42 +34,42 @@ pub struct VideoClipMetadata {
|
||||
pub struct MovieMetadata {
|
||||
pub title: String,
|
||||
// Original title (if different from title)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub original_title: Option<String>,
|
||||
// Release year
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub year: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub plot: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tagline: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub runtime: Option<u32>,
|
||||
// MPAA rating (e.g., "PG-13", "R")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub mpaa: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub imdb_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tmdb_id: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tvdb_id: Option<u32>,
|
||||
// Rating (0.0 - 10.0)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub rating: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub genres: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub directors: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub writers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub actors: Option<Vec<Actor>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub studios: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub poster: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub fanart: Option<String>,
|
||||
pub source: MetadataSource,
|
||||
pub last_updated: i64,
|
||||
@@ -81,72 +81,82 @@ pub struct MovieMetadata {
|
||||
pub struct SeriesMetadata {
|
||||
pub title: String,
|
||||
// Original title (if different)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub original_title: Option<String>,
|
||||
// First aired year
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub year: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub plot: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub mpaa: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub imdb_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tmdb_id: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub tvdb_id: Option<u32>,
|
||||
// Rating (0.0 - 10.0)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub rating: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub genres: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub directors: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub writers: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub actors: Option<Vec<Actor>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub studios: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub poster: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub fanart: Option<String>,
|
||||
// Status (e.g., "Continuing", "Ended")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub seasons: Option<Vec<SeasonMetadata>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub episodes: Option<Vec<EpisodeMetadata>>,
|
||||
#[serde(default)]
|
||||
pub source: MetadataSource,
|
||||
#[serde(default)]
|
||||
pub number_of_episodes: u32,
|
||||
#[serde(default)]
|
||||
pub number_of_seasons: u32,
|
||||
// Last updated timestamp (Unix epoch)
|
||||
#[serde(default)]
|
||||
pub last_updated: i64,
|
||||
#[serde(default)]
|
||||
pub videos: Option<Vec<VideoClipMetadata>>
|
||||
}
|
||||
|
||||
// Episode metadata for TV series
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EpisodeMetadata {
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
#[serde(default)]
|
||||
pub tmdb_id: u32,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub season: u32,
|
||||
#[serde(default)]
|
||||
pub episode: u32,
|
||||
// Aired date (ISO 8601 format)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub aired: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub plot: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub runtime: Option<u32>,
|
||||
// Rating (0.0 - 10.0)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub rating: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub thumb: Option<String>,
|
||||
pub file_path: String,
|
||||
pub file_size: u64,
|
||||
@@ -177,9 +187,9 @@ pub struct SeasonMetadata {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Actor {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub thumb: Option<String>,
|
||||
}
|
||||
|
||||
@@ -224,8 +234,8 @@ impl MediaMetadata {
|
||||
|
||||
pub fn poster(&self) -> Option<&str> {
|
||||
match self {
|
||||
MediaMetadata::Movie(m) => m.poster.as_deref(),
|
||||
MediaMetadata::Series(s) => s.poster.as_deref(),
|
||||
MediaMetadata::Movie(m) => m.poster.as_deref().or(m.fanart.as_deref()),
|
||||
MediaMetadata::Series(s) => s.poster.as_deref().or(s.fanart.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use log::{debug, error, info, warn};
|
||||
|
||||
use crate::library::classifier::{MediaClassification, MediaClassifier};
|
||||
use crate::library::metadata::{MediaMetadata, MetadataSource, MovieMetadata, SeriesMetadata};
|
||||
use crate::library::nfo_reader::NfoReader;
|
||||
use crate::library::scanner::ScannedMediaFile;
|
||||
use crate::library::tmdb_client::TmdbClient;
|
||||
use crate::library::{MediaGroup, MetadataStorage, MovieDbId, TMDB_API_KEY};
|
||||
use crate::library::{MediaGroup, MetadataStorage, TMDB_API_KEY};
|
||||
use crate::model::LibraryConfig;
|
||||
use crate::ptt::{ptt_parse_title, PttMetadata};
|
||||
|
||||
// Metadata resolver that tries multiple sources to get video metadata
|
||||
pub struct MetadataResolver {
|
||||
@@ -35,17 +34,17 @@ impl MetadataResolver {
|
||||
debug!("Resolving metadata for: {group}");
|
||||
|
||||
// Step 1: Classify the file
|
||||
let (Some(file), classification) = (match group {
|
||||
MediaGroup::Movie { file } => (Some(file), MediaClassification::Movie),
|
||||
let (movie, Some(file), metadata) = (match group {
|
||||
MediaGroup::Movie { file, metadata } => (true, Some(file), metadata),
|
||||
MediaGroup::Series { show_key: _, episodes } => {
|
||||
let episode = episodes.first()?;
|
||||
(Some(&episode.file), MediaClassification::Series { season: None, episode: None })
|
||||
},
|
||||
(false, Some(&episode.file), &episode.metadata)
|
||||
}
|
||||
}) else { return None };
|
||||
|
||||
// Step 2: Try TMDB if enabled
|
||||
if let Some(ref tmdb) = self.tmdb_client {
|
||||
match self.resolve_from_tmdb(file, &classification, tmdb).await {
|
||||
match self.resolve_from_tmdb(movie, file, metadata, tmdb).await {
|
||||
Ok(Some(metadata)) => {
|
||||
info!("Found TMDB metadata for: {}", file.file_path);
|
||||
return Some(metadata);
|
||||
@@ -58,18 +57,18 @@ impl MetadataResolver {
|
||||
}
|
||||
|
||||
// TODO series implementation missing
|
||||
if classification == MediaClassification::Movie {
|
||||
// Step 3: Try to read existing NFO file
|
||||
if let Some(metadata) = NfoReader::read_metadata(&file.path).await {
|
||||
info!("Found NFO metadata for: {}", file.file_path);
|
||||
return Some(metadata);
|
||||
}
|
||||
}
|
||||
// if classification == MediaClassification::Movie {
|
||||
// // Step 3: Try to read existing NFO file
|
||||
// if let Some(metadata) = NfoReader::read_metadata(&file.path).await {
|
||||
// info!("Found NFO metadata for: {}", file.file_path);
|
||||
// return Some(metadata);
|
||||
// }
|
||||
// }
|
||||
|
||||
// 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(file, &classification))
|
||||
Some(Self::resolve_from_filename(movie, metadata))
|
||||
} else {
|
||||
warn!("No metadata found for: {}", file.file_path);
|
||||
None
|
||||
@@ -77,66 +76,48 @@ impl MetadataResolver {
|
||||
}
|
||||
|
||||
// Attempts to resolve metadata from TMDB
|
||||
async fn resolve_from_tmdb(&self, file: &ScannedMediaFile, classification: &MediaClassification, tmdb: &TmdbClient) -> Result<Option<MediaMetadata>, String> {
|
||||
match classification {
|
||||
MediaClassification::Movie => {
|
||||
let (moviedb_ids, title, year) = MediaClassifier::extract_movie_search_info(file);
|
||||
let tmdb_id = MovieDbId::get_tmdb_id(moviedb_ids.as_ref());
|
||||
tmdb.search_movie(tmdb_id, &title, year).await
|
||||
}
|
||||
MediaClassification::Series { .. } => {
|
||||
let (seriesdb_id, show_name) = MediaClassifier::extract_show_name(file);
|
||||
let tmdb_id = MovieDbId::get_tmdb_id(seriesdb_id.as_ref());
|
||||
debug!("Searching TMDB for series: {show_name}");
|
||||
async fn resolve_from_tmdb(&self, movie: bool, file: &ScannedMediaFile, metadata: &PttMetadata, tmdb: &TmdbClient) -> Result<Option<MediaMetadata>, 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 {
|
||||
// Try to extract year from parent directory if available
|
||||
let year = file.path.parent()
|
||||
file.path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.and_then(|s| {
|
||||
// Look for 4-digit year in directory name
|
||||
s.chars()
|
||||
.collect::<Vec<_>>()
|
||||
.windows(4)
|
||||
.find_map(|w| {
|
||||
let year_str: String = w.iter().collect();
|
||||
year_str.parse::<u32>().ok()
|
||||
.filter(|&y| (1900..=2100).contains(&y))
|
||||
})
|
||||
});
|
||||
tmdb.search_series(tmdb_id, &show_name, year).await
|
||||
}
|
||||
.map_or((None, None), |s| {
|
||||
let ptt = ptt_parse_title(s);
|
||||
(ptt.year, ptt.tmdb)
|
||||
})
|
||||
};
|
||||
tmdb.search_series(tmdb_id, metadata.title.as_str(), series_year).await
|
||||
}
|
||||
}
|
||||
|
||||
// Creates basic metadata from filename parsing
|
||||
fn resolve_from_filename(
|
||||
file: &ScannedMediaFile,
|
||||
classification: &MediaClassification,
|
||||
) -> MediaMetadata {
|
||||
fn resolve_from_filename(movie: bool, metadata: &PttMetadata) -> MediaMetadata {
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
|
||||
match classification {
|
||||
MediaClassification::Movie => {
|
||||
let (moviedb_id, title, year) = MediaClassifier::extract_movie_search_info(file);
|
||||
MediaMetadata::Movie(MovieMetadata {
|
||||
title,
|
||||
year,
|
||||
tmdb_id: MovieDbId::get_tmdb_id(moviedb_id.as_ref()),
|
||||
tvdb_id: MovieDbId::get_tvdb_id(moviedb_id.as_ref()),
|
||||
source: MetadataSource::FilenameParsed,
|
||||
last_updated: timestamp,
|
||||
..MovieMetadata::default()
|
||||
})
|
||||
}
|
||||
MediaClassification::Series { season: _, episode: _ } => {
|
||||
let (_moviedb_id, show_name) = MediaClassifier::extract_show_name(file);
|
||||
MediaMetadata::Series(SeriesMetadata {
|
||||
title: show_name,
|
||||
source: MetadataSource::FilenameParsed,
|
||||
last_updated: timestamp,
|
||||
..SeriesMetadata::default()
|
||||
})
|
||||
}
|
||||
if movie {
|
||||
MediaMetadata::Movie(MovieMetadata {
|
||||
title: metadata.title.clone(),
|
||||
year: metadata.year,
|
||||
tmdb_id: metadata.tmdb,
|
||||
tvdb_id: metadata.tvdb,
|
||||
source: MetadataSource::FilenameParsed,
|
||||
last_updated: timestamp,
|
||||
..MovieMetadata::default()
|
||||
})
|
||||
} else {
|
||||
MediaMetadata::Series(SeriesMetadata {
|
||||
title: metadata.title.clone(),
|
||||
year: metadata.year,
|
||||
tmdb_id: metadata.tmdb,
|
||||
tvdb_id: metadata.tvdb,
|
||||
source: MetadataSource::FilenameParsed,
|
||||
last_updated: timestamp,
|
||||
..SeriesMetadata::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,9 +125,10 @@ impl MetadataResolver {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{LibraryClassificationConfig, LibraryMetadataConfig, LibraryMetadataReadConfig, LibraryPlaylistConfig, LibraryTmdbConfig};
|
||||
use crate::model::{LibraryMetadataConfig, LibraryMetadataReadConfig, LibraryPlaylistConfig, LibraryTmdbConfig};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use crate::library::{MediaClassification, MediaClassifier};
|
||||
|
||||
fn create_test_config(tmdb_enabled: bool) -> LibraryConfig {
|
||||
LibraryConfig {
|
||||
@@ -174,10 +156,6 @@ mod tests {
|
||||
fallback_to_filename: true,
|
||||
formats: vec![],
|
||||
},
|
||||
classification: LibraryClassificationConfig {
|
||||
series_patterns: vec![],
|
||||
series_directory_patterns: vec![],
|
||||
},
|
||||
playlist: LibraryPlaylistConfig {
|
||||
movie_category: "Movies".to_string(),
|
||||
series_category: "Series".to_string(),
|
||||
@@ -206,7 +184,11 @@ mod tests {
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let resolver = MetadataResolver::from_config(&config, client, MetadataStorage::new(PathBuf::from("/tmp")));
|
||||
let file = create_test_file("The.Matrix.1999.1080p.mkv");
|
||||
let group = MediaGroup::Movie { file };
|
||||
let metadata = match MediaClassifier::classify(&file) {
|
||||
MediaClassification::Movie { metadata, .. } => metadata,
|
||||
MediaClassification::Series { metadata, .. } => metadata,
|
||||
};
|
||||
let group = MediaGroup::Movie { file, metadata: Box::new(metadata) };
|
||||
|
||||
let metadata = resolver.resolve(&group).await;
|
||||
assert!(metadata.is_some());
|
||||
@@ -214,7 +196,7 @@ 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::FilenameParsed);
|
||||
assert_eq!(movie.source, MetadataSource::Tmdb);
|
||||
} else {
|
||||
panic!("Expected movie metadata");
|
||||
}
|
||||
@@ -231,7 +213,11 @@ mod tests {
|
||||
config.metadata.fallback_to_filename = false;
|
||||
let resolver = MetadataResolver::from_config(&config, client, MetadataStorage::new(PathBuf::from("/tmp")));
|
||||
let file = create_test_file("Unknown.Movie.mkv");
|
||||
let group = MediaGroup::Movie { file };
|
||||
let metadata = match MediaClassifier::classify(&file) {
|
||||
MediaClassification::Movie { metadata, .. } => metadata,
|
||||
MediaClassification::Series { metadata, .. } => metadata,
|
||||
};
|
||||
let group = MediaGroup::Movie { file, metadata: Box::new(metadata) };
|
||||
|
||||
let metadata = resolver.resolve(&group).await;
|
||||
assert!(metadata.is_none());
|
||||
|
||||
@@ -96,7 +96,7 @@ impl LibraryProcessor {
|
||||
let scanned_paths: std::collections::HashSet<_> = media_groups
|
||||
.iter()
|
||||
.flat_map(|group| match group {
|
||||
MediaGroup::Movie { file } => vec![file.file_path.as_str()],
|
||||
MediaGroup::Movie { file, .. } => vec![file.file_path.as_str()],
|
||||
MediaGroup::Series { episodes, .. } => episodes.iter().map(|ep| ep.file.file_path.as_str()).collect(),
|
||||
})
|
||||
.collect();
|
||||
@@ -118,7 +118,7 @@ impl LibraryProcessor {
|
||||
|
||||
async fn process_group(&self, group: &MediaGroup, existing_map: &HashMap<String, MetadataCacheEntry>, force_rescan: bool) -> Result<ProcessAction, String> {
|
||||
match group {
|
||||
MediaGroup::Movie { file: _ } => {
|
||||
MediaGroup::Movie { file: _, .. } => {
|
||||
self.process_movie(group, existing_map, force_rescan).await
|
||||
}
|
||||
MediaGroup::Series { show_key: _, episodes: _ } => {
|
||||
@@ -130,7 +130,7 @@ impl LibraryProcessor {
|
||||
// Processes a single video file
|
||||
async fn process_movie(&self, group: &MediaGroup, existing_map: &HashMap<String, MetadataCacheEntry>, force_rescan: bool,
|
||||
) -> Result<ProcessAction, String> {
|
||||
let MediaGroup::Movie { file } = group else { return Err(format!("Expected movie to resolve but got {group}")) };
|
||||
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) {
|
||||
// Check if file has been modified
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::library::MediaClassifier;
|
||||
use crate::library::{MediaClassification, MediaClassifier};
|
||||
use crate::model::{LibraryConfig, LibraryScanDirectory};
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use std::collections::HashMap;
|
||||
@@ -6,6 +6,7 @@ use std::fmt::{Display, Formatter};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::io;
|
||||
use crate::ptt::PttMetadata;
|
||||
|
||||
#[derive(Hash, Eq, PartialEq, Clone, Debug)]
|
||||
pub struct SeriesKey {
|
||||
@@ -30,6 +31,7 @@ impl Display for SeriesKey {
|
||||
pub enum MediaGroup {
|
||||
Movie {
|
||||
file: ScannedMediaFile,
|
||||
metadata: Box<PttMetadata>,
|
||||
},
|
||||
Series {
|
||||
show_key: SeriesKey,
|
||||
@@ -40,7 +42,7 @@ pub enum MediaGroup {
|
||||
impl Display for MediaGroup {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MediaGroup::Movie { file } => write!(f, "{}", file.file_path),
|
||||
MediaGroup::Movie { file, .. } => write!(f, "{}", file.file_path),
|
||||
MediaGroup::Series { show_key, .. } => write!(f, "{}", show_key.title),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +53,7 @@ pub struct SeriesEpisodeFile {
|
||||
pub file: ScannedMediaFile,
|
||||
pub season: u32,
|
||||
pub episode: u32,
|
||||
pub metadata: Box<PttMetadata>,
|
||||
}
|
||||
|
||||
pub struct MediaGrouper;
|
||||
@@ -61,17 +64,22 @@ impl MediaGrouper {
|
||||
let mut movies = Vec::new();
|
||||
|
||||
for file in files {
|
||||
if let Some((key, season, episode)) = MediaClassifier::classify_series_file(&file) {
|
||||
series_map
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.push(SeriesEpisodeFile {
|
||||
file,
|
||||
season,
|
||||
episode,
|
||||
});
|
||||
} else {
|
||||
movies.push(MediaGroup::Movie { file });
|
||||
let classification = MediaClassifier::classify(&file);
|
||||
match classification {
|
||||
MediaClassification::Movie { metadata } => {
|
||||
movies.push(MediaGroup::Movie { file, metadata: Box::new(metadata) });
|
||||
}
|
||||
MediaClassification::Series { key, metadata, season, episode,.. } => {
|
||||
series_map
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.push(SeriesEpisodeFile {
|
||||
file,
|
||||
season,
|
||||
episode,
|
||||
metadata: Box::new(metadata),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +282,7 @@ impl LibraryScanner {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{LibraryClassificationConfig, LibraryMetadataConfig, LibraryMetadataReadConfig, LibraryPlaylistConfig, LibraryTmdbConfig};
|
||||
use crate::model::{LibraryMetadataConfig, LibraryMetadataReadConfig, LibraryPlaylistConfig, LibraryTmdbConfig};
|
||||
|
||||
fn create_test_config() -> LibraryConfig {
|
||||
LibraryConfig {
|
||||
@@ -302,10 +310,6 @@ mod tests {
|
||||
fallback_to_filename: true,
|
||||
formats: vec![],
|
||||
},
|
||||
classification: LibraryClassificationConfig {
|
||||
series_patterns: vec![],
|
||||
series_directory_patterns: vec![],
|
||||
},
|
||||
playlist: LibraryPlaylistConfig {
|
||||
movie_category: "Local Movies".to_string(),
|
||||
series_category: "Local Series".to_string(),
|
||||
|
||||
@@ -100,6 +100,8 @@ impl TmdbClient {
|
||||
|
||||
// Searches for a TV series by title and optional year
|
||||
pub async fn search_series(&self, tmdb_id: Option<u32>, title: &str, year: Option<u32>) -> Result<Option<MediaMetadata>, String> {
|
||||
debug!("Searching TMDB for series: {title}");
|
||||
|
||||
let key = format!("{title}-{tmdb_id:?}-{year:?}");
|
||||
|
||||
if self.fetched_series_key.read().await.contains(&key) {
|
||||
|
||||
+12
-3
@@ -14,7 +14,7 @@ use crate::model::{
|
||||
AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets, SourcesConfig,
|
||||
};
|
||||
use crate::processing::processor::playlist;
|
||||
use crate::utils::init_logger;
|
||||
use crate::utils::{db_viewer, init_logger};
|
||||
use crate::utils::request::create_client;
|
||||
use crate::utils::{config_file_reader, resolve_env_var};
|
||||
use crate::library::LibraryProcessor;
|
||||
@@ -32,7 +32,7 @@ use std::sync::Arc;
|
||||
#[command(name = "tuliprox")]
|
||||
#[command(author = "euzu <euzu@proton.me>")]
|
||||
#[command(version)]
|
||||
#[command(about = "Extended M3U playlist filter", long_about = None)]
|
||||
#[command(about = "Extended playlist proxy", long_about = None)]
|
||||
struct Args {
|
||||
/// The config directory
|
||||
#[arg(short = 'p', long = "config-path")]
|
||||
@@ -85,6 +85,13 @@ struct Args {
|
||||
/// Force rescan of all local Library files
|
||||
#[arg(long = "force-library-rescan", default_value_t = false, default_missing_value = "true")]
|
||||
force_library_rescan: bool,
|
||||
|
||||
#[arg(long = "dbx")]
|
||||
db_xtream_file_name: Option<String>,
|
||||
|
||||
#[arg(long = "dbm")]
|
||||
db_m3u_file_name: Option<String>,
|
||||
|
||||
}
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
@@ -102,6 +109,8 @@ const BUILD_TIMESTAMP: &str = env!("VERGEN_BUILD_TIMESTAMP");
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
db_viewer(args.db_xtream_file_name.as_deref(), args.db_m3u_file_name.as_deref());
|
||||
|
||||
if args.genpwd {
|
||||
match generate_password() {
|
||||
Ok(pwd) => println!("{pwd}"),
|
||||
@@ -113,7 +122,7 @@ async fn main() {
|
||||
let mut config_paths = get_file_paths(&args);
|
||||
|
||||
init_logger(
|
||||
args.log_level.as_ref(),
|
||||
args.log_level.as_deref(),
|
||||
config_paths.config_file_path.as_str(),
|
||||
);
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ impl ApiProxyConfig {
|
||||
}
|
||||
} else {
|
||||
let user_db_path = get_api_user_db_path(cfg);
|
||||
if user_db_path.exists() {
|
||||
if let Ok(true) = tokio::fs::try_exists(&user_db_path).await {
|
||||
// we cant have user defined in db file.
|
||||
// we need to load them and save them into the config file
|
||||
if let Ok(stored_users) = load_api_user(cfg).await {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use crate::model::macros;
|
||||
use log::error;
|
||||
use regex::Regex;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{default_metadata_path, LibraryConfigDto, LibraryContentType, LibraryMetadataFormat};
|
||||
use std::path::PathBuf;
|
||||
@@ -39,12 +37,6 @@ pub struct LibraryTmdbConfig {
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryClassificationConfig {
|
||||
pub series_patterns: Vec<Regex>,
|
||||
pub series_directory_patterns: Vec<Regex>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryPlaylistConfig {
|
||||
pub movie_category: String,
|
||||
@@ -58,7 +50,6 @@ pub struct LibraryConfig {
|
||||
pub scan_directories: Vec<LibraryScanDirectory>,
|
||||
pub supported_extensions: Vec<String>,
|
||||
pub metadata: LibraryMetadataConfig,
|
||||
pub classification: LibraryClassificationConfig,
|
||||
pub playlist: LibraryPlaylistConfig,
|
||||
}
|
||||
|
||||
@@ -93,37 +84,6 @@ macros::from_impl!(LibraryConfig);
|
||||
|
||||
impl From<&LibraryConfigDto> for LibraryConfig {
|
||||
fn from(dto: &LibraryConfigDto) -> Self {
|
||||
// Compile series patterns
|
||||
let series_patterns = dto
|
||||
.classification
|
||||
.series_patterns
|
||||
.iter()
|
||||
.filter_map(|pattern| {
|
||||
match Regex::new(pattern) {
|
||||
Ok(re) => Some(re),
|
||||
Err(e) => {
|
||||
error!("Invalid series pattern '{pattern}': {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compile directory patterns
|
||||
let series_directory_patterns = dto
|
||||
.classification
|
||||
.series_directory_patterns
|
||||
.iter()
|
||||
.filter_map(|pattern| {
|
||||
match Regex::new(pattern) {
|
||||
Ok(re) => Some(re),
|
||||
Err(e) => {
|
||||
error!("Invalid series directory pattern '{pattern}': {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
enabled: dto.enabled,
|
||||
@@ -159,10 +119,6 @@ impl From<&LibraryConfigDto> for LibraryConfig {
|
||||
fallback_to_filename: dto.metadata.fallback_to_filename,
|
||||
formats: dto.metadata.formats.clone(),
|
||||
},
|
||||
classification: LibraryClassificationConfig {
|
||||
series_patterns,
|
||||
series_directory_patterns,
|
||||
},
|
||||
playlist: LibraryPlaylistConfig {
|
||||
movie_category: dto.playlist.movie_category.clone(),
|
||||
series_category: dto.playlist.series_category.clone(),
|
||||
|
||||
@@ -14,13 +14,13 @@ pub struct LogConfig {
|
||||
pub sanitize_sensitive_info: bool,
|
||||
#[serde(default)]
|
||||
pub log_active_user: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct LogLevelConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub log: Option<LogConfig>,
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,9 @@ impl ConfigTarget {
|
||||
}
|
||||
|
||||
pub fn is_force_redirect(&self, item_type: PlaylistItemType) -> bool {
|
||||
if item_type.is_local() {
|
||||
return false;
|
||||
}
|
||||
self.options
|
||||
.as_ref()
|
||||
.and_then(|options| options.force_redirect.as_ref())
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct Healthcheck {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub build_time: Option<String>,
|
||||
pub server_time: String,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// backend/src/model/media_properties.rs
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fmt;
|
||||
use serde_json::{Value, Map};
|
||||
|
||||
// Enum for Video Resolution
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -161,67 +159,12 @@ impl MediaQuality {
|
||||
parts.join(separator)
|
||||
}
|
||||
|
||||
/// Extracts media quality information from an `ffprobe` info block.
|
||||
/// The `info_block` is expected to be a `serde_json::Value` object.
|
||||
pub fn from_ffprobe_info(info_block: &Value) -> Option<Self> {
|
||||
let video_info = info_block.get("video")?.as_object()?;
|
||||
fn from_ffprobe_info_audio(audio: Option<&str>) -> Option<(AudioCodec, AudioChannels)> {
|
||||
// Assuming the first audio stream is the primary one.
|
||||
let audio_info = info_block.get("audio")?.as_object()?;
|
||||
let audio_info = audio.and_then(|v| serde_json::from_str::<Map<String, Value>>(v).ok())?;
|
||||
|
||||
// Helper to get a value by trying a prioritized list of field names.
|
||||
let get_value = |obj: &Map<String, Value>, fields: &[&str]| -> Option<Value> {
|
||||
for field in fields {
|
||||
if let Some(value) = obj.get(*field) {
|
||||
if !value.is_null() {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// 1. Classify video resolution from width
|
||||
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 >= 1000 => VideoResolution::P1080,
|
||||
_ if h >= 700 => VideoResolution::P720,
|
||||
_ => VideoResolution::SD,
|
||||
});
|
||||
|
||||
// 2. Classify video codec
|
||||
let video_codec = get_value(video_info, &["codec_name"])
|
||||
.and_then(|v| v.as_str().map(str::to_lowercase))
|
||||
.map_or(VideoCodec::default(), |name| match name.as_str() {
|
||||
"h264" => VideoCodec::H264,
|
||||
"hevc" => VideoCodec::H265,
|
||||
"mpeg4" => VideoCodec::MPEG4,
|
||||
"vc1" => VideoCodec::VC1,
|
||||
_ => VideoCodec::default(),
|
||||
});
|
||||
|
||||
// 3. Classify dynamic range
|
||||
let dynamic_range = {
|
||||
let tag_string = get_value(video_info, &["codec_tag_string"])
|
||||
.and_then(|v| v.as_str().map(str::to_lowercase));
|
||||
|
||||
if tag_string == Some("dovi".to_string()) {
|
||||
VideoDynamicRange::DV
|
||||
} else {
|
||||
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,
|
||||
"arib-std-b67" => VideoDynamicRange::HLG,
|
||||
_ => VideoDynamicRange::SDR,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Classify audio codec
|
||||
let audio_codec = get_value(audio_info, &["codec_name"])
|
||||
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() {
|
||||
"aac" => AudioCodec::AAC,
|
||||
@@ -234,7 +177,7 @@ impl MediaQuality {
|
||||
});
|
||||
|
||||
// 5. Classify audio channels
|
||||
let audio_channels = get_value(audio_info, &["channel_layout"])
|
||||
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,
|
||||
@@ -244,12 +187,84 @@ impl MediaQuality {
|
||||
_ => AudioChannels::default(),
|
||||
});
|
||||
|
||||
Some(Self {
|
||||
resolution,
|
||||
video_codec,
|
||||
dynamic_range,
|
||||
audio_codec,
|
||||
audio_channels,
|
||||
})
|
||||
Some((audio_codec, audio_channels))
|
||||
}
|
||||
}
|
||||
|
||||
fn from_ffprobe_info_video(video: Option<&str>) -> Option<(VideoResolution, VideoCodec, VideoDynamicRange)> {
|
||||
let video_info = video.and_then(|v| serde_json::from_str::<Map<String, Value>>(v).ok())?;
|
||||
|
||||
// 1. Classify video resolution from width
|
||||
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 >= 1000 => VideoResolution::P1080,
|
||||
_ if h >= 700 => VideoResolution::P720,
|
||||
_ => VideoResolution::SD,
|
||||
});
|
||||
|
||||
// 2. Classify video codec
|
||||
let video_codec = get_value(&video_info, &["codec_name"])
|
||||
.and_then(|v| v.as_str().map(str::to_lowercase))
|
||||
.map_or(VideoCodec::default(), |name| match name.as_str() {
|
||||
"h264" => VideoCodec::H264,
|
||||
"hevc" => VideoCodec::H265,
|
||||
"mpeg4" => VideoCodec::MPEG4,
|
||||
"vc1" => VideoCodec::VC1,
|
||||
_ => VideoCodec::default(),
|
||||
});
|
||||
|
||||
// 3. Classify dynamic range
|
||||
let dynamic_range = {
|
||||
let tag_string = get_value(&video_info, &["codec_tag_string"])
|
||||
.and_then(|v| v.as_str().map(str::to_lowercase));
|
||||
|
||||
if tag_string == Some("dovi".to_string()) {
|
||||
VideoDynamicRange::DV
|
||||
} else {
|
||||
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,
|
||||
"arib-std-b67" => VideoDynamicRange::HLG,
|
||||
_ => VideoDynamicRange::SDR,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Some((resolution, video_codec, dynamic_range))
|
||||
}
|
||||
|
||||
/// Extracts media quality information from an `ffprobe` info block.
|
||||
/// The `info_block` is expected to be a `serde_json::Value` object.
|
||||
pub fn from_ffprobe_info(audio: Option<&str>, video: Option<&str>) -> Option<Self> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper to get a value by trying a prioritized list of field names.
|
||||
fn get_value(obj: &Map<String, Value>, fields: &[&str]) -> Option<Value> {
|
||||
for field in fields {
|
||||
if let Some(value) = obj.get(*field) {
|
||||
if !value.is_null() {
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct PlaylistCategories {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub live: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub vod: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub series: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
+25
-694
@@ -1,68 +1,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use crate::model::{AppConfig, ProxyUserCredentials};
|
||||
use crate::model::{ConfigTarget, XtreamTargetOutput};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::{Value};
|
||||
use shared::model::{xtream_const, PlaylistItem, ProxyUserStatus, XtreamPlaylistItem};
|
||||
use shared::model::{ClusterFlags, PlaylistEntry, XtreamCluster};
|
||||
use shared::utils::{deserialize_as_option_string, deserialize_as_string, deserialize_as_string_array, deserialize_number_from_string,
|
||||
get_non_empty_str, opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32};
|
||||
use std::iter::FromIterator;
|
||||
use serde::ser::SerializeMap;
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
macro_rules! add_str_property_if_exists {
|
||||
($vec:expr, $prop:expr, $prop_name:expr) => {
|
||||
$vec.insert(String::from($prop_name), Value::String($prop.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! add_rc_str_property_if_exists {
|
||||
($vec:expr, $prop:expr, $prop_name:expr) => {
|
||||
if let Some(v) = $prop.as_ref() {
|
||||
$vec.insert(String::from($prop_name), Value::String(v.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! add_opt_i64_property_if_exists {
|
||||
($vec:expr, $prop:expr, $prop_name:expr) => {
|
||||
if let Some(v) = $prop.as_ref() {
|
||||
$vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from(*v))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! add_opt_f64_property_if_exists {
|
||||
($vec:expr, $prop:expr, $prop_name:expr) => {
|
||||
$prop.as_ref().map(|v| $vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from(*v)).unwrap_or_else(|| serde_json::Number::from(0)))));
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! add_f64_property_if_exists {
|
||||
($vec:expr, $prop:expr, $prop_name:expr) => {
|
||||
$vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from_f64(f64::from($prop)).unwrap_or_else(|| serde_json::Number::from(0))));
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! add_i64_property_if_exists {
|
||||
($vec:expr, $prop:expr, $prop_name:expr) => {
|
||||
$vec.insert(String::from($prop_name), Value::Number(serde_json::value::Number::from(i64::from($prop))));
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! add_to_doc_str_property_if_not_exists {
|
||||
($document:expr, $prop_name:expr, $prop_value:expr) => {
|
||||
match $document.get($prop_name) {
|
||||
None => {
|
||||
$document.insert(String::from($prop_name), $prop_value);
|
||||
}
|
||||
Some(value) => { if Value::is_null(value) {
|
||||
$document.insert(String::from($prop_name), $prop_value);
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use shared::model::{PlaylistItem, PlaylistItemType, PlaylistItemTypeSet, ProxyUserStatus, XtreamMappingOptions};
|
||||
use shared::utils::{deserialize_as_string, deserialize_number_from_string_or_zero};
|
||||
use enum_iterator::all;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct XtreamLoginInfo {
|
||||
@@ -72,8 +14,8 @@ pub struct XtreamLoginInfo {
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct XtreamCategory {
|
||||
#[serde(deserialize_with = "deserialize_as_string")]
|
||||
pub category_id: String,
|
||||
#[serde(deserialize_with = "deserialize_number_from_string_or_zero")]
|
||||
pub category_id: u32,
|
||||
#[serde(deserialize_with = "deserialize_as_string")]
|
||||
pub category_name: String,
|
||||
//pub parent_id: i32,
|
||||
@@ -87,492 +29,30 @@ impl XtreamCategory {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct XtreamStream {
|
||||
#[serde(default, deserialize_with = "deserialize_as_string")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "deserialize_as_string")]
|
||||
pub category_id: String,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub stream_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub series_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_string")]
|
||||
pub stream_icon: String,
|
||||
#[serde(default, deserialize_with = "deserialize_as_string")]
|
||||
pub direct_source: String,
|
||||
|
||||
// optional attributes
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub custom_sid: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_string_array")]
|
||||
pub backdrop_path: Option<Vec<String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub added: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub cast: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub container_extension: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub cover: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub director: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub episode_run_time: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub genre: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub last_modified: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub plot: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub rating: Option<f64>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub rating_5based: Option<f64>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub release_date: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub stream_type: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub year: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub trailer: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub youtube_trailer: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_as_option_string")]
|
||||
pub epg_channel_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub tv_archive: Option<i32>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub tv_archive_duration: Option<i32>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub tmdb: Option<u32>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub tmdb_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "deserialize_number_from_string")]
|
||||
pub is_adult: Option<i32>,
|
||||
pub fn xtream_mapping_option_from_target_options(target: &ConfigTarget, target_output: &XtreamTargetOutput,
|
||||
cfg: &AppConfig, user: &ProxyUserCredentials,
|
||||
resource_url: Option<&str>) -> XtreamMappingOptions {
|
||||
|
||||
}
|
||||
let force_redirect = target.options.as_ref().and_then(|o| o.force_redirect);
|
||||
let mut reverse_item_types = PlaylistItemTypeSet::empty();
|
||||
|
||||
impl XtreamStream {
|
||||
pub fn get_stream_id(&self) -> u32 {
|
||||
self.stream_id.unwrap_or_else(|| self.series_id.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub fn get_additional_properties(&self) -> Option<Box<RawValue>> {
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
if let Some(bdpath) = self.backdrop_path.as_ref() {
|
||||
if !bdpath.is_empty() {
|
||||
result.insert(String::from(xtream_const::XC_PROP_BACKDROP_PATH), Value::Array(Vec::from([Value::String(String::from(bdpath.first()?))])));
|
||||
}
|
||||
}
|
||||
|
||||
add_rc_str_property_if_exists!(result, self.tmdb_id.or(self.tmdb), "tmdb");
|
||||
add_rc_str_property_if_exists!(result, self.added, "added");
|
||||
add_rc_str_property_if_exists!(result, self.cast, "cast");
|
||||
add_rc_str_property_if_exists!(result, self.container_extension, "container_extension");
|
||||
add_rc_str_property_if_exists!(result, self.cover, "cover");
|
||||
add_rc_str_property_if_exists!(result, self.director, "director");
|
||||
add_rc_str_property_if_exists!(result, self.episode_run_time, "episode_run_time");
|
||||
add_rc_str_property_if_exists!(result, self.genre, "genre");
|
||||
add_rc_str_property_if_exists!(result, self.last_modified, "last_modified");
|
||||
add_rc_str_property_if_exists!(result, self.plot, "plot");
|
||||
add_opt_f64_property_if_exists!(result, self.rating, "rating");
|
||||
add_opt_f64_property_if_exists!(result, self.rating_5based, "rating_5based");
|
||||
add_rc_str_property_if_exists!(result, self.release_date, "release_date");
|
||||
add_rc_str_property_if_exists!(result, self.stream_type, "stream_type");
|
||||
add_rc_str_property_if_exists!(result, self.title, "title");
|
||||
add_rc_str_property_if_exists!(result, self.year, "year");
|
||||
add_rc_str_property_if_exists!(result, self.trailer, "trailer");
|
||||
add_rc_str_property_if_exists!(result, self.youtube_trailer, "youtube_trailer");
|
||||
add_rc_str_property_if_exists!(result, self.epg_channel_id, "epg_channel_id");
|
||||
add_opt_i64_property_if_exists!(result, self.tv_archive, "tv_archive");
|
||||
add_opt_i64_property_if_exists!(result, self.tv_archive_duration, "tv_archive_duration");
|
||||
add_opt_i64_property_if_exists!(result, self.is_adult, "is_adult");
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let s = serde_json::to_string(&Value::Object(result)).ok()?;
|
||||
RawValue::from_string(s).ok()
|
||||
for item_type in all::<PlaylistItemType>() {
|
||||
if user.proxy.is_reverse(item_type) && !force_redirect.as_ref().is_some_and(|o| o.has_cluster(item_type)) {
|
||||
reverse_item_types.insert(item_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamMovieInfo {
|
||||
pub info: XtreamMovieInfoDetails,
|
||||
pub movie_data: XtreamMovieData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamMovieInfoDetails {
|
||||
pub kinopoisk_url: Option<String>,
|
||||
pub tmdb_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub o_name: Option<String>,
|
||||
pub cover_big: Option<String>,
|
||||
pub movie_image: Option<String>,
|
||||
pub releasedate: Option<String>,
|
||||
pub episode_run_time: Option<u32>,
|
||||
pub youtube_trailer: Option<String>,
|
||||
pub director: Option<String>,
|
||||
pub actors: Option<String>,
|
||||
pub cast: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub plot: Option<String>,
|
||||
pub age: Option<String>,
|
||||
pub mpaa_rating: Option<String>,
|
||||
#[serde(default)]
|
||||
pub rating_count_kinopoisk: u32,
|
||||
pub country: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
#[serde(default)]
|
||||
pub backdrop_path: Vec<String>,
|
||||
pub duration_secs: Option<String>,
|
||||
pub duration: Option<String>,
|
||||
#[serde(default)]
|
||||
pub video: Vec<XtreamStreamInfo>,
|
||||
#[serde(default)]
|
||||
pub audio: Vec<XtreamStreamInfo>,
|
||||
#[serde(default)]
|
||||
pub bitrate: u32,
|
||||
pub rating: Option<String>,
|
||||
pub runtime: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamStreamInfo {
|
||||
#[serde(flatten)]
|
||||
pub extra: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamMovieData {
|
||||
pub stream_id: u32,
|
||||
pub name: String,
|
||||
pub added: Option<String>,
|
||||
pub category_id: u32,
|
||||
#[serde(default)]
|
||||
pub category_ids: Vec<u32>,
|
||||
pub container_extension: Option<String>,
|
||||
pub custom_sid: Option<String>,
|
||||
pub direct_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamSeriesInfoSeason {
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub air_date: String,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub episode_count: u32,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub id: u32,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub overview: String,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub season_number: u32,
|
||||
#[serde(default, deserialize_with = "string_or_number_f64")]
|
||||
pub vote_average: f64,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub cover: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub cover_big: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct XtreamSeriesInfoInfo {
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub(crate) name: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub cover: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub plot: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub cast: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub director: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub genre: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub release_date: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub releaseDate: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub releasedate: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub last_modified: String,
|
||||
#[serde(default, deserialize_with = "string_or_number_f64")]
|
||||
pub rating: f64,
|
||||
#[serde(default, deserialize_with = "string_or_number_f64")]
|
||||
pub rating_5based: f64,
|
||||
#[serde(default, deserialize_with = "deserialize_as_string_array")]
|
||||
pub backdrop_path: Option<Vec<String>>,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub trailer: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub youtube_trailer: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub episode_run_time: String,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub category_id: u32,
|
||||
#[serde(default, deserialize_with = "opt_string_or_number_u32")]
|
||||
pub tmdb_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "opt_string_or_number_u32")]
|
||||
pub tmdb: Option<u32>,
|
||||
#[serde(default, deserialize_with = "opt_string_or_number_u32")]
|
||||
pub year: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct XtreamSeriesInfoEpisodeInfo {
|
||||
#[serde(default, deserialize_with = "opt_string_or_number_u32")]
|
||||
pub id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "opt_string_or_number_u32")]
|
||||
pub tmdb_id: Option<u32>,
|
||||
#[serde(default, deserialize_with = "opt_string_or_number_u32")]
|
||||
pub tmdb: Option<u32>,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub release_date: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub releaseDate: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub releasedate: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub plot: String,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub duration_secs: u32,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub duration: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub movie_image: String,
|
||||
#[serde(default)]
|
||||
pub video: Option<Box<RawValue>>,
|
||||
#[serde(default)]
|
||||
pub audio: Option<Box<RawValue>>,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub bitrate: u32,
|
||||
#[serde(default, deserialize_with = "string_or_number_f64")]
|
||||
pub rating: f64,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub season: u32,
|
||||
}
|
||||
|
||||
// Used for serde_json deserialization, can not be used with bincode
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamSeriesInfoEpisode {
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub id: u32,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub episode_num: u32,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub title: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub container_extension: String,
|
||||
#[serde(default)]
|
||||
pub info: Option<XtreamSeriesInfoEpisodeInfo>,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub custom_sid: String,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub added: String,
|
||||
#[serde(default, deserialize_with = "string_or_number_u32")]
|
||||
pub season: u32,
|
||||
#[serde(default, deserialize_with = "string_default_on_null")]
|
||||
pub direct_source: String,
|
||||
}
|
||||
|
||||
impl XtreamSeriesInfoEpisode {
|
||||
pub fn get_id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
//bincode does not support deserialize_with. We use this struct for db
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamSeriesEpisode {
|
||||
pub id: u32,
|
||||
pub episode_num: u32,
|
||||
pub title: String,
|
||||
pub container_extension: String,
|
||||
pub custom_sid: String,
|
||||
pub added: String,
|
||||
pub season: u32,
|
||||
pub tmdb_id: u32,
|
||||
pub direct_source: String,
|
||||
}
|
||||
|
||||
impl XtreamSeriesEpisode {
|
||||
pub fn from(info_episode: &XtreamSeriesInfoEpisode) -> Self {
|
||||
Self {
|
||||
id: info_episode.get_id(),
|
||||
episode_num: info_episode.episode_num,
|
||||
title: info_episode.title.clone(),
|
||||
container_extension: info_episode.container_extension.clone(),
|
||||
custom_sid: info_episode.custom_sid.clone(),
|
||||
added: info_episode.added.clone(),
|
||||
season: info_episode.season,
|
||||
tmdb_id: info_episode.info.as_ref().and_then(|info| info.tmdb_id.or(info.tmdb)).unwrap_or(0),
|
||||
direct_source: info_episode.direct_source.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sometimes episodes are a map with season as key, sometimes an array
|
||||
fn deserialize_episodes<'de, D>(deserializer: D) -> Result<Option<Vec<XtreamSeriesInfoEpisode>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
// read as generic value
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Array(array) => {
|
||||
if array.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
let mut result = Vec::new();
|
||||
for inner in array {
|
||||
if let Some(inner_array) = inner.as_array() {
|
||||
for item in inner_array {
|
||||
let ep: XtreamSeriesInfoEpisode = serde_json::from_value(item.clone())
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
result.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
if object.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
let mut result = Vec::new();
|
||||
for (_key, val) in object {
|
||||
if let Some(inner_array) = val.as_array() {
|
||||
for item in inner_array {
|
||||
let ep: XtreamSeriesInfoEpisode = serde_json::from_value(item.clone())
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
result.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom("Invalid format for episodes")),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::ref_option)]
|
||||
fn serialize_episodes<S>(
|
||||
episodes: &Option<Vec<XtreamSeriesInfoEpisode>>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match episodes {
|
||||
None => {
|
||||
let map = serializer.serialize_map(Some(0))?;
|
||||
map.end()
|
||||
},
|
||||
Some(list) => {
|
||||
if list.is_empty() {
|
||||
let map = serializer.serialize_map(Some(0))?;
|
||||
return map.end();
|
||||
}
|
||||
let mut seasons: BTreeMap<String, Vec<&XtreamSeriesInfoEpisode>> = BTreeMap::new();
|
||||
for ep in list {
|
||||
seasons.entry(ep.season.to_string()).or_default().push(ep);
|
||||
}
|
||||
|
||||
seasons.serialize(serializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XtreamSeriesInfo {
|
||||
#[serde(default)]
|
||||
pub seasons: Option<Vec<XtreamSeriesInfoSeason>>,
|
||||
#[serde(default)]
|
||||
pub info: Option<XtreamSeriesInfoInfo>,
|
||||
#[serde(default, serialize_with = "serialize_episodes", deserialize_with = "deserialize_episodes")]
|
||||
pub episodes: Option<Vec<XtreamSeriesInfoEpisode>>,
|
||||
}
|
||||
|
||||
impl XtreamSeriesInfoEpisode {
|
||||
pub fn get_additional_properties(&self, series_info: &XtreamSeriesInfo) -> Option<Box<RawValue>> {
|
||||
let mut result = serde_json::Map::new();
|
||||
let info = series_info.info.as_ref();
|
||||
let bdpath = info.and_then(|i| i.backdrop_path.as_ref());
|
||||
let bdpath_is_set = bdpath.as_ref().is_some_and(|bdpath| !bdpath.is_empty());
|
||||
if bdpath_is_set {
|
||||
result.insert(String::from("backdrop_path"), Value::Array(Vec::from([Value::String(String::from(bdpath?.first()?))])));
|
||||
}
|
||||
add_str_property_if_exists!(result, info.map_or("", |i| i.name.as_str()), "series_name");
|
||||
add_str_property_if_exists!(result, info.map_or("", |i| get_non_empty_str(i.release_date.as_str(), i.releaseDate.as_str(), i.releasedate.as_str())), "series_release_date");
|
||||
add_str_property_if_exists!(result, self.added.as_str(), "added");
|
||||
add_str_property_if_exists!(result, info.map_or("", |i| i.cast.as_str()), "cast");
|
||||
add_str_property_if_exists!(result, self.container_extension.as_str(), "container_extension");
|
||||
add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| info.movie_image.as_str()), "cover");
|
||||
add_str_property_if_exists!(result, info.map_or("", |i| i.director.as_str()), "director");
|
||||
add_str_property_if_exists!(result, info.map_or("", |i| i.episode_run_time.as_str()), "episode_run_time");
|
||||
add_str_property_if_exists!(result, info.map_or("", |i| i.last_modified.as_str()), "last_modified");
|
||||
add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| info.plot.as_str()), "plot");
|
||||
add_f64_property_if_exists!(result, info.map_or(0_f64, |i| i.rating), "rating");
|
||||
add_f64_property_if_exists!(result, info.map_or(0_f64, |i| i.rating_5based), "rating_5based");
|
||||
add_str_property_if_exists!(result, self.info.as_ref().map_or("", |info| get_non_empty_str(info.release_date.as_str(), info.releaseDate.as_str(), info.releasedate.as_str())), "release_date");
|
||||
add_str_property_if_exists!(result, self.title, "title");
|
||||
add_i64_property_if_exists!(result, self.season, "season");
|
||||
add_i64_property_if_exists!(result, self.episode_num, "episode");
|
||||
let series_tmdb_id = info.and_then(|i| i.tmdb_id.or(i.tmdb));
|
||||
add_opt_i64_property_if_exists!(result, self.info.as_ref().and_then(|info| info.tmdb_id.or(info.tmdb.or(series_tmdb_id))), "tmdb_id");
|
||||
|
||||
// Add the "info" section to the playlist item additional properties.
|
||||
if let Some(episode_info) = &self.info {
|
||||
if let Ok(info_value) = serde_json::to_value(episode_info) {
|
||||
result.insert("info".to_string(), info_value);
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let s = serde_json::to_string(&Value::Object(result)).ok()?;
|
||||
RawValue::from_string(s).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct XtreamMappingOptions {
|
||||
pub skip_live_direct_source: bool,
|
||||
pub skip_video_direct_source: bool,
|
||||
pub skip_series_direct_source: bool,
|
||||
pub rewrite_resource_url: bool,
|
||||
pub force_redirect: Option<ClusterFlags>,
|
||||
}
|
||||
|
||||
impl XtreamMappingOptions {
|
||||
pub fn from_target_options(target: &ConfigTarget, target_output: &XtreamTargetOutput, cfg: &AppConfig) -> Self {
|
||||
Self {
|
||||
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,
|
||||
rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(),
|
||||
force_redirect: target.options.as_ref().and_then(|o| o.force_redirect),
|
||||
}
|
||||
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,
|
||||
rewrite_resource_url: cfg.is_reverse_proxy_resource_rewrite_enabled(),
|
||||
force_redirect,
|
||||
reverse_item_types,
|
||||
username: user.username.clone(),
|
||||
password: user.password.clone(),
|
||||
base_url: resource_url.map(ToString::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,155 +76,6 @@ pub fn normalize_release_date(document: &mut serde_json::Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn append_mandatory_fields(document: &mut serde_json::Map<String, Value>, fields: &[&str]) {
|
||||
for &field in fields {
|
||||
if !document.contains_key(field) {
|
||||
document.insert(field.to_string(), Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_prepared_series_properties(add_props: Option<&serde_json::Map<String, Value>>, document: &mut serde_json::Map<String, Value>) {
|
||||
if let Some(props) = add_props {
|
||||
match props.get("rating") {
|
||||
Some(value) => {
|
||||
document.insert("rating".to_string(), match value {
|
||||
Value::Number(val) => Value::String(format!("{:.0}", val.as_f64().unwrap_or(0f64))),
|
||||
Value::String(val) => Value::String(val.clone()),
|
||||
_ => Value::String("0".to_string()),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
document.insert("rating".to_string(), Value::String("0".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_bdpath_resource_url(resource_url: &str, bd_path: &str, index: usize, field_prefix: &str) -> String {
|
||||
if bd_path.starts_with("http") {
|
||||
format!("{resource_url}/{field_prefix}{}_{index}", xtream_const::XC_PROP_BACKDROP_PATH)
|
||||
} else {
|
||||
bd_path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xtream_playlistitem_to_document(pli: &XtreamPlaylistItem, url: &str, options: &XtreamMappingOptions, user: &ProxyUserCredentials) -> serde_json::Value {
|
||||
let stream_id_value = Value::Number(serde_json::Number::from(pli.virtual_id));
|
||||
|
||||
let is_reverse = user.proxy.is_reverse(pli.item_type) && !options.force_redirect.as_ref().is_some_and(|o| o.has_cluster(pli.item_type));
|
||||
let (resource_url, logo, logo_small) = if is_reverse && options.rewrite_resource_url {
|
||||
let resource_url = format!("{url}/resource/{}/{}/{}/{}", pli.xtream_cluster.as_stream_type(), user.username, user.password, pli.get_virtual_id());
|
||||
let logo_url = if pli.logo.is_empty() { String::new() } else { format!("{resource_url}/logo") };
|
||||
let logo_small_url = if pli.logo_small.is_empty() { String::new() } else { format!("{resource_url}/logo_small") };
|
||||
(Some(resource_url), logo_url, logo_small_url)
|
||||
} else {
|
||||
(None, pli.logo.clone(), pli.logo_small.clone())
|
||||
};
|
||||
let mut document = serde_json::Map::from_iter([
|
||||
("category_id".to_string(), Value::String(format!("{}", &pli.category_id))),
|
||||
("category_ids".to_string(), Value::Array(Vec::from([Value::Number(serde_json::Number::from(pli.category_id))]))),
|
||||
("name".to_string(), Value::String(pli.name.clone())),
|
||||
("num".to_string(), Value::Number(serde_json::Number::from(pli.channel_no))),
|
||||
("title".to_string(), Value::String(pli.title.clone())),
|
||||
("stream_icon".to_string(), Value::String(logo)),
|
||||
]);
|
||||
|
||||
match pli.xtream_cluster {
|
||||
XtreamCluster::Live => {
|
||||
document.insert("stream_id".to_string(), stream_id_value);
|
||||
if options.skip_live_direct_source {
|
||||
document.insert("direct_source".to_string(), Value::String(String::new()));
|
||||
} else {
|
||||
document.insert("direct_source".to_string(), Value::String(pli.url.clone()));
|
||||
}
|
||||
document.insert("thumbnail".to_string(), Value::String(logo_small));
|
||||
document.insert("custom_sid".to_string(), Value::String(String::new()));
|
||||
document.insert("epg_channel_id".to_string(), pli.epg_channel_id.as_ref().map_or(Value::Null, |epg_id| Value::String(epg_id.clone())));
|
||||
}
|
||||
XtreamCluster::Video => {
|
||||
document.insert("stream_id".to_string(), stream_id_value);
|
||||
if options.skip_video_direct_source {
|
||||
document.insert("direct_source".to_string(), Value::String(String::new()));
|
||||
} else {
|
||||
document.insert("direct_source".to_string(), Value::String(pli.url.clone()));
|
||||
}
|
||||
document.insert("custom_sid".to_string(), Value::String(String::new()));
|
||||
}
|
||||
XtreamCluster::Series => {
|
||||
document.insert("series_id".to_string(), stream_id_value);
|
||||
}
|
||||
}
|
||||
|
||||
let props = pli.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::<serde_json::Map<String, Value>>(add_props).ok());
|
||||
|
||||
if let Some(ref add_props) = props {
|
||||
let doc = if matches!(pli.xtream_cluster, XtreamCluster::Video | XtreamCluster::Series) {
|
||||
match add_props.get("info") {
|
||||
Some(Value::Object(info_doc)) => info_doc,
|
||||
_ => add_props,
|
||||
}
|
||||
} else {
|
||||
add_props
|
||||
};
|
||||
for (field_name, field_value) in doc {
|
||||
if !document.contains_key(field_name) {
|
||||
document.insert(field_name.clone(), field_value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match pli.xtream_cluster {
|
||||
XtreamCluster::Live => {
|
||||
append_mandatory_fields(&mut document, xtream_const::LIVE_STREAM_FIELDS);
|
||||
add_to_doc_str_property_if_not_exists!(document, "stream_type", Value::String(String::from("live")));
|
||||
add_to_doc_str_property_if_not_exists!(document, "added", Value::String(chrono::Utc::now().timestamp().to_string()));
|
||||
}
|
||||
XtreamCluster::Video => {
|
||||
append_mandatory_fields(&mut document, xtream_const::VIDEO_STREAM_FIELDS);
|
||||
add_to_doc_str_property_if_not_exists!(document, "stream_type", Value::String(String::from("movie")));
|
||||
add_to_doc_str_property_if_not_exists!(document, "added", Value::String(chrono::Utc::now().timestamp().to_string()));
|
||||
}
|
||||
XtreamCluster::Series => {
|
||||
append_prepared_series_properties(props.as_ref(), &mut document);
|
||||
append_mandatory_fields(&mut document, xtream_const::SERIES_STREAM_FIELDS);
|
||||
}
|
||||
}
|
||||
|
||||
rewrite_doc_urls(resource_url.as_ref(), &mut document, xtream_const::XTREAM_VOD_REWRITE_URL_PROPS, "");
|
||||
|
||||
Value::Object(document)
|
||||
}
|
||||
|
||||
pub fn rewrite_doc_urls(resource_url: Option<&String>, document: &mut serde_json::Map<String, Value>, fields: &[&str], field_prefix: &str) {
|
||||
if let Some(rewrite_url) = resource_url {
|
||||
if let Some(bdpath) = document.get(xtream_const::XC_PROP_BACKDROP_PATH) {
|
||||
match bdpath {
|
||||
Value::String(bd_path) => {
|
||||
document.insert(xtream_const::XC_PROP_BACKDROP_PATH.to_string(), Value::Array(vec![Value::String(make_bdpath_resource_url(rewrite_url.as_str(), bd_path, 0, field_prefix))]));
|
||||
}
|
||||
Value::Array(bd_path) => {
|
||||
document.insert(xtream_const::XC_PROP_BACKDROP_PATH.to_string(), Value::Array(
|
||||
bd_path.iter()
|
||||
.filter_map(|val| val.as_str())
|
||||
.enumerate()
|
||||
.map(|(index, value)| Value::String(make_bdpath_resource_url(rewrite_url.as_str(), value, index, field_prefix)))
|
||||
.collect()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for &field in fields {
|
||||
if let Some(Value::String(value)) = document.get(field) {
|
||||
if value.starts_with("http") {
|
||||
document.insert(field.to_string(), Value::String(format!("{rewrite_url}/{field_prefix}{field}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct PlaylistXtreamCategory {
|
||||
#[serde(alias = "category_id")]
|
||||
|
||||
@@ -13,6 +13,7 @@ macro_rules! include_modules {
|
||||
pub mod utils;
|
||||
pub mod tools;
|
||||
pub mod library;
|
||||
pub mod ptt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::model::{Config, ConfigInput};
|
||||
use crate::utils::request::DynReader;
|
||||
use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster, DEFAULT_VIDEO_EXTENSIONS};
|
||||
use shared::utils::extract_id_from_url;
|
||||
use std::borrow::BorrowMut;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use crate::utils::request::DynReader;
|
||||
|
||||
// other implementations like calculating text_distance on all titles took too much time
|
||||
// we keep it now as simple as possible and less memory intensive.
|
||||
@@ -126,7 +126,7 @@ fn process_header(input_name: &str, video_suffixes: &[&str], content: &str, url:
|
||||
let mut c = skip_digit(&mut it);
|
||||
loop {
|
||||
match c {
|
||||
None=> break,
|
||||
None => break,
|
||||
Some(chr) => {
|
||||
if chr.is_whitespace() {
|
||||
// skip
|
||||
@@ -142,18 +142,19 @@ fn process_header(input_name: &str, video_suffixes: &[&str], content: &str, url:
|
||||
if !value.is_empty() {
|
||||
provider_id = Some(value);
|
||||
}
|
||||
} else if token == "tvg-chno" {
|
||||
plih.chno = value.parse::<u32>().unwrap_or(0);
|
||||
} else {
|
||||
process_header_fields!(plih, token.as_str(),
|
||||
(id, "tvg-id"),
|
||||
(group, "group-title"),
|
||||
(name, "tvg-name"),
|
||||
(chno, "tvg-chno"),
|
||||
(parent_code, "parent-code"),
|
||||
(audio_track, "audio-track"),
|
||||
(logo, "tvg-logo"),
|
||||
(logo_small, "tvg-logo-small"),
|
||||
(time_shift, "timeshift"),
|
||||
(rec, "tvg-rec"); value);
|
||||
(id, "tvg-id"),
|
||||
(group, "group-title"),
|
||||
(name, "tvg-name"),
|
||||
(parent_code, "parent-code"),
|
||||
(audio_track, "audio-track"),
|
||||
(logo, "tvg-logo"),
|
||||
(logo_small, "tvg-logo-small"),
|
||||
(time_shift, "timeshift"),
|
||||
(rec, "tvg-rec"); value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,10 +203,10 @@ pub async fn consume_m3u<F: FnMut(PlaylistItem)>(cfg: &Config, input: &ConfigInp
|
||||
let input_name = input.name.as_str();
|
||||
|
||||
let video_suffixes = match cfg.video.as_ref() {
|
||||
Some(config) => {
|
||||
config.extensions.iter().map(String::as_str).collect::<Vec<&str>>()
|
||||
},
|
||||
None => DEFAULT_VIDEO_EXTENSIONS.to_vec()
|
||||
Some(config) => {
|
||||
config.extensions.iter().map(String::as_str).collect::<Vec<&str>>()
|
||||
}
|
||||
None => DEFAULT_VIDEO_EXTENSIONS.to_vec()
|
||||
};
|
||||
let mut lines = tokio::io::BufReader::new(lines).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
@@ -292,7 +293,7 @@ mod test {
|
||||
assert_eq!(pli.title, "Seven");
|
||||
assert_eq!(pli.id, "abc-seven");
|
||||
assert_eq!(pli.logo, "https://abc.nz/.images/seven.png");
|
||||
assert_eq!(pli.chno, "7");
|
||||
assert_eq!(pli.chno, 7);
|
||||
assert_eq!(pli.group, "Sydney");
|
||||
}
|
||||
|
||||
@@ -307,7 +308,7 @@ mod test {
|
||||
assert_eq!(pli.title, "Seven");
|
||||
assert_eq!(pli.id, "abc-seven");
|
||||
assert_eq!(pli.logo, "https://abc.nz/.images/seven.png");
|
||||
assert_eq!(pli.chno, "7");
|
||||
assert_eq!(pli.chno, 7);
|
||||
assert_eq!(pli.group, "Sydney");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind};
|
||||
use std::borrow::Cow;
|
||||
use crate::model::ConfigInput;
|
||||
use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster};
|
||||
use crate::model::{XtreamCategory, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamStream};
|
||||
use shared::utils::{generate_playlist_uuid, trim_last_slash};
|
||||
use crate::utils::xtream::{get_xtream_stream_url_base};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::task::spawn_blocking;
|
||||
use crate::model::{XtreamCategory};
|
||||
use crate::utils::request::DynReader;
|
||||
use crate::utils::xtream::get_xtream_stream_url_base;
|
||||
use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind};
|
||||
use shared::model::{EpisodeStreamProperties, LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamProperties, XtreamCluster};
|
||||
use shared::utils::{generate_playlist_uuid, trim_last_slash};
|
||||
use std::collections::HashMap;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
async fn map_to_xtream_category(categories: DynReader) -> Result<Vec<XtreamCategory>, TuliproxError> {
|
||||
spawn_blocking(move || {
|
||||
@@ -22,85 +21,78 @@ async fn map_to_xtream_category(categories: DynReader) -> Result<Vec<XtreamCateg
|
||||
}).await.map_err(|e| TuliproxError::new(TuliproxErrorKind::Notify, format!("Mapping xtream categories failed: {e}")))?
|
||||
}
|
||||
|
||||
async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader) -> Result<Vec<XtreamStream>, TuliproxError> {
|
||||
async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader) -> Result<Vec<StreamProperties>, TuliproxError> {
|
||||
spawn_blocking(move || {
|
||||
let reader = tokio_util::io::SyncIoBridge::new(streams);
|
||||
match serde_json::from_reader::<_, Vec<XtreamStream>>(reader) {
|
||||
Ok(stream_list) => Ok(stream_list),
|
||||
Err(err) => {
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to map to xtream streams {xtream_cluster}: {err}", )
|
||||
let reader = tokio_util::io::SyncIoBridge::new(streams);
|
||||
|
||||
let parsed: Result<Vec<StreamProperties>, serde_json::Error> = match xtream_cluster {
|
||||
XtreamCluster::Live => serde_json::from_reader::<_, Vec<LiveStreamProperties>>(reader).map(|list| list.into_iter().map(StreamProperties::Live).collect()),
|
||||
XtreamCluster::Video => serde_json::from_reader::<_, Vec<VideoStreamProperties>>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Video).collect()),
|
||||
XtreamCluster::Series => serde_json::from_reader::<_, Vec<SeriesStreamProperties>>(reader).map(|list| list.into_iter().map(Box::new).map(StreamProperties::Series).collect()),
|
||||
};
|
||||
|
||||
match parsed {
|
||||
Ok(mut stream_list) => {
|
||||
for stream in &mut stream_list {
|
||||
stream.prepare();
|
||||
}
|
||||
Ok(stream_list)
|
||||
}
|
||||
Err(err) => {
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to map to xtream streams {xtream_cluster}: {err}", )
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await.map_err(|e| TuliproxError::new(TuliproxErrorKind::Notify, format!("Mapping xtream streams failed: {e}")))?
|
||||
}
|
||||
|
||||
fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &XtreamSeriesInfoEpisode) -> Arc<String> {
|
||||
fn create_xtream_series_episode_url<'a>(url: &'a str, username: &'a str, password: &'a str, episode: &'a SeriesStreamDetailEpisodeProperties) -> Cow<'a, str> {
|
||||
if episode.direct_source.is_empty() {
|
||||
let ext = episode.container_extension.clone();
|
||||
let stream_base_url = format!("{url}/series/{username}/{password}/{}.{ext}", episode.id);
|
||||
Arc::new(stream_base_url)
|
||||
Cow::Owned(stream_base_url)
|
||||
} else {
|
||||
Arc::new(episode.direct_source.clone())
|
||||
Cow::Borrowed(&episode.direct_source)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_xtream_series_info(info: &Value, group_title: &str, series_name: &str, input: &ConfigInput) -> Result<Option<Vec<(XtreamSeriesInfoEpisode, PlaylistItem)>>, TuliproxError> {
|
||||
pub fn parse_xtream_series_info(parent_uuid: &UUIDType, series_info: &SeriesStreamProperties, group_title: &str, series_name: &str, input: &ConfigInput) -> Option<Vec<PlaylistItem>> {
|
||||
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);
|
||||
|
||||
|
||||
|
||||
|
||||
match serde_json::from_value::<XtreamSeriesInfo>(info.to_owned()) {
|
||||
Ok(series_info) => {
|
||||
if let Some(episodes) = &series_info.episodes {
|
||||
let result: Vec<(XtreamSeriesInfoEpisode, PlaylistItem)> = episodes.iter().map(|episode| {
|
||||
let episode_url = create_xtream_series_episode_url(url, username, password, episode);
|
||||
let mut new_episode = episode.clone();
|
||||
|
||||
// We need to set the tmdb_id and tmdb from the series info if it is not set.
|
||||
if let Some(episode_info) = &mut new_episode.info {
|
||||
if episode_info.tmdb_id.is_none_or(|id| id == 0) && episode_info.tmdb.is_none_or(|id| id == 0) {
|
||||
let series_tmdb_id = series_info.info.as_ref().and_then(|i| i.tmdb_id.or(i.tmdb));
|
||||
episode_info.tmdb_id = series_tmdb_id;
|
||||
episode_info.tmdb = series_tmdb_id;
|
||||
}
|
||||
}
|
||||
|
||||
(new_episode,
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: episode.id.to_string(),
|
||||
uuid: generate_playlist_uuid(&input.name, &episode.id.to_string(), PlaylistItemType::Series, &episode_url),
|
||||
name: series_name.to_string(),
|
||||
logo: episode.info.as_ref().map_or_else(String::new, |info| info.movie_image.clone()),
|
||||
group: group_title.to_string(),
|
||||
title: episode.title.clone(),
|
||||
url: episode_url.to_string(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: episode.get_additional_properties(&series_info),
|
||||
category_id: 0,
|
||||
input_name: input.name.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
}).collect();
|
||||
return if result.is_empty() { Ok(None) } else { Ok(Some(result)) };
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to process series info for {series_name} {err}")
|
||||
}
|
||||
if let Some(episodes) = series_info.details.as_ref().and_then(|d| d.episodes.as_ref()) {
|
||||
let result: Vec<PlaylistItem> = episodes.iter().map(|episode| {
|
||||
let episode_url = create_xtream_series_episode_url(url, username, password, episode);
|
||||
let episode_info = EpisodeStreamProperties::from_series(series_info, episode);
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: episode.id.to_string(),
|
||||
uuid: generate_playlist_uuid(&input.name, &episode.id.to_string(), PlaylistItemType::Series, &episode_url),
|
||||
// we use parent_code to track the parent series
|
||||
parent_code: parent_uuid.to_string(),
|
||||
name: series_name.to_string(),
|
||||
logo: episode.movie_image.clone(),
|
||||
group: group_title.to_string(),
|
||||
title: episode.title.clone(),
|
||||
url: episode_url.to_string(),
|
||||
item_type: PlaylistItemType::Series,
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
additional_properties: Some(StreamProperties::Episode(episode_info)),
|
||||
category_id: 0,
|
||||
input_name: input.name.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}).collect();
|
||||
return if result.is_empty() { None } else { Some(result) };
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str,
|
||||
username: &str, password: &str,
|
||||
stream_id: u32, container_extension: Option<&String>,
|
||||
stream_id: u32, container_extension: Option<&str>,
|
||||
live_stream_use_prefix: bool, live_stream_without_extension: bool) -> String {
|
||||
let url = trim_last_slash(url);
|
||||
let stream_base_url = match xtream_cluster {
|
||||
@@ -110,8 +102,11 @@ pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str,
|
||||
format!("{url}/{ctx_path}{username}/{password}/{stream_id}{suffix}")
|
||||
}
|
||||
XtreamCluster::Video => {
|
||||
let ext = container_extension.as_ref().map_or("mp4", |e| e.as_str());
|
||||
format!("{url}/movie/{username}/{password}/{stream_id}.{ext}")
|
||||
if let Some(extension) = container_extension {
|
||||
format!("{url}/movie/{username}/{password}/{stream_id}.{extension}")
|
||||
} else {
|
||||
format!("{url}/movie/{username}/{password}/{stream_id}")
|
||||
}
|
||||
}
|
||||
XtreamCluster::Series =>
|
||||
format!("{}&action={}&series_id={stream_id}", get_xtream_stream_url_base(url.as_ref(), username, password), crate::model::XC_ACTION_GET_SERIES_INFO)
|
||||
@@ -119,21 +114,19 @@ pub fn get_xtream_url(xtream_cluster: XtreamCluster, url: &str,
|
||||
stream_base_url
|
||||
}
|
||||
|
||||
pub fn create_xtream_url(xtream_cluster: XtreamCluster, url: &str, username: &str, password: &str,
|
||||
stream: &XtreamStream, live_stream_use_prefix: bool, live_stream_without_extension: bool) -> String {
|
||||
if stream.direct_source.is_empty() {
|
||||
get_xtream_url(xtream_cluster, url, username, password, stream.get_stream_id(),
|
||||
stream.container_extension.as_ref().map(std::string::ToString::to_string).as_ref(),
|
||||
live_stream_use_prefix, live_stream_without_extension)
|
||||
} else {
|
||||
stream.direct_source.clone()
|
||||
}
|
||||
pub fn create_xtream_url<'a>(xtream_cluster: XtreamCluster, url: &'a str, username: &'a str, password: &'a str,
|
||||
stream: &'a StreamProperties, live_stream_use_prefix: bool, live_stream_without_extension: bool) -> Cow<'a, str> {
|
||||
stream.get_direct_source().unwrap_or_else(||
|
||||
Cow::Owned(get_xtream_url(xtream_cluster, url, username, password, stream.get_stream_id(),
|
||||
stream.get_container_extension().as_deref(),
|
||||
live_stream_use_prefix, live_stream_without_extension))
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn parse_xtream(input: &ConfigInput,
|
||||
xtream_cluster: XtreamCluster,
|
||||
categories: DynReader,
|
||||
streams: DynReader) -> Result<Option<Vec<PlaylistGroup>>, TuliproxError> {
|
||||
xtream_cluster: XtreamCluster,
|
||||
categories: DynReader,
|
||||
streams: DynReader) -> Result<Option<Vec<PlaylistGroup>>, TuliproxError> {
|
||||
match map_to_xtream_category(categories).await {
|
||||
Ok(xtream_categories) => {
|
||||
let input_name = input.name.clone();
|
||||
@@ -142,13 +135,11 @@ pub async fn parse_xtream(input: &ConfigInput,
|
||||
let password = input.password.as_ref().map_or("", |v| v);
|
||||
|
||||
match map_to_xtream_streams(xtream_cluster, streams).await {
|
||||
Ok(mut xtream_streams) => {
|
||||
let mut group_map: HashMap<String, XtreamCategory> =
|
||||
xtream_categories.into_iter().map(|category|
|
||||
(category.category_id.clone(), category)
|
||||
).collect();
|
||||
Ok(xtream_streams) => {
|
||||
let mut group_map: HashMap<u32, XtreamCategory> =
|
||||
xtream_categories.into_iter().map(|category| (category.category_id, category)).collect();
|
||||
let mut unknown_grp = XtreamCategory {
|
||||
category_id: "0".to_string(),
|
||||
category_id: 0u32,
|
||||
category_name: "Unknown".to_string(),
|
||||
channels: vec![],
|
||||
};
|
||||
@@ -156,33 +147,24 @@ pub async fn parse_xtream(input: &ConfigInput,
|
||||
let (live_stream_use_prefix, live_stream_without_extension) = input.options.as_ref()
|
||||
.map_or((true, false), |o| (o.xtream_live_stream_use_prefix, o.xtream_live_stream_without_extension));
|
||||
|
||||
for stream in &mut xtream_streams {
|
||||
let group = group_map.get_mut(&stream.category_id).unwrap_or(&mut unknown_grp);
|
||||
for stream in xtream_streams {
|
||||
let group = group_map.get_mut(&stream.get_category_id()).unwrap_or(&mut unknown_grp);
|
||||
let category_name = &group.category_name;
|
||||
let stream_url = create_xtream_url(xtream_cluster, url, username, password, stream, live_stream_use_prefix, live_stream_without_extension);
|
||||
let stream_url = create_xtream_url(xtream_cluster, url, username, password, &stream, live_stream_use_prefix, live_stream_without_extension);
|
||||
let item_type = PlaylistItemType::from(xtream_cluster);
|
||||
// EPG Channel id fix, remove empty
|
||||
stream.epg_channel_id = if let XtreamCluster::Live = xtream_cluster {
|
||||
stream.epg_channel_id.as_ref()
|
||||
.filter(|epg_id| !epg_id.trim().is_empty())
|
||||
.map(|epg_id| epg_id.to_lowercase())
|
||||
.or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let item = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: stream.get_stream_id().to_string(),
|
||||
uuid: generate_playlist_uuid(&input_name, &stream.get_stream_id().to_string(), item_type, &stream_url),
|
||||
name: stream.name.clone(),
|
||||
logo: stream.stream_icon.clone(),
|
||||
name: stream.get_name().to_string(),
|
||||
logo: stream.get_stream_icon().to_string(),
|
||||
group: category_name.clone(),
|
||||
title: stream.name.clone(),
|
||||
url: stream_url.clone(),
|
||||
epg_channel_id: stream.epg_channel_id.clone(),
|
||||
title: stream.get_name().to_string(),
|
||||
url: stream_url.to_string(),
|
||||
epg_channel_id: stream.get_epg_channel_id(),
|
||||
item_type,
|
||||
xtream_cluster,
|
||||
additional_properties: stream.get_additional_properties(),
|
||||
additional_properties: Some(stream),
|
||||
category_id: 0,
|
||||
input_name: input_name.clone(),
|
||||
..Default::default()
|
||||
@@ -190,15 +172,17 @@ pub async fn parse_xtream(input: &ConfigInput,
|
||||
};
|
||||
group.add(item);
|
||||
}
|
||||
|
||||
|
||||
let has_channels = !unknown_grp.channels.is_empty();
|
||||
if has_channels {
|
||||
group_map.insert("0".to_string(), unknown_grp);
|
||||
group_map.insert(0, unknown_grp);
|
||||
}
|
||||
|
||||
Ok(Some(group_map.values().filter(|category| !category.channels.is_empty())
|
||||
.map(|category| {
|
||||
PlaylistGroup {
|
||||
id: category.category_id.parse::<u32>().unwrap_or(0),
|
||||
id: category.category_id,
|
||||
xtream_cluster,
|
||||
title: category.category_name.clone(),
|
||||
channels: category.channels.clone(),
|
||||
@@ -214,25 +198,23 @@ pub async fn parse_xtream(input: &ConfigInput,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use shared::model::XtreamCluster;
|
||||
use crate::model::{XtreamSeriesInfo};
|
||||
use crate::processing::parser::xtream::map_to_xtream_streams;
|
||||
use crate::utils::async_file_reader;
|
||||
use shared::model::{XtreamCluster, XtreamSeriesInfo};
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_read_json_file_into_struct() {
|
||||
let file_content = fs::read_to_string("/tmp/series-info.json").expect("Unable to read file");
|
||||
match serde_json::from_str::<XtreamSeriesInfo>(&file_content) {
|
||||
match serde_json::from_str::<XtreamSeriesInfo>(&file_content) {
|
||||
Ok(series_info) => {
|
||||
println!("{:#?}", series_info);
|
||||
assert!(true);
|
||||
},
|
||||
}
|
||||
Err(err) => {
|
||||
assert!(false, "Failed to parse json file: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -244,7 +226,7 @@ mod tests {
|
||||
println!("{:?}", _streams.get(100));
|
||||
println!("{:?}", _streams.get(200));
|
||||
assert!(true);
|
||||
},
|
||||
}
|
||||
Err(err) => {
|
||||
assert!(false, "Failed to parse json file: {err}");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use shared::model::{MsgKind, PlaylistGroup};
|
||||
use crate::messaging::{send_message};
|
||||
use crate::model::Config;
|
||||
use crate::utils;
|
||||
use crate::utils::{bincode_deserialize, bincode_serialize};
|
||||
use crate::utils::{binary_deserialize, binary_serialize};
|
||||
|
||||
pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_name: &str, pl: &PlaylistGroup) {
|
||||
let mut new_tree = BTreeSet::new();
|
||||
@@ -20,7 +20,7 @@ pub async fn process_group_watch(client: &reqwest::Client, cfg: &Config, target_
|
||||
Some(path) => {
|
||||
let save_path = path.as_path();
|
||||
let mut changed = false;
|
||||
if path.exists() {
|
||||
if let Ok(true) = tokio::fs::try_exists(&path).await {
|
||||
if let Some(loaded_tree) = load_watch_tree(&path).await {
|
||||
// Find elements in set2 but not in set1
|
||||
let added_difference: BTreeSet<String> = new_tree.difference(&loaded_tree).cloned().collect();
|
||||
@@ -79,11 +79,11 @@ async fn handle_watch_notification(client: &reqwest::Client, cfg: &Config, added
|
||||
|
||||
async fn load_watch_tree(path: &Path) -> Option<BTreeSet<String>> {
|
||||
let encoded = tokio::fs::read(path).await.ok()?;
|
||||
bincode_deserialize(&encoded[..]).ok()
|
||||
binary_deserialize(&encoded[..]).ok()
|
||||
}
|
||||
|
||||
async fn save_watch_tree(path: &Path, tree: &BTreeSet<String>) -> std::io::Result<()> {
|
||||
let encoded: Vec<u8> = bincode_serialize(&tree)?;
|
||||
let encoded: Vec<u8> = binary_serialize(&tree)?;
|
||||
tokio::fs::write(path, encoded).await
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ impl EpgIdCache<'_> {
|
||||
/// cache.normalize_and_store("Discovery Channel", Some(&"discovery.epg".to_string()));
|
||||
/// assert!(cache.normalized.contains_key(&cache.normalize("Discovery Channel")));
|
||||
/// ```
|
||||
fn normalize_and_store(&mut self, name: &str, epg_id: Option<&String>) {
|
||||
fn normalize_and_store(&mut self, name: &str, epg_id: Option<&str>) {
|
||||
self.insert_normalized(name);
|
||||
|
||||
if let Some(chan_epg_id) = epg_id {
|
||||
@@ -124,7 +124,7 @@ impl EpgIdCache<'_> {
|
||||
|
||||
if needs_normalization {
|
||||
let name = &channel.header.name;
|
||||
self.normalize_and_store(name, channel.header.epg_channel_id.as_ref());
|
||||
self.normalize_and_store(name, channel.header.epg_channel_id.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry};
|
||||
use crate::model::{AppConfig, ConfigInput, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamSeriesInfoEpisodeInfo, XtreamSeriesInfoInfo};
|
||||
use crate::model::{XtreamMovieData, XtreamMovieInfo, XtreamMovieInfoDetails};
|
||||
use serde_json::value::RawValue;
|
||||
use crate::model::{AppConfig, ConfigInput};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster};
|
||||
use shared::utils::{generate_playlist_uuid, string_to_uuid_type};
|
||||
use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster};
|
||||
use shared::utils::{generate_playlist_uuid};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn get_library_playlist(_client: &reqwest::Client, app_config: &Arc<AppConfig>, input: &Arc<ConfigInput>) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc<AppConfig>, input: &Arc<ConfigInput>) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
let config = &*app_config.config.load();
|
||||
let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) };
|
||||
if !library_config.enabled { return (vec![], vec![]); }
|
||||
@@ -56,16 +54,14 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s
|
||||
|
||||
match metadata {
|
||||
MediaMetadata::Movie(_) => {
|
||||
let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry).and_then(|info| {
|
||||
let raw = serde_json::to_string(&info).ok()?;
|
||||
RawValue::from_string(raw).ok()
|
||||
});
|
||||
let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry);
|
||||
vec![PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: string_to_uuid_type(&entry.uuid),
|
||||
uuid: UUIDType::from_valid_uuid(&entry.uuid),
|
||||
name: metadata.title().to_string(),
|
||||
group: group_name.to_string(),
|
||||
title: metadata.title().to_string(),
|
||||
logo: metadata.poster().map_or_else(String::new, ToString::to_string),
|
||||
url: format!("file://{}", entry.file_path),
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
additional_properties,
|
||||
@@ -77,48 +73,71 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s
|
||||
}
|
||||
MediaMetadata::Series(_series) => {
|
||||
let mut items = vec![];
|
||||
if let Some(xtream_series_info) = metadata_cache_entry_to_xtream_series_info(entry) {
|
||||
let additional_properties = serde_json::to_string(&xtream_series_info).ok().and_then(|r| RawValue::from_string(r).ok());
|
||||
if let Some(additional_properties) = metadata_cache_entry_to_xtream_series_info(entry) {
|
||||
let mut episodes = vec![];
|
||||
if let StreamProperties::Series(series_properties) = &additional_properties {
|
||||
if let Some(details_props) = series_properties.details.as_ref() {
|
||||
if let Some(prop_episodes) = details_props.episodes.as_ref() {
|
||||
for episode in prop_episodes {
|
||||
let logo = if episode.movie_image.is_empty() { metadata.poster().map_or_else(String::new, ToString::to_string) } else { episode.movie_image.clone() };
|
||||
let container_extension = Path::new(&episode.direct_source)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(ToString::to_string).unwrap_or_default();
|
||||
episodes.push(PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: episode.id.to_string(),
|
||||
// we use parent_code for local series to find the parent series info and straighten the virtual_ids
|
||||
parent_code: entry.uuid.clone(),
|
||||
uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source),
|
||||
logo: logo.clone(),
|
||||
name: episode.title.clone(),
|
||||
group: group_name.to_string(),
|
||||
title: episode.title.clone(),
|
||||
url: episode.direct_source.clone(),
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
item_type: PlaylistItemType::LocalSeries,
|
||||
category_id: 0,
|
||||
input_name: input_name.to_string(),
|
||||
additional_properties: Some(StreamProperties::Episode(EpisodeStreamProperties {
|
||||
episode_id: episode.id,
|
||||
episode: episode.episode_num,
|
||||
season: episode.season,
|
||||
added: Some(episode.added.clone()),
|
||||
release_date: Some(episode.release_date.clone()),
|
||||
tmdb: episode.tmdb,
|
||||
movie_image: logo,
|
||||
container_extension,
|
||||
audio: None,
|
||||
video: None,
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let series_info = PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
uuid: string_to_uuid_type(&entry.uuid),
|
||||
uuid: UUIDType::from_valid_uuid(&entry.uuid),
|
||||
id: entry.uuid.clone(),
|
||||
name: metadata.title().to_string(),
|
||||
group: group_name.to_string(),
|
||||
title: metadata.title().to_string(),
|
||||
logo: metadata.poster().map_or_else(String::new, ToString::to_string),
|
||||
url: format!("file://{}", entry.file_path),
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
item_type: PlaylistItemType::LocalSeriesInfo,
|
||||
input_name: input_name.to_string(),
|
||||
additional_properties,
|
||||
additional_properties: Some(additional_properties),
|
||||
..PlaylistItemHeader::default()
|
||||
}
|
||||
};
|
||||
|
||||
items.push(series_info);
|
||||
if let Some(episodes) = xtream_series_info.episodes.as_ref() {
|
||||
for episode in episodes {
|
||||
items.push(PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: episode.id.to_string(),
|
||||
// we use parent_code for local series to find the parent series info and straighten the virtual_ids
|
||||
parent_code: entry.uuid.clone(),
|
||||
uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source),
|
||||
name: episode.title.clone(),
|
||||
group: group_name.to_string(),
|
||||
title: episode.title.clone(),
|
||||
url: episode.direct_source.clone(),
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
item_type: PlaylistItemType::LocalSeries,
|
||||
category_id: 0,
|
||||
input_name: input_name.to_string(),
|
||||
additional_properties: episode.get_additional_properties(&xtream_series_info),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
items.extend(episodes);
|
||||
}
|
||||
|
||||
items
|
||||
@@ -128,7 +147,7 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &s
|
||||
|
||||
pub fn metadata_cache_entry_to_xtream_movie_info(
|
||||
entry: &MetadataCacheEntry,
|
||||
) -> Option<XtreamMovieInfo> {
|
||||
) -> Option<StreamProperties> {
|
||||
let movie = match &entry.metadata {
|
||||
MediaMetadata::Movie(m) => m,
|
||||
MediaMetadata::Series(_) => return None,
|
||||
@@ -137,96 +156,123 @@ pub fn metadata_cache_entry_to_xtream_movie_info(
|
||||
let container_extension = Path::new(&entry.file_path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(ToString::to_string);
|
||||
.map(ToString::to_string).unwrap_or_default();
|
||||
|
||||
let actor_names = movie.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", "));
|
||||
|
||||
let info = XtreamMovieInfoDetails {
|
||||
kinopoisk_url: movie.tmdb_id.map(|id| format!("https://www.themoviedb.org/movie/{id}")),
|
||||
tmdb_id: movie.tmdb_id.map(|id| id.to_string()),
|
||||
name: Some(movie.title.clone()),
|
||||
o_name: movie.original_title.clone(),
|
||||
cover_big: movie.poster.clone(),
|
||||
movie_image: movie.poster.clone(),
|
||||
releasedate: movie.year.map(|y| format!("{y}-01-01")),
|
||||
episode_run_time: movie.runtime,
|
||||
director: movie.directors.as_ref().map(|d| d.join(", ")),
|
||||
youtube_trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())),
|
||||
actors: actor_names.clone(),
|
||||
cast: actor_names,
|
||||
genre: movie.genres.as_ref().map(|g| g.join(", ")),
|
||||
description: movie.plot.clone(),
|
||||
plot: movie.plot.clone(),
|
||||
age: None,
|
||||
mpaa_rating: movie.mpaa.clone(),
|
||||
rating_count_kinopoisk: 0,
|
||||
country: None,
|
||||
backdrop_path: movie
|
||||
.fanart
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|f| vec![f.clone()])
|
||||
.or_else(|| {
|
||||
movie.poster
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|p| vec![p.clone()])
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
duration_secs: movie.runtime.map(|r| (r * 60).to_string()),
|
||||
duration: movie.runtime.map(|r| {
|
||||
let h = r / 60;
|
||||
let m = r % 60;
|
||||
format!("{h:02}:{m:02}:00")
|
||||
}),
|
||||
|
||||
video: Vec::new(),
|
||||
audio: Vec::new(),
|
||||
bitrate: 0,
|
||||
rating: movie.rating.map(|r| format!("{r:.2}")),
|
||||
runtime: movie.runtime.map(|r| (r * 60).to_string()),
|
||||
status: Some("Released".to_string()),
|
||||
};
|
||||
|
||||
let movie_data = XtreamMovieData {
|
||||
stream_id: 0,
|
||||
let properties = VideoStreamProperties {
|
||||
name: movie.title.clone(),
|
||||
added: Some(entry.file_modified.to_string()),
|
||||
category_id: 0,
|
||||
category_ids: vec![0],
|
||||
container_extension,
|
||||
custom_sid: None,
|
||||
stream_id: 0,
|
||||
stream_icon: movie.poster.as_deref().or(movie.fanart.as_deref()).unwrap_or("").to_owned(),
|
||||
direct_source: String::new(),
|
||||
custom_sid: None,
|
||||
added: entry.file_modified.to_string(),
|
||||
container_extension,
|
||||
rating: movie.rating,
|
||||
rating_5based: None,
|
||||
stream_type: Some("movie".to_string()),
|
||||
trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())),
|
||||
tmdb: movie.tmdb_id,
|
||||
is_adult: 0,
|
||||
details: Some(VideoStreamDetailProperties {
|
||||
kinopoisk_url: movie.tmdb_id.map(|id| format!("https://www.themoviedb.org/movie/{id}")),
|
||||
o_name: movie.original_title.clone(),
|
||||
cover_big: movie.poster.clone(),
|
||||
movie_image: movie.poster.clone(),
|
||||
release_date: movie.year.map(|y| format!("{y}-01-01")),
|
||||
episode_run_time: movie.runtime,
|
||||
director: movie.directors.as_ref().map(|d| d.join(", ")),
|
||||
youtube_trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())),
|
||||
actors: actor_names.clone(),
|
||||
cast: actor_names,
|
||||
genre: movie.genres.as_ref().map(|g| g.join(", ")),
|
||||
description: movie.plot.clone(),
|
||||
plot: movie.plot.clone(),
|
||||
age: None,
|
||||
mpaa_rating: movie.mpaa.clone(),
|
||||
rating_count_kinopoisk: 0,
|
||||
country: None,
|
||||
backdrop_path: movie
|
||||
.fanart
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|f| vec![f.clone()])
|
||||
.or_else(|| {
|
||||
movie.poster
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|p| vec![p.clone()])
|
||||
}),
|
||||
duration_secs: movie.runtime.map(|r| (r * 60).to_string()),
|
||||
duration: movie.runtime.map(|r| {
|
||||
let h = r / 60;
|
||||
let m = r % 60;
|
||||
format!("{h:02}:{m:02}:00")
|
||||
}),
|
||||
|
||||
video: None,
|
||||
audio: None,
|
||||
bitrate: 0,
|
||||
runtime: movie.runtime.map(|r| (r * 60).to_string()),
|
||||
status: Some("Released".to_string()),
|
||||
}),
|
||||
};
|
||||
|
||||
Some(XtreamMovieInfo { info, movie_data })
|
||||
Some(StreamProperties::Video(Box::new(properties)))
|
||||
}
|
||||
|
||||
pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
entry: &MetadataCacheEntry,
|
||||
) -> Option<XtreamSeriesInfo> {
|
||||
) -> Option<StreamProperties> {
|
||||
let series = match &entry.metadata {
|
||||
MediaMetadata::Movie(_) => return None,
|
||||
MediaMetadata::Series(m) => m,
|
||||
};
|
||||
|
||||
let actor_names = series.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", ")).unwrap_or_default();
|
||||
let release_date = series.year.map(|y| format!("{y}-01-01")).unwrap_or_default();
|
||||
let release_date = series.year.map(|y| format!("{y}-01-01"));
|
||||
let youtube_trailer = series.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())).unwrap_or_default();
|
||||
|
||||
let info = XtreamSeriesInfoInfo {
|
||||
let episodes = series.episodes.as_ref().map(|episodes| {
|
||||
episodes.iter().filter(|episode| !episode.file_path.is_empty()).map(|episode| {
|
||||
let container_extension = Path::new(&episode.file_path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default();
|
||||
let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id);
|
||||
|
||||
SeriesStreamDetailEpisodeProperties {
|
||||
id: tmdb_id.unwrap_or_default(),
|
||||
episode_num: episode.episode,
|
||||
season: episode.season,
|
||||
title: episode.title.clone(),
|
||||
container_extension,
|
||||
custom_sid: None,
|
||||
added: episode.file_modified.to_string(),
|
||||
direct_source: episode.file_path.clone(),
|
||||
tmdb: tmdb_id,
|
||||
release_date: episode_release_date.clone(),
|
||||
plot: episode.plot.clone(),
|
||||
duration_secs: episode.runtime.map_or(0, |r| r * 60),
|
||||
duration: episode.runtime
|
||||
.map(|r| format!("{:02}:{:02}:00", r / 60, r % 60))
|
||||
.unwrap_or_default(),
|
||||
movie_image: episode.thumb.clone().unwrap_or_default(),
|
||||
audio: None,
|
||||
video: None,
|
||||
bitrate: 0,
|
||||
rating: None,
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let properties = SeriesStreamProperties {
|
||||
name: series.title.clone(),
|
||||
cover: series.poster.clone().unwrap_or_default(),
|
||||
plot: series.plot.clone().unwrap_or_default(),
|
||||
cast: actor_names,
|
||||
director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default(),
|
||||
genre: series.genres.as_ref().map(|d| d.join(", ")).unwrap_or_default(),
|
||||
release_date: release_date.clone(),
|
||||
releaseDate: release_date.clone(),
|
||||
releasedate: release_date.clone(),
|
||||
last_modified: series.last_updated.to_string(),
|
||||
rating: series.rating.unwrap_or(0f64),
|
||||
rating_5based: 0.0,
|
||||
category_id: 0,
|
||||
series_id: 0,
|
||||
backdrop_path: series
|
||||
.fanart
|
||||
.as_ref()
|
||||
@@ -237,73 +283,24 @@ pub fn metadata_cache_entry_to_xtream_series_info(
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|p| vec![p.clone()])
|
||||
}),
|
||||
trailer: youtube_trailer.clone(),
|
||||
cast: actor_names,
|
||||
cover: series.poster.clone().unwrap_or_default(),
|
||||
director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default(),
|
||||
episode_run_time: None,
|
||||
genre: series.genres.as_ref().map(|d| d.join(", ")),
|
||||
last_modified: Some(series.last_updated.to_string()),
|
||||
plot: series.plot.clone(),
|
||||
rating: series.rating.unwrap_or(0f64),
|
||||
rating_5based: 0.0,
|
||||
release_date,
|
||||
youtube_trailer,
|
||||
episode_run_time: String::from("0"),
|
||||
category_id: 0,
|
||||
tmdb_id: series.tmdb_id,
|
||||
tmdb: series.tmdb_id,
|
||||
year: series.year,
|
||||
details: Some(SeriesStreamDetailProperties {
|
||||
year: series.year,
|
||||
// seasons are not delivered through xtream get_series_info.
|
||||
episodes,
|
||||
}),
|
||||
};
|
||||
|
||||
// seasons are not delivered through xtream get_series_info.
|
||||
let seasons = Some(vec![]); /*series.seasons.as_ref()
|
||||
.map(|seasons| seasons.iter().map(|season| XtreamSeriesInfoSeason {
|
||||
air_date: season.air_date.clone().unwrap_or_default(),
|
||||
episode_count: season.episode_count,
|
||||
id: season.id,
|
||||
name: season.name.clone(),
|
||||
overview: season.overview.clone().unwrap_or_default(),
|
||||
season_number: season.season_number,
|
||||
vote_average: season.vote_average,
|
||||
cover: season.poster_path.as_ref().cloned().unwrap_or_default(),
|
||||
cover_big: season.poster_path.as_ref().cloned().unwrap_or_default(),
|
||||
}).collect());
|
||||
*/
|
||||
|
||||
let episodes = series.episodes.as_ref().map(|episodes| {
|
||||
episodes.iter().filter(|episode| !episode.file_path.is_empty()).map(|episode| {
|
||||
let container_extension = Path::new(&episode.file_path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
|
||||
let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default();
|
||||
|
||||
let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id);
|
||||
|
||||
XtreamSeriesInfoEpisode {
|
||||
id: episode.id,
|
||||
episode_num: episode.episode,
|
||||
season: episode.season,
|
||||
title: episode.title.clone(),
|
||||
container_extension,
|
||||
info: Some(XtreamSeriesInfoEpisodeInfo {
|
||||
id: tmdb_id,
|
||||
tmdb_id,
|
||||
tmdb: tmdb_id,
|
||||
season: episode.season,
|
||||
release_date: episode_release_date.clone(),
|
||||
releaseDate: episode_release_date.clone(),
|
||||
releasedate: episode_release_date,
|
||||
plot: episode.plot.clone().unwrap_or_default(),
|
||||
duration_secs: episode.runtime.map_or(0, |r| r * 60),
|
||||
duration: episode.runtime
|
||||
.map(|r| format!("{:02}:{:02}:00", r / 60, r % 60))
|
||||
.unwrap_or_default(),
|
||||
movie_image: episode.thumb.clone().unwrap_or_default(),
|
||||
video: None,
|
||||
audio: None,
|
||||
bitrate: 0,
|
||||
rating: episode.rating.unwrap_or(0.0),
|
||||
}),
|
||||
custom_sid: String::new(),
|
||||
added: episode.file_modified.to_string(),
|
||||
direct_source: episode.file_path.clone(),
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
Some(XtreamSeriesInfo { seasons, info: Some(info), episodes })
|
||||
Some(StreamProperties::Series(Box::new(properties)))
|
||||
}
|
||||
@@ -8,28 +8,6 @@ mod sort;
|
||||
pub mod trakt;
|
||||
mod library;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! handle_error {
|
||||
($stmt:expr, $map_err:expr) => {
|
||||
if let Err(err) = $stmt {
|
||||
$map_err(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
use handle_error;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! handle_error_and_return {
|
||||
($stmt:expr, $map_err:expr) => {
|
||||
if let Err(err) = $stmt {
|
||||
$map_err(err);
|
||||
return Default::default();
|
||||
}
|
||||
};
|
||||
}
|
||||
use handle_error_and_return;
|
||||
|
||||
|
||||
//
|
||||
// fn get_resolve_<cluster>_options(target: &ConfigTarget, fpl: &FetchedPlaylist) -> (bool, u16)
|
||||
//
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::model::{AppConfig, ConfigFavourites, ConfigInput, ConfigRename};
|
||||
use crate::model::{AppConfig, Config, ConfigFavourites, ConfigInput, ConfigRename};
|
||||
use crate::utils::epg;
|
||||
use crate::utils::m3u;
|
||||
use crate::utils::xtream;
|
||||
use crate::Config;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -19,11 +18,12 @@ use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats};
|
||||
use crate::processing::parser::xmltv::flatten_tvguide;
|
||||
use crate::processing::playlist_watch::process_group_watch;
|
||||
use crate::processing::processor::epg::process_playlist_epg;
|
||||
use crate::processing::processor::library;
|
||||
use crate::processing::processor::sort::sort_playlist;
|
||||
use crate::processing::processor::trakt::process_trakt_categories_for_target;
|
||||
use crate::processing::processor::xtream_series::playlist_resolve_series;
|
||||
use crate::processing::processor::xtream_vod::playlist_resolve_vod;
|
||||
use crate::repository::playlist_repository::persist_playlist;
|
||||
use crate::repository::playlist_repository::{persist_input_playlist, persist_playlist};
|
||||
use crate::utils::StepMeasure;
|
||||
use crate::utils::{debug_if_enabled, trace_if_enabled};
|
||||
use deunicode::deunicode;
|
||||
@@ -34,7 +34,6 @@ use shared::foundation::filter::{get_field_value, set_field_value, Filter, Value
|
||||
use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistUpdateState, ProcessingOrder, UUIDType, XtreamCluster};
|
||||
use shared::utils::{default_as_default, hash_bytes};
|
||||
use std::time::Instant;
|
||||
use crate::processing::processor::library;
|
||||
|
||||
fn is_valid(pli: &PlaylistItem, filter: &Filter) -> bool {
|
||||
let provider = ValueProvider { pli };
|
||||
@@ -111,17 +110,17 @@ fn filter_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Opt
|
||||
|
||||
fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) {
|
||||
let assigned_chnos: HashSet<u32> = new_playlist.iter().flat_map(|g| &g.channels)
|
||||
.filter(|c| !c.header.chno.is_empty())
|
||||
.map(|c| c.header.chno.as_str())
|
||||
.flat_map(str::parse::<u32>).collect();
|
||||
.filter(|c| c.header.chno != 0)
|
||||
.map(|c| c.header.chno)
|
||||
.collect();
|
||||
let mut chno = 1;
|
||||
for group in new_playlist {
|
||||
for chan in &mut group.channels {
|
||||
if chan.header.chno.is_empty() {
|
||||
if chan.header.chno == 0 {
|
||||
while assigned_chnos.contains(&chno) {
|
||||
chno += 1;
|
||||
}
|
||||
chan.header.chno = chno.to_string();
|
||||
chan.header.chno = chno;
|
||||
chno += 1;
|
||||
}
|
||||
}
|
||||
@@ -173,7 +172,7 @@ fn rename_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Opt
|
||||
|
||||
fn create_alias_uuid(base_uuid: &UUIDType, mapping_id: &str) -> UUIDType {
|
||||
let mut data = Vec::with_capacity(base_uuid.len() + mapping_id.len());
|
||||
data.extend_from_slice(base_uuid);
|
||||
data.extend_from_slice(base_uuid.as_ref());
|
||||
data.extend_from_slice(mapping_id.as_bytes());
|
||||
hash_bytes(&data)
|
||||
}
|
||||
@@ -309,10 +308,10 @@ fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bo
|
||||
async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc<AppConfig>, input: &Arc<ConfigInput>) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
let config = &*app_config.config.load();
|
||||
match input.input_type {
|
||||
InputType::M3u => m3u::get_m3u_playlist(client, config, input).await,
|
||||
InputType::Xtream => xtream::get_xtream_playlist(config, client, input).await,
|
||||
InputType::M3u => m3u::download_m3u_playlist(client, config, input).await,
|
||||
InputType::Xtream => xtream::download_xtream_playlist(config, client, input).await,
|
||||
InputType::M3uBatch | InputType::XtreamBatch => (vec![], vec![]),
|
||||
InputType::Library => library::get_library_playlist(client, app_config, input).await,
|
||||
InputType::Library => library::download_library_playlist(client, app_config, input).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,13 +331,25 @@ async fn process_source(client: &reqwest::Client, app_config: Arc<AppConfig>, so
|
||||
if is_input_enabled(input, &user_targets) {
|
||||
source_downloaded = true;
|
||||
let start_time = Instant::now();
|
||||
let (mut playlistgroups, mut error_list) = playlist_download_from_input(client, &app_config, input).await;
|
||||
// Download playlist for input
|
||||
let (playlistgroups, mut error_list) = {
|
||||
let (downloaded_playlist, mut download_err) = playlist_download_from_input(client, &app_config, input).await;
|
||||
let (playlist, error) = persist_input_playlist(&app_config, input, downloaded_playlist).await;
|
||||
if let Some(err) = error {
|
||||
error!("Failed to persist input playlist {}", input.name);
|
||||
download_err.push(err);
|
||||
}
|
||||
(playlist, download_err)
|
||||
};
|
||||
|
||||
// Download epg for input
|
||||
let (tvguide, mut tvguide_errors) = if error_list.is_empty() {
|
||||
let working_dir = &app_config.config.load().working_dir;
|
||||
epg::get_xmltv(client, input, working_dir).await
|
||||
} else {
|
||||
(None, vec![])
|
||||
};
|
||||
|
||||
errors.append(&mut error_list);
|
||||
errors.append(&mut tvguide_errors);
|
||||
let group_count = playlistgroups.len();
|
||||
@@ -348,7 +359,6 @@ async fn process_source(client: &reqwest::Client, app_config: Arc<AppConfig>, so
|
||||
info!("Source is empty {input_name}");
|
||||
errors.push(notify_err!(format!("Source is empty {input_name}")));
|
||||
} else {
|
||||
playlistgroups.iter_mut().for_each(PlaylistGroup::on_load);
|
||||
source_playlists.push(
|
||||
FetchedPlaylist {
|
||||
input,
|
||||
@@ -533,7 +543,7 @@ fn flatten_groups(playlistgroups: Vec<PlaylistGroup>) -> Vec<PlaylistGroup> {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn process_playlist_for_target(app_config: &AppConfig,
|
||||
async fn process_playlist_for_target(app_config: &Arc<AppConfig>,
|
||||
client: &reqwest::Client,
|
||||
playlists: &mut [FetchedPlaylist<'_>],
|
||||
target: &ConfigTarget,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use std::borrow::Cow;
|
||||
use crate::model::{ConfigTarget, TraktListItem, TraktMatchItem};
|
||||
use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster};
|
||||
use crate::model::{TraktConfig, TraktListConfig, TraktMatchResult};
|
||||
use shared::error::TuliproxError;
|
||||
use crate::utils::{TraktClient, extract_year_from_title, normalize_title_for_matching};
|
||||
use shared::utils::{get_u32_from_serde_value};
|
||||
use shared::utils::{CONSTANTS};
|
||||
use crate::utils::{extract_year_from_title, normalize_title_for_matching, TraktClient};
|
||||
use crate::utils::{trace_if_enabled, with};
|
||||
use log::{debug, info, trace, warn};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster};
|
||||
use shared::utils::CONSTANTS;
|
||||
use std::borrow::Cow;
|
||||
use strsim::normalized_levenshtein;
|
||||
|
||||
fn extract_quality(value: &str) -> Option<&str> {
|
||||
@@ -37,26 +36,6 @@ fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContent
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract TMDB ID from playlist item
|
||||
fn extract_tmdb_id_from_playlist_item(item: &PlaylistItem) -> Option<u32> {
|
||||
if let Some(additional_props) = &item.header.additional_properties {
|
||||
let props_str = additional_props.get();
|
||||
if let Ok(props) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(props_str) {
|
||||
return props
|
||||
.get("tmdb_id")
|
||||
.and_then(get_u32_from_serde_value)
|
||||
.filter(|&id| id != 0)
|
||||
.or_else(|| {
|
||||
props.get("info")
|
||||
.and_then(|info| info.get("tmdb_id"))
|
||||
.and_then(get_u32_from_serde_value)
|
||||
.filter(|&id| id != 0)
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn calculate_year_bonus(playlist_year: Option<u32>, trakt_year: Option<u32>) -> f64 {
|
||||
if let (Some(p_year), Some(t_year)) = (playlist_year, trakt_year) {
|
||||
if p_year == t_year {
|
||||
@@ -230,7 +209,7 @@ fn match_trakt_items_with_playlist<'a>(
|
||||
if is_compatible_content_type(channel.header.xtream_cluster, list_config.content_type) {
|
||||
let normalized_title = normalize_title_for_matching(&channel.header.title);
|
||||
let channel_year = extract_year_from_title(&channel.header.title);
|
||||
let channel_tmdb_id = extract_tmdb_id_from_playlist_item(channel);
|
||||
let channel_tmdb_id = channel.get_tmdb_id();
|
||||
if let Some(matched) = find_best_match_for_item((channel, normalized_title, channel_year, channel_tmdb_id), &trakt_match_items, list_config) {
|
||||
matches.push(matched);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
use shared::error::{info_err, notify_err};
|
||||
use shared::error::{str_to_io_error, TuliproxError};
|
||||
use crate::model::{AppConfig, Config, ConfigInput, InputSource};
|
||||
use crate::model::{FetchedPlaylist};
|
||||
use shared::model::{PlaylistEntry, PlaylistItem, PlaylistItemType, XtreamCluster};
|
||||
use crate::model::normalize_release_date;
|
||||
use crate::repository::storage::get_input_storage_path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use crate::repository::bplustree::BPlusTree;
|
||||
use crate::repository::storage_const;
|
||||
use crate::repository::xtream_repository::xtream_get_record_file_path;
|
||||
use crate::utils;
|
||||
use shared::error::{info_err};
|
||||
use shared::error::{TuliproxError};
|
||||
use crate::model::{ConfigInput, InputSource};
|
||||
use shared::model::{PlaylistEntry, PlaylistItem, XtreamCluster};
|
||||
use crate::utils::xtream;
|
||||
use serde_json::{from_str, to_string, Value};
|
||||
|
||||
pub(in crate::processing) async fn playlist_resolve_download_playlist_item(client: &reqwest::Client, pli: &PlaylistItem, input: &ConfigInput, errors: &mut Vec<TuliproxError>, resolve_delay: u16, cluster: XtreamCluster) -> Option<String> {
|
||||
let mut result = None;
|
||||
@@ -33,86 +22,3 @@ pub(in crate::processing) async fn playlist_resolve_download_playlist_item(clien
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::processing) fn normalize_json_content(content: String) -> String {
|
||||
match from_str::<Value>(&content) {
|
||||
Ok(mut json_value) => {
|
||||
if let Some(info) = json_value.get_mut("info").and_then(Value::as_object_mut) {
|
||||
normalize_release_date(info);
|
||||
}
|
||||
to_string(&json_value).unwrap_or(content)
|
||||
},
|
||||
Err(_) => content,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::processing) async fn create_resolve_episode_wal_files(cfg: &Config, input: &ConfigInput) -> Option<(tokio::fs::File, PathBuf)> {
|
||||
match get_input_storage_path(&input.name, &cfg.working_dir) {
|
||||
Ok(storage_path) => {
|
||||
let info_path = storage_path.join(format!("{}.{}", crate::model::XC_FILE_SERIES_EPISODE_RECORD, storage_const::FILE_SUFFIX_WAL));
|
||||
let info_file = utils::async_append_or_create_file(&info_path).await.ok()?;
|
||||
Some((info_file, info_path))
|
||||
}
|
||||
Err(_) => None
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::processing) async fn create_resolve_info_wal_files(cfg: &Config, input: &ConfigInput, cluster: XtreamCluster) -> Option<(tokio::fs::File, tokio::fs::File, PathBuf, PathBuf)> {
|
||||
match get_input_storage_path(&input.name, &cfg.working_dir) {
|
||||
Ok(storage_path) => {
|
||||
if let Some(file_prefix) = match cluster {
|
||||
XtreamCluster::Live => None,
|
||||
XtreamCluster::Video => Some(crate::model::XC_FILE_VOD_INFO),
|
||||
XtreamCluster::Series => Some(crate::model::XC_FILE_SERIES_INFO)
|
||||
} {
|
||||
let content_path = storage_path.join(format!("{file_prefix}_content.{}", storage_const::FILE_SUFFIX_WAL));
|
||||
let info_path = storage_path.join(format!("{file_prefix}_record.{}", storage_const::FILE_SUFFIX_WAL));
|
||||
let content_file = utils::async_append_or_create_file(&content_path).await.ok()?;
|
||||
let info_file = utils::async_append_or_create_file(&info_path).await.ok()?;
|
||||
return Some((content_file, info_file, content_path, info_path));
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(_) => None
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::processing) fn should_update_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap<u32, u64>, field: &str) -> (bool, u32, u64) {
|
||||
let Some(provider_id) = pli.header.get_provider_id() else { return (false, 0, 0) };
|
||||
let last_modified = pli.header.get_additional_property_as_u64(field);
|
||||
let old_timestamp = processed_provider_ids.get(&provider_id);
|
||||
(old_timestamp.is_none()
|
||||
|| last_modified.is_none()
|
||||
|| *old_timestamp.unwrap() != last_modified.unwrap(), provider_id, last_modified.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(in crate::processing) async fn read_processed_info_ids<V, F>(cfg: &AppConfig, errors: &mut Vec<TuliproxError>, fpl: &FetchedPlaylist<'_>,
|
||||
item_type: PlaylistItemType, extract_ts: F) -> HashMap<u32, u64>
|
||||
where
|
||||
F: Fn(&V) -> u64,
|
||||
V: Serialize + for<'de> Deserialize<'de> + Clone,
|
||||
{
|
||||
let mut processed_info_ids = HashMap::new();
|
||||
|
||||
let fpl_name = &fpl.input.name;
|
||||
let file_path = match get_input_storage_path(fpl_name, &cfg.config.load().working_dir)
|
||||
.map(|storage_path| xtream_get_record_file_path(&storage_path, item_type)).and_then(|opt| opt.ok_or_else(|| str_to_io_error("Not supported")))
|
||||
{
|
||||
Ok(file_path) => file_path,
|
||||
Err(err) => {
|
||||
errors.push(notify_err!(format!("Could not create storage path for input {fpl_name}: {err}")));
|
||||
return processed_info_ids;
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let file_lock = cfg.file_locks.read_lock(&file_path).await;
|
||||
if let Ok(info_records) = BPlusTree::<u32, V>::load(&file_path) {
|
||||
info_records.iter().for_each(|(provider_id, record)| {
|
||||
processed_info_ids.insert(*provider_id, extract_ts(record));
|
||||
});
|
||||
}
|
||||
drop(file_lock);
|
||||
}
|
||||
processed_info_ids
|
||||
}
|
||||
|
||||
@@ -1,230 +1,83 @@
|
||||
use crate::model::{AppConfig, ConfigTarget};
|
||||
use crate::model::FetchedPlaylist;
|
||||
use crate::model::{XtreamSeriesEpisode, XtreamSeriesInfoEpisode};
|
||||
use crate::model::{AppConfig, ConfigTarget};
|
||||
use crate::processing::parser::xtream::parse_xtream_series_info;
|
||||
use crate::processing::processor::playlist::ProcessingPipe;
|
||||
use crate::processing::processor::xtream::normalize_json_content;
|
||||
use crate::processing::processor::xtream::{create_resolve_episode_wal_files, create_resolve_info_wal_files, playlist_resolve_download_playlist_item, read_processed_info_ids, should_update_info};
|
||||
use crate::processing::processor::{create_resolve_options_function_for_xtream_target, handle_error, handle_error_and_return};
|
||||
use crate::processing::processor::xtream::playlist_resolve_download_playlist_item;
|
||||
use crate::processing::processor::create_resolve_options_function_for_xtream_target;
|
||||
use crate::repository::storage::get_input_storage_path;
|
||||
use crate::repository::xtream_repository::{write_series_info_to_wal_file, xtream_get_info_file_paths,
|
||||
xtream_update_input_info_file, xtream_update_input_series_episodes_record_from_wal_file,
|
||||
xtream_update_input_series_record_from_wal_file};
|
||||
use crate::repository::IndexedDocumentReader;
|
||||
use crate::utils;
|
||||
use crate::utils::{bincode_serialize, IO_BUFFER_SIZE};
|
||||
use log::{error, info, log_enabled, warn, Level};
|
||||
use shared::error::{info_err, notify_err};
|
||||
use crate::repository::xtream_repository::persists_input_series_info;
|
||||
use log::{error, info, log_enabled, Level};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::InputType;
|
||||
use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use shared::model::{InputType, PlaylistEntry, SeriesStreamProperties, StreamProperties, XtreamSeriesInfo};
|
||||
use shared::model::{PlaylistGroup, PlaylistItemType, XtreamCluster};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
create_resolve_options_function_for_xtream_target!(series);
|
||||
|
||||
async fn read_processed_series_info_ids(cfg: &AppConfig, errors: &mut Vec<TuliproxError>, fpl: &FetchedPlaylist<'_>) -> HashMap<u32, u64> {
|
||||
read_processed_info_ids(cfg, errors, fpl, PlaylistItemType::SeriesInfo, |ts: &u64| *ts).await
|
||||
}
|
||||
|
||||
async fn write_series_episode_record_to_wal_file(
|
||||
writer: &mut tokio::io::BufWriter<&mut tokio::fs::File>,
|
||||
provider_id: u32,
|
||||
episode: &XtreamSeriesInfoEpisode,
|
||||
) -> std::io::Result<usize> {
|
||||
let series_episode = XtreamSeriesEpisode::from(episode);
|
||||
if let Ok(content_bytes) = bincode_serialize(&series_episode) {
|
||||
writer.write_all(&provider_id.to_le_bytes()).await?;
|
||||
let content_len = content_bytes.len();
|
||||
if let Ok(len) = u32::try_from(content_len) {
|
||||
writer.write_all(&len.to_le_bytes()).await?;
|
||||
writer.write_all(&content_bytes).await?;
|
||||
return Ok(content_len + 4usize);
|
||||
async fn playlist_resolve_series_info(app_config: &Arc<AppConfig>, client: &reqwest::Client, errors: &mut Vec<TuliproxError>,
|
||||
fpl: &mut FetchedPlaylist<'_>, resolve_delay: u16) -> Vec<PlaylistGroup> {
|
||||
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) {
|
||||
Ok(storage_path) => storage_path,
|
||||
Err(err) => {
|
||||
error!("Can't resolve vod, input storage directory for input '{}' failed: {err}", input.name);
|
||||
return vec![];
|
||||
}
|
||||
error!("Cant write to WAL file, content length exceeds u32");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn should_update_series_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap<u32, u64>) -> (bool, u32, u64) {
|
||||
should_update_info(pli, processed_provider_ids, crate::model::XC_TAG_SERIES_INFO_LAST_MODIFIED)
|
||||
}
|
||||
|
||||
async fn playlist_resolve_series_info(cfg: &AppConfig, client: &reqwest::Client, errors: &mut Vec<TuliproxError>,
|
||||
fpl: &mut FetchedPlaylist<'_>, resolve_delay: u16) -> bool {
|
||||
// TODO read existing WAL File and import it to avoid duplicate requests
|
||||
|
||||
let mut processed_info_ids: HashMap<u32, u64> = read_processed_series_info_ids(cfg, errors, fpl).await;
|
||||
let mut fetched_in_run: HashSet<u32> = HashSet::new();
|
||||
// we can't write to the indexed-document directly because of the write lock and time-consuming operation.
|
||||
// All readers would be waiting for the lock and the app would be unresponsive.
|
||||
// We collect the content into a wal file and write it once we collected everything.
|
||||
let Some((mut wal_content_file, mut wal_record_file, wal_content_path, wal_record_path))
|
||||
= create_resolve_info_wal_files(&cfg.config.load(), fpl.input, XtreamCluster::Series).await
|
||||
else { return !processed_info_ids.is_empty(); };
|
||||
|
||||
let mut content_writer = utils::async_file_writer(&mut wal_content_file);
|
||||
let mut record_writer = utils::async_file_writer(&mut wal_record_file);
|
||||
let mut content_updated = false;
|
||||
};
|
||||
|
||||
let series_info_count = fpl.playlistgroups.iter()
|
||||
.filter(|&plg| plg.xtream_cluster == XtreamCluster::Series)
|
||||
.flat_map(|plg| &plg.channels)
|
||||
.filter(|&pli| pli.header.item_type == PlaylistItemType::SeriesInfo).count();
|
||||
.filter(|&pli| pli.header.xtream_cluster == XtreamCluster::Series
|
||||
&& pli.header.item_type == PlaylistItemType::SeriesInfo
|
||||
&& !pli.has_details()).count();
|
||||
|
||||
info!("Found {series_info_count} series info to resolve");
|
||||
let mut last_log_time = Instant::now();
|
||||
let mut processed_series_info_count = 0;
|
||||
let mut write_counter = 0usize;
|
||||
|
||||
// LocalSeriesInfo entries are not resolved!
|
||||
let input = fpl.input;
|
||||
let mut result: Vec<PlaylistGroup> = vec![];
|
||||
|
||||
for plg in &mut fpl.playlistgroups {
|
||||
if plg.xtream_cluster != XtreamCluster::Series {
|
||||
continue;
|
||||
}
|
||||
for pli in &mut plg.channels {
|
||||
if pli.header.item_type != PlaylistItemType::SeriesInfo {
|
||||
continue;
|
||||
}
|
||||
let (should_update, provider_id, ts) = should_update_series_info(pli, &processed_info_ids);
|
||||
if should_update && provider_id != 0 && fetched_in_run.insert(provider_id) {
|
||||
if let Some(content) = playlist_resolve_download_playlist_item(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Series).await {
|
||||
let normalized_content = normalize_json_content(content);
|
||||
let normalized_str = normalized_content.as_str();
|
||||
handle_error_and_return!(write_series_info_to_wal_file(provider_id, ts, normalized_str, &mut content_writer, &mut record_writer).await,
|
||||
|err| errors.push(notify_err!(format!("Failed to resolve series, could not write to wal file {err}"))));
|
||||
processed_info_ids.insert(provider_id, ts);
|
||||
content_updated = true;
|
||||
write_counter += normalized_str.len();
|
||||
|
||||
// periodic flush to bound BufWriter memory
|
||||
if write_counter >= IO_BUFFER_SIZE {
|
||||
write_counter = 0;
|
||||
if let Err(err) = content_writer.flush().await {
|
||||
errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}")));
|
||||
}
|
||||
if let Err(err) = record_writer.flush().await {
|
||||
errors.push(notify_err!(format!("Failed periodic flush of wal record writer {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if log_enabled!(Level::Info) {
|
||||
processed_series_info_count += 1;
|
||||
if last_log_time.elapsed().as_secs() >= 30 {
|
||||
info!("resolved {processed_series_info_count}/{series_info_count} series info");
|
||||
last_log_time = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("resolved {processed_series_info_count}/{series_info_count} series info");
|
||||
// content_wal contains the provider_id and series_info with episode listing
|
||||
// record_wal contains provider_id and timestamp
|
||||
if content_updated {
|
||||
handle_error!(content_writer.flush().await,
|
||||
|err| errors.push(notify_err!(format!("Failed to resolve series, could not write to wal file {err}"))));
|
||||
handle_error!(record_writer.flush().await,
|
||||
|err| errors.push(notify_err!(format!("Failed to resolve series tmdb, could not write to wal file {err}"))));
|
||||
handle_error!(content_writer.get_ref().sync_all().await, |err| errors.push(notify_err!(format!("Failed to sync series info to wal file {err}"))));
|
||||
handle_error!(record_writer.get_ref().sync_all().await, |err| errors.push(notify_err!(format!("Failed to sync series info record to wal file {err}"))));
|
||||
drop(content_writer);
|
||||
drop(record_writer);
|
||||
drop(wal_content_file);
|
||||
drop(wal_record_file);
|
||||
handle_error!(xtream_update_input_info_file(cfg, fpl.input, &wal_content_path, XtreamCluster::Series).await,
|
||||
|err| errors.push(err));
|
||||
handle_error!(xtream_update_input_series_record_from_wal_file(cfg, fpl.input, &wal_record_path).await,
|
||||
|err| errors.push(err));
|
||||
}
|
||||
|
||||
// we updated now
|
||||
// - series_info.db which contains the original series_info json
|
||||
// - series_record.db which contains the series_info provider_id and timestamp
|
||||
!processed_info_ids.is_empty()
|
||||
}
|
||||
async fn process_series_info(
|
||||
app_config: &AppConfig,
|
||||
fpl: &mut FetchedPlaylist<'_>,
|
||||
errors: &mut Vec<TuliproxError>,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let mut result: Vec<PlaylistGroup> = vec![];
|
||||
let input = fpl.input;
|
||||
let config = app_config.config.load();
|
||||
let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &config.working_dir)
|
||||
.map(|storage_path| xtream_get_info_file_paths(&storage_path, XtreamCluster::Series))
|
||||
else {
|
||||
errors.push(notify_err!("Failed to open input info file for series".to_string()));
|
||||
return result;
|
||||
};
|
||||
|
||||
let mut write_counter = 0usize;
|
||||
|
||||
let _file_lock = app_config.file_locks.read_lock(&info_path).await;
|
||||
|
||||
// Contains the Series Info with episode listing
|
||||
let Ok(mut info_reader) = IndexedDocumentReader::<u32, String>::new(&info_path, &idx_path) else { return result; };
|
||||
|
||||
let Some((mut wal_file, wal_path)) = create_resolve_episode_wal_files(&config, input).await else {
|
||||
errors.push(notify_err!("Could not create wal file for series episodes record".to_string()));
|
||||
return result;
|
||||
};
|
||||
let mut wal_writer = utils::async_file_writer(&mut wal_file);
|
||||
|
||||
for plg in fpl
|
||||
.playlistgroups
|
||||
.iter_mut()
|
||||
.filter(|plg| plg.xtream_cluster == XtreamCluster::Series)
|
||||
{
|
||||
let mut group_series = vec![];
|
||||
|
||||
// Resolve does not handle LocalSeriesInfo
|
||||
for pli in plg
|
||||
.channels
|
||||
.iter_mut()
|
||||
.filter(|pli| pli.header.item_type == PlaylistItemType::SeriesInfo)
|
||||
{
|
||||
let Some(provider_id) = pli.header.get_provider_id() else { continue; };
|
||||
let Ok(content) = info_reader.get(&provider_id) else { continue; };
|
||||
if content.is_empty() {
|
||||
warn!("Series info content is empty, skipping series with provider id: {provider_id}");
|
||||
for pli in &mut plg.channels {
|
||||
if pli.header.xtream_cluster != XtreamCluster::Series
|
||||
|| pli.header.item_type != PlaylistItemType::SeriesInfo
|
||||
|| pli.has_details() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(series_content) => {
|
||||
let (group, series_name) = {
|
||||
let header = &pli.header;
|
||||
(header.group.clone(), if header.name.is_empty() { header.title.clone() } else { header.name.clone() })
|
||||
};
|
||||
match parse_xtream_series_info(&series_content, &group, &series_name, input) {
|
||||
Ok(Some(mut series)) => {
|
||||
for (episode, pli_episode) in &mut series {
|
||||
let Some(provider_id) = &pli_episode.header.get_provider_id() else { continue; };
|
||||
match write_series_episode_record_to_wal_file(&mut wal_writer, *provider_id, episode).await {
|
||||
Ok(written_bytes) => {
|
||||
write_counter += written_bytes;
|
||||
// periodic flush to bound BufWriter memory
|
||||
if write_counter >= IO_BUFFER_SIZE {
|
||||
write_counter = 0;
|
||||
if let Err(err) = wal_writer.flush().await {
|
||||
errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => { errors.push(info_err!(format!("Failed to write to series episode wal file: {err}"))) }
|
||||
}
|
||||
let Some(provider_id) = pli.get_provider_id() else { continue; };
|
||||
processed_series_info_count += 1;
|
||||
let (group, series_name) = {
|
||||
let header = &pli.header;
|
||||
(header.group.clone(), if header.name.is_empty() { header.title.clone() } else { header.name.clone() })
|
||||
};
|
||||
if provider_id != 0 {
|
||||
if let Some(content) = playlist_resolve_download_playlist_item(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Series).await {
|
||||
match serde_json::from_str::<XtreamSeriesInfo>(&content) {
|
||||
Ok(info) => {
|
||||
let series_stream_props = SeriesStreamProperties::from_info(&info, pli);
|
||||
// the input db needs to be updated
|
||||
let _ = persists_input_series_info(app_config, &storage_path, pli.header.xtream_cluster, &input.name, provider_id, &series_stream_props).await;
|
||||
// extract episodes from info
|
||||
if let Some(episodes) = parse_xtream_series_info(&pli.get_uuid(), &series_stream_props, &group, &series_name, input) {
|
||||
group_series.extend(episodes.into_iter());
|
||||
}
|
||||
group_series.extend(series.into_iter().map(|(_, pli)| pli));
|
||||
// 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)));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
errors.push(err);
|
||||
error!("Failed to parse series info for provider_id {provider_id}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => errors.push(info_err!(format!("Failed to parse JSON: {err}"))),
|
||||
}
|
||||
if 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 !group_series.is_empty() {
|
||||
@@ -236,19 +89,11 @@ async fn process_series_info(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handle_error!(wal_writer.flush().await, |err| errors.push(notify_err!(format!("Failed to resolve series episodes, could not write to wal file {err}"))));
|
||||
handle_error!(wal_writer.get_ref().sync_all().await, |err| errors.push(notify_err!(format!("Failed to sync series info to wal file {err}"))));
|
||||
|
||||
drop(wal_writer);
|
||||
drop(wal_file);
|
||||
handle_error!(xtream_update_input_series_episodes_record_from_wal_file(app_config, input, &wal_path).await,
|
||||
|err| errors.push(err));
|
||||
info!("resolved {processed_series_info_count}/{series_info_count} series info");
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
pub async fn playlist_resolve_series(cfg: &AppConfig,
|
||||
pub async fn playlist_resolve_series(cfg: &Arc<AppConfig>,
|
||||
client: &reqwest::Client,
|
||||
target: &ConfigTarget,
|
||||
errors: &mut Vec<TuliproxError>,
|
||||
@@ -259,8 +104,7 @@ pub async fn playlist_resolve_series(cfg: &AppConfig,
|
||||
let (resolve_series, resolve_delay) = get_resolve_series_options(target, processed_fpl);
|
||||
if !resolve_series { return; }
|
||||
|
||||
if !playlist_resolve_series_info(cfg, client, errors, processed_fpl, resolve_delay).await { return; }
|
||||
let series_playlist = process_series_info(cfg, provider_fpl, errors).await;
|
||||
let series_playlist = playlist_resolve_series_info(cfg, client, errors, processed_fpl, resolve_delay).await;
|
||||
if series_playlist.is_empty() { return; }
|
||||
// original content saved into original list
|
||||
for plg in &series_playlist {
|
||||
|
||||
@@ -1,175 +1,75 @@
|
||||
use crate::model::{AppConfig, ConfigTarget};
|
||||
use crate::model::FetchedPlaylist;
|
||||
use crate::processing::processor::xtream::normalize_json_content;
|
||||
use crate::processing::processor::xtream::{create_resolve_info_wal_files, playlist_resolve_download_playlist_item, read_processed_info_ids, should_update_info};
|
||||
use crate::processing::processor::{create_resolve_options_function_for_xtream_target, handle_error, handle_error_and_return};
|
||||
use crate::repository::xtream_repository::{write_vod_info_to_wal_file, xtream_update_input_info_file, xtream_update_input_vod_record_from_wal_file, InputVodInfoRecord};
|
||||
use crate::utils;
|
||||
use crate::utils::IO_BUFFER_SIZE;
|
||||
use log::{info, log_enabled, Level};
|
||||
use serde_json::value::RawValue;
|
||||
use serde_json::{Map, Value};
|
||||
use shared::error::notify_err;
|
||||
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::repository::storage::get_input_storage_path;
|
||||
use crate::repository::xtream_repository::persist_input_vod_info;
|
||||
use log::{error, info, log_enabled, Level};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{InputType, PlaylistEntry};
|
||||
use shared::model::{PlaylistItem, PlaylistItemType, XtreamCluster};
|
||||
use shared::utils::{get_string_from_serde_value, get_u32_from_serde_value, get_u64_from_serde_value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use shared::model::{InputType, PlaylistEntry, StreamProperties, VideoStreamProperties, XtreamVideoInfo};
|
||||
use shared::model::{PlaylistItemType, XtreamCluster};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
create_resolve_options_function_for_xtream_target!(vod);
|
||||
|
||||
async fn read_processed_vod_info_ids(cfg: &AppConfig, errors: &mut Vec<TuliproxError>, fpl: &FetchedPlaylist<'_>) -> HashMap<u32, u64> {
|
||||
read_processed_info_ids(cfg, errors, fpl, PlaylistItemType::Video, |record: &InputVodInfoRecord| record.ts).await
|
||||
}
|
||||
|
||||
fn extract_info_record_from_vod_info(content: &str) -> Option<(u32, InputVodInfoRecord)> {
|
||||
let doc = serde_json::from_str::<Map<String, Value>>(content).ok()?;
|
||||
|
||||
let movie_data = doc.get(crate::model::XC_TAG_VOD_INFO_MOVIE_DATA)?.as_object()?;
|
||||
let provider_id = get_u32_from_serde_value(
|
||||
movie_data.get(crate::model::XC_TAG_VOD_INFO_STREAM_ID)?,
|
||||
)?;
|
||||
|
||||
let added = movie_data
|
||||
.get(crate::model::XC_TAG_VOD_INFO_ADDED)
|
||||
.and_then(get_u64_from_serde_value)
|
||||
.unwrap_or(0);
|
||||
|
||||
let info_section = doc.get(crate::model::XC_TAG_VOD_INFO_INFO)?.as_object()?;
|
||||
|
||||
let tmdb_id = info_section
|
||||
.get(crate::model::XC_TAG_VOD_INFO_TMDB_ID)
|
||||
.and_then(get_u32_from_serde_value)
|
||||
.filter(|&id| id != 0)
|
||||
.or_else(|| {
|
||||
info_section
|
||||
.get(crate::model::XC_TAG_VOD_INFO_TMDB)
|
||||
.and_then(get_u32_from_serde_value)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let release_date = info_section
|
||||
.get(crate::model::XC_TAG_VOD_INFO_RELEASEDATE)
|
||||
.and_then(get_string_from_serde_value);
|
||||
|
||||
Some((provider_id, InputVodInfoRecord {
|
||||
tmdb_id,
|
||||
ts: added,
|
||||
release_date,
|
||||
}))
|
||||
}
|
||||
|
||||
fn should_update_vod_info(pli: &mut PlaylistItem, processed_provider_ids: &HashMap<u32, u64>) -> (bool, u32, u64) {
|
||||
should_update_info(pli, processed_provider_ids, crate::model::XC_TAG_VOD_INFO_ADDED)
|
||||
}
|
||||
|
||||
pub async fn playlist_resolve_vod(app_config: &AppConfig, client: &reqwest::Client, target: &ConfigTarget, errors: &mut Vec<TuliproxError>, fpl: &mut FetchedPlaylist<'_>) {
|
||||
pub async fn playlist_resolve_vod(app_config: &Arc<AppConfig>, client: &reqwest::Client, target: &ConfigTarget, errors: &mut Vec<TuliproxError>, fpl: &mut FetchedPlaylist<'_>) {
|
||||
let (resolve_movies, resolve_delay) = get_resolve_vod_options(target, fpl);
|
||||
if !resolve_movies { return; }
|
||||
|
||||
// TODO read existing WAL File and import it to avoid duplicate requests
|
||||
|
||||
// we cant write to the indexed-document directly because of the write lock and time-consuming operation.
|
||||
// All readers would be waiting for the lock and the app would be unresponsive.
|
||||
// We collect the content into a wal file and write it once we collected everything.
|
||||
let config = app_config.config.load();
|
||||
let Some((mut wal_content_file, mut wal_record_file, wal_content_path, wal_record_path))
|
||||
= create_resolve_info_wal_files(&config, fpl.input, XtreamCluster::Video).await
|
||||
else { return; };
|
||||
|
||||
let mut processed_info_ids: HashMap<u32, u64> = read_processed_vod_info_ids(app_config, errors, fpl).await;
|
||||
let mut fetched_in_run: HashSet<u32> = HashSet::new();
|
||||
let mut content_writer = utils::async_file_writer(&mut wal_content_file);
|
||||
let mut record_writer = utils::async_file_writer(&mut wal_record_file);
|
||||
let mut content_updated = false;
|
||||
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) {
|
||||
Ok(storage_path) => storage_path,
|
||||
Err(err) => {
|
||||
error!("Can't resolve vod, input storage directory for input '{}' failed: {err}", input.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// LocalVideo entries are not resolved!
|
||||
|
||||
// TODO merge both filters to one
|
||||
let vod_info_count = fpl.playlistgroups.iter()
|
||||
.flat_map(|plg| &plg.channels)
|
||||
.filter(|&pli| pli.header.xtream_cluster == XtreamCluster::Video && pli.header.item_type == PlaylistItemType::Video).count();
|
||||
.filter(|pli| pli.header.xtream_cluster == XtreamCluster::Video
|
||||
&& pli.header.item_type == PlaylistItemType::Video
|
||||
&& !pli.has_details()).count();
|
||||
|
||||
|
||||
info!("Found {vod_info_count} vod info to resolve");
|
||||
let mut last_log_time = Instant::now();
|
||||
let mut processed_vod_info_count = 0;
|
||||
let mut write_counter = 0usize;
|
||||
|
||||
for plg in &mut fpl.playlistgroups {
|
||||
for pli in &mut plg.channels {
|
||||
// LocalVideo files are not resolved
|
||||
if pli.header.xtream_cluster != XtreamCluster::Video || pli.header.item_type != PlaylistItemType::Video {
|
||||
if pli.header.xtream_cluster != XtreamCluster::Video
|
||||
|| pli.header.item_type != PlaylistItemType::Video
|
||||
|| pli.has_details() {
|
||||
continue;
|
||||
}
|
||||
let (should_update, provider_id, _ts) = should_update_vod_info(pli, &processed_info_ids);
|
||||
if should_update && provider_id != 0 && fetched_in_run.insert(provider_id) {
|
||||
if let Some(content) = if pli.get_item_type().is_local() {
|
||||
pli.header.additional_properties.as_ref().map(ToString::to_string)
|
||||
} else {
|
||||
playlist_resolve_download_playlist_item(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Video).await
|
||||
} {
|
||||
let normalized_content = normalize_json_content(content);
|
||||
let normalized_str: &str = &normalized_content;
|
||||
if let Some((provider_id, info_record)) = extract_info_record_from_vod_info(normalized_str) {
|
||||
let ts = info_record.ts;
|
||||
handle_error_and_return!(write_vod_info_to_wal_file(provider_id, normalized_str, &info_record, &mut content_writer, &mut record_writer).await,
|
||||
|err| errors.push(notify_err!(format!("Failed to resolve vod, could not write to wal file {err}"))));
|
||||
processed_info_ids.insert(provider_id, ts);
|
||||
content_updated = true;
|
||||
write_counter += normalized_str.len();
|
||||
// periodic flush to bound BufWriter memory
|
||||
if write_counter >= IO_BUFFER_SIZE {
|
||||
write_counter = 0;
|
||||
if let Err(err) = content_writer.flush().await {
|
||||
errors.push(notify_err!(format!("Failed periodic flush of wal content writer {err}")));
|
||||
}
|
||||
if let Err(err) = record_writer.flush().await {
|
||||
errors.push(notify_err!(format!("Failed periodic flush of wal record writer {err}")));
|
||||
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(client, pli, fpl.input, errors, resolve_delay, XtreamCluster::Video).await {
|
||||
match serde_json::from_str::<XtreamVideoInfo>(&content) {
|
||||
Ok(info) => {
|
||||
let video_stream_props = VideoStreamProperties::from_info(&info, pli);
|
||||
if let Err(err) = persist_input_vod_info(app_config, &storage_path, pli.header.xtream_cluster, &input.name, provider_id, &video_stream_props).await {
|
||||
error!("Failed to persist VOD info for provider_id {provider_id}: {err}");
|
||||
}
|
||||
// This makes the data available for subsequent processing steps like STRM export.
|
||||
pli.header.additional_properties = Some(StreamProperties::Video(Box::new(video_stream_props)));
|
||||
}
|
||||
|
||||
// Update in-memory playlist items with the newly fetched vod info.
|
||||
// This makes the data available for subsequent processing steps like STRM export.
|
||||
if let Ok(value) = serde_json::from_str::<Value>(normalized_str) {
|
||||
if let Some(info_content) = value.get("info") {
|
||||
if let Ok(raw_info) = serde_json::to_string(info_content) {
|
||||
pli.header.additional_properties = serde_json::from_str::<Box<RawValue>>(&raw_info).ok();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to parse video info for provider_id {provider_id}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if log_enabled!(Level::Info) {
|
||||
processed_vod_info_count += 1;
|
||||
if last_log_time.elapsed().as_secs() >= 30 {
|
||||
info!("resolved {processed_vod_info_count}/{vod_info_count} vod info");
|
||||
last_log_time = Instant::now();
|
||||
}
|
||||
if log_enabled!(Level::Info) && last_log_time.elapsed().as_secs() >= 30 {
|
||||
info!("resolved {processed_vod_info_count}/{vod_info_count} vod info");
|
||||
last_log_time = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("resolved {processed_vod_info_count}/{vod_info_count} vod info");
|
||||
if content_updated {
|
||||
// TODO better approach for transactional updates is multiplexed WAL file.
|
||||
// final flush & sync with proper error handling
|
||||
handle_error!(content_writer.flush().await,
|
||||
|err| errors.push(notify_err!(format!("Failed to resolve vod, could not write to wal file {err}"))));
|
||||
handle_error!(record_writer.flush().await,
|
||||
|err| errors.push(notify_err!(format!("Failed to resolve vod tmdb, could not write to wal file {err}"))));
|
||||
handle_error!(content_writer.get_ref().sync_all().await, |err| errors.push(notify_err!(format!("Failed to sync vod info to wal file {err}"))));
|
||||
handle_error!(record_writer.get_ref().sync_all().await, |err| errors.push(notify_err!(format!("Failed to sync vod info record to wal file {err}"))));
|
||||
// drop writers and files to release handles
|
||||
drop(content_writer);
|
||||
drop(record_writer);
|
||||
drop(wal_content_file);
|
||||
drop(wal_record_file);
|
||||
|
||||
handle_error!(xtream_update_input_info_file(app_config, fpl.input, &wal_content_path, XtreamCluster::Video).await,
|
||||
|err| errors.push(err));
|
||||
handle_error!(xtream_update_input_vod_record_from_wal_file(app_config, fpl.input, &wal_record_path).await,
|
||||
|err| errors.push(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::sync::LazyLock;
|
||||
use regex::Regex;
|
||||
use fancy_regex::{Regex as FancyRegex};
|
||||
|
||||
|
||||
pub const BRACKETS: &[(&str, &str)] = &[("{", "}"), ("[", "]"), ("(", ")")];
|
||||
|
||||
pub const NON_ENGLISH_RANGES: &str = "\u{3040}-\u{30ff}\u{3400}-\u{4dbf}\u{4e00}-\u{9fff}\u{f900}-\u{faff}\u{ff66}-\u{ff9f}\u{0400}-\u{04ff}\u{0600}-\u{06ff}\u{0750}-\u{077f}\u{0c80}-\u{0cff}\u{0d00}-\u{0d7f}\u{0e00}-\u{0e7f}";
|
||||
|
||||
pub struct PttConstants {
|
||||
pub movie_regex: Regex,
|
||||
pub russian_cast_regex_fancy: FancyRegex,
|
||||
pub alt_titles_regex: Regex,
|
||||
pub before_title_match_regex: Regex,
|
||||
pub not_only_non_english_regex: FancyRegex,
|
||||
pub not_allowed_symbols_at_start_and_end: FancyRegex,
|
||||
pub remaining_not_allowed_symbols_at_start_and_end: Regex,
|
||||
pub redundant_symbols_at_end: Regex,
|
||||
pub empty_brackets_regex: Regex,
|
||||
pub parantheses_without_content: Regex,
|
||||
pub star_regex_1: Regex,
|
||||
pub star_regex_2: Regex,
|
||||
pub mp3_regex: Regex,
|
||||
pub spacing_regex: Regex,
|
||||
pub special_char_spacing: Regex,
|
||||
pub sub_pattern: Regex,
|
||||
pub integer: Regex,
|
||||
pub word: Regex,
|
||||
pub dot: Regex,
|
||||
pub months: Vec<(Regex, &'static str)>,
|
||||
}
|
||||
|
||||
pub static PTT_CONSTANTS: LazyLock<PttConstants> = LazyLock::new(|| {
|
||||
PttConstants {
|
||||
movie_regex: Regex::new(r"(?i)[\[(]movie[)\]]").unwrap(),
|
||||
russian_cast_regex_fancy: FancyRegex::new(
|
||||
r"(?i)\([^)]*[\u0400-\u04ff][^)]*\)$|(?<=\/.*)\(.*\)$",
|
||||
)
|
||||
.unwrap(),
|
||||
alt_titles_regex: Regex::new(&format!(
|
||||
r"(?i)[^/|(]*[{NON_ENGLISH_RANGES}][^/|]*[/|]|[/|][^/|(]*[{NON_ENGLISH_RANGES}][^/|]*"
|
||||
))
|
||||
.unwrap(),
|
||||
before_title_match_regex: Regex::new(r"^(?:\[([^\]]+)\])").unwrap(),
|
||||
not_only_non_english_regex: FancyRegex::new(&format!(
|
||||
r"(?i)([a-zA-Z][^{NON_ENGLISH_RANGES}]+)([{NON_ENGLISH_RANGES}].*[{NON_ENGLISH_RANGES}])|[{NON_ENGLISH_RANGES}].*[{NON_ENGLISH_RANGES}](?=[^{NON_ENGLISH_RANGES}]+[a-zA-Z])"
|
||||
)).unwrap(),
|
||||
not_allowed_symbols_at_start_and_end: FancyRegex::new(&format!(
|
||||
r"(?i)^[^\w{NON_ENGLISH_RANGES}#\x5B【★]+|[ \-:/\x5C\x5B|{{(#$&^]+$"
|
||||
))
|
||||
.unwrap(),
|
||||
remaining_not_allowed_symbols_at_start_and_end: Regex::new(
|
||||
&format!(r"^[^\w{NON_ENGLISH_RANGES}#]+|]$")
|
||||
)
|
||||
.unwrap(),
|
||||
redundant_symbols_at_end: Regex::new(r"[ \-:./\\]+$").unwrap(),
|
||||
empty_brackets_regex: Regex::new(r"\(\s*\)|\[\s*\]|\{\s*\}").unwrap(),
|
||||
parantheses_without_content: Regex::new(r"\(\W*\)|\[\W*\]|\{\W*\}").unwrap(),
|
||||
star_regex_1: Regex::new(r"^[\[【★].*[\]】★][ .]?(.+)").unwrap(),
|
||||
star_regex_2: Regex::new(r"(.+)[ .]?[\[【★].*[\]】★]$").unwrap(),
|
||||
mp3_regex: Regex::new(r"(?i)\bmp3$").unwrap(),
|
||||
spacing_regex: Regex::new(r"\s+").unwrap(),
|
||||
special_char_spacing: Regex::new(r"[\-\+\_\{\}\[\]]\W{2,}").unwrap(),
|
||||
sub_pattern: Regex::new(r"_+").unwrap(),
|
||||
integer: Regex::new(r"\d+").unwrap(),
|
||||
word: Regex::new(r"\W+").unwrap(),
|
||||
dot: Regex::new(r"\.").unwrap(),
|
||||
months: vec! [
|
||||
(Regex::new(r"(?i)\bJanu\b").unwrap(), "Jan"),
|
||||
(Regex::new(r"(?i)\bFebr\b").unwrap(), "Feb"),
|
||||
(Regex::new(r"(?i)\bMarc\b").unwrap(), "Mar"),
|
||||
(Regex::new(r"(?i)\bApri\b").unwrap(), "Apr"),
|
||||
(Regex::new(r"(?i)\bMay\b").unwrap(), "May"),
|
||||
(Regex::new(r"(?i)\bJune\b").unwrap(), "Jun"),
|
||||
(Regex::new(r"(?i)\bJuly\b").unwrap(), "Jul"),
|
||||
(Regex::new(r"(?i)\bAugu\b").unwrap(), "Aug"),
|
||||
(Regex::new(r"(?i)\bSept\b").unwrap(), "Sep"),
|
||||
(Regex::new(r"(?i)\bOcto\b").unwrap(), "Oct"),
|
||||
(Regex::new(r"(?i)\bNove\b").unwrap(), "Nov"),
|
||||
(Regex::new(r"(?i)\bDece\b").unwrap(), "Dec"),
|
||||
]
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
use std::sync::LazyLock;
|
||||
use crate::ptt::parser::PttParser;
|
||||
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod parser;
|
||||
mod transformers;
|
||||
mod constants;
|
||||
|
||||
pub use models::PttMetadata;
|
||||
|
||||
static PTT_PARSER: LazyLock<PttParser> = LazyLock::new(|| {
|
||||
let mut parser = PttParser::new();
|
||||
handlers::add_defaults(&mut parser);
|
||||
parser
|
||||
});
|
||||
|
||||
pub fn ptt_parse_title(title: &str) -> PttMetadata {
|
||||
PTT_PARSER.parse(title, false)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PttMetadata {
|
||||
pub title: String,
|
||||
pub seasons: Vec<u32>,
|
||||
pub episodes: Vec<u32>,
|
||||
pub languages: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub year: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub tmdb: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub tvdb: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub resolution: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quality: Option<String>,
|
||||
#[serde(default)]
|
||||
pub codec: Option<String>,
|
||||
pub audio: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: Option<String>,
|
||||
pub networks: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub extended: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hardcoded: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub proper: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub repack: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub retail: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remastered: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub unrated: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub uncensored: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub documentary: bool,
|
||||
#[serde(default)]
|
||||
pub episode_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub adult: bool,
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bit_depth: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hdr: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<i32>,
|
||||
#[serde(default)]
|
||||
pub edition: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub trash: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub upscaled: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub convert: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub commentary: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub subbed: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dubbed: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub is_3d: bool,
|
||||
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub complete: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ppv: bool,
|
||||
#[serde(default)]
|
||||
pub country: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bitrate: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extension: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use std::cmp::min;
|
||||
use std::collections::HashMap;
|
||||
use fancy_regex::{Regex as FancyRegex, Match};
|
||||
use crate::ptt::constants::{BRACKETS, PTT_CONSTANTS};
|
||||
use crate::ptt::models::PttMetadata;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchInfo {
|
||||
pub raw_match: String,
|
||||
pub match_index: usize,
|
||||
pub remove: bool,
|
||||
pub skip_from_title: bool,
|
||||
}
|
||||
|
||||
pub struct ParseContext {
|
||||
pub title: String,
|
||||
pub result: PttMetadata,
|
||||
pub matched: HashMap<String, MatchInfo>,
|
||||
}
|
||||
|
||||
pub type HandlerFn = Box<dyn Fn(&mut ParseContext) -> Option<MatchInfo> + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PttParser {
|
||||
handlers: Vec<(String, HandlerFn)>,
|
||||
}
|
||||
|
||||
impl PttParser {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_handler_fn(&mut self, name: &str, handler: HandlerFn) {
|
||||
self.handlers.push((name.to_string(), handler));
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn add_handler<T, F, S>(
|
||||
&mut self,
|
||||
name: &str,
|
||||
regex: FancyRegex,
|
||||
transformer: F,
|
||||
setter: S,
|
||||
options: HandlerOptions,
|
||||
) where
|
||||
T: Clone + Send + Sync + 'static,
|
||||
F: Fn(&str) -> T + Send + Sync + 'static,
|
||||
S: Fn(&mut PttMetadata, T) + Send + Sync + 'static,
|
||||
{
|
||||
let name_owned = name.to_string();
|
||||
let name_clone = name.to_string();
|
||||
|
||||
let handler = Box::new(move |context: &mut ParseContext| -> Option<MatchInfo> {
|
||||
if options.skip_if_already_found && context.matched.contains_key(&name_owned) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let match_result: Option<Match> = regex.find(&context.title).ok().flatten();
|
||||
|
||||
if let Some(m) = match_result {
|
||||
let raw_match = m.as_str().to_string();
|
||||
let match_index = m.start();
|
||||
|
||||
let clean_match = if regex.captures_len() > 1 {
|
||||
regex
|
||||
.captures(&context.title)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|c| c.get(1).map(|g| g.as_str().to_string()))
|
||||
.unwrap_or_else(|| raw_match.clone())
|
||||
} else {
|
||||
raw_match.clone()
|
||||
};
|
||||
|
||||
let before_match_opt = PTT_CONSTANTS.before_title_match_regex.find(&context.title);
|
||||
let before_title_matched = if let Some(bm) = before_match_opt {
|
||||
let bm_str = bm.as_str();
|
||||
if bm_str.len() > 2 && bm_str.starts_with('[') && bm_str.ends_with(']') {
|
||||
let content = &bm_str[1..bm_str.len() - 1];
|
||||
content.contains(&raw_match)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let current_skip_from_title = before_title_matched || options.skip_from_title;
|
||||
|
||||
let transformed_value = transformer(&clean_match);
|
||||
|
||||
if options.skip_if_first {
|
||||
let other_matches_exist = !context.matched.is_empty();
|
||||
if other_matches_exist {
|
||||
let current_start = match_index;
|
||||
let is_before_all = context
|
||||
.matched
|
||||
.values()
|
||||
.all(|m| current_start < m.match_index);
|
||||
if is_before_all {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setter(&mut context.result, transformed_value);
|
||||
|
||||
let info = MatchInfo {
|
||||
raw_match,
|
||||
match_index,
|
||||
remove: options.remove,
|
||||
skip_from_title: current_skip_from_title,
|
||||
};
|
||||
context.matched.insert(name_owned.clone(), info.clone());
|
||||
|
||||
return Some(info);
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
self.handlers.push((name_clone, handler));
|
||||
}
|
||||
|
||||
pub fn parse(&self, raw_title: &str, _translate_languages: bool) -> PttMetadata {
|
||||
let mut context = ParseContext {
|
||||
title: raw_title.to_string(),
|
||||
result: PttMetadata::default(),
|
||||
matched: HashMap::new(),
|
||||
};
|
||||
|
||||
context.title = PTT_CONSTANTS.sub_pattern.replace_all(raw_title, " ").to_string();
|
||||
|
||||
let mut end_of_title = context.title.len();
|
||||
|
||||
for (_name, handler) in &self.handlers {
|
||||
if let Some(match_info) = handler(&mut context) {
|
||||
let match_index = match_info.match_index;
|
||||
let raw_len = match_info.raw_match.len();
|
||||
|
||||
if match_info.remove && match_index + raw_len <= context.title.len() {
|
||||
context
|
||||
.title
|
||||
.replace_range(match_index..match_index + raw_len, "");
|
||||
}
|
||||
|
||||
if !match_info.skip_from_title && match_index > 1 && match_index < end_of_title {
|
||||
end_of_title = match_index;
|
||||
}
|
||||
if match_info.remove
|
||||
&& match_info.skip_from_title
|
||||
&& match_index < end_of_title
|
||||
&& end_of_title >= raw_len
|
||||
{
|
||||
end_of_title -= min(raw_len, end_of_title - match_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = context.result;
|
||||
|
||||
result.seasons.sort_unstable();
|
||||
result.seasons.dedup();
|
||||
result.episodes.sort_unstable();
|
||||
result.episodes.dedup();
|
||||
result.languages.sort_unstable();
|
||||
result.languages.dedup();
|
||||
|
||||
let final_title = if end_of_title <= context.title.len() {
|
||||
context.title[..end_of_title].to_string()
|
||||
} else {
|
||||
context.title
|
||||
};
|
||||
|
||||
result.title = clean_title(&final_title);
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clean_title(raw_title: &str) -> String {
|
||||
let mut title = raw_title.replace('_', " ");
|
||||
|
||||
title = PTT_CONSTANTS.movie_regex.replace_all(&title, "").to_string();
|
||||
title = PTT_CONSTANTS
|
||||
.not_allowed_symbols_at_start_and_end
|
||||
.replace_all(&title, "")
|
||||
.to_string();
|
||||
title = PTT_CONSTANTS
|
||||
.russian_cast_regex_fancy
|
||||
.replace_all(&title, "")
|
||||
.to_string();
|
||||
title = PTT_CONSTANTS.star_regex_1.replace_all(&title, "$1").to_string();
|
||||
title = PTT_CONSTANTS.star_regex_2.replace_all(&title, "$1").to_string();
|
||||
title = PTT_CONSTANTS.alt_titles_regex.replace_all(&title, "").to_string();
|
||||
title = PTT_CONSTANTS
|
||||
.not_only_non_english_regex
|
||||
.replace_all(&title, "$1")
|
||||
.to_string();
|
||||
title = PTT_CONSTANTS
|
||||
.remaining_not_allowed_symbols_at_start_and_end
|
||||
.replace_all(&title, "")
|
||||
.to_string();
|
||||
title = PTT_CONSTANTS.empty_brackets_regex.replace_all(&title, "").to_string();
|
||||
title = PTT_CONSTANTS.mp3_regex.replace_all(&title, "").to_string();
|
||||
title = PTT_CONSTANTS
|
||||
.parantheses_without_content
|
||||
.replace_all(&title, "")
|
||||
.to_string();
|
||||
title = PTT_CONSTANTS
|
||||
.special_char_spacing
|
||||
.replace_all(&title, "")
|
||||
.to_string();
|
||||
|
||||
for (open, close) in BRACKETS {
|
||||
let open_count = title.matches(open).count();
|
||||
let close_count = title.matches(close).count();
|
||||
if open_count != close_count {
|
||||
title = title.replace(open, "").replace(close, "");
|
||||
}
|
||||
}
|
||||
|
||||
if !title.trim().contains(' ') && title.contains('.') {
|
||||
title = PTT_CONSTANTS.dot.replace_all(&title, " ").to_string();
|
||||
}
|
||||
|
||||
title = PTT_CONSTANTS.redundant_symbols_at_end.replace_all(&title, "").to_string();
|
||||
title = PTT_CONSTANTS.spacing_regex.replace_all(&title, " ").trim().to_string();
|
||||
|
||||
title
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct HandlerOptions {
|
||||
pub skip_if_already_found: bool,
|
||||
pub skip_from_title: bool,
|
||||
pub skip_if_first: bool,
|
||||
pub remove: bool,
|
||||
}
|
||||
|
||||
impl Default for HandlerOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
skip_if_already_found: true,
|
||||
skip_from_title: false,
|
||||
skip_if_first: false,
|
||||
remove: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use chrono::NaiveDate;
|
||||
use crate::ptt::constants::PTT_CONSTANTS;
|
||||
|
||||
pub fn none(input: &str) -> String {
|
||||
input.to_string()
|
||||
}
|
||||
|
||||
pub fn value(val: &str) -> String {
|
||||
val.to_string()
|
||||
}
|
||||
|
||||
pub fn boolean(_: &str) -> bool {
|
||||
true
|
||||
}
|
||||
pub fn uinteger(input: &str) -> Option<u32> {
|
||||
input.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
pub fn first_uinteger(input: &str) -> Option<u32> {
|
||||
PTT_CONSTANTS.integer.find(input).and_then(|m| m.as_str().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
pub fn lowercase(input: &str) -> String {
|
||||
input.to_lowercase()
|
||||
}
|
||||
|
||||
pub fn uppercase(input: &str) -> String {
|
||||
input.to_uppercase()
|
||||
}
|
||||
|
||||
pub fn convert_months(date_str: &str) -> String {
|
||||
let mut result = date_str.to_string();
|
||||
for (re, short) in &PTT_CONSTANTS.months {
|
||||
result = re.replace_all(&result, *short).to_string();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn date(input: &str, formats: &[&str]) -> Option<String> {
|
||||
let sanitized = PTT_CONSTANTS.word
|
||||
.replace_all(input, " ")
|
||||
.trim()
|
||||
.to_string();
|
||||
let sanitized = convert_months(&sanitized);
|
||||
|
||||
for fmt in formats {
|
||||
if let Ok(dt) = NaiveDate::parse_from_str(&sanitized, fmt) {
|
||||
return Some(dt.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
macro_rules! range_func {
|
||||
($name:ident, $t:ty) => {
|
||||
pub fn $name(input: &str) -> Option<Vec<$t>> {
|
||||
let numbers: Vec<$t> = PTT_CONSTANTS.integer
|
||||
.find_iter(input)
|
||||
.filter_map(|mat| mat.as_str().parse().ok())
|
||||
.collect();
|
||||
|
||||
if numbers.len() == 2 && numbers[0] < numbers[1] {
|
||||
return Some((numbers[0]..=numbers[1]).collect());
|
||||
}
|
||||
match numbers.len() {
|
||||
len if len > 2 => {
|
||||
if numbers.windows(2).all(|w| w[0] + 1 == w[1]) {
|
||||
Some(numbers)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
1 => Some(numbers),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
range_func!(range_i32, i32);
|
||||
range_func!(range_u32, u32);
|
||||
pub fn transform_resolution(input: &str) -> String {
|
||||
let lower = input.to_lowercase();
|
||||
if lower.contains("2160") || lower.contains("4k") {
|
||||
return "2160p".to_string();
|
||||
}
|
||||
if lower.contains("1440") || lower.contains("2k") {
|
||||
return "1440p".to_string();
|
||||
}
|
||||
if lower.contains("1080") {
|
||||
return "1080p".to_string();
|
||||
}
|
||||
if lower.contains("720") {
|
||||
return "720p".to_string();
|
||||
}
|
||||
if lower.contains("480") {
|
||||
return "480p".to_string();
|
||||
}
|
||||
if lower.contains("360") {
|
||||
return "360p".to_string();
|
||||
}
|
||||
if lower.contains("240") {
|
||||
return "240p".to_string();
|
||||
}
|
||||
input.to_string()
|
||||
}
|
||||
+2180
-389
File diff suppressed because it is too large
Load Diff
@@ -1,627 +0,0 @@
|
||||
use std::fmt::Debug;
|
||||
use std::fs::{File};
|
||||
use std::io::{BufReader, Error, ErrorKind, Read, Seek, SeekFrom, Write};
|
||||
use std::marker::PhantomData;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery};
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tempfile::NamedTempFile;
|
||||
use shared::error::{str_to_io_error, to_io_error};
|
||||
use crate::utils;
|
||||
use crate::utils::{bincode_deserialize, bincode_serialize, IO_BUFFER_SIZE};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
const LEN_SIZE: usize = 4;
|
||||
|
||||
pub(in crate::repository) type OffsetPointer = u32;
|
||||
type SizeType = u32;
|
||||
|
||||
pub type IndexedDocumentIndex<K> = BPlusTree<K, OffsetPointer>;
|
||||
|
||||
pub(in crate::repository) struct IndexedDocument {}
|
||||
|
||||
impl IndexedDocument {
|
||||
pub(in crate::repository) fn read_fragmentation<R: Read + Seek>(file: &mut R) -> std::io::Result<bool> {
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut bool_bytes = [0u8];
|
||||
file.read_exact(&mut bool_bytes)?;
|
||||
Ok(u8::from_le_bytes(bool_bytes) == 1)
|
||||
}
|
||||
|
||||
pub(in crate::repository) fn write_fragmentation<W: Write + Seek>(file: &mut W, fragmented: bool) -> std::io::Result<()> {
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let fragmented_byte = if fragmented { 1u8.to_le_bytes() } else { 0u8.to_le_bytes() };
|
||||
file.write_all(&fragmented_byte)
|
||||
}
|
||||
|
||||
pub(in crate::repository) fn read_content_size<R: Read + Seek>(reader: &mut R) -> Result<usize, Error>
|
||||
{
|
||||
let mut size_bytes = [0u8; LEN_SIZE];
|
||||
reader.read_exact(&mut size_bytes)?;
|
||||
let buf_size = SizeType::from_le_bytes(size_bytes) as usize;
|
||||
Ok(buf_size)
|
||||
}
|
||||
|
||||
|
||||
pub(in crate::repository) fn get_offset<K>(index_path: &Path, doc_id: &K) -> Result<u64, Error>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
match BPlusTreeQuery::<K, OffsetPointer>::try_new(index_path) {
|
||||
Ok(mut tree) => {
|
||||
tree.query(doc_id).map_or_else(|| Err(Error::new(ErrorKind::NotFound, format!("doc_id not found {doc_id:?}"))), |offset| Ok(u64::from(offset)))
|
||||
}
|
||||
Err(err) => Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
///
|
||||
/// `IndexedDocumentWriter`
|
||||
///
|
||||
////////////////////////////////////////////////////////
|
||||
/**
|
||||
* Creates two files,
|
||||
* - content
|
||||
* - index
|
||||
*
|
||||
* Layout of content file record is:
|
||||
* - content-size (u32) + content (deflate)
|
||||
*
|
||||
* index file is a bplustree
|
||||
*/
|
||||
pub(in crate::repository) struct IndexedDocumentWriter<K>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
main_path: PathBuf,
|
||||
index_path: PathBuf,
|
||||
main_file: File,
|
||||
main_offset: OffsetPointer,
|
||||
index_tree: IndexedDocumentIndex<K>,
|
||||
dirty: bool,
|
||||
fragmented: bool,
|
||||
}
|
||||
|
||||
impl<K> IndexedDocumentWriter<K>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
fn new_with_mode(main_path: PathBuf, index_path: PathBuf, append: bool) -> Result<Self, Error> {
|
||||
let append_mode = append && main_path.exists();
|
||||
let mut main_file = if append_mode {
|
||||
utils::open_read_write_file(&main_path)
|
||||
} else {
|
||||
utils::create_new_file_for_read_write(&main_path)
|
||||
}?;
|
||||
|
||||
// Retrieve file size and convert to `u32` for `main_offset`, if possible
|
||||
let mut main_offset = main_file
|
||||
.metadata()
|
||||
.and_then(|meta| SizeType::try_from(meta.len()).map_err(to_io_error))
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut fragmented = false;
|
||||
if main_offset == 0 {
|
||||
IndexedDocument::write_fragmentation(&mut main_file, false)?;
|
||||
main_offset = 1;
|
||||
} else {
|
||||
fragmented = IndexedDocument::read_fragmentation(&mut main_file)?;
|
||||
}
|
||||
|
||||
// Initialize the index tree (BPlusTree) - either by deserializing an existing one or creating a new one
|
||||
let index_tree = if append_mode && index_path.exists() {
|
||||
IndexedDocumentIndex::<K>::load(&index_path).unwrap_or_else(|err| {
|
||||
error!("Failed to load index {}: {err}", index_path.display());
|
||||
IndexedDocumentIndex::<K>::new()
|
||||
})
|
||||
} else {
|
||||
IndexedDocumentIndex::<K>::new()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
main_path,
|
||||
index_path,
|
||||
main_file,
|
||||
main_offset,
|
||||
index_tree,
|
||||
dirty: false,
|
||||
fragmented,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new(main_path: PathBuf, index_path: PathBuf) -> Result<Self, Error> {
|
||||
Self::new_with_mode(main_path, index_path, false)
|
||||
}
|
||||
|
||||
pub fn new_append(main_path: PathBuf, index_path: PathBuf) -> Result<Self, Error> {
|
||||
Self::new_with_mode(main_path, index_path, true)
|
||||
}
|
||||
|
||||
pub fn store(&mut self) -> std::io::Result<()> {
|
||||
if self.dirty {
|
||||
self.dirty = false;
|
||||
match self.index_tree.store(&self.index_path) {
|
||||
Ok(written_bytes) => {
|
||||
if written_bytes > 0 {
|
||||
self.main_file.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(err) => { Err(err) }
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_doc<T>(&mut self, doc_id: K, doc: &T) -> Result<(), Error>
|
||||
where
|
||||
T: ?Sized + serde::Serialize,
|
||||
{
|
||||
// Determine target position (overwrite in place or append)
|
||||
if let Some(&offset) = self.index_tree.query(&doc_id) {
|
||||
// Existing entry: check size and choose strategy
|
||||
self.main_file.seek(SeekFrom::Start(u64::from(offset)))?;
|
||||
let old_size = IndexedDocument::read_content_size(&mut self.main_file)?;
|
||||
|
||||
// Prepare new payload (bincode utils, compatible with project)
|
||||
let encoded = bincode_serialize(doc)
|
||||
.map_err(|e| str_to_io_error(&format!("Failed to serialize document for {}: {e}", self.main_path.display())))?;
|
||||
let new_size: SizeType = SizeType::try_from(encoded.len() as u64)
|
||||
.map_err(|e| str_to_io_error(&format!("Encoded document size does not fit into u32 for {}: {e}", self.main_path.display())))?;
|
||||
|
||||
if usize::try_from(new_size).unwrap_or(usize::MAX) > old_size {
|
||||
// Does not fit -> append at end of file (mark fragmentation)
|
||||
if !self.fragmented {
|
||||
self.fragmented = true;
|
||||
IndexedDocument::write_fragmentation(&mut self.main_file, true)?;
|
||||
}
|
||||
self.main_file.seek(SeekFrom::End(0))?;
|
||||
// We write: length + payload
|
||||
self.dirty = true;
|
||||
self.main_file.write_all(&new_size.to_le_bytes())?;
|
||||
self.main_file.write_all(&encoded)?;
|
||||
|
||||
self.index_tree.insert(doc_id, self.main_offset);
|
||||
// Increase main_offset: LEN_SIZE + new_size
|
||||
let written_bytes_u64 = (LEN_SIZE as u64) + u64::from(new_size);
|
||||
let written_bytes: SizeType = SizeType::try_from(written_bytes_u64)
|
||||
.map_err(|e| str_to_io_error(&format!("Written byte count overflow for {}: {e}", self.main_path.display())))?;
|
||||
self.main_offset = self.main_offset.checked_add(written_bytes)
|
||||
.ok_or_else(|| str_to_io_error(&format!("main_offset overflow while appending to {}", self.main_path.display())))?;
|
||||
return Ok(());
|
||||
}
|
||||
// Fits (<=): overwrite at the same position
|
||||
self.main_file.seek(SeekFrom::Start(u64::from(offset)))?;
|
||||
self.dirty = true;
|
||||
self.main_file.write_all(&new_size.to_le_bytes())?;
|
||||
self.main_file.write_all(&encoded)?;
|
||||
return Ok(());
|
||||
}
|
||||
// New entry -> append
|
||||
self.main_file.seek(SeekFrom::End(0))?;
|
||||
// Determine size and write it, then write payload
|
||||
let encoded = bincode_serialize(doc)
|
||||
.map_err(|e| str_to_io_error(&format!("Failed to serialize document for {}: {e}", self.main_path.display())))?;
|
||||
let new_size: SizeType = SizeType::try_from(encoded.len() as u64)
|
||||
.map_err(|e| str_to_io_error(&format!("Encoded document size does not fit into u32 for {}: {e}", self.main_path.display())))?;
|
||||
self.dirty = true;
|
||||
self.main_file
|
||||
.write_all(&new_size.to_le_bytes())
|
||||
.map_err(|e| str_to_io_error(&format!("Failed to write length prefix to {}: {e}", self.main_path.display())))?;
|
||||
self.main_file
|
||||
.write_all(&encoded)
|
||||
.map_err(|e| str_to_io_error(&format!("Failed to write document payload to {}: {e}", self.main_path.display())))?;
|
||||
|
||||
self.index_tree.insert(doc_id, self.main_offset);
|
||||
let written_bytes_u64 = (LEN_SIZE as u64) + u64::from(new_size);
|
||||
let written_bytes: SizeType = SizeType::try_from(written_bytes_u64)
|
||||
.map_err(|e| str_to_io_error(&format!("Written byte count overflow for {}: {e}", self.main_path.display())))?;
|
||||
self.main_offset = self.main_offset.checked_add(written_bytes)
|
||||
.ok_or_else(|| str_to_io_error(&format!("main_offset overflow while appending to {}", self.main_path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Drop for IndexedDocumentWriter<K>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
let _ = self.store();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
///
|
||||
/// `IndexedDocumentReader`
|
||||
///
|
||||
////////////////////////////////////////////////////////
|
||||
pub struct IndexedDocumentReader<K, T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
main_file: BufReader<File>,
|
||||
index_tree: IndexedDocumentIndex<K>,
|
||||
buffer: Vec<u8>,
|
||||
t_type: PhantomData<T>,
|
||||
}
|
||||
|
||||
|
||||
impl<K, T> IndexedDocumentReader<K, T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
pub fn new(main_path: &Path, index_path: &Path) -> Result<Self, Error> {
|
||||
if main_path.exists() && index_path.exists() {
|
||||
let main_file = utils::open_readonly_file(main_path)?;
|
||||
let index_tree = IndexedDocumentIndex::<K>::load(index_path)?;
|
||||
|
||||
Ok(Self {
|
||||
// Larger read buffer for sequential access
|
||||
main_file: BufReader::with_capacity(BLOCK_SIZE * 64, main_file),
|
||||
index_tree,
|
||||
buffer: Vec::with_capacity(BLOCK_SIZE),
|
||||
t_type: PhantomData,
|
||||
})
|
||||
} else {
|
||||
Err(Error::new(ErrorKind::NotFound, format!("File not found {}", main_path.display())))
|
||||
}
|
||||
}
|
||||
pub fn get(&mut self, doc_id: &K) -> Result<T, Error> {
|
||||
if let Some(offset) = self.index_tree.query(doc_id) {
|
||||
self.main_file.seek(SeekFrom::Start(u64::from(*offset)))?;
|
||||
let buf_size = IndexedDocument::read_content_size(&mut self.main_file)?;
|
||||
self.buffer.resize(buf_size, 0u8);
|
||||
self.main_file.read_exact(&mut self.buffer[..buf_size])?;
|
||||
if let Ok(item) = bincode_deserialize::<T>(&self.buffer[..buf_size]) {
|
||||
return Ok(item);
|
||||
}
|
||||
}
|
||||
Err(Error::new(ErrorKind::NotFound, format!("Entry not found {doc_id:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
/// `IndexedDocumentIterator`
|
||||
///
|
||||
/// Iterator | Sequential access with `has_next` / next
|
||||
////////////////////////////////////////////////////////
|
||||
pub(in crate::repository) struct IndexedDocumentIterator<K, T> {
|
||||
main_path: PathBuf,
|
||||
main_file: BufReader<File>,
|
||||
offsets: Vec<OffsetPointer>,
|
||||
index: usize,
|
||||
failed: bool,
|
||||
t_buffer: Vec<u8>,
|
||||
t_type: PhantomData<T>,
|
||||
k_type: PhantomData<K>,
|
||||
}
|
||||
|
||||
impl<K, T> IndexedDocumentIterator<K, T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
pub fn new(main_path: &Path, index_path: &Path) -> Result<Self, Error> {
|
||||
if main_path.exists() && index_path.exists() {
|
||||
let mut offsets = Vec::<OffsetPointer>::new();
|
||||
{
|
||||
let index_tree = IndexedDocumentIndex::<K>::load(index_path)?;
|
||||
index_tree.traverse(|_, values| offsets.extend(values));
|
||||
offsets.sort_unstable();
|
||||
}
|
||||
match File::open(main_path) {
|
||||
Ok(file) => {
|
||||
Ok(Self {
|
||||
main_path: main_path.to_path_buf(),
|
||||
// Larger read buffer for sequential iteration
|
||||
main_file: BufReader::with_capacity(BLOCK_SIZE * 128, file),
|
||||
offsets,
|
||||
index: 0,
|
||||
failed: false,
|
||||
t_buffer: Vec::with_capacity(BLOCK_SIZE),
|
||||
t_type: PhantomData,
|
||||
k_type: PhantomData,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
}
|
||||
} else {
|
||||
Err(Error::new(ErrorKind::NotFound, format!("File not found {}", main_path.display())))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_path(&self) -> &Path {
|
||||
&self.main_path
|
||||
}
|
||||
|
||||
pub const fn has_error(&self) -> bool {
|
||||
self.failed
|
||||
}
|
||||
|
||||
pub fn has_next(&self) -> bool {
|
||||
!self.failed && self.index < self.offsets.len()
|
||||
}
|
||||
pub fn read_next(&mut self) -> Result<Option<(T, bool)>, Error> {
|
||||
if !self.has_next() {
|
||||
return Ok(None);
|
||||
}
|
||||
// read content-size
|
||||
self.main_file.seek(SeekFrom::Start(u64::from(self.offsets[self.index])))?;
|
||||
self.index += 1;
|
||||
let has_next = self.has_next();
|
||||
let buf_size: usize = IndexedDocument::read_content_size(&mut self.main_file)?;
|
||||
// resize buffer if necessary
|
||||
if self.t_buffer.capacity() < buf_size {
|
||||
self.t_buffer.reserve(buf_size - self.t_buffer.capacity());
|
||||
}
|
||||
self.t_buffer.resize(buf_size, 0u8);
|
||||
// read content
|
||||
self.main_file.read_exact(&mut self.t_buffer[0..buf_size])?;
|
||||
// deserialize buffer
|
||||
match bincode_deserialize::<T>(&self.t_buffer[0..buf_size]) {
|
||||
Ok(value) => Ok(Some((value, has_next))),
|
||||
Err(err) => {
|
||||
self.failed = true;
|
||||
Err(str_to_io_error(&format!("Failed to deserialize document {err}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, T: serde::de::DeserializeOwned> Iterator for IndexedDocumentIterator<K, T>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
{
|
||||
type Item = (T, bool);
|
||||
|
||||
// Implement the next() method
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.has_next() {
|
||||
if let Ok(value) = self.read_next() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
///
|
||||
/// `IndexedDocumentDirectAccess`
|
||||
///
|
||||
////////////////////////////////////////////////////////
|
||||
pub(in crate::repository) struct IndexedDocumentDirectAccess {}
|
||||
|
||||
impl IndexedDocumentDirectAccess {
|
||||
pub(in crate::repository) fn read_indexed_item<K, T>(main_path: &Path, index_path: &Path, doc_id: &K) -> Result<T, Error>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Debug,
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
if main_path.exists() && index_path.exists() {
|
||||
// get the offset from index
|
||||
let offset = IndexedDocument::get_offset(index_path, doc_id)?;
|
||||
let mut main_file = File::open(main_path)?;
|
||||
main_file.seek(SeekFrom::Start(offset))?;
|
||||
let buf_size = IndexedDocument::read_content_size(&mut main_file)?;
|
||||
let mut buffer: Vec<u8> = vec![0; buf_size];
|
||||
main_file.read_exact(&mut buffer)?;
|
||||
if let Ok(item) = bincode_deserialize::<T>(&buffer) {
|
||||
return Ok(item);
|
||||
}
|
||||
}
|
||||
Err(str_to_io_error(&format!("Failed to read item for id {:?} - {}", doc_id, main_path.display())))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
///
|
||||
/// `IndexedDocumentGarbageCollector`
|
||||
///
|
||||
////////////////////////////////////////////////////////
|
||||
pub(in crate::repository) struct IndexedDocumentGarbageCollector<K> {
|
||||
main_path: PathBuf,
|
||||
index_path: PathBuf,
|
||||
main_file: File,
|
||||
index_tree: IndexedDocumentIndex<K>,
|
||||
}
|
||||
|
||||
impl<K> IndexedDocumentGarbageCollector<K>
|
||||
where
|
||||
K: Ord + Serialize + for<'de> Deserialize<'de> + Clone,
|
||||
{
|
||||
pub fn new(main_path: PathBuf, index_path: PathBuf) -> Result<Self, Error> {
|
||||
if main_path.exists() && index_path.exists() {
|
||||
// Attempt to open the main file in the specified mode (append or not)
|
||||
|
||||
let main_file = utils::open_read_write_file(&main_path)?;
|
||||
|
||||
// Retrieve file size and convert to `u32` for `main_file`, if possible
|
||||
let size = main_file
|
||||
.metadata()
|
||||
.and_then(|meta| SizeType::try_from(meta.len()).map_err(to_io_error))
|
||||
.unwrap_or(0);
|
||||
if size < 1 {
|
||||
return Err(Error::new(ErrorKind::UnexpectedEof, format!("File empty main:{}", main_path.display())));
|
||||
}
|
||||
|
||||
// Initialize the index tree (BPlusTree) - by deserializing an existing one
|
||||
let index_tree = IndexedDocumentIndex::<K>::load(&index_path)?;
|
||||
|
||||
Ok(Self {
|
||||
main_path,
|
||||
index_path,
|
||||
main_file,
|
||||
index_tree,
|
||||
})
|
||||
} else {
|
||||
Err(Error::new(ErrorKind::NotFound, format!("Files not found main:{} index:{}", main_path.display(), index_path.display())))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn garbage_collect(&mut self) -> Result<(), Error> {
|
||||
let fragmented = IndexedDocument::read_fragmentation(&mut self.main_file)?;
|
||||
if !fragmented {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gc_file = NamedTempFile::new()?;
|
||||
let gc_path = gc_file.path();
|
||||
{
|
||||
let mut key_offset = Vec::<(K, OffsetPointer)>::new();
|
||||
self.index_tree.traverse(|keys, values| {
|
||||
keys.iter().zip(values.iter()).for_each(|(key, &offset)| key_offset.push((key.clone(), offset)));
|
||||
});
|
||||
// For better disk locality: sort by offset (sequential reads)
|
||||
key_offset.sort_by_key(|(_, off)| *off);
|
||||
let mut gc_writer = utils::file_writer(&gc_file);
|
||||
|
||||
let fragmented_byte = 0u8.to_le_bytes();
|
||||
gc_writer.write_all(&fragmented_byte)?;
|
||||
|
||||
let mut gc_offset = 1usize; // offset is 1 because of fragment bit
|
||||
let mut buffer: Vec<u8> = Vec::with_capacity(BLOCK_SIZE);
|
||||
let mut size_bytes = [0u8; LEN_SIZE];
|
||||
let mut write_counter = 0usize;
|
||||
|
||||
for (key, offset) in key_offset {
|
||||
// read old content
|
||||
self.main_file.seek(SeekFrom::Start(u64::from(offset)))?;
|
||||
self.main_file.read_exact(&mut size_bytes)?;
|
||||
let buf_size = SizeType::from_le_bytes(size_bytes) as usize;
|
||||
// ensure buffer capacity
|
||||
if buffer.capacity() < buf_size {
|
||||
buffer.reserve(buf_size - buffer.capacity());
|
||||
}
|
||||
buffer.resize(buf_size, 0u8);
|
||||
self.main_file.read_exact(&mut buffer[0..buf_size])?;
|
||||
|
||||
gc_writer.write_all(&size_bytes)?;
|
||||
gc_writer.write_all(&buffer[0..buf_size])?;
|
||||
write_counter += buf_size;
|
||||
if write_counter >= IO_BUFFER_SIZE {
|
||||
write_counter = 0;
|
||||
gc_writer.flush()?;
|
||||
}
|
||||
|
||||
let pointer = OffsetPointer::try_from(gc_offset).map_err(to_io_error)?;
|
||||
self.index_tree.insert(key, pointer);
|
||||
gc_offset += size_bytes.len() + buf_size; // gc_file.stream_position();
|
||||
}
|
||||
|
||||
gc_writer.flush()?;
|
||||
}
|
||||
|
||||
utils::rename_or_copy(gc_path, &self.main_path, false)?;
|
||||
self.index_tree.store(&self.index_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::model::XtreamPlaylistItem;
|
||||
// use crate::model::XtreamPlaylistItem;
|
||||
use crate::repository::indexed_document::{IndexedDocumentGarbageCollector, IndexedDocumentIterator, IndexedDocumentWriter};
|
||||
use crate::utils::resolve_env_var;
|
||||
|
||||
// Example usage with a simple struct
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
struct Record {
|
||||
id: u32,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_test() -> io::Result<()> {
|
||||
let main_path = PathBuf::from("/tmp/main.iw");
|
||||
let index_path = PathBuf::from("/tmp/main.iw.idx");
|
||||
{
|
||||
let mut idw = IndexedDocumentWriter::new(main_path.clone(), index_path.clone())?;
|
||||
|
||||
for i in 0u32..=500 {
|
||||
idw.write_doc(i, &Record {
|
||||
id: i,
|
||||
data: format!("Entry {i}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
let size_main_file_1 = std::fs::metadata(&main_path)?.len();
|
||||
|
||||
// update same block, file size should not increase
|
||||
for i in 0u32..=500 {
|
||||
idw.write_doc(i, &Record {
|
||||
id: i,
|
||||
data: format!("E {i}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
let size_main_file_2 = std::fs::metadata(&main_path)?.len();
|
||||
assert_eq!(size_main_file_1, size_main_file_2, "Failed, the filesize should be the same");
|
||||
|
||||
// fragmentation
|
||||
for i in 0u32..=500 {
|
||||
idw.write_doc(i, &Record {
|
||||
id: i,
|
||||
data: format!("Entry {}", i + 9000),
|
||||
})?;
|
||||
}
|
||||
|
||||
let size_main_file_3 = std::fs::metadata(&main_path)?.len();
|
||||
assert!(size_main_file_1 < size_main_file_3, "Failed, the filesize should be greater");
|
||||
|
||||
idw.store()?;
|
||||
}
|
||||
{
|
||||
let size_main_file_4 = std::fs::metadata(&main_path)?.len();
|
||||
|
||||
let mut gc = IndexedDocumentGarbageCollector::<u32>::new(main_path.clone(), index_path.clone())?;
|
||||
gc.garbage_collect()?;
|
||||
|
||||
let size_main_file_5 = std::fs::metadata(&main_path)?.len();
|
||||
assert!(size_main_file_5 < size_main_file_4, "Failed, the filesize should be less");
|
||||
}
|
||||
{
|
||||
let reader = IndexedDocumentIterator::<u32, Record>::new(&main_path, &index_path)?;
|
||||
let mut i = 0;
|
||||
for (doc, _) in reader {
|
||||
assert_eq!(doc.id, i, "Wrong id");
|
||||
assert_eq!(doc.data, format!("Entry {}", i + 9000), "Wrong data");
|
||||
i += 1;
|
||||
}
|
||||
assert_eq!(501, i, "Wrong number of elements");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_xt() -> io::Result<()> {
|
||||
let main_path = PathBuf::from(resolve_env_var("${env:HOME}/projects/m3u-test/settings/alexyand/data/all_channels/xtream/live.db"));
|
||||
let index_path = PathBuf::from(resolve_env_var("${env:HOME}/projects/m3u-test/settings/alexyand/data/all_channels/xtream/live.idx"));
|
||||
let reader = IndexedDocumentIterator::<u32, XtreamPlaylistItem>::new(&main_path, &index_path)?;
|
||||
for (doc, _has_next) in reader {
|
||||
if doc.epg_channel_id.is_none() {
|
||||
println!("{doc:?}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ use shared::error::{TuliproxError};
|
||||
use crate::model::{AppConfig, ProxyUserCredentials};
|
||||
use crate::model::{ConfigTarget};
|
||||
use shared::model::{ConfigTargetOptions, M3uPlaylistItem, PlaylistItemType, ProxyType, TargetType, XtreamCluster};
|
||||
use crate::repository::indexed_document::IndexedDocumentIterator;
|
||||
use crate::repository::m3u_repository::m3u_get_file_paths;
|
||||
use crate::repository::bplustree::{BPlusTreeDiskIteratorOwned, BPlusTreeQuery};
|
||||
use crate::repository::m3u_repository::m3u_get_file_path;
|
||||
use crate::repository::storage::ensure_target_storage_path;
|
||||
use crate::repository::storage_const;
|
||||
use crate::repository::user_repository::user_get_bouquet_filter;
|
||||
@@ -14,7 +14,7 @@ use std::collections::HashSet;
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct M3uPlaylistIterator {
|
||||
reader: IndexedDocumentIterator<u32, M3uPlaylistItem>,
|
||||
reader: BPlusTreeDiskIteratorOwned<u32, M3uPlaylistItem>,
|
||||
base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
@@ -40,13 +40,13 @@ impl M3uPlaylistIterator {
|
||||
let m3u_output = target.get_m3u_output().ok_or_else(|| info_err!(format!("Unexpected failure, missing m3u target output for target {}", target.name)))?;
|
||||
let config = cfg.config.load();
|
||||
let target_path = ensure_target_storage_path(&config, target.name.as_str())?;
|
||||
let (m3u_path, idx_path) = m3u_get_file_paths(&target_path);
|
||||
let m3u_path = m3u_get_file_path(&target_path);
|
||||
|
||||
let file_lock = cfg.file_locks.read_lock(&m3u_path).await;
|
||||
|
||||
let reader =
|
||||
IndexedDocumentIterator::<u32, M3uPlaylistItem>::new(&m3u_path, &idx_path)
|
||||
.map_err(|err| info_err!(format!("Could not deserialize file {m3u_path:?} - {err}")))?;
|
||||
let query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
|
||||
.map_err(|err| info_err!(format!("Could not open BPlusTreeQuery {m3u_path:?} - {err}")))?;
|
||||
let reader = query.disk_iter();
|
||||
|
||||
let filter = user_get_bouquet_filter(&config, &user.username, None, TargetType::M3u, XtreamCluster::Live).await;
|
||||
|
||||
@@ -116,14 +116,14 @@ impl M3uPlaylistIterator {
|
||||
let entry = if let Some(set) = &self.filter {
|
||||
if let Some((current_item, _)) = self.lookup_item.take() {
|
||||
// Avoid cloning strings while filtering
|
||||
let next_valid = self.reader.find(|(pli, _)| set.contains(pli.group.as_str()));
|
||||
self.lookup_item = next_valid;
|
||||
let next_valid = self.reader.find(|(_, pli)| set.contains(pli.group.as_str()));
|
||||
self.lookup_item = next_valid.map(|(_k, v)| (v, true)); // has_next handled by iterator usually, but here we just need the item
|
||||
let has_next = self.lookup_item.is_some();
|
||||
Some((current_item, has_next))
|
||||
} else {
|
||||
let current_item = self.reader.find(|(item, _)| set.contains(item.group.as_str()));
|
||||
if let Some((item, _)) = current_item {
|
||||
self.lookup_item = self.reader.find(|(item, _)| set.contains(item.group.as_str()));
|
||||
let current_item = self.reader.find(|(_, item)| set.contains(item.group.as_str()));
|
||||
if let Some((_, item)) = current_item {
|
||||
self.lookup_item = self.reader.find(|(_, item)| set.contains(item.group.as_str())).map(|(_, item)| (item, true));
|
||||
let has_next = self.lookup_item.is_some();
|
||||
Some((item, has_next))
|
||||
} else {
|
||||
@@ -131,7 +131,7 @@ impl M3uPlaylistIterator {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.reader.next()
|
||||
self.reader.next().map(|(_, v)| (v, !self.reader.is_empty()))
|
||||
};
|
||||
|
||||
// TODO hls and unknown reverse proxy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::model::{AppConfig, ProxyUserCredentials};
|
||||
use crate::model::{Config, ConfigTarget, M3uTargetOutput};
|
||||
use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentIterator, IndexedDocumentWriter};
|
||||
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery};
|
||||
use crate::repository::m3u_playlist_iterator::M3uPlaylistM3uTextIterator;
|
||||
use crate::repository::storage::get_target_storage_path;
|
||||
use crate::repository::storage_const;
|
||||
@@ -9,7 +9,7 @@ use crate::utils;
|
||||
use shared::error::{create_tuliprox_error, info_err};
|
||||
use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind};
|
||||
use shared::model::PlaylistItemType;
|
||||
use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItem};
|
||||
use shared::model::{M3uPlaylistItem, PlaylistGroup};
|
||||
use std::io::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -25,10 +25,8 @@ macro_rules! cant_write_result {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn m3u_get_file_paths(target_path: &Path) -> (PathBuf, PathBuf) {
|
||||
let m3u_path = target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_M3U, storage_const::FILE_SUFFIX_DB)));
|
||||
let index_path = target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_M3U, storage_const::FILE_SUFFIX_INDEX)));
|
||||
(m3u_path, index_path)
|
||||
pub fn m3u_get_file_path(target_path: &Path) -> PathBuf {
|
||||
target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_M3U, storage_const::FILE_SUFFIX_DB)))
|
||||
}
|
||||
|
||||
pub fn m3u_get_epg_file_path(target_path: &Path) -> PathBuf {
|
||||
@@ -88,13 +86,13 @@ pub async fn m3u_write_playlist(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (m3u_path, idx_path) = m3u_get_file_paths(target_path);
|
||||
let m3u_path = m3u_get_file_path(target_path);
|
||||
let m3u_playlist = Arc::new(
|
||||
new_playlist
|
||||
.iter()
|
||||
.flat_map(|pg| &pg.channels)
|
||||
.filter(|&pli| !matches!(pli.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo))
|
||||
.map(PlaylistItem::to_m3u)
|
||||
.map(M3uPlaylistItem::from)
|
||||
.collect::<Vec<M3uPlaylistItem>>(),
|
||||
);
|
||||
|
||||
@@ -106,23 +104,15 @@ pub async fn m3u_write_playlist(
|
||||
|
||||
let playlist = Arc::clone(&m3u_playlist);
|
||||
let m3u_path_clone = m3u_path.clone();
|
||||
let idx_path_clone = idx_path.clone();
|
||||
|
||||
task::spawn_blocking(move || -> Result<(), TuliproxError> {
|
||||
let _guard = file_lock;
|
||||
match IndexedDocumentWriter::new(m3u_path_clone.clone(), idx_path_clone) {
|
||||
Ok(mut writer) => {
|
||||
for m3u in playlist.iter() {
|
||||
writer
|
||||
.write_doc(m3u.virtual_id, m3u)
|
||||
.map_err(|err| cant_write_result!(&m3u_path_clone, err))?;
|
||||
}
|
||||
writer
|
||||
.store()
|
||||
.map_err(|err| cant_write_result!(&m3u_path_clone, err))
|
||||
}
|
||||
Err(err) => Err(cant_write_result!(&m3u_path_clone, err)),
|
||||
let mut tree = BPlusTree::new();
|
||||
for m3u in playlist.iter() {
|
||||
tree.insert(m3u.virtual_id, m3u.clone());
|
||||
}
|
||||
tree.store(&m3u_path_clone).map_err(|err| cant_write_result!(&m3u_path_clone, err))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Notify, "failed to write m3u playlist: {} - {err}", m3u_path.display()))??;
|
||||
@@ -154,22 +144,28 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, app_state: &AppState, ta
|
||||
|
||||
let cfg: &AppConfig = &app_state.app_config;
|
||||
let target_path = get_target_storage_path(&cfg.config.load(), target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?;
|
||||
let (m3u_path, idx_path) = m3u_get_file_paths(&target_path);
|
||||
let m3u_path = m3u_get_file_path(&target_path);
|
||||
let _file_lock = cfg.file_locks.read_lock(&m3u_path).await;
|
||||
IndexedDocumentDirectAccess::read_indexed_item::<u32, M3uPlaylistItem>(&m3u_path, &idx_path, &stream_id)
|
||||
|
||||
let mut query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)?;
|
||||
query.query(&stream_id).ok_or_else(|| str_to_io_error(&format!("Item not found: {stream_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn iter_raw_m3u_playlist(config: &AppConfig, target: &ConfigTarget) -> Option<(utils::FileReadGuard, impl Iterator<Item=(M3uPlaylistItem, bool)>)> {
|
||||
let target_path = get_target_storage_path(&config.config.load(), target.name.as_str())?;
|
||||
let (m3u_path, idx_path) = m3u_get_file_paths(&target_path);
|
||||
if !m3u_path.exists() || !idx_path.exists() {
|
||||
let m3u_path = m3u_get_file_path(&target_path);
|
||||
if let Ok(false) = tokio::fs::try_exists(&m3u_path).await {
|
||||
return None;
|
||||
}
|
||||
let file_lock = config.file_locks.read_lock(&m3u_path).await;
|
||||
match IndexedDocumentIterator::<u32, M3uPlaylistItem>::new(&m3u_path, &idx_path)
|
||||
.map_err(|err| info_err!(format!("Could not deserialize file {m3u_path:?} - {err}"))) {
|
||||
Ok(reader) => Some((file_lock, reader)),
|
||||
match BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
|
||||
.map_err(|err| info_err!(format!("Could not open BPlusTreeQuery {m3u_path:?} - {err}"))) {
|
||||
Ok(mut query) => {
|
||||
let items: Vec<M3uPlaylistItem> = query.iter().map(|(_, v)| v).collect();
|
||||
let len = items.len();
|
||||
Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i < len - 1))))
|
||||
}
|
||||
Err(_) => None
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
pub mod storage;
|
||||
pub mod target_id_mapping;
|
||||
pub mod bplustree;
|
||||
mod indexed_document;
|
||||
pub use indexed_document::IndexedDocumentReader;
|
||||
pub mod playlist_repository;
|
||||
pub mod m3u_repository;
|
||||
pub mod xtream_repository;
|
||||
@@ -12,4 +10,5 @@ pub mod m3u_playlist_iterator;
|
||||
pub mod xtream_playlist_iterator;
|
||||
pub mod user_repository;
|
||||
pub mod storage_const;
|
||||
mod playlist_scratch;
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
use crate::api::model::{AppState, PlaylistM3uStorage, PlaylistStorage, PlaylistStorageState, PlaylistXtreamStorage};
|
||||
use crate::model::{AppConfig, ConfigTarget, TargetOutput};
|
||||
use crate::model::{Epg, XtreamSeriesInfo};
|
||||
use crate::model::{AppConfig, ConfigInput, ConfigTarget, TargetOutput};
|
||||
use crate::model::Epg;
|
||||
use crate::processing::processor::playlist::apply_filter_to_playlist;
|
||||
use crate::repository::bplustree::BPlusTree;
|
||||
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery};
|
||||
use crate::repository::epg_repository::epg_write;
|
||||
use crate::repository::indexed_document::IndexedDocumentIterator;
|
||||
use crate::repository::m3u_repository::{m3u_get_file_paths, m3u_write_playlist};
|
||||
use crate::repository::storage::{ensure_target_storage_path, get_target_id_mapping_file, get_target_storage_path};
|
||||
use crate::repository::m3u_repository::{m3u_get_file_path, m3u_write_playlist};
|
||||
use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path, get_target_id_mapping_file, get_target_storage_path};
|
||||
use crate::repository::strm_repository::write_strm_playlist;
|
||||
use crate::repository::target_id_mapping::{TargetIdMapping, VirtualIdRecord};
|
||||
use crate::repository::xtream_repository::{xtream_get_file_paths, xtream_get_storage_path, xtream_write_playlist};
|
||||
use crate::repository::xtream_repository::{persist_input_xtream_playlist, xtream_get_file_path, xtream_get_storage_path, xtream_write_playlist};
|
||||
use crate::utils;
|
||||
use log::info;
|
||||
use serde_json::value::RawValue;
|
||||
use shared::create_tuliprox_error;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::error::{info_err, TuliproxErrorKind};
|
||||
use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItemHeader, PlaylistItemType, XtreamCluster, XtreamPlaylistItem};
|
||||
use shared::model::{InputType, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem};
|
||||
use shared::utils::{is_dash_url, is_hls_url};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -27,7 +25,12 @@ struct LocalEpisodeKey {
|
||||
virtual_id: u32,
|
||||
}
|
||||
|
||||
pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGroup], epg: Option<&Epg>,
|
||||
pub struct ProviderEpisodeKey {
|
||||
pub(crate) provider_id: u32,
|
||||
pub(crate) virtual_id: u32,
|
||||
}
|
||||
|
||||
pub async fn persist_playlist(app_config: &Arc<AppConfig>, playlist: &mut [PlaylistGroup], epg: Option<&Epg>,
|
||||
target: &ConfigTarget, playlist_state: Option<&Arc<PlaylistStorageState>>) -> Result<(), Vec<TuliproxError>> {
|
||||
let mut errors = vec![];
|
||||
let config = &app_config.config.load();
|
||||
@@ -39,6 +42,7 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr
|
||||
let (mut target_id_mapping, file_lock) = get_target_id_mapping(app_config, &target_path).await;
|
||||
|
||||
let mut local_library_series = HashMap::<String, Vec<LocalEpisodeKey>>::new();
|
||||
let mut provider_series = HashMap::<String, Vec<ProviderEpisodeKey>>::new();
|
||||
|
||||
// Virtual IDs assignment
|
||||
for group in playlist.iter_mut() {
|
||||
@@ -63,11 +67,16 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr
|
||||
let item_type = header.item_type;
|
||||
header.virtual_id = target_id_mapping.get_and_update_virtual_id(uuid, provider_id, item_type, 0);
|
||||
|
||||
assign_local_series_info_episode_key(&mut local_library_series, header, item_type);
|
||||
if item_type == PlaylistItemType::LocalSeries {
|
||||
assign_local_series_info_episode_key(&mut local_library_series, header, item_type);
|
||||
}
|
||||
if item_type == PlaylistItemType::Series {
|
||||
assign_provider_series_info_episode_key(&mut provider_series, header, item_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rewrite_local_series_info_episode_virtual_id(playlist, &mut local_library_series);
|
||||
rewrite_series_info_episode_virtual_id(playlist, &local_library_series, &provider_series);
|
||||
|
||||
for output in &target.output {
|
||||
let mut filtered = match output {
|
||||
@@ -143,29 +152,32 @@ fn assign_local_series_info_episode_key(local_library_series: &mut HashMap<Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_local_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], local_library_series: &mut HashMap<String, Vec<LocalEpisodeKey>>) {
|
||||
// assign local series virtual ids
|
||||
for group in playlist.iter_mut() {
|
||||
for channel in &mut group.channels {
|
||||
let header = &mut channel.header;
|
||||
if header.item_type == PlaylistItemType::LocalSeriesInfo {
|
||||
if let Some(episode_keys) = local_library_series.get(&header.id) {
|
||||
if let Some(props) = &header.additional_properties {
|
||||
let props_str = props.get();
|
||||
if let Ok(mut series_info) = serde_json::from_str::<XtreamSeriesInfo>(props_str) {
|
||||
if let Some(episodes) = series_info.episodes.as_mut() {
|
||||
for episode in episodes.iter_mut() {
|
||||
for episode_key in episode_keys {
|
||||
if episode.direct_source == episode_key.path {
|
||||
episode.id = episode_key.virtual_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(s) = serde_json::to_string(&series_info) {
|
||||
header.additional_properties = RawValue::from_string(s).ok();
|
||||
}
|
||||
}
|
||||
fn assign_provider_series_info_episode_key(provider_series: &mut HashMap<String, Vec<ProviderEpisodeKey>>, header: &mut PlaylistItemHeader, item_type: PlaylistItemType) {
|
||||
// we need to rewrite local series info with the new virtual ids
|
||||
if item_type == PlaylistItemType::Series {
|
||||
provider_series
|
||||
.entry(header.parent_code.clone())
|
||||
.or_default()
|
||||
.push(ProviderEpisodeKey {
|
||||
provider_id: header.get_provider_id().unwrap_or_default(),
|
||||
virtual_id: header.virtual_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::implicit_hasher)]
|
||||
fn rewrite_local_series_info_episode_virtual_id(pli: &mut PlaylistItem, local_library_series: &HashMap<String, Vec<LocalEpisodeKey>>) {
|
||||
let header = &mut pli.header;
|
||||
if let Some(episode_keys) = local_library_series.get(&header.id) {
|
||||
if let Some(StreamProperties::Series(series)) = header.additional_properties.as_mut() {
|
||||
if let Some(episodes) =
|
||||
series.details.as_mut().and_then(|d| d.episodes.as_mut())
|
||||
{
|
||||
for episode in episodes.iter_mut() {
|
||||
for episode_key in episode_keys {
|
||||
if episode.direct_source == episode_key.path {
|
||||
episode.id = episode_key.virtual_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +186,46 @@ fn rewrite_local_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup],
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::implicit_hasher)]
|
||||
pub fn rewrite_provider_series_info_episode_virtual_id<P>(pli: &mut P, provider_series: &HashMap<String, Vec<ProviderEpisodeKey>>)
|
||||
where P: PlaylistEntry
|
||||
{
|
||||
if let Some(episode_keys) = provider_series.get(&pli.get_uuid().to_string()) {
|
||||
if let Some(StreamProperties::Series(series)) = pli.get_additional_properties_mut() {
|
||||
if let Some(episodes) =
|
||||
series.details.as_mut().and_then(|d| d.episodes.as_mut())
|
||||
{
|
||||
for episode in episodes.iter_mut() {
|
||||
for episode_key in episode_keys {
|
||||
if episode.id == episode_key.provider_id {
|
||||
episode.id = episode_key.virtual_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup],
|
||||
local_library_series: &HashMap<String, Vec<LocalEpisodeKey>>,
|
||||
provider_series: &HashMap<String, Vec<ProviderEpisodeKey>>) {
|
||||
if local_library_series.is_empty() && provider_series.is_empty() {
|
||||
return;
|
||||
}
|
||||
for group in playlist.iter_mut() {
|
||||
for channel in &mut group.channels {
|
||||
let item_type = channel.header.item_type;
|
||||
if item_type == PlaylistItemType::SeriesInfo {
|
||||
rewrite_provider_series_info_episode_virtual_id(channel, provider_series);
|
||||
} else if item_type == PlaylistItemType::LocalSeriesInfo {
|
||||
rewrite_local_series_info_episode_virtual_id(channel, local_library_series);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (TargetIdMapping, utils::FileWriteGuard) {
|
||||
let target_id_mapping_file = get_target_id_mapping_file(target_path);
|
||||
let file_lock = cfg.file_locks.write_lock(&target_id_mapping_file).await;
|
||||
@@ -184,20 +236,16 @@ pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (Targ
|
||||
async fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, target: &ConfigTarget) -> Result<BPlusTree<u32, VirtualIdRecord>, TuliproxError> {
|
||||
let target_id_mapping_file = get_target_id_mapping_file(target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await;
|
||||
|
||||
BPlusTree::<u32, VirtualIdRecord>::load(&target_id_mapping_file).map_err(|err|
|
||||
create_tuliprox_error!(
|
||||
TuliproxErrorKind::Info,
|
||||
"Could not find path for target {} err:{err}", &target.name
|
||||
))
|
||||
create_tuliprox_error!(TuliproxErrorKind::Info, "Could not find path for target {} err:{err}", &target.name))
|
||||
}
|
||||
|
||||
async fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, cluster: XtreamCluster) -> BPlusTree<u32, XtreamPlaylistItem> {
|
||||
let (main_path, index_path) = xtream_get_file_paths(storage_path, cluster);
|
||||
let _file_lock = app_config.file_locks.read_lock(&main_path).await;
|
||||
let xtream_path = xtream_get_file_path(storage_path, cluster);
|
||||
let _file_lock = app_config.file_locks.read_lock(&xtream_path).await;
|
||||
let mut tree = BPlusTree::<u32, XtreamPlaylistItem>::new();
|
||||
if let Ok(reader) = IndexedDocumentIterator::<u32, XtreamPlaylistItem>::new(&main_path, &index_path) {
|
||||
for (doc, _has_next) in reader {
|
||||
if let Ok(mut query) = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path) {
|
||||
for (_, doc) in query.iter() {
|
||||
tree.insert(doc.virtual_id, doc);
|
||||
}
|
||||
}
|
||||
@@ -238,11 +286,11 @@ async fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget)
|
||||
"Could not find path for target {}", &target.name
|
||||
))?;
|
||||
|
||||
let (main_path, index_path) = m3u_get_file_paths(&target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&main_path).await;
|
||||
let m3u_path = m3u_get_file_path(&target_path);
|
||||
let _file_lock = app_config.file_locks.read_lock(&m3u_path).await;
|
||||
let mut tree = BPlusTree::<u32, M3uPlaylistItem>::new();
|
||||
if let Ok(reader) = IndexedDocumentIterator::<u32, M3uPlaylistItem>::new(&main_path, &index_path) {
|
||||
for (doc, _has_next) in reader {
|
||||
if let Ok(mut query) = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path) {
|
||||
for (_, doc) in query.iter() {
|
||||
tree.insert(doc.virtual_id, doc);
|
||||
}
|
||||
}
|
||||
@@ -279,3 +327,23 @@ pub async fn load_target_into_memory_cache(app_state: &AppState, target: &Arc<Co
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn persist_input_playlist(app_config: &Arc<AppConfig>, input: &Arc<ConfigInput>, mut playlist: Vec<PlaylistGroup>) -> (Vec<PlaylistGroup>, Option<TuliproxError>) {
|
||||
playlist.iter_mut().for_each(PlaylistGroup::on_load);
|
||||
|
||||
let (result, err) = match input.input_type {
|
||||
InputType::Xtream | InputType::XtreamBatch => {
|
||||
let working_dir = &app_config.config.load().working_dir;
|
||||
let storage_path = match get_input_storage_path(&input.name, working_dir) {
|
||||
Ok(storage_path) => storage_path,
|
||||
Err(err) => {
|
||||
return (playlist, Some(create_tuliprox_error!( TuliproxErrorKind::Info, "Error creating input storage directory for input '{}' failed: {err}", input.name)));
|
||||
}
|
||||
};
|
||||
persist_input_xtream_playlist(app_config, &storage_path, playlist).await
|
||||
}
|
||||
_ => (playlist, None),
|
||||
};
|
||||
|
||||
(result, err)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use indexmap::IndexMap;
|
||||
use shared::model::XtreamCluster;
|
||||
|
||||
pub(in crate::repository) trait WithCapacity {
|
||||
fn with_capacity(capacity: usize) -> Self;
|
||||
}
|
||||
|
||||
impl<T> WithCapacity for Vec<T> {
|
||||
fn with_capacity(capacity: usize) -> Self {
|
||||
Vec::with_capacity(capacity)
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> WithCapacity for IndexMap<K, V> {
|
||||
fn with_capacity(capacity: usize) -> Self {
|
||||
IndexMap::with_capacity(capacity)
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::repository) trait IsEmpty {
|
||||
fn is_empty(&self) -> bool;
|
||||
}
|
||||
|
||||
|
||||
impl<T> IsEmpty for Vec<T> {
|
||||
fn is_empty(&self) -> bool {
|
||||
Vec::is_empty(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> IsEmpty for IndexMap<K, V> {
|
||||
fn is_empty(&self) -> bool {
|
||||
IndexMap::is_empty(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlaylistScratch<C> {
|
||||
live: C,
|
||||
vods: C,
|
||||
series: C,
|
||||
}
|
||||
|
||||
impl<C: WithCapacity> PlaylistScratch<C> {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
live: C::with_capacity(capacity),
|
||||
vods: C::with_capacity(capacity),
|
||||
series: C::with_capacity(capacity),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> PlaylistScratch<C> {
|
||||
pub fn get_mut(&mut self, cluster: XtreamCluster) -> &mut C {
|
||||
match cluster {
|
||||
XtreamCluster::Live => &mut self.live,
|
||||
XtreamCluster::Video => &mut self.vods,
|
||||
XtreamCluster::Series => &mut self.series,
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn get(&self, cluster: XtreamCluster) -> &C {
|
||||
// match cluster {
|
||||
// XtreamCluster::Live => &self.live,
|
||||
// XtreamCluster::Video => &self.vods,
|
||||
// XtreamCluster::Series => &self.series,
|
||||
// }
|
||||
// }
|
||||
|
||||
pub fn set(&mut self, cluster: XtreamCluster, content: C) {
|
||||
match cluster {
|
||||
XtreamCluster::Live => self.live = content,
|
||||
XtreamCluster::Video => self.vods = content,
|
||||
XtreamCluster::Series => self.series = content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Default> PlaylistScratch<C> {
|
||||
pub fn take(&mut self, cluster: XtreamCluster) -> C {
|
||||
match cluster {
|
||||
XtreamCluster::Live => std::mem::take(&mut self.live),
|
||||
XtreamCluster::Video => std::mem::take(&mut self.vods),
|
||||
XtreamCluster::Series => std::mem::take(&mut self.series),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: IsEmpty> PlaylistScratch<C> {
|
||||
pub fn is_empty(&self, cluster: XtreamCluster) -> bool {
|
||||
match cluster {
|
||||
XtreamCluster::Live => self.live.is_empty(),
|
||||
XtreamCluster::Video => self.vods.is_empty(),
|
||||
XtreamCluster::Series => self.series.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
pub const FILE_EPG: &str = "epg.xml";
|
||||
pub(in crate::repository) const FILE_SUFFIX_DB: &str = "db";
|
||||
pub(in crate::repository) const FILE_SUFFIX_INDEX: &str = "idx";
|
||||
pub(in crate::repository) const FILE_ID_MAPPING: &str = "id_mapping.db";
|
||||
pub(in crate::repository) const FILE_STRM: &str = "strm";
|
||||
pub(in crate::repository) const FILE_M3U: &str = "m3u";
|
||||
|
||||
pub const FILE_SUFFIX_WAL: &str = "wal";
|
||||
pub const M3U_STREAM_PATH: &str = "m3u-stream";
|
||||
pub const M3U_RESOURCE_PATH: &str = "resource/m3u";
|
||||
pub const EPG_RESOURCE_PATH: &str = "resource/epg";
|
||||
@@ -18,14 +15,5 @@ pub(in crate::repository) const USER_LIVE_BOUQUET: &str = "live_bouquet.json";
|
||||
pub(in crate::repository) const USER_VOD_BOUQUET: &str = "vod_bouquet.json";
|
||||
pub(in crate::repository) const USER_SERIES_BOUQUET: &str = "series_bouquet.json";
|
||||
pub(in crate::repository) const API_USER_DB_FILE: &str = "api_user.db";
|
||||
|
||||
|
||||
pub(in crate::repository) const FILE_SERIES_INFO: &str = "series_info";
|
||||
pub(in crate::repository) const FILE_VOD_INFO: &str = "vod_info";
|
||||
pub(in crate::repository) const FILE_VOD_INFO_RECORD: &str = "vod_info_record";
|
||||
pub(in crate::repository) const FILE_SERIES_INFO_RECORD: &str = "series_info_record";
|
||||
pub(in crate::repository) const FILE_SERIES_EPISODE_RECORD: &str = "series_episode_record";
|
||||
// pub(in crate::repository) const FILE_SERIES: &str = "series";
|
||||
pub(in crate::repository) const PATH_XTREAM: &str = "xtream";
|
||||
pub(in crate::repository) const INFO_REWRITE_FIELDS: &[&str] = &["cover_big", "cover", "cover_tmdb", "movie_image", "tmdb_url", "overview", "kinopoisk_url"];
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// Import the new MediaQuality struct
|
||||
use crate::model::MediaQuality;
|
||||
use crate::model::XtreamSeriesEpisode;
|
||||
use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials};
|
||||
use crate::model::{ConfigTarget, StrmTargetOutput};
|
||||
use crate::repository::bplustree::BPlusTree;
|
||||
use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path};
|
||||
use crate::repository::storage::{ensure_target_storage_path};
|
||||
use crate::repository::storage_const;
|
||||
use crate::repository::xtream_repository::{xtream_get_record_file_path, InputVodInfoRecord};
|
||||
use crate::utils;
|
||||
use crate::utils::{async_file_reader, async_file_writer, normalize_string_path, truncate_filename, FileReadGuard, IO_BUFFER_SIZE};
|
||||
use crate::utils::{async_file_reader, async_file_writer, normalize_string_path, truncate_filename, IO_BUFFER_SIZE};
|
||||
use chrono::Datelike;
|
||||
use filetime::{set_file_times, FileTime};
|
||||
use log::{error, trace};
|
||||
@@ -16,7 +12,7 @@ use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use shared::error::{create_tuliprox_error_result, info_err};
|
||||
use shared::error::{TuliproxError, TuliproxErrorKind};
|
||||
use shared::model::{ClusterFlags, FieldGetAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType, StrmExportStyle, UUIDType};
|
||||
use shared::model::{ClusterFlags, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle, UUIDType};
|
||||
use shared::utils::{extract_extension_from_url, hash_bytes, hash_string_as_hex, truncate_string, ExportStyleConfig, CONSTANTS};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -24,15 +20,6 @@ use std::sync::Arc;
|
||||
use tokio::fs::{create_dir_all, remove_dir, remove_file, File};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::value::RawValue as JsonRawValue;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AdditionalProps<'a> {
|
||||
#[serde(borrow)]
|
||||
info: Option<&'a JsonRawValue>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn sanitize_for_filename(text: &str, underscore_whitespace: bool) -> String {
|
||||
@@ -92,39 +79,6 @@ fn extract_match(name: &str, pattern: &Regex) -> (String, Option<String>) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts a number from a pattern (e.g., "S01" -> 1), falling back to a default value.
|
||||
fn extract_season_or_episode_match_with_default(
|
||||
name: &str,
|
||||
pattern: &Regex,
|
||||
default_value: Option<&String>,
|
||||
) -> (String, Option<u32>) {
|
||||
let (new_name, value) = extract_match(name, pattern);
|
||||
let new_value = match value {
|
||||
// we skip the prefix S or E to parse the num
|
||||
Some(num_value) => num_value[1..].parse::<u32>().ok(),
|
||||
None => default_value.and_then(|val| val.parse::<u32>().ok()),
|
||||
};
|
||||
(new_name, new_value)
|
||||
}
|
||||
|
||||
// Extracts and removes an episode number (e.g., "E01") based on style patterns.
|
||||
fn style_rename_season(
|
||||
name: &str,
|
||||
style: &ExportStyleConfig,
|
||||
series_season: Option<&String>,
|
||||
) -> (String, Option<u32>) {
|
||||
extract_season_or_episode_match_with_default(name, &style.season, series_season)
|
||||
}
|
||||
|
||||
/// Renames episode numbers in media titles according to style patterns
|
||||
fn style_rename_episode(
|
||||
name: &str,
|
||||
style: &ExportStyleConfig,
|
||||
series_episode: Option<&String>,
|
||||
) -> (String, Option<u32>) {
|
||||
extract_season_or_episode_match_with_default(name, &style.episode, series_episode)
|
||||
}
|
||||
|
||||
/// Extracts and formats year information from media titles
|
||||
fn style_rename_year<'a>(
|
||||
name: &'a str,
|
||||
@@ -168,91 +122,6 @@ fn style_rename_year<'a>(
|
||||
(std::borrow::Cow::Owned(new_name), smallest_year)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum InputTmdbIndexTree {
|
||||
Video(BPlusTree<u32, InputVodInfoRecord>),
|
||||
Series(BPlusTree<u32, XtreamSeriesEpisode>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum InputTmdbIndexValue {
|
||||
Video(InputVodInfoRecord),
|
||||
Series(XtreamSeriesEpisode),
|
||||
}
|
||||
|
||||
type InputTmdbIndexMap = HashMap<String, Option<(FileReadGuard, InputTmdbIndexTree)>>;
|
||||
async fn get_tmdb_value(
|
||||
cfg: &AppConfig,
|
||||
provider_id: Option<u32>,
|
||||
input_name: &str,
|
||||
input_indexes: &mut InputTmdbIndexMap,
|
||||
item_type: PlaylistItemType,
|
||||
) -> Option<InputTmdbIndexValue> {
|
||||
// the tmdb_ids are stored inside record files for xtream input.
|
||||
// we load this record files on request for each input and item_type.
|
||||
let pid = provider_id?;
|
||||
match input_indexes.entry(input_name.to_string()) {
|
||||
std::collections::hash_map::Entry::Occupied(entry) => {
|
||||
if let Some((_, tree_value)) = entry.get() {
|
||||
match tree_value {
|
||||
InputTmdbIndexTree::Video(tree) => tree
|
||||
.query(&pid)
|
||||
.map(|vod_record| InputTmdbIndexValue::Video(vod_record.clone())),
|
||||
InputTmdbIndexTree::Series(tree) => tree
|
||||
.query(&pid)
|
||||
.map(|episode| InputTmdbIndexValue::Series(episode.clone())),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
if let Ok(Some(tmdb_path)) = get_input_storage_path(input_name, &cfg.config.load().working_dir)
|
||||
.map(|storage_path| xtream_get_record_file_path(&storage_path, item_type))
|
||||
{
|
||||
{
|
||||
let file_lock = cfg.file_locks.read_lock(&tmdb_path).await;
|
||||
match item_type {
|
||||
PlaylistItemType::Series
|
||||
| PlaylistItemType::LocalSeries => {
|
||||
if let Ok(tree) =
|
||||
BPlusTree::<u32, XtreamSeriesEpisode>::load(&tmdb_path)
|
||||
{
|
||||
let tmdb_id = tree.query(&pid).map(|episode| {
|
||||
InputTmdbIndexValue::Series(episode.clone())
|
||||
});
|
||||
entry.insert(Some((
|
||||
file_lock,
|
||||
InputTmdbIndexTree::Series(tree),
|
||||
)));
|
||||
return tmdb_id;
|
||||
}
|
||||
}
|
||||
PlaylistItemType::Video
|
||||
| PlaylistItemType::LocalVideo => {
|
||||
if let Ok(tree) =
|
||||
BPlusTree::<u32, InputVodInfoRecord>::load(&tmdb_path)
|
||||
{
|
||||
let tmdb_id = tree.query(&pid).map(|vod_record| {
|
||||
InputTmdbIndexValue::Video(vod_record.clone())
|
||||
});
|
||||
entry.insert(Some((
|
||||
file_lock,
|
||||
InputTmdbIndexTree::Video(tree),
|
||||
)));
|
||||
return tmdb_id;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
}
|
||||
entry.insert(None);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strm_get_file_paths(file_prefix: &str, target_path: &Path) -> PathBuf {
|
||||
target_path.join(PathBuf::from(format!("{file_prefix}_{}.{}", storage_const::FILE_STRM, storage_const::FILE_SUFFIX_DB)))
|
||||
}
|
||||
@@ -269,8 +138,8 @@ struct StrmItemInfo {
|
||||
url: String,
|
||||
series_name: Option<String>,
|
||||
release_date: Option<String>,
|
||||
season: Option<String>,
|
||||
episode: Option<String>,
|
||||
season: Option<u32>,
|
||||
episode: Option<u32>,
|
||||
added: Option<u64>,
|
||||
tmdb_id: Option<u32>,
|
||||
}
|
||||
@@ -290,35 +159,34 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo {
|
||||
let virtual_id = header.virtual_id;
|
||||
let input_name = header.input_name.clone();
|
||||
let url = header.url.clone();
|
||||
let (series_name, release_date, added, season, episode, tmdb_id) = match header.item_type {
|
||||
let (series_name, release_date, added, tmdb_id, season, episode) = match header.item_type {
|
||||
PlaylistItemType::Series
|
||||
| PlaylistItemType::LocalSeries => {
|
||||
let series_name = match header.get_field("name") {
|
||||
Some(name) if !name.is_empty() => Some(name.to_string()),
|
||||
_ => header.get_additional_property_as_str("series_name"),
|
||||
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),
|
||||
Some(props) => (
|
||||
props.get_release_date(),
|
||||
props.get_added(),
|
||||
props.get_tmdb_id().filter(|&id| id != 0),
|
||||
props.get_season(),
|
||||
props.get_episode(),
|
||||
)
|
||||
};
|
||||
let release_date = header
|
||||
.get_additional_property_as_str("series_release_date")
|
||||
.or_else(|| header.get_additional_property_as_str("release_date"));
|
||||
let season = header.get_additional_property_as_str("season");
|
||||
let episode = header.get_additional_property_as_str("episode");
|
||||
let added = header.get_additional_property_as_u64("added");
|
||||
let tmdb_id = header
|
||||
.get_additional_property_as_u32("tmdb_id")
|
||||
.filter(|&id| id != 0)
|
||||
.or_else(|| header.get_additional_property_as_u32("tmdb"));
|
||||
(series_name, release_date, added, season, episode, tmdb_id)
|
||||
(series_name, release_date, added, tmdb_id, season, episode)
|
||||
}
|
||||
PlaylistItemType::Video
|
||||
| PlaylistItemType::LocalVideo => {
|
||||
let name = header.get_field("name").map(|v| v.to_string());
|
||||
let release_date = header.get_additional_property_as_str("release_date");
|
||||
let added = header.get_additional_property_as_u64("added");
|
||||
let tmdb_id = header
|
||||
.get_additional_property_as_u32("tmdb_id")
|
||||
.filter(|&id| id != 0)
|
||||
.or_else(|| header.get_additional_property_as_u32("tmdb"));
|
||||
(name, release_date, added, None, None, tmdb_id)
|
||||
let name = Some(header.name.clone());
|
||||
let (release_date, added, tmdb_id) = match header.additional_properties.as_ref() {
|
||||
None => (None, None, None),
|
||||
Some(props) => (
|
||||
props.get_release_date(),
|
||||
props.get_added(),
|
||||
props.get_tmdb_id().filter(|&id| id != 0),
|
||||
)
|
||||
};
|
||||
(name, release_date, added, tmdb_id, None, None)
|
||||
}
|
||||
_ => (None, None, None, None, None, None),
|
||||
};
|
||||
@@ -331,10 +199,10 @@ fn extract_item_info(pli: &mut PlaylistItem) -> StrmItemInfo {
|
||||
input_name,
|
||||
url,
|
||||
series_name,
|
||||
release_date,
|
||||
release_date: release_date.as_deref().map(ToString::to_string),
|
||||
season,
|
||||
episode,
|
||||
added,
|
||||
added: added.as_ref().map_or_else(|| Some(0), |a| a.parse::<u64>().ok()),
|
||||
tmdb_id,
|
||||
}
|
||||
}
|
||||
@@ -478,10 +346,8 @@ fn format_for_kodi(
|
||||
|
||||
let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}");
|
||||
|
||||
let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref());
|
||||
let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref());
|
||||
let season_num = season.unwrap_or(1);
|
||||
let episode_num = episode.unwrap_or(1);
|
||||
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 season_folder = format!("Season{separator}{season_num:02}");
|
||||
@@ -542,10 +408,8 @@ fn format_for_plex(
|
||||
|
||||
let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}");
|
||||
|
||||
let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref());
|
||||
let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref());
|
||||
let season_num = season.unwrap_or(1);
|
||||
let episode_num = episode.unwrap_or(1);
|
||||
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}");
|
||||
@@ -609,10 +473,8 @@ fn format_for_emby(
|
||||
|
||||
let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}");
|
||||
|
||||
let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref());
|
||||
let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref());
|
||||
let season_num = season.unwrap_or(1);
|
||||
let episode_num = episode.unwrap_or(1);
|
||||
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}");
|
||||
@@ -674,10 +536,8 @@ fn format_for_jellyfin(
|
||||
|
||||
let series_folder_name = format!("{sanitized_series_name}{year_string}{id_string}");
|
||||
|
||||
let (_, season) = style_rename_season(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.season.as_ref());
|
||||
let (_, episode) = style_rename_episode(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.episode.as_ref());
|
||||
let season_num = season.unwrap_or(1);
|
||||
let episode_num = episode.unwrap_or(1);
|
||||
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}");
|
||||
@@ -699,29 +559,17 @@ fn format_for_jellyfin(
|
||||
|
||||
/// Generates style-compliant directory and file names by dispatching
|
||||
/// the call to a dedicated formatting function for the respective style.
|
||||
async fn style_based_rename(
|
||||
cfg: &AppConfig,
|
||||
fn style_based_rename(
|
||||
strm_item_info: &StrmItemInfo,
|
||||
input_tmdb_indexes: &mut InputTmdbIndexMap,
|
||||
style: &StrmExportStyle,
|
||||
tmdb: Option<u32>,
|
||||
style: StrmExportStyle,
|
||||
underscore_whitespace: bool,
|
||||
flat: bool,
|
||||
) -> (PathBuf, String) {
|
||||
let separator = if underscore_whitespace { "_" } else { " " };
|
||||
|
||||
let tmdb_id_val = get_tmdb_value(
|
||||
cfg,
|
||||
strm_item_info.provider_id,
|
||||
strm_item_info.input_name.as_str(),
|
||||
input_tmdb_indexes,
|
||||
strm_item_info.item_type,
|
||||
).await;
|
||||
|
||||
let tmdb_id = match tmdb_id_val {
|
||||
Some(InputTmdbIndexValue::Video(r)) if r.tmdb_id != 0 => r.tmdb_id,
|
||||
Some(InputTmdbIndexValue::Series(e)) if e.tmdb_id != 0 => e.tmdb_id,
|
||||
_ => strm_item_info.tmdb_id.unwrap_or(0),
|
||||
};
|
||||
let tmdb_id = tmdb.or(strm_item_info.tmdb_id).unwrap_or(0);
|
||||
|
||||
// Dispatch the call to the responsible function based on the style.
|
||||
match style {
|
||||
@@ -732,8 +580,8 @@ async fn style_based_rename(
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_strm_files(
|
||||
cfg: &AppConfig,
|
||||
fn prepare_strm_files(
|
||||
_app_config: &AppConfig,
|
||||
new_playlist: &mut [PlaylistGroup],
|
||||
_root_path: &Path,
|
||||
strm_target_output: &StrmTargetOutput,
|
||||
@@ -746,7 +594,6 @@ async fn prepare_strm_files(
|
||||
let mut all_filenames = HashSet::with_capacity(channel_count);
|
||||
// contains only collision filenames
|
||||
let mut collisions: HashSet<Arc<String>> = HashSet::new();
|
||||
let mut input_tmdb_indexes: InputTmdbIndexMap = HashMap::with_capacity(channel_count);
|
||||
let mut result = Vec::with_capacity(channel_count);
|
||||
|
||||
// first we create the names to identify name collisions
|
||||
@@ -755,38 +602,16 @@ async fn prepare_strm_files(
|
||||
let strm_item_info = extract_item_info(pli);
|
||||
|
||||
let (dir_path, strm_file_name) = style_based_rename(
|
||||
cfg,
|
||||
&strm_item_info,
|
||||
&mut input_tmdb_indexes,
|
||||
&strm_target_output.style,
|
||||
pli.get_tmdb_id(),
|
||||
strm_target_output.style,
|
||||
strm_target_output.underscore_whitespace,
|
||||
strm_target_output.flat,
|
||||
).await;
|
||||
);
|
||||
|
||||
// Conditionally generate the quality string based on the new config flag
|
||||
let separator = if strm_target_output.underscore_whitespace { "_" } else { " " };
|
||||
let quality_string = if strm_target_output.add_quality_to_filename {
|
||||
pli.header.additional_properties
|
||||
.as_ref()
|
||||
.and_then(|raw| {
|
||||
let props: AdditionalProps<'_> = serde_json::from_str(raw.get()).ok()?;
|
||||
let info = props.info?;
|
||||
serde_json::from_str::<serde_json::Value>(info.get()).ok()
|
||||
})
|
||||
.as_ref()
|
||||
.and_then(MediaQuality::from_ffprobe_info)
|
||||
.map_or_else(String::new, |quality| {
|
||||
let formatted = quality.format_for_filename(separator);
|
||||
if formatted.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
// Hard-coded separator for filename clarity.
|
||||
format!(" - [{formatted}]")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let quality_string = get_quality(strm_target_output, pli, separator);
|
||||
|
||||
let final_filename = format!("{strm_file_name}{quality_string}");
|
||||
let filename = Arc::new(final_filename);
|
||||
@@ -839,6 +664,32 @@ async fn prepare_strm_files(
|
||||
result
|
||||
}
|
||||
|
||||
fn get_quality(strm_target_output: &StrmTargetOutput, pli: &PlaylistItem, separator: &str) -> String {
|
||||
if strm_target_output.add_quality_to_filename {
|
||||
let (audio, video) = match pli.header.additional_properties.as_ref() {
|
||||
None => (None, None),
|
||||
Some(props) => {
|
||||
match props {
|
||||
StreamProperties::Live(_)
|
||||
| StreamProperties::Series(_) => (None, None),
|
||||
StreamProperties::Video(video) =>
|
||||
video.details.as_ref().map_or_else(|| (None, None), |d| (d.audio.as_deref(), d.video.as_deref())),
|
||||
StreamProperties::Episode(episode) =>
|
||||
(episode.audio.as_deref(), episode.video.as_deref())
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(media_quality) = MediaQuality::from_ffprobe_info(audio, video) {
|
||||
let formatted = media_quality.format_for_filename(separator);
|
||||
if !formatted.is_empty() {
|
||||
// Hard-coded separator for filename clarity.
|
||||
return format!(" - [{formatted}]")
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub async fn write_strm_playlist(
|
||||
app_config: &AppConfig,
|
||||
target: &ConfigTarget,
|
||||
@@ -850,7 +701,7 @@ pub async fn write_strm_playlist(
|
||||
}
|
||||
|
||||
let config = app_config.config.load();
|
||||
let Some(root_path) = utils::get_file_path(
|
||||
let Some(root_path) = crate::utils::get_file_path(
|
||||
&config.working_dir,
|
||||
Some(std::path::PathBuf::from(&target_output.directory)),
|
||||
) else {
|
||||
@@ -860,7 +711,7 @@ pub async fn write_strm_playlist(
|
||||
)));
|
||||
};
|
||||
|
||||
let user_and_server_info = get_credentials_and_server_info(app_config, target_output.username.as_ref());
|
||||
let user_and_server_info = get_credentials_and_server_info(app_config, target_output.username.as_deref());
|
||||
let normalized_dir = normalize_string_path(&target_output.directory);
|
||||
let strm_file_prefix = hash_string_as_hex(&normalized_dir);
|
||||
let strm_index_path =
|
||||
@@ -886,7 +737,7 @@ pub async fn write_strm_playlist(
|
||||
new_playlist,
|
||||
&root_path,
|
||||
target_output,
|
||||
).await;
|
||||
);
|
||||
for strm_file in strm_files {
|
||||
// file paths
|
||||
let output_path = truncate_filename(&root_path.join(&strm_file.dir_path), 255);
|
||||
@@ -1044,7 +895,7 @@ async fn has_strm_file_same_hash(file_path: &PathBuf, content_hash: UUIDType) ->
|
||||
|
||||
fn get_credentials_and_server_info(
|
||||
cfg: &AppConfig,
|
||||
username: Option<&String>,
|
||||
username: Option<&str>,
|
||||
) -> Option<(ProxyUserCredentials, ApiProxyServerInfo)> {
|
||||
let username = username?;
|
||||
let credentials = cfg.get_user_credentials(username)?;
|
||||
|
||||
@@ -494,7 +494,6 @@ mod tests {
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
library_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
}))),
|
||||
file_locks: Arc::new(FileLockManager::default()),
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
use crate::model::XtreamMappingOptions;
|
||||
use crate::model::{xtream_playlistitem_to_document, AppConfig, ProxyUserCredentials};
|
||||
use crate::model::{xtream_mapping_option_from_target_options, AppConfig, ProxyUserCredentials};
|
||||
use crate::model::ConfigTarget;
|
||||
use crate::repository::indexed_document::IndexedDocumentIterator;
|
||||
use crate::repository::bplustree::{BPlusTreeDiskIteratorOwned, BPlusTreeQuery};
|
||||
use crate::repository::user_repository::user_get_bouquet_filter;
|
||||
use crate::repository::xtream_repository::{xtream_get_file_paths, xtream_get_storage_path};
|
||||
use crate::repository::xtream_repository::{xtream_get_file_path, xtream_get_storage_path};
|
||||
use crate::utils::FileReadGuard;
|
||||
use log::error;
|
||||
use serde_json::Value;
|
||||
use shared::error::info_err;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem};
|
||||
use shared::model::{PlaylistItemType, TargetType, XtreamCluster, XtreamMappingOptions, XtreamPlaylistItem};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct XtreamPlaylistIterator {
|
||||
reader: IndexedDocumentIterator<u32, XtreamPlaylistItem>,
|
||||
reader: BPlusTreeDiskIteratorOwned<u32, XtreamPlaylistItem>,
|
||||
options: XtreamMappingOptions,
|
||||
cluster: XtreamCluster,
|
||||
// Use parsed numeric filter to avoid per-item String allocations (no to_string per check)
|
||||
filter_ids: Option<HashSet<u32>>,
|
||||
base_url: String,
|
||||
user: ProxyUserCredentials,
|
||||
lookup_item: Option<(XtreamPlaylistItem, bool)>, // this is for filtered iteration
|
||||
_file_lock: FileReadGuard,
|
||||
}
|
||||
@@ -38,17 +34,18 @@ impl XtreamPlaylistIterator {
|
||||
let xtream_output = target.get_xtream_output().ok_or_else(|| info_err!(format!("Unexpected: xtream output required for target {}", target.name)))?;
|
||||
let config = app_config.config.load();
|
||||
if let Some(storage_path) = xtream_get_storage_path(&config, target.name.as_str()) {
|
||||
let (xtream_path, idx_path) = xtream_get_file_paths(&storage_path, cluster);
|
||||
if !xtream_path.exists() || !idx_path.exists() {
|
||||
let xtream_path = xtream_get_file_path(&storage_path, cluster);
|
||||
if !xtream_path.exists() {
|
||||
return Err(info_err!(format!("No {cluster} entries found for target {}", &target.name)));
|
||||
}
|
||||
let file_lock = app_config.file_locks.read_lock(&xtream_path).await;
|
||||
|
||||
let reader = IndexedDocumentIterator::<u32, XtreamPlaylistItem>::new(&xtream_path, &idx_path)
|
||||
.map_err(|err| info_err!(format!("Could not deserialize file {xtream_path:?} - {err}")))?;
|
||||
let query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)
|
||||
.map_err(|err| info_err!(format!("Could not open BPlusTreeQuery {xtream_path:?} - {err}")))?;
|
||||
let reader = query.disk_iter();
|
||||
|
||||
let options = XtreamMappingOptions::from_target_options(target, xtream_output, app_config);
|
||||
let server_info = app_config.get_user_server_info(user);
|
||||
let options = xtream_mapping_option_from_target_options(target, xtream_output, app_config, user, Some(server_info.get_base_url().as_str()));
|
||||
|
||||
let filter = user_get_bouquet_filter(&config, &user.username, category_id, TargetType::Xtream, cluster).await;
|
||||
// Parse bouquet filter (strings) once into u32 set to minimize per-item allocations
|
||||
@@ -67,8 +64,6 @@ impl XtreamPlaylistIterator {
|
||||
cluster,
|
||||
filter_ids,
|
||||
_file_lock: file_lock,
|
||||
base_url: server_info.get_base_url(),
|
||||
user: user.clone(),
|
||||
lookup_item: None,
|
||||
})
|
||||
} else {
|
||||
@@ -77,11 +72,11 @@ impl XtreamPlaylistIterator {
|
||||
}
|
||||
|
||||
fn matches_filters(cluster: XtreamCluster, filter_ids: Option<&HashSet<u32>>, item: &XtreamPlaylistItem) -> bool {
|
||||
|
||||
// We can't serve episodes within series
|
||||
if cluster == XtreamCluster::Series
|
||||
&& !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// category_id-Filter
|
||||
if let Some(set) = filter_ids {
|
||||
@@ -94,28 +89,25 @@ impl XtreamPlaylistIterator {
|
||||
}
|
||||
|
||||
fn get_next(&mut self) -> Option<(XtreamPlaylistItem, bool)> {
|
||||
if self.reader.has_error() {
|
||||
error!("Could not deserialize xtream item: {}", self.reader.get_path().display());
|
||||
return None;
|
||||
}
|
||||
// reader no longer has manual error state, BPlusTreeQuery handles it via Result elsewhere
|
||||
|
||||
let filter_ids = self.filter_ids.as_ref();
|
||||
let cluster = self.cluster;
|
||||
|
||||
let predicate = |(item, _): &(XtreamPlaylistItem, bool)| {
|
||||
let predicate = |(_, item): &(u32, XtreamPlaylistItem)| {
|
||||
Self::matches_filters(cluster, filter_ids, item)
|
||||
};
|
||||
|
||||
if self.cluster == XtreamCluster::Series || self.filter_ids.is_some() {
|
||||
if let Some((current_item, _)) = self.lookup_item.take() {
|
||||
let next_valid = self.reader.find(predicate);
|
||||
self.lookup_item = next_valid;
|
||||
self.lookup_item = next_valid.map(|(_, item)| (item, true));
|
||||
let has_next = self.lookup_item.is_some();
|
||||
Some((current_item, has_next))
|
||||
} else {
|
||||
let current_item = self.reader.find(predicate);
|
||||
if let Some((item, _)) = current_item {
|
||||
self.lookup_item = self.reader.find(predicate);
|
||||
if let Some((_, item)) = current_item {
|
||||
self.lookup_item = self.reader.find(predicate).map(|(_, item)| (item, true));
|
||||
let has_next = self.lookup_item.is_some();
|
||||
Some((item, has_next))
|
||||
} else {
|
||||
@@ -123,7 +115,7 @@ impl XtreamPlaylistIterator {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.reader.next()
|
||||
self.reader.next().map(|(_, item)| (item, !self.reader.is_empty()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,14 +146,10 @@ impl XtreamPlaylistJsonIterator {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_doc(pli: &XtreamPlaylistItem, url: &str, options: &XtreamMappingOptions, user: &ProxyUserCredentials) -> Value {
|
||||
xtream_playlistitem_to_document(pli, url, options, user)
|
||||
}
|
||||
|
||||
impl Iterator for XtreamPlaylistJsonIterator {
|
||||
type Item = (String, bool);
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.get_next().map(|(pli, has_next)| (to_doc(&pli, &self.inner.base_url, &self.inner.options, &self.inner.user).to_string(), has_next))
|
||||
self.inner.get_next().map(|(pli, has_next)| (pli.to_document(&self.inner.options).to_string(), has_next))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
use crate::utils::{traverse_dir};
|
||||
use shared::utils::{hash_string_as_hex, human_readable_byte_size, sanitize_sensitive_info};
|
||||
use crate::utils::{decode_base64_string, encode_base64_hash, encode_base64_string, traverse_dir};
|
||||
use shared::utils::{human_readable_byte_size, sanitize_sensitive_info};
|
||||
use log::{debug, error, info, trace};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use crate::utils::{trace_if_enabled};
|
||||
|
||||
#[inline]
|
||||
fn encode_cache_key(key: &str) -> String {
|
||||
encode_base64_hash(key)
|
||||
}
|
||||
|
||||
/// `LRUResourceCache`
|
||||
///
|
||||
/// A least-recently-used (LRU) file-based resource cache that stores files in a directory on disk,
|
||||
@@ -22,7 +27,7 @@ pub struct LRUResourceCache {
|
||||
capacity: usize, // Maximum size in bytes
|
||||
cache_dir: PathBuf,
|
||||
current_size: usize, // Current size in bytes
|
||||
cache: HashMap<String, (PathBuf, usize)>,
|
||||
cache: HashMap<String, (PathBuf, Option<String>, usize)>,
|
||||
usage_order: VecDeque<String>,
|
||||
}
|
||||
|
||||
@@ -33,11 +38,13 @@ impl LRUResourceCache {
|
||||
/// - `cache_dir`: The directory path where cached files are stored.
|
||||
///
|
||||
pub fn new(capacity: usize, cache_dir: &str) -> Self {
|
||||
// Estimate: assume average file size of 256KB
|
||||
let estimated_entries = (capacity / (256 * 1024)).clamp(64, 16384);
|
||||
Self {
|
||||
capacity,
|
||||
cache_dir: PathBuf::from(cache_dir),
|
||||
current_size: 0,
|
||||
cache: HashMap::<String, (PathBuf, usize)>::new(),
|
||||
cache: HashMap::<String, (PathBuf, Option<String>, usize)>::with_capacity(estimated_entries),
|
||||
usage_order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
@@ -53,15 +60,22 @@ impl LRUResourceCache {
|
||||
pub fn scan(&mut self) -> std::io::Result<()> {
|
||||
let mut visit = |entry: &std::fs::DirEntry, metadata: &std::fs::Metadata| {
|
||||
let path = entry.path();
|
||||
if let Some(file_name) = path.file_name() {
|
||||
let key = String::from(file_name.to_string_lossy());
|
||||
if let Some(os_file_name) = path.file_name() {
|
||||
let file_name = String::from(os_file_name.to_string_lossy());
|
||||
let (key, mime_type) = if let Some((part1, part2)) = file_name.split_once('.') {
|
||||
(part1.to_string(), String::from_utf8(decode_base64_string(part2)).ok())
|
||||
} else {
|
||||
(file_name.clone(), None)
|
||||
};
|
||||
|
||||
let file_size = usize::try_from(metadata.len()).unwrap_or(0);
|
||||
// we need to duplicate because of closure we cant call insert_to_cache
|
||||
{ // insert_to_cache
|
||||
|
||||
let mut path = self.cache_dir.clone();
|
||||
path.push(&key);
|
||||
path.push(&file_name);
|
||||
trace!("Added file to cache: {}", &path.to_string_lossy());
|
||||
self.cache.insert(key.clone(), (path.clone(), file_size));
|
||||
self.cache.insert(key.clone(), (path.clone(), mime_type, file_size));
|
||||
self.usage_order.push_back(key);
|
||||
self.current_size += file_size;
|
||||
}
|
||||
@@ -83,55 +97,67 @@ impl LRUResourceCache {
|
||||
/// - `file_size`: The size of the file in bytes.
|
||||
/// - Returns:
|
||||
/// - The `PathBuf` where the file is stored.
|
||||
pub fn add_content(&mut self, url: &str, file_size: usize) -> std::io::Result<PathBuf> {
|
||||
let key = hash_string_as_hex(url);
|
||||
let path = self.insert_to_cache(key, file_size);
|
||||
pub fn add_content(&mut self, url: &str, mime_type: Option<String>, file_size: usize) -> std::io::Result<PathBuf> {
|
||||
let key = encode_cache_key(url);
|
||||
let path = self.insert_to_cache(key, mime_type, file_size);
|
||||
if self.current_size > self.capacity {
|
||||
self.evict_if_needed();
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn insert_to_cache(&mut self, key: String, file_size: usize) -> PathBuf {
|
||||
let mut path = self.cache_dir.clone();
|
||||
path.push(&key);
|
||||
fn insert_to_cache(&mut self, key: String, mime_type: Option<String>, file_size: usize) -> PathBuf {
|
||||
let path = self.get_store_path(&key, mime_type.as_deref());
|
||||
debug!("Added file to cache: {}", &path.to_string_lossy());
|
||||
self.cache.insert(key.clone(), (path.clone(), file_size));
|
||||
self.cache.insert(key.clone(), (path.clone(), mime_type, file_size));
|
||||
self.usage_order.push_back(key);
|
||||
self.current_size += file_size;
|
||||
path
|
||||
}
|
||||
|
||||
pub fn store_path(&self, url: &str) -> PathBuf {
|
||||
let key = hash_string_as_hex(url);
|
||||
pub fn store_path(&self, url: &str, mime_type: Option<&str>) -> PathBuf {
|
||||
self.get_store_path(&encode_cache_key(url), mime_type)
|
||||
}
|
||||
|
||||
fn get_store_path(&self, cache_key: &str, mime_type: Option<&str>) -> PathBuf {
|
||||
let key = if let Some(mime) = mime_type {
|
||||
format!("{cache_key}.{}", encode_base64_string(mime.as_bytes()))
|
||||
} else {
|
||||
cache_key.to_string()
|
||||
};
|
||||
let mut path = self.cache_dir.clone();
|
||||
path.push(&key);
|
||||
path
|
||||
}
|
||||
|
||||
|
||||
/// - Retrieves a file from the cache if it exists.
|
||||
/// - Moves the file's key to the end of the usage queue to mark it as recently used.
|
||||
/// - Arguments:
|
||||
/// - `url`: The unique identifier for the file.
|
||||
/// - Returns:
|
||||
/// - The `PathBuf` of the file if it exists; `None` otherwise.
|
||||
pub fn get_content(&mut self, url: &str) -> Option<PathBuf> {
|
||||
let key = hash_string_as_hex(url);
|
||||
pub fn get_content(&mut self, url: &str) -> Option<(PathBuf, Option<String>)> {
|
||||
let key = encode_cache_key(url);
|
||||
{
|
||||
if let Some((path, size)) = self.cache.get(&key) {
|
||||
if let Some((path, mime_type, size)) = self.cache.get(&key) {
|
||||
if path.exists() {
|
||||
trace_if_enabled!("Responding resource from cache with key: {key} for url: {}", sanitize_sensitive_info(url));
|
||||
// Move to the end of the queue
|
||||
self.usage_order.retain(|k| k != &key); // remove from queue
|
||||
if let Some(pos) = self.usage_order.iter().position(|k| k == &key) {
|
||||
self.usage_order.remove(pos); // remove from queue
|
||||
}
|
||||
self.usage_order.push_back(key); // add to the to end
|
||||
return Some(path.clone());
|
||||
return Some((path.clone(), mime_type.clone()));
|
||||
}
|
||||
{
|
||||
trace_if_enabled!("Cache inconsistency: file missing for key: {key}, url: {}", sanitize_sensitive_info(url));
|
||||
// this should not happen, someone deleted the file manually and the cache is not in sync
|
||||
self.current_size -= size;
|
||||
self.cache.remove(&key);
|
||||
self.usage_order.retain(|k| k != &key);
|
||||
if let Some(pos) = self.usage_order.iter().position(|k| k == &key) {
|
||||
self.usage_order.remove(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +168,7 @@ impl LRUResourceCache {
|
||||
// if the cache size is to small and one element exceeds the size than the cache won't work, we ignore this
|
||||
while self.current_size > self.capacity {
|
||||
if let Some(oldest_file) = self.usage_order.pop_front() {
|
||||
if let Some((file, size)) = self.cache.remove(&oldest_file) {
|
||||
if let Some((file, _mime_type, size)) = self.cache.remove(&oldest_file) {
|
||||
self.current_size -= size;
|
||||
if let Err(err) = fs::remove_file(&file) {
|
||||
error!("Failed to delete cached file {} {err}", file.to_string_lossy());
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::io;
|
||||
use log::error;
|
||||
use shared::error::to_io_error;
|
||||
|
||||
#[inline]
|
||||
pub fn binary_serialize<T>(value: &T) -> io::Result<Vec<u8>>
|
||||
where
|
||||
T: ?Sized + serde::Serialize,
|
||||
{
|
||||
rmp_serde::to_vec(value).map_err(to_io_error)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn binary_deserialize<T>(value: &[u8]) -> io::Result<T>
|
||||
where
|
||||
T: for<'a> serde::Deserialize<'a>,
|
||||
{
|
||||
rmp_serde::from_slice(value).map_err(|e| {
|
||||
error!("Failed to decode {e}");
|
||||
to_io_error(e)
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use std::io;
|
||||
use log::error;
|
||||
use shared::error::to_io_error;
|
||||
|
||||
#[inline]
|
||||
pub fn bincode_serialize<T>(value: &T) -> io::Result<Vec<u8>>
|
||||
where
|
||||
T: ?Sized + serde::Serialize,
|
||||
{
|
||||
bincode::serde::encode_to_vec(value, bincode::config::legacy()).map_err(to_io_error)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn bincode_deserialize<T>(value: &[u8]) -> io::Result<T>
|
||||
where
|
||||
T: for<'a> serde::Deserialize<'a>,
|
||||
{
|
||||
match bincode::serde::decode_from_slice(value, bincode::config::legacy()) {
|
||||
Ok((instance, _size)) => Ok(instance),
|
||||
Err(e) => {
|
||||
error!("Failed to decode {e}");
|
||||
Err(to_io_error(e))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,16 @@ use openssl::symm::{Cipher, Crypter, Mode};
|
||||
use rand::{RngCore, rngs::OsRng, TryRngCore};
|
||||
use shared::error::{TuliproxError, TuliproxErrorKind};
|
||||
|
||||
fn encode_base64_string(input: &[u8]) -> String {
|
||||
pub fn encode_base64_hash(text: &str) -> String {
|
||||
let hash = blake3::hash(text.as_bytes());
|
||||
encode_base64_string(hash.as_bytes())
|
||||
}
|
||||
|
||||
pub fn encode_base64_string(input: &[u8]) -> String {
|
||||
general_purpose::URL_SAFE_NO_PAD.encode(input)
|
||||
}
|
||||
|
||||
fn decode_base64_string(input: &str) -> Vec<u8> {
|
||||
pub fn decode_base64_string(input: &str) -> Vec<u8> {
|
||||
general_purpose::URL_SAFE_NO_PAD.decode(input).unwrap_or_else(|_| input.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::repository::bplustree::{BPlusTreeDiskIterator, BPlusTreeQuery};
|
||||
use env_logger::{Builder, Target};
|
||||
use log::{error, LevelFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::model::{M3uPlaylistItem, XtreamPlaylistItem};
|
||||
use std::io::Write;
|
||||
use std::path::{PathBuf};
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
enum DbType {
|
||||
Xtream,
|
||||
M3u,
|
||||
}
|
||||
|
||||
pub fn db_viewer(xtream_filename: Option<&str>, m3u_filename: Option<&str>) {
|
||||
let mut any_processed = false;
|
||||
if let Some(filename) = xtream_filename {
|
||||
any_processed |= dump_db(filename, DbType::Xtream);
|
||||
}
|
||||
if let Some(filename) = m3u_filename {
|
||||
any_processed |= dump_db(filename, DbType::M3u);
|
||||
}
|
||||
exit_app(if any_processed { 0 } else { 1 });
|
||||
}
|
||||
|
||||
fn dump_db(filename: &str, db_type: DbType) -> bool {
|
||||
let mut log_builder = Builder::from_default_env();
|
||||
log_builder.target(Target::Stderr);
|
||||
log_builder.filter_level(LevelFilter::Info);
|
||||
let _ = log_builder.try_init();
|
||||
|
||||
match PathBuf::from(filename).canonicalize() {
|
||||
Ok(path) => {
|
||||
match db_type {
|
||||
DbType::Xtream => {
|
||||
if let Ok(mut query) = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&path) {
|
||||
let iterator = query.iter();
|
||||
return print_json_from_iter(iterator);
|
||||
}
|
||||
}
|
||||
DbType::M3u => {
|
||||
if let Ok(mut query) = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&path) {
|
||||
let iterator = query.iter();
|
||||
return print_json_from_iter(iterator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Invalid file path! {err}");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn print_json_from_iter<P>(iterator: BPlusTreeDiskIterator<u32, P>) -> bool
|
||||
where
|
||||
P: Serialize + for<'de> Deserialize<'de> + Clone,
|
||||
{
|
||||
let mut error_count = 0;
|
||||
|
||||
println!("[");
|
||||
let mut first = true;
|
||||
for (_, entry) in iterator {
|
||||
match serde_json::to_string(&entry) {
|
||||
Ok(text) => {
|
||||
if !first {
|
||||
println!(",");
|
||||
}
|
||||
println!("{text}");
|
||||
first = false;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed: {err}");
|
||||
error_count += 1;
|
||||
},
|
||||
}
|
||||
}
|
||||
println!("]");
|
||||
|
||||
if error_count > 0 { false } else { true }
|
||||
}
|
||||
|
||||
fn exit_app(code: i32) {
|
||||
let _ = std::io::stdout().flush();
|
||||
let _ = std::io::stderr().flush();
|
||||
std::process::exit(code);
|
||||
}
|
||||
@@ -351,9 +351,25 @@ pub fn cleanup_unlisted_files_with_suffix(
|
||||
for entry in (fs::read_dir(dir)?).flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && !keep_set.contains(&path) && path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|name| name.ends_with(suffix)) && fs::remove_file(&path).is_ok() {
|
||||
if !path.is_file() || keep_set.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let delete = {
|
||||
let zero_size = entry
|
||||
.metadata()
|
||||
.map(|m| m.len() == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let suffix_match = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|name| name.ends_with(suffix));
|
||||
|
||||
zero_size || suffix_match
|
||||
};
|
||||
|
||||
if delete && fs::remove_file(&path).is_ok() {
|
||||
debug!("Deleted {:?}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::repository::bplustree::BPlusTree;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -9,7 +8,6 @@ fn ipv4_to_u32(ip: &str) -> Option<u32> {
|
||||
ip.parse::<Ipv4Addr>().ok().map(u32::from)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GeoIp {
|
||||
tree: BPlusTree<u32, (u32, String)>,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ fn get_log_level(log_level: &str) -> LevelFilter {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_logger(user_log_level: Option<&String>, config_file: &str) {
|
||||
pub fn init_logger(user_log_level: Option<&str>, config_file: &str) {
|
||||
|
||||
|
||||
// tracing_subscriber::registry()
|
||||
@@ -74,6 +74,6 @@ pub fn init_logger(user_log_level: Option<&String>, config_file: &str) {
|
||||
for module in LOG_ERROR_LEVEL_MOD {
|
||||
log_builder.filter_module(module, LevelFilter::Error);
|
||||
}
|
||||
log_builder.init();
|
||||
let _ = log_builder.try_init();
|
||||
info!("Log Level {}", &log_levels.join(", "));
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@ mod step_measure;
|
||||
mod logging;
|
||||
mod trakt;
|
||||
mod json_utils;
|
||||
mod bincode_utils;
|
||||
mod binary_utils;
|
||||
mod telegram;
|
||||
mod geoip;
|
||||
mod db_viewer;
|
||||
|
||||
pub use self::bincode_utils::*;
|
||||
pub use self::binary_utils::*;
|
||||
pub use self::logging::*;
|
||||
pub use self::trakt::*;
|
||||
pub use self::telegram::*;
|
||||
pub use self::geoip::*;
|
||||
pub use self::db_viewer::*;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! debug_if_enabled {
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::processing::parser::m3u;
|
||||
use crate::utils::prepare_file_path;
|
||||
use crate::utils::request;
|
||||
|
||||
pub async fn get_m3u_playlist(client: &reqwest::Client, cfg: &Arc<Config>, input: &Arc<ConfigInput>) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
pub async fn download_m3u_playlist(client: &reqwest::Client, cfg: &Arc<Config>, input: &Arc<ConfigInput>) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
let working_dir = &cfg.working_dir;
|
||||
let input_source: InputSource = {
|
||||
match input.staged.as_ref() {
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn content_type_from_ext(ext: &str) -> &'static str {
|
||||
"avi" => "video/x-msvideo",
|
||||
"mov" => "video/quicktime",
|
||||
"webm" => "video/webm",
|
||||
"ts" => "video/mp2t",
|
||||
"ts" => "video/mp2t",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,6 @@ pub async fn get_input_epg_content_as_file(client: &reqwest::Client, input: &Con
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn get_input_text_content(client: &reqwest::Client, input: &InputSource, working_dir: &str, persist_filepath: Option<PathBuf>) -> Result<String, TuliproxError> {
|
||||
debug_if_enabled!("getting input text content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(&input.url));
|
||||
|
||||
@@ -415,12 +414,9 @@ pub async fn get_remote_content_as_stream(
|
||||
}
|
||||
|
||||
async fn get_remote_content(client: &reqwest::Client, input: &InputSource, headers: Option<&HeaderMap>, url: &Url, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> Result<(String, String), Error> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
let (mut stream, response_url) = get_remote_content_as_stream(client, input, headers, url, disabled_headers).await.map_err(|e| str_to_io_error(&format!("Failed to read content: {e}")))?;
|
||||
let mut content = String::new();
|
||||
stream.read_to_string(&mut content).await.map_err(|e| str_to_io_error(&format!("Failed to read content: {e}")))?;
|
||||
debug_if_enabled!("Request took: {} {}", format_elapsed_time(start_time.elapsed().as_secs()), sanitize_sensitive_info(url.as_str()));
|
||||
Ok((content, response_url))
|
||||
}
|
||||
|
||||
@@ -456,7 +452,8 @@ pub async fn download_text_content(
|
||||
headers: Option<&HeaderMap>,
|
||||
persist_filepath: Option<PathBuf>,
|
||||
) -> Result<(String, String), Error> {
|
||||
if let Ok(url) = input.url.parse::<url::Url>() {
|
||||
let start_time = Instant::now();
|
||||
let result = if let Ok(url) = input.url.parse::<url::Url>() {
|
||||
let result = if url.scheme() == "file" {
|
||||
match url.to_file_path() {
|
||||
Ok(file_path) => get_local_file_content(&file_path).await.map(|c| (c, url.to_string())),
|
||||
@@ -479,7 +476,15 @@ pub async fn download_text_content(
|
||||
}
|
||||
} else {
|
||||
Err(str_to_io_error(&format!("Malformed URL {}", sanitize_sensitive_info(&input.url))))
|
||||
};
|
||||
|
||||
if log_enabled!(log::Level::Debug) {
|
||||
if let Ok((_content, response_url)) = result.as_ref() {
|
||||
debug!("Request took: {} {}", format_elapsed_time(start_time.elapsed().as_secs()), sanitize_sensitive_info(response_url.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn download_text_content_as_stream(
|
||||
@@ -520,7 +525,7 @@ pub async fn download_text_content_as_stream(
|
||||
}
|
||||
|
||||
async fn download_json_content(client: &reqwest::Client, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option<PathBuf>) -> Result<serde_json::Value, Error> {
|
||||
debug_if_enabled!("downloading json content from {}", sanitize_sensitive_info(&input.url));
|
||||
debug_if_enabled!("Downloading json content from {}", sanitize_sensitive_info(&input.url));
|
||||
match download_text_content(client, disabled_headers, input, None, persist_filepath).await {
|
||||
Ok((content, _response_url)) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
@@ -540,7 +545,7 @@ pub async fn get_input_json_content(client: &reqwest::Client, disabled_headers:
|
||||
}
|
||||
|
||||
async fn download_json_content_as_stream(client: &reqwest::Client, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option<PathBuf>) -> Result<DynReader, Error> {
|
||||
debug_if_enabled!("downloading json content from {}", sanitize_sensitive_info(&input.url));
|
||||
debug_if_enabled!("Downloading json content from {}", sanitize_sensitive_info(&input.url));
|
||||
match download_text_content_as_stream(client, disabled_headers, input, None, persist_filepath).await {
|
||||
Ok((reader, _response_url)) => Ok(reader),
|
||||
Err(err) => Err(err)
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::messaging::send_message;
|
||||
use crate::model::{is_input_expired, Config, ConfigInput, ConfigTarget, XtreamLoginInfo};
|
||||
use crate::model::{is_input_expired, xtream_mapping_option_from_target_options, Config, ConfigInput, ConfigTarget, XtreamLoginInfo, XtreamTargetOutput};
|
||||
use crate::model::{InputSource, ProxyUserCredentials};
|
||||
use crate::processing::parser::xtream;
|
||||
use crate::repository::xtream_repository;
|
||||
use crate::repository::xtream_repository::{rewrite_xtream_series_info_content, rewrite_xtream_vod_info_content, xtream_get_input_info};
|
||||
use crate::processing::parser::xtream::parse_xtream_series_info;
|
||||
use crate::repository::playlist_repository::{get_target_id_mapping, rewrite_provider_series_info_episode_virtual_id, ProviderEpisodeKey};
|
||||
use crate::repository::storage::{get_input_storage_path, get_target_storage_path};
|
||||
use crate::repository::target_id_mapping::VirtualIdRecord;
|
||||
use crate::repository::xtream_repository::{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::{error, info, warn};
|
||||
use shared::error::{str_to_io_error, TuliproxError};
|
||||
use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, XtreamCluster, XtreamPlaylistItem};
|
||||
use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value};
|
||||
use shared::error::{str_to_io_error, to_io_error, TuliproxError};
|
||||
use shared::model::{MsgKind, PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties,
|
||||
StreamProperties, VideoStreamProperties, XtreamCluster,
|
||||
XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo};
|
||||
use shared::utils::{extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Error;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -53,64 +59,176 @@ pub async fn get_xtream_stream_info_content(client: &reqwest::Client, input: &In
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn get_xtream_stream_info<P>(client: &reqwest::Client,
|
||||
app_state: &Arc<AppState>,
|
||||
user: &ProxyUserCredentials,
|
||||
input: &ConfigInput,
|
||||
target: &ConfigTarget,
|
||||
pli: &P,
|
||||
info_url: &str,
|
||||
cluster: XtreamCluster) -> Result<String, Error>
|
||||
where
|
||||
P: PlaylistEntry,
|
||||
{
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub async fn get_xtream_stream_info(client: &reqwest::Client,
|
||||
app_state: &Arc<AppState>,
|
||||
user: &ProxyUserCredentials,
|
||||
input: &ConfigInput,
|
||||
target: &ConfigTarget,
|
||||
pli: &XtreamPlaylistItem,
|
||||
info_url: &str,
|
||||
cluster: XtreamCluster) -> Result<String, Error> {
|
||||
let xtream_output = target.get_xtream_output().ok_or_else(|| Error::other("Unexpected error, missing xtream output"))?;
|
||||
|
||||
let app_config = &app_state.app_config;
|
||||
let server_info = app_config.get_user_server_info(user);
|
||||
let options = xtream_mapping_option_from_target_options(target, xtream_output, app_config, user, Some(server_info.get_base_url().as_str()));
|
||||
|
||||
if cluster == XtreamCluster::Series {
|
||||
if let Some(content) = xtream_repository::xtream_load_series_info(app_config, target.name.as_str(), pli.get_virtual_id()).await {
|
||||
// Deliver existing target content
|
||||
return rewrite_xtream_series_info_content(app_state, target, xtream_output, pli, user, &content).await;
|
||||
}
|
||||
|
||||
// Check if the content has been resolved
|
||||
if xtream_output.resolve_series {
|
||||
if let Some(provider_id) = pli.get_provider_id() {
|
||||
if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Series).await {
|
||||
return xtream_repository::write_and_get_xtream_series_info(app_state, target, xtream_output, pli, user, &content).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if cluster == XtreamCluster::Video {
|
||||
if let Some(content) = xtream_repository::xtream_load_vod_info(app_config, target.name.as_str(), pli.get_virtual_id()).await {
|
||||
// Deliver existing target content
|
||||
return rewrite_xtream_vod_info_content(app_config, target, xtream_output, pli, user, &content);
|
||||
}
|
||||
// Check if the content has been resolved
|
||||
if xtream_output.resolve_vod {
|
||||
if let Some(provider_id) = pli.get_provider_id() {
|
||||
if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Video).await {
|
||||
return xtream_repository::write_and_get_xtream_vod_info(app_config, target, xtream_output, pli, user, &content).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(content) = pli.get_resolved_info_document(&options) {
|
||||
return serde_json::to_string(&content).map_err(to_io_error);
|
||||
}
|
||||
|
||||
let input_source = InputSource::from(input).with_url(info_url.to_owned());
|
||||
if let Ok(content) = get_xtream_stream_info_content(client, &input_source).await {
|
||||
return match cluster {
|
||||
XtreamCluster::Live => Ok(content),
|
||||
XtreamCluster::Video => xtream_repository::write_and_get_xtream_vod_info(app_config, target, xtream_output, pli, user, &content).await,
|
||||
XtreamCluster::Series => xtream_repository::write_and_get_xtream_series_info(app_state, target, xtream_output, pli, user, &content).await,
|
||||
};
|
||||
if let Some(provider_id) = pli.get_provider_id() {
|
||||
match cluster {
|
||||
XtreamCluster::Live => {}
|
||||
XtreamCluster::Video => {
|
||||
let working_dir = &app_config.config.load().working_dir;
|
||||
if let Ok(storage_path) = get_input_storage_path(&input.name, working_dir) {
|
||||
match serde_json::from_str::<XtreamVideoInfo>(&content) {
|
||||
Ok(info) => {
|
||||
// parse downloaded info into StreamProperties
|
||||
let video_stream_props = VideoStreamProperties::from_info(&info, pli);
|
||||
|
||||
// persist input info
|
||||
if let Err(err) = persist_input_vod_info(&app_state.app_config, &storage_path, cluster, &input.name, provider_id, &video_stream_props).await {
|
||||
error!("Failed to persist video stream for input {}: {err}", &input.name);
|
||||
}
|
||||
|
||||
// update target playlist
|
||||
let mut vod_pli = pli.clone();
|
||||
vod_pli.additional_properties = Some(StreamProperties::Video(Box::new(video_stream_props)));
|
||||
|
||||
if let Err(err) = write_playlist_item_update(app_config, &target.name, &vod_pli).await {
|
||||
error!("Failed to persist video stream: {err}");
|
||||
}
|
||||
|
||||
if target.use_memory_cache {
|
||||
app_state.playlists.update_playlist_items(target, vec![&vod_pli]).await;
|
||||
}
|
||||
|
||||
if let Some(value) = xtream_resolve_stream_info(app_state, user, target, xtream_output, &vod_pli) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Err(err) => error!("Failed to persist video info: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
XtreamCluster::Series => {
|
||||
let working_dir = &app_config.config.load().working_dir;
|
||||
let group = pli.get_group();
|
||||
let series_name = pli.get_name();
|
||||
|
||||
match serde_json::from_str::<XtreamSeriesInfo>(&content) {
|
||||
Ok(info) => {
|
||||
// parse series info
|
||||
let series_stream_props = SeriesStreamProperties::from_info(&info, pli);
|
||||
if let Ok(storage_path) = get_input_storage_path(&input.name, working_dir) {
|
||||
// update input db
|
||||
if let Err(err) = persists_input_series_info(app_config, &storage_path, cluster, &input.name, provider_id, &series_stream_props).await {
|
||||
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) {
|
||||
let config = &app_state.app_config.config.load();
|
||||
match get_target_storage_path(config, target.name.as_str()) {
|
||||
None => {
|
||||
error!("Failed to get target storage path {}. Cant save episodes", &target.name);
|
||||
}
|
||||
Some(target_path) => {
|
||||
let mut in_memory_updates = Vec::new();
|
||||
let mut provider_series: HashMap<String, Vec<ProviderEpisodeKey>> = HashMap::new();
|
||||
{
|
||||
let (mut target_id_mapping, _file_lock) = get_target_id_mapping(&app_state.app_config, &target_path).await;
|
||||
if let Some(parent_id) = pli.get_provider_id() {
|
||||
let category_id = pli.get_category_id().unwrap_or(0);
|
||||
for episode in &mut episodes {
|
||||
episode.header.virtual_id = target_id_mapping.get_and_update_virtual_id(&episode.header.uuid, provider_id, episode.header.item_type, parent_id);
|
||||
episode.header.category_id = category_id;
|
||||
let episode_provider_id = episode.header.get_provider_id().unwrap_or(0);
|
||||
provider_series.entry(pli.get_uuid().to_string())
|
||||
.or_default()
|
||||
.push(ProviderEpisodeKey {
|
||||
provider_id: episode_provider_id,
|
||||
virtual_id: episode.header.virtual_id,
|
||||
});
|
||||
if target.use_memory_cache {
|
||||
in_memory_updates.push(
|
||||
VirtualIdRecord::new(
|
||||
episode.header.get_provider_id().unwrap_or(0),
|
||||
episode.header.virtual_id,
|
||||
episode.header.item_type,
|
||||
provider_id,
|
||||
episode.get_uuid(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = target_id_mapping.persist() {
|
||||
error!("Failed to persist target id mapping: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
let xtream_episodes: Vec<XtreamPlaylistItem> = episodes.iter().map(XtreamPlaylistItem::from).collect();
|
||||
if let Err(err) = write_playlist_batch_item_upsert(
|
||||
app_config,
|
||||
&target.name,
|
||||
XtreamCluster::Series,
|
||||
&xtream_episodes).await {
|
||||
error!("Failed to persist playlist batch item update: {err}");
|
||||
}
|
||||
|
||||
if target.use_memory_cache && !in_memory_updates.is_empty() {
|
||||
app_state.playlists.insert_playlist_items(target, episodes).await;
|
||||
app_state.playlists.update_target_id_mapping(target, in_memory_updates).await;
|
||||
}
|
||||
|
||||
if !provider_series.is_empty() {
|
||||
let mut series_pli = pli.clone();
|
||||
series_pli.additional_properties = Some(StreamProperties::Series(Box::new(series_stream_props)));
|
||||
rewrite_provider_series_info_episode_virtual_id(&mut series_pli, &provider_series);
|
||||
if let Err(err) = write_playlist_item_update(app_config, &target.name, &series_pli).await {
|
||||
error!("Failed to persist series stream: {err}");
|
||||
}
|
||||
app_state.playlists.update_playlist_items(target, vec![&series_pli]).await;
|
||||
|
||||
if let Some(value) = xtream_resolve_stream_info(app_state, user, target, xtream_output, &series_pli) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to persist series info: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(str_to_io_error(&format!("Cant find stream with id: {}/{}/{}",
|
||||
target.name.replace(' ', "_").as_str(), &cluster, pli.get_virtual_id())))
|
||||
}
|
||||
|
||||
fn xtream_resolve_stream_info(app_state: &Arc<AppState>, user: &ProxyUserCredentials,
|
||||
target: &ConfigTarget, xtream_output: &XtreamTargetOutput,
|
||||
pli: &XtreamPlaylistItem) -> Option<Result<String, Error>> {
|
||||
let app_config = &app_state.app_config;
|
||||
let server_info = app_config.get_user_server_info(user);
|
||||
let options = xtream_mapping_option_from_target_options(target, xtream_output, app_config, user, Some(server_info.get_base_url().as_str()));
|
||||
if let Some(content) = pli.get_resolved_info_document(&options) {
|
||||
return Some(serde_json::to_string(&content).map_err(to_io_error));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_skip_cluster(input: &ConfigInput) -> Vec<XtreamCluster> {
|
||||
let mut skip_cluster = vec![];
|
||||
if let Some(input_options) = &input.options {
|
||||
@@ -139,7 +257,8 @@ async fn xtream_login(cfg: &Config, client: &reqwest::Client, input: &InputSourc
|
||||
let content = if let Ok(content) = request::get_input_json_content(client, None, input, None).await {
|
||||
content
|
||||
} else {
|
||||
let input_source_account_info = input.with_url(format!("{}&action={}", &input.url, crate::model::XC_ACTION_GET_ACCOUNT_INFO));
|
||||
let input_source_account_info =
|
||||
input.with_url(format!("{}&action={}", &input.url, crate::model::XC_ACTION_GET_ACCOUNT_INFO));
|
||||
match request::get_input_json_content(client, None, &input_source_account_info, None).await {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
@@ -161,7 +280,8 @@ async fn xtream_login(cfg: &Config, client: &reqwest::Client, input: &InputSourc
|
||||
login_info.status = Some(cur_status);
|
||||
if !matches!(cur_status, ProxyUserStatus::Active | ProxyUserStatus::Trial) {
|
||||
warn!("User status for user {username} is {cur_status:?}");
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(), &format!("User status for user {username} is {cur_status:?}")).await;
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(),
|
||||
&format!("User status for user {username} is {cur_status:?}")).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +302,8 @@ async fn xtream_login(cfg: &Config, client: &reqwest::Client, input: &InputSourc
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn notify_account_expire(exp_date: Option<i64>, cfg: &Config, client: &reqwest::Client, username: &str, input_name: &str) {
|
||||
pub async fn notify_account_expire(exp_date: Option<i64>, cfg: &Config, client: &reqwest::Client,
|
||||
username: &str, input_name: &str) {
|
||||
if let Some(expiration_timestamp) = exp_date {
|
||||
let now_secs = Utc::now().timestamp(); // UTC-Time
|
||||
if expiration_timestamp > now_secs {
|
||||
@@ -192,17 +313,20 @@ pub async fn notify_account_expire(exp_date: Option<i64>, cfg: &Config, client:
|
||||
if let Some(datetime) = DateTime::<Utc>::from_timestamp(expiration_timestamp, 0) {
|
||||
let formatted = datetime.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
warn!("User account for user {username} expires {formatted}");
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(), &format!("User account for user {username} expires {formatted}")).await;
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(),
|
||||
&format!("User account for user {username} expires {formatted}")).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("User account for user {username} is expired");
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(), &format!("User account for user {username} for provider {input_name} is expired")).await;
|
||||
send_message(client, MsgKind::Info, cfg.messaging.as_ref(),
|
||||
&format!("User account for user {username} for provider {input_name} is expired")).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_xtream_playlist(cfg: &Arc<Config>, client: &reqwest::Client, input: &Arc<ConfigInput>) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
pub async fn download_xtream_playlist(cfg: &Arc<Config>, client: &reqwest::Client, input: &Arc<ConfigInput>)
|
||||
-> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
|
||||
let input_source: InputSource = {
|
||||
match input.staged.as_ref() {
|
||||
None => input.as_ref().into(),
|
||||
@@ -233,8 +357,10 @@ pub async fn get_xtream_playlist(cfg: &Arc<Config>, client: &reqwest::Client, in
|
||||
if !skip_cluster.contains(xtream_cluster) {
|
||||
let input_source_category = input_source.with_url(format!("{base_url}&action={category}"));
|
||||
let input_source_stream = input_source.with_url(format!("{base_url}&action={stream}"));
|
||||
let category_file_path = crate::utils::prepare_file_path(input.persist.as_deref(), working_dir, format!("{category}_").as_str());
|
||||
let stream_file_path = crate::utils::prepare_file_path(input.persist.as_deref(), working_dir, format!("{stream}_").as_str());
|
||||
let category_file_path = crate::utils::prepare_file_path(input.persist.as_deref(),
|
||||
working_dir, format!("{category}_").as_str());
|
||||
let stream_file_path = crate::utils::prepare_file_path(input.persist.as_deref(),
|
||||
working_dir, format!("{stream}_").as_str());
|
||||
|
||||
match futures::join!(
|
||||
request::get_input_json_content_as_stream(client, None, &input_source_category, category_file_path),
|
||||
@@ -248,6 +374,9 @@ pub async fn get_xtream_playlist(cfg: &Arc<Config>, client: &reqwest::Client, in
|
||||
Ok(sub_playlist_parsed) => {
|
||||
if let Some(mut xtream_sub_playlist) = sub_playlist_parsed {
|
||||
playlist_groups.append(&mut xtream_sub_playlist);
|
||||
} else {
|
||||
error!("Could not parse playlist {xtream_cluster} for input {}: {}",
|
||||
input_source.name, sanitize_sensitive_info(&input_source.url));
|
||||
}
|
||||
}
|
||||
Err(err) => errors.push(err)
|
||||
@@ -271,7 +400,8 @@ async fn check_alias_user_state(cfg: &Arc<Config>, client: &reqwest::Client, inp
|
||||
if let Some(aliases) = input.aliases.as_ref() {
|
||||
for alias in aliases {
|
||||
if is_input_expired(alias.exp_date) {
|
||||
notify_account_expire(alias.exp_date, cfg, client, alias.username.as_ref().map_or("", |s| s.as_str()), &alias.name).await;
|
||||
notify_account_expire(alias.exp_date, cfg, client, alias.username.as_ref()
|
||||
.map_or("", |s| s.as_str()), &alias.name).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,14 +446,18 @@ async fn check_alias_user_state(cfg: &Arc<Config>, client: &reqwest::Client, inp
|
||||
// });
|
||||
}
|
||||
|
||||
pub fn create_vod_info_from_item(target: &ConfigTarget, user: &ProxyUserCredentials, pli: &XtreamPlaylistItem, last_updated: i64) -> String {
|
||||
pub fn create_vod_info_from_item(target: &ConfigTarget, user: &ProxyUserCredentials, pli: &XtreamPlaylistItem) -> String {
|
||||
let category_id = pli.category_id;
|
||||
let stream_id = if user.proxy.is_redirect(pli.item_type) || target.is_force_redirect(pli.item_type) { pli.provider_id } else { pli.virtual_id };
|
||||
let added = pli.additional_properties.as_ref().and_then(StreamProperties::get_last_modified).unwrap_or(0);
|
||||
let name = &pli.name;
|
||||
let extension = pli.get_additional_property("container_extension")
|
||||
.map_or_else(|| extract_extension_from_url(&pli.url).map_or_else(String::new, std::string::ToString::to_string),
|
||||
|v| get_string_from_serde_value(&v).unwrap_or_default());
|
||||
let added = last_updated / 1000;
|
||||
let extension = pli
|
||||
.get_container_extension()
|
||||
.as_deref()
|
||||
.filter(|ce| !ce.is_empty())
|
||||
.or_else(|| extract_extension_from_url(&pli.url))
|
||||
.map_or_else(String::new, ToString::to_string);
|
||||
|
||||
format!(r#"{{
|
||||
"info": {{}},
|
||||
"movie_data": {{
|
||||
|
||||
@@ -40,10 +40,10 @@ fn get_send_message_parse_mode_str(mode: &SendMessageParseMode) -> &'static str
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct RequestObj {
|
||||
pub chat_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub message_thread_id: Option<String>,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub parse_mode: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -113,16 +113,6 @@ library:
|
||||
formats:
|
||||
# - "nfo" # Optionally write Kodi-compatible NFO files
|
||||
|
||||
# Classification rules for movies vs series
|
||||
classification:
|
||||
# Regex patterns to identify series/TV shows
|
||||
# If a file/folder name matches any pattern, it's classified as a series
|
||||
series_patterns:
|
||||
- "(?i)s\\d+e\\d+" # S01E02 format
|
||||
- "(?i)\\d+x\\d+" # 1x02 format
|
||||
- "(?i)season[\\s._-]*\\d+" # "Season 1" format
|
||||
- "(?i)episode[\\s._-]*\\d+" # "Episode 1" format
|
||||
|
||||
# Playlist integration
|
||||
playlist:
|
||||
# Group name for movies in M3U/Xtream playlist
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "frontend"
|
||||
version = "3.2.24"
|
||||
version = "3.2.25"
|
||||
edition = "2021"
|
||||
rust-version = "1.87.0"
|
||||
|
||||
[dependencies]
|
||||
shared = { version = "3.2.24", path = "../shared" }
|
||||
shared = { version = "3.2.25", path = "../shared" }
|
||||
chrono = "0"
|
||||
yew = "0.21"
|
||||
yew-router = "0.18"
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "3.2.24"
|
||||
version = "3.2.25"
|
||||
edition = "2021"
|
||||
rust-version = "1.87.0"
|
||||
|
||||
@@ -23,6 +23,9 @@ zeroize = "1"
|
||||
chrono = "0.4.42"
|
||||
bytes = "1"
|
||||
ciborium = "0.2.2"
|
||||
hex = "0.4.3"
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
lz4_flex = "0.12.0"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3.82"
|
||||
|
||||
@@ -14,11 +14,11 @@ pub struct ApiProxyServerInfoDto {
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub host: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub port: Option<String>,
|
||||
pub timezone: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,25 +14,25 @@ pub enum UserConnectionPermission {
|
||||
pub struct ProxyUserCredentialsDto {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
#[serde(default = "ProxyType::default")]
|
||||
pub proxy: ProxyType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub server: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub epg_timeshift: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default, deserialize_with = "deserialize_timestamp")]
|
||||
pub exp_date: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub max_connections: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub status: Option<ProxyUserStatus>,
|
||||
#[serde(default = "default_as_true")]
|
||||
pub ui_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,25 +12,25 @@ pub struct ConfigDto {
|
||||
pub process_parallel: bool,
|
||||
pub api: ConfigApiDto,
|
||||
pub working_dir: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub backup_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub user_config_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub mapping_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub custom_stream_response_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub video: Option<VideoConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schedules: Option<Vec<ScheduleConfigDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub log: Option<LogConfigDto>,
|
||||
#[serde(default)]
|
||||
pub user_access_control: bool,
|
||||
#[serde(default = "default_connect_timeout_secs")]
|
||||
pub connect_timeout_secs: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub sleep_timer_mins: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub update_on_boot: bool,
|
||||
@@ -40,17 +40,17 @@ pub struct ConfigDto {
|
||||
pub accept_insecure_ssl_certificates: bool,
|
||||
#[serde(default)]
|
||||
pub web_ui: Option<WebUiConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub messaging: Option<MessagingConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub reverse_proxy: Option<ReverseProxyConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub hdhomerun: Option<HdHomeRunConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub proxy: Option<ProxyConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub ipcheck: Option<IpCheckConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub library: Option<LibraryConfigDto>,
|
||||
}
|
||||
|
||||
@@ -61,19 +61,19 @@ pub struct MainConfigDto {
|
||||
#[serde(default)]
|
||||
pub process_parallel: bool,
|
||||
pub working_dir: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub backup_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub user_config_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub mapping_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub custom_stream_response_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_access_control: bool,
|
||||
#[serde(default = "default_connect_timeout_secs")]
|
||||
pub connect_timeout_secs: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub sleep_timer_mins: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub update_on_boot: bool,
|
||||
@@ -125,7 +125,7 @@ impl From<&ConfigDto> for MainConfigDto {
|
||||
// It has no other purpose than editing and saving the schedules
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
|
||||
pub struct SchedulesConfigDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub schedules: Option<Vec<ScheduleConfigDto>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@ use path_clean::PathClean;
|
||||
pub struct CacheConfigDto {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub size: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub dir: Option<String>,
|
||||
}
|
||||
|
||||
impl CacheConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.enabled && is_blank_optional_string(&self.size) && is_blank_optional_string(&self.dir)
|
||||
!self.enabled && is_blank_optional_string(self.size.as_deref()) && is_blank_optional_string(self.dir.as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> {
|
||||
|
||||
@@ -27,9 +27,9 @@ impl EpgSourceDto {
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EpgConfigDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub sources: Option<Vec<EpgSourceDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub smart_match: Option<EpgSmartMatchConfigDto>,
|
||||
#[serde(skip)]
|
||||
pub t_sources: Vec<EpgSourceDto>,
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct EpgSmartMatchConfigDto {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub normalize_regex: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub strip: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub name_prefix: EpgNamePrefix,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::create_tuliprox_error_result;
|
||||
use crate::error::{TuliproxError, TuliproxErrorKind};
|
||||
use crate::utils::{default_as_true, generate_hdhr_device_id, generate_hdhr_device_id_from_base, hash_string, hex_encode, validate_hdhr_device_id};
|
||||
use crate::utils::{default_as_true, generate_hdhr_device_id, generate_hdhr_device_id_from_base, hash_string, validate_hdhr_device_id};
|
||||
use log::warn;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -82,12 +82,7 @@ impl HdHomeRunDeviceConfigDto {
|
||||
if self.device_udn == default_device_udn() || self.device_udn.is_empty() {
|
||||
let hash = hash_string(&self.name);
|
||||
// Format the hash into a valid UUID string
|
||||
let p1 = hex_encode(&hash[0..4]);
|
||||
let p2 = hex_encode(&hash[4..6]);
|
||||
let p3 = hex_encode(&hash[6..8]);
|
||||
let p4 = hex_encode(&hash[8..10]);
|
||||
let p5 = hex_encode(&hash[10..16]);
|
||||
self.device_udn = format!("{p1}-{p2}-{p3}-{p4}-{p5}");
|
||||
self.device_udn = hash.to_valid_uuid();
|
||||
if include_computed {
|
||||
warn!("HDHomeRun device '{}' is missing a unique device_udn. A new one has been generated: {}", self.name, self.device_udn);
|
||||
}
|
||||
|
||||
@@ -200,9 +200,9 @@ impl ConfigInputOptionsDto {
|
||||
pub struct StagedInputDto {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub method: InputFetchMethod,
|
||||
@@ -240,15 +240,15 @@ pub struct ConfigInputAliasDto {
|
||||
pub id: u16,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub priority: i16,
|
||||
#[serde(default)]
|
||||
pub max_connections: u16,
|
||||
#[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default, deserialize_with = "deserialize_timestamp")]
|
||||
pub exp_date: Option<i64>,
|
||||
|
||||
}
|
||||
@@ -284,19 +284,19 @@ pub struct ConfigInputDto {
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub epg: Option<EpgConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub persist: Option<String>,
|
||||
#[serde(default = "default_as_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub options: Option<ConfigInputOptionsDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub aliases: Option<Vec<ConfigInputAliasDto>>,
|
||||
#[serde(default)]
|
||||
pub priority: i16,
|
||||
@@ -304,11 +304,11 @@ pub struct ConfigInputDto {
|
||||
pub max_connections: u16,
|
||||
#[serde(default)]
|
||||
pub method: InputFetchMethod,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub staged: Option<StagedInputDto>,
|
||||
#[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default, deserialize_with = "deserialize_timestamp")]
|
||||
pub exp_date: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub panel_api: Option<PanelApiConfigDto>,
|
||||
}
|
||||
|
||||
@@ -341,9 +341,8 @@ impl ConfigInputDto {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
pub fn prepare(&mut self, index: u16, _include_computed: bool) -> Result<u16, TuliproxError> {
|
||||
|
||||
let is_batch = matches!(self.input_type, InputType::M3uBatch | InputType::XtreamBatch);
|
||||
self.name = self.name.trim().to_owned();
|
||||
if self.name.is_empty() && !is_batch {
|
||||
if self.name.is_empty() {
|
||||
return Err(info_err!("name for input is mandatory".to_owned()));
|
||||
}
|
||||
|
||||
@@ -356,7 +355,7 @@ impl ConfigInputDto {
|
||||
}
|
||||
}
|
||||
|
||||
self.persist = get_trimmed_string(&self.persist);
|
||||
self.persist = get_trimmed_string(self.persist.as_deref());
|
||||
|
||||
let mut current_index = index + 1;
|
||||
self.id = current_index;
|
||||
|
||||
@@ -6,33 +6,33 @@ use regex::Regex;
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IpCheckConfigDto {
|
||||
/// URL that may return both IPv4 and IPv6 in one response
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// Dedicated URL to fetch only IPv4
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub url_ipv4: Option<String>,
|
||||
|
||||
/// Dedicated URL to fetch only IPv6
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub url_ipv6: Option<String>,
|
||||
|
||||
/// Optional regex pattern to extract IPv4
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub pattern_ipv4: Option<String>,
|
||||
|
||||
/// Optional regex pattern to extract IPv6
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub pattern_ipv6: Option<String>,
|
||||
}
|
||||
|
||||
impl IpCheckConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
is_blank_optional_string(&self.url)
|
||||
&& is_blank_optional_string(&self.url_ipv4)
|
||||
&& is_blank_optional_string(&self.url_ipv6)
|
||||
&& is_blank_optional_string(&self.pattern_ipv4)
|
||||
&& is_blank_optional_string(&self.pattern_ipv6)
|
||||
is_blank_optional_string(self.url.as_deref())
|
||||
&& is_blank_optional_string(self.url_ipv4.as_deref())
|
||||
&& is_blank_optional_string(self.url_ipv6.as_deref())
|
||||
&& is_blank_optional_string(self.pattern_ipv4.as_deref())
|
||||
&& is_blank_optional_string(self.pattern_ipv6.as_deref())
|
||||
}
|
||||
|
||||
pub fn clean(&mut self) {
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::utils::default_as_true;
|
||||
pub fn default_metadata_path() -> String {
|
||||
"library_metadata".to_string()
|
||||
}
|
||||
|
||||
fn default_tmdb_rate_limit_ms() -> u64 {
|
||||
250
|
||||
}
|
||||
@@ -24,15 +23,6 @@ fn default_movie_category() -> String {
|
||||
fn default_series_category() -> String {
|
||||
"Local TV Shows".to_string()
|
||||
}
|
||||
fn default_series_patterns() -> Vec<String> {
|
||||
[
|
||||
r"S\d{2}E\d{2}",
|
||||
r"s\d{2}e\d{2}",
|
||||
r"\d{1,2}x\d{1,2}",
|
||||
r"Season\s*\d+",
|
||||
r"Episode\s*\d+",
|
||||
].iter().map(ToString::to_string).collect()
|
||||
}
|
||||
fn default_supported_extensions() -> Vec<String> {
|
||||
[
|
||||
"mp4",
|
||||
@@ -57,8 +47,6 @@ pub struct LibraryConfigDto {
|
||||
#[serde(default)]
|
||||
pub metadata: LibraryMetadataConfigDto,
|
||||
#[serde(default)]
|
||||
pub classification: LibraryClassificationConfigDto,
|
||||
#[serde(default)]
|
||||
pub playlist: LibraryPlaylistConfigDto,
|
||||
}
|
||||
|
||||
@@ -114,7 +102,7 @@ pub struct LibraryMetadataReadConfigDto {
|
||||
pub struct LibraryTmdbConfigDto {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default = "default_tmdb_rate_limit_ms")]
|
||||
pub rate_limit_ms: u64,
|
||||
@@ -130,15 +118,6 @@ pub enum LibraryMetadataFormat {
|
||||
Nfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LibraryClassificationConfigDto {
|
||||
#[serde(default = "default_series_patterns")]
|
||||
pub series_patterns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub series_directory_patterns: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LibraryPlaylistConfigDto {
|
||||
@@ -148,7 +127,6 @@ pub struct LibraryPlaylistConfigDto {
|
||||
pub series_category: String,
|
||||
}
|
||||
|
||||
|
||||
impl LibraryConfigDto {
|
||||
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
|
||||
// Validate enabled state
|
||||
|
||||
@@ -7,7 +7,7 @@ pub struct LogConfigDto {
|
||||
pub sanitize_sensitive_info: bool,
|
||||
#[serde(default)]
|
||||
pub log_active_user: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ impl Default for LogConfigDto {
|
||||
|
||||
impl LogConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sanitize_sensitive_info && !self.log_active_user && is_blank_optional_string(&self.log_level)
|
||||
self.sanitize_sensitive_info && !self.log_active_user && is_blank_optional_string(self.log_level.as_deref())
|
||||
}
|
||||
|
||||
pub fn clean(&mut self) {
|
||||
if is_blank_optional_string(&self.log_level) {
|
||||
if is_blank_optional_string(self.log_level.as_deref()) {
|
||||
self.log_level = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ macro_rules! check_input_credentials {
|
||||
return Err(info_err!("url for input is mandatory".to_string()));
|
||||
}
|
||||
|
||||
$this.username = $crate::utils::get_trimmed_string(&$this.username);
|
||||
$this.password = $crate::utils::get_trimmed_string(&$this.password);
|
||||
$this.username = $crate::utils::get_trimmed_string($this.username.as_deref());
|
||||
$this.password = $crate::utils::get_trimmed_string($this.password.as_deref());
|
||||
}
|
||||
match $input_type {
|
||||
InputType::M3u => {
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct PushoverMessagingConfigDto {
|
||||
|
||||
impl PushoverMessagingConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
is_blank_optional_string(&self.url)
|
||||
is_blank_optional_string(self.url.as_deref())
|
||||
&& self.token.trim().is_empty()
|
||||
&& self.user.trim().is_empty()
|
||||
}
|
||||
@@ -49,11 +49,11 @@ impl PushoverMessagingConfigDto {
|
||||
pub struct MessagingConfigDto {
|
||||
#[serde(default)]
|
||||
pub notify_on: Vec<MsgKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub telegram: Option<TelegramMessagingConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub rest: Option<RestMessagingConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub pushover: Option<PushoverMessagingConfigDto>,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct PanelApiConfigDto {
|
||||
#[serde(default = "default_as_true")]
|
||||
pub enabled: bool,
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub query_parameter: PanelApiQueryParametersDto,
|
||||
|
||||
@@ -5,6 +5,6 @@ pub struct ConfigPaths {
|
||||
pub sources_file_path: String,
|
||||
pub mapping_file_path: Option<String>,
|
||||
pub api_proxy_file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub custom_stream_response_path: Option<String>,
|
||||
}
|
||||
|
||||
@@ -11,16 +11,16 @@ pub struct ProxyConfigDto {
|
||||
|
||||
impl ProxyConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
is_blank_optional_string(&self.username)
|
||||
&& is_blank_optional_string(&self.password)
|
||||
is_blank_optional_string(self.username.as_deref())
|
||||
&& is_blank_optional_string(self.password.as_deref())
|
||||
&& self.url.trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn clean(&mut self) {
|
||||
if is_blank_optional_string(&self.username) {
|
||||
if is_blank_optional_string(self.username.as_deref()) {
|
||||
self.username = None;
|
||||
}
|
||||
if is_blank_optional_string(&self.password) {
|
||||
if is_blank_optional_string(self.password.as_deref()) {
|
||||
self.password = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,17 @@ pub struct ReverseProxyConfigDto {
|
||||
#[serde(default)]
|
||||
pub resource_rewrite_disabled: bool,
|
||||
pub rewrite_secret: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub resource_retry: Option<ResourceRetryConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub disabled_header: Option<ReverseProxyDisabledHeaderConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub stream: Option<StreamConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub cache: Option<CacheConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub rate_limit: Option<RateLimitConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub geoip: Option<GeoIpConfigDto>,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
pub struct ScheduleConfigDto {
|
||||
#[serde(default)]
|
||||
pub schedule: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub targets: Option<Vec<String>>,
|
||||
}
|
||||
@@ -42,7 +42,7 @@ impl Display for SortOrder {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigSortGroupDto {
|
||||
pub order: SortOrder,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub sequence: Option<Vec<String>>,
|
||||
#[serde(skip)]
|
||||
pub t_sequence: Option<Vec<Regex>>,
|
||||
@@ -85,7 +85,7 @@ pub struct ConfigSortChannelDto {
|
||||
// match against group title
|
||||
pub group_pattern: String,
|
||||
pub order: SortOrder,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub sequence: Option<Vec<String>>,
|
||||
#[serde(skip)]
|
||||
pub t_sequence: Option<Vec<Regex>>,
|
||||
@@ -136,9 +136,9 @@ impl ConfigSortChannelDto {
|
||||
pub struct ConfigSortDto {
|
||||
#[serde(default)]
|
||||
pub match_as_ascii: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub groups: Option<ConfigSortGroupDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub channels: Option<Vec<ConfigSortChannelDto>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ impl ConfigSourceDto {
|
||||
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SourcesConfigDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub templates: Option<Vec<PatternTemplate>>,
|
||||
pub sources: Vec<ConfigSourceDto>,
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ impl StreamBufferConfigDto {
|
||||
pub struct StreamConfigDto {
|
||||
#[serde(default)]
|
||||
pub retry: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub buffer: Option<StreamBufferConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub throttle: Option<String>,
|
||||
#[serde(default = "default_grace_period_millis")]
|
||||
pub grace_period_millis: u64,
|
||||
|
||||
@@ -13,7 +13,7 @@ pub struct ConfigTargetOptions {
|
||||
pub share_live_streams: bool,
|
||||
#[serde(default)]
|
||||
pub remove_duplicates: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub force_redirect: Option<ClusterFlags>,
|
||||
}
|
||||
|
||||
@@ -44,9 +44,9 @@ pub struct XtreamTargetOutputDto {
|
||||
pub resolve_vod: bool,
|
||||
#[serde(default = "default_resolve_delay_secs")]
|
||||
pub resolve_vod_delay: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub trakt: Option<TraktConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub t_filter: Option<Filter>,
|
||||
@@ -94,13 +94,13 @@ impl XtreamTargetOutputDto {
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct M3uTargetOutputDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default)]
|
||||
pub include_type_in_url: bool,
|
||||
#[serde(default)]
|
||||
pub mask_redirect_url: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub t_filter: Option<Filter>,
|
||||
@@ -127,7 +127,7 @@ impl M3uTargetOutputDto {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StrmTargetOutputDto {
|
||||
pub directory: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub style: StrmExportStyle,
|
||||
@@ -137,9 +137,9 @@ pub struct StrmTargetOutputDto {
|
||||
pub underscore_whitespace: bool,
|
||||
#[serde(default)]
|
||||
pub cleanup: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub strm_props: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub t_filter: Option<Filter>,
|
||||
@@ -161,7 +161,7 @@ impl StrmTargetOutputDto {
|
||||
pub struct HdHomeRunTargetOutputDto {
|
||||
pub device: String,
|
||||
pub username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub use_output: Option<TargetType>,
|
||||
}
|
||||
|
||||
@@ -204,22 +204,22 @@ pub struct ConfigTargetDto {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_as_default")]
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub options: Option<ConfigTargetOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub sort: Option<ConfigSortDto>,
|
||||
pub filter: String,
|
||||
#[serde(default)]
|
||||
pub output: Vec<TargetOutputDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub rename: Option<Vec<ConfigRenameDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub mapping: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub favourites: Option<Vec<ConfigFavouritesDto>>,
|
||||
#[serde(default)]
|
||||
pub processing_order: ProcessingOrder,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub watch: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub use_memory_cache: bool,
|
||||
|
||||
@@ -12,11 +12,11 @@ pub const DEFAULT_VIDEO_EXTENSIONS: [&str; 6] = ["mkv", "avi", "mp4", "mpeg", "d
|
||||
pub struct VideoDownloadConfigDto {
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub directory: Option<String>,
|
||||
#[serde(default)]
|
||||
pub organize_into_directories: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub episode_pattern: Option<String>,
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ impl VideoDownloadConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.organize_into_directories
|
||||
&& self.headers.is_empty()
|
||||
&& is_blank_optional_string(&self.directory)
|
||||
&& is_blank_optional_string(&self.episode_pattern)
|
||||
&& is_blank_optional_string(self.directory.as_deref())
|
||||
&& is_blank_optional_string(self.episode_pattern.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,16 +34,16 @@ impl VideoDownloadConfigDto {
|
||||
pub struct VideoConfigDto {
|
||||
#[serde(default)]
|
||||
pub extensions: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub download: Option<VideoDownloadConfigDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub web_search: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
impl VideoConfigDto {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.extensions.is_empty() && is_blank_optional_string(&self.web_search)
|
||||
self.extensions.is_empty() && is_blank_optional_string(self.web_search.as_deref())
|
||||
&& (self.download.is_none() || self.download.as_ref().is_some_and(|d| d.is_empty()))
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user