style: apply ruff format across backend + scripts (line-length 100)

Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in
.git-blame-ignore-revs (next commit) so git blame skips it.
This commit is contained in:
2026-08-23 07:43:06 +02:00
parent 434a98d6ed
commit b1832afd18
217 changed files with 3149 additions and 1744 deletions
+1
View File
@@ -4,6 +4,7 @@ Revision ID: 0001_initial
Revises:
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0002_ingest_core
Revises: 0001_initial
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
+1
View File
@@ -4,6 +4,7 @@ Revision ID: 0003_tags
Revises: 0002_ingest_core
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0004_feed_state_prefs
Revises: 0003_tags
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0005_shorts_probed
Revises: 0004_feed_state_prefs
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0006_app_state
Revises: 0005_shorts_probed
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0007_deep_requested
Revises: 0006_app_state
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
+2 -3
View File
@@ -4,6 +4,7 @@ Revision ID: 0008_invites
Revises: 0007_deep_requested
Create Date: 2026-06-12
"""
from typing import Sequence, Union
from alembic import op
@@ -22,9 +23,7 @@ def upgrade() -> None:
"invites",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column(
"status", sa.String(length=16), nullable=False, server_default="pending"
),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.Column("note", sa.Text(), nullable=True),
sa.Column(
"requested_at",
@@ -4,6 +4,7 @@ Revision ID: 0009_quota_events
Revises: 0008_invites
Create Date: 2026-06-12
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0010_watch_progress
Revises: 0009_quota_events
Create Date: 2026-06-14
"""
from typing import Sequence, Union
from alembic import op
+4 -9
View File
@@ -4,6 +4,7 @@ Revision ID: 0011_playlists
Revises: 0010_watch_progress
Create Date: 2026-06-15
"""
from typing import Sequence, Union
from alembic import op
@@ -26,16 +27,10 @@ def upgrade() -> None:
nullable=False,
),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"kind", sa.String(length=16), nullable=False, server_default="user"
),
sa.Column(
"source", sa.String(length=16), nullable=False, server_default="local"
),
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
sa.Column("source", sa.String(length=16), nullable=False, server_default="local"),
sa.Column("yt_playlist_id", sa.String(length=64), nullable=True),
sa.Column(
"dirty", sa.Boolean(), nullable=False, server_default="false"
),
sa.Column("dirty", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
@@ -7,6 +7,7 @@ Create Date: 2026-06-15
Migrates the old per-video status='saved' into membership of a built-in 'watch_later'
playlist (one per user who had saved videos), then demotes those states to 'new'.
"""
from typing import Sequence, Union
from alembic import op
@@ -11,6 +11,7 @@ a YouTube read. Backfills the baseline for already-clean linked playlists (the c
state == the synced state for them); dirty ones are left NULL so they stay dirty until the
next real sync establishes a baseline.
"""
import hashlib
from typing import Sequence, Union
@@ -32,24 +33,18 @@ def _fingerprint(name: str, ids: list[str]) -> str:
def upgrade() -> None:
op.add_column(
"playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True)
)
op.add_column("playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True))
bind = op.get_bind()
# Backfill clean, YouTube-linked playlists: their current state IS the synced state.
rows = bind.execute(
sa.text(
"SELECT id, name FROM playlists "
"WHERE yt_playlist_id IS NOT NULL AND dirty = false"
)
sa.text("SELECT id, name FROM playlists WHERE yt_playlist_id IS NOT NULL AND dirty = false")
).fetchall()
for pid, name in rows:
ids = [
r[0]
for r in bind.execute(
sa.text(
"SELECT video_id FROM playlist_items "
"WHERE playlist_id = :pid ORDER BY position"
"SELECT video_id FROM playlist_items WHERE playlist_id = :pid ORDER BY position"
),
{"pid": pid},
).fetchall()
@@ -11,6 +11,7 @@ Adds the shared demo-account plumbing:
The demo user row itself is created lazily on first demo login (works across all three DBs
without a data migration), so this only adds the schema.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -47,9 +48,7 @@ def upgrade() -> None:
nullable=False,
),
)
op.create_index(
"ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True
)
op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True)
def downgrade() -> None:
@@ -7,6 +7,7 @@ Create Date: 2026-06-16
Adds scheduler_settings: a per-job run-interval override (minutes), editable from the admin
Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -10,6 +10,7 @@ the validation lifecycle (last_checked_at for rolling re-validation; unavailable
unavailable_reason to flag a video as currently unplayable — hidden from the feed
immediately, hard-deleted after a grace period). Purely additive; all columns nullable.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -26,7 +27,9 @@ def upgrade() -> None:
op.add_column("videos", sa.Column("privacy_status", sa.String(length=16), nullable=True))
op.add_column("videos", sa.Column("upload_status", sa.String(length=16), nullable=True))
op.add_column("videos", sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("videos", sa.Column("unavailable_reason", sa.String(length=24), nullable=True))
op.create_index("ix_videos_last_checked_at", "videos", ["last_checked_at"])
op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"])
@@ -9,6 +9,7 @@ JSON payload column is named `data` (SQLAlchemy's declarative Base reserves `met
First producer is the maintenance job's batched "videos removed" notice. Coexists with the
existing client-side transient bell.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -36,18 +37,14 @@ def upgrade() -> None:
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("read", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
op.create_index("ix_notifications_type", "notifications", ["type"])
op.create_index("ix_notifications_read", "notifications", ["read"])
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
# Fast unread-badge count + inbox listing per user.
op.create_index(
"ix_notifications_user_read", "notifications", ["user_id", "read"]
)
op.create_index("ix_notifications_user_read", "notifications", ["user_id", "read"])
def downgrade() -> None:
@@ -8,6 +8,7 @@ Adds app_state.maintenance_revalidate_batch: an admin-tunable override (from the
dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
env/config default. Purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -9,6 +9,7 @@ channels.list?mine=true and cached. Used to exclude the user's own channel from
playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
Purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -10,6 +10,7 @@ stored historical values to the canonical scheme defined in app.quota.QuotaActio
fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
are NOT touched.)
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -8,6 +8,7 @@ Adds the system_config table: admin overrides for operational config that otherw
from env/config defaults (see app.sysconfig for the registry of known keys). Secret values
are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -9,6 +9,7 @@ google_sub nullable (password-only accounts have no Google sub until they link),
single-use auth_tokens table for email verification + password reset. Existing rows are all
Google/demo accounts → backfilled email_verified=true (is_active defaults true).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -8,6 +8,7 @@ Adds users.is_suspended — an admin-controlled access block, distinct from is_a
approval lifecycle). A suspended account can't sign in (password or Google) and any live
session is rejected; the user is told why and given the operator's contact.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -9,6 +9,7 @@ email_verified=false (the callback didn't set it). Backfill those so the flag re
matters because password-login gates on email_verified, so a Google user who later adds a password
must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
"""
from typing import Sequence, Union
from alembic import op
@@ -8,6 +8,7 @@ Adds app_state.configured + setup_token_hash for the first-run install wizard. E
(any users present) are marked configured so they never see the wizard; a genuinely fresh DB (no
users) starts unconfigured and boots into setup mode.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -8,6 +8,7 @@ Adds the `messages` table for direct user-to-user messaging (notification module
A conversation is the set of rows between two users; both FKs cascade so a GDPR account
erasure removes that user's messages.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -12,6 +12,7 @@ hold each user's public key + passphrase-wrapped private key (the server can nev
Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
prod has none).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -11,6 +11,7 @@ the Library by default:
- channels.from_search — this channel exists only because search surfaced one of its videos
Both default false (every existing row is organic), indexed for the feed's exclude filter.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -25,16 +26,12 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"videos",
sa.Column(
"via_search", sa.Boolean(), nullable=False, server_default="false"
),
sa.Column("via_search", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("ix_videos_via_search", "videos", ["via_search"])
op.add_column(
"channels",
sa.Column(
"from_search", sa.Boolean(), nullable=False, server_default="false"
),
sa.Column("from_search", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("ix_channels_from_search", "channels", ["from_search"])
@@ -10,6 +10,7 @@ query wraps both sides in unaccent(); this migration just makes the function ava
`unaccent` ships with the official postgres image (postgresql-contrib). The DB role is a
superuser in our deployments, so CREATE EXTENSION succeeds; IF NOT EXISTS keeps it idempotent.
"""
from typing import Sequence, Union
from alembic import op
@@ -9,6 +9,7 @@ live YouTube search. This lets "videos I searched for" appear in that user's Min
Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as
search-discovered by anyone, for the shared Library's Source filter).
"""
from typing import Sequence, Union
import sqlalchemy as sa
+2 -3
View File
@@ -15,6 +15,7 @@ search stays accent-insensitive ("tiesto" matches "Tiësto"). A GIN expression i
query uses the exact same expression so the planner picks the index. `unaccent` is already
enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
"""
from typing import Sequence, Union
from alembic import op
@@ -26,9 +27,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)"
)
op.execute("CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)")
op.execute(
"""
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple
@@ -21,6 +21,7 @@ title-only expression index from 0031 is dropped (superseded). Existing rows get
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -48,9 +49,7 @@ def upgrade() -> None:
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
)
op.execute(
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
)
op.execute("CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)")
def downgrade() -> None:
@@ -17,6 +17,7 @@ un-subscribed channels):
access to its ephemeral videos and drives the grace-period purge of un-kept channels.
All new flags default false / NULL, so every existing row stays organic.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -30,9 +31,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
op.add_column(
"channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("channels", sa.Column("banner_url", sa.String(length=1024), nullable=True))
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
op.add_column(
@@ -8,6 +8,7 @@ Adds the `blocked_channels` table: a per-user blocklist. Videos from a blocked c
never shown in that user's live YouTube search (and not ingested), and are hidden from their
feed / explore views. Per-user, so one user's block doesn't affect anyone else.
"""
from typing import Sequence, Union
import sqlalchemy as sa
+2 -3
View File
@@ -8,6 +8,7 @@ Adds the `saved_views` table: per-user named snapshots of the feed's filter/sort
row stores the serialized FeedFilters (JSON), a sidebar `position` for drag-reorder, and an
`is_default` flag (at most one per user, enforced by the API) for the view applied on load.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -26,9 +27,7 @@ def upgrade() -> None:
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("filters", sa.JSON(), nullable=False),
sa.Column(
"position", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column("position", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column(
"is_default",
sa.Boolean(),
@@ -10,6 +10,7 @@ FOR UPDATE SKIP LOCKED, live progress written back for polling); `download_profi
reusable format presets (builtins seeded in the next migration); `download_shares` grant a file
to another user; `download_quotas` hold admin-set per-user limits (default via sysconfig).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -47,9 +48,16 @@ def upgrade() -> None:
sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"),
sa.UniqueConstraint(
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
),
)
op.create_index("ix_media_assets_status", "media_assets", ["status"])
@@ -61,7 +69,12 @@ def upgrade() -> None:
sa.Column("spec", sa.JSON(), nullable=False),
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
@@ -84,8 +97,18 @@ def upgrade() -> None:
sa.Column("phase", sa.String(length=32), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("queue_pos", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["profile_id"], ["download_profiles.id"], ondelete="SET NULL"),
@@ -102,7 +125,12 @@ def upgrade() -> None:
sa.Column("job_id", sa.Integer(), nullable=False),
sa.Column("from_user_id", sa.Integer(), nullable=False),
sa.Column("to_user_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"], ondelete="CASCADE"),
@@ -120,7 +148,12 @@ def upgrade() -> None:
sa.Column("max_concurrent", sa.Integer(), nullable=False),
sa.Column("max_jobs", sa.Integer(), nullable=False),
sa.Column("unlimited", sa.Boolean(), server_default="false", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
@@ -10,6 +10,7 @@ definition consumed by app.downloads.formats: mode ("av" audio+video / "v" video
the embed_*/sponsorblock postprocessor flags. Names are English (the UI default); the frontend
localizes builtin labels by a stable key derived from the name.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -7,6 +7,7 @@ Create Date: 2026-07-03
The download GC warns each holder once, a grace window before an asset's TTL expiry; this flag
records that so subsequent GC runs don't re-notify until the asset is deleted.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -9,6 +9,7 @@ display-friendly form (emoji stripped, ALL-CAPS de-shouted, trailing SEO hashtag
see app.titles). Reversible: original_title preserves the source, and title can be re-derived.
The generated search_vector column regenerates automatically as each title is updated.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -45,5 +46,7 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL"))
conn.execute(
sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL")
)
op.drop_column("videos", "original_title")
@@ -11,6 +11,7 @@ rows so both fresh installs (0037 then this) and existing DBs converge on the sa
jobs are unaffected (they snapshot their spec; profile_id just SET NULLs if it pointed at a
replaced row).
"""
from typing import Sequence, Union
import sqlalchemy as sa
+6 -15
View File
@@ -10,6 +10,7 @@ still hangs off a `media_assets` row (with `source_kind='edit'` and a per-user c
existing file-serve / ref-count / GC / quota machinery is reused unchanged. `source_job_id` /
`source_asset_id` point at the parent download; `edit_spec` is the trim/crop recipe.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -24,16 +25,10 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"download_jobs",
sa.Column(
"job_kind", sa.String(length=16), server_default="download", nullable=False
),
)
op.add_column(
"download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True)
)
op.add_column(
"download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True)
sa.Column("job_kind", sa.String(length=16), server_default="download", nullable=False),
)
op.add_column("download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True))
op.add_column("download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True))
op.add_column("download_jobs", sa.Column("edit_spec", sa.JSON(), nullable=True))
op.create_foreign_key(
"fk_download_jobs_source_job",
@@ -54,12 +49,8 @@ def upgrade() -> None:
def downgrade() -> None:
op.drop_constraint(
"fk_download_jobs_source_asset", "download_jobs", type_="foreignkey"
)
op.drop_constraint(
"fk_download_jobs_source_job", "download_jobs", type_="foreignkey"
)
op.drop_constraint("fk_download_jobs_source_asset", "download_jobs", type_="foreignkey")
op.drop_constraint("fk_download_jobs_source_job", "download_jobs", type_="foreignkey")
op.drop_column("download_jobs", "edit_spec")
op.drop_column("download_jobs", "source_asset_id")
op.drop_column("download_jobs", "source_job_id")
@@ -9,6 +9,7 @@ can watch the file on the public `/watch/{token}` player page — no account nee
per-link controls: an expiry, an "allow download" toggle, and an (argon2-hashed) password. Revoke
= delete the row. Distinct from `download_shares` (an ACL grant to another *registered* user).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -31,7 +32,12 @@ def upgrade() -> None:
sa.Column("password_hash", sa.Text(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("view_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
@@ -8,6 +8,7 @@ Stores yt-dlp's canonical `webpage_url` for a download's source, so the Download
clean "downloaded from" reference (copyable + openable). Nullable — filled by the worker on
extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref.
"""
from typing import Sequence, Union
import sqlalchemy as sa
+67 -16
View File
@@ -18,6 +18,7 @@ Plex server (playback is from the local physical file; Plex is metadata only). T
The generated `search_vector` columns + their GIN indexes are added via raw SQL (same approach
as migration 0032), reusing the `unaccent_simple` text-search config from migration 0031.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -52,8 +53,12 @@ def upgrade() -> None:
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_plex_libraries_plex_key", "plex_libraries", ["plex_key"], unique=True)
@@ -61,7 +66,12 @@ def upgrade() -> None:
"plex_shows",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False),
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"library_id",
sa.Integer(),
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("title", sa.String(length=512), nullable=False),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("year", sa.Integer(), nullable=True),
@@ -69,8 +79,12 @@ def upgrade() -> None:
sa.Column("art_key", sa.String(length=512), nullable=True),
sa.Column("child_count", sa.Integer(), nullable=True),
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_plex_shows_rating_key", "plex_shows", ["rating_key"], unique=True)
op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"])
@@ -81,12 +95,21 @@ def upgrade() -> None:
"plex_seasons",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False),
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"show_id",
sa.Integer(),
sa.ForeignKey("plex_shows.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("season_number", sa.Integer(), nullable=True),
sa.Column("thumb_key", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_plex_seasons_rating_key", "plex_seasons", ["rating_key"], unique=True)
op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"])
@@ -96,10 +119,25 @@ def upgrade() -> None:
"plex_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False),
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"library_id",
sa.Integer(),
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=True),
sa.Column("season_id", sa.Integer(), sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"), nullable=True),
sa.Column(
"show_id",
sa.Integer(),
sa.ForeignKey("plex_shows.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"season_id",
sa.Integer(),
sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("season_number", sa.Integer(), nullable=True),
sa.Column("episode_number", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=512), nullable=False),
@@ -119,8 +157,12 @@ def upgrade() -> None:
sa.Column("probed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("markers", sa.JSON(), nullable=True),
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_plex_items_rating_key", "plex_items", ["rating_key"], unique=True)
op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"])
@@ -137,14 +179,23 @@ def upgrade() -> None:
op.create_table(
"plex_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"item_id",
sa.Integer(),
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("status", sa.String(length=12), server_default="new", nullable=False),
sa.Column("position_seconds", sa.Integer(), server_default="0", nullable=False),
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("synced_to_plex", sa.Boolean(), server_default="false", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),
)
op.create_index("ix_plex_states_user_id", "plex_states", ["user_id"])
@@ -9,6 +9,7 @@ Plex section listing (no per-item API calls): rating (audienceRating ~ IMDb), co
release date, and GIN-indexed genre / director / cast arrays. These also double as the content
signals a future watch-habit recommender would use.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -33,9 +34,13 @@ def upgrade() -> None:
op.create_index("ix_plex_items_rating", "plex_items", ["rating"])
op.create_index("ix_plex_items_content_rating", "plex_items", ["content_rating"])
op.create_index("ix_plex_items_studio", "plex_items", ["studio"])
op.create_index("ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"])
op.create_index(
"ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"]
)
op.create_index("ix_plex_items_genres_gin", "plex_items", ["genres"], postgresql_using="gin")
op.create_index("ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin")
op.create_index(
"ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin"
)
op.create_index("ix_plex_items_cast_gin", "plex_items", ["cast_names"], postgresql_using="gin")
@@ -10,6 +10,7 @@ finds titles by an actor/director name and ranks those above films that merely m
their synopsis. A generated column's expression can't be altered in place, so we drop + recreate it
(and its GIN index). `people_text` is populated on the next Plex sync.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -10,6 +10,7 @@ A `plex_collections` table holds each collection's card metadata; membership is
combinable filter and a title's "collection strip" is a pure local lookup. `editable` marks the
(curated) collections we may later write back to Plex; everything is read-only by default.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -27,7 +28,12 @@ def upgrade() -> None:
"plex_collections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False),
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"library_id",
sa.Integer(),
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("title", sa.String(length=512), nullable=False),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("thumb_key", sa.String(length=512), nullable=True),
@@ -35,16 +41,32 @@ def upgrade() -> None:
sa.Column("smart", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("editable", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_index(
"ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True
)
op.create_index("ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True)
op.create_index("ix_plex_collections_library_id", "plex_collections", ["library_id"])
op.add_column("plex_items", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
op.create_index("ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin")
op.create_index(
"ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin"
)
op.add_column("plex_shows", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
op.create_index("ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin")
op.create_index(
"ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin"
)
def downgrade() -> None:
@@ -9,6 +9,7 @@ Plex account (like plex_states) — the "your own lists" counterpart to the shar
collections. `plex_playlists` is owned by a user; `plex_playlist_items` holds the ordered entries
(`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -24,11 +25,23 @@ def upgrade() -> None:
op.create_table(
"plex_playlists",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("title", sa.String(length=512), nullable=False),
sa.Column("plex_rating_key", sa.String(length=32), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"])
@@ -36,12 +49,16 @@ def upgrade() -> None:
"plex_playlist_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"playlist_id", sa.Integer(),
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"), nullable=False,
"playlist_id",
sa.Integer(),
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"item_id", sa.Integer(),
sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False,
"item_id",
sa.Integer(),
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),
@@ -13,6 +13,7 @@ and captures yt-dlp's canonical channel/uploader URL so an auto-filled channel b
overrides (like display_name — display metadata only, never touches the on-disk file).
- download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -26,7 +27,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("media_assets", sa.Column("uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True))
op.add_column(
"download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True)
)
op.add_column("download_jobs", sa.Column("display_uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), nullable=True))
@@ -9,6 +9,7 @@ download shows a blank image box. The worker now extracts a representative frame
saves it as a poster sidecar; `poster_path` (relative to download_root) records it so the card /
watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -5,6 +5,7 @@ state with a Plex account. The owner MVP uses the server admin token (uses_admin
token); a personal token column is reserved for later friend-account linking (PIN OAuth). See the
PlexLink model docstring.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -11,6 +11,7 @@ blob folded into the generated `search_vector` (weight B). This lets the TV grid
sorted like the movie grid. A generated column's expression can't be altered in place, so the
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -57,9 +58,13 @@ def upgrade() -> None:
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"])
op.create_index(
"ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"]
)
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin")
op.create_index(
"ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin"
)
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
@@ -5,6 +5,7 @@ current_user rejects any cookie whose epoch is stale. Bumped on password reset,
or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -4,6 +4,7 @@ Revision ID: 0054_audit_log
Revises: 0053_user_session_epoch
Create Date: 2026-07-12
"""
from typing import Sequence, Union
from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0055_channel_keywords
Revises: 0054_audit_log
Create Date: 2026-07-12
"""
from typing import Sequence, Union
from alembic import op
+7 -2
View File
@@ -4,6 +4,7 @@ Revision ID: 0056_plex_people
Revises: 0055_channel_keywords
Create Date: 2026-07-19
"""
from typing import Sequence, Union
from alembic import op
@@ -23,8 +24,12 @@ def upgrade() -> None:
# A representative proxied headshot URL (or NULL when none is available). Captured
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
sa.Column("photo_thumb", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
@@ -4,6 +4,7 @@ Revision ID: 0057_plex_ppl_enriched
Revises: 0056_plex_people
Create Date: 2026-07-19
"""
from typing import Sequence, Union
from alembic import op
@@ -17,7 +18,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
for tbl in ("plex_items", "plex_shows"):
op.add_column(tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
@@ -10,6 +10,7 @@ Best still one click away. Re-seeds the builtin rows so fresh installs and exist
(same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
pointed at a replaced row just SET NULLs).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -10,6 +10,7 @@ did NOT invalidate outstanding grants — they lived out their 6h TTL. Fold a pe
into the signed grant and bump it on every password change, so a rotation revokes old grants at once.
Small table (one row per share link), so this is a quick add-column with a default.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -9,6 +9,7 @@ This one-time data fixup rewrites those scalars to SQL NULL so the columns are u
forward-only: a SQL NULL is indistinguishable from the old JSON `null` to every reader (the guards
treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change.
"""
from typing import Sequence, Union
from alembic import op
@@ -25,9 +26,7 @@ _COLS = ("genres", "directors", "cast_names", "collection_keys")
def upgrade() -> None:
for table in _TABLES:
for col in _COLS:
op.execute(
f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'"
)
op.execute(f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'")
def downgrade() -> None:
@@ -7,6 +7,7 @@ small category code (`unavailable` / `login_required` / `geo_blocked` / `format_
a retry makes sense, keeping the raw text as a technical-detail disclosure. Nullable; only set on the
error path. Forward-only — an existing errored row simply has NULL until its next failure/retry.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -17,6 +17,7 @@ Redundant index -> the composite that already covers it:
ix_plex_states_user_id <- uq_plex_user_item (user_id, item_id)
ix_plex_playlist_items_playlist_id <- uq_plex_playlist_item (playlist_id, item_id)
"""
from typing import Sequence, Union
from alembic import op
@@ -5,6 +5,7 @@ OVERLAY library is the Hungarian-metadata twin of another section: it is never b
its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap:
metadata-only, no table rewrite.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -9,6 +9,7 @@ Run BEFORE the first HU overlay sync: while the *_hu columns are all NULL the ge
is trivial. ADD only (no drop/rebuild of the EN search_vector). NOTE: adding a STORED generated
column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -11,6 +11,7 @@ so the data step below resets `people_enriched_at` to NULL on movie/show rows: t
each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table
rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -6,6 +6,7 @@ schema allowed NULL — a model/schema drift the new migration drift-test (E1 S2
server_default has filled every row since day one, so no NULLs exist; enforcing NOT NULL just makes the
schema match the contract the model already promises. Pure DDL, reversible.
"""
from typing import Sequence, Union
from alembic import op
@@ -22,6 +22,7 @@ CONCURRENTLY (in alembic's autocommit_block, since it can't run inside a transac
these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
downgrade->re-upgrade round-trip idempotent.
"""
from typing import Sequence, Union
from alembic import op
@@ -13,6 +13,7 @@ Note: `SET NOT NULL` validates with a brief ACCESS EXCLUSIVE lock + one table sc
CONCURRENTLY). On the prod row count (~235k) that is sub-second; still, it is a short write-blocking lock
rather than the ~0 of 0067's CONCURRENTLY index build.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -27,9 +28,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe).
op.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
op.alter_column(
"videos", "published_at", nullable=False, server_default=sa.text("now()")
)
op.alter_column("videos", "published_at", nullable=False, server_default=sa.text("now()"))
def downgrade() -> None:
@@ -18,6 +18,7 @@ biggest table, same lookups. live_status is left alone (a separate finding).
All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
downgrade->re-upgrade idempotent.
"""
from typing import Sequence, Union
from alembic import op
@@ -5,6 +5,7 @@ hand-rolled override-or-default reader in `app.state`, duplicating the sysconfig
now a sysconfig key (`system_config` store), so every admin knob lives in one place. This migration
carries any existing override into the sysconfig store, then drops the dead column. Reversible.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -8,6 +8,7 @@ Plex-sourced `imdb_rating`/`rating`, which are left untouched. Plus `plex_people
foundation for S6's person bios). ADD-only, all nullable → no table rewrite, no ACCESS EXCLUSIVE lock.
No watermark reset needed: `tmdb_enriched_at` starts NULL, so every eligible row is picked up once.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -7,6 +7,7 @@ first time a person page is opened (from /person/{tmdb_person_id}), then SERVED
`tmdb_person_id` link itself already exists (0071). ADD-only, all nullable → no table rewrite, no
ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view.
"""
from typing import Sequence, Union
import sqlalchemy as sa
@@ -6,6 +6,7 @@ to (user_id, source, name) so a feed view and a Plex view can share a name. `sou
server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
small per-user table.
"""
from typing import Sequence, Union
import sqlalchemy as sa