653 lines
22 KiB
Python
653 lines
22 KiB
Python
"""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.
|
|
|
|
Attributes:
|
|
id: Primary key
|
|
key: Unique identifier used by provider
|
|
name: Series name
|
|
site: Provider site URL
|
|
folder: Local filesystem path
|
|
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
|
|
download_items: Relationship to DownloadQueueItem models
|
|
created_at: Creation timestamp (from TimestampMixin)
|
|
updated_at: Last update timestamp (from TimestampMixin)
|
|
"""
|
|
__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"
|
|
)
|
|
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="Local filesystem path"
|
|
)
|
|
|
|
# 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"<AnimeSeries(id={self.id}, key='{self.key}', name='{self.name}')>"
|
|
|
|
|
|
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"<Episode(id={self.id}, series_id={self.series_id}, "
|
|
f"S{self.season:02d}E{self.episode_number:02d})>"
|
|
)
|
|
|
|
|
|
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"<DownloadQueueItem(id={self.id}, "
|
|
f"series_id={self.series_id}, "
|
|
f"S{self.season:02d}E{self.episode_number:02d}, "
|
|
f"status={self.status})>"
|
|
)
|
|
|
|
|
|
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"<UserSession(id={self.id}, "
|
|
f"session_id='{self.session_id}', "
|
|
f"is_active={self.is_active})>"
|
|
)
|
|
|
|
@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
|