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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user