486 lines
16 KiB
Python
486 lines
16 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 Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
|
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)
|
|
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"
|
|
)
|
|
|
|
# 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()
|
|
|
|
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
|
|
is_downloaded: Whether episode is 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"
|
|
)
|
|
is_downloaded: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, nullable=False,
|
|
doc="Whether episode is 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
|
|
|
|
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 error information.
|
|
Provides persistence for the DownloadService queue state.
|
|
|
|
Attributes:
|
|
id: Primary key
|
|
series_id: Foreign key to AnimeSeries
|
|
episode_id: Foreign key to Episode
|
|
error_message: Error description if failed
|
|
download_url: Provider download URL
|
|
file_destination: Target file path
|
|
started_at: When download started
|
|
completed_at: When download completed
|
|
series: Relationship to AnimeSeries
|
|
episode: Relationship to Episode
|
|
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
|
|
)
|
|
|
|
# Foreign key to episode
|
|
episode_id: Mapped[int] = mapped_column(
|
|
ForeignKey("episodes.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True
|
|
)
|
|
|
|
# Error handling
|
|
error_message: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True,
|
|
doc="Error description"
|
|
)
|
|
|
|
# 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"
|
|
)
|
|
episode: Mapped["Episode"] = relationship(
|
|
"Episode"
|
|
)
|
|
|
|
@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"episode_id={self.episode_id})>"
|
|
)
|
|
|
|
|
|
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
|