E24 S1. The anti-paralysis answer layer, served from our own Postgres with no upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's own comfort shelf. Survey first, because the roadmap line was wrong about the starting point: Plex already ships the whole trio (E5's Discover rails, including a reshuffling Surprise me), the YouTube side ships none of it, and nothing anywhere answers the question across both. The data already existed on both sides — VideoState and PlexState are structurally identical, the feed has an `in_progress` filter and a seeded shuffle, and the YT player already resumes position. - app/discover/budget.py — the time bands. "How much time do you have" (10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire: deterministic, answerable from a column we have, and it picks the source for free. Matched against REMAINING time, so a film with 8 minutes left is a ten-minute answer. An unknown runtime fits only the unbounded band. - app/discover/cards.py — a thin unified card that carries the NATIVE feed / Plex payload, so the page renders with the existing card components instead of a third one. `aspect` is the one new field: a rail needs to know poster from 16:9, and that is a property of the artwork, not of the module. - app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a surface that has a sidebar. One seed drives both halves so a reroll moves them together. - app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in the payload so the client labels it honestly instead of guessing. - comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1 rather than a flag or a polymorphic string ref. A flag cannot order a shelf or hold a channel; a string ref would let a deleted library item leave a dead tile on the one surface whose whole job is to be reliable. Two honest-state rules the tests pin: the surprise draw excludes anything already started (the resume tier may have skipped it for not fitting, and offering it back as "Meglepetés" would contradict the question), and which source leads the draw follows the seed, so the Plex library can actually answer "egy este". Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770, pytest 987.
79 lines
3.5 KiB
Python
79 lines
3.5 KiB
Python
"""comfort_pins — the shelf you fall back to when you cannot decide
|
|
|
|
Revision ID: 0083_comfort_pins
|
|
Revises: 0082_invite_lang
|
|
Create Date: 2026-09-06
|
|
|
|
E24's one new table. The shelf holds five different things — a video, a YouTube channel, a Plex show,
|
|
a Plex movie, a playlist — because comfort media is usually "put on X", a series or a channel, and
|
|
almost never one particular video.
|
|
|
|
Three shapes were possible and two are wrong here:
|
|
|
|
- a boolean on `video_states` / `plex_states` cannot ORDER a shelf, and has nowhere to put a channel
|
|
or a show (neither has a per-user state row at all);
|
|
- a polymorphic `(kind, ref)` string pair orders and holds anything, but drops the foreign key — and a
|
|
Plex item removed from the library, or a channel the user unsubscribes and prunes, would then leave
|
|
a dead tile on the one surface whose entire job is to be reliable when nothing else is.
|
|
|
|
So: one nullable CASCADE-ing FK per kind, plus `num_nonnulls(...) = 1`. The union invariant lives in
|
|
the schema rather than in a comment at every insert site, which is the same rule that the
|
|
`cancelled`/`canceled` bug taught this codebase.
|
|
|
|
NULLs are DISTINCT in a Postgres unique index, so the five per-kind `(user_id, <ref>)` uniques each
|
|
stop a double-pin without colliding with the other four kinds' NULL rows.
|
|
|
|
No backfill: an empty shelf is the honest state for every existing account. The surface renders its
|
|
own empty state and invites the first pin.
|
|
"""
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
revision: str = "0083_comfort_pins"
|
|
down_revision: str | None = "0082_invite_lang"
|
|
branch_labels: str | None = None
|
|
depends_on: str | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"comfort_pins",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column(
|
|
"user_id",
|
|
sa.Integer(),
|
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("video_id", sa.String(length=16), sa.ForeignKey("videos.id", ondelete="CASCADE")),
|
|
sa.Column(
|
|
"channel_id", sa.String(length=32), sa.ForeignKey("channels.id", ondelete="CASCADE")
|
|
),
|
|
sa.Column("plex_item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE")),
|
|
sa.Column("plex_show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE")),
|
|
sa.Column("playlist_id", sa.Integer(), sa.ForeignKey("playlists.id", ondelete="CASCADE")),
|
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.func.now(),
|
|
nullable=False,
|
|
),
|
|
sa.CheckConstraint(
|
|
"num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1",
|
|
name="ck_comfort_pin_one_ref",
|
|
),
|
|
sa.UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"),
|
|
sa.UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"),
|
|
sa.UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"),
|
|
sa.UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"),
|
|
sa.UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"),
|
|
)
|
|
op.create_index("ix_comfort_pins_user_id", "comfort_pins", ["user_id"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index("ix_comfort_pins_user_id", table_name="comfort_pins")
|
|
op.drop_table("comfort_pins")
|