From 5cb5313c780c335dbfdc1b17066ea75d3c6af339 Mon Sep 17 00:00:00 2001 From: euzu Date: Wed, 25 Dec 2024 16:56:05 +0100 Subject: [PATCH] resolve series wip --- src/model/playlist.rs | 9 +-- src/model/xtream.rs | 17 +++- src/processing/xtream_parser.rs | 8 +- src/processing/xtream_processor_series.rs | 42 +++++----- src/repository/kodi_repository.rs | 95 +++++++++++++++++------ src/repository/m3u_playlist_iterator.rs | 3 +- src/repository/xtream_repository.rs | 41 +++++++--- src/utils/json_utils.rs | 63 +++++++++++++++ 8 files changed, 210 insertions(+), 68 deletions(-) diff --git a/src/model/playlist.rs b/src/model/playlist.rs index 4ab5af07d..2450325ea 100644 --- a/src/model/playlist.rs +++ b/src/model/playlist.rs @@ -85,10 +85,9 @@ pub enum PlaylistItemType { Video = 2, Series = 3, // xtream series description SeriesInfo = 4, // xtream series info fetched for series description - SeriesEpisode = 5, // from SeriesInfo parsed episodes - Catchup = 6, - LiveUnknown = 7, // No Provider id - LiveHls = 8, // m3u8 entry + Catchup = 5, + LiveUnknown = 6, // No Provider id + LiveHls = 7, // m3u8 entry } impl From for PlaylistItemType { @@ -106,7 +105,6 @@ impl PlaylistItemType { const VIDEO: &'static str = "video"; const SERIES: &'static str = "series"; const SERIES_INFO: &'static str = "series-info"; - const SERIES_EPISODE: &'static str = "series-episode"; const CATCHUP: &'static str = "catchup"; } @@ -117,7 +115,6 @@ impl Display for PlaylistItemType { Self::Video => Self::VIDEO, Self::Series => Self::SERIES, Self::SeriesInfo => Self::SERIES_INFO, - Self::SeriesEpisode => Self::SERIES_EPISODE, Self::Catchup => Self::CATCHUP, }) } diff --git a/src/model/xtream.rs b/src/model/xtream.rs index 26be76011..92a3b4184 100644 --- a/src/model/xtream.rs +++ b/src/model/xtream.rs @@ -5,6 +5,7 @@ use std::rc::Rc; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; +use crate::utils::json_utils::{string_or_number_u32, string_or_number_f64, opt_string_or_number_u32}; use crate::model::config::ConfigTargetOptions; use crate::model::playlist::{PlaylistItem, XtreamCluster, XtreamPlaylistItem}; @@ -270,7 +271,9 @@ pub struct XtreamSeriesInfoSeason { pub id: u32, pub name: String, pub overview: String, + #[serde(deserialize_with = "string_or_number_u32")] pub season_number: u32, + #[serde(deserialize_with = "string_or_number_f64")] pub vote_average: f64, pub cover: String, pub cover_big: String, @@ -288,38 +291,48 @@ pub struct XtreamSeriesInfoInfo { genre: String, releaseDate: String, last_modified: String, - rating: String, + #[serde(deserialize_with = "string_or_number_f64")] + rating: f64, + #[serde(deserialize_with = "string_or_number_f64")] rating_5based: f64, backdrop_path: Vec, youtube_trailer: String, episode_run_time: String, - category_id: String, + #[serde(deserialize_with = "string_or_number_u32")] + category_id: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamSeriesInfoEpisodeInfo { + #[serde(deserialize_with = "opt_string_or_number_u32")] pub tmdb_id: Option, pub releasedate: String, pub plot: String, + #[serde(deserialize_with = "string_or_number_u32")] pub duration_secs: u32, pub duration: String, pub movie_image: String, pub video: Value, pub audio: Value, + #[serde(deserialize_with = "string_or_number_u32")] pub bitrate: u32, + #[serde(deserialize_with = "string_or_number_f64")] pub rating: f64, + #[serde(deserialize_with = "string_or_number_u32")] pub season: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamSeriesInfoEpisode { pub id: String, + #[serde(deserialize_with = "string_or_number_u32")] pub episode_num: u32, pub title: String, pub container_extension: String, pub info: XtreamSeriesInfoEpisodeInfo, pub custom_sid: String, pub added: String, + #[serde(deserialize_with = "string_or_number_u32")] pub season: u32, pub direct_source: String, } diff --git a/src/processing/xtream_parser.rs b/src/processing/xtream_parser.rs index ce86a15f0..e1ba3f646 100644 --- a/src/processing/xtream_parser.rs +++ b/src/processing/xtream_parser.rs @@ -39,16 +39,16 @@ fn create_xtream_series_info_url(url: &str, username: &str, password: &str, epis } } -pub fn parse_xtream_series_info(info: &Value, group_title: &str, input: &ConfigInput) -> Result>, M3uFilterError> { +pub fn parse_xtream_series_info(info: &Value, group_title: &str, input: &ConfigInput) -> Result>, M3uFilterError> { 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::(info.to_owned()) { Ok(series_info) => { - let result: Vec = series_info.episodes.values().flatten().map(|episode| { - + let result: Vec<(XtreamSeriesInfoEpisode, PlaylistItem)> = series_info.episodes.values().flatten().into_iter().map(|episode| { let episode_url = create_xtream_series_info_url(url, username, password, episode); + (episode.clone(), PlaylistItem { header: RefCell::new(PlaylistItemHeader { id: Rc::new(episode.id.to_string()), @@ -65,7 +65,7 @@ pub fn parse_xtream_series_info(info: &Value, group_title: &str, input: &ConfigI input_id: input.id, ..Default::default() }) - }}).collect(); + })}).collect(); if result.is_empty() { Ok(None) } else { Ok(Some(result)) } } Err(err) => { diff --git a/src/processing/xtream_processor_series.rs b/src/processing/xtream_processor_series.rs index 937b04fc9..cbe441579 100644 --- a/src/processing/xtream_processor_series.rs +++ b/src/processing/xtream_processor_series.rs @@ -3,15 +3,15 @@ use crate::model::config::{Config, ConfigTarget, InputType}; use crate::model::playlist::{FetchedPlaylist, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster}; use crate::processing::playlist_processor::ProcessingPipe; use crate::processing::xtream_parser::parse_xtream_series_info; -use crate::processing::xtream_processor::{create_resolve_episode_wal_files, create_resolve_info_wal_files, get_u32_from_serde_value, playlist_resolve_process_playlist_item, read_processed_info_ids, should_update_info, write_info_content_to_wal_file}; +use crate::processing::xtream_processor::{create_resolve_episode_wal_files, create_resolve_info_wal_files, playlist_resolve_process_playlist_item, read_processed_info_ids, should_update_info, write_info_content_to_wal_file}; use crate::repository::storage::get_input_storage_path; use crate::repository::xtream_repository::{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::{create_resolve_options_function_for_xtream_target, handle_error, handle_error_and_return, info_err, notify_err}; -use serde_json::{Value}; use std::collections::HashMap; use std::fs::File; use std::io::{BufWriter, Write}; +use crate::model::xtream::XtreamSeriesInfoEpisode; const TAG_SERIES_INFO_LAST_MODIFIED: &str = "last_modified"; @@ -34,10 +34,15 @@ fn write_series_info_record_to_wal_file( fn write_series_episode_record_to_wal_file( writer: &mut BufWriter<&File>, provider_id: u32, - tmdb_id: u32, + episode: &XtreamSeriesInfoEpisode, ) -> std::io::Result<()> { - writer.write_all(&provider_id.to_le_bytes())?; - writer.write_all(&tmdb_id.to_le_bytes())?; + if let Ok(content) = serde_json::to_string(episode) { + writer.write_all(&provider_id.to_le_bytes())?; + let content_bytes = content.as_bytes(); + let len = u32::try_from(content_bytes.len()).unwrap(); + writer.write_all(&len.to_le_bytes())?; + writer.write_all(&content_bytes)?; + } Ok(()) } @@ -64,11 +69,11 @@ async fn playlist_resolve_series_info(cfg: &Config, errors: &mut Vec(&content) { Ok(series_content) => { - if let Ok(Some(mut series)) = - parse_xtream_series_info(&series_content, pli.header.borrow().group.as_str(), input) - { - for episode in &series { - let Some(provider_id) = &episode.header.borrow_mut().get_provider_id() else { continue; }; - let Some(Value::Object(props)) = &episode.header.borrow().additional_properties else { continue; }; - let Some(value) = props.get("tmdb_id") else { continue; }; - if let Some(tmdb_id) = get_u32_from_serde_value(value) { - println!("episode {tmdb_id} = {provider_id}"); - handle_error!(write_series_episode_record_to_wal_file(&mut wal_writer, *provider_id, tmdb_id), - |err| errors.push(info_err!(format!("Failed to write to series episode wal file: {err}")))); + match parse_xtream_series_info(&series_content, pli.header.borrow().group.as_str(), input) { + Ok(Some(series)) => { + for (episode, pli_episode) in &series { + let Some(provider_id) = &pli_episode.header.borrow_mut().get_provider_id() else { continue; }; + // TODO only write to wal if not previously handled + like tmdb_id handled or not + handle_error!(write_series_episode_record_to_wal_file(&mut wal_writer, *provider_id, episode), + |err| errors.push(info_err!(format!("Failed to write to series episode wal file: {err}")))); } + group_series.extend(series.into_iter().map(|(_, pli)| pli)); + } + Ok(None) => {} + Err(err) => { + errors.push(err); } - group_series.append(&mut series); } } Err(err) => errors.push(info_err!(format!("Failed to parse JSON: {err}"))), diff --git a/src/repository/kodi_repository.rs b/src/repository/kodi_repository.rs index fcb5545e3..0b6c91095 100644 --- a/src/repository/kodi_repository.rs +++ b/src/repository/kodi_repository.rs @@ -1,11 +1,13 @@ -use crate::{create_m3u_filter_error_result, notify_err}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::model::config::{Config, ConfigTarget}; use crate::model::playlist::{PlaylistGroup, PlaylistItemType}; +use crate::model::xtream::XtreamSeriesInfoEpisode; use crate::repository::bplustree::BPlusTree; use crate::repository::storage::get_input_storage_path; +use crate::repository::xtream_repository::xtream_get_record_file_path; use crate::utils::file_lock_manager::FileReadGuard; use crate::utils::file_utils; +use crate::{create_m3u_filter_error_result, notify_err}; use chrono::Datelike; use log::error; use std::collections::HashMap; @@ -13,7 +15,6 @@ use std::fs::File; use std::io::Write; use std::rc::Rc; use std::sync::LazyLock; -use crate::repository::xtream_repository::xtream_get_record_file_path; struct KodiStyle { year: regex::Regex, @@ -64,21 +65,19 @@ fn kodi_style_rename_episode(name: &str, style: &KodiStyle) -> (String, Option (Vec, String) { - let (work_name_1, year) = kodi_style_rename_year(name, style); - let (work_name_2, season) = kodi_style_rename_season(&work_name_1, style); - let (work_name_3, episode) = kodi_style_rename_episode(&work_name_2, style); - let mut filename = work_name_3; + let (work_name, year) = kodi_style_rename_year(name, style); + + // TODO use this for non xtream input + // let (work_name_2, season) = kodi_style_rename_season(&work_name_1, style); + // let (work_name_3, episode) = kodi_style_rename_episode(&work_name_2, style); + + let mut filename = work_name; let mut filedir = vec![String::from(style.whitespace.replace_all(filename.as_str(), " "))]; - if year.is_some() || season.is_some() { + if year.is_some() { if year.is_some() { filename = format!("{filename} ({})", year.unwrap()); filedir = vec![String::from(style.whitespace.replace_all(filename.as_str(), " "))]; } - if season.is_some() && episode.is_some() { - let season_value = season.unwrap(); - filedir.push(format!("Season {season_value}")); - filename = format!("{filename} S{season_value}E{}", episode.unwrap()); - } return (filedir, String::from(style.whitespace.replace_all(filename.as_str(), " "))); } let new_name = String::from(style.whitespace.replace_all(name, " ")); @@ -92,16 +91,31 @@ static KODI_STYLE: LazyLock = LazyLock::new(|| KodiStyle { whitespace: regex::Regex::new(r"\s+").unwrap(), }); -type InputTmdbIndexMap = HashMap)>>; -async fn get_tmdb_id(cfg: &Config, provider_id: Option, input_id: u16, - input_indexes: &mut InputTmdbIndexMap, item_type: PlaylistItemType) -> Option { +#[derive(Clone)] +enum InputTmdbIndexTree { + Video(BPlusTree), + Series(BPlusTree), +} + +#[derive(Clone)] +enum InputTmdbIndexValue { + Video(u32), + Series(XtreamSeriesInfoEpisode), +} + +type InputTmdbIndexMap = HashMap>; +async fn get_tmdb_value(cfg: &Config, provider_id: Option, input_id: u16, + input_indexes: &mut InputTmdbIndexMap, item_type: PlaylistItemType) -> Option { match provider_id { None => None, Some(pid) => { match input_indexes.entry(input_id) { std::collections::hash_map::Entry::Occupied(entry) => { - if let Some((_, tree)) = entry.get() { - tree.query(&pid).copied() + if let Some((_, tree_value)) = entry.get() { + match tree_value { + InputTmdbIndexTree::Video(tree) => tree.query(&pid).map(|id| InputTmdbIndexValue::Video(*id)), + InputTmdbIndexTree::Series(tree) => tree.query(&pid).map(|episode| InputTmdbIndexValue::Series(episode.clone())) + } } else { None } @@ -111,10 +125,22 @@ async fn get_tmdb_id(cfg: &Config, provider_id: Option, input_id: u16, if let Ok(Some(tmdb_path)) = get_input_storage_path(input, &cfg.working_dir) .map(|storage_path| xtream_get_record_file_path(&storage_path, item_type)) { if let Ok(file_lock) = cfg.file_locks.read_lock(&tmdb_path).await { - if let Ok(tree) = BPlusTree::::load(&tmdb_path) { - let tmdb_id = tree.query(&pid).copied(); - entry.insert(Some((file_lock, tree))); - return tmdb_id; + match item_type { + PlaylistItemType::Series => { + if let Ok(tree) = BPlusTree::::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 => { + if let Ok(tree) = BPlusTree::::load(&tmdb_path) { + let tmdb_id = tree.query(&pid).map(|id| InputTmdbIndexValue::Video(*id)); + entry.insert(Some((file_lock, InputTmdbIndexTree::Video(tree)))); + return tmdb_id; + } + } + _ => {} } }; }; @@ -148,7 +174,12 @@ pub async fn kodi_write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_p let mut input_tmdb_indexes: InputTmdbIndexMap = HashMap::new(); for pg in new_playlist { - for pli in &pg.channels { + for pli in pg.channels.iter().filter(|&pli| { + let item_type = pli.header.borrow().item_type; + item_type == PlaylistItemType::Series + || item_type == PlaylistItemType::Live + || item_type == PlaylistItemType::Video + }) { let (group, title, item_type, provider_id, input_id, url) = { let mut header = pli.header.borrow_mut(); let group = Rc::clone(&header.group); @@ -168,10 +199,24 @@ pub async fn kodi_write_strm_playlist(target: &ConfigTarget, cfg: &Config, new_p kodi_file_name = kodi_style_filename; kodi_file_dir_name.iter().for_each(|p| dir_path = dir_path.join(p)); - let tmdb_id = get_tmdb_id(cfg, provider_id, input_id, &mut input_tmdb_indexes, item_type).await; - additional_info = match tmdb_id { + let tmdb_value = match item_type { + PlaylistItemType::Series | PlaylistItemType::Video => get_tmdb_value(cfg, provider_id, input_id, &mut input_tmdb_indexes, item_type).await, + _ => None, + }; + additional_info = match tmdb_value { None => { String::new() } - Some(id) => { format!(" {{tmdb={id}}}") } + Some(value) => { + match value { + InputTmdbIndexValue::Video(tmdb_id) => format!(" {{tmdb={tmdb_id}}}"), + InputTmdbIndexValue::Series(episode) => { + let mut episode_ext = format!(" S{:02}E{:02}", episode.season, episode.episode_num); + if let Some(tmdb_id) = episode.info.tmdb_id { + episode_ext = format!("{episode_ext} {{tmdb={tmdb_id}}}"); + } + episode_ext + } + } + } }; } if let Err(e) = std::fs::create_dir_all(&dir_path) { diff --git a/src/repository/m3u_playlist_iterator.rs b/src/repository/m3u_playlist_iterator.rs index 51c9b8d89..a815cdc90 100644 --- a/src/repository/m3u_playlist_iterator.rs +++ b/src/repository/m3u_playlist_iterator.rs @@ -68,8 +68,7 @@ impl M3uPlaylistIterator { | PlaylistItemType::LiveHls => "live", PlaylistItemType::Video => "movie", PlaylistItemType::Series - | PlaylistItemType::SeriesInfo - | PlaylistItemType::SeriesEpisode => "series", + | PlaylistItemType::SeriesInfo => "series", }; format!("{}/{M3U_STREAM_PATH}/{stream_type}/{}/{}/{}", &self.base_url, diff --git a/src/repository/xtream_repository.rs b/src/repository/xtream_repository.rs index eaf717546..216079c18 100644 --- a/src/repository/xtream_repository.rs +++ b/src/repository/xtream_repository.rs @@ -10,7 +10,7 @@ use serde_json::{json, Map, Value}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::model::config::{Config, ConfigInput, ConfigTarget}; use crate::model::playlist::{PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; -use crate::model::xtream::XtreamMappingOptions; +use crate::model::xtream::{XtreamMappingOptions, XtreamSeriesInfoEpisode}; use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate}; use crate::repository::indexed_document::{IndexedDocumentGarbageCollector, IndexedDocumentWriter, IndexedDocumentDirectAccess}; use crate::repository::storage::{get_input_storage_path, get_target_id_mapping_file, get_target_storage_path, hash_string, FILE_SUFFIX_DB, FILE_SUFFIX_INDEX}; @@ -104,8 +104,8 @@ pub fn xtream_get_info_file_paths( pub fn xtream_get_record_file_path(storage_path: &Path, item_type: PlaylistItemType) -> Option { match item_type { PlaylistItemType::Video => Some(storage_path.join(format!("{FILE_VOD_INFO_RECORD}.{FILE_SUFFIX_DB}"))), - PlaylistItemType::SeriesInfo | PlaylistItemType::Series => Some(storage_path.join(format!("{FILE_SERIES_INFO_RECORD}.{FILE_SUFFIX_DB}"))), - PlaylistItemType::SeriesEpisode => Some(storage_path.join(format!("{FILE_SERIES_EPISODE_RECORD}.{FILE_SUFFIX_DB}"))), + PlaylistItemType::SeriesInfo => Some(storage_path.join(format!("{FILE_SERIES_INFO_RECORD}.{FILE_SUFFIX_DB}"))), + PlaylistItemType::Series => Some(storage_path.join(format!("{FILE_SERIES_EPISODE_RECORD}.{FILE_SUFFIX_DB}"))), _ => None, } } @@ -390,7 +390,7 @@ pub async fn xtream_get_item_for_stream_id( PlaylistItemType::SeriesInfo => { xtream_read_series_item_for_stream_id(config, virtual_id, &storage_path).await } - PlaylistItemType::SeriesEpisode => { + PlaylistItemType::Series => { let mut item = xtream_read_series_item_for_stream_id(config, mapping.parent_virtual_id, &storage_path).await?; item.provider_id = mapping.provider_id; Ok(item) @@ -629,7 +629,7 @@ pub async fn write_and_get_xtream_series_info

( let episode_virtual_id = target_id_mapping.insert_entry( uuid, provider_id, - PlaylistItemType::SeriesEpisode, + PlaylistItemType::Series, virtual_id, ); episode.insert( @@ -798,25 +798,44 @@ pub async fn xtream_update_input_series_episodes_record_from_wal_file( input: &ConfigInput, wal_path: &Path, ) -> Result<(), M3uFilterError> { - let record_path = get_input_storage_path(input, &cfg.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::SeriesEpisode)) + let record_path = get_input_storage_path(input, &cfg.working_dir).map(|storage_path| xtream_get_record_file_path(&storage_path, PlaylistItemType::Series)) .map_err(|err| notify_err!(format!("Error accessing storage path: {err}"))) .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", input.name.clone().unwrap_or_else(|| input.id.to_string())))))?; match cfg.file_locks.write_lock(&record_path).await { Ok(_file_lock) => { let mut reader = BufReader::new(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read series episode wal info {err}")))?); let mut provider_id_bytes = [0u8; 4]; - let mut tmdb_id_bytes = [0u8; 4]; - let mut tree_record_index: BPlusTree = BPlusTree::load(&record_path).unwrap_or_else(|_| BPlusTree::new()); + let mut len_bytes = [0u8; 4]; + let mut tree_record_index: BPlusTree = BPlusTree::load(&record_path).unwrap_or_else(|_| BPlusTree::new()); + let mut buffer = vec![0u8; 4096]; 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() { + if reader.read_exact(&mut len_bytes).is_err() { break; // End of file } - let tmdb_id = u32::from_le_bytes(tmdb_id_bytes); - tree_record_index.insert(provider_id, tmdb_id); + let len = usize::try_from(u32::from_le_bytes(len_bytes)).unwrap_or(0); + if len <= 0 { + break; + } + if len > buffer.len() { + buffer = vec![0u8; len]; + } + if reader.read_exact(&mut buffer[0..len]).is_err() { + break; + } + let content = String::from_utf8_lossy(&buffer[0..len]); + + match serde_json::from_str(&content) { + Ok(episode) => { + tree_record_index.insert(provider_id, episode); + }, + Err(err) => { + error!("Failed to delete deserialize record WAL file for series episode {err}"); + } + } } tree_record_index.store(&record_path).map_err(|err| notify_err!(format!("Could not store series episode record info {err}")))?; drop(reader); diff --git a/src/utils/json_utils.rs b/src/utils/json_utils.rs index 3448a62cd..ddd73ef5c 100644 --- a/src/utils/json_utils.rs +++ b/src/utils/json_utils.rs @@ -101,4 +101,67 @@ where } Err(e) => Err(e) } +} + +pub fn string_or_number_u32<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value: Value = serde::Deserialize::deserialize(deserializer)?; + + match value { + Value::Number(num) => { + if let Some(v) = num.as_u64() { + u32::try_from(v).map_err(|_| serde::de::Error::custom("Number out of range for u32")) + } else { + Err(serde::de::Error::custom("Invalid number")) + } + }, + Value::String(s) => s + .parse::() + .map_err(|_| serde::de::Error::custom("Invalid string number")), + _ => Err(serde::de::Error::custom("Expected number or string")), + } +} + +pub fn opt_string_or_number_u32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value: Value = serde::Deserialize::deserialize(deserializer)?; + + match value { + Value::Null => Ok(None), // Handle null explicitly + Value::Number(num) => { + if let Some(v) = num.as_u64() { + u32::try_from(v) + .map(Some) + .map_err(|_| serde::de::Error::custom("Number out of range for u32")) + } else { + Err(serde::de::Error::custom("Invalid number")) + } + }, + Value::String(s) => s + .parse::() + .map(Some) + .map_err(|_| serde::de::Error::custom("Invalid string number")), + _ => Err(serde::de::Error::custom("Expected number, string, or null")), + } +} + +pub fn string_or_number_f64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value: Value = serde::Deserialize::deserialize(deserializer)?; + + match value { + Value::Number(num) => num + .as_f64() + .ok_or_else(|| serde::de::Error::custom("Invalid number")), + Value::String(s) => s + .parse::() + .map_err(|_| serde::de::Error::custom("Invalid string number")), + _ => Err(serde::de::Error::custom("Expected number or string")), + } } \ No newline at end of file