"""SQLAlchemy ORM models for the Aniworld web application. This module defines database models for anime series, episodes, download queue, and user sessions. Models use SQLAlchemy 2.0 style with type annotations. Models: - AnimeSeries: Represents an anime series with metadata - Episode: Individual episodes linked to series - DownloadQueueItem: Download queue with status and progress tracking - UserSession: User authentication sessions with JWT tokens """ from __future__ import annotations from datetime import datetime, timezone from enum import Enum from typing import List, Optional from sqlalchemy import ( JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func, ) from sqlalchemy import Enum as SQLEnum from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from src.server.database.base import Base, TimestampMixin class AnimeSeries(Base, TimestampMixin): """SQLAlchemy model for anime series. Represents an anime series with metadata, provider information, and links to episodes. Corresponds to the core Serie class. Series Identifier Convention: - `key`: PRIMARY IDENTIFIER - Unique, provider-assigned, URL-safe (e.g., "attack-on-titan"). Used for all lookups and operations. - `folder`: METADATA ONLY - Filesystem folder name for display (e.g., "Attack on Titan (2013)"). Never used for identification. - `id`: Internal database primary key for relationships. Attributes: id: Database primary key (internal use for relationships) key: Unique provider key - PRIMARY IDENTIFIER for all lookups name: Display name of the series site: Provider site URL folder: Filesystem folder name (metadata only, not for lookups) description: Optional series description status: Current status (ongoing, completed, etc.) total_episodes: Total number of episodes cover_url: URL to series cover image episodes: Relationship to Episode models (via id foreign key) download_items: Relationship to DownloadQueueItem models (via id foreign key) created_at: Creation timestamp (from TimestampMixin) updated_at: Last update timestamp (from TimestampMixin) Note: All database relationships use `id` (primary key), not `key` or `folder`. Use `get_by_key()` in AnimeSeriesService for lookups. """ __tablename__ = "anime_series" # Primary key id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True ) # Core identification key: Mapped[str] = mapped_column( String(255), unique=True, nullable=False, index=True, doc="Unique provider key - PRIMARY IDENTIFIER for all lookups" ) name: Mapped[str] = mapped_column( String(500), nullable=False, index=True, doc="Series name" ) site: Mapped[str] = mapped_column( String(500), nullable=False, doc="Provider site URL" ) folder: Mapped[str] = mapped_column( String(1000), nullable=False, doc="Filesystem folder name - METADATA ONLY, not for lookups" ) # Metadata description: Mapped[Optional[str]] = mapped_column( Text, nullable=True, doc="Series description" ) status: Mapped[Optional[str]] = mapped_column( String(50), nullable=True, doc="Series status (ongoing, completed, etc.)" ) total_episodes: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, doc="Total number of episodes" ) cover_url: Mapped[Optional[str]] = mapped_column( String(1000), nullable=True, doc="URL to cover image" ) # JSON field for episode dictionary (season -> [episodes]) episode_dict: Mapped[Optional[dict]] = mapped_column( JSON, nullable=True, doc="Episode dictionary {season: [episodes]}" ) # Relationships episodes: Mapped[List["Episode"]] = relationship( "Episode", back_populates="series", cascade="all, delete-orphan" ) download_items: Mapped[List["DownloadQueueItem"]] = relationship( "DownloadQueueItem", back_populates="series", cascade="all, delete-orphan" ) @validates('key') def validate_key(self, key: str, value: str) -> str: """Validate key field length and format.""" if not value or not value.strip(): raise ValueError("Series key cannot be empty") if len(value) > 255: raise ValueError("Series key must be 255 characters or less") return value.strip() @validates('name') def validate_name(self, key: str, value: str) -> str: """Validate name field length.""" if not value or not value.strip(): raise ValueError("Series name cannot be empty") if len(value) > 500: raise ValueError("Series name must be 500 characters or less") return value.strip() @validates('site') def validate_site(self, key: str, value: str) -> str: """Validate site URL length.""" if not value or not value.strip(): raise ValueError("Series site URL cannot be empty") if len(value) > 500: raise ValueError("Site URL must be 500 characters or less") return value.strip() @validates('folder') def validate_folder(self, key: str, value: str) -> str: """Validate folder path length.""" if not value or not value.strip(): raise ValueError("Series folder path cannot be empty") if len(value) > 1000: raise ValueError("Folder path must be 1000 characters or less") return value.strip() @validates('cover_url') def validate_cover_url(self, key: str, value: Optional[str]) -> Optional[str]: """Validate cover URL length.""" if value is not None and len(value) > 1000: raise ValueError("Cover URL must be 1000 characters or less") return value @validates('total_episodes') def validate_total_episodes(self, key: str, value: Optional[int]) -> Optional[int]: """Validate total episodes is positive.""" if value is not None and value < 0: raise ValueError("Total episodes must be non-negative") if value is not None and value > 10000: raise ValueError("Total episodes must be 10000 or less") return value def __repr__(self) -> str: return f"" class Episode(Base, TimestampMixin): """SQLAlchemy model for anime episodes. Represents individual episodes linked to an anime series. Tracks download status and file location. Attributes: id: Primary key series_id: Foreign key to AnimeSeries season: Season number episode_number: Episode number within season title: Episode title file_path: Local file path if downloaded file_size: File size in bytes is_downloaded: Whether episode is downloaded download_date: When episode was downloaded series: Relationship to AnimeSeries created_at: Creation timestamp (from TimestampMixin) updated_at: Last update timestamp (from TimestampMixin) """ __tablename__ = "episodes" # Primary key id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True ) # Foreign key to series series_id: Mapped[int] = mapped_column( ForeignKey("anime_series.id", ondelete="CASCADE"), nullable=False, index=True ) # Episode identification season: Mapped[int] = mapped_column( Integer, nullable=False, doc="Season number" ) episode_number: Mapped[int] = mapped_column( Integer, nullable=False, doc="Episode number within season" ) title: Mapped[Optional[str]] = mapped_column( String(500), nullable=True, doc="Episode title" ) # Download information file_path: Mapped[Optional[str]] = mapped_column( String(1000), nullable=True, doc="Local file path" ) file_size: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, doc="File size in bytes" ) is_downloaded: Mapped[bool] = mapped_column( Boolean, default=False, nullable=False, doc="Whether episode is downloaded" ) download_date: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, doc="When episode was downloaded" ) # Relationship series: Mapped["AnimeSeries"] = relationship( "AnimeSeries", back_populates="episodes" ) @validates('season') def validate_season(self, key: str, value: int) -> int: """Validate season number is positive.""" if value < 0: raise ValueError("Season number must be non-negative") if value > 1000: raise ValueError("Season number must be 1000 or less") return value @validates('episode_number') def validate_episode_number(self, key: str, value: int) -> int: """Validate episode number is positive.""" if value < 0: raise ValueError("Episode number must be non-negative") if value > 10000: raise ValueError("Episode number must be 10000 or less") return value @validates('title') def validate_title(self, key: str, value: Optional[str]) -> Optional[str]: """Validate title length.""" if value is not None and len(value) > 500: raise ValueError("Episode title must be 500 characters or less") return value @validates('file_path') def validate_file_path( self, key: str, value: Optional[str] ) -> Optional[str]: """Validate file path length.""" if value is not None and len(value) > 1000: raise ValueError("File path must be 1000 characters or less") return value @validates('file_size') def validate_file_size(self, key: str, value: Optional[int]) -> Optional[int]: """Validate file size is non-negative.""" if value is not None and value < 0: raise ValueError("File size must be non-negative") return value def __repr__(self) -> str: return ( f"" ) class DownloadStatus(str, Enum): """Status enum for download queue items.""" PENDING = "pending" DOWNLOADING = "downloading" PAUSED = "paused" COMPLETED = "completed" FAILED = "failed" CANCELLED = "cancelled" class DownloadPriority(str, Enum): """Priority enum for download queue items.""" LOW = "low" NORMAL = "normal" HIGH = "high" class DownloadQueueItem(Base, TimestampMixin): """SQLAlchemy model for download queue items. Tracks download queue with status, progress, and error information. Provides persistence for the DownloadService queue state. Attributes: id: Primary key series_id: Foreign key to AnimeSeries season: Season number episode_number: Episode number status: Current download status priority: Download priority progress_percent: Download progress (0-100) downloaded_bytes: Bytes downloaded total_bytes: Total file size download_speed: Current speed in bytes/sec error_message: Error description if failed retry_count: Number of retry attempts download_url: Provider download URL file_destination: Target file path started_at: When download started completed_at: When download completed series: Relationship to AnimeSeries created_at: Creation timestamp (from TimestampMixin) updated_at: Last update timestamp (from TimestampMixin) """ __tablename__ = "download_queue" # Primary key id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True ) # Foreign key to series series_id: Mapped[int] = mapped_column( ForeignKey("anime_series.id", ondelete="CASCADE"), nullable=False, index=True ) # Episode identification season: Mapped[int] = mapped_column( Integer, nullable=False, doc="Season number" ) episode_number: Mapped[int] = mapped_column( Integer, nullable=False, doc="Episode number" ) # Queue management status: Mapped[str] = mapped_column( SQLEnum(DownloadStatus), default=DownloadStatus.PENDING, nullable=False, index=True, doc="Current download status" ) priority: Mapped[str] = mapped_column( SQLEnum(DownloadPriority), default=DownloadPriority.NORMAL, nullable=False, doc="Download priority" ) # Progress tracking progress_percent: Mapped[float] = mapped_column( Float, default=0.0, nullable=False, doc="Progress percentage (0-100)" ) downloaded_bytes: Mapped[int] = mapped_column( Integer, default=0, nullable=False, doc="Bytes downloaded" ) total_bytes: Mapped[Optional[int]] = mapped_column( Integer, nullable=True, doc="Total file size" ) download_speed: Mapped[Optional[float]] = mapped_column( Float, nullable=True, doc="Current download speed (bytes/sec)" ) # Error handling error_message: Mapped[Optional[str]] = mapped_column( Text, nullable=True, doc="Error description" ) retry_count: Mapped[int] = mapped_column( Integer, default=0, nullable=False, doc="Number of retry attempts" ) # Download details download_url: Mapped[Optional[str]] = mapped_column( String(1000), nullable=True, doc="Provider download URL" ) file_destination: Mapped[Optional[str]] = mapped_column( String(1000), nullable=True, doc="Target file path" ) # Timestamps started_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, doc="When download started" ) completed_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, doc="When download completed" ) # Relationship series: Mapped["AnimeSeries"] = relationship( "AnimeSeries", back_populates="download_items" ) @validates('season') def validate_season(self, key: str, value: int) -> int: """Validate season number is positive.""" if value < 0: raise ValueError("Season number must be non-negative") if value > 1000: raise ValueError("Season number must be 1000 or less") return value @validates('episode_number') def validate_episode_number(self, key: str, value: int) -> int: """Validate episode number is positive.""" if value < 0: raise ValueError("Episode number must be non-negative") if value > 10000: raise ValueError("Episode number must be 10000 or less") return value @validates('progress_percent') def validate_progress_percent(self, key: str, value: float) -> float: """Validate progress is between 0 and 100.""" if value < 0.0: raise ValueError("Progress percent must be non-negative") if value > 100.0: raise ValueError("Progress percent cannot exceed 100") return value @validates('downloaded_bytes') def validate_downloaded_bytes(self, key: str, value: int) -> int: """Validate downloaded bytes is non-negative.""" if value < 0: raise ValueError("Downloaded bytes must be non-negative") return value @validates('total_bytes') def validate_total_bytes( self, key: str, value: Optional[int] ) -> Optional[int]: """Validate total bytes is non-negative.""" if value is not None and value < 0: raise ValueError("Total bytes must be non-negative") return value @validates('download_speed') def validate_download_speed( self, key: str, value: Optional[float] ) -> Optional[float]: """Validate download speed is non-negative.""" if value is not None and value < 0.0: raise ValueError("Download speed must be non-negative") return value @validates('retry_count') def validate_retry_count(self, key: str, value: int) -> int: """Validate retry count is non-negative.""" if value < 0: raise ValueError("Retry count must be non-negative") if value > 100: raise ValueError("Retry count cannot exceed 100") return value @validates('download_url') def validate_download_url( self, key: str, value: Optional[str] ) -> Optional[str]: """Validate download URL length.""" if value is not None and len(value) > 1000: raise ValueError("Download URL must be 1000 characters or less") return value @validates('file_destination') def validate_file_destination( self, key: str, value: Optional[str] ) -> Optional[str]: """Validate file destination path length.""" if value is not None and len(value) > 1000: raise ValueError( "File destination path must be 1000 characters or less" ) return value def __repr__(self) -> str: return ( f"" ) class UserSession(Base, TimestampMixin): """SQLAlchemy model for user sessions. Tracks authenticated user sessions with JWT tokens. Supports session management, revocation, and expiry. Attributes: id: Primary key session_id: Unique session identifier token_hash: Hashed JWT token for validation user_id: User identifier (for multi-user support) ip_address: Client IP address user_agent: Client user agent string expires_at: Session expiration timestamp is_active: Whether session is active last_activity: Last activity timestamp created_at: Creation timestamp (from TimestampMixin) updated_at: Last update timestamp (from TimestampMixin) """ __tablename__ = "user_sessions" # Primary key id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True ) # Session identification session_id: Mapped[str] = mapped_column( String(255), unique=True, nullable=False, index=True, doc="Unique session identifier" ) token_hash: Mapped[str] = mapped_column( String(255), nullable=False, doc="Hashed JWT token" ) # User information user_id: Mapped[Optional[str]] = mapped_column( String(255), nullable=True, index=True, doc="User identifier (for multi-user)" ) # Client information ip_address: Mapped[Optional[str]] = mapped_column( String(45), nullable=True, doc="Client IP address" ) user_agent: Mapped[Optional[str]] = mapped_column( String(500), nullable=True, doc="Client user agent" ) # Session management expires_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, doc="Session expiration" ) is_active: Mapped[bool] = mapped_column( Boolean, default=True, nullable=False, index=True, doc="Whether session is active" ) last_activity: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, doc="Last activity timestamp" ) @validates('session_id') def validate_session_id(self, key: str, value: str) -> str: """Validate session ID length and format.""" if not value or not value.strip(): raise ValueError("Session ID cannot be empty") if len(value) > 255: raise ValueError("Session ID must be 255 characters or less") return value.strip() @validates('token_hash') def validate_token_hash(self, key: str, value: str) -> str: """Validate token hash length.""" if not value or not value.strip(): raise ValueError("Token hash cannot be empty") if len(value) > 255: raise ValueError("Token hash must be 255 characters or less") return value.strip() @validates('user_id') def validate_user_id( self, key: str, value: Optional[str] ) -> Optional[str]: """Validate user ID length.""" if value is not None and len(value) > 255: raise ValueError("User ID must be 255 characters or less") return value @validates('ip_address') def validate_ip_address( self, key: str, value: Optional[str] ) -> Optional[str]: """Validate IP address length (IPv4 or IPv6).""" if value is not None and len(value) > 45: raise ValueError("IP address must be 45 characters or less") return value @validates('user_agent') def validate_user_agent( self, key: str, value: Optional[str] ) -> Optional[str]: """Validate user agent length.""" if value is not None and len(value) > 500: raise ValueError("User agent must be 500 characters or less") return value def __repr__(self) -> str: return ( f"" ) @property def is_expired(self) -> bool: """Check if session has expired.""" # Ensure expires_at is timezone-aware for comparison expires_at = self.expires_at if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) return datetime.now(timezone.utc) > expires_at def revoke(self) -> None: """Revoke this session.""" self.is_active = False