"""Reader's tables: what a user does with the shared catalog — where they are in a video (`VideoState`), their playlists, their saved views, their labels on channels (`Tag`, `ChannelTag`) and the comfort shelf on Home. Only this unit writes them; another unit reads them along a declared edge, or asks this unit's service.""" from datetime import datetime from sqlalchemy import ( Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, func, text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base from app.models import TimestampMixin, UpdatedAtMixin class Tag(Base, TimestampMixin): """A label. System tags (user_id NULL) are computed automatically and shared by all users; user tags (user_id set) are personal. category groups them in the UI.""" __tablename__ = "tags" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(64)) color: Mapped[str | None] = mapped_column(String(16)) # language | topic | other category: Mapped[str] = mapped_column(String(16), default="other", server_default="other") class ChannelTag(Base): """Links a tag to a channel. source='auto' rows are managed by the auto-tagger and regenerated freely; source='user' rows are never touched by the system.""" __tablename__ = "channel_tags" __table_args__ = (UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),) id: Mapped[int] = mapped_column(primary_key=True) channel_id: Mapped[str] = mapped_column( ForeignKey("channels.id", ondelete="CASCADE"), index=True ) tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), index=True) # NULL = system/auto link visible to everyone; set = a specific user's link. user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto") confidence: Mapped[float | None] = mapped_column(Float) class VideoState(Base, UpdatedAtMixin): """Per-user watch state for a video. Rows exist only for non-default states; the absence of a row means 'new/unseen'.""" __tablename__ = "video_states" __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True) # new | watched | hidden ("saved" is now Watch-later playlist membership, not a status) status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Resume position (seconds into the video) for the in-app player. Orthogonal to # status: a partially-watched video keeps status "new" but a non-zero position, so # it can render a progress bar and match the "in progress" feed filter. 0 = not # started / finished (trivially-early and near-finished positions are not stored). # int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500). position_seconds: Mapped[int] = mapped_column( Integer, default=0, server_default="0", nullable=False ) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class Playlist(Base, TimestampMixin, UpdatedAtMixin): """A per-user named, ordered collection of videos from the shared catalog. `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). `source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id. `dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT sync phases; the foundation phase only uses local playlists.)""" __tablename__ = "playlists" __table_args__ = ( # One Watch later per user, held by the database: two first saves at once each made one. Index( "uq_playlist_watch_later", "user_id", unique=True, postgresql_where=text("kind = 'watch_later'"), ), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) name: Mapped[str] = mapped_column(String(255)) kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") source: Mapped[str] = mapped_column(String(16), default="local", server_default="local") yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) # True when the current items/name differ from `synced_fingerprint` (unpushed edits). dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty). synced_fingerprint: Mapped[str | None] = mapped_column(String) # JSON list of the ordered video ids as of the last sync — the 3-way-merge baseline the # two-way YouTube sync classifies each item against (local-add vs YT-add vs real delete). # NULL = no baseline yet; the first merge then does a lossless union (never deletes). synced_video_ids: Mapped[str | None] = mapped_column(Text) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") items: Mapped[list["PlaylistItem"]] = relationship( back_populates="playlist", cascade="all, delete-orphan", order_by="PlaylistItem.position", ) class PlaylistItem(Base): """A video's membership in a playlist, with its order within that playlist.""" __tablename__ = "playlist_items" __table_args__ = (UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),) id: Mapped[int] = mapped_column(primary_key=True) playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE")) video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) playlist: Mapped["Playlist"] = relationship(back_populates="items") class SavedView(Base, TimestampMixin, UpdatedAtMixin): """A per-user named snapshot of a module's filter/sort state (a "smart view"). `source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar (drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most one per (user, source), enforced by the API on set. The name is unique per (user, source).""" __tablename__ = "saved_views" __table_args__ = ( UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed") name: Mapped[str] = mapped_column(String(80)) filters: Mapped[dict] = mapped_column(JSON) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") class ComfortPin(Base, TimestampMixin): """One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X". Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is usually "put on X", a series or a channel, not one particular video. A string ref would order and hold anything, but it would also lose the foreign key: a Plex item dropped from the library, or an unsubscribed channel, would leave a dead tile on the one surface whose whole job is to be reliable. So instead: one nullable CASCADE-ing FK per pinnable kind, and a check constraint that exactly one of them is set. That constraint is the house rule applied to a five-way union — the invariant lives in the schema, not in a comment every insert site has to remember. `kind`/`ref` below derive FROM the columns, so there is no second vocabulary to keep in sync. NULLs are DISTINCT in a Postgres unique index, so the five per-kind unique constraints each stop a double-pin of the same thing without colliding across kinds.""" __tablename__ = "comfort_pins" __table_args__ = ( CheckConstraint( "num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1", name="ck_comfort_pin_one_ref", ), UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"), UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"), UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"), UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"), UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) video_id: Mapped[str | None] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE")) channel_id: Mapped[str | None] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) plex_item_id: Mapped[int | None] = mapped_column( ForeignKey("plex_items.id", ondelete="CASCADE") ) plex_show_id: Mapped[int | None] = mapped_column( ForeignKey("plex_shows.id", ondelete="CASCADE") ) playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE")) # 0-based shelf order. The shelf is short and hand-curated, so a dense integer the reorder # endpoint rewrites wholesale beats a fractional index nobody would ever exercise here. position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)