feat: models
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
"""music schema
|
||||
|
||||
Revision ID: e670d6c41d0c
|
||||
Revises: 0001_auth_users
|
||||
Create Date: 2026-06-07 11:37:41.420644
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e670d6c41d0c"
|
||||
down_revision: str | None = "0001_auth_users"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"artists",
|
||||
sa.Column("name", sa.String(length=512), nullable=False),
|
||||
sa.Column("musicbrainz_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_artists")),
|
||||
)
|
||||
op.create_index(op.f("ix_artists_musicbrainz_id"), "artists", ["musicbrainz_id"], unique=False)
|
||||
op.create_index(op.f("ix_artists_name"), "artists", ["name"], unique=False)
|
||||
op.create_table(
|
||||
"albums",
|
||||
sa.Column("title", sa.String(length=1024), nullable=False),
|
||||
sa.Column("artist_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
sa.Column("cover_path", sa.String(length=1024), nullable=True),
|
||||
sa.Column("musicbrainz_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"],
|
||||
["artists.id"],
|
||||
name=op.f("fk_albums_artist_id_artists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_albums")),
|
||||
)
|
||||
op.create_index(op.f("ix_albums_artist_id"), "albums", ["artist_id"], unique=False)
|
||||
op.create_index(op.f("ix_albums_musicbrainz_id"), "albums", ["musicbrainz_id"], unique=False)
|
||||
op.create_index(op.f("ix_albums_title"), "albums", ["title"], unique=False)
|
||||
op.create_table(
|
||||
"download_jobs",
|
||||
sa.Column("source", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=512), nullable=True),
|
||||
sa.Column("query", sa.String(length=1024), nullable=True),
|
||||
sa.Column("requested_by", sa.Uuid(), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("progress", sa.Float(), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["requested_by"],
|
||||
["users.id"],
|
||||
name=op.f("fk_download_jobs_requested_by_users"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_download_jobs")),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_download_jobs_requested_by"), "download_jobs", ["requested_by"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_download_jobs_status"), "download_jobs", ["status"], unique=False)
|
||||
op.create_table(
|
||||
"playlists",
|
||||
sa.Column("name", sa.String(length=512), nullable=False),
|
||||
sa.Column("description", sa.String(length=2048), nullable=True),
|
||||
sa.Column("owner_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("cover_path", sa.String(length=1024), nullable=True),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["owner_id"], ["users.id"], name=op.f("fk_playlists_owner_id_users"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_playlists")),
|
||||
)
|
||||
op.create_index(op.f("ix_playlists_owner_id"), "playlists", ["owner_id"], unique=False)
|
||||
op.create_table(
|
||||
"tracks",
|
||||
sa.Column("title", sa.String(length=1024), nullable=False),
|
||||
sa.Column("artist_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("album_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("track_number", sa.Integer(), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("genre", sa.String(length=255), nullable=True),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
sa.Column("file_path", sa.String(length=2048), nullable=False),
|
||||
sa.Column("file_format", sa.String(length=32), nullable=False),
|
||||
sa.Column("file_size", sa.Integer(), nullable=False),
|
||||
sa.Column("bitrate", sa.Integer(), nullable=True),
|
||||
sa.Column("acoustid_fingerprint", sa.String(length=64), nullable=True),
|
||||
sa.Column("musicbrainz_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("source", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=512), nullable=False),
|
||||
sa.Column("is_replaceable", sa.Boolean(), nullable=False),
|
||||
sa.Column("storage_policy", sa.String(length=16), nullable=False),
|
||||
sa.Column("metadata_status", sa.String(length=16), nullable=False),
|
||||
sa.Column("added_by", sa.Uuid(), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["added_by"], ["users.id"], name=op.f("fk_tracks_added_by_users"), ondelete="SET NULL"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["album_id"], ["albums.id"], name=op.f("fk_tracks_album_id_albums"), ondelete="SET NULL"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"],
|
||||
["artists.id"],
|
||||
name=op.f("fk_tracks_artist_id_artists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_tracks")),
|
||||
sa.UniqueConstraint("source", "source_id", name="uq_tracks_source_source_id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_tracks_acoustid_fingerprint"), "tracks", ["acoustid_fingerprint"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_tracks_added_by"), "tracks", ["added_by"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_album_id"), "tracks", ["album_id"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_artist_id"), "tracks", ["artist_id"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_genre"), "tracks", ["genre"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_musicbrainz_id"), "tracks", ["musicbrainz_id"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_title"), "tracks", ["title"], unique=False)
|
||||
op.create_table(
|
||||
"likes",
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("value", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"], ["tracks.id"], name=op.f("fk_likes_track_id_tracks"), ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"], name=op.f("fk_likes_user_id_users"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_likes")),
|
||||
)
|
||||
op.create_index("ix_likes_user_id_track_id", "likes", ["user_id", "track_id"], unique=False)
|
||||
op.create_table(
|
||||
"lyrics",
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("synced", sa.Text(), nullable=True),
|
||||
sa.Column("plain", sa.Text(), nullable=True),
|
||||
sa.Column("source", sa.String(length=64), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"fetched_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"], ["tracks.id"], name=op.f("fk_lyrics_track_id_tracks"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_lyrics")),
|
||||
sa.UniqueConstraint("track_id", name=op.f("uq_lyrics_track_id")),
|
||||
)
|
||||
op.create_table(
|
||||
"play_history",
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"played_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
|
||||
),
|
||||
sa.Column("play_duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("completed", sa.Boolean(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"],
|
||||
["tracks.id"],
|
||||
name=op.f("fk_play_history_track_id_tracks"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_play_history_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_play_history")),
|
||||
)
|
||||
op.create_index(op.f("ix_play_history_track_id"), "play_history", ["track_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_play_history_user_id_played_at", "play_history", ["user_id", "played_at"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"playlist_tracks",
|
||||
sa.Column("playlist_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("position", sa.Float(), nullable=False),
|
||||
sa.Column(
|
||||
"added_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
|
||||
),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["playlist_id"],
|
||||
["playlists.id"],
|
||||
name=op.f("fk_playlist_tracks_playlist_id_playlists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"],
|
||||
["tracks.id"],
|
||||
name=op.f("fk_playlist_tracks_track_id_tracks"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_playlist_tracks")),
|
||||
sa.UniqueConstraint(
|
||||
"playlist_id", "track_id", name="uq_playlist_tracks_playlist_id_track_id"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_playlist_tracks_playlist_id"), "playlist_tracks", ["playlist_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_playlist_tracks_track_id"), "playlist_tracks", ["track_id"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_playlist_tracks_track_id"), table_name="playlist_tracks")
|
||||
op.drop_index(op.f("ix_playlist_tracks_playlist_id"), table_name="playlist_tracks")
|
||||
op.drop_table("playlist_tracks")
|
||||
op.drop_index("ix_play_history_user_id_played_at", table_name="play_history")
|
||||
op.drop_index(op.f("ix_play_history_track_id"), table_name="play_history")
|
||||
op.drop_table("play_history")
|
||||
op.drop_table("lyrics")
|
||||
op.drop_index("ix_likes_user_id_track_id", table_name="likes")
|
||||
op.drop_table("likes")
|
||||
op.drop_index(op.f("ix_tracks_title"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_musicbrainz_id"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_genre"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_artist_id"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_album_id"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_added_by"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_acoustid_fingerprint"), table_name="tracks")
|
||||
op.drop_table("tracks")
|
||||
op.drop_index(op.f("ix_playlists_owner_id"), table_name="playlists")
|
||||
op.drop_table("playlists")
|
||||
op.drop_index(op.f("ix_download_jobs_status"), table_name="download_jobs")
|
||||
op.drop_index(op.f("ix_download_jobs_requested_by"), table_name="download_jobs")
|
||||
op.drop_table("download_jobs")
|
||||
op.drop_index(op.f("ix_albums_title"), table_name="albums")
|
||||
op.drop_index(op.f("ix_albums_musicbrainz_id"), table_name="albums")
|
||||
op.drop_index(op.f("ix_albums_artist_id"), table_name="albums")
|
||||
op.drop_table("albums")
|
||||
op.drop_index(op.f("ix_artists_name"), table_name="artists")
|
||||
op.drop_index(op.f("ix_artists_musicbrainz_id"), table_name="artists")
|
||||
op.drop_table("artists")
|
||||
# ### end Alembic commands ###
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user