church-api/src/main.rs
Benjamin Slingo 4899c3829c Complete major performance optimizations: eliminate N+1 patterns and memory pagination
- Add event categories config endpoint (/api/config/event-categories)
- Fix bulletin processing N+1: batch hymn lookups for single bulletins
- Optimize V2 events list endpoint: replace memory pagination with database LIMIT/OFFSET
- Add missing SQL functions: list_events_paginated() and count_events()
- Preserve HTTP response compatibility for iOS app
2025-09-07 22:33:23 -04:00

331 lines
15 KiB
Rust

use anyhow::{Context, Result};
use axum::{
extract::Path,
middleware,
response::Redirect,
routing::{delete, get, post, put},
Router,
response::Html,
};
use std::{env, sync::Arc};
use tower::ServiceBuilder;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod auth;
mod sql;
mod email;
mod upload;
mod recurring;
mod error;
mod utils;
mod handlers;
mod models;
mod services;
mod app_state;
use email::{EmailConfig, Mailer};
use app_state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize tracing
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "church_api=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Load environment variables
dotenvy::dotenv().ok();
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
// Initialize database
// Database connection
let pool = sqlx::PgPool::connect(&database_url)
.await
.context("Failed to connect to database")?;
// Run migrations (disabled temporarily)
// sqlx::migrate!("./migrations")
// .run(&pool)
// .await
// .context("Failed to run migrations")?;
let email_config = EmailConfig::from_env().map_err(|e| anyhow::anyhow!("Failed to load email config: {:?}", e))?;
let mailer = Arc::new(Mailer::new(email_config).map_err(|e| anyhow::anyhow!("Failed to initialize mailer: {:?}", e))?);
// Initialize Owncast service with default values
let owncast_host = env::var("OWNCAST_HOST").unwrap_or_else(|_| "stream.rockvilletollandsda.church".to_string());
let stream_host = env::var("STREAM_HOST").unwrap_or_else(|_| "stream.rockvilletollandsda.church".to_string());
let owncast_service = Some(Arc::new(services::OwncastService::new(&owncast_host, &stream_host)));
// Transcoding services removed - replaced by simple smart streaming system
tracing::info!("🎬 Using direct AV1/H.264 smart streaming - no complex transcoding needed");
let state = AppState {
pool: pool.clone(),
jwt_secret,
mailer,
owncast_service,
};
// Create protected admin routes
let admin_routes = Router::new()
.route("/users", get(handlers::auth::list_users))
.route("/bulletins", post(handlers::bulletins::create))
.route("/bulletins/:id", put(handlers::bulletins::update))
.route("/bulletins/:id", delete(handlers::bulletins::delete))
.route("/events/pending", get(handlers::events::list_pending))
.route("/events/pending/:id/approve", post(handlers::events::approve))
.route("/events/pending/:id/reject", post(handlers::events::reject))
.route("/events/pending/:id", delete(handlers::events::delete_pending))
.route("/events/:id", put(handlers::events::update))
.route("/events/:id", delete(handlers::events::delete))
.route("/config", get(handlers::config::get_admin_config))
.route("/config", put(handlers::config::update_config))
.route("/schedule", post(handlers::schedule::create_schedule))
.route("/schedule/:date", put(handlers::schedule::update_schedule))
.route("/schedule/:date", delete(handlers::schedule::delete_schedule))
.route("/schedule", get(handlers::schedule::list_schedules))
.route("/members", get(handlers::members::list))
.route("/members", post(handlers::members::create))
.route("/members/:id", delete(handlers::members::delete))
.route("/quarterlies", post(handlers::quarterlies::create))
.route("/quarterlies/:id", put(handlers::quarterlies::update))
.route("/quarterlies/:id", delete(handlers::quarterlies::delete))
.route("/quarterlies/import", post(handlers::quarterlies::import_from_json))
.route("/backup/create", post(handlers::backup::create_backup))
.route("/backup/list", get(handlers::backup::list_backups))
.route("/backup/cleanup", post(handlers::backup::cleanup_backups))
.route("/backup/now", post(handlers::backup::backup_now))
.layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
// Build our application with routes
let app = Router::new()
// Public routes (no auth required)
.route("/api/auth/login", post(handlers::auth::login))
.route("/api/bulletins", get(handlers::bulletins::list))
.route("/api/bulletins/current", get(handlers::bulletins::current))
.route("/api/bulletins/next", get(handlers::bulletins::next))
.route("/api/bulletins/:id", get(handlers::bulletins::get))
.route("/api/events", get(handlers::events::list))
.route("/api/events/upcoming", get(handlers::events::upcoming))
.route("/api/events/featured", get(handlers::events::featured))
.route("/api/events/submit", post(handlers::events::submit))
.route("/api/events/:id", get(handlers::events::get))
.route("/api/config", get(handlers::config::get_public_config))
.route("/api/config/recurring-types", get(handlers::config::get_recurring_types))
.route("/api/config/event-categories", get(handlers::config::get_event_categories))
.route("/api/collections/rtsda_android/records", get(handlers::legacy::android_update))
.route("/api/bible_verses/random", get(handlers::bible_verses::random))
.route("/api/bible_verses", get(handlers::bible_verses::list))
.route("/api/bible_verses/search", get(handlers::bible_verses::search))
.route("/api/contact", post(handlers::contact::submit_contact))
.route("/api/schedule", get(handlers::schedule::get_schedule))
.route("/api/conference-data", get(handlers::schedule::get_conference_data))
.route("/api/members/active", get(handlers::members::list_active))
.route("/api/quarterlies", get(handlers::quarterlies::list))
.route("/api/quarterlies/:id", get(handlers::quarterlies::get))
// Hymnal API endpoints
.route("/api/hymnals", get(handlers::hymnal::list_hymnals))
.route("/api/hymnals/:id", get(handlers::hymnal::get_hymnal))
.route("/api/hymnals/code/:code", get(handlers::hymnal::get_hymnal_by_code))
.route("/api/hymns", get(handlers::hymnal::list_hymns))
.route("/api/hymns/search", get(handlers::hymnal::search_hymns))
.route("/api/hymns/:hymnal_code/:number", get(handlers::hymnal::get_hymn_by_number))
.route("/api/hymnals/:id/themes", get(handlers::hymnal::list_thematic_lists))
.route("/api/hymnals/code/:code/themes", get(handlers::hymnal::list_thematic_lists_by_code))
.route("/api/responsive-readings", get(handlers::hymnal::list_responsive_readings))
.route("/api/responsive-readings/search", get(handlers::hymnal::search_responsive_readings))
.route("/api/responsive-readings/:number", get(handlers::hymnal::get_responsive_reading))
// New media library endpoints (replacing Jellyfin)
.route("/api/sermons", get(handlers::media::list_sermons))
.route("/api/livestreams", get(handlers::media::list_livestreams))
.route("/api/media/items", get(handlers::media::list_media_items))
.route("/api/media/items/:id", get(handlers::media::get_media_item))
// 🎯 SMART STREAMING - AV1 direct, HLS for legacy (like Jellyfin but not shit)
.route("/api/media/stream/:media_id", get(handlers::smart_streaming::smart_video_streaming))
.route("/api/media/stream/:media_id/playlist.m3u8", get(handlers::smart_streaming::generate_hls_playlist_for_segment_generation))
.route("/api/media/stream/:media_id/:segment_name", get(handlers::smart_streaming::serve_hls_segment))
.route("/api/media/:media_id/thumbnail", get(handlers::smart_streaming::serve_thumbnail))
// Legacy chunk streaming API removed - never released transcoding nightmare
// Test pages
.route("/chunk-test", get(serve_chunk_test_page))
.route("/smart-test", get(serve_smart_test_page))
.route("/api/media/thumbnail/:id", get(handlers::media::get_thumbnail))
// Legacy Jellyfin endpoints (keep for compatibility during transition)
// Jellyfin debug routes removed - clients hit Jellyfin directly now
.route("/api/stream/status", get(handlers::owncast::get_stream_status))
.route("/api/stream/live", get(handlers::owncast::get_live_status))
.route("/api/stream/hls/stream.m3u8", get(handlers::owncast::proxy_hls_playlist))
.route("/api/stream/hls/:variant/stream.m3u8", get(handlers::owncast::proxy_hls_variant))
.route("/api/stream/hls/:variant/:segment", get(handlers::owncast::proxy_hls_segment))
.route("/api/v1/stream/status", get(handlers::owncast::get_stream_status))
// V2 API routes with enhanced timezone handling
.route("/api/v2/events", get(handlers::v2::events::list))
.route("/api/v2/events/upcoming", get(handlers::v2::events::get_upcoming))
.route("/api/v2/events/featured", get(handlers::v2::events::get_featured))
.route("/api/v2/events/submit", post(handlers::v2::events::submit))
.route("/api/v2/events/:id", get(handlers::v2::events::get_by_id))
.route("/api/v2/bulletins", get(handlers::v2::bulletins::list))
.route("/api/v2/bulletins/current", get(handlers::v2::bulletins::get_current))
.route("/api/v2/bulletins/next", get(handlers::v2::bulletins::get_next))
.route("/api/v2/bulletins/:id", get(handlers::v2::bulletins::get_by_id))
.route("/api/v2/bible_verses/random", get(handlers::v2::bible_verses::get_random))
.route("/api/v2/bible_verses", get(handlers::v2::bible_verses::list))
.route("/api/v2/bible_verses/search", get(handlers::v2::bible_verses::search))
.route("/api/v2/contact", post(handlers::v2::contact::submit_contact))
.route("/api/v2/schedule", get(handlers::v2::schedule::get_schedule))
.route("/api/v2/conference-data", get(handlers::v2::schedule::get_conference_data))
// Redirect /api/v1/* to /api/*
.route("/api/v1/*path", get(redirect_v1_to_api))
// Mount protected admin routes (with chunk streaming cleanup)
.nest("/api/admin", admin_routes)
.nest("/api/upload", upload::routes())
.with_state(state)
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
),
);
// Start recurring events scheduler
recurring::start_recurring_events_scheduler(pool.clone()).await;
// Start database backup scheduler
let backup_database_url = database_url.clone();
tokio::spawn(async move {
use services::BackupScheduler;
let scheduler = BackupScheduler::default_config(backup_database_url);
if let Err(e) = scheduler.start().await {
tracing::error!("Backup scheduler failed: {}", e);
}
});
// Start periodic media scanning (disabled initial scan for performance)
let media_pool = pool.clone();
tokio::spawn(async move {
use services::MediaScanner;
let scanner = MediaScanner::new(media_pool);
// Wait 30 seconds after startup before first scan to allow server to settle
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
// Periodic scanning every 10 minutes (reduced frequency for performance)
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
loop {
interval.tick().await;
tracing::info!("Starting periodic media scan...");
if let Err(e) = scanner.scan_directory("/media/archive/jellyfin/sermons").await {
tracing::error!("Periodic sermons scan failed: {:?}", e);
}
if let Err(e) = scanner.scan_directory("/media/archive/jellyfin/livestreams").await {
tracing::error!("Periodic livestreams scan failed: {:?}", e);
}
tracing::info!("Periodic media scan completed");
}
});
// Start Intel Arc A770 background thumbnail generation
tokio::spawn(async move {
use services::ThumbnailGenerator;
// Wait 60 seconds after startup to allow server to settle and media scan to complete
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
tracing::info!("🚀 Starting Intel Arc A770 background thumbnail generation");
// Run thumbnail generation every 30 minutes
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1800));
loop {
interval.tick().await;
tracing::info!("📸 Starting Arc A770 thumbnail generation scan...");
// Generate thumbnails for sermons directory
if let Err(e) = ThumbnailGenerator::scan_and_generate_missing_thumbnails(
"/media/archive/jellyfin/sermons",
&format!("{}/thumbnails",
std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "/opt/rtsda/church-api/uploads".to_string()))
).await {
tracing::error!("Sermons thumbnail generation failed: {:?}", e);
}
// Generate thumbnails for livestreams directory
if let Err(e) = ThumbnailGenerator::scan_and_generate_missing_thumbnails(
"/media/archive/jellyfin/livestreams",
&format!("{}/thumbnails",
std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "/opt/rtsda/church-api/uploads".to_string()))
).await {
tracing::error!("Livestreams thumbnail generation failed: {:?}", e);
}
tracing::info!("📸 Arc A770 thumbnail generation scan completed");
}
});
let listener = tokio::net::TcpListener::bind("0.0.0.0:3002").await?;
tracing::info!("🚀 Church API server running on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
/// Serve the chunk streaming test page
async fn serve_chunk_test_page() -> Html<String> {
let html = include_str!("../chunk_streaming_test.html");
Html(html.to_string())
}
/// Serve the smart streaming test page
async fn serve_smart_test_page() -> Html<String> {
let html = include_str!("../smart_streaming_test.html");
Html(html.to_string())
}
/// Redirect /api/v1/* requests to /api/*
async fn redirect_v1_to_api(Path(path): Path<String>) -> Redirect {
let new_path = format!("/api/{}", path);
Redirect::permanent(&new_path)
}
#[cfg(test)]
mod tests {
use bcrypt::{hash, verify, DEFAULT_COST};
#[test]
fn test_bcrypt() {
let password = "test123";
let hashed = hash(password, DEFAULT_COST).unwrap();
println!("Hash: {}", hashed);
assert!(verify(password, &hashed).unwrap());
}
}
#[cfg(test)]
mod tests4 {
use bcrypt::{hash, DEFAULT_COST};
#[test]
fn generate_real_password_hash() {
let password = "Alright8-Reapply-Shrewdly-Platter-Important-Keenness-Banking-Streak-Tactile";
let hashed = hash(password, DEFAULT_COST).unwrap();
println!("Hash for real password: {}", hashed);
}
}