feat: Implement SQLAlchemy database layer with comprehensive models

Implemented a complete database layer for persistent storage of anime series,
episodes, download queue, and user sessions using SQLAlchemy ORM.

Features:
- 4 SQLAlchemy models: AnimeSeries, Episode, DownloadQueueItem, UserSession
- Automatic timestamp tracking via TimestampMixin
- Foreign key relationships with cascade deletes
- Async and sync database session support
- FastAPI dependency injection integration
- SQLite optimizations (WAL mode, foreign keys)
- Enum types for status and priority fields

Models:
- AnimeSeries: Series metadata with one-to-many relationships
- Episode: Individual episodes linked to series
- DownloadQueueItem: Queue persistence with progress tracking
- UserSession: JWT session storage with expiry and revocation

Database Management:
- Async engine creation with aiosqlite
- Session factory with proper lifecycle
- Connection pooling configuration
- Automatic table creation on initialization

Testing:
- 19 comprehensive unit tests (all passing)
- In-memory SQLite for test isolation
- Relationship and constraint validation
- Query operation testing

Documentation:
- Comprehensive database section in infrastructure.md
- Database package README with examples
- Implementation summary document
- Usage guides and troubleshooting

Dependencies:
- Added: sqlalchemy>=2.0.35 (Python 3.13 compatible)
- Added: alembic==1.13.0 (for future migrations)
- Added: aiosqlite>=0.19.0 (async SQLite driver)

Files:
- src/server/database/__init__.py (package exports)
- src/server/database/base.py (base classes and mixins)
- src/server/database/models.py (ORM models, ~435 lines)
- src/server/database/connection.py (connection management)
- src/server/database/migrations.py (migration placeholder)
- src/server/database/README.md (package documentation)
- tests/unit/test_database_models.py (19 test cases)
- DATABASE_IMPLEMENTATION_SUMMARY.md (implementation summary)

Closes #9 Database Layer implementation task
This commit is contained in:
2025-10-17 20:46:21 +02:00
parent 0d6cade56c
commit ff0d865b7c
12 changed files with 2390 additions and 49 deletions

View File

@@ -0,0 +1,429 @@
"""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
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
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"
)
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"
)
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"
)
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"
)
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."""
return datetime.utcnow() > self.expires_at
def revoke(self) -> None:
"""Revoke this session."""
self.is_active = False