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.
This commit is contained in:
2026-10-19 00:53:44 +02:00
parent e9c60f72cd
commit e82f87acb0
35 changed files with 262 additions and 274 deletions
+2 -1
View File
@@ -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.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. # Plex artwork is a poster for the things you browse and a landscape still for the things you play.
_POSTER_KINDS = ("movie", "show") _POSTER_KINDS = ("movie", "show")
+2 -1
View File
@@ -22,7 +22,8 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.discover import cards, queries 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 import rollup
from app.plex.cards import SHOW_NEW, leaf_card, show_card from app.plex.cards import SHOW_NEW, leaf_card, show_card
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
+2 -10
View File
@@ -29,16 +29,8 @@ from app.feedquery import (
serialize, serialize,
sort_keys, sort_keys,
) )
from app.models import ( from app.models import Channel, PlexItem, PlexShow, PlexState, User, Video
Channel, from app.reader.models import PlaylistItem, VideoState
PlaylistItem,
PlexItem,
PlexShow,
PlexState,
User,
Video,
VideoState,
)
from app.plex import rollup from app.plex import rollup
from app.plex.cards import display_title, leaf_card, movie_card, show_card from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.query import apply_show_state_filter, content_libs from app.plex.query import apply_show_state_filter, content_libs
+1 -5
View File
@@ -20,17 +20,13 @@ from app.models import (
LIVE_OR_UPCOMING, LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
ChannelTag,
ExploredChannel, ExploredChannel,
Playlist,
PlaylistItem,
SearchFind, SearchFind,
Subscription, Subscription,
Tag,
User, User,
Video, Video,
VideoState,
) )
from app.reader.models import ChannelTag, Playlist, PlaylistItem, Tag, VideoState
from app.utils import iso, like_escape, to_tsquery_str from app.utils import iso, like_escape, to_tsquery_str
-180
View File
@@ -406,64 +406,6 @@ class Video(Base, TimestampMixin):
channel: Mapped["Channel"] = relationship(back_populates="videos") 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): class SearchFind(Base, TimestampMixin):
"""Per-user record of a video the user surfaced via their own live YouTube search. """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): class Notification(Base):
"""A durable, server-backed per-user notification (the inbox center, phase 1). """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 ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
) )
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) 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)
View File
+204
View File
@@ -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)
+1 -4
View File
@@ -19,15 +19,12 @@ from app.models import (
LIVE_OR_UPCOMING, LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
ChannelTag,
ExploredChannel, ExploredChannel,
Playlist,
PlaylistItem,
Subscription, Subscription,
Tag,
User, User,
Video, 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.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.sync.explore import explore_ingest_page from app.sync.explore import explore_ingest_page
from app.sync.runner import backfill_channel_now, run_recent_backfill from app.sync.runner import backfill_channel_now, run_recent_backfill
+2 -1
View File
@@ -22,7 +22,8 @@ from app.db import get_db
from app.discover import hero as hero_mod from app.discover import hero as hero_mod
from app.discover import pins, rails from app.discover import pins, rails
from app.discover.budget import Budget, DEFAULT_BUDGET 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.plex.cards import norm_lang
from app.routes._common import owned_or_404, not_found from app.routes._common import owned_or_404, not_found
+2 -7
View File
@@ -25,13 +25,8 @@ from app.feedquery import (
windowed_rank, windowed_rank,
windowed_relevance_rows, windowed_relevance_rows,
) )
from app.models import ( from app.models import User, Video
ChannelTag, from app.reader.models import ChannelTag, Tag, VideoState
Tag,
User,
Video,
VideoState,
)
from app.routes._common import ( from app.routes._common import (
ProgressIn, ProgressIn,
get_or_404, get_or_404,
+2 -1
View File
@@ -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.auth import current_user, has_read_scope, has_write_scope, require_write_scope
from app.db import get_db from app.db import get_db
from app.feedquery import feed_columns, serialize 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 ( from app.sync.playlists import (
PlaylistGone, PlaylistGone,
lock_playlists, lock_playlists,
+2 -1
View File
@@ -6,7 +6,8 @@ from sqlalchemy.orm import Session
from app.auth import current_user from app.auth import current_user
from app.db import get_db 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.routes._common import owned_or_404
from app.refusal import refused from app.refusal import refused
+1 -3
View File
@@ -29,14 +29,12 @@ from app.models import (
LIVE_OR_UPCOMING, LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
Playlist,
PlaylistItem,
SearchFind, SearchFind,
Subscription, Subscription,
User, User,
Video, Video,
VideoState,
) )
from app.reader.models import Playlist, PlaylistItem, VideoState
from app.feedquery import feed_columns, serialize from app.feedquery import feed_columns, serialize
from app.utils import like_escape from app.utils import like_escape
from app.sync.subscriptions import apply_channel_details from app.sync.subscriptions import apply_channel_details
+2 -1
View File
@@ -6,7 +6,8 @@ from sqlalchemy.orm import Session
from app.auth import current_user from app.auth import current_user
from app.db import get_db 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.routes._common import owned_or_404
from app.sync.tagsuggest import compute_tag_suggestions from app.sync.tagsuggest import compute_tag_suggestions
from app.refusal import refused from app.refusal import refused
+2 -1
View File
@@ -12,7 +12,8 @@ from sqlalchemy import and_, exists, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, progress, sysconfig 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__) log = logs.get_logger(__name__)
+2 -9
View File
@@ -14,15 +14,8 @@ from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.models import ( from app.models import Channel, ExploredChannel, SearchFind, Subscription, Video
Channel, from app.reader.models import PlaylistItem, VideoState
ExploredChannel,
PlaylistItem,
SearchFind,
Subscription,
Video,
VideoState,
)
from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details
from app.youtube.client import YouTubeClient, YouTubeError, is_playlist_gone from app.youtube.client import YouTubeClient, YouTubeError, is_playlist_gone
+2 -1
View File
@@ -27,7 +27,8 @@ from sqlalchemy.orm import Session
from app import logs, progress, quota, sysconfig from app import logs, progress, quota, sysconfig
from app.config import settings 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.notifications import create_notification
from app.sync.runner import get_service_user from app.sync.runner import get_service_user
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
+2 -1
View File
@@ -21,7 +21,8 @@ from sqlalchemy.pool import NullPool
from app import logs, progress, quota from app import logs, progress, quota
from app.auth import has_read_scope 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.playlist_merge import merge_plan
from app.sync.runner import token_users from app.sync.runner import token_users
from app.sync.subscriptions import blank_listing, refuse_blank_listing, vanished from app.sync.subscriptions import blank_listing, refuse_blank_listing, vanished
+2 -1
View File
@@ -23,7 +23,8 @@ from collections import Counter
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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 # 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"). # profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming").
+2 -1
View File
@@ -6,4 +6,5 @@ every mapper — imports this module.
""" """
import app.downloads.models # Capture's tables 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
+1 -1
View File
@@ -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 # 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 # 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. # 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 from app.routes import playlists as plroutes
user = make_user() user = make_user()
+2 -1
View File
@@ -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 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 from app.sync import autotag
+2 -1
View File
@@ -14,7 +14,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app 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): def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch, override_dep):
+2 -11
View File
@@ -9,17 +9,8 @@ from app.auth import current_user
from app.collation import sorted_words from app.collation import sorted_words
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import ( from app.models import Channel, PlexCollection, PlexItem, PlexLibrary, Subscription, User, Video
Channel, from app.reader.models import Playlist, PlaylistItem
Playlist,
PlaylistItem,
PlexCollection,
PlexItem,
PlexLibrary,
Subscription,
User,
Video,
)
from datetime import datetime, timezone from datetime import datetime, timezone
WORDS = ["Zorró", "Életrajzi", "Ötödik", "oroszlán", "Csillag", "Cukor"] WORDS = ["Zorró", "Életrajzi", "Ötödik", "oroszlán", "Csillag", "Cukor"]
+1 -4
View File
@@ -20,18 +20,15 @@ from app.plex.cards import leaf_card
from app.models import ( from app.models import (
BlockedChannel, BlockedChannel,
Channel, Channel,
ComfortPin,
PlexItem, PlexItem,
PlexLibrary, PlexLibrary,
PlexShow, PlexShow,
PlexState, PlexState,
Playlist,
PlaylistItem,
Subscription, Subscription,
User, User,
Video, Video,
VideoState,
) )
from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState
NOW = datetime(2026, 6, 1, tzinfo=timezone.utc) NOW = datetime(2026, 6, 1, tzinfo=timezone.utc)
+2 -9
View File
@@ -12,15 +12,8 @@ contradicts the remote's own count is not believed either.
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from app.models import ( from app.models import Channel, PlexCollection, PlexItem, PlexLibrary, Video
Channel, from app.reader.models import Playlist, PlaylistItem
Playlist,
PlaylistItem,
PlexCollection,
PlexItem,
PlexLibrary,
Video,
)
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
from app.sync import playlists as plsync from app.sync import playlists as plsync
from app.youtube.client import YouTubeError from app.youtube.client import YouTubeError
+2 -1
View File
@@ -14,7 +14,8 @@ import pytest
from app import feedquery 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: def _dt(day: int) -> datetime:
+2 -1
View File
@@ -12,7 +12,8 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy import select 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 from app.sync import maintenance
+2 -1
View File
@@ -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 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): def _seed(db, user, ids, *, positions=None, **kw):
+2 -1
View File
@@ -10,7 +10,8 @@ from datetime import datetime, timezone
import pytest 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 import playlists as pl_mod
from app.sync.playlist_merge import merge_plan from app.sync.playlist_merge import merge_plan
from app.sync.playlists import ( from app.sync.playlists import (
+2 -1
View File
@@ -16,7 +16,8 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy.exc import OperationalError 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): def _seed_videos(db, ids):
+2 -10
View File
@@ -19,16 +19,8 @@ from datetime import datetime, timezone
import pytest import pytest
from sqlalchemy import event from sqlalchemy import event
from app.models import ( from app.models import Channel, PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem, Video
Channel, from app.reader.models import Playlist, PlaylistItem
Playlist,
PlaylistItem,
PlexItem,
PlexLibrary,
PlexPlaylist,
PlexPlaylistItem,
Video,
)
@pytest.fixture @pytest.fixture
+1 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import text
from app.auth import require_write_scope from app.auth import require_write_scope
from app.db import SessionLocal 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.routes import playlists as plroutes
from app.sync import playlists as plsync from app.sync import playlists as plsync
@@ -18,7 +18,8 @@ import pytest
from sqlalchemy import delete, event, text from sqlalchemy import delete, event, text
from app.db import SessionLocal 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 from app.sync import playlists as pl_mod
ITEMS = PlaylistItem.__table__.name ITEMS = PlaylistItem.__table__.name
+2 -1
View File
@@ -11,7 +11,8 @@ network, pure DB state.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. 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 ( from app.sync.tagsuggest import (
AUTOAPPLY_THRESHOLD, AUTOAPPLY_THRESHOLD,
CHAR_SUPPORT, CHAR_SUPPORT,