use axum::{ extract::{Path, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use serde::{Deserialize, Serialize}; use crate::api::server::AppState; use crate::core::config; use crate::core::db::PostgresDb; use crate::core::tmdb; #[derive(Debug, Serialize)] struct TmdbPrefetchResponse { success: bool, file_uuid: String, message: String, cache_path: Option, } #[derive(Debug, Serialize)] struct TmdbProbeResponse { success: bool, file_uuid: String, tmdb_id: Option, movie_title: Option, cast_count: Option, identities_created: Option, message: String, } #[derive(Debug, Serialize)] struct TmdbResourceResponse { success: bool, status: tmdb::status::TmdbResourceStatus, identities_seeded: i64, identities_with_embedding: i64, cache_files: usize, operations: Vec, } #[derive(Debug, Serialize)] struct TmdbOperation { method: String, path: String, description: String, } #[derive(Debug, Serialize)] struct TmdbCheckResponse { success: bool, status: tmdb::status::TmdbResourceStatus, } #[derive(Debug, Deserialize)] struct PrefetchRequest { file_uuid: String, } #[derive(Debug, Deserialize)] struct FileUuidParam { file_uuid: String, } pub fn tmdb_routes() -> Router { Router::new() .route("/api/v1/agents/tmdb/prefetch", post(tmdb_prefetch)) .route("/api/v1/file/:file_uuid/tmdb-probe", post(tmdb_probe_handler)) .route("/api/v1/resource/tmdb", get(tmdb_resource_status)) .route("/api/v1/resource/tmdb/check", post(tmdb_resource_check)) } async fn tmdb_prefetch( State(state): State, Json(req): Json, ) -> Json { let file_uuid = req.file_uuid; // Verify file exists in DB let file_exists: bool = sqlx::query_scalar( &format!("SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1", crate::core::db::schema::table_name("videos")) ) .bind(&file_uuid) .fetch_one(state.db.pool()) .await .unwrap_or(false); if !file_exists { return Json(TmdbPrefetchResponse { success: false, file_uuid: file_uuid.clone(), message: format!("File not found: {}", file_uuid), cache_path: None, }); } // Offline-first: check if identity files already exist on disk (pre-prepared) let identities_dir = std::path::Path::new(&*config::OUTPUT_DIR).join("identities"); let index_path = identities_dir.join("_index.json"); let cache_path = format!("{}/{}.tmdb.json", *config::OUTPUT_DIR, file_uuid); let cache_file = std::path::Path::new(&cache_path); if index_path.exists() && cache_file.exists() { return Json(TmdbPrefetchResponse { success: true, file_uuid, message: format!( "Offline: using local identity files from {}.", identities_dir.display() ), cache_path: Some(cache_path), }); } if config::tmdb::API_KEY.is_none() { return Json(TmdbPrefetchResponse { success: false, file_uuid: file_uuid.clone(), message: "TMDB_API_KEY not configured and no local cache found.".to_string(), cache_path: None, }); } let scripts_dir = config::SCRIPTS_DIR.clone(); let python_path = config::PYTHON_PATH.clone(); let agent_script = std::path::Path::new(&scripts_dir).join("tmdb_agent.py"); if !agent_script.exists() { return Json(TmdbPrefetchResponse { success: false, file_uuid, message: format!("tmdb_agent.py not found at {}", agent_script.display()), cache_path: None, }); } let db_url = config::DATABASE_URL.clone(); let output = tokio::process::Command::new(&*python_path) .arg(&agent_script) .arg("--file-uuid") .arg(&file_uuid) .env("DATABASE_URL", &db_url) .env("DATABASE_SCHEMA", &*config::DATABASE_SCHEMA) .output() .await; match output { Ok(o) => { if o.status.success() { let out = String::from_utf8_lossy(&o.stdout); Json(TmdbPrefetchResponse { success: true, file_uuid, message: out.lines().last().unwrap_or("OK").to_string(), cache_path: Some(cache_path), }) } else { let stderr = String::from_utf8_lossy(&o.stderr); Json(TmdbPrefetchResponse { success: false, file_uuid, message: stderr.to_string(), cache_path: None, }) } } Err(e) => Json(TmdbPrefetchResponse { success: false, file_uuid, message: format!("Failed to run tmdb_agent.py: {}", e), cache_path: None, }), } } async fn tmdb_probe_handler( Path(params): Path, State(state): State, ) -> Result, (StatusCode, Json)> { let file_uuid = params.file_uuid; // Verify file exists let file_exists: bool = sqlx::query_scalar( &format!("SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1", crate::core::db::schema::table_name("videos")) ) .bind(&file_uuid) .fetch_one(state.db.pool()) .await .unwrap_or(false); if !file_exists { return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Video not found", "file_uuid": file_uuid })))); } match tmdb::probe::probe_from_cache(&state.db, &file_uuid).await { Ok(result) => Ok(Json(TmdbProbeResponse { success: true, file_uuid, tmdb_id: Some(result.tmdb_id), movie_title: Some(result.title), cast_count: Some(result.cast_count), identities_created: Some(result.identities_created), message: format!( "Created/updated {} identities for movie ID {}", result.identities_created, result.tmdb_id ), })), Err(e) => { let msg = e.to_string(); if msg.contains("not found") { Ok(Json(TmdbProbeResponse { success: false, file_uuid, tmdb_id: None, movie_title: None, cast_count: None, identities_created: None, message: "No TMDb cache found. Run tmdb-prefetch first.".to_string(), })) } else { Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg, "file_uuid": file_uuid })))) } } } } async fn tmdb_resource_status( State(state): State, ) -> Json { let status = tmdb::status::quick_status(); let identities_seeded = tmdb::status::count_tmdb_identities(state.db.pool()) .await .unwrap_or(0); let identities_with_embedding = tmdb::status::count_tmdb_identities_with_embedding(state.db.pool()) .await .unwrap_or(0); let cache_files = tmdb::status::count_cache_files(); Json(TmdbResourceResponse { success: true, status, identities_seeded, identities_with_embedding, cache_files, operations: vec![ TmdbOperation { method: "GET".to_string(), path: "/api/v1/resource/tmdb".to_string(), description: "TMDb resource status".to_string(), }, TmdbOperation { method: "POST".to_string(), path: "/api/v1/resource/tmdb/check".to_string(), description: "Ping TMDb API health".to_string(), }, TmdbOperation { method: "POST".to_string(), path: "/api/v1/agents/tmdb/prefetch".to_string(), description: "Fetch TMDb data and cache locally".to_string(), }, TmdbOperation { method: "POST".to_string(), path: "/api/v1/file/:file_uuid/tmdb-probe".to_string(), description: "Read cache and create identities".to_string(), }, ], }) } async fn tmdb_resource_check() -> Json { let status = tmdb::status::check_tmdb_api().await; Json(TmdbCheckResponse { success: status.api_reachable.unwrap_or(false) && status.api_key_configured, status, }) }