/
web-master
/
IPTV-Aggregator
Обзор
Документация
Войти
/
web-master
/
IPTV-Aggregator
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/web.rs
639 строк
22 KB
web-master
Изменение классификатора и добавление возможности работы с локальными файлами плейлистов
04 апр 2026, 19:18
04 апр 2026, 19:18
91d9f73
Код
Авторство
О чём код?
//! HTTP эндпоинты веб-сервера //! //! Модуль содержит все HTTP обработчики для API IPTV агрегатора. use actix_web::{get, post, delete, web, HttpResponse, Responder}; use actix_multipart::Multipart; use futures::TryStreamExt; use log::{error, info}; use parking_lot::Mutex as PLMutex; use std::fs::{self, OpenOptions}; use std::io::Write; use std::sync::Arc; use crate::config::{CHANNEL_ORDER, PLAYLISTS_FILE, LOCAL_PLAYLISTS_DIR}; use crate::generator::{count_channels, generate_m3u}; use crate::types::ChannelMap; /// MIME-тип для M3U плейлистов const MIME_M3U: &str = "audio/x-mpegurl"; /// MIME-тип для JSON ответов const MIME_JSON: &str = "application/json"; // ============================================================================ // Вспомогательные функции // ============================================================================ /// Форматирует один канал в формате EXTINF fn format_channel_entry(ch: &crate::types::Channel) -> String { let logo = ch.logo.as_deref().unwrap_or(""); format!( "#EXTINF:-1 tvg-logo=\"{}\" group-title=\"{}\",{}\n{}\n", logo, ch.group, ch.name, ch.url ) } /// Генерирует M3U контент для группы каналов fn generate_group_m3u(channels: &[crate::types::Channel]) -> String { let mut m3u = String::from("#EXTM3U\n"); for ch in channels.iter().filter(|ch| ch.is_alive) { m3u.push_str(&format_channel_entry(ch)); } m3u } /// Создает JSON ответ со статистикой группы fn group_stats_json(name: &str, channels: &[crate::types::Channel]) -> serde_json::Value { let alive = channels.iter().filter(|ch| ch.is_alive).count(); serde_json::json!({ "name": name, "total": channels.len(), "alive": alive, "dead": channels.len() - alive, }) } /// Обработка ошибок файлового ввода/вывода fn file_io_error(message: &str, error: std::io::Error) -> HttpResponse { error!("{}: {}", message, error); HttpResponse::InternalServerError().body(format!("{}: {}", message, error)) } // ============================================================================ // Эндпоинты отдачи плейлистов // ============================================================================ /// Эндпоинт отдачи полного M3U плейлиста /// /// GET /playlist.m3u #[get("/playlist.m3u")] pub async fn get_m3u(data: web::Data<Arc<PLMutex<ChannelMap>>>) -> impl Responder { let lock = data.lock(); let m3u_text = generate_m3u(&lock); HttpResponse::Ok().content_type(MIME_M3U).body(m3u_text) } /// Эндпоинт отдачи M3U плейлиста для конкретной группы /// /// GET /playlist/{group}.m3u #[get("/playlist/{group}.m3u")] pub async fn get_group_m3u( data: web::Data<Arc<PLMutex<ChannelMap>>>, path: web::Path<String>, ) -> impl Responder { let group_raw = path.into_inner(); let group: String = urlencoding::decode(&group_raw) .unwrap_or_else(|_| group_raw.clone().into()) .into_owned(); let lock = data.lock(); if let Some(group_channels) = lock.get(&group) { let m3u = generate_group_m3u(group_channels); HttpResponse::Ok().content_type(MIME_M3U).body(m3u) } else { HttpResponse::NotFound().body(format!("Группа '{}' не найдена", group)) } } // ============================================================================ // Эндпоинты статистики и информации // ============================================================================ /// Эндпоинт получения статистики каналов /// /// GET /stats #[get("/stats")] pub async fn get_stats(data: web::Data<Arc<PLMutex<ChannelMap>>>) -> impl Responder { let lock = data.lock(); let stats = count_channels(&lock); let json = serde_json::json!({ "total": stats.total, "alive": stats.alive, "dead": stats.dead, "alive_percentage": format!("{:.1}", stats.alive_percentage()), "groups": lock.len(), "group_order": CHANNEL_ORDER, }); HttpResponse::Ok().content_type(MIME_JSON).body(json.to_string()) } /// Эндпоинт получения списка групп /// /// GET /groups #[get("/groups")] pub async fn get_groups(data: web::Data<Arc<PLMutex<ChannelMap>>>) -> impl Responder { let lock = data.lock(); let mut groups: Vec<serde_json::Value> = lock .iter() .map(|(name, channels)| group_stats_json(name, channels)) .collect(); // Сортировка согласно CHANNEL_ORDER groups.sort_by(|a, b| { let a_name = a["name"].as_str().unwrap_or(""); let b_name = b["name"].as_str().unwrap_or(""); let a_idx = CHANNEL_ORDER.iter().position(|&g| g == a_name); let b_idx = CHANNEL_ORDER.iter().position(|&g| g == b_name); match (a_idx, b_idx) { (Some(a), Some(b)) => a.cmp(&b), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => a_name.cmp(b_name), } }); HttpResponse::Ok() .content_type(MIME_JSON) .body(serde_json::to_string_pretty(&groups).unwrap_or_default()) } // ============================================================================ // Эндпоинты управления плейлистами // ============================================================================ /// Эндпоинт проверки URL (быстрая проверка) /// /// POST /check_url #[post("/check_url")] pub async fn check_url(body: web::Json<String>) -> impl Responder { let url = body.into_inner().trim().to_string(); if url.is_empty() { return HttpResponse::BadRequest().body("Пустой URL"); } let is_valid = crate::validator::quick_validate_channel(&url).await; let json = serde_json::json!({ "url": url, "valid": is_valid, }); HttpResponse::Ok().content_type(MIME_JSON).body(json.to_string()) } /// Эндпоинт добавления нового URL в плейлист /// /// POST /add_playlist #[post("/add_playlist")] pub async fn add_playlist(new_url: web::Json<String>) -> impl Responder { let url = new_url.into_inner().trim().to_string(); if url.is_empty() { return HttpResponse::BadRequest().body("Пустой URL"); } if !url.starts_with("http://") && !url.starts_with("https://") { return HttpResponse::BadRequest() .body("URL должен начинаться с http:// или https://"); } match OpenOptions::new().create(true).append(true).open(PLAYLISTS_FILE) { Ok(mut file) => { if let Err(e) = writeln!(file, "{}", url) { return file_io_error("Ошибка записи", e); } info!("Добавлен URL: {}", url); } Err(e) => { return file_io_error( &format!("Не удалось открыть файл {}", PLAYLISTS_FILE), e, ); } } HttpResponse::Ok().body("URL добавлен") } /// Эндпоинт удаления URL из плейлиста /// /// POST /remove_playlist #[post("/remove_playlist")] pub async fn remove_playlist(url_to_remove: web::Json<String>) -> impl Responder { let url = url_to_remove.into_inner().trim().to_string(); if url.is_empty() { return HttpResponse::BadRequest().body("Пустой URL"); } let content = match std::fs::read_to_string(PLAYLISTS_FILE) { Ok(c) => c, Err(e) => { return file_io_error("Не удалось прочитать файл", e); } }; let new_content: Vec<String> = content .lines() .filter(|line| line.trim() != url) .map(|s| s.to_string()) .collect(); match std::fs::write(PLAYLISTS_FILE, new_content.join("\n")) { Ok(_) => { info!("Удален URL: {}", url); HttpResponse::Ok().body("URL удален") } Err(e) => file_io_error("Ошибка записи", e), } } /// Эндпоинт получения текущего списка URL /// /// GET /playlists #[get("/playlists")] pub async fn get_playlists() -> impl Responder { let content = match std::fs::read_to_string(PLAYLISTS_FILE) { Ok(c) => c, Err(e) => { return file_io_error("Не удалось прочитать файл", e); } }; let urls: Vec<String> = content .lines() .filter(|line| !line.trim().is_empty()) .map(|s| s.to_string()) .collect(); HttpResponse::Ok() .content_type(MIME_JSON) .body(serde_json::to_string_pretty(&urls).unwrap_or_default()) } // ============================================================================ // Управление локальными плейлистами // ============================================================================ /// Загрузка локального M3U плейлиста через файл /// /// POST /upload_playlist (multipart/form-data с полем "file") #[post("/upload_playlist")] pub async fn upload_playlist(mut payload: Multipart) -> impl Responder { // Создаём директорию для локальных плейлистов if let Err(e) = fs::create_dir_all(LOCAL_PLAYLISTS_DIR) { error!("Не удалось создать директорию {}: {}", LOCAL_PLAYLISTS_DIR, e); return HttpResponse::InternalServerError() .body(format!("Не удалось создать директорию: {}", e)); } let mut file_count = 0; let mut errors = Vec::new(); while let Ok(Some(mut field)) = payload.try_next().await { let filename = match field.content_disposition() { Some(cd) => cd.get_filename(), None => None, }; let filename = match filename { Some(name) if name.ends_with(".m3u") || name.ends_with(".m3u8") => name.to_string(), Some(name) => { errors.push(format!("Пропущен файл '{}' (требуется .m3u или .m3u8)", name)); continue; } None => continue, }; // Защита от path traversal let safe_name = std::path::Path::new(&filename) .file_name() .and_then(|n| n.to_str()) .unwrap_or("playlist.m3u"); let file_path = format!("{}/{}", LOCAL_PLAYLISTS_DIR, safe_name); let mut file_bytes = Vec::new(); while let Some(chunk) = field.try_next().await.unwrap_or(None) { file_bytes.extend_from_slice(&chunk); } match fs::write(&file_path, &file_bytes) { Ok(_) => { info!("Загружен локальный плейлист: {} ({} байт)", file_path, file_bytes.len()); file_count += 1; // Добавляем путь в playlists.txt match OpenOptions::new().create(true).append(true).open(PLAYLISTS_FILE) { Ok(mut file) => { if let Err(e) = writeln!(file, "{}", file_path) { error!("Ошибка записи в {}: {}", PLAYLISTS_FILE, e); } } Err(e) => { error!("Не удалось открыть {}: {}", PLAYLISTS_FILE, e); } } } Err(e) => { error!("Ошибка записи файла {}: {}", file_path, e); errors.push(format!("Ошибка записи '{}': {}", safe_name, e)); } } } if file_count > 0 { let msg = format!("Загружено файлов: {}", file_count); let json = serde_json::json!({ "message": msg, "uploaded": file_count, "errors": errors, }); HttpResponse::Ok() .content_type(MIME_JSON) .body(json.to_string()) } else { HttpResponse::BadRequest() .body("Не найдено файлов .m3u/.m3u8 для загрузки") } } /// Получить список загруженных локальных плейлистов /// /// GET /local_playlists #[get("/local_playlists")] pub async fn list_local_playlists() -> impl Responder { if !std::path::Path::new(LOCAL_PLAYLISTS_DIR).exists() { return HttpResponse::Ok() .content_type(MIME_JSON) .body("[]"); } let entries = match fs::read_dir(LOCAL_PLAYLISTS_DIR) { Ok(e) => e, Err(err) => { error!("Не удалось прочитать директорию {}: {}", LOCAL_PLAYLISTS_DIR, err); return HttpResponse::InternalServerError() .body(format!("Ошибка чтения директории: {}", err)); } }; let playlists: Vec<serde_json::Value> = entries .filter_map(|entry| { entry.ok().and_then(|e| { let metadata = e.metadata().ok()?; if metadata.is_file() { let name = e.file_name().to_string_lossy().to_string(); let size = metadata.len(); Some(serde_json::json!({ "name": name, "path": e.path().to_string_lossy().to_string(), "size_bytes": size, })) } else { None } }) }) .collect(); HttpResponse::Ok() .content_type(MIME_JSON) .body(serde_json::to_string_pretty(&playlists).unwrap_or_default()) } /// Удалить локальный плейлист /// /// DELETE /local_playlists/{name} #[delete("/local_playlists/{name}")] pub async fn remove_local_playlist(path: web::Path<String>) -> impl Responder { let name = path.into_inner(); // Защита от path traversal let safe_name = std::path::Path::new(&name) .file_name() .and_then(|n| n.to_str()); if let Some(safe_name) = safe_name { let file_path = format!("{}/{}", LOCAL_PLAYLISTS_DIR, safe_name); if std::path::Path::new(&file_path).exists() { match fs::remove_file(&file_path) { Ok(_) => { info!("Удалён локальный плейлист: {}", file_path); // Удаляем из playlists.txt if let Ok(content) = fs::read_to_string(PLAYLISTS_FILE) { let new_content: Vec<String> = content .lines() .filter(|line| line.trim() != file_path) .map(|s| s.to_string()) .collect(); let _ = fs::write(PLAYLISTS_FILE, new_content.join("\n")); } HttpResponse::Ok().body("Плейлист удалён") } Err(e) => { error!("Ошибка удаления {}: {}", file_path, e); HttpResponse::InternalServerError() .body(format!("Ошибка удаления: {}", e)) } } } else { HttpResponse::NotFound().body(format!("Плейлист '{}' не найден", safe_name)) } } else { HttpResponse::BadRequest().body("Некорректное имя файла") } } /// Добавить локальный плейлист по пути /// /// POST /add_local_playlist #[post("/add_local_playlist")] pub async fn add_local_playlist_path(body: web::Json<String>) -> impl Responder { let file_path = body.into_inner().trim().to_string(); if file_path.is_empty() { return HttpResponse::BadRequest().body("Пустой путь"); } // Проверяем существование файла if !std::path::Path::new(&file_path).exists() { return HttpResponse::BadRequest() .body(format!("Файл '{}' не найден", file_path)); } // Проверяем расширение if !file_path.ends_with(".m3u") && !file_path.ends_with(".m3u8") { return HttpResponse::BadRequest() .body("Файл должен иметь расширение .m3u или .m3u8"); } // Добавляем в playlists.txt match OpenOptions::new().create(true).append(true).open(PLAYLISTS_FILE) { Ok(mut file) => { if let Err(e) = writeln!(file, "{}", file_path) { error!("Ошибка записи в {}: {}", PLAYLISTS_FILE, e); return HttpResponse::InternalServerError() .body(format!("Ошибка записи: {}", e)); } info!("Добавлен локальный плейлист: {}", file_path); } Err(e) => { error!("Не удалось открыть {}: {}", PLAYLISTS_FILE, e); return HttpResponse::InternalServerError() .body(format!("Не удалось открыть файл: {}", e)); } } HttpResponse::Ok().body("Локальный плейлист добавлен") } // ============================================================================ // Служебные эндпоинты // ============================================================================ /// Эндпоинт health check /// /// GET /health #[get("/health")] pub async fn health() -> impl Responder { HttpResponse::Ok().body("OK") } // ============================================================================ // Регистрация маршрутов // ============================================================================ /// Регистрация всех эндпоинтов pub fn register_routes(cfg: &mut web::ServiceConfig) { cfg.service(get_m3u); cfg.service(get_group_m3u); cfg.service(get_stats); cfg.service(get_groups); cfg.service(check_url); cfg.service(add_playlist); cfg.service(remove_playlist); cfg.service(get_playlists); cfg.service(upload_playlist); cfg.service(list_local_playlists); cfg.service(remove_local_playlist); cfg.service(add_local_playlist_path); cfg.service(health); } // ============================================================================ // Тесты // ============================================================================ #[cfg(test)] mod tests { use super::*; use actix_web::{test, App}; #[actix_web::test] async fn test_health_endpoint() { let app = test::init_service(App::new().configure(register_routes)).await; let req = test::TestRequest::get().uri("/health").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); } #[actix_web::test] async fn test_add_playlist_empty_url() { let app = test::init_service(App::new().configure(register_routes)).await; let req = test::TestRequest::post() .uri("/add_playlist") .set_json("") .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 400); } #[actix_web::test] async fn test_add_playlist_invalid_url() { let app = test::init_service(App::new().configure(register_routes)).await; let req = test::TestRequest::post() .uri("/add_playlist") .set_json("invalid-url") .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 400); } #[actix_web::test] async fn test_get_m3u_empty() { let channels = Arc::new(PLMutex::new(ChannelMap::new())); let app = test::init_service( App::new() .app_data(web::Data::new(channels)) .configure(register_routes), ) .await; let req = test::TestRequest::get().uri("/playlist.m3u").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); assert_eq!(resp.headers().get("content-type").unwrap(), MIME_M3U); } #[actix_web::test] async fn test_get_stats_empty() { let channels = Arc::new(PLMutex::new(ChannelMap::new())); let app = test::init_service( App::new() .app_data(web::Data::new(channels)) .configure(register_routes), ) .await; let req = test::TestRequest::get().uri("/stats").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); assert_eq!(resp.headers().get("content-type").unwrap(), MIME_JSON); } #[actix_web::test] async fn test_get_groups_empty() { let channels = Arc::new(PLMutex::new(ChannelMap::new())); let app = test::init_service( App::new() .app_data(web::Data::new(channels)) .configure(register_routes), ) .await; let req = test::TestRequest::get().uri("/groups").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); } #[actix_web::test] async fn test_get_group_m3u_not_found() { let channels = Arc::new(PLMutex::new(ChannelMap::new())); let app = test::init_service( App::new() .app_data(web::Data::new(channels)) .configure(register_routes), ) .await; let req = test::TestRequest::get() .uri("/playlist/NonExistent.m3u") .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 404); } }