diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a32dc8a9..703d48bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ If we assume the variable `station` contains the value `WINK`, this script receives the template with the concatenated name `US_WINK_PREFIX` which should be defined in `templates` section, and assigns it to the variable `station_prefix`. +# 3.1.4 (???) +- Extended STRM export functionality with: + - Support for various media tools (Kodi, Plex, Emby, Jellyfin), with consideration for recommended naming conventions and file organization. + - Optional flat directory structure via 'flat' parameter (nested folder structures are not supported by some media scanners). + # 3.1.3 (2025-06-06) - Fixed xtream codes series info duplicate fields problem. - Fixed series info container_extension problem. diff --git a/README.md b/README.md index 40dc43331..760225bd5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ designed for processing, filtering, and serving media playlists with minimal res ## 🔧 Core Features: - **Advanced Playlist Processing**: Filter, rename, map, and sort entries with ease. -- **Flexible Proxy Support**: Acts as a reverse/redirect proxy for EXTM3U, Xtream Codes, HDHomeRun, and STRM (Kodi) formats. +- **Flexible Proxy Support**: Acts as a reverse/redirect proxy for EXTM3U, Xtream Codes, HDHomeRun, and STRM formats (Kodi, Plex, Emby, Jellyfin) with: + - app-specific naming conventions + - flat directory structure option (for compatibility reasons of some media scanners) - **Multi-Source Handling**: Supports multiple input and output sources. Merge various playlists and generate custom outputs. - **Scheduled Updates**: Keep playlists fresh with automatic updates in server mode. - **Web Delivery**: Run as a CLI tool to create m3u playlist to serve with web servers like Nginx or Apache. @@ -786,10 +788,11 @@ Each format has different properties `strm` - directory: _mandatory_, - username: _optional_, -- underscore_whitespace: _optional_, true|false, default false -- cleanup: _optional_, true|false, default false -- kodi_style: _optional_, true|false, default false -- strm_props: _optional_, list of strings, +- underscore_whitespace: _optional_, true|false, default false +- cleanup: _optional_, true|false, default false +- style: _mandatory_, kodi|plex|emby|jellyfin +- flat: _optional_, true|false, default false +- strm_props: _optional_, list of strings, `hdhomerun` - device: _mandatory_, @@ -831,12 +834,20 @@ Target options are: - `share_live_streams` to share live stream connections in reverse proxy mode. - `remove_duplicates` tries to remove duplicates by `url`. -`strm` output has additional options -- `underscore_whitespace` replaces all whitespaces with `_` in the path. -- `cleanup` deletes the directory given at `filename`. Don't point at existing media folder or everything will be deleted. -- `kodi_style` tries to rename `filename` with [kodi style](https://kodi.wiki/view/Naming_video_files/TV_shows). -- `strm_props` is a list of properties written to the strm file. - If `kodi_style` set to `true` the property `#KODIPROP:seekable=true|false` is added. If `strm_props` is not given `#KODIPROP:inputstream=inputstream.ffmpeg`, `"#KODIPROP:http-reconnect=true` are set too for `kody_style`. +`strm` output has additional options: +- `underscore_whitespace`: replaces all whitespaces with `_` in the path +- `cleanup`: deletes the directory given at `filename`. Don't point at existing media folder or everything will be deleted +- `style`: determines naming convention (kodi, plex, emby, jellyfin) +- `flat`: creates flat directory structure with category tags in folder names +- `strm_props`: list of properties written to the strm file + +Supported styles: +- Kodi: `Movie Name (Year) {tmdb=ID}/Movie Name (Year).strm` +- Plex: `Movie Name (Year) {tmdb-ID}/Movie Name (Year).strm` +- Emby: `Movie Name (Year) [tmdbid=ID]/Movie Name (Year).strm` +- Jellyfin: `Movie Name (Year) [tmdbid-ID]/Movie Name (Year).strm` + +If style is set to 'kodi', the property `#KODIPROP:seekable=true|false` is added. And if `strm_props` is not given `#KODIPROP:inputstream=inputstream.ffmpeg`, `"#KODIPROP:http-reconnect=true` are set too for style `kodi`. `m3u` output has additional options - `include_type_in_url`, default false, if true adds the stream type `live`, `movie`, `series` to the url of the stream. @@ -966,8 +977,9 @@ sources: options: ignore_logo: true underscore_whitespace: false - kodi_style: true + style: kodi cleanup: true + flat: true sort: order: asc filter: "!PROV1_ALL!" diff --git a/frontend/src/model/server-config.ts b/frontend/src/model/server-config.ts index 26d5a1fc0..02a618bf9 100644 --- a/frontend/src/model/server-config.ts +++ b/frontend/src/model/server-config.ts @@ -8,6 +8,13 @@ export enum SortOrder { desc = "desc" } +export enum ExportStyle { + kodi = "kodi", + plex = "plex", + emby = "emby", + jellyfin = "jellyfin" +} + export enum TargetType { m3u = "m3u", xtream = "xtream", @@ -78,12 +85,15 @@ export interface TargetConfig { resolve_vod: boolean, resolve_vod_delay: number, // strm + style: ExportStyle, + flat?: boolean, cleanup: boolean, - kodi_style: boolean, directory: string, username?: string, // hdhomerun & strm underscore_whitespace: boolean, strm_props?: string[], + /** @deprecated Use style="kodi" instead */ + kodi_style?: boolean, // hdhomerun device: String, use_output?: TargetType, diff --git a/src/model/config_target.rs b/src/model/config_target.rs index 8cbb53368..6ca3fe41f 100644 --- a/src/model/config_target.rs +++ b/src/model/config_target.rs @@ -12,6 +12,19 @@ use std::fmt::Display; use std::sync::Arc; use arc_swap::{ArcSwapOption}; +#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Hash, Default)] +pub enum ExportStyle { + #[serde(rename = "kodi")] + #[default] + Kodi, + #[serde(rename = "plex")] + Plex, + #[serde(rename = "emby")] + Emby, + #[serde(rename = "jellyfin")] + Jellyfin, +} + #[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Hash)] pub enum TargetType { #[serde(rename = "m3u")] @@ -131,15 +144,16 @@ pub struct StrmTargetOutput { #[serde(default, skip_serializing_if = "Option::is_none")] pub username: Option, #[serde(default)] + pub style: ExportStyle, + #[serde(default)] + pub flat: bool, + #[serde(default)] pub underscore_whitespace: bool, #[serde(default)] pub cleanup: bool, - #[serde(default)] - pub kodi_style: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub strm_props: Option>, } - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct HdHomeRunTargetOutput { diff --git a/src/model/mod.rs b/src/model/mod.rs index cfd9001e8..9f071c6bf 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -28,6 +28,7 @@ mod config_log; mod config_healthcheck; mod config_cache; +// Re-export modules for easier access pub use self::config_cache::*; pub use self::config::*; pub use self::playlist::*; diff --git a/src/model/xtream.rs b/src/model/xtream.rs index 954bc0dc5..daf9f775f 100644 --- a/src/model/xtream.rs +++ b/src/model/xtream.rs @@ -396,19 +396,23 @@ impl XtreamMappingOptions { } } -fn append_release_date(document: &mut serde_json::Map) { - let release_date = document - .get("release_date") +pub fn normalize_release_date(document: &mut serde_json::Map) { + // Find the first non-empty release date key + let date_value = document.get("release_date") .or_else(|| document.get("releaseDate")) .or_else(|| document.get("releasedate")) - .cloned() - .unwrap_or(Value::Null); + .filter(|v| v.as_str().is_some_and(|s| !s.is_empty())) + .cloned(); - if !document.contains_key("release_date") { - document.insert("release_date".to_string(), release_date.clone()); - } - if !document.contains_key("releaseDate") { - document.insert("releaseDate".to_string(), release_date); + // Remove unused keys (optional) + document.remove("releaseDate"); + document.remove("releasedate"); + + // Insert the normalized release date or null if not found + if let Some(date) = date_value { + document.insert("release_date".to_string(), date); + } else { + document.insert("release_date".to_string(), Value::Null); } } @@ -516,7 +520,6 @@ pub fn xtream_playlistitem_to_document(pli: &XtreamPlaylistItem, url: &str, opti XtreamCluster::Series => { append_prepared_series_properties(props.as_ref(), &mut document); append_mandatory_fields(&mut document, xtream_const::SERIES_STREAM_FIELDS); - append_release_date(&mut document); } } diff --git a/src/model/xtream_const.rs b/src/model/xtream_const.rs index a60049fc4..dd70013af 100644 --- a/src/model/xtream_const.rs +++ b/src/model/xtream_const.rs @@ -37,6 +37,7 @@ pub const XC_TAG_VOD_INFO_MOVIE_DATA: &str = "movie_data"; pub const XC_TAG_VOD_INFO_TMDB_ID: &str = "tmdb_id"; pub const XC_TAG_VOD_INFO_STREAM_ID: &str = "stream_id"; pub const XC_TAG_VOD_INFO_ADDED: &str = "added"; +pub const XC_TAG_VOD_INFO_RELEASEDATE: &str = "release_date"; pub const XC_FILE_SERIES_INFO: &str = "xtream_series_info"; pub const XC_FILE_VOD_INFO: &str = "xtream_vod_info"; diff --git a/src/processing/processor/playlist.rs b/src/processing/processor/playlist.rs index b0febe8ad..28b41e9b2 100644 --- a/src/processing/processor/playlist.rs +++ b/src/processing/processor/playlist.rs @@ -17,7 +17,6 @@ use crate::model::{FetchedPlaylist, FieldGetAccessor, FieldSetAccessor, Playlist use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats}; use crate::processing::playlist_watch::process_group_watch; 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::tuliprox_error::{get_errors_notify_message, notify_err, TuliproxError, TuliproxErrorKind}; use crate::utils::debug_if_enabled; @@ -28,6 +27,7 @@ use std::time::Instant; use crate::model::Epg; use crate::processing::parser::xmltv::flatten_tvguide; use crate::processing::processor::epg::process_playlist_epg; +use crate::processing::processor::xtream_vod::playlist_resolve_vod; use crate::processing::processor::sort::sort_playlist; use crate::utils::StepMeasure; diff --git a/src/processing/processor/xtream.rs b/src/processing/processor/xtream.rs index 353bdb89d..25682b685 100644 --- a/src/processing/processor/xtream.rs +++ b/src/processing/processor/xtream.rs @@ -2,6 +2,7 @@ use crate::tuliprox_error::{info_err, notify_err}; use crate::tuliprox_error::{str_to_io_error, to_io_error, TuliproxError, TuliproxErrorKind}; use crate::model::{Config, ConfigInput}; use crate::model::{FetchedPlaylist, 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; @@ -14,6 +15,7 @@ use crate::repository::storage_const; use crate::repository::xtream_repository::xtream_get_record_file_path; use crate::utils; use crate::utils::xtream; +use serde_json::{from_str, to_string, Value}; pub(in crate::processing) async fn playlist_resolve_download_playlist_item(client: Arc, pli: &PlaylistItem, input: &ConfigInput, errors: &mut Vec, resolve_delay: u16, cluster: XtreamCluster) -> Option { let mut result = None; @@ -33,6 +35,18 @@ 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::(&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) fn write_info_content_to_wal_file(writer: &mut BufWriter<&File>, provider_id: u32, content: &str) -> std::io::Result<()> { let length = u32::try_from(content.len()).map_err(to_io_error)?; if length > 0 { diff --git a/src/processing/processor/xtream_series.rs b/src/processing/processor/xtream_series.rs index 711bdee27..6754a7d62 100644 --- a/src/processing/processor/xtream_series.rs +++ b/src/processing/processor/xtream_series.rs @@ -18,6 +18,7 @@ use log::{info, log_enabled, Level}; use crate::model::{XtreamSeriesEpisode, XtreamSeriesInfoEpisode}; use crate::utils; use crate::utils::bincode_serialize; +use crate::processing::processor::xtream::normalize_json_content; create_resolve_options_function_for_xtream_target!(series); @@ -87,7 +88,8 @@ async fn playlist_resolve_series_info(client: Arc, cfg: &Config let (should_update, provider_id, ts) = should_update_series_info(pli, &processed_info_ids); if should_update { if let Some(content) = playlist_resolve_download_playlist_item(Arc::clone(&client), pli, fpl.input, errors, resolve_delay, XtreamCluster::Series).await { - handle_error_and_return!(write_info_content_to_wal_file(&mut content_writer, provider_id, &content), + let normalized_content = normalize_json_content(content); + handle_error_and_return!(write_info_content_to_wal_file(&mut content_writer, provider_id, &normalized_content), |err| errors.push(notify_err!(format!("Failed to resolve series, could not write to content wal file {err}")))); processed_info_ids.insert(provider_id, ts); handle_error_and_return!(write_series_info_record_to_wal_file(&mut record_writer, provider_id, ts), diff --git a/src/processing/processor/xtream_vod.rs b/src/processing/processor/xtream_vod.rs index b5a287b02..d4bd87411 100644 --- a/src/processing/processor/xtream_vod.rs +++ b/src/processing/processor/xtream_vod.rs @@ -5,8 +5,9 @@ use crate::processing::processor::xtream::{create_resolve_info_wal_files, playli use crate::repository::xtream_repository::{xtream_update_input_info_file, xtream_update_input_vod_record_from_wal_file, InputVodInfoRecord}; use crate::tuliprox_error::{notify_err}; use crate::processing::processor::{handle_error, handle_error_and_return, create_resolve_options_function_for_xtream_target}; -use crate::utils::{get_u32_from_serde_value, get_u64_from_serde_value}; -use serde_json::{Map, Value}; +use crate::utils::{get_u32_from_serde_value, get_u64_from_serde_value, get_string_from_serde_value}; +use crate::repository::xtream_repository::xtream_get_input_info; +use serde_json::{from_str, Map, Value}; use std::collections::HashMap; use std::fs::File; use std::io::{BufWriter, Write}; @@ -14,6 +15,8 @@ use std::sync::Arc; use std::time::Instant; use log::{info, log_enabled, Level}; use crate::utils; +use crate::processing::processor::xtream::normalize_json_content; +use crate::tuliprox_error::to_io_error; create_resolve_options_function_for_xtream_target!(vod); @@ -34,14 +37,21 @@ fn extract_info_record_from_vod_info(content: &str) -> Option<(u32, InputVodInfo .and_then(get_u64_from_serde_value) .unwrap_or(0); - let tmdb_id = doc.get(crate::model::XC_TAG_VOD_INFO_INFO)?.as_object() - .and_then(|info| info.get(crate::model::XC_TAG_VOD_INFO_TMDB_ID)) + 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) .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, })) } @@ -53,6 +63,19 @@ fn write_vod_info_record_to_wal_file( writer.write_all(&provider_id.to_le_bytes())?; writer.write_all(&record.tmdb_id.to_le_bytes())?; writer.write_all(&record.ts.to_le_bytes())?; + + match &record.release_date { + Some(date_str) => { + // Write the length as a 4-byte u32 to prevent overflow. + let len = u32::try_from(date_str.len()).map_err(to_io_error)?; + writer.write_all(&len.to_le_bytes())?; + writer.write_all(date_str.as_bytes())?; + } + None => { + // Write a length of 0 as 4 bytes. + writer.write_all(&0u32.to_le_bytes())?; + } + } Ok(()) } @@ -93,9 +116,10 @@ pub async fn playlist_resolve_vod(client: Arc, cfg: &Config, ta let (should_update, _provider_id, _ts) = should_update_vod_info(pli, &processed_info_ids); if should_update { if let Some(content) = playlist_resolve_download_playlist_item(Arc::clone(&client), pli, fpl.input, errors, resolve_delay, XtreamCluster::Video).await { - if let Some((provider_id, info_record)) = extract_info_record_from_vod_info(&content) { + let normalized_content = normalize_json_content(content); + if let Some((provider_id, info_record)) = extract_info_record_from_vod_info(&normalized_content) { let ts = info_record.ts; - handle_error_and_return!(write_info_content_to_wal_file(&mut content_writer, provider_id, &content), + handle_error_and_return!(write_info_content_to_wal_file(&mut content_writer, provider_id, &normalized_content), |err| errors.push(notify_err!(format!("Failed to resolve vod, could not write to content wal file {err}")))); processed_info_ids.insert(provider_id, ts); handle_error_and_return!(write_vod_info_record_to_wal_file(&mut record_writer, provider_id, &info_record), @@ -130,4 +154,18 @@ pub async fn playlist_resolve_vod(client: Arc, cfg: &Config, ta handle_error!(xtream_update_input_vod_record_from_wal_file(cfg, fpl.input, &wal_record_path).await, |err| errors.push(err)); } + + // Update in-memory playlist items with the newly fetched vod info. + // This makes the data available for subsequent processing steps like STRM export. + let vod_info_iter = fpl.playlistgroups.iter_mut() + .flat_map(|plg| &mut plg.channels) + .filter(|pli| pli.header.xtream_cluster == XtreamCluster::Video); + + for pli in vod_info_iter { + if let Some(provider_id) = pli.header.get_provider_id() { + if let Some(content) = xtream_get_input_info(cfg, fpl.input, provider_id, XtreamCluster::Video) { + pli.header.additional_properties = from_str::>(&content).ok().and_then(|info_doc| info_doc.get("info").cloned()); + } + } + } } diff --git a/src/repository/kodi_repository.rs b/src/repository/kodi_repository.rs index 4210bf12d..f2a80e462 100644 --- a/src/repository/kodi_repository.rs +++ b/src/repository/kodi_repository.rs @@ -3,14 +3,14 @@ use crate::tuliprox_error::{TuliproxError, TuliproxErrorKind}; use crate::model::{ApiProxyServerInfo, ProxyUserCredentials}; use crate::model::{ClusterFlags, Config, ConfigTarget, StrmTargetOutput}; use crate::model::{ - FieldGetAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType, UUIDType, + FieldGetAccessor, PlaylistGroup, PlaylistItem, PlaylistItemType, UUIDType, ExportStyle, }; use crate::model::XtreamSeriesEpisode; use crate::repository::bplustree::BPlusTree; use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path, hash_bytes}; use crate::repository::storage_const; use crate::repository::xtream_repository::{xtream_get_record_file_path, InputVodInfoRecord}; -use crate::utils::{KodiStyle, CONSTANTS}; +use crate::utils::{ExportStyleConfig, CONSTANTS}; use crate::utils::FileReadGuard; use crate::utils::request::extract_extension_from_url; use chrono::Datelike; @@ -25,24 +25,51 @@ use tokio::fs::{create_dir_all, remove_dir, remove_file, File}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter}; use crate::utils; +/// 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 { - text.trim() + // A default placeholder for filenames that become empty after sanitization. + const EMPTY_FILENAME_REPLACEMENT: &str = "unnamed"; + + // 1. Trim leading/trailing whitespace. + let trimmed = text.trim(); + + // 2. Build the sanitized string by filtering and mapping characters. + let mut sanitized: String = trimmed .chars() - .filter(|c| c.is_alphanumeric() || c.is_whitespace() || (*c == '(') || (*c == ')')) - .map(|c| { - if underscore_whitespace { - if c.is_whitespace() { - '_' + .filter_map(|c| { + // Decide which characters to keep or transform. + if c.is_alphanumeric() { + Some(c) + } else if "+=,._-@#()".contains(c) { // <-- Allow list of safe punctuation. + Some(c) + } else if c.is_whitespace() { + if underscore_whitespace { + Some('_') } else { - c + Some(' ') } } else { - c + // Discard all other characters. + None } }) - .collect::() + .collect(); + + // 3. Remove any leading periods to prevent creating hidden files/directories. + while sanitized.starts_with('.') { + sanitized.remove(0); + } + + // 4. Final check: If sanitization resulted in an empty string, return a default. + if sanitized.is_empty() { + EMPTY_FILENAME_REPLACEMENT.to_string() + } else { + sanitized + } } +/// Finds and removes the first regex match, returning the modified string and the match. fn extract_match(name: &str, pattern: &Regex) -> (String, Option) { pattern.find(name).map_or_else( || (name.to_string(), None), @@ -54,6 +81,7 @@ fn extract_match(name: &str, pattern: &Regex) -> (String, Option) { ) } +//// 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, @@ -68,25 +96,28 @@ fn extract_season_or_episode_match_with_default( (new_name, new_value) } -fn kodi_style_rename_season( +// Extracts and removes an episode number (e.g., "E01") based on style patterns. +fn style_rename_season( name: &str, - style: &KodiStyle, + style: &ExportStyleConfig, series_season: Option<&String>, ) -> (String, Option) { extract_season_or_episode_match_with_default(name, &style.season, series_season) } -fn kodi_style_rename_episode( +/// Renames episode numbers in media titles according to style patterns +fn style_rename_episode( name: &str, - style: &KodiStyle, + style: &ExportStyleConfig, series_episode: Option<&String>, ) -> (String, Option) { extract_season_or_episode_match_with_default(name, &style.episode, series_episode) } -fn kodi_style_rename_year<'a>( +/// Extracts and formats year information from media titles +fn style_rename_year<'a>( name: &'a str, - style: &KodiStyle, + style: &ExportStyleConfig, release_date: Option<&'a String>, ) -> (&'a str, Option) { let mut years = Vec::new(); @@ -96,13 +127,15 @@ fn kodi_style_rename_year<'a>( let mut last_index = 0; for caps in style.year.captures_iter(name) { - if let Ok(year) = caps[0].parse::() { - if (1900..=cur_year).contains(&year) { - years.push(year); - let match_start = caps.get(0).unwrap().start(); - let match_end = caps.get(0).unwrap().end(); - new_name.push_str(&name[last_index..match_start]); - last_index = match_end; + if let Some(year_match) = caps.get(1) { + if let Ok(year) = year_match.as_str().parse::() { + if (1900..=cur_year).contains(&year) { + years.push(year); + let match_start = caps.get(0).unwrap().start(); + let match_end = caps.get(0).unwrap().end(); + new_name.push_str(&name[last_index..match_start]); + last_index = match_end; + } } } } @@ -123,131 +156,6 @@ fn kodi_style_rename_year<'a>( (Box::leak(new_name.into_boxed_str()), smallest_year) } -fn trim_string_after_pos(input: &str, start_pos: usize) -> Option { - if let Some(slice) = input.get(start_pos..) { - if let Some(index) = slice.find(|c: char| c.is_alphanumeric()) { - return Some(String::from(&slice[index..])); - } - } - None -} - -fn trim_whitespace(pattern: &Regex, input: &str) -> String { - pattern.replace_all(input, " ").to_string() -} - -async fn kodi_style_rename( - cfg: &Config, - strm_item_info: &StrmItemInfo, - style: &KodiStyle, - input_tmdb_indexes: &mut InputTmdbIndexMap, - underscore_whitespace: bool, -) -> (PathBuf, String) { - let separator = if underscore_whitespace { "_" } else { " " }; - let (name_1, year_from_title) = kodi_style_rename_year( - &strm_item_info.title, - style, - strm_item_info.release_date.as_ref(), - ); - let mut series_year = year_from_title; - let (name_2, season) = kodi_style_rename_season(name_1, style, strm_item_info.season.as_ref()); - let (name_3, episode) = - kodi_style_rename_episode(&name_2, style, strm_item_info.episode.as_ref()); - let name_4 = trim_whitespace( - &style.whitespace, - &style.alphanumeric.replace_all(&name_3, ""), - ); - let title = &strm_item_info - .series_name - .as_ref() - .filter(|&series_name| name_4.starts_with(series_name)) - .and_then(|series_name| trim_string_after_pos(&name_3, series_name.len())); - let tmdb_id = if let Some(value) = match strm_item_info.item_type { - PlaylistItemType::Series | PlaylistItemType::Video => { - 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 - } - _ => None, - } { - match value { - InputTmdbIndexValue::Video(vod_record) => vod_record.tmdb_id, - InputTmdbIndexValue::Series(episode) => episode.tmdb_id, - } - } else { - 0 - }; - - let mut file_dir = vec![strm_item_info.group.to_string()]; - let mut filename = vec![]; - - let sanitized_name = sanitize_for_filename(&name_4, underscore_whitespace); - filename.push(sanitized_name.clone()); - - let dir_name = if let Some(series_name) = &strm_item_info.series_name { - let (folder_name, year) = - kodi_style_rename_year(series_name, style, strm_item_info.release_date.as_ref()); - if let (Some(y), Some(sy)) = (year, series_year) { - if y < sy { - series_year = year; - } - } - trim_whitespace( - &style.whitespace, - &style.alphanumeric.replace_all(folder_name, ""), - ) - } else { - name_4 - }; - - let sanitized_dir_name = sanitize_for_filename(&dir_name, underscore_whitespace); - if let Some(value) = series_year { - filename.push(format!("{separator}({value})")); - file_dir.push(format!("{sanitized_dir_name}{separator}({value})")); - } else { - file_dir.push(sanitized_dir_name); - } - - if let Some(value) = season { - filename.push(format!("{separator}S{value:02}")); - file_dir.push(format!("Season{separator}{value}")); - } - - if let Some(value) = episode { - if season.is_none() { - filename.push(separator.to_string()); - } - filename.push(format!("E{value:02}")); - } - if let Some(value) = title { - let sanitized_value = sanitize_for_filename( - &trim_whitespace( - &style.whitespace, - &style.alphanumeric.replace_all(value, ""), - ), - underscore_whitespace, - ); - if !filename.iter().any(|e| e.contains(&sanitized_value)) { - filename.push(format!("{separator}-{separator}{sanitized_value}", )); - } - } - - if tmdb_id > 0 { - filename.push(format!("{separator}{{tmdb={tmdb_id}}}")); - } - let kodi_filename = filename.join(""); - - let mut path = PathBuf::new(); - for dir in file_dir { - path.push(sanitize_for_filename(&dir, underscore_whitespace)); - } - (path, kodi_filename) -} - #[derive(Clone)] enum InputTmdbIndexTree { Video(BPlusTree), @@ -336,6 +244,7 @@ pub fn strm_get_file_paths(target_path: &Path) -> PathBuf { } #[derive(Serialize)] +#[serde(rename_all = "camelCase")] struct StrmItemInfo { group: String, title: String, @@ -503,12 +412,301 @@ struct StrmFile { strm_info: StrmItemInfo, } +/// Formats names according to the official Kodi documentation, with TMDb ID for better matching. +/// Movie: /Movie Name (Year) {tmdb=XXXXX}/Movie Name (Year).strm +/// Series: /Show Name (Year) {tmdb=XXXXX}/Season 01/Show Name S01E01.strm +fn format_for_kodi( + strm_item_info: &StrmItemInfo, + tmdb_id: u32, + separator: &str, + flat: bool, +) -> (PathBuf, String) { + let mut dir_path = PathBuf::new(); + let category = sanitize_for_filename(&strm_item_info.group, false); + + match strm_item_info.item_type { + PlaylistItemType::Video => { + let id_string = if tmdb_id > 0 { format!("{}{{tmdb={}}}", separator, tmdb_id) } else { String::new() }; + let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_title = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let base_name = format!("{}{}", sanitized_title, year_string); + let folder_name = format!("{}{}", base_name, id_string); + let final_filename = base_name; // Kodi is flexible, filename without ID is clean. The folder has the ID. + + if flat { + dir_path.push(format!("{}{separator}[{}]", folder_name, category)); + } else { + dir_path.push(category); + dir_path.push(folder_name); + } + (dir_path, final_filename) + } + PlaylistItemType::Series => { + let id_string = if tmdb_id > 0 { format!("{}{{tmdb={}}}", separator, tmdb_id) } else { String::new() }; + let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); + let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_series_name = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let series_folder_name = format!("{}{}{}", sanitized_series_name, year_string, id_string); + + 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 final_filename = format!("{}{separator}S{:02}E{:02}", sanitized_series_name, season_num, episode_num); + let season_folder = format!("Season{}{:02}", separator, season_num); + + if flat { + dir_path.push(format!("{}{separator}[{}]", series_folder_name, category)); + dir_path.push(season_folder); + } else { + dir_path.push(category); + dir_path.push(series_folder_name); + dir_path.push(season_folder); + } + (dir_path, final_filename) + } + _ => (PathBuf::new(), sanitize_for_filename(&strm_item_info.title, separator == "_")), + } +} + +/// Formats names according to the official Plex documentation. +/// Movie: /Movie Name (Year) {tmdb-XXXXX}/Movie Name (Year).strm +/// Series: /Show Name (Year) {tmdb-XXXXX}/Season 01/Show Name - s01e01.strm +fn format_for_plex( + strm_item_info: &StrmItemInfo, + tmdb_id: u32, + separator: &str, + flat: bool, +) -> (PathBuf, String) { + let mut dir_path = PathBuf::new(); + let category = sanitize_for_filename(&strm_item_info.group, false); + + match strm_item_info.item_type { + PlaylistItemType::Video => { + let id_string = if tmdb_id > 0 { format!("{}{{tmdb-{}}}", separator, tmdb_id) } else { String::new() }; + let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_title = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let base_name = format!("{}{}", sanitized_title, year_string); + let folder_name = format!("{}{}", base_name, id_string); + let final_filename = base_name; // Plex: ID is only in the folder name. + + if flat { + dir_path.push(format!("{}{separator}[{}]", folder_name, category)); + } else { + dir_path.push(category); + dir_path.push(folder_name); + } + (dir_path, final_filename) + } + PlaylistItemType::Series => { + let id_string = if tmdb_id > 0 { format!("{}{{tmdb-{}}}", separator, tmdb_id) } else { String::new() }; + let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); + let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_series_name = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let series_folder_name = format!("{}{}{}", sanitized_series_name, year_string, id_string); + + 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); + + // Plex standard: lowercase 's' and hyphens as separators. + let final_filename = format!("{} - s{:02}e{:02}", sanitized_series_name, season_num, episode_num); + let season_folder = format!("Season{}{:02}", separator, season_num); + + if flat { + dir_path.push(format!("{}{separator}[{}]", series_folder_name, category)); + dir_path.push(season_folder); + } else { + dir_path.push(category); + dir_path.push(series_folder_name); + dir_path.push(season_folder); + } + (dir_path, final_filename) + } + _ => (PathBuf::new(), sanitize_for_filename(&strm_item_info.title, separator == "_")), + } +} + +/// Formats names according to the official Emby documentation. +/// Movie: /Movie Name (Year)/Movie Name (Year) [tmdbid=XXXXX].strm +/// Series: /Show Name (Year) [tmdbid=XXXXX]/Season 01/Show Name - S01E01.strm +fn format_for_emby( + strm_item_info: &StrmItemInfo, + tmdb_id: u32, + separator: &str, + flat: bool, +) -> (PathBuf, String) { + let mut dir_path = PathBuf::new(); + let category = sanitize_for_filename(&strm_item_info.group, false); + + match strm_item_info.item_type { + PlaylistItemType::Video => { + // Emby prefers the ID in the filename. + let id_string = if tmdb_id > 0 { format!("{} [tmdbid={}]", separator, tmdb_id) } else { String::new() }; + let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_title = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let base_name = format!("{}{}", sanitized_title, year_string); + let folder_name = base_name.clone(); // Folder name does not contain the ID. + let final_filename = format!("{}{}", base_name, id_string); + + if flat { + dir_path.push(format!("{}{separator}[{}]", folder_name, category)); + } else { + dir_path.push(category); + dir_path.push(folder_name); + } + (dir_path, final_filename) + } + PlaylistItemType::Series => { + // For series, the ID goes in the folder name. + let id_string = if tmdb_id > 0 { format!("{} [tmdbid={}]", separator, tmdb_id) } else { String::new() }; + let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); + let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_series_name = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let series_folder_name = format!("{}{}{}", sanitized_series_name, year_string, id_string); + + 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); + + // Emby/Jellyfin standard: uppercase 'S' and hyphens. + let final_filename = format!("{} - S{:02}E{:02}", sanitized_series_name, season_num, episode_num); + let season_folder = format!("Season{}{:02}", separator, season_num); + + if flat { + dir_path.push(format!("{}{separator}[{}]", series_folder_name, category)); + dir_path.push(season_folder); + } else { + dir_path.push(category); + dir_path.push(series_folder_name); + dir_path.push(season_folder); + } + (dir_path, final_filename) + } + _ => (PathBuf::new(), sanitize_for_filename(&strm_item_info.title, separator == "_")), + } +} + +/// Formats names according to the official Jellyfin documentation. +/// Movie: /Movie Name (Year) [tmdbid-XXXXX]/Movie Name (Year).strm +/// Series: /Show Name (Year) [tmdbid-XXXXX]/Season 01/Show Name - S01E01.strm +fn format_for_jellyfin( + strm_item_info: &StrmItemInfo, + tmdb_id: u32, + separator: &str, + flat: bool, +) -> (PathBuf, String) { + let mut dir_path = PathBuf::new(); + let category = sanitize_for_filename(&strm_item_info.group, false); + + match strm_item_info.item_type { + PlaylistItemType::Video => { + let id_string = if tmdb_id > 0 { format!("{} [tmdbid-{}]", separator, tmdb_id) } else { String::new() }; + let (name, year) = style_rename_year(&strm_item_info.title, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_title = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let base_name = format!("{}{}", sanitized_title, year_string); + let folder_name = format!("{}{}", base_name, id_string); + let final_filename = base_name; // Jellyfin: ID is only in the folder name. + + if flat { + dir_path.push(format!("{}{separator}[{}]", folder_name, category)); + } else { + dir_path.push(category); + dir_path.push(folder_name); + } + (dir_path, final_filename) + } + PlaylistItemType::Series => { + let id_string = if tmdb_id > 0 { format!("{} [tmdbid-{}]", separator, tmdb_id) } else { String::new() }; + let series_name_raw = strm_item_info.series_name.as_ref().unwrap_or(&strm_item_info.title); + let (name, year) = style_rename_year(series_name_raw, &CONSTANTS.export_style_config, strm_item_info.release_date.as_ref()); + let sanitized_series_name = sanitize_for_filename(name.trim(), false); + let year_string = year.map_or(String::new(), |y| format!("{}({})", separator, y)); + + let series_folder_name = format!("{}{}{}", sanitized_series_name, year_string, id_string); + + 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); + + // Emby/Jellyfin standard: uppercase 'S' and hyphens. + let final_filename = format!("{} - S{:02}E{:02}", sanitized_series_name, season_num, episode_num); + let season_folder = format!("Season{}{:02}", separator, season_num); + + if flat { + dir_path.push(format!("{}{separator}[{}]", series_folder_name, category)); + dir_path.push(season_folder); + } else { + dir_path.push(category); + dir_path.push(series_folder_name); + dir_path.push(season_folder); + } + (dir_path, final_filename) + } + _ => (PathBuf::new(), sanitize_for_filename(&strm_item_info.title, separator == "_")), + } +} + + +/// 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: &Config, + strm_item_info: &StrmItemInfo, + input_tmdb_indexes: &mut InputTmdbIndexMap, + style: &ExportStyle, + 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 = tmdb_id_val.map_or(0, |v| match v { + InputTmdbIndexValue::Video(r) => r.tmdb_id, + InputTmdbIndexValue::Series(e) => e.tmdb_id, + }); + + // Dispatch the call to the responsible function based on the style. + match style { + ExportStyle::Kodi => format_for_kodi(strm_item_info, tmdb_id, separator, flat), + ExportStyle::Plex => format_for_plex(strm_item_info, tmdb_id, separator, flat), + ExportStyle::Emby => format_for_emby(strm_item_info, tmdb_id, separator, flat), + ExportStyle::Jellyfin => format_for_jellyfin(strm_item_info, tmdb_id, separator, flat), + } +} + async fn prepare_strm_files( cfg: &Config, new_playlist: &mut [PlaylistGroup], - root_path: &Path, + _root_path: &Path, underscore_whitespace: bool, - kodi_style: bool, + style: &ExportStyle, + flat: bool, ) -> Vec { let channel_count = new_playlist .iter() @@ -525,23 +723,16 @@ async fn prepare_strm_files( for pg in new_playlist.iter_mut() { for pli in pg.channels.iter_mut().filter(|c| filter_strm_item(c)) { let strm_item_info = extract_item_info(pli); - let (dir_path, strm_file_name) = if kodi_style { - kodi_style_rename( + + let (dir_path, strm_file_name) = style_based_rename( cfg, &strm_item_info, - &CONSTANTS.kodi_style, &mut input_tmdb_indexes, + style, underscore_whitespace, - ).await - } else { - let dir_path = root_path.join(sanitize_for_filename( - &strm_item_info.group, - underscore_whitespace, - )); - let strm_file_name = - sanitize_for_filename(&strm_item_info.title, underscore_whitespace); - (dir_path, strm_file_name) - }; + flat, + ).await; + let filename = Arc::new(strm_file_name); if all_filenames.contains(&filename) { collisions.insert(Arc::clone(&filename)); @@ -562,7 +753,7 @@ async fn prepare_strm_files( .filter(|s| collisions.contains(&s.file_name)) .for_each(|s| { s.file_name = Arc::new(format!( - "{}{separator}-{separator}[{}]", + "{}{separator}[{}]", s.file_name, s.strm_info.virtual_id )); }); @@ -570,7 +761,7 @@ async fn prepare_strm_files( result } -pub async fn kodi_write_strm_playlist( +pub async fn write_strm_playlist( target: &ConfigTarget, target_output: &StrmTargetOutput, cfg: &Config, @@ -620,7 +811,8 @@ pub async fn kodi_write_strm_playlist( new_playlist, &root_path, target_output.underscore_whitespace, - target_output.kodi_style, + &target_output.style, + target_output.flat, ).await; for strm_file in strm_files { // file paths diff --git a/src/repository/playlist_repository.rs b/src/repository/playlist_repository.rs index b308f4c46..face0f80f 100644 --- a/src/repository/playlist_repository.rs +++ b/src/repository/playlist_repository.rs @@ -4,7 +4,7 @@ use crate::model::{Config, ConfigTarget, TargetOutput}; use crate::model::{PlaylistGroup, PlaylistItemType}; use crate::model::Epg; use crate::repository::epg_repository::epg_write; -use crate::repository::kodi_repository::kodi_write_strm_playlist; +use crate::repository::kodi_repository::write_strm_playlist; use crate::repository::m3u_repository::m3u_write_playlist; use crate::repository::storage::{ensure_target_storage_path, get_target_id_mapping_file}; use crate::repository::target_id_mapping::TargetIdMapping; @@ -51,7 +51,7 @@ pub async fn persist_playlist(playlist: &mut [PlaylistGroup], epg: Option<&Epg>, let result = match output { TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(target, cfg, playlist).await, TargetOutput::M3u(m3u_output) => m3u_write_playlist(cfg, target, m3u_output, &target_path, playlist).await, - TargetOutput::Strm(strm_output) => kodi_write_strm_playlist(target, strm_output, cfg, playlist).await, + TargetOutput::Strm(strm_output) => write_strm_playlist(target, strm_output, cfg, playlist).await, TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()), }; diff --git a/src/repository/xtream_repository.rs b/src/repository/xtream_repository.rs index dd099efe5..3e8893d28 100644 --- a/src/repository/xtream_repository.rs +++ b/src/repository/xtream_repository.rs @@ -3,6 +3,7 @@ use crate::model::{ProxyUserCredentials}; use crate::model::{Config, ConfigInput, ConfigTarget, XtreamTargetOutput}; use crate::model::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; use crate::model::{rewrite_doc_urls, PlaylistXtreamCategory, XtreamMappingOptions, XtreamSeriesEpisode}; +use crate::model::normalize_release_date; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; use crate::repository::indexed_document::{IndexedDocumentDirectAccess, IndexedDocumentGarbageCollector, IndexedDocumentIterator, IndexedDocumentWriter}; use crate::repository::playlist_repository::get_target_id_mapping; @@ -29,6 +30,7 @@ use std::io::{BufReader, Error, ErrorKind, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; + macro_rules! cant_write_result { ($path:expr, $err:expr) => { create_tuliprox_error!( @@ -50,10 +52,11 @@ macro_rules! try_option_ok { } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] pub struct InputVodInfoRecord { pub(crate) tmdb_id: u32, pub(crate) ts: u64, + pub(crate) release_date: Option, } fn get_collection_path(path: &Path, collection: &str) -> PathBuf { @@ -529,6 +532,7 @@ fn rewrite_xtream_vod_info

( // we need to update the info data. if config.is_reverse_proxy_resource_rewrite_enabled() { if let Some(Value::Object(info_data)) = doc.get_mut(crate::model::XC_TAG_INFO_DATA) { + normalize_release_date(info_data); // info data is muteable, so we do not need to clone it. let item_type = pli.get_item_type(); if user.proxy.is_reverse(item_type) && !target.is_force_redirect(item_type) { let server_info = config.get_user_server_info(user); @@ -783,22 +787,50 @@ pub async fn xtream_update_input_vod_record_from_wal_file( let mut tmdb_id_bytes = [0u8; 4]; let mut ts_bytes = [0u8; 8]; let mut tree_record_index: BPlusTree = BPlusTree::load(&record_path).unwrap_or_else(|_| BPlusTree::new()); + loop { if reader.read_exact(&mut provider_id_bytes).is_err() { break; // End of file } let provider_id = u32::from_le_bytes(provider_id_bytes); + if reader.read_exact(&mut tmdb_id_bytes).is_err() { - break; // End of file + error!("Unexpected EOF after reading provider_id {} for VOD record.", provider_id); + break; } let tmdb_id = u32::from_le_bytes(tmdb_id_bytes); + if reader.read_exact(&mut ts_bytes).is_err() { - break; // End of file + error!("Unexpected EOF after reading tmdb_id for VOD record with provider_id {}.", provider_id); + break; } let ts = u64::from_le_bytes(ts_bytes); - tree_record_index.insert(provider_id, InputVodInfoRecord { tmdb_id, ts }); + + // Read the date string length as a 4-byte u32. + let mut len_bytes = [0u8; 4]; + if reader.read_exact(&mut len_bytes).is_err() { + error!("Unexpected EOF when reading release_date length for VOD record with provider_id {}.", provider_id); + break; + } + let len = u32::from_le_bytes(len_bytes) as usize; + + let release_date = if len > 0 { + let mut date_buffer = vec![0u8; len]; + if reader.read_exact(&mut date_buffer).is_err() { + error!("Unexpected EOF when reading release_date string for VOD record with provider_id {}.", provider_id); + break; + } + String::from_utf8(date_buffer).ok() + } else { + // If length is 0, set release_date to None + None + }; + + tree_record_index.insert(provider_id, InputVodInfoRecord { tmdb_id, ts, release_date }); } + tree_record_index.store(&record_path).map_err(|err| notify_err!(format!("Could not store vod record info {err}")))?; + drop(reader); if let Err(err) = fs::remove_file(wal_path) { error!("Failed to delete record WAL file for vod {err}"); diff --git a/src/utils/constants.rs b/src/utils/constants.rs index 7ced5f32b..2c95d4b73 100644 --- a/src/utils/constants.rs +++ b/src/utils/constants.rs @@ -60,7 +60,8 @@ pub fn filter_request_header(key: &str) -> bool { true } -pub struct KodiStyle { +/// Configuration for media export naming styles (Kodi, Plex, Emby, Jellyfin) +pub struct ExportStyleConfig { pub year: Regex, pub season: Regex, pub episode: Regex, @@ -86,7 +87,7 @@ pub struct Constants { pub re_whitespace: Regex, pub re_hls_uri: Regex, pub sanitize: AtomicBool, - pub kodi_style: KodiStyle, + pub export_style_config: ExportStyleConfig, pub country_codes: HashSet<&'static str>, pub allowed_output_formats: Vec, } @@ -111,10 +112,10 @@ pub static CONSTANTS: LazyLock = LazyLock::new(|| re_hls_uri: Regex::new(r#"URI="([^"]+)""#).unwrap(), sanitize: AtomicBool::new(true), - kodi_style: KodiStyle { + export_style_config: ExportStyleConfig { season: Regex::new(r"[Ss]\d{1,2}").unwrap(), episode: Regex::new(r"[Ee]\d{1,2}").unwrap(), - year: Regex::new(r"\d{4}").unwrap(), + year: Regex::new(r"(\d{4})").unwrap(), whitespace: Regex::new(r"\s+").unwrap(), alphanumeric: Regex::new(r"[^\w\s]").unwrap(), },