From e82f87acb01497e64df4a7102068b1a56923acb2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 19 Oct 2026 00:53:44 +0200 Subject: [PATCH] refactor(reader): the unit's tables move into reader/models VideoState, Playlist, PlaylistItem, SavedView, ComfortPin, Tag and ChannelTag leave app/models.py for app/reader/models.py; app.tables registers them. No schema change. --- backend/app/discover/cards.py | 3 +- backend/app/discover/pins.py | 3 +- backend/app/discover/queries.py | 12 +- backend/app/feedquery.py | 6 +- backend/app/models.py | 180 ---------------- backend/app/reader/__init__.py | 0 backend/app/reader/models.py | 204 ++++++++++++++++++ backend/app/routes/channels.py | 5 +- backend/app/routes/discover.py | 3 +- backend/app/routes/feed.py | 9 +- backend/app/routes/playlists.py | 3 +- backend/app/routes/saved_views.py | 3 +- backend/app/routes/search.py | 4 +- backend/app/routes/tags.py | 3 +- backend/app/sync/autotag.py | 3 +- backend/app/sync/explore.py | 11 +- backend/app/sync/maintenance.py | 3 +- backend/app/sync/playlists.py | 3 +- backend/app/sync/tagsuggest.py | 3 +- backend/app/tables.py | 3 +- backend/tests/test_abuse_limits.py | 2 +- backend/tests/test_autotag_orphans.py | 3 +- backend/tests/test_channel_tags.py | 3 +- backend/tests/test_collation.py | 13 +- backend/tests/test_discover.py | 5 +- backend/tests/test_empty_listing_guards.py | 11 +- backend/tests/test_feed_views.py | 3 +- backend/tests/test_maintenance_lifecycle.py | 3 +- backend/tests/test_playlist_add_position.py | 3 +- backend/tests/test_playlist_merge.py | 3 +- backend/tests/test_playlist_move.py | 3 +- backend/tests/test_playlist_position_lock.py | 12 +- backend/tests/test_playlist_sync_claim.py | 2 +- .../tests/test_playlist_sync_lock_order.py | 3 +- backend/tests/test_tag_suggest.py | 3 +- 35 files changed, 262 insertions(+), 274 deletions(-) create mode 100644 backend/app/reader/__init__.py create mode 100644 backend/app/reader/models.py diff --git a/backend/app/discover/cards.py b/backend/app/discover/cards.py index eb481364..39cb1d2a 100644 --- a/backend/app/discover/cards.py +++ b/backend/app/discover/cards.py @@ -22,7 +22,8 @@ to the client, which has both the native payload and the translations. """ from app.discover.budget import remaining_seconds -from app.models import Channel, Playlist +from app.models import Channel +from app.reader.models import Playlist # Plex artwork is a poster for the things you browse and a landscape still for the things you play. _POSTER_KINDS = ("movie", "show") diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py index 63cb3c88..6655f1bb 100644 --- a/backend/app/discover/pins.py +++ b/backend/app/discover/pins.py @@ -22,7 +22,8 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app.discover import cards, queries -from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video +from app.models import Channel, PlexItem, PlexShow, User, Video +from app.reader.models import ComfortPin, Playlist from app.plex import rollup from app.plex.cards import SHOW_NEW, leaf_card, show_card from app.plex.lookups import by_rating_key diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index aa3c965d..38fd6567 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -29,16 +29,8 @@ from app.feedquery import ( serialize, sort_keys, ) -from app.models import ( - Channel, - PlaylistItem, - PlexItem, - PlexShow, - PlexState, - User, - Video, - VideoState, -) +from app.models import Channel, PlexItem, PlexShow, PlexState, User, Video +from app.reader.models import PlaylistItem, VideoState from app.plex import rollup from app.plex.cards import display_title, leaf_card, movie_card, show_card from app.plex.query import apply_show_state_filter, content_libs diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index dfedd76a..950cf200 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -20,17 +20,13 @@ from app.models import ( LIVE_OR_UPCOMING, BlockedChannel, Channel, - ChannelTag, ExploredChannel, - Playlist, - PlaylistItem, SearchFind, Subscription, - Tag, User, Video, - VideoState, ) +from app.reader.models import ChannelTag, Playlist, PlaylistItem, Tag, VideoState from app.utils import iso, like_escape, to_tsquery_str diff --git a/backend/app/models.py b/backend/app/models.py index 000baa44..64260cca 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -406,64 +406,6 @@ class Video(Base, TimestampMixin): channel: Mapped["Channel"] = relationship(back_populates="videos") -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 SearchFind(Base, TimestampMixin): """Per-user record of a video the user surfaced via their own live YouTube search. @@ -739,79 +681,6 @@ class SystemConfig(Base): ) -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" - - 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 Notification(Base): """A durable, server-backed per-user notification (the inbox center, phase 1). @@ -1314,52 +1183,3 @@ class PlexPlaylistItem(Base): ForeignKey("plex_items.id", ondelete="CASCADE"), index=True ) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=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) diff --git a/backend/app/reader/__init__.py b/backend/app/reader/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/reader/models.py b/backend/app/reader/models.py new file mode 100644 index 00000000..8cad2eaa --- /dev/null +++ b/backend/app/reader/models.py @@ -0,0 +1,204 @@ +"""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, + Integer, + JSON, + String, + Text, + UniqueConstraint, + func, +) +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" + + 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) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 3c1e753c..2df0d9ab 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -19,15 +19,12 @@ from app.models import ( LIVE_OR_UPCOMING, BlockedChannel, Channel, - ChannelTag, ExploredChannel, - Playlist, - PlaylistItem, Subscription, - Tag, User, Video, ) +from app.reader.models import ChannelTag, Playlist, PlaylistItem, Tag from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal from app.sync.explore import explore_ingest_page from app.sync.runner import backfill_channel_now, run_recent_backfill diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 104aea67..aa5ffd6e 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -22,7 +22,8 @@ from app.db import get_db from app.discover import hero as hero_mod from app.discover import pins, rails from app.discover.budget import Budget, DEFAULT_BUDGET -from app.models import ComfortPin, User +from app.models import User +from app.reader.models import ComfortPin from app.plex.cards import norm_lang from app.routes._common import owned_or_404, not_found diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 15da2ecb..40ffe64f 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -25,13 +25,8 @@ from app.feedquery import ( windowed_rank, windowed_relevance_rows, ) -from app.models import ( - ChannelTag, - Tag, - User, - Video, - VideoState, -) +from app.models import User, Video +from app.reader.models import ChannelTag, Tag, VideoState from app.routes._common import ( ProgressIn, get_or_404, diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index bc2f79cb..491b0f4d 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -12,7 +12,8 @@ from app import quota from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope from app.db import get_db from app.feedquery import feed_columns, serialize -from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState +from app.models import Channel, User, Video +from app.reader.models import Playlist, PlaylistItem, VideoState from app.sync.playlists import ( PlaylistGone, lock_playlists, diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py index cdc9d23f..14cf9cdb 100644 --- a/backend/app/routes/saved_views.py +++ b/backend/app/routes/saved_views.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db -from app.models import SavedView, User +from app.models import User +from app.reader.models import SavedView from app.routes._common import owned_or_404 from app.refusal import refused diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 0073384b..e7b96b8c 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -29,14 +29,12 @@ from app.models import ( LIVE_OR_UPCOMING, BlockedChannel, Channel, - Playlist, - PlaylistItem, SearchFind, Subscription, User, Video, - VideoState, ) +from app.reader.models import Playlist, PlaylistItem, VideoState from app.feedquery import feed_columns, serialize from app.utils import like_escape from app.sync.subscriptions import apply_channel_details diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 8228e912..19389811 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db -from app.models import ChannelTag, Tag, User +from app.models import User +from app.reader.models import ChannelTag, Tag from app.routes._common import owned_or_404 from app.sync.tagsuggest import compute_tag_suggestions from app.refusal import refused diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py index ce2965a9..fe58fd66 100644 --- a/backend/app/sync/autotag.py +++ b/backend/app/sync/autotag.py @@ -12,7 +12,8 @@ from sqlalchemy import and_, exists, func, select from sqlalchemy.orm import Session from app import logs, progress, sysconfig -from app.models import Channel, ChannelTag, SavedView, Tag, Video +from app.models import Channel, Video +from app.reader.models import ChannelTag, SavedView, Tag log = logs.get_logger(__name__) diff --git a/backend/app/sync/explore.py b/backend/app/sync/explore.py index 1a4172c8..d0a7d3c0 100644 --- a/backend/app/sync/explore.py +++ b/backend/app/sync/explore.py @@ -14,15 +14,8 @@ from sqlalchemy import delete, or_, select from sqlalchemy.orm import Session from app import logs, sysconfig -from app.models import ( - Channel, - ExploredChannel, - PlaylistItem, - SearchFind, - Subscription, - Video, - VideoState, -) +from app.models import Channel, ExploredChannel, SearchFind, Subscription, Video +from app.reader.models import PlaylistItem, VideoState from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details from app.youtube.client import YouTubeClient, YouTubeError, is_playlist_gone diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py index fa1c2602..ab2d50d0 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -27,7 +27,8 @@ from sqlalchemy.orm import Session from app import logs, progress, quota, sysconfig from app.config import settings -from app.models import Playlist, PlaylistItem, Video +from app.models import Video +from app.reader.models import Playlist, PlaylistItem from app.notifications import create_notification from app.sync.runner import get_service_user from app.sync.videos import apply_video_details, parse_dt diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index cac99c2d..f224978f 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -21,7 +21,8 @@ from sqlalchemy.pool import NullPool from app import logs, progress, quota from app.auth import has_read_scope -from app.models import Channel, Playlist, PlaylistItem, User, Video +from app.models import Channel, User, Video +from app.reader.models import Playlist, PlaylistItem from app.sync.playlist_merge import merge_plan from app.sync.runner import token_users from app.sync.subscriptions import blank_listing, refuse_blank_listing, vanished diff --git a/backend/app/sync/tagsuggest.py b/backend/app/sync/tagsuggest.py index a4e2d99e..3a24ba23 100644 --- a/backend/app/sync/tagsuggest.py +++ b/backend/app/sync/tagsuggest.py @@ -23,7 +23,8 @@ from collections import Counter from sqlalchemy import select from sqlalchemy.orm import Session -from app.models import ChannelTag, Subscription, Tag, User +from app.models import Subscription, User +from app.reader.models import ChannelTag, Tag # A user tag needs at least this many manually-tagged example channels before we trust its # profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming"). diff --git a/backend/app/tables.py b/backend/app/tables.py index 5727fe9b..62a66863 100644 --- a/backend/app/tables.py +++ b/backend/app/tables.py @@ -6,4 +6,5 @@ every mapper — imports this module. """ import app.downloads.models # Capture's tables -import app.models # noqa: F401 — the core tables +import app.models # the core tables +import app.reader.models # noqa: F401 — Reader's tables diff --git a/backend/tests/test_abuse_limits.py b/backend/tests/test_abuse_limits.py index f9ec8303..4333d269 100644 --- a/backend/tests/test_abuse_limits.py +++ b/backend/tests/test_abuse_limits.py @@ -106,7 +106,7 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u # SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key # as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one # route over. Both must draw on the one allowance. - from app.models import Playlist + from app.reader.models import Playlist from app.routes import playlists as plroutes user = make_user() diff --git a/backend/tests/test_autotag_orphans.py b/backend/tests/test_autotag_orphans.py index 76e01810..d3375703 100644 --- a/backend/tests/test_autotag_orphans.py +++ b/backend/tests/test_autotag_orphans.py @@ -7,7 +7,8 @@ drops an unknown id, so the view silently showed the whole feed under its old na from sqlalchemy import select -from app.models import Channel, ChannelTag, SavedView, Tag +from app.models import Channel +from app.reader.models import ChannelTag, SavedView, Tag from app.sync import autotag diff --git a/backend/tests/test_channel_tags.py b/backend/tests/test_channel_tags.py index 375673e7..2ae70dd8 100644 --- a/backend/tests/test_channel_tags.py +++ b/backend/tests/test_channel_tags.py @@ -14,7 +14,8 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import Channel, ChannelTag, Tag, User +from app.models import Channel, User +from app.reader.models import ChannelTag, Tag def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch, override_dep): diff --git a/backend/tests/test_collation.py b/backend/tests/test_collation.py index 84f9a1e8..676526be 100644 --- a/backend/tests/test_collation.py +++ b/backend/tests/test_collation.py @@ -9,17 +9,8 @@ from app.auth import current_user from app.collation import sorted_words from app.db import get_db from app.main import app -from app.models import ( - Channel, - Playlist, - PlaylistItem, - PlexCollection, - PlexItem, - PlexLibrary, - Subscription, - User, - Video, -) +from app.models import Channel, PlexCollection, PlexItem, PlexLibrary, Subscription, User, Video +from app.reader.models import Playlist, PlaylistItem from datetime import datetime, timezone WORDS = ["Zorró", "Életrajzi", "Ötödik", "oroszlán", "Csillag", "Cukor"] diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index d40ee832..607940a0 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -20,18 +20,15 @@ from app.plex.cards import leaf_card from app.models import ( BlockedChannel, Channel, - ComfortPin, PlexItem, PlexLibrary, PlexShow, PlexState, - Playlist, - PlaylistItem, Subscription, User, Video, - VideoState, ) +from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState NOW = datetime(2026, 6, 1, tzinfo=timezone.utc) diff --git a/backend/tests/test_empty_listing_guards.py b/backend/tests/test_empty_listing_guards.py index 0fcb44d1..b8823ad1 100644 --- a/backend/tests/test_empty_listing_guards.py +++ b/backend/tests/test_empty_listing_guards.py @@ -12,15 +12,8 @@ contradicts the remote's own count is not believed either. import pytest from sqlalchemy import select -from app.models import ( - Channel, - Playlist, - PlaylistItem, - PlexCollection, - PlexItem, - PlexLibrary, - Video, -) +from app.models import Channel, PlexCollection, PlexItem, PlexLibrary, Video +from app.reader.models import Playlist, PlaylistItem from app.plex import sync as plex_sync from app.sync import playlists as plsync from app.youtube.client import YouTubeError diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index 46f35401..8e412d35 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -14,7 +14,8 @@ import pytest from app import feedquery -from app.models import Channel, ChannelTag, Subscription, Tag, Video, VideoState +from app.models import Channel, Subscription, Video +from app.reader.models import ChannelTag, Tag, VideoState def _dt(day: int) -> datetime: diff --git a/backend/tests/test_maintenance_lifecycle.py b/backend/tests/test_maintenance_lifecycle.py index af16ab04..ae77147c 100644 --- a/backend/tests/test_maintenance_lifecycle.py +++ b/backend/tests/test_maintenance_lifecycle.py @@ -12,7 +12,8 @@ from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import select -from app.models import Channel, Notification, Playlist, PlaylistItem, Video +from app.models import Channel, Notification, Video +from app.reader.models import Playlist, PlaylistItem from app.sync import maintenance diff --git a/backend/tests/test_playlist_add_position.py b/backend/tests/test_playlist_add_position.py index e766b0be..7ae6c125 100644 --- a/backend/tests/test_playlist_add_position.py +++ b/backend/tests/test_playlist_add_position.py @@ -8,7 +8,8 @@ the position is an index over the ORDERED rows, and the list is renumbered aroun from datetime import datetime, timezone -from app.models import Channel, Playlist, PlaylistItem, Video +from app.models import Channel, Video +from app.reader.models import Playlist, PlaylistItem def _seed(db, user, ids, *, positions=None, **kw): diff --git a/backend/tests/test_playlist_merge.py b/backend/tests/test_playlist_merge.py index 7755104a..dfe995af 100644 --- a/backend/tests/test_playlist_merge.py +++ b/backend/tests/test_playlist_merge.py @@ -10,7 +10,8 @@ from datetime import datetime, timezone import pytest -from app.models import Channel, Playlist, PlaylistItem, Video +from app.models import Channel, Video +from app.reader.models import Playlist, PlaylistItem from app.sync import playlists as pl_mod from app.sync.playlist_merge import merge_plan from app.sync.playlists import ( diff --git a/backend/tests/test_playlist_move.py b/backend/tests/test_playlist_move.py index 31e941b5..0fa16daa 100644 --- a/backend/tests/test_playlist_move.py +++ b/backend/tests/test_playlist_move.py @@ -16,7 +16,8 @@ from datetime import datetime, timedelta, timezone import pytest from sqlalchemy.exc import OperationalError -from app.models import Channel, Playlist, PlaylistItem, Video +from app.models import Channel, Video +from app.reader.models import Playlist, PlaylistItem def _seed_videos(db, ids): diff --git a/backend/tests/test_playlist_position_lock.py b/backend/tests/test_playlist_position_lock.py index 3785bd7a..de18638c 100644 --- a/backend/tests/test_playlist_position_lock.py +++ b/backend/tests/test_playlist_position_lock.py @@ -19,16 +19,8 @@ from datetime import datetime, timezone import pytest from sqlalchemy import event -from app.models import ( - Channel, - Playlist, - PlaylistItem, - PlexItem, - PlexLibrary, - PlexPlaylist, - PlexPlaylistItem, - Video, -) +from app.models import Channel, PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem, Video +from app.reader.models import Playlist, PlaylistItem @pytest.fixture diff --git a/backend/tests/test_playlist_sync_claim.py b/backend/tests/test_playlist_sync_claim.py index cf63d762..fb133640 100644 --- a/backend/tests/test_playlist_sync_claim.py +++ b/backend/tests/test_playlist_sync_claim.py @@ -10,7 +10,7 @@ from sqlalchemy import text from app.auth import require_write_scope from app.db import SessionLocal -from app.models import Playlist +from app.reader.models import Playlist from app.routes import playlists as plroutes from app.sync import playlists as plsync diff --git a/backend/tests/test_playlist_sync_lock_order.py b/backend/tests/test_playlist_sync_lock_order.py index 80ac6a48..65fc97ae 100644 --- a/backend/tests/test_playlist_sync_lock_order.py +++ b/backend/tests/test_playlist_sync_lock_order.py @@ -18,7 +18,8 @@ import pytest from sqlalchemy import delete, event, text from app.db import SessionLocal -from app.models import Channel, Playlist, PlaylistItem, Video +from app.models import Channel, Video +from app.reader.models import Playlist, PlaylistItem from app.sync import playlists as pl_mod ITEMS = PlaylistItem.__table__.name diff --git a/backend/tests/test_tag_suggest.py b/backend/tests/test_tag_suggest.py index 7274736f..4439c5ff 100644 --- a/backend/tests/test_tag_suggest.py +++ b/backend/tests/test_tag_suggest.py @@ -11,7 +11,8 @@ network, pure DB state. Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. """ -from app.models import Channel, ChannelTag, Subscription, Tag, User +from app.models import Channel, Subscription, User +from app.reader.models import ChannelTag, Tag from app.sync.tagsuggest import ( AUTOAPPLY_THRESHOLD, CHAR_SUPPORT,