From 238e6f8a613743aa94a1aebc7aed800cd3d8fad3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 19 Oct 2026 02:03:14 +0200 Subject: [PATCH] refactor(catalog): the unit's tables move into catalog/models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel, Subscription, Video, SearchFind, ExploredChannel and BlockedChannel (and LIVE_OR_UPCOMING) leave app/models.py for app/catalog/models.py; app.tables registers them. The User↔Subscription relationship names its other side as a type only. No schema change. --- backend/app/catalog/__init__.py | 0 backend/app/catalog/models.py | 265 ++++++++++++++++++ backend/app/discover/cards.py | 2 +- backend/app/discover/pins.py | 3 +- backend/app/discover/queries.py | 3 +- backend/app/downloads/service.py | 3 +- backend/app/feedquery.py | 4 +- backend/app/models.py | 241 +--------------- backend/app/reader/queries.py | 3 +- backend/app/routes/channels.py | 4 +- backend/app/routes/runs.py | 3 +- backend/app/routes/scheduler.py | 3 +- backend/app/routes/search.py | 4 +- backend/app/routes/sync.py | 3 +- backend/app/scheduler.py | 3 +- backend/app/sync/autotag.py | 2 +- backend/app/sync/explore.py | 2 +- backend/app/sync/maintenance.py | 2 +- backend/app/sync/playlists.py | 3 +- backend/app/sync/runner.py | 3 +- backend/app/sync/subscriptions.py | 3 +- backend/app/sync/tagsuggest.py | 3 +- backend/app/sync/videos.py | 2 +- backend/app/tables.py | 1 + backend/tests/test_activity_reads.py | 3 +- backend/tests/test_autotag_orphans.py | 2 +- backend/tests/test_channel_backfill.py | 3 +- backend/tests/test_channel_details_refresh.py | 2 +- backend/tests/test_channel_list_index_only.py | 2 +- .../tests/test_channel_subscribe_upstream.py | 2 +- backend/tests/test_channel_tags.py | 3 +- backend/tests/test_clip.py | 3 +- backend/tests/test_collation.py | 5 +- backend/tests/test_discover.py | 13 +- backend/tests/test_empty_listing_guards.py | 3 +- backend/tests/test_feed_fallback.py | 2 +- backend/tests/test_feed_keyset.py | 3 +- backend/tests/test_feed_relevance_window.py | 3 +- backend/tests/test_feed_sort_indexes.py | 2 +- backend/tests/test_feed_views.py | 2 +- backend/tests/test_followed_channels.py | 2 +- backend/tests/test_like_literals.py | 2 +- backend/tests/test_maintenance_lifecycle.py | 3 +- backend/tests/test_my_status_deep.py | 2 +- backend/tests/test_playlist_add_position.py | 2 +- backend/tests/test_playlist_merge.py | 2 +- backend/tests/test_playlist_move.py | 2 +- backend/tests/test_playlist_position_lock.py | 3 +- .../tests/test_playlist_sync_lock_order.py | 2 +- backend/tests/test_reader_service.py | 2 +- backend/tests/test_scheduler.py | 3 +- backend/tests/test_search_scrape_ingest.py | 3 +- backend/tests/test_tag_suggest.py | 3 +- backend/tests/test_youtube_refusals.py | 3 +- scripts/e2e_seed.py | 9 +- 55 files changed, 353 insertions(+), 308 deletions(-) create mode 100644 backend/app/catalog/__init__.py create mode 100644 backend/app/catalog/models.py diff --git a/backend/app/catalog/__init__.py b/backend/app/catalog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/catalog/models.py b/backend/app/catalog/models.py new file mode 100644 index 00000000..b1bd89e3 --- /dev/null +++ b/backend/app/catalog/models.py @@ -0,0 +1,265 @@ +"""Catalog's tables: the shared YouTube world every user reads — channels and their videos — and +each user's footprint in it: what they subscribe to, block, explored or found by search. Only this +unit writes them; another unit reads them along a declared edge, or asks this unit's service.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + BigInteger, + Boolean, + Computed, + DateTime, + ForeignKey, + Integer, + JSON, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import TSVECTOR +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base +from app.models import TimestampMixin + +if TYPE_CHECKING: + from app.models import User + + +class Channel(Base, TimestampMixin): + """A YouTube channel. Shared across all users (one channel's videos are the same + for everyone), so its expensive metadata is fetched and stored only once.""" + + __tablename__ = "channels" + + id: Mapped[str] = mapped_column(String(32), primary_key=True) # "UC..." channel id + title: Mapped[str | None] = mapped_column(String(255)) + handle: Mapped[str | None] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text) + thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) + uploads_playlist_id: Mapped[str | None] = mapped_column(String(64)) + subscriber_count: Mapped[int | None] = mapped_column(BigInteger) + video_count: Mapped[int | None] = mapped_column(BigInteger) + # Channel "About" metadata (fetched with the same channels.list call, no extra quota): + # lifetime view count, the channel's join date, banner image, and external links. + total_view_count: Mapped[int | None] = mapped_column(BigInteger) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + banner_url: Mapped[str | None] = mapped_column(String(1024)) + external_links: Mapped[list | None] = mapped_column(JSON) + topic_categories: Mapped[list | None] = mapped_column(JSON) + default_language: Mapped[str | None] = mapped_column(String(16)) + country: Mapped[str | None] = mapped_column(String(8)) + # Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string, + # multi-word tags quoted; the client parses it into chips for the About tab. + keywords: Mapped[str | None] = mapped_column(Text) + + # Sync bookkeeping. + details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # When the newest uploads were last read through the Data API because the RSS feed would not + # answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock. + feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + backfill_cursor: Mapped[str | None] = mapped_column(String(128)) + backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # True when this channel only entered the catalog because a live YouTube search + # surfaced one of its videos (nobody subscribes to it / it has no organic upload sync). + # Lets the Library hide search-only "noise" by default. + from_search: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", index=True + ) + # True when this channel exists only because a user EXPLORED it (opened its page without + # subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a + # channel (and its via_explore videos) once nobody keeps it (no subscription, no live + # ExploredChannel row, no per-user state) after a grace period. Cleared when someone + # subscribes (the "keep" signal promotes it to organic). + from_explore: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", index=True + ) + + videos: Mapped[list["Video"]] = relationship(back_populates="channel") + subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") + + +class Subscription(Base, TimestampMixin): + """Per-user link to a channel, with the user's own priority/visibility overrides.""" + + __tablename__ = "subscriptions" + __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + channel_id: Mapped[str] = mapped_column( + ForeignKey("channels.id", ondelete="CASCADE"), index=True + ) + # YouTube's subscription resource id, needed to unsubscribe via the API. + yt_subscription_id: Mapped[str | None] = mapped_column(String(128)) + priority: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # Per-user opt-in to full-history (deep) backfill of this channel. Default off so a + # new user's unique channels don't trigger a big shared-quota burst; the deep + # scheduler only picks up channels at least one user has requested. + deep_requested: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped["User"] = relationship(back_populates="subscriptions") + channel: Mapped["Channel"] = relationship(back_populates="subscriptions") + + +# Video.live_status values that mean "currently a live/upcoming broadcast" — transient states +# hidden from feeds/search and revisited by the refresh job until they settle into a VOD. +LIVE_OR_UPCOMING = ("live", "upcoming") + + +class Video(Base, TimestampMixin): + """A YouTube video. Shared across users; per-user state lives elsewhere.""" + + __tablename__ = "videos" + + id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id + # No standalone channel_id index: the composite `ix_videos_channel_pub_cover` + # (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it + # serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed + # sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only + # Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only). + # Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`. + channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) + title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles) + original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved + description: Mapped[str | None] = mapped_column(Text) + # NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key + # forced the feed's keyset predicate into a non-sargable `... OR published_at IS NULL` disjunction + # that blocked the ix_videos_published_at_desc range (deep pages scanned from the top and filtered — + # PERF-15). server_default now() is the safety net for the rare date-less ingest (the enrich fills + # the real date); the ix_videos_published_at index stays for the `oldest` sort. + published_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), index=True, nullable=False, server_default=func.now() + ) + thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) + # The feed's views / duration / title sorts each ride a migration-defined index in their exact + # ORDER BY, one per direction (and per ICU collation for title): migration 0096, held to + # `feedquery.order_by` by test_feed_sort_indexes. + duration_seconds: Mapped[int | None] = mapped_column(Integer) + view_count: Mapped[int | None] = mapped_column(BigInteger) + like_count: Mapped[int | None] = mapped_column(BigInteger) + category_id: Mapped[int | None] = mapped_column(Integer) + topic_categories: Mapped[list | None] = mapped_column(JSON) + default_language: Mapped[str | None] = mapped_column(String(16)) + detected_language: Mapped[str | None] = mapped_column(String(16)) + # Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the + # snippet we already request. Indexed into search_vector so the feed search matches the + # uploader's own keywords, not just words in the title. + keywords: Mapped[str | None] = mapped_column(Text) + # Accumulated distinct live-search queries that surfaced this video (across all users) — + # folds YouTube's own relevance judgement into the local search: a video YouTube returned + # for "ti amo magyarul" becomes findable by that query locally even if its title lacks it. + search_terms: Mapped[str | None] = mapped_column(Text) + # No btree here or on the flag columns below: migration 0069 (PERF-3) dropped the never-picked + # boolean btrees and replaced the useful ones with partial indexes over the target subset + # (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the + # search_vector GIN. + is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # Whether the youtube.com/shorts/ probe has run for this video. + shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # True when this video first entered the catalog via a live YouTube search (not an + # organic channel/playlist sync). Used to hide search-discovered "noise" from the + # Library by default; left False by the organic ingest paths. + via_search: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # True when this video entered the catalog because a user EXPLORED its (un-subscribed) + # channel. Like via_search it's hidden from the shared Library by default, but it's + # additionally PER-USER private: a via_explore video is only visible to users who + # explored or subscribe to its channel (a gate in the feed query), so one user's + # curiosity never floods everyone else's catalog. Left False by the organic ingest paths. + via_explore: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # none | live | upcoming | premiere | was_live + live_status: Mapped[str] = mapped_column( + String(16), default="none", server_default="none", index=True + ) + enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # videos.list?part=status fields (populated during enrichment); used by the + # maintenance/validation job to decide whether a video is playable anywhere. + embeddable: Mapped[bool | None] = mapped_column(Boolean) + privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private + upload_status: Mapped[str | None] = mapped_column( + String(16) + ) # processed|failed|rejected|deleted + # Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation + # (least-recently-checked first); unavailable_since marks a video as currently + # unplayable (hidden from the feed immediately, hard-deleted after a grace period); + # unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live). + # last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial + # ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the + # revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3). + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + unavailable_reason: Mapped[str | None] = mapped_column(String(24)) + + # Weighted full-text search document (DB-generated, never written by the app): title (A) > + # creator keywords + search queries (B) > truncated description (C). ts_rank honours the + # weights so title matches outrank description matches. Backed by a GIN index (migration + # 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031. + search_vector: Mapped[object | None] = mapped_column( + TSVECTOR, + Computed( + "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || " + "coalesce(search_terms, '')), 'B') || " + "setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')", + persisted=True, + ), + ) + + channel: Mapped["Channel"] = relationship(back_populates="videos") + + +class SearchFind(Base, TimestampMixin): + """Per-user record of a video the user surfaced via their own live YouTube search. + + Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source + filter — distinct from the GLOBAL `Video.via_search` provenance, which marks a video as + search-discovered by *anyone* and drives the shared Library's Source filter.""" + + __tablename__ = "search_finds" + __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_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) + + +class ExploredChannel(Base, TimestampMixin): + """Per-user record of a channel the user opened the page of WITHOUT subscribing. + + Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the + user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the + explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period + purge: a from_explore channel with no live ExploredChannel row (and no subscription / no + per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep" + signal — it promotes the channel to organic and these rows become irrelevant. `created_at` + (from TimestampMixin) is the grace clock; a re-visit refreshes it.""" + + __tablename__ = "explored_channels" + __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + channel_id: Mapped[str] = mapped_column( + ForeignKey("channels.id", ondelete="CASCADE"), index=True + ) + + +class BlockedChannel(Base, TimestampMixin): + """Per-user channel blocklist. Videos from a blocked channel are excluded from this user's + live YouTube search (and not ingested on their behalf) and hidden from their feed / explore + views — a personal "never show me this channel again" that doesn't affect other users.""" + + __tablename__ = "blocked_channels" + __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + channel_id: Mapped[str] = mapped_column( + ForeignKey("channels.id", ondelete="CASCADE"), index=True + ) diff --git a/backend/app/discover/cards.py b/backend/app/discover/cards.py index 39cb1d2a..20faaf9f 100644 --- a/backend/app/discover/cards.py +++ b/backend/app/discover/cards.py @@ -22,7 +22,7 @@ to the client, which has both the native payload and the translations. """ from app.discover.budget import remaining_seconds -from app.models import Channel +from app.catalog.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. diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py index 9e165dff..ac6307f8 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, PlexItem, PlexShow, User, Video +from app.models import PlexItem, PlexShow, User +from app.catalog.models import Channel, Video from app.reader.models import ComfortPin, Playlist from app.plex import rollup from app.plex.cards import SHOW_NEW, leaf_card, show_card diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index 80ee229f..c06c3001 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -29,7 +29,8 @@ from app.feedquery import ( serialize, sort_keys, ) -from app.models import Channel, PlexItem, PlexShow, PlexState, User, Video +from app.models import PlexItem, PlexShow, PlexState, User +from app.catalog.models import Channel, 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 diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index f1b85e0e..3b4b3453 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -24,7 +24,8 @@ from app.downloads import credentials from app.downloads import edit as editmod from app.downloads import folders, formats, mediakind, quota, storage from app.downloads.status import HOLDING, JobKind, JobStatus -from app.models import DISPLAY_TEXT_MAX, Channel, PlexItem, PlexShow, User, Video +from app.models import DISPLAY_TEXT_MAX, PlexItem, PlexShow, User +from app.catalog.models import Channel, Video from app.downloads.models import ( DownloadJob, DownloadProfile, diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index e0b043cd..808dbe09 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -17,14 +17,14 @@ from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, s from sqlalchemy.orm import Session, aliased from app.collation import collated -from app.models import ( +from app.models import User +from app.catalog.models import ( LIVE_OR_UPCOMING, BlockedChannel, Channel, ExploredChannel, SearchFind, Subscription, - User, Video, ) from app.reader.models import ChannelTag, Playlist, PlaylistItem, Tag, VideoState diff --git a/backend/app/models.py b/backend/app/models.py index 64260cca..5a4efa71 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,5 @@ from datetime import date, datetime +from typing import TYPE_CHECKING from sqlalchemy import ( BigInteger, @@ -26,6 +27,9 @@ from app.db import Base from app.runstatus import RunStatus, RunTrigger, sql_in from app.utils import canonical_email +if TYPE_CHECKING: + from app.catalog.models import Subscription + # Every user-supplied display string in this schema shares one width, and it lives HERE so the # columns below are declared with it rather than with a literal: a name a person typed goes into # `User.display_name` (their Google profile name), `DownloadJob.display_name` and @@ -220,243 +224,6 @@ class Invite(Base, CanonicalEmailMixin): lang: Mapped[str | None] = mapped_column(String(8)) -class Channel(Base, TimestampMixin): - """A YouTube channel. Shared across all users (one channel's videos are the same - for everyone), so its expensive metadata is fetched and stored only once.""" - - __tablename__ = "channels" - - id: Mapped[str] = mapped_column(String(32), primary_key=True) # "UC..." channel id - title: Mapped[str | None] = mapped_column(String(255)) - handle: Mapped[str | None] = mapped_column(String(255)) - description: Mapped[str | None] = mapped_column(Text) - thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) - uploads_playlist_id: Mapped[str | None] = mapped_column(String(64)) - subscriber_count: Mapped[int | None] = mapped_column(BigInteger) - video_count: Mapped[int | None] = mapped_column(BigInteger) - # Channel "About" metadata (fetched with the same channels.list call, no extra quota): - # lifetime view count, the channel's join date, banner image, and external links. - total_view_count: Mapped[int | None] = mapped_column(BigInteger) - published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - banner_url: Mapped[str | None] = mapped_column(String(1024)) - external_links: Mapped[list | None] = mapped_column(JSON) - topic_categories: Mapped[list | None] = mapped_column(JSON) - default_language: Mapped[str | None] = mapped_column(String(16)) - country: Mapped[str | None] = mapped_column(String(8)) - # Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string, - # multi-word tags quoted; the client parses it into chips for the About tab. - keywords: Mapped[str | None] = mapped_column(Text) - - # Sync bookkeeping. - details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # When the newest uploads were last read through the Data API because the RSS feed would not - # answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock. - feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - backfill_cursor: Mapped[str | None] = mapped_column(String(128)) - backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - # True when this channel only entered the catalog because a live YouTube search - # surfaced one of its videos (nobody subscribes to it / it has no organic upload sync). - # Lets the Library hide search-only "noise" by default. - from_search: Mapped[bool] = mapped_column( - Boolean, default=False, server_default="false", index=True - ) - # True when this channel exists only because a user EXPLORED it (opened its page without - # subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a - # channel (and its via_explore videos) once nobody keeps it (no subscription, no live - # ExploredChannel row, no per-user state) after a grace period. Cleared when someone - # subscribes (the "keep" signal promotes it to organic). - from_explore: Mapped[bool] = mapped_column( - Boolean, default=False, server_default="false", index=True - ) - - videos: Mapped[list["Video"]] = relationship(back_populates="channel") - subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") - - -class Subscription(Base, TimestampMixin): - """Per-user link to a channel, with the user's own priority/visibility overrides.""" - - __tablename__ = "subscriptions" - __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),) - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - channel_id: Mapped[str] = mapped_column( - ForeignKey("channels.id", ondelete="CASCADE"), index=True - ) - # YouTube's subscription resource id, needed to unsubscribe via the API. - yt_subscription_id: Mapped[str | None] = mapped_column(String(128)) - priority: Mapped[int] = mapped_column(Integer, default=0, server_default="0") - hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - # Per-user opt-in to full-history (deep) backfill of this channel. Default off so a - # new user's unique channels don't trigger a big shared-quota burst; the deep - # scheduler only picks up channels at least one user has requested. - deep_requested: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - - user: Mapped["User"] = relationship(back_populates="subscriptions") - channel: Mapped["Channel"] = relationship(back_populates="subscriptions") - - -# Video.live_status values that mean "currently a live/upcoming broadcast" — transient states -# hidden from feeds/search and revisited by the refresh job until they settle into a VOD. -LIVE_OR_UPCOMING = ("live", "upcoming") - - -class Video(Base, TimestampMixin): - """A YouTube video. Shared across users; per-user state lives elsewhere.""" - - __tablename__ = "videos" - - id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id - # No standalone channel_id index: the composite `ix_videos_channel_pub_cover` - # (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it - # serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed - # sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only - # Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only). - # Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`. - channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) - title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles) - original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved - description: Mapped[str | None] = mapped_column(Text) - # NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key - # forced the feed's keyset predicate into a non-sargable `... OR published_at IS NULL` disjunction - # that blocked the ix_videos_published_at_desc range (deep pages scanned from the top and filtered — - # PERF-15). server_default now() is the safety net for the rare date-less ingest (the enrich fills - # the real date); the ix_videos_published_at index stays for the `oldest` sort. - published_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), index=True, nullable=False, server_default=func.now() - ) - thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) - # The feed's views / duration / title sorts each ride a migration-defined index in their exact - # ORDER BY, one per direction (and per ICU collation for title): migration 0096, held to - # `feedquery.order_by` by test_feed_sort_indexes. - duration_seconds: Mapped[int | None] = mapped_column(Integer) - view_count: Mapped[int | None] = mapped_column(BigInteger) - like_count: Mapped[int | None] = mapped_column(BigInteger) - category_id: Mapped[int | None] = mapped_column(Integer) - topic_categories: Mapped[list | None] = mapped_column(JSON) - default_language: Mapped[str | None] = mapped_column(String(16)) - detected_language: Mapped[str | None] = mapped_column(String(16)) - # Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the - # snippet we already request. Indexed into search_vector so the feed search matches the - # uploader's own keywords, not just words in the title. - keywords: Mapped[str | None] = mapped_column(Text) - # Accumulated distinct live-search queries that surfaced this video (across all users) — - # folds YouTube's own relevance judgement into the local search: a video YouTube returned - # for "ti amo magyarul" becomes findable by that query locally even if its title lacks it. - search_terms: Mapped[str | None] = mapped_column(Text) - # No btree here or on the flag columns below: migration 0069 (PERF-3) dropped the never-picked - # boolean btrees and replaced the useful ones with partial indexes over the target subset - # (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the - # search_vector GIN. - is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - # Whether the youtube.com/shorts/ probe has run for this video. - shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - # True when this video first entered the catalog via a live YouTube search (not an - # organic channel/playlist sync). Used to hide search-discovered "noise" from the - # Library by default; left False by the organic ingest paths. - via_search: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - # True when this video entered the catalog because a user EXPLORED its (un-subscribed) - # channel. Like via_search it's hidden from the shared Library by default, but it's - # additionally PER-USER private: a via_explore video is only visible to users who - # explored or subscribe to its channel (a gate in the feed query), so one user's - # curiosity never floods everyone else's catalog. Left False by the organic ingest paths. - via_explore: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - # none | live | upcoming | premiere | was_live - live_status: Mapped[str] = mapped_column( - String(16), default="none", server_default="none", index=True - ) - enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # videos.list?part=status fields (populated during enrichment); used by the - # maintenance/validation job to decide whether a video is playable anywhere. - embeddable: Mapped[bool | None] = mapped_column(Boolean) - privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private - upload_status: Mapped[str | None] = mapped_column( - String(16) - ) # processed|failed|rejected|deleted - # Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation - # (least-recently-checked first); unavailable_since marks a video as currently - # unplayable (hidden from the feed immediately, hard-deleted after a grace period); - # unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live). - # last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial - # ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the - # revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3). - last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - unavailable_reason: Mapped[str | None] = mapped_column(String(24)) - - # Weighted full-text search document (DB-generated, never written by the app): title (A) > - # creator keywords + search queries (B) > truncated description (C). ts_rank honours the - # weights so title matches outrank description matches. Backed by a GIN index (migration - # 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031. - search_vector: Mapped[object | None] = mapped_column( - TSVECTOR, - Computed( - "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " - "setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || " - "coalesce(search_terms, '')), 'B') || " - "setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')", - persisted=True, - ), - ) - - channel: Mapped["Channel"] = relationship(back_populates="videos") - - -class SearchFind(Base, TimestampMixin): - """Per-user record of a video the user surfaced via their own live YouTube search. - - Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source - filter — distinct from the GLOBAL `Video.via_search` provenance, which marks a video as - search-discovered by *anyone* and drives the shared Library's Source filter.""" - - __tablename__ = "search_finds" - __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_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) - - -class ExploredChannel(Base, TimestampMixin): - """Per-user record of a channel the user opened the page of WITHOUT subscribing. - - Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the - user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the - explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period - purge: a from_explore channel with no live ExploredChannel row (and no subscription / no - per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep" - signal — it promotes the channel to organic and these rows become irrelevant. `created_at` - (from TimestampMixin) is the grace clock; a re-visit refreshes it.""" - - __tablename__ = "explored_channels" - __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),) - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - channel_id: Mapped[str] = mapped_column( - ForeignKey("channels.id", ondelete="CASCADE"), index=True - ) - - -class BlockedChannel(Base, TimestampMixin): - """Per-user channel blocklist. Videos from a blocked channel are excluded from this user's - live YouTube search (and not ingested on their behalf) and hidden from their feed / explore - views — a personal "never show me this channel again" that doesn't affect other users.""" - - __tablename__ = "blocked_channels" - __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),) - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - channel_id: Mapped[str] = mapped_column( - ForeignKey("channels.id", ondelete="CASCADE"), index=True - ) - - class ApiQuotaUsage(Base): """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at midnight Pacific). The whole app shares one daily budget.""" diff --git a/backend/app/reader/queries.py b/backend/app/reader/queries.py index 9cff9fa9..80beaac5 100644 --- a/backend/app/reader/queries.py +++ b/backend/app/reader/queries.py @@ -11,7 +11,8 @@ from sqlalchemy import ColumnElement, and_, func, or_, select from sqlalchemy.orm import InstrumentedAttribute, Session, aliased from app.feedquery import feed_columns -from app.models import Channel, User, Video +from app.models import User +from app.catalog.models import Channel, Video from app.reader.models import ChannelTag, Playlist, PlaylistItem, SavedView, Tag, VideoState from app.userscope import owned_by, owned_row diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 2df0d9ab..f681ab87 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -15,13 +15,13 @@ from app.mailtext import lang_for_user from app import logs, quota, scheduler from app.auth import admin_user, current_user, require_write_scope from app.db import get_db -from app.models import ( +from app.models import User +from app.catalog.models import ( LIVE_OR_UPCOMING, BlockedChannel, Channel, ExploredChannel, Subscription, - User, Video, ) from app.reader.models import ChannelTag, Playlist, PlaylistItem, Tag diff --git a/backend/app/routes/runs.py b/backend/app/routes/runs.py index c1bb53d6..466a38be 100644 --- a/backend/app/routes/runs.py +++ b/backend/app/routes/runs.py @@ -18,7 +18,8 @@ from sqlalchemy.orm import Session from app import scheduler from app.auth import admin_user from app.db import get_db -from app.models import Channel, JobRun, LogEvent, User +from app.models import JobRun, LogEvent, User +from app.catalog.models import Channel from app.routes import _account as account from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc from app.runstatus import RunStatus diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index db0f5308..76172806 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -12,7 +12,8 @@ from app.audit import AuditAction from app.auth import admin_user from app.config import settings from app.db import get_db -from app.models import Channel, Subscription, User, Video +from app.models import User +from app.catalog.models import Channel, Subscription, Video from app.scheduler import ( JOB_INTERVALS, MAX_INTERVAL, diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index e7b96b8c..33d3188b 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -25,13 +25,13 @@ from sqlalchemy.orm import Session, aliased from app import logs, quota, sysconfig from app.auth import current_user from app.db import get_db -from app.models import ( +from app.models import User +from app.catalog.models import ( LIVE_OR_UPCOMING, BlockedChannel, Channel, SearchFind, Subscription, - User, Video, ) from app.reader.models import Playlist, PlaylistItem, VideoState diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index f4a04f95..64bc0762 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -6,7 +6,8 @@ from app import audit, quota, state from app.audit import AuditAction from app.auth import admin_user, current_user, has_read_scope from app.db import get_db -from app.models import Channel, Subscription, User, Video +from app.models import User +from app.catalog.models import Channel, Subscription, Video from app.routes._common import youtube_refusal from app.ratelimit import RateLimiter from app.sync.runner import estimate_deep_backfill diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index d7e4c82b..57e1147b 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -15,7 +15,8 @@ from app import audit, debugsession, jobruns, logs, logsink, progress, quota, sy from app.config import settings from app.db import SessionLocal from app.downloads.gc import run_download_gc -from app.models import Channel, JobRun +from app.models import JobRun +from app.catalog.models import Channel from app.plex.sync import sync as run_plex_sync from app.plex.tmdb import run_enrich as run_tmdb_enrich from app.plex.tmdb import run_people_enrich diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py index 2c92aba5..c84e6e2e 100644 --- a/backend/app/sync/autotag.py +++ b/backend/app/sync/autotag.py @@ -12,7 +12,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app import logs, progress, sysconfig -from app.models import Channel, Video +from app.catalog.models import Channel, Video from app.reader import queries as reader_queries from app.reader import service as reader_service diff --git a/backend/app/sync/explore.py b/backend/app/sync/explore.py index 51e9f075..d3257e24 100644 --- a/backend/app/sync/explore.py +++ b/backend/app/sync/explore.py @@ -14,7 +14,7 @@ from sqlalchemy import delete, or_, select from sqlalchemy.orm import Session from app import logs, sysconfig -from app.models import Channel, ExploredChannel, SearchFind, Subscription, Video +from app.catalog.models import Channel, ExploredChannel, SearchFind, Subscription, Video from app.reader import queries as reader_queries 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 438180ed..cb4e5104 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -27,7 +27,7 @@ from sqlalchemy.orm import Session from app import logs, progress, quota, sysconfig from app.config import settings -from app.models import Video +from app.catalog.models import Video from app.reader import queries as reader_queries from app.notifications import create_notification from app.sync.runner import get_service_user diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 2ab76b95..334cfb44 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -20,7 +20,8 @@ from sqlalchemy.pool import NullPool from app import logs, progress, quota from app.auth import has_read_scope -from app.models import User, Video +from app.models import User +from app.catalog.models import Video from app.reader.models import Playlist, PlaylistItem from app.sync.playlist_merge import merge_plan from app.sync.runner import token_users diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index d13e904b..0e8afaaa 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -16,7 +16,8 @@ from sqlalchemy.orm import Session from app import logs, progress, quota, sysconfig from app.db import SessionLocal -from app.models import Channel, OAuthToken, Subscription, User +from app.models import OAuthToken, User +from app.catalog.models import Channel, Subscription from app.sync.explore import followed_channel from app.sync.subscriptions import import_subscriptions from app.sync.videos import ( diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index d715f677..79a66364 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -7,7 +7,8 @@ from sqlalchemy import or_, select from sqlalchemy.orm import Session from app import logs -from app.models import Channel, Subscription, User +from app.models import User +from app.catalog.models import Channel, Subscription from app.utils import normalize_description from app.youtube.client import YouTubeClient, YouTubeError, best_thumbnail diff --git a/backend/app/sync/tagsuggest.py b/backend/app/sync/tagsuggest.py index d1693f1c..47ff25c8 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 Subscription, User +from app.models import User +from app.catalog.models import Subscription from app.reader.models import ChannelTag, Tag from app.userscope import owned_by diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index a87f9d7a..6ae40983 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -12,7 +12,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app import logs, progress, quota, sysconfig -from app.models import LIVE_OR_UPCOMING, Channel, Video +from app.catalog.models import LIVE_OR_UPCOMING, Channel, Video from app.titles import normalize_title from app.youtube.client import YouTubeClient, YouTubeError, best_thumbnail, is_playlist_gone from app.youtube.shorts import make_client, probe_is_short diff --git a/backend/app/tables.py b/backend/app/tables.py index 62a66863..183b7e7e 100644 --- a/backend/app/tables.py +++ b/backend/app/tables.py @@ -5,6 +5,7 @@ names the whole schema. Whatever needs all of it — the migrations, the test da every mapper — imports this module. """ +import app.catalog.models # Catalog's tables import app.downloads.models # Capture's tables import app.models # the core tables import app.reader.models # noqa: F401 — Reader's tables diff --git a/backend/tests/test_activity_reads.py b/backend/tests/test_activity_reads.py index 78e97ef7..c4df1b02 100644 --- a/backend/tests/test_activity_reads.py +++ b/backend/tests/test_activity_reads.py @@ -9,7 +9,8 @@ from datetime import datetime, timedelta, timezone from app.auth import admin_user from app.main import app -from app.models import AuditLog, Channel, JobRun, LogEvent, QuotaEvent +from app.models import AuditLog, JobRun, LogEvent, QuotaEvent +from app.catalog.models import Channel from tests.test_body_limit import _route_objects T0 = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc) diff --git a/backend/tests/test_autotag_orphans.py b/backend/tests/test_autotag_orphans.py index c4168e49..b525f18f 100644 --- a/backend/tests/test_autotag_orphans.py +++ b/backend/tests/test_autotag_orphans.py @@ -7,7 +7,7 @@ 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 +from app.catalog.models import Channel from app.reader.models import ChannelTag, SavedView, Tag from app.reader import service as reader_service diff --git a/backend/tests/test_channel_backfill.py b/backend/tests/test_channel_backfill.py index 6e00567c..cd813e7f 100644 --- a/backend/tests/test_channel_backfill.py +++ b/backend/tests/test_channel_backfill.py @@ -12,7 +12,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, Subscription, User, Video +from app.models import User +from app.catalog.models import Channel, Subscription, Video from app.sync import runner, videos _PUB = datetime(2020, 1, 1, tzinfo=timezone.utc) diff --git a/backend/tests/test_channel_details_refresh.py b/backend/tests/test_channel_details_refresh.py index f4906cf1..379c57ad 100644 --- a/backend/tests/test_channel_details_refresh.py +++ b/backend/tests/test_channel_details_refresh.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone import pytest -from app.models import Channel, Subscription +from app.catalog.models import Channel, Subscription from app.routes import channels from app.sync import subscriptions from app.sync.subscriptions import DETAILS_MAX_AGE, import_subscriptions diff --git a/backend/tests/test_channel_list_index_only.py b/backend/tests/test_channel_list_index_only.py index 670b153c..397a8555 100644 --- a/backend/tests/test_channel_list_index_only.py +++ b/backend/tests/test_channel_list_index_only.py @@ -14,7 +14,7 @@ from sqlalchemy import text from sqlalchemy.dialects import postgresql from app.feedquery import filtered_query -from app.models import Video +from app.catalog.models import Video from app.routes.channels import channel_aggregates INDEX = "ix_videos_channel_pub_cover" diff --git a/backend/tests/test_channel_subscribe_upstream.py b/backend/tests/test_channel_subscribe_upstream.py index a7f6331d..8a5100ff 100644 --- a/backend/tests/test_channel_subscribe_upstream.py +++ b/backend/tests/test_channel_subscribe_upstream.py @@ -9,7 +9,7 @@ import pytest from fastapi import HTTPException from sqlalchemy import select -from app.models import Channel, Subscription +from app.catalog.models import Channel, Subscription from app.routes import channels from app.youtube.client import YouTubeError diff --git a/backend/tests/test_channel_tags.py b/backend/tests/test_channel_tags.py index 2ae70dd8..b8195467 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, User +from app.models import User +from app.catalog.models import Channel from app.reader.models import ChannelTag, Tag diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py index 6b492959..3a84999b 100644 --- a/backend/tests/test_clip.py +++ b/backend/tests/test_clip.py @@ -18,7 +18,8 @@ from app.downloads import errors as dlerrors from app.downloads import formats, pipeline, quota, runner, service from app.downloads import probe as probemod from app.downloads.edit import INPUT_GUARD -from app.models import Channel, PlexItem, PlexLibrary, PlexShow, Video +from app.models import PlexItem, PlexLibrary, PlexShow +from app.catalog.models import Channel, Video from app.downloads.models import DownloadJob, MediaAsset from app.plex import paths as plex_paths diff --git a/backend/tests/test_collation.py b/backend/tests/test_collation.py index 676526be..41d0e6a8 100644 --- a/backend/tests/test_collation.py +++ b/backend/tests/test_collation.py @@ -9,7 +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, PlexCollection, PlexItem, PlexLibrary, Subscription, User, Video +from app.models import PlexCollection, PlexItem, PlexLibrary, User +from app.catalog.models import Channel, Subscription, Video from app.reader.models import Playlist, PlaylistItem from datetime import datetime, timezone @@ -222,7 +223,7 @@ def test_the_share_recipients_are_in_the_readers_alphabet(db, monkeypatch, overr def test_the_blocked_channels_are_in_the_readers_alphabet(db, monkeypatch, override_dep): - from app.models import BlockedChannel + from app.catalog.models import BlockedChannel user = _user(db, "hu") db.add_all(Channel(id=f"UC{i}", title=w) for i, w in enumerate(WORDS)) diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index 607940a0..49eb0eb5 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -17,17 +17,8 @@ from app.discover import hero, pins, queries, rails from app.discover.budget import BANDS, fits, remaining_seconds from app.plex import rollup from app.plex.cards import leaf_card -from app.models import ( - BlockedChannel, - Channel, - PlexItem, - PlexLibrary, - PlexShow, - PlexState, - Subscription, - User, - Video, -) +from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User +from app.catalog.models import BlockedChannel, Channel, Subscription, Video 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 b8823ad1..bcd868ed 100644 --- a/backend/tests/test_empty_listing_guards.py +++ b/backend/tests/test_empty_listing_guards.py @@ -12,7 +12,8 @@ contradicts the remote's own count is not believed either. import pytest from sqlalchemy import select -from app.models import Channel, PlexCollection, PlexItem, PlexLibrary, Video +from app.models import PlexCollection, PlexItem, PlexLibrary +from app.catalog.models import Channel, Video from app.reader.models import Playlist, PlaylistItem from app.plex import sync as plex_sync from app.sync import playlists as plsync diff --git a/backend/tests/test_feed_fallback.py b/backend/tests/test_feed_fallback.py index 54d7bb89..f002704a 100644 --- a/backend/tests/test_feed_fallback.py +++ b/backend/tests/test_feed_fallback.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone import pytest from app import sysconfig -from app.models import Channel, Video +from app.catalog.models import Channel, Video from app.sync import runner from app.youtube.client import YouTubeError diff --git a/backend/tests/test_feed_keyset.py b/backend/tests/test_feed_keyset.py index 1153edeb..ce5221cc 100644 --- a/backend/tests/test_feed_keyset.py +++ b/backend/tests/test_feed_keyset.py @@ -16,7 +16,8 @@ from datetime import datetime, timezone import pytest -from app.models import Channel, Subscription, User, Video +from app.models import User +from app.catalog.models import Channel, Subscription, Video # Every sort feedquery.sort_keys defines except "relevance" (which needs a search query to rank). _SORTS = [ diff --git a/backend/tests/test_feed_relevance_window.py b/backend/tests/test_feed_relevance_window.py index bd1b1dee..54aa5f4a 100644 --- a/backend/tests/test_feed_relevance_window.py +++ b/backend/tests/test_feed_relevance_window.py @@ -12,7 +12,8 @@ from datetime import datetime, timezone import pytest from app import feedquery -from app.models import Channel, Subscription, User, Video +from app.models import User +from app.catalog.models import Channel, Subscription, Video def _video(vid: str, day: int, title: str) -> Video: diff --git a/backend/tests/test_feed_sort_indexes.py b/backend/tests/test_feed_sort_indexes.py index d0f5943a..9869ce04 100644 --- a/backend/tests/test_feed_sort_indexes.py +++ b/backend/tests/test_feed_sort_indexes.py @@ -17,7 +17,7 @@ from sqlalchemy.dialects import postgresql from app import collation from app.db import engine from app.feedquery import order_by, sort_keys -from app.models import Video +from app.catalog.models import Video VIDEO_SORTS = [ "newest", diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index 8e412d35..b9e7314f 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -14,7 +14,7 @@ import pytest from app import feedquery -from app.models import Channel, Subscription, Video +from app.catalog.models import Channel, Subscription, Video from app.reader.models import ChannelTag, Tag, VideoState diff --git a/backend/tests/test_followed_channels.py b/backend/tests/test_followed_channels.py index 5b914695..f6d4aedf 100644 --- a/backend/tests/test_followed_channels.py +++ b/backend/tests/test_followed_channels.py @@ -7,7 +7,7 @@ by someone. The admin card counting "channels to sync" must use the same selecti channels the backfill will never touch. DB lane. """ -from app.models import Channel, ExploredChannel, Subscription +from app.catalog.models import Channel, ExploredChannel, Subscription from app.sync import runner diff --git a/backend/tests/test_like_literals.py b/backend/tests/test_like_literals.py index 217ee86d..fc763e1d 100644 --- a/backend/tests/test_like_literals.py +++ b/backend/tests/test_like_literals.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from sqlalchemy import select -from app.models import Channel, Subscription, Video +from app.catalog.models import Channel, Subscription, Video from app.routes.search import _fold_term diff --git a/backend/tests/test_maintenance_lifecycle.py b/backend/tests/test_maintenance_lifecycle.py index ae77147c..df85c25f 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, Video +from app.models import Notification +from app.catalog.models import Channel, Video from app.reader.models import Playlist, PlaylistItem from app.sync import maintenance diff --git a/backend/tests/test_my_status_deep.py b/backend/tests/test_my_status_deep.py index 891fa74d..2798e5af 100644 --- a/backend/tests/test_my_status_deep.py +++ b/backend/tests/test_my_status_deep.py @@ -3,7 +3,7 @@ ones this user asked full history for. The numerator used to count every subscri back-catalog was complete (another user had asked, or the channel was short), so the page read "342/329". "Channels without full history yet" still counts every subscribed channel. Needs the DB.""" -from app.models import Channel, Subscription +from app.catalog.models import Channel, Subscription def test_full_history_ratio_counts_only_the_requested_channels(db, make_user, as_user): diff --git a/backend/tests/test_playlist_add_position.py b/backend/tests/test_playlist_add_position.py index 7ae6c125..33532ad2 100644 --- a/backend/tests/test_playlist_add_position.py +++ b/backend/tests/test_playlist_add_position.py @@ -8,7 +8,7 @@ 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, Video +from app.catalog.models import Channel, Video from app.reader.models import Playlist, PlaylistItem diff --git a/backend/tests/test_playlist_merge.py b/backend/tests/test_playlist_merge.py index 04f0f718..1bcb5eca 100644 --- a/backend/tests/test_playlist_merge.py +++ b/backend/tests/test_playlist_merge.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone import pytest -from app.models import Channel, Video +from app.catalog.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 diff --git a/backend/tests/test_playlist_move.py b/backend/tests/test_playlist_move.py index 315834a2..d0b74c58 100644 --- a/backend/tests/test_playlist_move.py +++ b/backend/tests/test_playlist_move.py @@ -16,7 +16,7 @@ from datetime import datetime, timedelta, timezone import pytest from sqlalchemy.exc import OperationalError -from app.models import Channel, Video +from app.catalog.models import Channel, Video from app.reader.models import Playlist, PlaylistItem diff --git a/backend/tests/test_playlist_position_lock.py b/backend/tests/test_playlist_position_lock.py index de18638c..95ee1ba7 100644 --- a/backend/tests/test_playlist_position_lock.py +++ b/backend/tests/test_playlist_position_lock.py @@ -19,7 +19,8 @@ from datetime import datetime, timezone import pytest from sqlalchemy import event -from app.models import Channel, PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem, Video +from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem +from app.catalog.models import Channel, Video from app.reader.models import Playlist, PlaylistItem diff --git a/backend/tests/test_playlist_sync_lock_order.py b/backend/tests/test_playlist_sync_lock_order.py index 65fc97ae..d161e2af 100644 --- a/backend/tests/test_playlist_sync_lock_order.py +++ b/backend/tests/test_playlist_sync_lock_order.py @@ -18,7 +18,7 @@ import pytest from sqlalchemy import delete, event, text from app.db import SessionLocal -from app.models import Channel, Video +from app.catalog.models import Channel, Video from app.reader.models import Playlist, PlaylistItem from app.sync import playlists as pl_mod diff --git a/backend/tests/test_reader_service.py b/backend/tests/test_reader_service.py index bf51d5b8..f62d3d01 100644 --- a/backend/tests/test_reader_service.py +++ b/backend/tests/test_reader_service.py @@ -14,7 +14,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from app.discover import pins -from app.models import Channel, Video +from app.catalog.models import Channel, Video from app.reader import queries, service from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index 32707328..8f795b46 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -13,7 +13,8 @@ import pytest from sqlalchemy import select from app import jobruns, logs, logsink, quota, scheduler, state, sysconfig -from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent +from app.models import AuditLog, JobRun, Notification, QuotaEvent +from app.catalog.models import Channel from app.routes import scheduler as scheduler_routes from app.runstatus import RunStatus, RunTrigger diff --git a/backend/tests/test_search_scrape_ingest.py b/backend/tests/test_search_scrape_ingest.py index 7961bcb2..90da002c 100644 --- a/backend/tests/test_search_scrape_ingest.py +++ b/backend/tests/test_search_scrape_ingest.py @@ -14,7 +14,8 @@ a placeholder never becomes the video's permanent date. from datetime import datetime, timezone from app import sysconfig -from app.models import User, Video +from app.models import User +from app.catalog.models import Video from app.routes.search import _ingest_candidates diff --git a/backend/tests/test_tag_suggest.py b/backend/tests/test_tag_suggest.py index 4439c5ff..fabf7066 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, Subscription, User +from app.models import User +from app.catalog.models import Channel, Subscription from app.reader.models import ChannelTag, Tag from app.sync.tagsuggest import ( AUTOAPPLY_THRESHOLD, diff --git a/backend/tests/test_youtube_refusals.py b/backend/tests/test_youtube_refusals.py index 2ec5c619..94d51dbd 100644 --- a/backend/tests/test_youtube_refusals.py +++ b/backend/tests/test_youtube_refusals.py @@ -8,7 +8,8 @@ import httpx import pytest from app.auth import has_read_scope -from app.models import Channel, OAuthToken +from app.models import OAuthToken +from app.catalog.models import Channel from app.sync.explore import explore_ingest_page from app.sync.videos import backfill_channel_deep, backfill_channel_recent from app.youtube import client as yt diff --git a/scripts/e2e_seed.py b/scripts/e2e_seed.py index 59d3a2bb..e0c14551 100644 --- a/scripts/e2e_seed.py +++ b/scripts/e2e_seed.py @@ -24,13 +24,8 @@ sys.path.insert(0, "/app") from sqlalchemy import delete, func, select from app.db import SessionLocal -from app.models import ( - AppState, - Channel, - Subscription, - User, - Video, -) +from app.catalog.models import Channel, Subscription, Video +from app.models import AppState, User from app.reader.models import Playlist, PlaylistItem, VideoState from app.security import hash_password