diff --git a/alembic/versions/20260607_1137-e670d6c41d0c_music_schema.py b/alembic/versions/20260607_1137-e670d6c41d0c_music_schema.py new file mode 100644 index 0000000..6ba840f --- /dev/null +++ b/alembic/versions/20260607_1137-e670d6c41d0c_music_schema.py @@ -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 ### diff --git a/app/infrastructure/db/models/__init__.py b/app/infrastructure/db/models/__init__.py index be7d1ba..8ca5f49 100644 --- a/app/infrastructure/db/models/__init__.py +++ b/app/infrastructure/db/models/__init__.py @@ -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", +] diff --git a/app/infrastructure/db/models/album.py b/app/infrastructure/db/models/album.py new file mode 100644 index 0000000..d1960d5 --- /dev/null +++ b/app/infrastructure/db/models/album.py @@ -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) diff --git a/app/infrastructure/db/models/artist.py b/app/infrastructure/db/models/artist.py new file mode 100644 index 0000000..4a706e7 --- /dev/null +++ b/app/infrastructure/db/models/artist.py @@ -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) diff --git a/app/infrastructure/db/models/download_job.py b/app/infrastructure/db/models/download_job.py new file mode 100644 index 0000000..1900d78 --- /dev/null +++ b/app/infrastructure/db/models/download_job.py @@ -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) diff --git a/app/infrastructure/db/models/enums.py b/app/infrastructure/db/models/enums.py new file mode 100644 index 0000000..4a5ef0c --- /dev/null +++ b/app/infrastructure/db/models/enums.py @@ -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" diff --git a/app/infrastructure/db/models/like.py b/app/infrastructure/db/models/like.py new file mode 100644 index 0000000..ce84ba3 --- /dev/null +++ b/app/infrastructure/db/models/like.py @@ -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, + ) diff --git a/app/infrastructure/db/models/lyrics.py b/app/infrastructure/db/models/lyrics.py new file mode 100644 index 0000000..876609f --- /dev/null +++ b/app/infrastructure/db/models/lyrics.py @@ -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, + ) diff --git a/app/infrastructure/db/models/play_history.py b/app/infrastructure/db/models/play_history.py new file mode 100644 index 0000000..2383aad --- /dev/null +++ b/app/infrastructure/db/models/play_history.py @@ -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) diff --git a/app/infrastructure/db/models/playlist.py b/app/infrastructure/db/models/playlist.py new file mode 100644 index 0000000..801bbba --- /dev/null +++ b/app/infrastructure/db/models/playlist.py @@ -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, + ) diff --git a/app/infrastructure/db/models/track.py b/app/infrastructure/db/models/track.py new file mode 100644 index 0000000..cb3dc50 --- /dev/null +++ b/app/infrastructure/db/models/track.py @@ -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, + )