vuio/web/
handlers.rs

1use crate::{
2    database::MediaDirectory,
3    error::AppError,
4    state::AppState,
5    web::xml::{generate_description_xml, generate_scpd_xml},
6};
7use axum::{
8    body::Body,
9    extract::{Path, State},
10    http::{header, HeaderMap, Method, StatusCode},
11    response::{IntoResponse, Response},
12};
13use futures_util::StreamExt;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::Instant;
16use tokio::io::AsyncSeekExt;
17use tokio_util::io::ReaderStream;
18use tracing::{debug, error, info, warn};
19
20/// Atomic performance tracking for web handlers
21pub struct WebHandlerMetrics {
22    pub browse_requests: AtomicU64,
23    pub cache_hits: AtomicU64,
24    pub cache_misses: AtomicU64,
25    pub directory_listings: AtomicU64,
26    pub file_serves: AtomicU64,
27    pub errors: AtomicU64,
28    pub total_response_time_ms: AtomicU64,
29}
30
31impl WebHandlerMetrics {
32    pub fn new() -> Self {
33        Self {
34            browse_requests: AtomicU64::new(0),
35            cache_hits: AtomicU64::new(0),
36            cache_misses: AtomicU64::new(0),
37            directory_listings: AtomicU64::new(0),
38            file_serves: AtomicU64::new(0),
39            errors: AtomicU64::new(0),
40            total_response_time_ms: AtomicU64::new(0),
41        }
42    }
43    
44    pub fn record_browse_request(&self, response_time_ms: u64, cache_hit: bool) {
45        self.browse_requests.fetch_add(1, Ordering::Relaxed);
46        self.total_response_time_ms.fetch_add(response_time_ms, Ordering::Relaxed);
47        if cache_hit {
48            self.cache_hits.fetch_add(1, Ordering::Relaxed);
49        } else {
50            self.cache_misses.fetch_add(1, Ordering::Relaxed);
51        }
52    }
53    
54    pub fn record_directory_listing(&self, response_time_ms: u64) {
55        self.directory_listings.fetch_add(1, Ordering::Relaxed);
56        self.total_response_time_ms.fetch_add(response_time_ms, Ordering::Relaxed);
57    }
58    
59    pub fn record_file_serve(&self, response_time_ms: u64) {
60        self.file_serves.fetch_add(1, Ordering::Relaxed);
61        self.total_response_time_ms.fetch_add(response_time_ms, Ordering::Relaxed);
62    }
63    
64    pub fn record_error(&self) {
65        self.errors.fetch_add(1, Ordering::Relaxed);
66    }
67    
68    pub fn get_stats(&self) -> WebHandlerStats {
69        let browse_requests = self.browse_requests.load(Ordering::Relaxed);
70        let total_time = self.total_response_time_ms.load(Ordering::Relaxed);
71        
72        WebHandlerStats {
73            browse_requests,
74            cache_hits: self.cache_hits.load(Ordering::Relaxed),
75            cache_misses: self.cache_misses.load(Ordering::Relaxed),
76            directory_listings: self.directory_listings.load(Ordering::Relaxed),
77            file_serves: self.file_serves.load(Ordering::Relaxed),
78            errors: self.errors.load(Ordering::Relaxed),
79            average_response_time_ms: if browse_requests > 0 { total_time / browse_requests } else { 0 },
80            cache_hit_rate: if browse_requests > 0 { 
81                (self.cache_hits.load(Ordering::Relaxed) as f64 / browse_requests as f64) * 100.0 
82            } else { 0.0 },
83        }
84    }
85}
86
87#[derive(Debug, Clone)]
88pub struct WebHandlerStats {
89    pub browse_requests: u64,
90    pub cache_hits: u64,
91    pub cache_misses: u64,
92    pub directory_listings: u64,
93    pub file_serves: u64,
94    pub errors: u64,
95    pub average_response_time_ms: u64,
96    pub cache_hit_rate: f64,
97}
98
99// Web metrics will be stored in AppState for atomic access
100
101pub async fn root_handler() -> &'static str {
102    "VuIO Media Server"
103}
104
105pub async fn description_handler(State(state): State<AppState>) -> impl IntoResponse {
106    let xml = generate_description_xml(&state).await;
107    (
108        StatusCode::OK,
109        [(header::CONTENT_TYPE, "text/xml; charset=utf-8")],
110        xml,
111    )
112}
113
114pub async fn content_directory_scpd() -> impl IntoResponse {
115    let xml = generate_scpd_xml();
116    (
117        StatusCode::OK,
118        [(header::CONTENT_TYPE, "text/xml; charset=utf-8")],
119        xml,
120    )
121}
122
123/// Extracts browse parameters from SOAP request
124#[derive(Debug, Clone)]
125struct BrowseParams {
126    object_id: String,
127    starting_index: u32,
128    requested_count: u32,
129}
130
131fn parse_browse_params(body: &str) -> BrowseParams {
132    use quick_xml::events::Event;
133    use quick_xml::Reader;
134    
135    let mut reader = Reader::from_str(body);
136    reader.config_mut().trim_text(true);
137    
138    let mut object_id = "0".to_string();
139    let mut starting_index = 0u32;
140    let mut requested_count = 0u32;
141    
142    let mut buf = Vec::new();
143    let mut current_element = String::new();
144    
145    loop {
146        match reader.read_event_into(&mut buf) {
147            Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
148                current_element = String::from_utf8_lossy(e.name().as_ref()).to_string();
149            }
150            Ok(Event::Text(ref e)) => {
151                let text = reader.decoder().decode(e.as_ref()).unwrap_or_default();
152                match current_element.as_str() {
153                    "ObjectID" => {
154                        object_id = text.trim().to_string();
155                        if object_id.is_empty() {
156                            object_id = "0".to_string();
157                        }
158                    }
159                    "StartingIndex" => {
160                        starting_index = text.trim().parse().unwrap_or_else(|e| {
161                            warn!("Failed to parse StartingIndex '{}': {}", text, e);
162                            0
163                        });
164                    }
165                    "RequestedCount" => {
166                        requested_count = text.trim().parse().unwrap_or_else(|e| {
167                            warn!("Failed to parse RequestedCount '{}': {}", text, e);
168                            0
169                        });
170                    }
171                    _ => {}
172                }
173            }
174            Ok(Event::Eof) => break,
175            Err(e) => {
176                warn!("Error parsing XML: {}, falling back to defaults", e);
177                break;
178            }
179            _ => {}
180        }
181        buf.clear();
182    }
183    
184    debug!("Parsed browse params - ObjectID: '{}', StartingIndex: {}, RequestedCount: {}", 
185           object_id, starting_index, requested_count);
186    
187    BrowseParams {
188        object_id,
189        starting_index,
190        requested_count,
191    }
192}
193
194/// Content Directory Handler struct to encapsulate specialized browse handlers
195pub struct ContentDirectoryHandler;
196
197impl ContentDirectoryHandler {
198    /// Handle video browse requests
199    async fn handle_video_browse(
200        params: &BrowseParams,
201        state: &AppState,
202        path_prefix_str: &str,
203    ) -> Response {
204        Self::handle_folder_browse(params, state, "video/", path_prefix_str).await
205    }
206
207    /// Handle music browse requests (folder-based, not categorized)
208    async fn handle_music_browse(
209        params: &BrowseParams,
210        state: &AppState,
211        path_prefix_str: &str,
212    ) -> Response {
213        Self::handle_folder_browse(params, state, "audio/", path_prefix_str).await
214    }
215
216    /// Handle image browse requests
217    async fn handle_image_browse(
218        params: &BrowseParams,
219        state: &AppState,
220        path_prefix_str: &str,
221    ) -> Response {
222        Self::handle_folder_browse(params, state, "image/", path_prefix_str).await
223    }
224
225    /// Handle generic folder-based browse requests with consistent path normalization
226    /// Enhanced with atomic performance tracking and cache-friendly operations
227    async fn handle_folder_browse(
228        params: &BrowseParams,
229        state: &AppState,
230        media_type_filter: &str,
231        path_prefix_str: &str,
232    ) -> Response {
233        use crate::web::xml::generate_browse_response;
234
235        let start_time = Instant::now();
236        let cache_hit;
237
238        // Determine the base path for the media type
239        let media_root = state.config.get_primary_media_dir();
240        let browse_path = if path_prefix_str.is_empty() {
241            media_root.clone()
242        } else {
243            media_root.join(path_prefix_str)
244        };
245        
246        // Apply canonical path normalization to match how paths are stored in the database
247        // Use the same normalization logic used during file scanning
248        let canonical_browse_path = match state.filesystem_manager.get_canonical_path(&browse_path) {
249            Ok(canonical) => std::path::PathBuf::from(canonical),
250            Err(e) => {
251                warn!("Failed to get canonical path for browse request '{}': {}, using basic normalization", browse_path.display(), e);
252                state.web_metrics.record_error();
253                state.filesystem_manager.normalize_path(&browse_path)
254            }
255        };
256        
257        // Query the ZeroCopy database for the directory listing with timeout and atomic operations
258        let query_future = state.database.get_directory_listing(&canonical_browse_path, media_type_filter);
259        let timeout_duration = std::time::Duration::from_secs(30); // 30 second timeout
260        
261        match tokio::time::timeout(timeout_duration, query_future).await {
262            Ok(Ok((subdirectories, files))) => {
263                cache_hit = !subdirectories.is_empty() || !files.is_empty(); // Assume cache hit if data found
264                
265                debug!("ZeroCopy browse request for '{}' -> canonical '{}' (filter: '{}') returned {} subdirs, {} files", 
266                       browse_path.display(), canonical_browse_path.display(), media_type_filter, subdirectories.len(), files.len());
267                       
268                // Apply pagination if requested
269                let starting_index = params.starting_index as usize;
270                let requested_count = if params.requested_count == 0 { 
271                    // If RequestedCount is 0, return all items (but limit to prevent hanging)
272                    2000 
273                } else { 
274                    std::cmp::min(params.requested_count as usize, 2000) 
275                };
276                
277                // Combine directories and files for pagination with atomic counting
278                let mut all_items = Vec::new();
279                for subdir in &subdirectories {
280                    all_items.push((subdir.clone(), None));
281                }
282                for file in &files {
283                    all_items.push((MediaDirectory { path: file.path.clone(), name: String::new() }, Some(file.clone())));
284                }
285                
286                let total_matches = all_items.len();
287                let end_index = std::cmp::min(starting_index + requested_count, total_matches);
288                
289                if starting_index >= total_matches {
290                    // Starting index is beyond available items - record metrics and return empty
291                    let response_time = start_time.elapsed().as_millis() as u64;
292                    state.web_metrics.record_browse_request(response_time, cache_hit);
293                    
294                    let server_ip = state.get_server_ip();
295                    let response = generate_browse_response(&params.object_id, &[], &[], state, &server_ip).await;
296                    return (
297                        StatusCode::OK,
298                        [
299                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
300                            (header::HeaderName::from_static("ext"), ""),
301                        ],
302                        response,
303                    )
304                        .into_response();
305                }
306                
307                // Extract paginated items with zero-copy operations
308                let paginated_items = &all_items[starting_index..end_index];
309                let mut paginated_subdirs = Vec::new();
310                let mut paginated_files = Vec::new();
311                
312                for (item, file_opt) in paginated_items {
313                    if let Some(file) = file_opt {
314                        paginated_files.push(file.clone());
315                    } else {
316                        paginated_subdirs.push(item.clone());
317                    }
318                }
319                
320                debug!("ZeroCopy returning paginated results: {} subdirs, {} files (index {}-{} of {})",
321                       paginated_subdirs.len(), paginated_files.len(), 
322                       starting_index, end_index, total_matches);
323                
324                // Record atomic performance metrics
325                let response_time = start_time.elapsed().as_millis() as u64;
326                state.web_metrics.record_browse_request(response_time, cache_hit);
327                state.web_metrics.record_directory_listing(response_time);
328                
329                let server_ip = state.get_server_ip();
330                let response = generate_browse_response(&params.object_id, &paginated_subdirs, &paginated_files, state, &server_ip).await;
331                (
332                    StatusCode::OK,
333                    [
334                        (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
335                        (header::HeaderName::from_static("ext"), ""),
336                    ],
337                    response,
338                )
339                    .into_response()
340            },
341            Ok(Err(e)) => {
342                error!("ZeroCopy database error getting directory listing for {}: {}", params.object_id, e);
343                
344                // Record atomic error metrics
345                let response_time = start_time.elapsed().as_millis() as u64;
346                state.web_metrics.record_error();
347                state.web_metrics.record_browse_request(response_time, false);
348                
349                (
350                    StatusCode::INTERNAL_SERVER_ERROR,
351                    [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
352                    "Error browsing content".to_string(),
353                )
354                    .into_response()
355            },
356            Err(_timeout) => {
357                error!("ZeroCopy database query timeout for object_id: {} (path: {} -> canonical: {})", params.object_id, browse_path.display(), canonical_browse_path.display());
358                
359                // Record atomic timeout metrics
360                let response_time = start_time.elapsed().as_millis() as u64;
361                state.web_metrics.record_error();
362                state.web_metrics.record_browse_request(response_time, false);
363                
364                (
365                    StatusCode::REQUEST_TIMEOUT,
366                    [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
367                    "Request timeout - directory too large".to_string(),
368                )
369                    .into_response()
370            }
371        }
372    }
373
374    /// Handle root browse request (ObjectID "0")
375    async fn handle_root_browse(_params: &BrowseParams, state: &AppState) -> Response {
376        use crate::web::xml::generate_browse_response;
377        
378        // For the root, we typically return the top-level containers (Video, Audio, Image).
379        // The generate_browse_response function should be smart enough to create these
380        // when given an object_id of "0" and empty lists of subdirectories and files.
381        let server_ip = state.get_server_ip();
382        let response = generate_browse_response("0", &[], &[], state, &server_ip).await;
383        (
384            StatusCode::OK,
385            [
386                (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
387                (header::HeaderName::from_static("ext"), ""),
388            ],
389            response,
390        )
391            .into_response()
392    }
393}
394
395pub async fn content_directory_control(
396    State(state): State<AppState>,
397    body: String,
398) -> Response {
399    if body.contains("<u:Browse") {
400        let params = parse_browse_params(&body);
401        info!("Browse request - ObjectID: {}, StartingIndex: {}, RequestedCount: {}", 
402              params.object_id, params.starting_index, params.requested_count);
403
404        // Handle root browse request (ObjectID "0")
405        if params.object_id == "0" {
406            return ContentDirectoryHandler::handle_root_browse(&params, &state).await;
407        }
408
409        // Determine media type and delegate to specialized handlers
410        if params.object_id.starts_with("video") {
411            let path_prefix_str = params.object_id.strip_prefix("video").unwrap_or("").trim_start_matches('/');
412            return ContentDirectoryHandler::handle_video_browse(&params, &state, path_prefix_str).await;
413        } else if params.object_id.starts_with("audio") {
414            // Handle music categorization within audio section
415            let audio_path = params.object_id.strip_prefix("audio").unwrap_or("").trim_start_matches('/');
416            
417            // Check for music categorization paths
418            if audio_path.is_empty() {
419                // Root audio container - return categorization containers
420                return handle_audio_root_browse(&params, &state).await;
421            } else if audio_path.starts_with("artists") {
422                return ContentDirectoryHandler::handle_artist_browse(&params, &state, audio_path).await;
423            } else if audio_path.starts_with("albums") {
424                return ContentDirectoryHandler::handle_album_browse(&params, &state, audio_path).await;
425            } else if audio_path.starts_with("genres") {
426                return handle_genres_browse(&params, &state, audio_path).await;
427            } else if audio_path.starts_with("years") {
428                return handle_years_browse(&params, &state, audio_path).await;
429            } else if audio_path.starts_with("playlists") {
430                return handle_playlists_browse(&params, &state, audio_path).await;
431            } else {
432                // Traditional folder browsing within audio
433                return ContentDirectoryHandler::handle_music_browse(&params, &state, audio_path).await;
434            }
435        } else if params.object_id.starts_with("image") {
436            let path_prefix_str = params.object_id.strip_prefix("image").unwrap_or("").trim_start_matches('/');
437            return ContentDirectoryHandler::handle_image_browse(&params, &state, path_prefix_str).await;
438        } else {
439            // This case might happen for deeper browsing or custom object IDs.
440            // Assume no specific type filter for the database query, and the object_id itself
441            // represents the path relative to the media root.
442            return ContentDirectoryHandler::handle_folder_browse(&params, &state, "", params.object_id.as_str()).await;
443        }
444    } else {
445        (
446            StatusCode::NOT_IMPLEMENTED,
447            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
448            "Not implemented".to_string(),
449        )
450            .into_response()
451    }
452}
453
454pub async fn serve_media(
455    State(state): State<AppState>,
456    Path(id): Path<String>,
457    headers: HeaderMap,
458) -> Result<Response, AppError> {
459    let start_time = Instant::now();
460    
461    let file_id = id.parse::<i64>().map_err(|_| {
462        state.web_metrics.record_error();
463        AppError::NotFound
464    })?;
465    
466    // Use ZeroCopy database with atomic cache lookup
467    let file_info = state.database
468        .get_file_by_id(file_id)
469        .await
470        .map_err(|e| {
471            error!("ZeroCopy database error getting file by ID {}: {}", file_id, e);
472            state.web_metrics.record_error();
473            AppError::NotFound
474        })?
475        .ok_or_else(|| {
476            debug!("ZeroCopy database: file ID {} not found", file_id);
477            state.web_metrics.record_error();
478            AppError::NotFound
479        })?;
480
481    // Enforce read-only access to media files
482    let mut file = tokio::fs::OpenOptions::new()
483        .read(true)
484        .write(false)
485        .open(&file_info.path)
486        .await
487        .map_err(AppError::Io)?;
488    let file_size = file_info.size;
489
490    let mut response_builder = Response::builder()
491        .header(header::CONTENT_TYPE, file_info.mime_type)
492        .header(header::ACCEPT_RANGES, "bytes");
493
494    let (start, end) = if let Some(range_header) = headers.get(header::RANGE) {
495        let range_str = range_header.to_str().map_err(|_| AppError::InvalidRange)?;
496        debug!("Received range request: {}", range_str);
497        
498        // Parse the range header manually to avoid enum variant issues
499        parse_range_header(range_str, file_size)?
500    } else {
501        // No range requested, serve the whole file
502        (0, file_size - 1)
503    };
504
505    let len = end - start + 1;
506
507    let response_status = if len < file_size {
508        response_builder = response_builder.header(
509            header::CONTENT_RANGE,
510            format!("bytes {}-{}/{}", start, end, file_size),
511        );
512        StatusCode::PARTIAL_CONTENT
513    } else {
514        StatusCode::OK
515    };
516
517    response_builder = response_builder.header(header::CONTENT_LENGTH, len);
518
519    file.seek(std::io::SeekFrom::Start(start)).await?;
520    let stream = ReaderStream::with_capacity(file, 64 * 1024).take(len as usize);
521    let body = Body::from_stream(stream);
522
523    // Record atomic performance metrics for file serving
524    let response_time = start_time.elapsed().as_millis() as u64;
525    state.web_metrics.record_file_serve(response_time);
526    
527    debug!("ZeroCopy served media file ID {} in {}ms", file_id, response_time);
528
529    Ok(response_builder.status(response_status).body(body)?)
530}
531
532// Helper function to parse range header manually
533fn parse_range_header(range_str: &str, file_size: u64) -> Result<(u64, u64), AppError> {
534    // Remove "bytes=" prefix
535    let range_part = range_str.strip_prefix("bytes=").ok_or(AppError::InvalidRange)?;
536    
537    // Split on comma to get individual ranges (we'll just handle the first one)
538    let first_range = range_part.split(',').next().ok_or(AppError::InvalidRange)?;
539    
540    // Parse the range
541    if let Some((start_str, end_str)) = first_range.split_once('-') {
542        let start = if start_str.is_empty() {
543            // Suffix range like "-500" (last 500 bytes)
544            let suffix_len: u64 = end_str.parse().map_err(|_| AppError::InvalidRange)?;
545            file_size.saturating_sub(suffix_len)
546        } else {
547            start_str.parse().map_err(|_| AppError::InvalidRange)?
548        };
549        
550        let end = if end_str.is_empty() {
551            // Range like "500-" (from 500 to end)
552            file_size - 1
553        } else {
554            let parsed_end: u64 = end_str.parse().map_err(|_| AppError::InvalidRange)?;
555            parsed_end.min(file_size - 1)
556        };
557        
558        // Validate range
559        if start > end || start >= file_size {
560            return Err(AppError::InvalidRange);
561        }
562        
563        Ok((start, end))
564    } else {
565        Err(AppError::InvalidRange)
566    }
567}
568
569/// Handle UPnP eventing subscription requests for ContentDirectory service
570pub async fn content_directory_subscribe(
571    State(state): State<AppState>,
572    headers: HeaderMap,
573    method: Method,
574) -> impl IntoResponse {
575    // Handle SUBSCRIBE method (which might come as GET or a custom method)
576    if method == Method::GET || headers.get("CALLBACK").is_some() {
577        // Handle subscription request
578        if let Some(callback) = headers.get("CALLBACK") {
579            let callback_url = callback.to_str().unwrap_or("");
580            info!("UPnP subscription request from: {}", callback_url);
581            
582            // Generate a subscription ID (in a real implementation, this should be stored)
583            let subscription_id = format!("uuid:{}", uuid::Uuid::new_v4());
584            let timeout = "Second-1800"; // 30 minutes
585            
586            // Get current update ID
587            let update_id = state.content_update_id.load(std::sync::atomic::Ordering::Relaxed);
588            
589            // Send initial event notification
590            tokio::spawn(send_initial_event_notification(callback_url.to_string(), update_id));
591            
592            (
593                StatusCode::OK,
594                [
595                    (header::HeaderName::from_static("sid"), subscription_id.as_str()),
596                    (header::HeaderName::from_static("timeout"), timeout),
597                    (header::CONTENT_LENGTH, "0"),
598                ],
599                "",
600            ).into_response()
601        } else {
602            warn!("UPnP subscription request missing CALLBACK header");
603            (
604                StatusCode::BAD_REQUEST,
605                [
606                    (header::CONTENT_TYPE, "text/plain"),
607                    (header::CONTENT_LENGTH, "0"),
608                ],
609                "",
610            ).into_response()
611        }
612    } else if headers.get("SID").is_some() {
613        // Handle unsubscription request (UNSUBSCRIBE method)
614        let subscription_id = headers.get("SID").unwrap().to_str().unwrap_or("");
615        info!("UPnP unsubscription request for: {}", subscription_id);
616        
617        (
618            StatusCode::OK,
619            [(header::CONTENT_LENGTH, "0")],
620            "",
621        ).into_response()
622    } else {
623        (
624            StatusCode::METHOD_NOT_ALLOWED,
625            [
626                (header::CONTENT_TYPE, "text/plain"),
627                (header::CONTENT_LENGTH, "0"),
628            ],
629            "",
630        ).into_response()
631    }
632}
633
634/// Send initial event notification to a subscribed client
635async fn send_initial_event_notification(callback_url: String, update_id: u32) {
636    let event_body = format!(
637        r#"<?xml version="1.0" encoding="UTF-8"?>
638<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
639    <e:property>
640        <SystemUpdateID>{}</SystemUpdateID>
641    </e:property>
642    <e:property>
643        <ContainerUpdateIDs></ContainerUpdateIDs>
644    </e:property>
645</e:propertyset>"#,
646        update_id
647    );
648    
649    // Extract the actual URL from the callback (remove angle brackets if present)
650    let url = callback_url.trim_start_matches('<').trim_end_matches('>');
651    
652    let client = reqwest::Client::new();
653    match client
654        .request(reqwest::Method::from_bytes(b"NOTIFY").unwrap(), url)
655        .header("HOST", "")
656        .header("CONTENT-TYPE", "text/xml; charset=\"utf-8\"")
657        .header("NT", "upnp:event")
658        .header("NTS", "upnp:propchange")
659        .header("SID", "uuid:dummy") // In real implementation, use actual subscription ID
660        .header("SEQ", "0")
661        .body(event_body)
662        .send()
663        .await
664    {
665        Ok(response) => {
666            debug!("Event notification sent successfully, status: {}", response.status());
667        }
668        Err(e) => {
669            warn!("Failed to send event notification to {}: {}", url, e);
670        }
671    }
672}
673
674// Music categorization handlers
675
676/// Handle browsing the root audio container with music categorization
677async fn handle_audio_root_browse(
678    params: &BrowseParams,
679    state: &AppState,
680) -> Response {
681    use crate::web::xml::generate_browse_response;
682    
683    // Create virtual categorization containers
684    let virtual_containers = vec![
685        ("audio/artists", "Artists"),
686        ("audio/albums", "Albums"), 
687        ("audio/genres", "Genres"),
688        ("audio/years", "Years"),
689        ("audio/playlists", "Playlists"),
690        ("audio/folders", "Folders"),
691    ];
692    
693    // Convert to MediaDirectory for XML generation
694    let subdirectories: Vec<crate::database::MediaDirectory> = virtual_containers
695        .into_iter()
696        .map(|(id, name)| crate::database::MediaDirectory {
697            path: std::path::PathBuf::from(id),
698            name: name.to_string(),
699        })
700        .collect();
701    
702    let server_ip = state.get_server_ip();
703    let response = generate_browse_response(&params.object_id, &subdirectories, &[], state, &server_ip).await;
704    (
705        StatusCode::OK,
706        [
707            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
708            (header::HeaderName::from_static("ext"), ""),
709        ],
710        response,
711    )
712        .into_response()
713}
714    
715impl ContentDirectoryHandler {
716    /// Handle artist browse requests with atomic performance tracking and ZeroCopy operations
717    async fn handle_artist_browse(
718        params: &BrowseParams,
719        state: &AppState,
720        audio_path: &str,
721    ) -> Response {
722        use crate::web::xml::generate_browse_response;
723        
724        let start_time = Instant::now();
725        let path_parts: Vec<&str> = audio_path.split('/').filter(|s| !s.is_empty()).collect();
726        
727        if path_parts.len() == 1 {
728            // List all artists using ZeroCopy atomic operations
729            match state.database.get_artists().await {
730                Ok(artists) => {
731                    let has_data = !artists.is_empty();
732                    let subdirectories: Vec<crate::database::MediaDirectory> = artists
733                        .into_iter()
734                        .map(|artist| crate::database::MediaDirectory {
735                            path: std::path::PathBuf::from(format!("audio/artists/{}", artist.name)),
736                            name: format!("{} ({})", artist.name, artist.count),
737                        })
738                        .collect();
739                    
740                    // Record atomic performance metrics
741                    let response_time = start_time.elapsed().as_millis() as u64;
742                    state.web_metrics.record_browse_request(response_time, has_data);
743                    
744                    debug!("ZeroCopy retrieved {} artists in {}ms", subdirectories.len(), response_time);
745                        
746                    let server_ip = state.get_server_ip();
747                    let response = generate_browse_response(&params.object_id, &subdirectories, &[], state, &server_ip).await;
748                    (
749                        StatusCode::OK,
750                        [
751                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
752                            (header::HeaderName::from_static("ext"), ""),
753                        ],
754                        response,
755                    )
756                        .into_response()
757                }
758                Err(e) => {
759                    error!("ZeroCopy error getting artists: {}", e);
760                    
761                    // Record atomic error metrics
762                    let response_time = start_time.elapsed().as_millis() as u64;
763                    state.web_metrics.record_error();
764                    state.web_metrics.record_browse_request(response_time, false);
765                    
766                    (
767                        StatusCode::INTERNAL_SERVER_ERROR,
768                        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
769                        "Error browsing artists".to_string(),
770                    )
771                        .into_response()
772                }
773            }
774        } else if path_parts.len() == 2 {
775            // List tracks by specific artist using ZeroCopy atomic operations
776            let artist_name = path_parts[1];
777            match state.database.get_music_by_artist(artist_name).await {
778                Ok(files) => {
779                    // Record atomic performance metrics
780                    let response_time = start_time.elapsed().as_millis() as u64;
781                    state.web_metrics.record_browse_request(response_time, !files.is_empty());
782                    
783                    debug!("ZeroCopy retrieved {} tracks for artist '{}' in {}ms", files.len(), artist_name, response_time);
784                    
785                    let server_ip = state.get_server_ip();
786                    let response = generate_browse_response(&params.object_id, &[], &files, state, &server_ip).await;
787                    (
788                        StatusCode::OK,
789                        [
790                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
791                            (header::HeaderName::from_static("ext"), ""),
792                        ],
793                        response,
794                    )
795                        .into_response()
796                }
797                Err(e) => {
798                    error!("ZeroCopy error getting music by artist {}: {}", artist_name, e);
799                    
800                    // Record atomic error metrics
801                    let response_time = start_time.elapsed().as_millis() as u64;
802                    state.web_metrics.record_error();
803                    state.web_metrics.record_browse_request(response_time, false);
804                    
805                    (
806                        StatusCode::INTERNAL_SERVER_ERROR,
807                        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
808                        "Error browsing artist tracks".to_string(),
809                    )
810                        .into_response()
811                }
812            }
813        } else {
814            // Record atomic error metrics for invalid path
815            let response_time = start_time.elapsed().as_millis() as u64;
816            state.web_metrics.record_error();
817            state.web_metrics.record_browse_request(response_time, false);
818            
819            (
820                StatusCode::NOT_FOUND,
821                [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
822                "Invalid artist path".to_string(),
823            )
824                .into_response()
825        }
826    }
827
828    /// Handle album browse requests with atomic performance tracking and ZeroCopy operations
829    async fn handle_album_browse(
830        params: &BrowseParams,
831        state: &AppState,
832        audio_path: &str,
833    ) -> Response {
834        use crate::web::xml::generate_browse_response;
835        
836        let start_time = Instant::now();
837        let path_parts: Vec<&str> = audio_path.split('/').filter(|s| !s.is_empty()).collect();
838        
839        if path_parts.len() == 1 {
840            // List all albums using ZeroCopy atomic operations
841            match state.database.get_albums(None).await {
842                Ok(albums) => {
843                    let has_data = !albums.is_empty();
844                    let subdirectories: Vec<crate::database::MediaDirectory> = albums
845                        .into_iter()
846                        .map(|album| crate::database::MediaDirectory {
847                            path: std::path::PathBuf::from(format!("audio/albums/{}", album.name)),
848                            name: format!("{} ({})", album.name, album.count),
849                        })
850                        .collect();
851                    
852                    // Record atomic performance metrics
853                    let response_time = start_time.elapsed().as_millis() as u64;
854                    state.web_metrics.record_browse_request(response_time, has_data);
855                    
856                    debug!("ZeroCopy retrieved {} albums in {}ms", subdirectories.len(), response_time);
857                        
858                    let server_ip = state.get_server_ip();
859                    let response = generate_browse_response(&params.object_id, &subdirectories, &[], state, &server_ip).await;
860                    (
861                        StatusCode::OK,
862                        [
863                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
864                            (header::HeaderName::from_static("ext"), ""),
865                        ],
866                        response,
867                    )
868                        .into_response()
869                }
870                Err(e) => {
871                    error!("ZeroCopy error getting albums: {}", e);
872                    
873                    // Record atomic error metrics
874                    let response_time = start_time.elapsed().as_millis() as u64;
875                    state.web_metrics.record_error();
876                    state.web_metrics.record_browse_request(response_time, false);
877                    
878                    (
879                        StatusCode::INTERNAL_SERVER_ERROR,
880                        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
881                        "Error browsing albums".to_string(),
882                    )
883                        .into_response()
884                }
885            }
886        } else if path_parts.len() == 2 {
887            // List tracks by specific album using ZeroCopy atomic operations
888            let album_name = path_parts[1];
889            match state.database.get_music_by_album(album_name, None).await {
890                Ok(files) => {
891                    // Record atomic performance metrics
892                    let response_time = start_time.elapsed().as_millis() as u64;
893                    state.web_metrics.record_browse_request(response_time, !files.is_empty());
894                    
895                    debug!("ZeroCopy retrieved {} tracks for album '{}' in {}ms", files.len(), album_name, response_time);
896                    
897                    let server_ip = state.get_server_ip();
898                    let response = generate_browse_response(&params.object_id, &[], &files, state, &server_ip).await;
899                    (
900                        StatusCode::OK,
901                        [
902                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
903                            (header::HeaderName::from_static("ext"), ""),
904                        ],
905                        response,
906                    )
907                        .into_response()
908                }
909                Err(e) => {
910                    error!("ZeroCopy error getting music by album {}: {}", album_name, e);
911                    
912                    // Record atomic error metrics
913                    let response_time = start_time.elapsed().as_millis() as u64;
914                    state.web_metrics.record_error();
915                    state.web_metrics.record_browse_request(response_time, false);
916                    
917                    (
918                        StatusCode::INTERNAL_SERVER_ERROR,
919                        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
920                        "Error browsing album tracks".to_string(),
921                    )
922                        .into_response()
923                }
924            }
925        } else {
926            (
927                StatusCode::NOT_FOUND,
928                [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
929                "Invalid album path".to_string(),
930            )
931                .into_response()
932        }
933    }
934}
935
936
937
938/// Handle browsing genres with atomic performance tracking and ZeroCopy operations
939async fn handle_genres_browse(
940    params: &BrowseParams,
941    state: &AppState,
942    audio_path: &str,
943) -> Response {
944    use crate::web::xml::generate_browse_response;
945    
946    let start_time = Instant::now();
947    let path_parts: Vec<&str> = audio_path.split('/').filter(|s| !s.is_empty()).collect();
948    
949    if path_parts.len() == 1 {
950        // List all genres using ZeroCopy atomic operations
951        match state.database.get_genres().await {
952            Ok(genres) => {
953                let has_data = !genres.is_empty();
954                let subdirectories: Vec<crate::database::MediaDirectory> = genres
955                    .into_iter()
956                    .map(|genre| crate::database::MediaDirectory {
957                        path: std::path::PathBuf::from(format!("audio/genres/{}", genre.name)),
958                        name: format!("{} ({})", genre.name, genre.count),
959                    })
960                    .collect();
961                
962                // Record atomic performance metrics
963                let response_time = start_time.elapsed().as_millis() as u64;
964                state.web_metrics.record_browse_request(response_time, has_data);
965                
966                debug!("ZeroCopy retrieved {} genres in {}ms", subdirectories.len(), response_time);
967                    
968                let server_ip = state.get_server_ip();
969                let response = generate_browse_response(&params.object_id, &subdirectories, &[], state, &server_ip).await;
970                (
971                    StatusCode::OK,
972                    [
973                        (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
974                        (header::HeaderName::from_static("ext"), ""),
975                    ],
976                    response,
977                )
978                    .into_response()
979            }
980            Err(e) => {
981                error!("ZeroCopy error getting genres: {}", e);
982                
983                // Record atomic error metrics
984                let response_time = start_time.elapsed().as_millis() as u64;
985                state.web_metrics.record_error();
986                state.web_metrics.record_browse_request(response_time, false);
987                
988                (
989                    StatusCode::INTERNAL_SERVER_ERROR,
990                    [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
991                    "Error browsing genres".to_string(),
992                )
993                    .into_response()
994            }
995        }
996    } else if path_parts.len() == 2 {
997        // List tracks by specific genre using ZeroCopy atomic operations
998        let genre_name = path_parts[1];
999        match state.database.get_music_by_genre(genre_name).await {
1000            Ok(files) => {
1001                // Record atomic performance metrics
1002                let response_time = start_time.elapsed().as_millis() as u64;
1003                state.web_metrics.record_browse_request(response_time, !files.is_empty());
1004                
1005                debug!("ZeroCopy retrieved {} tracks for genre '{}' in {}ms", files.len(), genre_name, response_time);
1006                
1007                let server_ip = state.get_server_ip();
1008                let response = generate_browse_response(&params.object_id, &[], &files, state, &server_ip).await;
1009                (
1010                    StatusCode::OK,
1011                    [
1012                        (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
1013                        (header::HeaderName::from_static("ext"), ""),
1014                    ],
1015                    response,
1016                )
1017                    .into_response()
1018            }
1019            Err(e) => {
1020                error!("ZeroCopy error getting music by genre {}: {}", genre_name, e);
1021                
1022                // Record atomic error metrics
1023                let response_time = start_time.elapsed().as_millis() as u64;
1024                state.web_metrics.record_error();
1025                state.web_metrics.record_browse_request(response_time, false);
1026                
1027                (
1028                    StatusCode::INTERNAL_SERVER_ERROR,
1029                    [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1030                    "Error browsing genre tracks".to_string(),
1031                )
1032                    .into_response()
1033            }
1034        }
1035    } else {
1036        // Record atomic error metrics for invalid path
1037        let response_time = start_time.elapsed().as_millis() as u64;
1038        state.web_metrics.record_error();
1039        state.web_metrics.record_browse_request(response_time, false);
1040        
1041        (
1042            StatusCode::NOT_FOUND,
1043            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1044            "Invalid genre path".to_string(),
1045        )
1046            .into_response()
1047    }
1048}
1049
1050/// Handle browsing years with atomic performance tracking and ZeroCopy operations
1051async fn handle_years_browse(
1052    params: &BrowseParams,
1053    state: &AppState,
1054    audio_path: &str,
1055) -> Response {
1056    use crate::web::xml::generate_browse_response;
1057    
1058    let start_time = Instant::now();
1059    let path_parts: Vec<&str> = audio_path.split('/').filter(|s| !s.is_empty()).collect();
1060    
1061    if path_parts.len() == 1 {
1062        // List all years using ZeroCopy atomic operations
1063        match state.database.get_years().await {
1064            Ok(years) => {
1065                let has_data = !years.is_empty();
1066                let subdirectories: Vec<crate::database::MediaDirectory> = years
1067                    .into_iter()
1068                    .map(|year| crate::database::MediaDirectory {
1069                        path: std::path::PathBuf::from(format!("audio/years/{}", year.name)),
1070                        name: format!("{} ({})", year.name, year.count),
1071                    })
1072                    .collect();
1073                
1074                // Record atomic performance metrics
1075                let response_time = start_time.elapsed().as_millis() as u64;
1076                state.web_metrics.record_browse_request(response_time, has_data);
1077                
1078                debug!("ZeroCopy retrieved {} years in {}ms", subdirectories.len(), response_time);
1079                    
1080                let server_ip = state.get_server_ip();
1081                let response = generate_browse_response(&params.object_id, &subdirectories, &[], state, &server_ip).await;
1082                (
1083                    StatusCode::OK,
1084                    [
1085                        (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
1086                        (header::HeaderName::from_static("ext"), ""),
1087                    ],
1088                    response,
1089                )
1090                    .into_response()
1091            }
1092            Err(e) => {
1093                error!("ZeroCopy error getting years: {}", e);
1094                
1095                // Record atomic error metrics
1096                let response_time = start_time.elapsed().as_millis() as u64;
1097                state.web_metrics.record_error();
1098                state.web_metrics.record_browse_request(response_time, false);
1099                
1100                (
1101                    StatusCode::INTERNAL_SERVER_ERROR,
1102                    [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1103                    "Error browsing years".to_string(),
1104                )
1105                    .into_response()
1106            }
1107        }
1108    } else if path_parts.len() == 2 {
1109        // List tracks by specific year using ZeroCopy atomic operations
1110        let year_str = path_parts[1];
1111        if let Ok(year) = year_str.parse::<u32>() {
1112            match state.database.get_music_by_year(year).await {
1113                Ok(files) => {
1114                    // Record atomic performance metrics
1115                    let response_time = start_time.elapsed().as_millis() as u64;
1116                    state.web_metrics.record_browse_request(response_time, !files.is_empty());
1117                    
1118                    debug!("ZeroCopy retrieved {} tracks for year {} in {}ms", files.len(), year, response_time);
1119                    
1120                    let server_ip = state.get_server_ip();
1121                    let response = generate_browse_response(&params.object_id, &[], &files, state, &server_ip).await;
1122                    (
1123                        StatusCode::OK,
1124                        [
1125                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
1126                            (header::HeaderName::from_static("ext"), ""),
1127                        ],
1128                        response,
1129                    )
1130                        .into_response()
1131                }
1132                Err(e) => {
1133                    error!("ZeroCopy error getting music by year {}: {}", year, e);
1134                    
1135                    // Record atomic error metrics
1136                    let response_time = start_time.elapsed().as_millis() as u64;
1137                    state.web_metrics.record_error();
1138                    state.web_metrics.record_browse_request(response_time, false);
1139                    
1140                    (
1141                        StatusCode::INTERNAL_SERVER_ERROR,
1142                        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1143                        "Error browsing year tracks".to_string(),
1144                    )
1145                        .into_response()
1146                }
1147            }
1148        } else {
1149            // Record atomic error metrics for invalid year format
1150            let response_time = start_time.elapsed().as_millis() as u64;
1151            state.web_metrics.record_error();
1152            state.web_metrics.record_browse_request(response_time, false);
1153            
1154            (
1155                StatusCode::BAD_REQUEST,
1156                [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1157                "Invalid year format".to_string(),
1158            )
1159                .into_response()
1160        }
1161    } else {
1162        // Record atomic error metrics for invalid path
1163        let response_time = start_time.elapsed().as_millis() as u64;
1164        state.web_metrics.record_error();
1165        state.web_metrics.record_browse_request(response_time, false);
1166        
1167        (
1168            StatusCode::NOT_FOUND,
1169            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1170            "Invalid year path".to_string(),
1171        )
1172            .into_response()
1173    }
1174}
1175
1176/// Handle browsing playlists with atomic performance tracking and ZeroCopy operations
1177async fn handle_playlists_browse(
1178    params: &BrowseParams,
1179    state: &AppState,
1180    audio_path: &str,
1181) -> Response {
1182    use crate::web::xml::generate_browse_response;
1183    
1184    let start_time = Instant::now();
1185    let path_parts: Vec<&str> = audio_path.split('/').filter(|s| !s.is_empty()).collect();
1186    
1187    if path_parts.len() == 1 {
1188        // List all playlists using ZeroCopy atomic operations
1189        match state.database.get_playlists().await {
1190            Ok(playlists) => {
1191                let has_data = !playlists.is_empty();
1192                let subdirectories: Vec<crate::database::MediaDirectory> = playlists
1193                    .into_iter()
1194                    .map(|playlist| crate::database::MediaDirectory {
1195                        path: std::path::PathBuf::from(format!("audio/playlists/{}", playlist.id.unwrap_or(0))),
1196                        name: playlist.name,
1197                    })
1198                    .collect();
1199                
1200                // Record atomic performance metrics
1201                let response_time = start_time.elapsed().as_millis() as u64;
1202                state.web_metrics.record_browse_request(response_time, has_data);
1203                
1204                debug!("ZeroCopy retrieved {} playlists in {}ms", subdirectories.len(), response_time);
1205                    
1206                let server_ip = state.get_server_ip();
1207                let response = generate_browse_response(&params.object_id, &subdirectories, &[], state, &server_ip).await;
1208                (
1209                    StatusCode::OK,
1210                    [
1211                        (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
1212                        (header::HeaderName::from_static("ext"), ""),
1213                    ],
1214                    response,
1215                )
1216                    .into_response()
1217            }
1218            Err(e) => {
1219                error!("ZeroCopy error getting playlists: {}", e);
1220                
1221                // Record atomic error metrics
1222                let response_time = start_time.elapsed().as_millis() as u64;
1223                state.web_metrics.record_error();
1224                state.web_metrics.record_browse_request(response_time, false);
1225                
1226                (
1227                    StatusCode::INTERNAL_SERVER_ERROR,
1228                    [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1229                    "Error browsing playlists".to_string(),
1230                )
1231                    .into_response()
1232            }
1233        }
1234    } else if path_parts.len() == 2 {
1235        // List tracks in specific playlist using ZeroCopy atomic operations
1236        let playlist_id_str = path_parts[1];
1237        if let Ok(playlist_id) = playlist_id_str.parse::<i64>() {
1238            match state.database.get_playlist_tracks(playlist_id).await {
1239                Ok(files) => {
1240                    // Record atomic performance metrics
1241                    let response_time = start_time.elapsed().as_millis() as u64;
1242                    state.web_metrics.record_browse_request(response_time, !files.is_empty());
1243                    
1244                    debug!("ZeroCopy retrieved {} tracks for playlist {} in {}ms", files.len(), playlist_id, response_time);
1245                    
1246                    let server_ip = state.get_server_ip();
1247                    let response = generate_browse_response(&params.object_id, &[], &files, state, &server_ip).await;
1248                    (
1249                        StatusCode::OK,
1250                        [
1251                            (header::CONTENT_TYPE, "text/xml; charset=utf-8"),
1252                            (header::HeaderName::from_static("ext"), ""),
1253                        ],
1254                        response,
1255                    )
1256                        .into_response()
1257                }
1258                Err(e) => {
1259                    error!("ZeroCopy error getting playlist tracks for {}: {}", playlist_id, e);
1260                    
1261                    // Record atomic error metrics
1262                    let response_time = start_time.elapsed().as_millis() as u64;
1263                    state.web_metrics.record_error();
1264                    state.web_metrics.record_browse_request(response_time, false);
1265                    
1266                    (
1267                        StatusCode::INTERNAL_SERVER_ERROR,
1268                        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1269                        "Error browsing playlist tracks".to_string(),
1270                    )
1271                        .into_response()
1272                }
1273            }
1274        } else {
1275            // Record atomic error metrics for invalid playlist ID
1276            let response_time = start_time.elapsed().as_millis() as u64;
1277            state.web_metrics.record_error();
1278            state.web_metrics.record_browse_request(response_time, false);
1279            
1280            (
1281                StatusCode::BAD_REQUEST,
1282                [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1283                "Invalid playlist ID format".to_string(),
1284            )
1285                .into_response()
1286        }
1287    } else {
1288        // Record atomic error metrics for invalid path
1289        let response_time = start_time.elapsed().as_millis() as u64;
1290        state.web_metrics.record_error();
1291        state.web_metrics.record_browse_request(response_time, false);
1292        
1293        (
1294            StatusCode::NOT_FOUND,
1295            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1296            "Invalid playlist path".to_string(),
1297        )
1298            .into_response()
1299    }
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304    use super::*;
1305
1306    #[test]
1307    fn test_parse_browse_params_valid_xml() {
1308        let xml_body = r#"<?xml version="1.0" encoding="utf-8"?>
1309<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
1310    <s:Body>
1311        <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
1312            <ObjectID>video/movies</ObjectID>
1313            <BrowseFlag>BrowseDirectChildren</BrowseFlag>
1314            <Filter>*</Filter>
1315            <StartingIndex>10</StartingIndex>
1316            <RequestedCount>25</RequestedCount>
1317            <SortCriteria></SortCriteria>
1318        </u:Browse>
1319    </s:Body>
1320</s:Envelope>"#;
1321
1322        let params = parse_browse_params(xml_body);
1323        assert_eq!(params.object_id, "video/movies");
1324        assert_eq!(params.starting_index, 10);
1325        assert_eq!(params.requested_count, 25);
1326    }
1327
1328    #[test]
1329    fn test_parse_browse_params_minimal_xml() {
1330        let xml_body = r#"<ObjectID>0</ObjectID><StartingIndex>0</StartingIndex><RequestedCount>0</RequestedCount>"#;
1331
1332        let params = parse_browse_params(xml_body);
1333        assert_eq!(params.object_id, "0");
1334        assert_eq!(params.starting_index, 0);
1335        assert_eq!(params.requested_count, 0);
1336    }
1337
1338    #[test]
1339    fn test_parse_browse_params_missing_elements() {
1340        let xml_body = r#"<ObjectID>audio/artists</ObjectID>"#;
1341
1342        let params = parse_browse_params(xml_body);
1343        assert_eq!(params.object_id, "audio/artists");
1344        assert_eq!(params.starting_index, 0); // Default value
1345        assert_eq!(params.requested_count, 0); // Default value
1346    }
1347
1348    #[test]
1349    fn test_parse_browse_params_invalid_numbers() {
1350        let xml_body = r#"<ObjectID>test</ObjectID><StartingIndex>invalid</StartingIndex><RequestedCount>not_a_number</RequestedCount>"#;
1351
1352        let params = parse_browse_params(xml_body);
1353        assert_eq!(params.object_id, "test");
1354        assert_eq!(params.starting_index, 0); // Falls back to default
1355        assert_eq!(params.requested_count, 0); // Falls back to default
1356    }
1357
1358    #[test]
1359    fn test_parse_browse_params_empty_xml() {
1360        let xml_body = "";
1361
1362        let params = parse_browse_params(xml_body);
1363        assert_eq!(params.object_id, "0"); // Default value
1364        assert_eq!(params.starting_index, 0); // Default value
1365        assert_eq!(params.requested_count, 0); // Default value
1366    }
1367
1368    #[test]
1369    fn test_parse_browse_params_malformed_xml() {
1370        let xml_body = r#"<ObjectID>test</ObjectID><StartingIndex>5<RequestedCount>10</RequestedCount>"#;
1371
1372        let params = parse_browse_params(xml_body);
1373        // Should handle malformed XML gracefully and extract what it can
1374        assert_eq!(params.object_id, "test");
1375        // The parser should still work despite the malformed StartingIndex tag
1376    }
1377
1378    #[test]
1379    fn test_parse_browse_params_with_whitespace() {
1380        let xml_body = r#"
1381        <ObjectID>  video/series  </ObjectID>
1382        <StartingIndex>  5  </StartingIndex>
1383        <RequestedCount>  15  </RequestedCount>
1384        "#;
1385
1386        let params = parse_browse_params(xml_body);
1387        assert_eq!(params.object_id, "video/series"); // Should be trimmed
1388        assert_eq!(params.starting_index, 5);
1389        assert_eq!(params.requested_count, 15);
1390    }
1391
1392    #[test]
1393    fn test_parse_browse_params_performance_comparison() {
1394        // This test demonstrates that the new XML parser handles complex XML correctly
1395        // while the old string-based approach would be fragile
1396        let complex_xml = r#"<?xml version="1.0" encoding="utf-8"?>
1397<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 
1398            s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
1399    <s:Body>
1400        <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
1401            <ObjectID>video/movies/action</ObjectID>
1402            <BrowseFlag>BrowseDirectChildren</BrowseFlag>
1403            <Filter>dc:title,dc:date,upnp:class,res@duration,res@size</Filter>
1404            <StartingIndex>100</StartingIndex>
1405            <RequestedCount>50</RequestedCount>
1406            <SortCriteria>+dc:title</SortCriteria>
1407        </u:Browse>
1408    </s:Body>
1409</s:Envelope>"#;
1410
1411        let params = parse_browse_params(complex_xml);
1412        assert_eq!(params.object_id, "video/movies/action");
1413        assert_eq!(params.starting_index, 100);
1414        assert_eq!(params.requested_count, 50);
1415    }
1416
1417}/// 
1418/// Get web handler performance metrics for monitoring
1419pub async fn get_web_metrics(State(state): State<AppState>) -> impl IntoResponse {
1420    let stats = state.web_metrics.get_stats();
1421    
1422    let metrics_json = serde_json::json!({
1423        "web_handler_metrics": {
1424            "browse_requests": stats.browse_requests,
1425            "cache_hits": stats.cache_hits,
1426            "cache_misses": stats.cache_misses,
1427            "cache_hit_rate_percent": stats.cache_hit_rate,
1428            "directory_listings": stats.directory_listings,
1429            "file_serves": stats.file_serves,
1430            "errors": stats.errors,
1431            "average_response_time_ms": stats.average_response_time_ms,
1432            "zerocopy_database": "active"
1433        }
1434    });
1435    
1436    (
1437        StatusCode::OK,
1438        [(header::CONTENT_TYPE, "application/json")],
1439        metrics_json.to_string(),
1440    )
1441}