feat: full clean architecture refactor — domain → application → infrastructure → presentation
- Restructure from Modular MVC to Clean Architecture with 4 strict layers - Domain: entities (anime/komik/proxy), Repository traits, typed errors - Application: use case classes with proper error propagation - Infrastructure: repository impls, parsers, Redis cache, HTTP/scraping, browser - Presentation: Axum handlers, DTOs, AppState, router, AppError+IntoResponse - Migrate all parsers (otakudesu, alqanime, komik) to native infra implementations - Replace once_cell::sync::Lazy/OnceCell with std::sync::LazyLock/OnceLock - Remove 150+ old files in modules/ and shared/ directories - Remove once_cell from Cargo.toml dependencies - Fix test/debug binaries to use new import paths - Zero new clippy warnings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
80c96eaa42
commit
776fa828d8
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Anime (Otakudesu) application use cases.
|
||||
//!
|
||||
//! Orchestrates repository fetching, caching, and image poster processing.
|
||||
//! Returns pure domain types — no DTOs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deadpool_redis::Pool;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::domain::entity::anime::*;
|
||||
use crate::domain::error::*;
|
||||
use crate::infrastructure::cache::redis::Cache;
|
||||
use crate::infrastructure::repository::OtakudesuRepository;
|
||||
use crate::infrastructure::services::images::cache::{
|
||||
cache_image_urls_batch_lazy, get_cached_or_original,
|
||||
};
|
||||
|
||||
const INDEX_CACHE_TTL: u64 = 10;
|
||||
const GENRE_LIST_CACHE_TTL: u64 = 3600;
|
||||
const DEFAULT_CACHE_TTL: u64 = 300;
|
||||
|
||||
pub struct AnimeUseCases {
|
||||
repository: OtakudesuRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
}
|
||||
|
||||
impl AnimeUseCases {
|
||||
pub fn new(
|
||||
repository: OtakudesuRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
redis_pool,
|
||||
db,
|
||||
semaphore,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(&self) -> Cache<'_> {
|
||||
Cache::new(&self.redis_pool)
|
||||
}
|
||||
|
||||
pub async fn get_anime_index(&self) -> Result<AnimeData, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("anime:index:v2", INDEX_CACHE_TTL, || async {
|
||||
let mut data = self
|
||||
.repository
|
||||
.fetch_anime_index()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if data.ongoing_anime.is_empty() && data.complete_anime.is_empty() {
|
||||
return Err("Empty anime index — refusing to cache".to_string());
|
||||
}
|
||||
|
||||
let mut posters: Vec<String> = data
|
||||
.ongoing_anime
|
||||
.iter()
|
||||
.map(|item| item.poster.clone())
|
||||
.collect();
|
||||
posters.extend(data.complete_anime.iter().map(|item| item.poster.clone()));
|
||||
|
||||
let cached_posters = cache_image_urls_batch_lazy(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
posters,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let ongoing_len = data.ongoing_anime.len();
|
||||
for (i, item) in data.ongoing_anime.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_posters.get(i) {
|
||||
item.poster = url.clone();
|
||||
}
|
||||
}
|
||||
for (i, item) in data.complete_anime.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_posters.get(ongoing_len + i) {
|
||||
item.poster = url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_genres(&self) -> Result<Vec<Genre>, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("anime:genres:list", GENRE_LIST_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_genres()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_anime_detail(&self, slug: String) -> Result<AnimeDetailData, DomainError> {
|
||||
let cache_key = format!("anime:detail:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
let mut data = self
|
||||
.repository
|
||||
.fetch_anime_detail(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
data.poster = get_cached_or_original(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
&data.poster,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let rec_posters: Vec<String> = data
|
||||
.recommendations
|
||||
.iter()
|
||||
.map(|r| r.poster.clone())
|
||||
.collect();
|
||||
let cached_rec_posters = cache_image_urls_batch_lazy(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
rec_posters,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (i, rec) in data.recommendations.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_rec_posters.get(i) {
|
||||
rec.poster = url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_complete_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
) -> Result<(Vec<CompleteAnimeListItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:complete:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_complete_anime_page(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_ongoing_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
) -> Result<(Vec<OngoingAnimeListItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:ongoing:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_ongoing_anime_page(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_latest_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
) -> Result<(Vec<LatestAnimeItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:latest:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_latest_anime_page(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_search_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
page: String,
|
||||
) -> Result<(Vec<SearchAnimeItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:search:{}:{}", slug, page);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_search_anime_page(&slug, &page)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_genre_anime_page(
|
||||
&self,
|
||||
genre_slug: String,
|
||||
page: String,
|
||||
) -> Result<(Vec<GenreAnimeItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:genre:{}:{}", genre_slug, page);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_genre_anime_page(&genre_slug, &page)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_anime_full(&self, slug: String) -> Result<AnimeFullData, DomainError> {
|
||||
let cache_key = format!("anime:full:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_anime_full(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,559 @@
|
||||
//! Anime2 (Alqanime) application use cases.
|
||||
//!
|
||||
//! Orchestrates repository fetching, caching, and image poster processing.
|
||||
//!
|
||||
//! TODO: Move parsers from `crate::modules::anime2::parser` to
|
||||
//! `crate::infrastructure::repository::parsers::alqanime_parser`.
|
||||
//! TODO: Move response DTOs to `crate::presentation::dto::anime2`.
|
||||
//! TODO: Once parsers return domain types, replace shared types with
|
||||
//! `crate::domain::entity::anime::{GenreAnimeItem, SearchAnimeItem, LatestAnimeItem}`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deadpool_redis::Pool;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::domain::error::*;
|
||||
use crate::domain::repository::ScrapingRepository;
|
||||
use crate::infrastructure::cache::redis::Cache;
|
||||
use crate::infrastructure::repository::AlqanimeRepository;
|
||||
use crate::infrastructure::services::images::cache::{
|
||||
apply_cached_posters, cache_image_urls_batch_lazy, get_cached_or_original,
|
||||
};
|
||||
|
||||
use crate::infrastructure::repository::parsers::alqanime_parser as parser;
|
||||
|
||||
use crate::domain::entity::anime::{
|
||||
CompleteAnimeItem, FilterAnimeItem, Genre, GenreAnimeItem, HasPoster, LatestAnimeItem,
|
||||
OngoingAnimeItemWithScore, Pagination, SearchAnimeItem,
|
||||
};
|
||||
|
||||
use crate::presentation::dto::common::ApiResponse;
|
||||
|
||||
// Re-export types for handlers to use
|
||||
pub use crate::domain::entity::anime::{
|
||||
CompleteAnimeItem as Anime2CompleteAnimeItem, GenreAnimeItem as Anime2GenreItem,
|
||||
LatestAnimeItem as Anime2LatestItem, OngoingAnimeItemWithScore as Anime2OngoingItem,
|
||||
SearchAnimeItem as Anime2SearchItem,
|
||||
};
|
||||
pub use crate::infrastructure::repository::parsers::alqanime_parser::{
|
||||
AlqDetailData, AlqDownloadItem, AlqLink, AlqRecommendation,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// Response types (will move to presentation::dto::anime2)
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Anime2Item {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub status: String,
|
||||
pub r#type: String,
|
||||
pub score: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
impl HasPoster for Anime2Item {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Anime2Data {
|
||||
pub ongoing_anime: Vec<Anime2Item>,
|
||||
pub complete_anime: Vec<Anime2Item>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Anime2Response {
|
||||
pub status: String,
|
||||
pub data: Anime2Data,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct GenresResponse {
|
||||
pub status: String,
|
||||
pub data: Vec<Genre>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct FiltersApplied {
|
||||
pub genre: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub r#type: Option<String>,
|
||||
pub order: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct FilterResponse {
|
||||
pub success: bool,
|
||||
pub data: Vec<FilterAnimeItem>,
|
||||
pub pagination: Pagination,
|
||||
pub filters_applied: FiltersApplied,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct DetailResponse {
|
||||
pub status: String,
|
||||
pub data: AlqDetailData,
|
||||
}
|
||||
|
||||
const INDEX_CACHE_TTL: u64 = 300;
|
||||
const GENRE_LIST_CACHE_TTL: u64 = 3600;
|
||||
const FILTER_CACHE_TTL: u64 = 300;
|
||||
const DETAIL_CACHE_TTL: u64 = 300;
|
||||
const GENRE_CACHE_TTL: u64 = 300;
|
||||
const SEARCH_CACHE_TTL: u64 = 300;
|
||||
const LATEST_CACHE_TTL: u64 = 120;
|
||||
const ONGOING_CACHE_TTL: u64 = 300;
|
||||
const COMPLETE_CACHE_TTL: u64 = 300;
|
||||
|
||||
// ============================================================================
|
||||
// Use case struct
|
||||
// ============================================================================
|
||||
|
||||
pub struct Anime2UseCases {
|
||||
repository: AlqanimeRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
}
|
||||
|
||||
impl Anime2UseCases {
|
||||
pub fn new(
|
||||
repository: AlqanimeRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
redis_pool,
|
||||
db,
|
||||
semaphore,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(&self) -> Cache<'_> {
|
||||
Cache::new(&self.redis_pool)
|
||||
}
|
||||
|
||||
pub async fn index(&self) -> Result<Anime2Response, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("anime2:index", INDEX_CACHE_TTL, || async {
|
||||
let ongoing_html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.index_ongoing_url())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let complete_html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.index_complete_url())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let data = tokio::task::spawn_blocking(move || {
|
||||
Ok::<_, String>((
|
||||
parser::parse_ongoing_anime(&ongoing_html).map_err(|e| e.to_string())?,
|
||||
parser::parse_complete_anime(&complete_html).map_err(|e| e.to_string())?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let mut ongoing: Vec<Anime2Item> = data
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|item| Anime2Item {
|
||||
title: item.title,
|
||||
slug: item.slug,
|
||||
poster: item.poster,
|
||||
status: String::new(),
|
||||
r#type: String::new(),
|
||||
score: item.current_episode,
|
||||
anime_url: item.anime_url,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut complete: Vec<Anime2Item> = data
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|item| Anime2Item {
|
||||
title: item.title,
|
||||
slug: item.slug,
|
||||
poster: item.poster,
|
||||
status: String::new(),
|
||||
r#type: String::new(),
|
||||
score: item.episode_count,
|
||||
anime_url: item.anime_url,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut posters: Vec<String> =
|
||||
ongoing.iter().map(|item| item.poster.clone()).collect();
|
||||
posters.extend(complete.iter().map(|item| item.poster.clone()));
|
||||
|
||||
let cached_posters = cache_image_urls_batch_lazy(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
posters,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let ongoing_len = ongoing.len();
|
||||
for (i, item) in ongoing.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_posters.get(i) {
|
||||
item.poster = url.clone();
|
||||
}
|
||||
}
|
||||
for (i, item) in complete.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_posters.get(ongoing_len + i) {
|
||||
item.poster = url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Anime2Response {
|
||||
status: "Ok".to_string(),
|
||||
data: Anime2Data {
|
||||
ongoing_anime: ongoing,
|
||||
complete_anime: complete,
|
||||
},
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn genre_list(&self) -> Result<GenresResponse, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("anime2:genres:list:v3", GENRE_LIST_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.genre_list_url())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let genres = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_genres(&html).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
Ok(GenresResponse {
|
||||
status: "Ok".to_string(),
|
||||
data: genres,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn filter(
|
||||
&self,
|
||||
page: u32,
|
||||
genre: Option<String>,
|
||||
status: Option<String>,
|
||||
anime_type: Option<String>,
|
||||
order: String,
|
||||
) -> Result<FilterResponse, DomainError> {
|
||||
let cache_key = format!(
|
||||
"anime2:filter:{}:{:?}:{:?}:{:?}:{}",
|
||||
page, genre, status, anime_type, order
|
||||
);
|
||||
let genre_clone = genre.clone();
|
||||
let status_clone = status.clone();
|
||||
let anime_type_clone = anime_type.clone();
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, FILTER_CACHE_TTL, || async {
|
||||
let mut url = self.repository.filter_url(page, &order);
|
||||
|
||||
if let Some(g) = &genre {
|
||||
for genre_item in g.split(',') {
|
||||
url.push_str(&format!("&genre[]={}", genre_item.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(s) = &status {
|
||||
url.push_str(&format!("&status={}", s));
|
||||
}
|
||||
if let Some(t) = &anime_type {
|
||||
url.push_str(&format!("&type={}", t));
|
||||
}
|
||||
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut data, pagination) = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_filter_page(&html, page).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut data,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(FilterResponse {
|
||||
success: true,
|
||||
data,
|
||||
pagination,
|
||||
filters_applied: FiltersApplied {
|
||||
genre: genre_clone,
|
||||
status: status_clone,
|
||||
r#type: anime_type_clone,
|
||||
order: order.clone(),
|
||||
},
|
||||
status: "Ok".to_string(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn detail(&self, slug: String) -> Result<DetailResponse, DomainError> {
|
||||
let cache_key = format!("anime2:detail:{}", slug);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DETAIL_CACHE_TTL, || async {
|
||||
let detail_url = self.repository.detail_url(&slug);
|
||||
let image_url = self.repository.detail_image_url(&slug);
|
||||
let (detail_html, image_html) = tokio::join!(
|
||||
self.repository.fetch_html(&detail_url),
|
||||
self.repository.fetch_html(&image_url)
|
||||
);
|
||||
let detail_html = detail_html.map_err(|e| e.to_string())?;
|
||||
let image_html = image_html.ok();
|
||||
|
||||
let mut data = tokio::task::spawn_blocking(move || {
|
||||
let mut data =
|
||||
parser::parse_anime_detail(&detail_html).map_err(|e| e.to_string())?;
|
||||
if let Some(image_html) = image_html {
|
||||
if let Ok(image_data) = parser::parse_anime_detail(&image_html) {
|
||||
if !image_data.poster.is_empty() {
|
||||
data.poster = image_data.poster;
|
||||
}
|
||||
if !image_data.poster2.is_empty() {
|
||||
data.poster2 = image_data.poster2;
|
||||
}
|
||||
data.recommendations = image_data.recommendations;
|
||||
}
|
||||
}
|
||||
Ok::<_, String>(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
data.poster = get_cached_or_original(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
&data.poster,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
data.poster2 = get_cached_or_original(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
&data.poster2,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Recommendation in modules::anime2::types implements HasPoster
|
||||
apply_cached_posters(
|
||||
&mut data.recommendations,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(DetailResponse {
|
||||
status: "Ok".to_string(),
|
||||
data,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn genre_slug(
|
||||
&self,
|
||||
genre_slug: String,
|
||||
page: u32,
|
||||
) -> Result<ApiResponse<Vec<GenreAnimeItem>>, DomainError> {
|
||||
let cache_key = format!("anime2:genre:{}:{}", genre_slug, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, GENRE_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.genre_page_url(&genre_slug, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut data, _pagination) = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_genre_page(&html, page).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut data,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ApiResponse::success(data))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: String,
|
||||
page: u32,
|
||||
) -> Result<ApiResponse<Vec<SearchAnimeItem>>, DomainError> {
|
||||
let cache_key = format!("anime2:search:{}:{}", query, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, SEARCH_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.search_url(&query, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut data, _pagination) = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_search_page(&html, page).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut data,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ApiResponse::success(data))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn latest(
|
||||
&self,
|
||||
page: u32,
|
||||
) -> Result<ApiResponse<Vec<LatestAnimeItem>>, DomainError> {
|
||||
let cache_key = format!("anime2:latest:{}", page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, LATEST_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.latest_url(page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut data, _pagination) = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_latest_page(&html, page).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut data,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ApiResponse::success(data))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn ongoing_anime(
|
||||
&self,
|
||||
page: u32,
|
||||
) -> Result<ApiResponse<Vec<OngoingAnimeItemWithScore>>, DomainError> {
|
||||
let cache_key = format!("anime2:ongoing:{}", page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, ONGOING_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.ongoing_url(page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut data, _pagination) = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_ongoing_page(&html, page).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut data,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ApiResponse::success(data))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn complete_anime(
|
||||
&self,
|
||||
page: u32,
|
||||
) -> Result<ApiResponse<Vec<CompleteAnimeItem>>, DomainError> {
|
||||
let cache_key = format!("anime2:complete:{}", page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, COMPLETE_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.complete_url(page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (mut data, _pagination) = tokio::task::spawn_blocking(move || {
|
||||
parser::parse_complete_page(&html, page).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut data,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ApiResponse::success(data))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Komik application use cases.
|
||||
//!
|
||||
//! Orchestrates repository fetching, caching, and image poster processing.
|
||||
//!
|
||||
//! TODO: Move parsers from `crate::modules::komik::parser` to
|
||||
//! `crate::infrastructure::repository::parsers::komik_parser`.
|
||||
//! TODO: Move response DTOs to `crate::presentation::dto::komik`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deadpool_redis::Pool;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::domain::entity::anime::Pagination;
|
||||
use crate::domain::entity::komik::{ChapterData, DetailData, KomikGenre, KomikItem};
|
||||
use crate::domain::error::*;
|
||||
use crate::domain::repository::ScrapingRepository;
|
||||
use crate::infrastructure::cache::redis::Cache;
|
||||
use crate::infrastructure::repository::KomikRepository;
|
||||
use crate::infrastructure::services::images::cache::{
|
||||
apply_cached_posters, cache_image_urls_batch_lazy, get_cached_or_original,
|
||||
};
|
||||
|
||||
use crate::infrastructure::repository::parsers::komik_parser as parser;
|
||||
|
||||
const GENRE_LIST_CACHE_TTL: u64 = 3600;
|
||||
const GENRE_CACHE_TTL: u64 = 300;
|
||||
const DETAIL_CACHE_TTL: u64 = 300;
|
||||
const CHAPTER_CACHE_TTL: u64 = 300;
|
||||
const SEARCH_CACHE_TTL: u64 = 300;
|
||||
|
||||
// ============================================================================
|
||||
// Use case struct
|
||||
// ============================================================================
|
||||
|
||||
pub struct KomikUseCases {
|
||||
repository: KomikRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
}
|
||||
|
||||
impl KomikUseCases {
|
||||
pub fn new(
|
||||
repository: KomikRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
redis_pool,
|
||||
db,
|
||||
semaphore,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(&self) -> Cache<'_> {
|
||||
Cache::new(&self.redis_pool)
|
||||
}
|
||||
|
||||
pub async fn genre_list(&self) -> Result<Vec<KomikGenre>, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("komik:genres:list:v3", GENRE_LIST_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&self.repository.api_url())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let genres = tokio::task::spawn_blocking(move || parser::parse_genres(&html))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(genres)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn genre_slug(
|
||||
&self,
|
||||
genre_slug: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let page = 1u32;
|
||||
let cache_key = format!("komik:genre:{}:{}:v2", genre_slug, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, GENRE_CACHE_TTL, || async {
|
||||
let url = self.repository.genre_url(&genre_slug, page);
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (mut komik_list, pagination) =
|
||||
tokio::task::spawn_blocking(move || parser::parse_genre_page(&html, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut komik_list,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((komik_list, pagination))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn genre_slug_page(
|
||||
&self,
|
||||
genre_slug: String,
|
||||
page: u32,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("komik:genre:{}:{}:v2", genre_slug, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, GENRE_CACHE_TTL, || async {
|
||||
let url = self.repository.genre_url(&genre_slug, page);
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (mut komik_list, pagination) =
|
||||
tokio::task::spawn_blocking(move || parser::parse_genre_page(&html, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut komik_list,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((komik_list, pagination))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn detail_slug(&self, komik_id: String) -> Result<DetailData, DomainError> {
|
||||
let cache_key = format!("komik:detail:{}", komik_id);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DETAIL_CACHE_TTL, || async {
|
||||
let url = self.repository.detail_url(&komik_id);
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut data =
|
||||
tokio::task::spawn_blocking(move || parser::parse_komik_detail_document(&html))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !data.poster.is_empty() {
|
||||
data.poster = get_cached_or_original(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
&data.poster,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn chapter_slug(&self, chapter_url: String) -> Result<ChapterData, DomainError> {
|
||||
let cache_key = format!("komik:chapter:{}", chapter_url);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, CHAPTER_CACHE_TTL, || async {
|
||||
let url = self.repository.chapter_url(&chapter_url);
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut data = tokio::task::spawn_blocking({
|
||||
let chapter_url = chapter_url.clone();
|
||||
move || parser::parse_komik_chapter_document(&html, &chapter_url)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
data.images = cache_image_urls_batch_lazy(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
data.images,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn manga_slug(
|
||||
&self,
|
||||
page_slug: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let page = page_slug
|
||||
.parse::<u32>()
|
||||
.map_err(|_| DomainError::Validation("Invalid page number".to_string()))?;
|
||||
self.list_by_url("manga", page, self.repository.manga_list_url(page))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn manhua_slug(
|
||||
&self,
|
||||
page_slug: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let page = page_slug
|
||||
.parse::<u32>()
|
||||
.map_err(|_| DomainError::Validation("Invalid page number".to_string()))?;
|
||||
self.list_by_url("manhua", page, self.repository.manhua_list_url(page))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn manhwa_slug(
|
||||
&self,
|
||||
page_slug: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let page = page_slug
|
||||
.parse::<u32>()
|
||||
.map_err(|_| DomainError::Validation("Invalid page number".to_string()))?;
|
||||
self.list_by_url("manhwa", page, self.repository.manhwa_list_url(page))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn popular_slug(
|
||||
&self,
|
||||
page_slug: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let page = page_slug
|
||||
.parse::<u32>()
|
||||
.map_err(|_| DomainError::Validation("Invalid page number".to_string()))?;
|
||||
self.list_by_url("popular", page, self.repository.popular_list_url(page))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_by_url(
|
||||
&self,
|
||||
list_name: &str,
|
||||
page: u32,
|
||||
url: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("komik:list:{}:{}:v2", list_name, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, GENRE_CACHE_TTL, || async {
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (mut komik_list, pagination) =
|
||||
tokio::task::spawn_blocking(move || parser::parse_genre_page(&html, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if komik_list.is_empty() {
|
||||
return Err(format!("Empty komik {} page {}", list_name, page));
|
||||
}
|
||||
|
||||
apply_cached_posters(
|
||||
&mut komik_list,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((komik_list, pagination))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn search_slug(
|
||||
&self,
|
||||
query: String,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let page = 1u32;
|
||||
let cache_key = format!("komik:search:{}:{}", query, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, SEARCH_CACHE_TTL, || async {
|
||||
let url = self.repository.search_url(&query, page);
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (mut komik_list, pagination) =
|
||||
tokio::task::spawn_blocking(move || parser::parse_genre_page(&html, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut komik_list,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((komik_list, pagination))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn search_slug_page(
|
||||
&self,
|
||||
query: String,
|
||||
page: u32,
|
||||
) -> Result<(Vec<KomikItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("komik:search:{}:{}", query, page);
|
||||
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, SEARCH_CACHE_TTL, || async {
|
||||
let url = self.repository.search_url(&query, page);
|
||||
let html = self
|
||||
.repository
|
||||
.fetch_html(&url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (mut komik_list, pagination) =
|
||||
tokio::task::spawn_blocking(move || parser::parse_genre_page(&html, page))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
apply_cached_posters(
|
||||
&mut komik_list,
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((komik_list, pagination))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod anime;
|
||||
pub mod anime2;
|
||||
pub mod komik;
|
||||
pub mod proxy;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Proxy application use cases.
|
||||
//!
|
||||
//! Provides proxy fetch, image caching, and audit/repair operations.
|
||||
//!
|
||||
//! TODO: Move result types to `crate::presentation::dto::proxy`.
|
||||
//! TODO: Add event bus integration for ImageRepaired events.
|
||||
//! TODO: Replace `reqwest::Client::new()` with shared HTTP client from infrastructure.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use serde::Serialize;
|
||||
use tracing::{error, info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::domain::error::*;
|
||||
use crate::domain::repository::ImageCacheRepository;
|
||||
use crate::infrastructure::repository::{ProxyRepository, SeaOrmImageCacheRepository};
|
||||
use crate::infrastructure::services::images::cache::ImageCache;
|
||||
|
||||
// ============================================================================
|
||||
// Result types — TEMPORARY: move to presentation layer
|
||||
// ============================================================================
|
||||
|
||||
/// Result of caching an image URL.
|
||||
/// TODO: Move to presentation::dto::proxy
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ImageCacheResult {
|
||||
pub success: bool,
|
||||
pub original_url: String,
|
||||
pub cdn_url: String,
|
||||
pub from_cache: bool,
|
||||
pub pending: Option<bool>,
|
||||
}
|
||||
|
||||
/// Result of auditing/repairing a cached image URL.
|
||||
/// TODO: Move to presentation::dto::proxy
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AuditImageCacheResult {
|
||||
pub success: bool,
|
||||
pub original_url: String,
|
||||
pub cdn_url: Option<String>,
|
||||
pub was_accessible: bool,
|
||||
pub re_uploaded: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Use case struct
|
||||
// ============================================================================
|
||||
|
||||
pub struct ProxyUseCases {
|
||||
repository: ProxyRepository,
|
||||
image_cache_repo: Arc<dyn ImageCacheRepository>,
|
||||
}
|
||||
|
||||
impl ProxyUseCases {
|
||||
pub fn new(
|
||||
repository: ProxyRepository,
|
||||
image_cache_repo: Arc<SeaOrmImageCacheRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
image_cache_repo,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_cache(&self) -> ImageCache {
|
||||
ImageCache::new(self.image_cache_repo.clone())
|
||||
}
|
||||
|
||||
pub async fn fetch_with_proxy_only(&self, url: String) -> Result<Response, DomainError> {
|
||||
let fetch_result = self
|
||||
.repository
|
||||
.fetch_with_proxy_url(&url)
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e.to_string())))?;
|
||||
|
||||
let mut builder = Response::builder().status(StatusCode::OK);
|
||||
if let Some(content_type) = fetch_result.content_type {
|
||||
builder = builder.header("Content-Type", content_type);
|
||||
}
|
||||
|
||||
builder
|
||||
.body(fetch_result.data.into())
|
||||
.map_err(|e| DomainError::Repository(RepositoryError::Network(e.to_string())))
|
||||
}
|
||||
|
||||
pub async fn image_cache(
|
||||
&self,
|
||||
url: String,
|
||||
lazy: bool,
|
||||
) -> Result<ImageCacheResult, DomainError> {
|
||||
let cache = self.build_image_cache();
|
||||
|
||||
if let Some(cdn_url) = cache.get_cdn_url(&url).await {
|
||||
return Ok(ImageCacheResult {
|
||||
success: true,
|
||||
original_url: url,
|
||||
cdn_url,
|
||||
from_cache: true,
|
||||
pending: None,
|
||||
});
|
||||
}
|
||||
|
||||
if lazy {
|
||||
let repo = self.image_cache_repo.clone();
|
||||
let url_clone = url.clone();
|
||||
tokio::spawn(async move {
|
||||
let cache = ImageCache::new(repo);
|
||||
match cache.get_or_cache(&url_clone).await {
|
||||
Ok(cdn) => info!("[LazyCache] Cached {} -> {}", url_clone, cdn),
|
||||
Err(e) => warn!("[LazyCache] Failed {}: {}", url_clone, e),
|
||||
}
|
||||
});
|
||||
return Ok(ImageCacheResult {
|
||||
success: true,
|
||||
original_url: url.clone(),
|
||||
cdn_url: url,
|
||||
from_cache: false,
|
||||
pending: Some(true),
|
||||
});
|
||||
}
|
||||
|
||||
match cache.get_or_cache(&url).await {
|
||||
Ok(cdn_url) => Ok(ImageCacheResult {
|
||||
success: true,
|
||||
original_url: url,
|
||||
cdn_url,
|
||||
from_cache: false,
|
||||
pending: None,
|
||||
}),
|
||||
Err(e) => {
|
||||
error!("ImageCache error: {}", e);
|
||||
Ok(ImageCacheResult {
|
||||
success: false,
|
||||
original_url: url.clone(),
|
||||
cdn_url: url,
|
||||
from_cache: false,
|
||||
pending: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn audit_image_cache(
|
||||
&self,
|
||||
url: String,
|
||||
) -> Result<AuditImageCacheResult, DomainError> {
|
||||
let cache = self.build_image_cache();
|
||||
let mut cdn_opt = cache.get_cdn_url(&url).await;
|
||||
let mut original = url.clone();
|
||||
|
||||
if cdn_opt.is_none() {
|
||||
if let Some(orig) = cache.find_original_from_cdn(&url).await {
|
||||
info!("SmartAudit: {} recognized as CDN, original {}", url, orig);
|
||||
original = orig;
|
||||
cdn_opt = Some(url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cdn_url) = cdn_opt {
|
||||
let client = reqwest::Client::new();
|
||||
let mut accessible = false;
|
||||
|
||||
match client.get(&cdn_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
if infer::get(&bytes)
|
||||
.map(|k| k.mime_type().starts_with("image/"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
accessible = true;
|
||||
} else {
|
||||
warn!("CDN {} returned non-image content", cdn_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) => warn!("CDN {} status {}", cdn_url, resp.status()),
|
||||
Err(e) => warn!("CDN {} fetch error {}", cdn_url, e),
|
||||
}
|
||||
|
||||
if accessible {
|
||||
return Ok(AuditImageCacheResult {
|
||||
success: true,
|
||||
original_url: original,
|
||||
cdn_url: Some(cdn_url),
|
||||
was_accessible: true,
|
||||
re_uploaded: false,
|
||||
message: "CDN URL is accessible and the image is valid".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
info!("CDN {} inaccessible, purging and reuploading", cdn_url);
|
||||
let _ = cache.invalidate(&original).await;
|
||||
match cache.get_or_cache(&original).await {
|
||||
Ok(new_cdn) => Ok(AuditImageCacheResult {
|
||||
success: true,
|
||||
original_url: original,
|
||||
cdn_url: Some(new_cdn),
|
||||
was_accessible: false,
|
||||
re_uploaded: true,
|
||||
message: "CDN URL was inaccessible, re-uploaded".to_string(),
|
||||
}),
|
||||
Err(e) => Ok(AuditImageCacheResult {
|
||||
success: false,
|
||||
original_url: original,
|
||||
cdn_url: None,
|
||||
was_accessible: false,
|
||||
re_uploaded: false,
|
||||
message: format!("Re-upload failed: {}", e),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
match cache.get_or_cache(&original).await {
|
||||
Ok(new_cdn) => Ok(AuditImageCacheResult {
|
||||
success: true,
|
||||
original_url: original,
|
||||
cdn_url: Some(new_cdn),
|
||||
was_accessible: false,
|
||||
re_uploaded: true,
|
||||
message: "Cached newly".to_string(),
|
||||
}),
|
||||
Err(e) => Ok(AuditImageCacheResult {
|
||||
success: false,
|
||||
original_url: original,
|
||||
cdn_url: None,
|
||||
was_accessible: false,
|
||||
re_uploaded: false,
|
||||
message: format!("Cache failed: {}", e),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user