#![allow(clippy::too_many_arguments)] #[macro_use] extern crate diesel; extern crate rayon; use actix_web::web::Data; use actix_web_prom::PrometheusMetricsBuilder; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::collections::HashMap; use std::env; use std::sync::{Arc, Mutex}; use actix_cors::Cors; use actix_governor::{Governor, GovernorConfigBuilder}; use actix_multipart as mp; use actix_web::{App, HttpResponse, HttpServer, middleware, web}; use diesel::sqlite::Sqlite; use std::error::Error; use crate::ai::InsightGenerator; use crate::auth::login; use crate::data::*; use crate::database::*; use crate::files::{RealFileSystem, move_file}; use crate::service::ServiceBuilder; use crate::state::AppState; use crate::tags::*; use crate::video::actors::ScanDirectoryMessage; use log::{error, info}; mod ai; mod auth; mod backfill; mod content_hash; mod data; mod database; mod date_resolver; mod duplicates; mod error; mod exif; mod face_watch; mod faces; mod file_scan; mod file_types; mod files; mod geo; mod handlers; mod libraries; mod library_maintenance; mod perceptual_hash; mod state; mod tags; mod thumbnails; mod utils; mod video; mod watcher; mod knowledge; mod memories; mod otel; mod personas; mod service; #[cfg(test)] mod testhelpers; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); fn main() -> std::io::Result<()> { if let Err(err) = dotenv::dotenv() { println!("Error parsing .env {:?}", err); } run_migrations(&mut connect()).expect("Failed to run migrations"); let system = actix::System::new(); system.block_on(async { // Just use basic logger when running a non-release build #[cfg(debug_assertions)] { env_logger::init(); } #[cfg(not(debug_assertions))] { otel::init_logs(); otel::init_tracing(); } // AppState construction loads (and seeds if needed) the libraries // table; we use that list to drive the initial thumbnail sweep. let app_data = Data::new(AppState::default()); // Kick thumbnail generation onto a background thread so the HTTP // server can accept traffic while large libraries are backfilling. // Existing thumbs are re-used (exists() check inside the walk), // so missed files are filled in over successive scans. { let libs = app_data.libraries.clone(); let excluded = app_data.excluded_dirs.clone(); std::thread::spawn(move || { thumbnails::create_thumbnails(&libs, &excluded); }); } // generate_video_gifs().await; let labels = HashMap::new(); let prometheus = PrometheusMetricsBuilder::new("api") .const_labels(labels) .build() .expect("Unable to build prometheus metrics middleware"); prometheus .registry .register(Box::new(thumbnails::IMAGE_GAUGE.clone())) .unwrap(); prometheus .registry .register(Box::new(thumbnails::VIDEO_GAUGE.clone())) .unwrap(); let app_state = app_data.clone(); for lib in &app_state.libraries { app_state.playlist_manager.do_send(ScanDirectoryMessage { directory: lib.root_path.clone(), }); } // Start file watcher with playlist manager and preview generator let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone(); let preview_gen_for_watcher = app_state.preview_clip_generator.as_ref().clone(); watcher::watch_files( app_state.libraries.clone(), playlist_mgr_for_watcher, preview_gen_for_watcher, app_state.face_client.clone(), app_state.excluded_dirs.clone(), app_state.library_health.clone(), ); // Start orphaned playlist cleanup job. Multi-library aware: walks // every configured library when looking for the source video, and // skips the whole cycle while any library is stale (a missing // source is indistinguishable from a transiently-unmounted share). watcher::cleanup_orphaned_playlists( app_state.libraries.clone(), app_state.excluded_dirs.clone(), app_state.library_health.clone(), ); // Spawn background job to generate daily conversation summaries { use crate::ai::generate_daily_summaries; use crate::database::{DailySummaryDao, SqliteDailySummaryDao}; use chrono::NaiveDate; // Configure date range for summary generation // Default: August 2024 ±30 days (July 1 - September 30, 2024) // To expand: change start_date and end_date let start_date = Some(NaiveDate::from_ymd_opt(2015, 10, 1).unwrap()); let end_date = Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()); // let contacts_to_summarize = vec!["Domenique", "Zach", "Paul"]; // Add more contacts as needed let contacts_to_summarize = vec![]; // Add more contacts as needed let ollama = app_state.ollama.clone(); let sms_client = app_state.sms_client.clone(); for contact in contacts_to_summarize { let ollama_clone = ollama.clone(); let sms_client_clone = sms_client.clone(); let summary_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteDailySummaryDao::new()))); let start = start_date; let end = end_date; tokio::spawn(async move { info!("Starting daily summary generation for {}", contact); if let Err(e) = generate_daily_summaries( contact, start, end, &ollama_clone, &sms_client_clone, summary_dao, ) .await { error!("Daily summary generation failed for {}: {:?}", contact, e); } else { info!("Daily summary generation completed for {}", contact); } }); } } HttpServer::new(move || { let user_dao = SqliteUserDao::new(); let favorites_dao = SqliteFavoriteDao::new(); let tag_dao = SqliteTagDao::default(); let exif_dao = SqliteExifDao::new(); let insight_dao = SqliteInsightDao::new(); let preview_dao = SqlitePreviewDao::new(); let face_dao = faces::SqliteFaceDao::new(); let cors = Cors::default() .allowed_origin_fn(|origin, _req_head| { // Allow all origins in development, or check against CORS_ALLOWED_ORIGINS env var if let Ok(allowed_origins) = env::var("CORS_ALLOWED_ORIGINS") { allowed_origins .split(',') .any(|allowed| origin.as_bytes() == allowed.trim().as_bytes()) } else { // Default: allow all origins if not configured true } }) .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) .allowed_headers(vec![ actix_web::http::header::AUTHORIZATION, actix_web::http::header::ACCEPT, actix_web::http::header::CONTENT_TYPE, ]) .supports_credentials() .max_age(3600); // Configure rate limiting for login endpoint (2 requests/sec, burst of 5) let governor_conf = GovernorConfigBuilder::default() .per_second(2) .burst_size(5) .finish() .unwrap(); App::new() .wrap(middleware::Logger::default()) .wrap(cors) .service( web::resource("/login") .wrap(Governor::new(&governor_conf)) .route(web::post().to(login::)), ) .service( web::resource("/photos") .route(web::get().to(files::list_photos::)), ) .service( web::resource("/photos/gps-summary") .route(web::get().to(files::get_gps_summary)), ) .service( web::resource("/photos/exif").route(web::get().to(files::list_exif_summary)), ) .service(web::resource("/file/move").post(move_file::)) .service(handlers::image::get_image) .service(handlers::image::upload_image) .service(handlers::video::generate_video) .service(handlers::video::stream_video) .service(handlers::video::get_video_preview) .service(handlers::video::get_preview_status) .service(handlers::video::get_video_part) .service(handlers::favorites::favorites) .service(handlers::favorites::put_add_favorite) .service(handlers::favorites::delete_favorite) .service(handlers::image::get_file_metadata) .service(handlers::image::set_image_gps) .service(handlers::image::set_image_date) .service(handlers::image::clear_image_date) .service(handlers::image::get_full_exif) .service(memories::list_memories) .service(ai::generate_insight_handler) .service(ai::generate_agentic_insight_handler) .service(ai::get_insight_handler) .service(ai::delete_insight_handler) .service(ai::get_all_insights_handler) .service(ai::get_available_models_handler) .service(ai::get_openrouter_models_handler) .service(ai::chat_turn_handler) .service(ai::chat_stream_handler) .service(ai::chat_history_handler) .service(ai::chat_rewind_handler) .service(ai::rate_insight_handler) .service(ai::export_training_data_handler) .service(libraries::list_libraries) .add_feature(add_tag_services::<_, SqliteTagDao>) .add_feature(knowledge::add_knowledge_services::<_, SqliteKnowledgeDao>) .add_feature(personas::add_persona_services) .add_feature(faces::add_face_services::<_, faces::SqliteFaceDao>) .add_feature(duplicates::add_duplicate_services) .app_data(app_data.clone()) .app_data::>(Data::new(RealFileSystem::new( app_data.base_path.clone(), ))) .app_data::>>(Data::new(Mutex::new(user_dao))) .app_data::>>>(Data::new(Mutex::new(Box::new( favorites_dao, )))) .app_data::>>(Data::new(Mutex::new(tag_dao))) .app_data::>>>(Data::new(Mutex::new(Box::new( exif_dao, )))) .app_data::>>>(Data::new(Mutex::new(Box::new( insight_dao, )))) .app_data::>>>(Data::new(Mutex::new(Box::new( preview_dao, )))) .app_data::>>(Data::new(Mutex::new( SqliteKnowledgeDao::new(), ))) .app_data::>>>(Data::new(Mutex::new( Box::new(database::SqlitePersonaDao::new()), ))) .app_data::>>(Data::new(Mutex::new(face_dao))) .app_data::>(Data::new( app_data.face_client.clone(), )) .app_data(mp::form::MultipartFormConfig::default().total_limit(1024 * 1024 * 1024)) // 1GB upload limit .app_data(web::JsonConfig::default().error_handler(|err, req| { let detail = err.to_string(); log::warn!( "JSON parse error on {} {}: {}", req.method(), req.uri(), detail ); let response = HttpResponse::BadRequest().json(serde_json::json!({"error": detail})); actix_web::error::InternalError::from_response(err, response).into() })) .app_data::>(Data::new(app_data.insight_generator.clone())) .wrap(prometheus.clone()) }) .bind(dotenv::var("BIND_URL").unwrap())? .bind("localhost:8088")? .run() .await }) } fn run_migrations( connection: &mut impl MigrationHarness, ) -> Result<(), Box> { connection.run_pending_migrations(MIGRATIONS)?; Ok(()) }