diff --git a/k-tv-backend/domain/src/lib.rs b/k-tv-backend/domain/src/lib.rs index acfee41..e0ee9ad 100644 --- a/k-tv-backend/domain/src/lib.rs +++ b/k-tv-backend/domain/src/lib.rs @@ -23,6 +23,7 @@ pub use iptv::{generate_m3u, generate_xmltv}; pub use library::{ ILibraryRepository, LibraryCollection, LibraryItem, LibrarySearchFilter, LibrarySyncAdapter, LibrarySyncLogEntry, LibrarySyncResult, + SeasonSummary, ShowSummary, }; pub use services::{ChannelService, ScheduleEngineService, UserService}; pub use value_objects::*; diff --git a/k-tv-backend/infra/src/library_repository.rs b/k-tv-backend/infra/src/library_repository.rs index 0cc4fbf..eb09db1 100644 --- a/k-tv-backend/infra/src/library_repository.rs +++ b/k-tv-backend/infra/src/library_repository.rs @@ -272,6 +272,107 @@ impl ILibraryRepository for SqliteLibraryRepository { .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(count > 0) } + + async fn list_shows( + &self, + provider_id: Option<&str>, + search_term: Option<&str>, + genres: &[String], + ) -> DomainResult> { + let mut conditions = vec![ + "content_type = 'episode'".to_string(), + "series_name IS NOT NULL".to_string(), + ]; + if let Some(p) = provider_id { + conditions.push(format!("provider_id = '{}'", p.replace('\'', "''"))); + } + if let Some(st) = search_term { + let escaped = st.replace('\'', "''"); + conditions.push(format!( + "(title LIKE '%{escaped}%' OR series_name LIKE '%{escaped}%')" + )); + } + if !genres.is_empty() { + let genre_conditions: Vec = genres + .iter() + .map(|g| format!( + "EXISTS (SELECT 1 FROM json_each(library_items.genres) WHERE value = '{}')", + g.replace('\'', "''") + )) + .collect(); + conditions.push(format!("({})", genre_conditions.join(" OR "))); + } + let where_clause = format!("WHERE {}", conditions.join(" AND ")); + let sql = format!( + "SELECT series_name, COUNT(*) AS episode_count, COUNT(DISTINCT season_number) AS season_count, MAX(thumbnail_url) AS thumbnail_url, GROUP_CONCAT(genres, ',') AS genres_blob FROM library_items {} GROUP BY series_name ORDER BY series_name ASC", + where_clause + ); + let rows = sqlx::query_as::<_, ShowSummaryRow>(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(rows + .into_iter() + .map(|r| { + let genres: Vec = r + .genres_blob + .split("],[") + .flat_map(|chunk| { + let cleaned = chunk.trim_start_matches('[').trim_end_matches(']'); + cleaned + .split(',') + .filter_map(|s| { + let s = s.trim().trim_matches('"'); + if s.is_empty() { None } else { Some(s.to_string()) } + }) + .collect::>() + }) + .collect::>() + .into_iter() + .collect(); + ShowSummary { + series_name: r.series_name, + episode_count: r.episode_count as u32, + season_count: r.season_count as u32, + thumbnail_url: r.thumbnail_url, + genres, + } + }) + .collect()) + } + + async fn list_seasons( + &self, + series_name: &str, + provider_id: Option<&str>, + ) -> DomainResult> { + let mut conditions = vec![ + format!("series_name = '{}'", series_name.replace('\'', "''")), + "content_type = 'episode'".to_string(), + ]; + if let Some(p) = provider_id { + conditions.push(format!("provider_id = '{}'", p.replace('\'', "''"))); + } + let where_clause = format!("WHERE {}", conditions.join(" AND ")); + let sql = format!( + "SELECT season_number, COUNT(*) AS episode_count, MAX(thumbnail_url) AS thumbnail_url FROM library_items {} GROUP BY season_number ORDER BY season_number ASC", + where_clause + ); + let rows = sqlx::query_as::<_, SeasonSummaryRow>(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(rows + .into_iter() + .map(|r| SeasonSummary { + season_number: r.season_number as u32, + episode_count: r.episode_count as u32, + thumbnail_url: r.thumbnail_url, + }) + .collect()) + } } // ── SQLx row types ───────────────────────────────────────────────────────── @@ -311,6 +412,22 @@ struct SyncLogRow { items_found: i64, status: String, error_msg: Option, } +#[derive(sqlx::FromRow)] +struct ShowSummaryRow { + series_name: String, + episode_count: i64, + season_count: i64, + thumbnail_url: Option, + genres_blob: String, +} + +#[derive(sqlx::FromRow)] +struct SeasonSummaryRow { + season_number: i64, + episode_count: i64, + thumbnail_url: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/k-tv-backend/infra/src/library_sync.rs b/k-tv-backend/infra/src/library_sync.rs index cf2478a..b4db5aa 100644 --- a/k-tv-backend/infra/src/library_sync.rs +++ b/k-tv-backend/infra/src/library_sync.rs @@ -209,6 +209,8 @@ mod tests { async fn log_sync_finish(&self, _id: i64, _r: &LibrarySyncResult) -> DomainResult<()> { Ok(()) } async fn latest_sync_status(&self) -> DomainResult> { Ok(vec![]) } async fn is_sync_running(&self, _pid: &str) -> DomainResult { Ok(false) } + async fn list_shows(&self, _p: Option<&str>, _st: Option<&str>, _g: &[String]) -> DomainResult> { Ok(vec![]) } + async fn list_seasons(&self, _sn: &str, _p: Option<&str>) -> DomainResult> { Ok(vec![]) } } #[tokio::test]