feat: agregar configuración para almacenamiento remoto de ZIP en modo DEV y mejorar la gestión de límites de tamaño de archivos en la importación de ZIP
This commit is contained in:
@@ -57,5 +57,14 @@ LOCAL_LLM_MODEL=llama3.2:3b
|
|||||||
# Workers para procesamiento RAG asíncrono post-ZIP ("túneles"): 1..12
|
# Workers para procesamiento RAG asíncrono post-ZIP ("túneles"): 1..12
|
||||||
ZIP_RAG_CONCURRENCY=5
|
ZIP_RAG_CONCURRENCY=5
|
||||||
|
|
||||||
|
# Opcional: almacenamiento remoto para ZIP en modo DEV (cuando marcas "Procesar este ZIP con infraestructura DEV")
|
||||||
|
# Si defines DEV_S3_BUCKET, el ZIP se descomprime/procesa localmente y los archivos listos
|
||||||
|
# (incluyendo FLV ya convertido a MP4) se suben a este S3 remoto para consumo RAG.
|
||||||
|
DEV_S3_BUCKET=
|
||||||
|
DEV_S3_REGION=us-east-2
|
||||||
|
DEV_S3_ENDPOINT=
|
||||||
|
DEV_S3_PUBLIC_BASE_URL=
|
||||||
|
DEV_S3_FORCE_PATH_STYLE=false
|
||||||
|
|
||||||
# Backend-to-backend (LMS -> CMS)
|
# Backend-to-backend (LMS -> CMS)
|
||||||
CMS_API_URL=http://studio:3001
|
CMS_API_URL=http://studio:3001
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ use tokio::process::Command;
|
|||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
|
const DEFAULT_ZIP_IMPORT_MAX_UPLOAD_BYTES: u64 = 512 * 1024 * 1024; // 512 MiB
|
||||||
|
const DEFAULT_ZIP_IMPORT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB por archivo
|
||||||
|
const DEFAULT_ZIP_IMPORT_MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB descomprimido
|
||||||
|
|
||||||
|
fn read_env_u64_with_bounds(name: &str, default: u64, min: u64, max: u64) -> u64 {
|
||||||
|
env::var(name)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||||
|
.map(|v| v.clamp(min, max))
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_env_usize_with_bounds(name: &str, default: usize, min: usize, max: usize) -> usize {
|
||||||
|
env::var(name)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||||
|
.map(|v| v.clamp(min, max))
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AssetUploadResponse {
|
pub struct AssetUploadResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -73,22 +93,20 @@ struct S3Settings {
|
|||||||
force_path_style: bool,
|
force_path_style: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_s3_settings() -> Option<S3Settings> {
|
fn load_s3_settings_from_env(prefix: &str) -> Option<S3Settings> {
|
||||||
let enabled = env::var("ASSETS_STORAGE")
|
let bucket_key = format!("{}S3_BUCKET", prefix);
|
||||||
.unwrap_or_else(|_| "local".to_string())
|
let region_key = format!("{}S3_REGION", prefix);
|
||||||
.to_lowercase();
|
let endpoint_key = format!("{}S3_ENDPOINT", prefix);
|
||||||
|
let public_base_key = format!("{}S3_PUBLIC_BASE_URL", prefix);
|
||||||
|
let force_path_key = format!("{}S3_FORCE_PATH_STYLE", prefix);
|
||||||
|
|
||||||
if enabled != "s3" {
|
let bucket = env::var(&bucket_key).ok()?;
|
||||||
return None;
|
let region = env::var(®ion_key).unwrap_or_else(|_| "us-east-2".to_string());
|
||||||
}
|
let endpoint = env::var(&endpoint_key).ok().filter(|v| !v.trim().is_empty());
|
||||||
|
let public_base_url = env::var(&public_base_key)
|
||||||
let bucket = env::var("S3_BUCKET").ok()?;
|
|
||||||
let region = env::var("S3_REGION").unwrap_or_else(|_| "us-east-2".to_string());
|
|
||||||
let endpoint = env::var("S3_ENDPOINT").ok().filter(|v| !v.trim().is_empty());
|
|
||||||
let public_base_url = env::var("S3_PUBLIC_BASE_URL")
|
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.trim().is_empty());
|
.filter(|v| !v.trim().is_empty());
|
||||||
let force_path_style = env::var("S3_FORCE_PATH_STYLE")
|
let force_path_style = env::var(&force_path_key)
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
let lower = v.to_lowercase();
|
let lower = v.to_lowercase();
|
||||||
lower == "1" || lower == "true" || lower == "yes"
|
lower == "1" || lower == "true" || lower == "yes"
|
||||||
@@ -104,6 +122,38 @@ fn get_s3_settings() -> Option<S3Settings> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_s3_settings() -> Option<S3Settings> {
|
||||||
|
let enabled = env::var("ASSETS_STORAGE")
|
||||||
|
.unwrap_or_else(|_| "local".to_string())
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if enabled != "s3" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
load_s3_settings_from_env("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_dev_s3_settings() -> Option<S3Settings> {
|
||||||
|
load_s3_settings_from_env("DEV_")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_s3_settings_for_bucket(bucket: &str) -> Option<S3Settings> {
|
||||||
|
if let Some(default) = get_s3_settings() {
|
||||||
|
if default.bucket == bucket {
|
||||||
|
return Some(default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dev) = get_dev_s3_settings() {
|
||||||
|
if dev.bucket == bucket {
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
async fn build_s3_client(settings: &S3Settings) -> Result<S3Client, (StatusCode, String)> {
|
async fn build_s3_client(settings: &S3Settings) -> Result<S3Client, (StatusCode, String)> {
|
||||||
let region_provider = RegionProviderChain::first_try(Some(Region::new(settings.region.clone())))
|
let region_provider = RegionProviderChain::first_try(Some(Region::new(settings.region.clone())))
|
||||||
.or_default_provider();
|
.or_default_provider();
|
||||||
@@ -213,7 +263,7 @@ async fn push_bytes_to_s3(
|
|||||||
|
|
||||||
async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, String)> {
|
async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, String)> {
|
||||||
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
||||||
let settings = get_s3_settings().ok_or((
|
let settings = get_s3_settings_for_bucket(bucket).ok_or((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Se encontró una ruta de almacenamiento S3 pero S3 no está configurado".to_string(),
|
"Se encontró una ruta de almacenamiento S3 pero S3 no está configurado".to_string(),
|
||||||
))?;
|
))?;
|
||||||
@@ -232,6 +282,12 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup_local_temp_file(storage_path: &str) {
|
||||||
|
if !storage_path.starts_with("s3://") {
|
||||||
|
let _ = tokio::fs::remove_file(storage_path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> {
|
fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> {
|
||||||
let without_prefix = path.strip_prefix("s3://")?;
|
let without_prefix = path.strip_prefix("s3://")?;
|
||||||
let (bucket, key) = without_prefix.split_once('/')?;
|
let (bucket, key) = without_prefix.split_once('/')?;
|
||||||
@@ -255,7 +311,7 @@ pub async fn public_s3_proxy(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.ok_or((StatusCode::BAD_REQUEST, "Falta la clave (key)".to_string()))?;
|
.ok_or((StatusCode::BAD_REQUEST, "Falta la clave (key)".to_string()))?;
|
||||||
|
|
||||||
let settings = get_s3_settings().ok_or((
|
let settings = get_s3_settings_for_bucket(&bucket).ok_or((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"El almacenamiento S3 no está configurado".to_string(),
|
"El almacenamiento S3 no está configurado".to_string(),
|
||||||
))?;
|
))?;
|
||||||
@@ -286,7 +342,7 @@ pub async fn public_s3_proxy(
|
|||||||
|
|
||||||
async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode, String)> {
|
async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode, String)> {
|
||||||
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
||||||
let settings = get_s3_settings().ok_or((
|
let settings = get_s3_settings_for_bucket(bucket).ok_or((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"S3 storage path found but S3 is not configured".to_string(),
|
"S3 storage path found but S3 is not configured".to_string(),
|
||||||
))?;
|
))?;
|
||||||
@@ -856,28 +912,51 @@ async fn process_zip_entry_without_rag(
|
|||||||
|
|
||||||
let (storage_path, stored_filename, mimetype) = if is_flv {
|
let (storage_path, stored_filename, mimetype) = if is_flv {
|
||||||
if use_dev_processing && ingest_rag {
|
if use_dev_processing && ingest_rag {
|
||||||
let storage_path = build_ready_for_rag_path(org_id, asset_id, &format!("{}.flv", asset_id));
|
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||||
|
let temp_storage_path = format!("uploads/tmp/{}", temp_storage_filename);
|
||||||
|
tokio::fs::create_dir_all("uploads/tmp")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{}: Error creating temp dir ({})", entry_name, e))?;
|
||||||
|
tokio::fs::write(&temp_storage_path, &content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?;
|
||||||
|
|
||||||
|
let storage_path = build_ready_for_rag_path(org_id, asset_id, &format!("{}.mp4", asset_id));
|
||||||
tokio::fs::create_dir_all(StdPath::new(&storage_path).parent().unwrap_or(StdPath::new(".")))
|
tokio::fs::create_dir_all(StdPath::new(&storage_path).parent().unwrap_or(StdPath::new(".")))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{}: Error creating ready-for-rag dir ({})", entry_name, e))?;
|
.map_err(|e| format!("{}: Error creating ready-for-rag dir ({})", entry_name, e))?;
|
||||||
tokio::fs::write(&storage_path, &content)
|
|
||||||
.await
|
if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &storage_path).await {
|
||||||
.map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?;
|
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||||
|
return Err(format!("{}: la transcodificación de flv falló ({})", entry_name, msg));
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||||
(
|
(
|
||||||
storage_path,
|
storage_path,
|
||||||
safe_filename.clone(),
|
replace_extension(&safe_filename, "mp4"),
|
||||||
if guessed_mimetype.is_empty() { "video/x-flv".to_string() } else { guessed_mimetype.clone() },
|
"video/mp4".to_string(),
|
||||||
)
|
)
|
||||||
} else if use_dev_processing {
|
} else if use_dev_processing {
|
||||||
let storage_filename = format!("{}.flv", asset_id);
|
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||||
let storage_path = format!("uploads/{}", storage_filename);
|
let temp_storage_path = format!("uploads/tmp/{}", temp_storage_filename);
|
||||||
tokio::fs::write(&storage_path, &content)
|
tokio::fs::create_dir_all("uploads/tmp")
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{}: Error creating temp dir ({})", entry_name, e))?;
|
||||||
|
tokio::fs::write(&temp_storage_path, &content)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?;
|
.map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?;
|
||||||
|
|
||||||
|
let final_storage_filename = format!("{}.mp4", asset_id);
|
||||||
|
let final_storage_path = format!("uploads/{}", final_storage_filename);
|
||||||
|
if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await {
|
||||||
|
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||||
|
return Err(format!("{}: la transcodificación de flv falló ({})", entry_name, msg));
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||||
(
|
(
|
||||||
storage_path,
|
final_storage_path,
|
||||||
safe_filename.clone(),
|
replace_extension(&safe_filename, "mp4"),
|
||||||
if guessed_mimetype.is_empty() { "video/x-flv".to_string() } else { guessed_mimetype.clone() },
|
"video/mp4".to_string(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let temp_storage_filename = format!("{}.flv", asset_id);
|
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||||
@@ -948,9 +1027,7 @@ async fn process_zip_entry_without_rag(
|
|||||||
.await
|
.await
|
||||||
.map_err(|(_, msg)| format!("{}: s3 upload failed ({})", entry_name, msg))?;
|
.map_err(|(_, msg)| format!("{}: s3 upload failed ({})", entry_name, msg))?;
|
||||||
|
|
||||||
if is_flv {
|
cleanup_local_temp_file(&storage_path).await;
|
||||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
(s3_path, uploaded_len, public_url)
|
(s3_path, uploaded_len, public_url)
|
||||||
} else {
|
} else {
|
||||||
@@ -1006,6 +1083,25 @@ pub async fn import_assets_zip(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<AssetZipImportResponse>, (StatusCode, String)> {
|
) -> Result<Json<AssetZipImportResponse>, (StatusCode, String)> {
|
||||||
|
let max_upload_bytes = read_env_u64_with_bounds(
|
||||||
|
"ZIP_IMPORT_MAX_UPLOAD_BYTES",
|
||||||
|
DEFAULT_ZIP_IMPORT_MAX_UPLOAD_BYTES,
|
||||||
|
1,
|
||||||
|
10 * 1024 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
let max_entry_bytes = read_env_u64_with_bounds(
|
||||||
|
"ZIP_IMPORT_MAX_ENTRY_BYTES",
|
||||||
|
DEFAULT_ZIP_IMPORT_MAX_ENTRY_BYTES,
|
||||||
|
1,
|
||||||
|
2 * 1024 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
let max_total_uncompressed_bytes = read_env_u64_with_bounds(
|
||||||
|
"ZIP_IMPORT_MAX_TOTAL_BYTES",
|
||||||
|
DEFAULT_ZIP_IMPORT_MAX_TOTAL_BYTES,
|
||||||
|
1,
|
||||||
|
20 * 1024 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
|
||||||
let mut zip_temp_path: Option<String> = None;
|
let mut zip_temp_path: Option<String> = None;
|
||||||
let mut course_id: Option<Uuid> = None;
|
let mut course_id: Option<Uuid> = None;
|
||||||
let mut english_level: Option<String> = None;
|
let mut english_level: Option<String> = None;
|
||||||
@@ -1033,12 +1129,25 @@ pub async fn import_assets_zip(
|
|||||||
let mut temp_file = tokio::fs::File::create(&temp_name)
|
let mut temp_file = tokio::fs::File::create(&temp_name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create temp zip file: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create temp zip file: {}", e)))?;
|
||||||
|
let mut received_bytes: u64 = 0;
|
||||||
|
|
||||||
while let Some(chunk) = field
|
while let Some(chunk) = field
|
||||||
.chunk()
|
.chunk()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read upload chunk: {}", e)))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read upload chunk: {}", e)))?
|
||||||
{
|
{
|
||||||
|
received_bytes = received_bytes.saturating_add(chunk.len() as u64);
|
||||||
|
if received_bytes > max_upload_bytes {
|
||||||
|
let _ = tokio::fs::remove_file(&temp_name).await;
|
||||||
|
return Err((
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
format!(
|
||||||
|
"ZIP demasiado grande (>{} bytes). Ajusta ZIP_IMPORT_MAX_UPLOAD_BYTES si necesitas permitir más tamaño.",
|
||||||
|
max_upload_bytes
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
temp_file
|
temp_file
|
||||||
.write_all(&chunk)
|
.write_all(&chunk)
|
||||||
.await
|
.await
|
||||||
@@ -1120,13 +1229,14 @@ pub async fn import_assets_zip(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?;
|
||||||
|
|
||||||
if archive.is_empty() {
|
if archive.is_empty() {
|
||||||
let _ = tokio::fs::remove_file(&zip_path).await;
|
let _ = std::fs::remove_file(&zip_path);
|
||||||
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 1: collect all ZIP entries into memory ──────────────────────────
|
// ── Phase 1: collect all ZIP entries into memory ──────────────────────────
|
||||||
let mut all_entries: Vec<ZipEntryData> = Vec::new();
|
let mut all_entries: Vec<ZipEntryData> = Vec::new();
|
||||||
let mut unit_set: std::collections::BTreeSet<i32> = Default::default();
|
let mut unit_set: std::collections::BTreeSet<i32> = Default::default();
|
||||||
|
let mut total_uncompressed_bytes: u64 = 0;
|
||||||
|
|
||||||
let len = archive.len();
|
let len = archive.len();
|
||||||
for i in 0..len {
|
for i in 0..len {
|
||||||
@@ -1141,6 +1251,32 @@ pub async fn import_assets_zip(
|
|||||||
if entry_name.starts_with("__MACOSX/") || entry_name.ends_with(".DS_Store") {
|
if entry_name.starts_with("__MACOSX/") || entry_name.ends_with(".DS_Store") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let declared_entry_size = file.size();
|
||||||
|
if declared_entry_size > max_entry_bytes {
|
||||||
|
let _ = std::fs::remove_file(&zip_path);
|
||||||
|
return Err((
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
format!(
|
||||||
|
"Entrada ZIP demasiado grande: {} ({} bytes). Límite actual por archivo: {} bytes (ZIP_IMPORT_MAX_ENTRY_BYTES).",
|
||||||
|
entry_name,
|
||||||
|
declared_entry_size,
|
||||||
|
max_entry_bytes
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
total_uncompressed_bytes = total_uncompressed_bytes.saturating_add(declared_entry_size);
|
||||||
|
if total_uncompressed_bytes > max_total_uncompressed_bytes {
|
||||||
|
let _ = std::fs::remove_file(&zip_path);
|
||||||
|
return Err((
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
format!(
|
||||||
|
"El ZIP excede el límite descomprimido total ({} bytes). Ajusta ZIP_IMPORT_MAX_TOTAL_BYTES para permitir más.",
|
||||||
|
max_total_uncompressed_bytes
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let safe_filename = StdPath::new(&entry_name)
|
let safe_filename = StdPath::new(&entry_name)
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
@@ -1175,6 +1311,9 @@ pub async fn import_assets_zip(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZipArchive usa tipos no-Send; se libera antes de cualquier await posterior.
|
||||||
|
drop(archive);
|
||||||
|
|
||||||
// ── Phase 1b: calculate split midpoint (intensive → 2 regular courses) ───
|
// ── Phase 1b: calculate split midpoint (intensive → 2 regular courses) ───
|
||||||
// For 8-10 units: first half → regular 1, second half → regular 2.
|
// For 8-10 units: first half → regular 1, second half → regular 2.
|
||||||
// Mid is the last unit number that goes to regular 1 (ceiling of N/2).
|
// Mid is the last unit number that goes to regular 1 (ceiling of N/2).
|
||||||
@@ -1193,6 +1332,8 @@ pub async fn import_assets_zip(
|
|||||||
// Sort: audio/video first so their asset IDs are known when text is ingested
|
// Sort: audio/video first so their asset IDs are known when text is ingested
|
||||||
all_entries.sort_by_key(|e| if e.is_audio_video { 0usize } else { 1 });
|
all_entries.sort_by_key(|e| if e.is_audio_video { 0usize } else { 1 });
|
||||||
|
|
||||||
|
// El modo DEV solo cambia endpoints de IA/procesamiento.
|
||||||
|
// El almacenamiento de assets del ZIP siempre usa el S3 del proyecto.
|
||||||
let s3_settings = get_s3_settings();
|
let s3_settings = get_s3_settings();
|
||||||
let s3_client = if let Some(settings) = &s3_settings {
|
let s3_client = if let Some(settings) = &s3_settings {
|
||||||
Some(build_s3_client(settings).await?)
|
Some(build_s3_client(settings).await?)
|
||||||
@@ -1324,18 +1465,31 @@ pub async fn import_assets_zip(
|
|||||||
|
|
||||||
let (storage_path, stored_filename, mimetype) = if is_flv {
|
let (storage_path, stored_filename, mimetype) = if is_flv {
|
||||||
if use_dev_processing {
|
if use_dev_processing {
|
||||||
let storage_path = build_ready_for_rag_path(org_ctx.id, asset_id, &format!("{}.flv", asset_id));
|
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||||
tokio::fs::create_dir_all(StdPath::new(&storage_path).parent().unwrap_or(StdPath::new(".")))
|
let temp_storage_path = format!("uploads/tmp/{}", temp_storage_filename);
|
||||||
|
tokio::fs::create_dir_all("uploads/tmp")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error creating ready-for-rag dir: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error creating temp dir: {}", e)))?;
|
||||||
if let Err(e) = tokio::fs::write(&storage_path, &content).await {
|
if let Err(e) = tokio::fs::write(&temp_storage_path, &content).await {
|
||||||
failed_entries.push(format!("{}: local write failed ({})", entry_name, e));
|
failed_entries.push(format!("{}: local write failed ({})", entry_name, e));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let storage_path = build_ready_for_rag_path(org_ctx.id, asset_id, &format!("{}.mp4", asset_id));
|
||||||
|
tokio::fs::create_dir_all(StdPath::new(&storage_path).parent().unwrap_or(StdPath::new(".")))
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error creating ready-for-rag dir: {}", e)))?;
|
||||||
|
|
||||||
|
if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &storage_path).await {
|
||||||
|
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||||
|
failed_entries.push(format!("{}: flv transcode failed ({})", entry_name, msg));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::remove_file(&temp_storage_path).await;
|
||||||
(
|
(
|
||||||
storage_path,
|
storage_path,
|
||||||
safe_filename.clone(),
|
replace_extension(&safe_filename, "mp4"),
|
||||||
if guessed_mimetype.is_empty() { "video/x-flv".to_string() } else { guessed_mimetype.clone() },
|
"video/mp4".to_string(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let temp_storage_filename = format!("{}.flv", asset_id);
|
let temp_storage_filename = format!("{}.flv", asset_id);
|
||||||
@@ -1407,15 +1561,11 @@ pub async fn import_assets_zip(
|
|||||||
|
|
||||||
match push_bytes_to_s3(client, settings, &key, &mimetype, upload_bytes).await {
|
match push_bytes_to_s3(client, settings, &key, &mimetype, upload_bytes).await {
|
||||||
Ok((s3_path, public_url)) => {
|
Ok((s3_path, public_url)) => {
|
||||||
if is_flv {
|
cleanup_local_temp_file(&storage_path).await;
|
||||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
|
||||||
}
|
|
||||||
(s3_path, public_url)
|
(s3_path, public_url)
|
||||||
}
|
}
|
||||||
Err((_, msg)) => {
|
Err((_, msg)) => {
|
||||||
if is_flv {
|
cleanup_local_temp_file(&storage_path).await;
|
||||||
let _ = tokio::fs::remove_file(&storage_path).await;
|
|
||||||
}
|
|
||||||
failed_entries.push(format!("{}: s3 upload failed ({})", entry_name, msg));
|
failed_entries.push(format!("{}: s3 upload failed ({})", entry_name, msg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1850,8 +2000,8 @@ fn replace_last_path_extension(path: &str, new_ext: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_public_url_from_storage_path(storage_path: &str) -> String {
|
fn build_public_url_from_storage_path(storage_path: &str) -> String {
|
||||||
if let Some((_, key)) = parse_s3_storage_path(storage_path) {
|
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
|
||||||
if let Some(settings) = get_s3_settings() {
|
if let Some(settings) = get_s3_settings_for_bucket(bucket) {
|
||||||
return build_s3_public_url(&settings, key);
|
return build_s3_public_url(&settings, key);
|
||||||
}
|
}
|
||||||
return storage_path.to_string();
|
return storage_path.to_string();
|
||||||
@@ -1899,7 +2049,7 @@ async fn normalize_flv_asset_for_rag(
|
|||||||
|
|
||||||
let next_storage_path = replace_last_path_extension(&asset.storage_path, "mp4");
|
let next_storage_path = replace_last_path_extension(&asset.storage_path, "mp4");
|
||||||
if let Some((bucket, key)) = parse_s3_storage_path(&next_storage_path) {
|
if let Some((bucket, key)) = parse_s3_storage_path(&next_storage_path) {
|
||||||
let settings = get_s3_settings().ok_or((
|
let settings = get_s3_settings_for_bucket(bucket).ok_or((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"S3 path detected but storage is not configured".to_string(),
|
"S3 path detected but storage is not configured".to_string(),
|
||||||
))?;
|
))?;
|
||||||
@@ -1960,8 +2110,12 @@ async fn normalize_flv_asset_for_rag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn transcode_flv_to_mp4(input_path: &str, output_path: &str) -> Result<(), (StatusCode, String)> {
|
async fn transcode_flv_to_mp4(input_path: &str, output_path: &str) -> Result<(), (StatusCode, String)> {
|
||||||
|
let ffmpeg_threads = read_env_usize_with_bounds("ZIP_FFMPEG_THREADS", 1, 1, 8);
|
||||||
|
|
||||||
let output = Command::new("ffmpeg")
|
let output = Command::new("ffmpeg")
|
||||||
.arg("-y")
|
.arg("-y")
|
||||||
|
.arg("-threads")
|
||||||
|
.arg(ffmpeg_threads.to_string())
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
.arg(input_path)
|
.arg(input_path)
|
||||||
.arg("-c:v")
|
.arg("-c:v")
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ struct ForumEmailRecipient {
|
|||||||
struct EmailTemplate {
|
struct EmailTemplate {
|
||||||
subject_template: String,
|
subject_template: String,
|
||||||
body_template: String,
|
body_template: String,
|
||||||
is_html: bool,
|
|
||||||
is_enabled: bool,
|
is_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +154,9 @@ async fn load_org_smtp_config(pool: &PgPool, organization_id: Uuid) -> Option<Sm
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_email_template(
|
async fn load_email_template(
|
||||||
organization_id: Uuid,
|
_organization_id: Uuid,
|
||||||
template_key: &str,
|
template_key: &str,
|
||||||
) -> Option<EmailTemplate> {
|
) -> Option<EmailTemplate> {
|
||||||
let cms_api_url = env::var("CMS_API_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
|
|
||||||
let url = format!("{}/organization/email-templates", cms_api_url);
|
|
||||||
|
|
||||||
// Para simplificar, por ahora devolvemos plantillas hardcoded
|
// Para simplificar, por ahora devolvemos plantillas hardcoded
|
||||||
// En producción, haríamos la llamada HTTP con autenticación
|
// En producción, haríamos la llamada HTTP con autenticación
|
||||||
match template_key {
|
match template_key {
|
||||||
@@ -177,7 +173,6 @@ Ver hilo completo: {{thread_url}}
|
|||||||
|
|
||||||
Saludos,
|
Saludos,
|
||||||
El equipo de {{organization_name}}".to_string(),
|
El equipo de {{organization_name}}".to_string(),
|
||||||
is_html: false,
|
|
||||||
is_enabled: true,
|
is_enabled: true,
|
||||||
}),
|
}),
|
||||||
"forum_thread" => Some(EmailTemplate {
|
"forum_thread" => Some(EmailTemplate {
|
||||||
@@ -193,7 +188,6 @@ Ver hilo: {{thread_url}}
|
|||||||
|
|
||||||
Saludos,
|
Saludos,
|
||||||
El equipo de {{organization_name}}".to_string(),
|
El equipo de {{organization_name}}".to_string(),
|
||||||
is_html: false,
|
|
||||||
is_enabled: true,
|
is_enabled: true,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
Reference in New Issue
Block a user