feat: models

This commit is contained in:
Senko-san
2026-06-07 14:50:35 +03:00
parent 87b48e941e
commit dfd512a13f
11 changed files with 732 additions and 1 deletions
+21 -1
View File
@@ -5,6 +5,26 @@ autogenerate and ``create_all`` (tests) see the full schema. ``alembic/env.py``
imports it for exactly this side effect.
"""
from app.infrastructure.db.models.album import AlbumModel
from app.infrastructure.db.models.artist import ArtistModel
from app.infrastructure.db.models.download_job import DownloadJobModel
from app.infrastructure.db.models.like import LikeModel
from app.infrastructure.db.models.lyrics import LyricsModel
from app.infrastructure.db.models.play_history import PlayHistoryModel
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
from app.infrastructure.db.models.track import TrackModel
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
__all__ = ["RefreshTokenModel", "UserModel"]
__all__ = [
"AlbumModel",
"ArtistModel",
"DownloadJobModel",
"LikeModel",
"LyricsModel",
"PlayHistoryModel",
"PlaylistModel",
"PlaylistTrackModel",
"RefreshTokenModel",
"TrackModel",
"UserModel",
]
+23
View File
@@ -0,0 +1,23 @@
"""ORM model for albums."""
import uuid
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class AlbumModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "albums"
title: Mapped[str] = mapped_column(String(1024), index=True, nullable=False)
artist_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("artists.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
+14
View File
@@ -0,0 +1,14 @@
"""ORM model for artists."""
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class ArtistModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "artists"
name: Mapped[str] = mapped_column(String(512), index=True, nullable=False)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
@@ -0,0 +1,37 @@
"""ORM model for download jobs (plan §6.1).
Tracks a queued download through its lifecycle. ``retry_count`` supports the
exponential-backoff retries that yt-dlp needs; ``progress`` drives the UI
download manager.
"""
import uuid
from sqlalchemy import Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.enums import DownloadStatus
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class DownloadJobModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "download_jobs"
source: Mapped[str] = mapped_column(String(32), nullable=False)
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
query: Mapped[str | None] = mapped_column(String(1024), nullable=True)
requested_by: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
index=True,
nullable=True,
)
status: Mapped[str] = mapped_column(
String(16),
index=True,
nullable=False,
default=DownloadStatus.QUEUED.value,
)
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+66
View File
@@ -0,0 +1,66 @@
"""Domain enums used by ORM columns.
Plain ``str``-valued enums, stored as strings (not native PG enums) — adding a
variant is a code change, never a migration (plan §4). Columns map these via
``mapped_column(String(...))`` and persist ``Enum.value``.
"""
import enum
class TrackSource(enum.StrEnum):
"""Which backend imported a track. Drives ``is_replaceable`` (plan §6.6)."""
YOUTUBE = "youtube"
LOCAL = "local"
UPLOAD = "upload"
SOUNDCLOUD = "soundcloud"
BANDCAMP = "bandcamp"
class StoragePolicy(enum.StrEnum):
"""What the system did / must do with the stored format (plan §6.6).
``master_keep`` is inviolable — never auto-optimized.
"""
AS_IS = "as_is"
OPTIMIZED = "optimized"
MASTER_KEEP = "master_keep"
class MetadataStatus(enum.StrEnum):
"""Enrichment state. ``manual`` is never overwritten by auto-enrichment."""
PENDING = "pending"
ENRICHED = "enriched"
FAILED = "failed"
MANUAL = "manual"
class LikeValue(enum.StrEnum):
"""A like event's value. Likes are an append-only log, not a boolean —
current state is the latest event per ``(user, track)`` (plan §4.1)."""
LIKE = "like"
DISLIKE = "dislike"
NEUTRAL = "neutral"
class DownloadStatus(enum.StrEnum):
"""Lifecycle of a download job (plan §6.1)."""
QUEUED = "queued"
DOWNLOADING = "downloading"
ENRICHING = "enriching"
DONE = "done"
FAILED = "failed"
class LyricsStatus(enum.StrEnum):
"""Lyrics fetch outcome. ``not_found`` is cached too (with TTL) so we don't
hammer the provider for tracks that have no lyrics (plan §6.7)."""
FOUND = "found"
NOT_FOUND = "not_found"
PENDING = "pending"
+39
View File
@@ -0,0 +1,39 @@
"""ORM model for likes — an append-only event log.
A like/dislike is **never** updated in place. Current state for a ``(user,
track)`` pair is the latest event by ``created_at``. This shape is required for
future sync and as a clean ML signal (plan §4.1). Hence: no ``updated_at``,
no unique constraint on ``(user, track)``.
"""
import datetime as dt
import uuid
from sqlalchemy import DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.mixins import UUIDPrimaryKeyMixin
class LikeModel(UUIDPrimaryKeyMixin, Base):
__tablename__ = "likes"
__table_args__ = (
# Latest-event lookups query by (user, track) ordered by time.
Index("ix_likes_user_id_track_id", "user_id", "track_id"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
track_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE"),
nullable=False,
)
value: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
+40
View File
@@ -0,0 +1,40 @@
"""ORM model for cached lyrics (plan §6.7).
Cached per track (one row, ``track_id`` unique) so the external provider
(LRCLIB) isn't hit on every play. ``not_found`` is cached too — re-fetch policy
(TTL) lives in the service. ``synced`` holds timestamped LRC; ``plain`` the
fallback text.
"""
import datetime as dt
import uuid
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.enums import LyricsStatus
from app.infrastructure.db.models.mixins import UUIDPrimaryKeyMixin
class LyricsModel(UUIDPrimaryKeyMixin, Base):
__tablename__ = "lyrics"
track_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE"),
unique=True,
nullable=False,
)
synced: Mapped[str | None] = mapped_column(Text, nullable=True)
plain: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=LyricsStatus.PENDING.value,
)
fetched_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
@@ -0,0 +1,36 @@
"""ORM model for play history — an append-only event log (scrobbles).
``play_duration_seconds`` (how long was actually listened) feeds skip-rate for
future ML; ``completed`` marks a full play (plan §4).
"""
import datetime as dt
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.mixins import UUIDPrimaryKeyMixin
class PlayHistoryModel(UUIDPrimaryKeyMixin, Base):
__tablename__ = "play_history"
__table_args__ = (Index("ix_play_history_user_id_played_at", "user_id", "played_at"),)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
track_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
played_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
play_duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+55
View File
@@ -0,0 +1,55 @@
"""ORM models for playlists and their ordered tracks."""
import datetime as dt
import uuid
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class PlaylistModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "playlists"
name: Mapped[str] = mapped_column(String(512), nullable=False)
description: Mapped[str | None] = mapped_column(String(2048), nullable=True)
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# Optimistic-locking / future sync counter — bumped on every mutation.
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
class PlaylistTrackModel(UUIDPrimaryKeyMixin, Base):
"""A track's membership in a playlist.
``position`` is a float so a track can be inserted between two others
without reindexing the whole list (plan §4).
"""
__tablename__ = "playlist_tracks"
__table_args__ = (
UniqueConstraint("playlist_id", "track_id", name="uq_playlist_tracks_playlist_id_track_id"),
)
playlist_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("playlists.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
track_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tracks.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
position: Mapped[float] = mapped_column(Float, nullable=False)
added_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
+71
View File
@@ -0,0 +1,71 @@
"""ORM model for tracks — the central entity.
``id`` is the stable, client-facing ``content_id`` (generated app-side by
``UUIDPrimaryKeyMixin``) — never regenerate it. Dedup is enforced on both
``(source, source_id)`` (unique) and ``acoustid_fingerprint`` (indexed) so
imports/downloads stay idempotent (plan §4, §6.1).
"""
import uuid
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base
from app.infrastructure.db.models.enums import MetadataStatus, StoragePolicy
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class TrackModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "tracks"
__table_args__ = (
# Dedup by source identity — a source never yields the same id twice.
UniqueConstraint("source", "source_id", name="uq_tracks_source_source_id"),
)
title: Mapped[str] = mapped_column(String(1024), index=True, nullable=False)
artist_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("artists.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
album_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("albums.id", ondelete="SET NULL"),
index=True,
nullable=True,
)
track_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
genre: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True)
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- file (original, stored as-is) -----------------------------------
file_path: Mapped[str] = mapped_column(String(2048), nullable=False)
file_format: Mapped[str] = mapped_column(String(32), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
bitrate: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- dedup / external ids --------------------------------------------
acoustid_fingerprint: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
# -- provenance / policy ---------------------------------------------
source: Mapped[str] = mapped_column(String(32), nullable=False)
source_id: Mapped[str] = mapped_column(String(512), nullable=False)
is_replaceable: Mapped[bool] = mapped_column(nullable=False, default=False)
storage_policy: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=StoragePolicy.AS_IS.value,
)
metadata_status: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=MetadataStatus.PENDING.value,
)
added_by: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
index=True,
nullable=True,
)