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,404 @@
|
||||
//! Domain entities for anime data.
|
||||
//!
|
||||
//! Pure domain structs with no framework dependencies beyond serde + utoipa.
|
||||
//! These represent the scraped anime data model regardless of source site.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION
|
||||
// ============================================================================
|
||||
|
||||
/// Common pagination structure shared across all endpoints
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Pagination {
|
||||
pub current_page: u32,
|
||||
pub last_visible_page: u32,
|
||||
pub has_next_page: bool,
|
||||
pub next_page: Option<u32>,
|
||||
pub has_previous_page: bool,
|
||||
pub previous_page: Option<u32>,
|
||||
}
|
||||
|
||||
impl Pagination {
|
||||
pub fn with_string_pages(
|
||||
current_page: u32,
|
||||
last_visible_page: u32,
|
||||
has_next_page: bool,
|
||||
next_page: Option<String>,
|
||||
has_previous_page: bool,
|
||||
previous_page: Option<String>,
|
||||
) -> PaginationWithStringPages {
|
||||
PaginationWithStringPages {
|
||||
current_page,
|
||||
last_visible_page,
|
||||
has_next_page,
|
||||
next_page,
|
||||
has_previous_page,
|
||||
previous_page,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pagination variant with string-based page numbers (used in search endpoints)
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct PaginationWithStringPages {
|
||||
pub current_page: u32,
|
||||
pub last_visible_page: u32,
|
||||
pub has_next_page: bool,
|
||||
pub next_page: Option<String>,
|
||||
pub has_previous_page: bool,
|
||||
pub previous_page: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OTakudesu (Anime Module) — Index Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct OngoingAnimeItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub current_episode: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct CompleteAnimeItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub episode_count: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct AnimeData {
|
||||
pub ongoing_anime: Vec<OngoingAnimeItem>,
|
||||
pub complete_anime: Vec<CompleteAnimeItem>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Genre Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Genre {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Otakudesu Detail Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct DetailGenre {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct EpisodeList {
|
||||
pub episode: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Recommendation {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct AnimeDetailData {
|
||||
pub title: String,
|
||||
pub alternative_title: String,
|
||||
pub poster: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub r#type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
pub release_date: String,
|
||||
pub studio: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub genres: Vec<DetailGenre>,
|
||||
pub synopsis: String,
|
||||
pub episode_lists: Vec<EpisodeList>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub batch: Vec<EpisodeList>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub producers: Vec<String>,
|
||||
pub recommendations: Vec<Recommendation>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Otakudesu List Page Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct CompleteAnimeListItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub episode_count: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct OngoingAnimeListItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub score: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct LatestAnimeItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub episode: String,
|
||||
pub score: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct SearchAnimeItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub episode: String,
|
||||
pub anime_url: String,
|
||||
pub genres: Vec<String>,
|
||||
pub status: String,
|
||||
pub rating: String,
|
||||
pub description: String,
|
||||
pub r#type: String,
|
||||
pub season: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct GenreAnimeItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub episode: String,
|
||||
pub score: String,
|
||||
pub status: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Otakudesu Full Episode Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct AnimeInfo {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct EpisodeInfo {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct DownloadLink {
|
||||
pub server: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct AnimeFullData {
|
||||
pub episode: String,
|
||||
pub episode_number: String,
|
||||
pub anime: AnimeInfo,
|
||||
pub has_next_episode: bool,
|
||||
pub next_episode: Option<EpisodeInfo>,
|
||||
pub has_previous_episode: bool,
|
||||
pub previous_episode: Option<EpisodeInfo>,
|
||||
pub stream_url: String,
|
||||
pub download_urls: std::collections::HashMap<String, Vec<DownloadLink>>,
|
||||
pub image_url: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ALQanime (Anime2 Module) — Index Types
|
||||
// ============================================================================
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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 Anime2ItemDetail {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub poster2: String,
|
||||
pub synopsis: String,
|
||||
pub alternative_title: String,
|
||||
pub r#type: String,
|
||||
pub status: String,
|
||||
pub score: String,
|
||||
pub genres: Vec<DetailGenre>,
|
||||
pub episodes: Vec<EpisodeList>,
|
||||
pub recommendations: Vec<Recommendation>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct OngoingAnimeItemWithScore {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub score: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct FilterAnimeItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub score: String,
|
||||
pub status: String,
|
||||
pub r#type: String,
|
||||
pub anime_url: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRAITS
|
||||
// ============================================================================
|
||||
|
||||
/// Trait for types that have a poster image URL
|
||||
pub trait HasPoster {
|
||||
fn poster(&self) -> &str;
|
||||
fn set_poster(&mut self, url: String);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRAIT IMPLEMENTATIONS
|
||||
// ============================================================================
|
||||
|
||||
impl HasPoster for OngoingAnimeItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for CompleteAnimeItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for CompleteAnimeListItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for OngoingAnimeListItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for LatestAnimeItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for SearchAnimeItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for GenreAnimeItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for Recommendation {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for Anime2Item {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for OngoingAnimeItemWithScore {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
|
||||
impl HasPoster for FilterAnimeItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Domain entities for komik (comic) data.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::domain::entity::anime::HasPoster;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct KomikGenre {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct Chapter {
|
||||
pub chapter: String,
|
||||
pub date: String,
|
||||
pub chapter_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct ChapterData {
|
||||
pub title: String,
|
||||
pub next_chapter_id: String,
|
||||
pub prev_chapter_id: String,
|
||||
pub list_chapter: String,
|
||||
pub images: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct DetailData {
|
||||
pub title: String,
|
||||
pub poster: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub r#type: String,
|
||||
pub release_date: String,
|
||||
pub author: String,
|
||||
pub total_chapter: String,
|
||||
pub updated_on: String,
|
||||
pub genres: Vec<String>,
|
||||
pub chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct KomikItem {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub poster: String,
|
||||
pub chapter: String,
|
||||
pub score: String,
|
||||
pub r#type: String,
|
||||
pub komik_url: String,
|
||||
}
|
||||
|
||||
impl HasPoster for KomikItem {
|
||||
fn poster(&self) -> &str {
|
||||
&self.poster
|
||||
}
|
||||
fn set_poster(&mut self, url: String) {
|
||||
self.poster = url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod anime;
|
||||
pub mod komik;
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Domain-level error types.
|
||||
//!
|
||||
//! These are framework-agnostic errors that can be mapped to HTTP errors
|
||||
//! at the presentation layer. Domain and application layers only use these.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors originating from repository operations (DB, HTTP, etc.)
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RepositoryError {
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// Errors originating from scraping/parsing operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ScrapingError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("Empty response")]
|
||||
EmptyResponse,
|
||||
}
|
||||
|
||||
/// Generic domain error
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DomainError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("Repository error: {0}")]
|
||||
Repository(#[from] RepositoryError),
|
||||
#[error("Scraping error: {0}")]
|
||||
Scraping(#[from] ScrapingError),
|
||||
}
|
||||
|
||||
impl From<String> for RepositoryError {
|
||||
fn from(s: String) -> Self {
|
||||
RepositoryError::Database(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for RepositoryError {
|
||||
fn from(s: &str) -> Self {
|
||||
RepositoryError::Database(s.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod repository;
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Repository trait for image cache (original → CDN mapping).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Repository for managing cached image URL mappings.
|
||||
#[async_trait]
|
||||
pub trait ImageCacheRepository: Send + Sync {
|
||||
async fn get_from_redis(&self, key: &str) -> Option<String>;
|
||||
async fn set_in_redis(&self, key: &str, value: &str, ttl: u64) -> Result<(), String>;
|
||||
async fn get_from_db(&self, original_url: &str) -> Result<Option<String>, String>;
|
||||
async fn save_to_db(&self, original_url: &str, cdn_url: &str) -> Result<(), String>;
|
||||
async fn find_original_from_cdn(&self, cdn_url: &str) -> Result<Option<String>, String>;
|
||||
async fn delete_from_db(&self, original_url: &str) -> Result<(), String>;
|
||||
async fn delete_from_redis(&self, key: &str) -> Result<(), String>;
|
||||
async fn get_lock(&self, key: &str) -> bool;
|
||||
async fn set_lock(&self, key: &str, ttl: u64) -> Result<(), String>;
|
||||
async fn release_lock(&self, key: &str) -> Result<(), String>;
|
||||
async fn invalidate_api_caches(&self, patterns: Vec<&str>) -> Result<(), String>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod image_cache;
|
||||
pub mod scraping;
|
||||
|
||||
pub use image_cache::ImageCacheRepository;
|
||||
pub use scraping::ScrapingRepository;
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Repository trait for scraping HTML from remote sources.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::domain::error::ScrapingError;
|
||||
|
||||
/// Trait for repositories that fetch and scrape HTML content.
|
||||
#[async_trait]
|
||||
pub trait ScrapingRepository: Send + Sync {
|
||||
/// Fetch raw HTML from a URL.
|
||||
async fn fetch_html(&self, url: &str) -> Result<String, ScrapingError>;
|
||||
}
|
||||
Reference in New Issue
Block a user