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:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user