158 lines
5.3 KiB
Python
158 lines
5.3 KiB
Python
|
|
from datetime import datetime
|
||
|
|
from typing import List, Optional, TYPE_CHECKING
|
||
|
|
from sqlalchemy import String, Integer, ForeignKey, DateTime, BigInteger, Enum as SQLEnum, func, UniqueConstraint
|
||
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
|
import enum
|
||
|
|
|
||
|
|
from ..database import Base
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from ..db_models import Opening
|
||
|
|
|
||
|
|
|
||
|
|
class ThemeType(str, enum.Enum):
|
||
|
|
"""Type of anime theme (opening or ending)."""
|
||
|
|
OP = "OP"
|
||
|
|
ED = "ED"
|
||
|
|
|
||
|
|
|
||
|
|
class DownloadStatus(str, enum.Enum):
|
||
|
|
"""Status of a download task."""
|
||
|
|
QUEUED = "queued"
|
||
|
|
DOWNLOADING = "downloading"
|
||
|
|
CONVERTING = "converting"
|
||
|
|
UPLOADING = "uploading"
|
||
|
|
DONE = "done"
|
||
|
|
FAILED = "failed"
|
||
|
|
|
||
|
|
|
||
|
|
class Anime(Base):
|
||
|
|
"""Anime entity from Shikimori."""
|
||
|
|
__tablename__ = "anime"
|
||
|
|
|
||
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||
|
|
shikimori_id: Mapped[int] = mapped_column(Integer, unique=True, index=True, nullable=False)
|
||
|
|
animethemes_slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
||
|
|
|
||
|
|
title_russian: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||
|
|
title_english: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||
|
|
title_japanese: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||
|
|
|
||
|
|
year: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||
|
|
poster_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||
|
|
|
||
|
|
created_at: Mapped[datetime] = mapped_column(
|
||
|
|
DateTime(timezone=True),
|
||
|
|
server_default=func.now()
|
||
|
|
)
|
||
|
|
|
||
|
|
# Relationships
|
||
|
|
themes: Mapped[List["AnimeTheme"]] = relationship(
|
||
|
|
"AnimeTheme",
|
||
|
|
back_populates="anime",
|
||
|
|
cascade="all, delete-orphan"
|
||
|
|
)
|
||
|
|
|
||
|
|
def __repr__(self):
|
||
|
|
return f"<Anime {self.shikimori_id}: {self.title_russian or self.title_english}>"
|
||
|
|
|
||
|
|
|
||
|
|
class AnimeTheme(Base):
|
||
|
|
"""Anime opening/ending theme."""
|
||
|
|
__tablename__ = "anime_themes"
|
||
|
|
|
||
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||
|
|
anime_id: Mapped[int] = mapped_column(
|
||
|
|
Integer,
|
||
|
|
ForeignKey("anime.id", ondelete="CASCADE"),
|
||
|
|
nullable=False
|
||
|
|
)
|
||
|
|
|
||
|
|
theme_type: Mapped[ThemeType] = mapped_column(
|
||
|
|
SQLEnum(ThemeType, native_enum=False),
|
||
|
|
nullable=False
|
||
|
|
)
|
||
|
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # 1, 2, 3...
|
||
|
|
song_title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||
|
|
artist: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||
|
|
|
||
|
|
# AnimeThemes video URL (WebM source)
|
||
|
|
animethemes_video_url: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||
|
|
|
||
|
|
# Downloaded file info
|
||
|
|
audio_s3_key: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||
|
|
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||
|
|
|
||
|
|
# Link to existing Opening entity (after download)
|
||
|
|
opening_id: Mapped[Optional[int]] = mapped_column(
|
||
|
|
Integer,
|
||
|
|
ForeignKey("openings.id", ondelete="SET NULL"),
|
||
|
|
nullable=True
|
||
|
|
)
|
||
|
|
|
||
|
|
created_at: Mapped[datetime] = mapped_column(
|
||
|
|
DateTime(timezone=True),
|
||
|
|
server_default=func.now()
|
||
|
|
)
|
||
|
|
|
||
|
|
# Unique constraint: one anime can have only one OP1, OP2, ED1, etc.
|
||
|
|
__table_args__ = (
|
||
|
|
UniqueConstraint('anime_id', 'theme_type', 'sequence', name='uq_anime_theme_sequence'),
|
||
|
|
)
|
||
|
|
|
||
|
|
# Relationships
|
||
|
|
anime: Mapped["Anime"] = relationship("Anime", back_populates="themes")
|
||
|
|
opening: Mapped[Optional["Opening"]] = relationship("Opening")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def full_name(self) -> str:
|
||
|
|
"""Return full theme name like 'OP1' or 'ED2'."""
|
||
|
|
return f"{self.theme_type.value}{self.sequence}"
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_downloaded(self) -> bool:
|
||
|
|
"""Check if theme has been downloaded."""
|
||
|
|
return self.audio_s3_key is not None
|
||
|
|
|
||
|
|
def __repr__(self):
|
||
|
|
return f"<AnimeTheme {self.full_name}: {self.song_title}>"
|
||
|
|
|
||
|
|
|
||
|
|
class DownloadTask(Base):
|
||
|
|
"""Download queue task."""
|
||
|
|
__tablename__ = "download_tasks"
|
||
|
|
|
||
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||
|
|
theme_id: Mapped[int] = mapped_column(
|
||
|
|
Integer,
|
||
|
|
ForeignKey("anime_themes.id", ondelete="CASCADE"),
|
||
|
|
nullable=False,
|
||
|
|
unique=True # One task per theme
|
||
|
|
)
|
||
|
|
|
||
|
|
status: Mapped[DownloadStatus] = mapped_column(
|
||
|
|
SQLEnum(DownloadStatus, native_enum=False),
|
||
|
|
nullable=False,
|
||
|
|
default=DownloadStatus.QUEUED
|
||
|
|
)
|
||
|
|
|
||
|
|
# Progress tracking
|
||
|
|
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||
|
|
error_message: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||
|
|
|
||
|
|
# Estimated size (6 MB default if unknown)
|
||
|
|
estimated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=6_291_456) # 6 MB
|
||
|
|
|
||
|
|
created_at: Mapped[datetime] = mapped_column(
|
||
|
|
DateTime(timezone=True),
|
||
|
|
server_default=func.now()
|
||
|
|
)
|
||
|
|
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
|
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
|
|
||
|
|
# Relationships
|
||
|
|
theme: Mapped["AnimeTheme"] = relationship("AnimeTheme")
|
||
|
|
|
||
|
|
def __repr__(self):
|
||
|
|
return f"<DownloadTask {self.id}: {self.status.value}>"
|