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
20pub 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
99pub 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#[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
194pub struct ContentDirectoryHandler;
196
197impl ContentDirectoryHandler {
198 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 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 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 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 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 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 let query_future = state.database.get_directory_listing(&canonical_browse_path, media_type_filter);
259 let timeout_duration = std::time::Duration::from_secs(30); match tokio::time::timeout(timeout_duration, query_future).await {
262 Ok(Ok((subdirectories, files))) => {
263 cache_hit = !subdirectories.is_empty() || !files.is_empty(); 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 let starting_index = params.starting_index as usize;
270 let requested_count = if params.requested_count == 0 {
271 2000
273 } else {
274 std::cmp::min(params.requested_count as usize, 2000)
275 };
276
277 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 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(¶ms.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 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 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(¶ms.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 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 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 async fn handle_root_browse(_params: &BrowseParams, state: &AppState) -> Response {
376 use crate::web::xml::generate_browse_response;
377
378 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 if params.object_id == "0" {
406 return ContentDirectoryHandler::handle_root_browse(¶ms, &state).await;
407 }
408
409 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(¶ms, &state, path_prefix_str).await;
413 } else if params.object_id.starts_with("audio") {
414 let audio_path = params.object_id.strip_prefix("audio").unwrap_or("").trim_start_matches('/');
416
417 if audio_path.is_empty() {
419 return handle_audio_root_browse(¶ms, &state).await;
421 } else if audio_path.starts_with("artists") {
422 return ContentDirectoryHandler::handle_artist_browse(¶ms, &state, audio_path).await;
423 } else if audio_path.starts_with("albums") {
424 return ContentDirectoryHandler::handle_album_browse(¶ms, &state, audio_path).await;
425 } else if audio_path.starts_with("genres") {
426 return handle_genres_browse(¶ms, &state, audio_path).await;
427 } else if audio_path.starts_with("years") {
428 return handle_years_browse(¶ms, &state, audio_path).await;
429 } else if audio_path.starts_with("playlists") {
430 return handle_playlists_browse(¶ms, &state, audio_path).await;
431 } else {
432 return ContentDirectoryHandler::handle_music_browse(¶ms, &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(¶ms, &state, path_prefix_str).await;
438 } else {
439 return ContentDirectoryHandler::handle_folder_browse(¶ms, &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 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 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_range_header(range_str, file_size)?
500 } else {
501 (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 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
532fn parse_range_header(range_str: &str, file_size: u64) -> Result<(u64, u64), AppError> {
534 let range_part = range_str.strip_prefix("bytes=").ok_or(AppError::InvalidRange)?;
536
537 let first_range = range_part.split(',').next().ok_or(AppError::InvalidRange)?;
539
540 if let Some((start_str, end_str)) = first_range.split_once('-') {
542 let start = if start_str.is_empty() {
543 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 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 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
569pub async fn content_directory_subscribe(
571 State(state): State<AppState>,
572 headers: HeaderMap,
573 method: Method,
574) -> impl IntoResponse {
575 if method == Method::GET || headers.get("CALLBACK").is_some() {
577 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 let subscription_id = format!("uuid:{}", uuid::Uuid::new_v4());
584 let timeout = "Second-1800"; let update_id = state.content_update_id.load(std::sync::atomic::Ordering::Relaxed);
588
589 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 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
634async 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 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") .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
674async fn handle_audio_root_browse(
678 params: &BrowseParams,
679 state: &AppState,
680) -> Response {
681 use crate::web::xml::generate_browse_response;
682
683 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 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(¶ms.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 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 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 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(¶ms.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 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 let artist_name = path_parts[1];
777 match state.database.get_music_by_artist(artist_name).await {
778 Ok(files) => {
779 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(¶ms.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 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 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 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 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 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(¶ms.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 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 let album_name = path_parts[1];
889 match state.database.get_music_by_album(album_name, None).await {
890 Ok(files) => {
891 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(¶ms.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 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
938async 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 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 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(¶ms.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 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 let genre_name = path_parts[1];
999 match state.database.get_music_by_genre(genre_name).await {
1000 Ok(files) => {
1001 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(¶ms.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 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 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
1050async 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 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 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(¶ms.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 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 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 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(¶ms.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 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 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 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
1176async 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 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 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(¶ms.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 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 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 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(¶ms.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 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 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 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); assert_eq!(params.requested_count, 0); }
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); assert_eq!(params.requested_count, 0); }
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"); assert_eq!(params.starting_index, 0); assert_eq!(params.requested_count, 0); }
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 assert_eq!(params.object_id, "test");
1375 }
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"); 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 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}pub 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}