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: Revises:
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0002_ingest_core
Revises: 0001_initial Revises: 0001_initial
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
+1
View File
@@ -4,6 +4,7 @@ Revision ID: 0003_tags
Revises: 0002_ingest_core Revises: 0002_ingest_core
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0004_feed_state_prefs
Revises: 0003_tags Revises: 0003_tags
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0005_shorts_probed
Revises: 0004_feed_state_prefs Revises: 0004_feed_state_prefs
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0006_app_state
Revises: 0005_shorts_probed Revises: 0005_shorts_probed
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0007_deep_requested
Revises: 0006_app_state Revises: 0006_app_state
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
+2 -3
View File
@@ -4,6 +4,7 @@ Revision ID: 0008_invites
Revises: 0007_deep_requested Revises: 0007_deep_requested
Create Date: 2026-06-12 Create Date: 2026-06-12
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -22,9 +23,7 @@ def upgrade() -> None:
"invites", "invites",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False), sa.Column("email", sa.String(length=320), nullable=False),
sa.Column( sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
"status", sa.String(length=16), nullable=False, server_default="pending"
),
sa.Column("note", sa.Text(), nullable=True), sa.Column("note", sa.Text(), nullable=True),
sa.Column( sa.Column(
"requested_at", "requested_at",
@@ -4,6 +4,7 @@ Revision ID: 0009_quota_events
Revises: 0008_invites Revises: 0008_invites
Create Date: 2026-06-12 Create Date: 2026-06-12
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0010_watch_progress
Revises: 0009_quota_events Revises: 0009_quota_events
Create Date: 2026-06-14 Create Date: 2026-06-14
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
+4 -9
View File
@@ -4,6 +4,7 @@ Revision ID: 0011_playlists
Revises: 0010_watch_progress Revises: 0010_watch_progress
Create Date: 2026-06-15 Create Date: 2026-06-15
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -26,16 +27,10 @@ def upgrade() -> None:
nullable=False, nullable=False,
), ),
sa.Column("name", sa.String(length=255), nullable=False), sa.Column("name", sa.String(length=255), nullable=False),
sa.Column( sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
"kind", sa.String(length=16), nullable=False, server_default="user" sa.Column("source", sa.String(length=16), nullable=False, server_default="local"),
),
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("yt_playlist_id", sa.String(length=64), nullable=True),
sa.Column( sa.Column("dirty", sa.Boolean(), nullable=False, server_default="false"),
"dirty", sa.Boolean(), nullable=False, server_default="false"
),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"), sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column( sa.Column(
"created_at", "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' 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'. playlist (one per user who had saved videos), then demotes those states to 'new'.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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 state == the synced state for them); dirty ones are left NULL so they stay dirty until the
next real sync establishes a baseline. next real sync establishes a baseline.
""" """
import hashlib import hashlib
from typing import Sequence, Union from typing import Sequence, Union
@@ -32,24 +33,18 @@ def _fingerprint(name: str, ids: list[str]) -> str:
def upgrade() -> None: def upgrade() -> None:
op.add_column( op.add_column("playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True))
"playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True)
)
bind = op.get_bind() bind = op.get_bind()
# Backfill clean, YouTube-linked playlists: their current state IS the synced state. # Backfill clean, YouTube-linked playlists: their current state IS the synced state.
rows = bind.execute( rows = bind.execute(
sa.text( sa.text("SELECT id, name FROM playlists WHERE yt_playlist_id IS NOT NULL AND dirty = false")
"SELECT id, name FROM playlists "
"WHERE yt_playlist_id IS NOT NULL AND dirty = false"
)
).fetchall() ).fetchall()
for pid, name in rows: for pid, name in rows:
ids = [ ids = [
r[0] r[0]
for r in bind.execute( for r in bind.execute(
sa.text( sa.text(
"SELECT video_id FROM playlist_items " "SELECT video_id FROM playlist_items WHERE playlist_id = :pid ORDER BY position"
"WHERE playlist_id = :pid ORDER BY position"
), ),
{"pid": pid}, {"pid": pid},
).fetchall() ).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 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. without a data migration), so this only adds the schema.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -47,9 +48,7 @@ def upgrade() -> None:
nullable=False, nullable=False,
), ),
) )
op.create_index( op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True)
"ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True
)
def downgrade() -> None: 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 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. Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. immediately, hard-deleted after a grace period). Purely additive; all columns nullable.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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("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("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("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.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_last_checked_at", "videos", ["last_checked_at"])
op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"]) 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 First producer is the maintenance job's batched "videos removed" notice. Coexists with the
existing client-side transient bell. existing client-side transient bell.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -36,18 +37,14 @@ def upgrade() -> None:
sa.Column("data", sa.JSON(), nullable=True), sa.Column("data", sa.JSON(), nullable=True),
sa.Column("read", sa.Boolean(), nullable=False, server_default="false"), sa.Column("read", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"), sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"),
sa.Column( sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
"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_user_id", "notifications", ["user_id"])
op.create_index("ix_notifications_type", "notifications", ["type"]) op.create_index("ix_notifications_type", "notifications", ["type"])
op.create_index("ix_notifications_read", "notifications", ["read"]) op.create_index("ix_notifications_read", "notifications", ["read"])
op.create_index("ix_notifications_created_at", "notifications", ["created_at"]) op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
# Fast unread-badge count + inbox listing per user. # Fast unread-badge count + inbox listing per user.
op.create_index( op.create_index("ix_notifications_user_read", "notifications", ["user_id", "read"])
"ix_notifications_user_read", "notifications", ["user_id", "read"]
)
def downgrade() -> None: 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 dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
env/config default. Purely additive. env/config default. Purely additive.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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. playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
Purely additive. Purely additive.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
are NOT touched.) are NOT touched.)
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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). Google/demo accounts → backfilled email_verified=true (is_active defaults true).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. session is rejected; the user is told why and given the operator's contact.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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 (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. users) starts unconfigured and boots into setup mode.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 A conversation is the set of rows between two users; both FKs cascade so a GDPR account
erasure removes that user's messages. erasure removes that user's messages.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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; Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
prod has none). prod has none).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 - 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. Both default false (every existing row is organic), indexed for the feed's exclude filter.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -25,16 +26,12 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.add_column( op.add_column(
"videos", "videos",
sa.Column( sa.Column("via_search", sa.Boolean(), nullable=False, server_default="false"),
"via_search", sa.Boolean(), nullable=False, server_default="false"
),
) )
op.create_index("ix_videos_via_search", "videos", ["via_search"]) op.create_index("ix_videos_via_search", "videos", ["via_search"])
op.add_column( op.add_column(
"channels", "channels",
sa.Column( sa.Column("from_search", sa.Boolean(), nullable=False, server_default="false"),
"from_search", sa.Boolean(), nullable=False, server_default="false"
),
) )
op.create_index("ix_channels_from_search", "channels", ["from_search"]) 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 `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. superuser in our deployments, so CREATE EXTENSION succeeds; IF NOT EXISTS keeps it idempotent.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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 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). search-discovered by anyone, for the shared Library's Source filter).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -26,9 +27,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.execute( op.execute("CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)")
"CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)"
)
op.execute( op.execute(
""" """
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple 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 description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive). searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -48,9 +49,7 @@ def upgrade() -> None:
f"ALTER TABLE videos ADD COLUMN search_vector tsvector " f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED" f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
) )
op.execute( op.execute("CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)")
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
)
def downgrade() -> None: 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. 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. All new flags default false / NULL, so every existing row stays organic.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -30,9 +31,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True)) op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
op.add_column( op.add_column("channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True))
"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("banner_url", sa.String(length=1024), nullable=True))
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True)) op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
op.add_column( 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 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. feed / explore views. Per-user, so one user's block doesn't affect anyone else.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. `is_default` flag (at most one per user, enforced by the API) for the view applied on load.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -26,9 +27,7 @@ def upgrade() -> None:
sa.Column("user_id", sa.Integer(), nullable=False), sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False), sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("filters", sa.JSON(), nullable=False), sa.Column("filters", sa.JSON(), nullable=False),
sa.Column( sa.Column("position", sa.Integer(), server_default=sa.text("0"), nullable=False),
"position", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column( sa.Column(
"is_default", "is_default",
sa.Boolean(), 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 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). to another user; `download_quotas` hold admin-set per-user limits (default via sysconfig).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -47,9 +48,16 @@ def upgrade() -> None:
sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False), sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_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.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"]) 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("spec", sa.JSON(), nullable=False),
sa.Column("is_builtin", sa.Boolean(), server_default="false", 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("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.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),
) )
@@ -84,8 +97,18 @@ def upgrade() -> None:
sa.Column("phase", sa.String(length=32), nullable=True), sa.Column("phase", sa.String(length=32), nullable=True),
sa.Column("error", sa.Text(), nullable=True), sa.Column("error", sa.Text(), nullable=True),
sa.Column("queue_pos", sa.Integer(), server_default="0", nullable=False), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), "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(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["profile_id"], ["download_profiles.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("job_id", sa.Integer(), nullable=False),
sa.Column("from_user_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("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(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["from_user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["to_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_concurrent", sa.Integer(), nullable=False),
sa.Column("max_jobs", sa.Integer(), nullable=False), sa.Column("max_jobs", sa.Integer(), nullable=False),
sa.Column("unlimited", sa.Boolean(), server_default="false", 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.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"), 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 the embed_*/sponsorblock postprocessor flags. Names are English (the UI default); the frontend
localizes builtin labels by a stable key derived from the name. localizes builtin labels by a stable key derived from the name.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. records that so subsequent GC runs don't re-notify until the asset is deleted.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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. 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. The generated search_vector column regenerates automatically as each title is updated.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -45,5 +46,7 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind() 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") 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 jobs are unaffected (they snapshot their spec; profile_id just SET NULLs if it pointed at a
replaced row). replaced row).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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` / 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. `source_asset_id` point at the parent download; `edit_spec` is the trim/crop recipe.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -24,16 +25,10 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.add_column( op.add_column(
"download_jobs", "download_jobs",
sa.Column( sa.Column("job_kind", sa.String(length=16), server_default="download", nullable=False),
"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("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.add_column("download_jobs", sa.Column("edit_spec", sa.JSON(), nullable=True))
op.create_foreign_key( op.create_foreign_key(
"fk_download_jobs_source_job", "fk_download_jobs_source_job",
@@ -54,12 +49,8 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
op.drop_constraint( op.drop_constraint("fk_download_jobs_source_asset", "download_jobs", type_="foreignkey")
"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_job", "download_jobs", type_="foreignkey"
)
op.drop_column("download_jobs", "edit_spec") op.drop_column("download_jobs", "edit_spec")
op.drop_column("download_jobs", "source_asset_id") op.drop_column("download_jobs", "source_asset_id")
op.drop_column("download_jobs", "source_job_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 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). = delete the row. Distinct from `download_shares` (an ACL grant to another *registered* user).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -31,7 +32,12 @@ def upgrade() -> None:
sa.Column("password_hash", sa.Text(), nullable=True), sa.Column("password_hash", sa.Text(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), 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("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(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"), 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 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. extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. as migration 0032), reusing the `unaccent_simple` text-search config from migration 0031.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -52,8 +53,12 @@ def upgrade() -> None:
sa.Column("kind", sa.String(length=16), nullable=False), sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False), sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), "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) op.create_index("ix_plex_libraries_plex_key", "plex_libraries", ["plex_key"], unique=True)
@@ -61,7 +66,12 @@ def upgrade() -> None:
"plex_shows", "plex_shows",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False), 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("title", sa.String(length=512), nullable=False),
sa.Column("summary", sa.Text(), nullable=True), sa.Column("summary", sa.Text(), nullable=True),
sa.Column("year", sa.Integer(), 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("art_key", sa.String(length=512), nullable=True),
sa.Column("child_count", sa.Integer(), nullable=True), sa.Column("child_count", sa.Integer(), nullable=True),
sa.Column("added_at", sa.DateTime(timezone=True), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), "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_rating_key", "plex_shows", ["rating_key"], unique=True)
op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"]) op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"])
@@ -81,12 +95,21 @@ def upgrade() -> None:
"plex_seasons", "plex_seasons",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False), 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("title", sa.String(length=255), nullable=True),
sa.Column("season_number", sa.Integer(), nullable=True), sa.Column("season_number", sa.Integer(), nullable=True),
sa.Column("thumb_key", sa.String(length=512), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), "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_rating_key", "plex_seasons", ["rating_key"], unique=True)
op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"]) op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"])
@@ -96,10 +119,25 @@ def upgrade() -> None:
"plex_items", "plex_items",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False), 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("kind", sa.String(length=16), nullable=False),
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=True), sa.Column(
sa.Column("season_id", sa.Integer(), sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"), nullable=True), "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("season_number", sa.Integer(), nullable=True),
sa.Column("episode_number", sa.Integer(), nullable=True), sa.Column("episode_number", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=512), nullable=False), 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("probed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("markers", sa.JSON(), nullable=True), sa.Column("markers", sa.JSON(), nullable=True),
sa.Column("added_at", sa.DateTime(timezone=True), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), "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_rating_key", "plex_items", ["rating_key"], unique=True)
op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"]) op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"])
@@ -137,14 +179,23 @@ def upgrade() -> None:
op.create_table( op.create_table(
"plex_states", "plex_states",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column(
sa.Column("item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False), "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("status", sa.String(length=12), server_default="new", nullable=False),
sa.Column("position_seconds", sa.Integer(), server_default="0", 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("watched_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("progress_updated_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("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"), sa.UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),
) )
op.create_index("ix_plex_states_user_id", "plex_states", ["user_id"]) 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 release date, and GIN-indexed genre / director / cast arrays. These also double as the content
signals a future watch-habit recommender would use. signals a future watch-habit recommender would use.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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_rating", "plex_items", ["rating"])
op.create_index("ix_plex_items_content_rating", "plex_items", ["content_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_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_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") 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 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. (and its GIN index). `people_text` is populated on the next Plex sync.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. (curated) collections we may later write back to Plex; everything is read-only by default.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -27,7 +28,12 @@ def upgrade() -> None:
"plex_collections", "plex_collections",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rating_key", sa.String(length=32), nullable=False), 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("title", sa.String(length=512), nullable=False),
sa.Column("summary", sa.Text(), nullable=True), sa.Column("summary", sa.Text(), nullable=True),
sa.Column("thumb_key", sa.String(length=512), 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("smart", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("editable", 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("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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), "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.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.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.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: 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 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. (`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -24,11 +25,23 @@ def upgrade() -> None:
op.create_table( op.create_table(
"plex_playlists", "plex_playlists",
sa.Column("id", sa.Integer(), primary_key=True), 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("title", sa.String(length=512), nullable=False),
sa.Column("plex_rating_key", sa.String(length=32), nullable=True), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), "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"]) op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"])
@@ -36,12 +49,16 @@ def upgrade() -> None:
"plex_playlist_items", "plex_playlist_items",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("id", sa.Integer(), primary_key=True),
sa.Column( sa.Column(
"playlist_id", sa.Integer(), "playlist_id",
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"), nullable=False, sa.Integer(),
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"),
nullable=False,
), ),
sa.Column( sa.Column(
"item_id", sa.Integer(), "item_id",
sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False, sa.Integer(),
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
nullable=False,
), ),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"), sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"), 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). 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. - download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -26,7 +27,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
op.add_column("media_assets", sa.Column("uploader_url", sa.Text(), nullable=True)) 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("display_uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), 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 / 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. watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 token); a personal token column is reserved for later friend-account linking (PIN OAuth). See the
PlexLink model docstring. PlexLink model docstring.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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_rating", "plex_shows", ["rating"])
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_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_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_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") 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). # 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 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). staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -4,6 +4,7 @@ Revision ID: 0054_audit_log
Revises: 0053_user_session_epoch Revises: 0053_user_session_epoch
Create Date: 2026-07-12 Create Date: 2026-07-12
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -4,6 +4,7 @@ Revision ID: 0055_channel_keywords
Revises: 0054_audit_log Revises: 0054_audit_log
Create Date: 2026-07-12 Create Date: 2026-07-12
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
+7 -2
View File
@@ -4,6 +4,7 @@ Revision ID: 0056_plex_people
Revises: 0055_channel_keywords Revises: 0055_channel_keywords
Create Date: 2026-07-19 Create Date: 2026-07-19
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -23,8 +24,12 @@ def upgrade() -> None:
# A representative proxied headshot URL (or NULL when none is available). Captured # 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). # opportunistically from an item's rich metadata (info-page view + the rich re-sync).
sa.Column("photo_thumb", sa.Text(), nullable=True), 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(
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), "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) 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 Revises: 0056_plex_people
Create Date: 2026-07-19 Create Date: 2026-07-19
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -17,7 +18,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
for tbl in ("plex_items", "plex_shows"): 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: 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 (same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
pointed at a replaced row just SET NULLs). pointed at a replaced row just SET NULLs).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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. 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. Small table (one row per share link), so this is a quick add-column with a default.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -25,9 +26,7 @@ _COLS = ("genres", "directors", "cast_names", "collection_keys")
def upgrade() -> None: def upgrade() -> None:
for table in _TABLES: for table in _TABLES:
for col in _COLS: for col in _COLS:
op.execute( op.execute(f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'")
f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'"
)
def downgrade() -> None: 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 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. error path. Forward-only — an existing errored row simply has NULL until its next failure/retry.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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_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) ix_plex_playlist_items_playlist_id <- uq_plex_playlist_item (playlist_id, item_id)
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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: its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap:
metadata-only, no table rewrite. metadata-only, no table rewrite.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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). rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 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. schema match the contract the model already promises. Pure DDL, reversible.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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 these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
downgrade->re-upgrade round-trip idempotent. downgrade->re-upgrade round-trip idempotent.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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 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. rather than the ~0 of 0067's CONCURRENTLY index build.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
@@ -27,9 +28,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe). # 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.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
op.alter_column( op.alter_column("videos", "published_at", nullable=False, server_default=sa.text("now()"))
"videos", "published_at", nullable=False, server_default=sa.text("now()")
)
def downgrade() -> None: 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 All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
downgrade->re-upgrade idempotent. downgrade->re-upgrade idempotent.
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op 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 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. carries any existing override into the sysconfig store, then drops the dead column. Reversible.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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. 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. No watermark reset needed: `tmdb_enriched_at` starts NULL, so every eligible row is picked up once.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 `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. ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa 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 server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
small per-user table. small per-user table.
""" """
from typing import Sequence, Union from typing import Sequence, Union
import sqlalchemy as sa import sqlalchemy as sa
+1
View File
@@ -9,6 +9,7 @@ runs are always logged. Secrets are never stored (config changes log the key nam
mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops
its audit row too). its audit row too).
""" """
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import delete from sqlalchemy import delete
+6 -9
View File
@@ -41,8 +41,7 @@ log = logging.getLogger("siftlode.auth")
def google_enabled(db: Session) -> bool: def google_enabled(db: Session) -> bool:
"""Whether Sign in with Google is available (both client id + secret resolve to non-empty).""" """Whether Sign in with Google is available (both client id + secret resolve to non-empty)."""
return bool( return bool(
sysconfig.get_str(db, "google_client_id") sysconfig.get_str(db, "google_client_id") and sysconfig.get_str(db, "google_client_secret")
and sysconfig.get_str(db, "google_client_secret")
) )
@@ -125,7 +124,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a # ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
# TOKEN_ENCRYPTION_KEY mismatch can't 500 the purge — a best-effort revoke degrades to "skip". (B-core-2) # TOKEN_ENCRYPTION_KEY mismatch can't 500 the purge — a best-effort revoke degrades to "skip". (B-core-2)
google_token = ( google_token = (
(decrypt_optional(tok.refresh_token_enc) or decrypt_optional(tok.access_token)) if tok else None (decrypt_optional(tok.refresh_token_enc) or decrypt_optional(tok.access_token))
if tok
else None
) )
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id)) db.execute(delete(User).where(User.id == user_id))
@@ -197,9 +198,7 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return user return user
def optional_current_user( def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
request: Request, db: Session = Depends(get_db)
) -> User | None:
"""Like `current_user` but returns None instead of raising 401 when there's no valid session. """Like `current_user` but returns None instead of raising 401 when there's no valid session.
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
the browser console (a non-2xx fetch is a console error no matter how the app handles it).""" the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
@@ -215,9 +214,7 @@ def require_human(user: User = Depends(current_user)) -> User:
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
where there's no scope check to lean on.""" where there's no scope check to lean on."""
if user.is_demo: if user.is_demo:
raise HTTPException( raise HTTPException(status_code=403, detail="Not available in the demo account.")
status_code=403, detail="Not available in the demo account."
)
return user return user
+1
View File
@@ -169,6 +169,7 @@ class Settings(BaseSettings):
@property @property
def player_client_fallback_list(self) -> list[str]: def player_client_fallback_list(self) -> list[str]:
return self._clients(self.download_player_clients_fallback) return self._clients(self.download_player_clients_fallback)
# How many downloads the worker runs concurrently (claimed via FOR UPDATE SKIP LOCKED). # How many downloads the worker runs concurrently (claimed via FOR UPDATE SKIP LOCKED).
download_worker_concurrency: int = 2 download_worker_concurrency: int = 2
# How often (empty pending queue) the worker polls for a new job, in seconds. # How often (empty pending queue) the worker polls for a new job, in seconds.
+7 -2
View File
@@ -4,6 +4,7 @@ Shared by the admin's manual reset endpoint (routes/admin.py) and the scheduled
(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an (scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy). `app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
""" """
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -49,13 +50,17 @@ def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
Channel manager isn't empty — gives visitors something to explore and matches the landing-page Channel manager isn't empty — gives visitors something to explore and matches the landing-page
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N.""" screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
db.execute(delete(Subscription).where(Subscription.user_id == demo.id)) db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
top = db.execute( top = (
db.execute(
select(Video.channel_id) select(Video.channel_id)
.where(Video.channel_id.is_not(None)) .where(Video.channel_id.is_not(None))
.group_by(Video.channel_id) .group_by(Video.channel_id)
.order_by(func.count().desc()) .order_by(func.count().desc())
.limit(n) .limit(n)
).scalars().all() )
.scalars()
.all()
)
for channel_id in top: for channel_id in top:
db.add(Subscription(user_id=demo.id, channel_id=channel_id)) db.add(Subscription(user_id=demo.id, channel_id=channel_id))
return len(top) return len(top)
+5 -2
View File
@@ -10,6 +10,7 @@ Adapters are stateless singletons; get one with `resolve_adapter(source_kind, so
Resolution works both before extraction (from source_kind / URL host) and after (from the yt-dlp Resolution works both before extraction (from source_kind / URL host) and after (from the yt-dlp
`extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter. `extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter.
""" """
from urllib.parse import urlparse from urllib.parse import urlparse
from app.config import settings from app.config import settings
@@ -140,8 +141,10 @@ def resolve_adapter(
def host_is(*domains: str) -> bool: def host_is(*domains: str) -> bool:
return any(_host_is(h, *domains) for h in hosts) return any(_host_is(h, *domains) for h in hosts)
if source_kind == "youtube" or "youtube" in extractor or host_is( if (
"youtube.com", "youtu.be", "youtube-nocookie.com" source_kind == "youtube"
or "youtube" in extractor
or host_is("youtube.com", "youtu.be", "youtube-nocookie.com")
): ):
return _YOUTUBE return _YOUTUBE
if "facebook" in extractor or host_is("facebook.com", "fb.watch"): if "facebook" in extractor or host_is("facebook.com", "fb.watch"):
+1
View File
@@ -10,6 +10,7 @@ URL may have expired — those just get a locally-generated poster instead).
Run once per environment: Run once per environment:
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())" docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
""" """
import logging import logging
import yt_dlp import yt_dlp
+151 -29
View File
@@ -20,6 +20,7 @@ Also builds the editor **filmstrip** — a single tiled sprite of the source vid
scrub track. Cached on the source asset's `storyboard_path` (reserved in phase 1) and reusable scrub track. Cached on the source asset's `storyboard_path` (reserved in phase 1) and reusable
for a player hover-preview later. for a player hover-preview later.
""" """
import hashlib import hashlib
import json import json
import logging import logging
@@ -40,6 +41,7 @@ class EditAborted(Exception):
# --- edit spec ------------------------------------------------------------------------------ # --- edit spec ------------------------------------------------------------------------------
def normalize_edit_spec(spec: dict | None) -> dict: def normalize_edit_spec(spec: dict | None) -> dict:
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties. """Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
@@ -140,6 +142,7 @@ def clip_duration(spec: dict, source_duration: int | None) -> int | None:
# --- ffmpeg: trim / crop -------------------------------------------------------------------- # --- ffmpeg: trim / crop --------------------------------------------------------------------
def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]: def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]:
"""ffmpeg command for a trim/crop edit. `-progress pipe:1` streams machine-readable progress. """ffmpeg command for a trim/crop edit. `-progress pipe:1` streams machine-readable progress.
@@ -153,8 +156,15 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
reencode = needs_reencode(spec) reencode = needs_reencode(spec)
cmd = [ cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "ffmpeg",
"-progress", "pipe:1", "-nostats", "-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-progress",
"pipe:1",
"-nostats",
] ]
if start: if start:
cmd += ["-ss", f"{start:.3f}"] cmd += ["-ss", f"{start:.3f}"]
@@ -170,8 +180,16 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
if crop: if crop:
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"] cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
cmd += [ cmd += [
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:v",
"-c:a", "aac", "-b:a", "192k", "libx264",
"-preset",
"veryfast",
"-crf",
"20",
"-c:a",
"aac",
"-b:a",
"192k",
] ]
else: else:
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"] cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
@@ -204,8 +222,15 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
crop = spec.get("crop") crop = spec.get("crop")
prep: dict[Path, str] = {} prep: dict[Path, str] = {}
head = [ head = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "ffmpeg",
"-progress", "pipe:1", "-nostats", "-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-progress",
"pipe:1",
"-nostats",
] ]
if needs_reencode(spec): if needs_reencode(spec):
@@ -218,9 +243,24 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
labels.append(f"[v{i}][a{i}]") labels.append(f"[v{i}][a{i}]")
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]") parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
cmd = head + [ cmd = head + [
"-i", str(src), "-filter_complex", ";".join(parts), "-i",
"-map", "[v]", "-map", "[a]", str(src),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-b:a", "192k", "-filter_complex",
";".join(parts),
"-map",
"[v]",
"-map",
"[a]",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"20",
"-c:a",
"aac",
"-b:a",
"192k",
] ]
else: else:
listp = staging / "concat.txt" listp = staging / "concat.txt"
@@ -314,7 +354,9 @@ def _stop(proc: subprocess.Popen) -> None:
escalate_kill(proc, proc.terminate, proc.kill, grace_s=5, label="ffmpeg") escalate_kill(proc, proc.terminate, proc.kill, grace_s=5, label="ffmpeg")
def run_ffmpeg(cmd, total_s, on_progress, should_cancel, stall_timeout_s=_FFMPEG_STALL_TIMEOUT_S) -> None: def run_ffmpeg(
cmd, total_s, on_progress, should_cancel, stall_timeout_s=_FFMPEG_STALL_TIMEOUT_S
) -> None:
"""Run an ffmpeg edit, reporting 0–100 progress and honouring cooperative cancel. """Run an ffmpeg edit, reporting 0–100 progress and honouring cooperative cancel.
Both pipes are drained by threads (see `_drain_tail`) and the main loop polls on a 1s tick, so Both pipes are drained by threads (see `_drain_tail`) and the main loop polls on a 1s tick, so
@@ -411,9 +453,20 @@ def probe_codecs(path: Path) -> tuple[str | None, str | None]:
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream).""" stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
try: try:
out = subprocess.run( out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name", [
"-of", "json", str(path)], "ffprobe",
capture_output=True, text=True, timeout=30, check=True, "-v",
"error",
"-show_entries",
"stream=codec_type,codec_name",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
timeout=30,
check=True,
).stdout ).stdout
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError): except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
return None, None return None, None
@@ -425,16 +478,29 @@ def probe_duration(path: Path) -> int:
re-encode when the yt-dlp info dict carried no `duration` (some URL/live extractions).""" re-encode when the yt-dlp info dict carried no `duration` (some URL/live extractions)."""
try: try:
out = subprocess.run( out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration", [
"-of", "default=nw=1:nk=1", str(path)], "ffprobe",
capture_output=True, text=True, timeout=30, check=True, "-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nw=1:nk=1",
str(path),
],
capture_output=True,
text=True,
timeout=30,
check=True,
).stdout.strip() ).stdout.strip()
return int(float(out)) if out else 0 return int(float(out)) if out else 0
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError): except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
return 0 return 0
def browser_transcode_flags(vcodec: str | None, acodec: str | None, is_mp4: bool) -> tuple[bool, bool, bool]: def browser_transcode_flags(
vcodec: str | None, acodec: str | None, is_mp4: bool
) -> tuple[bool, bool, bool]:
"""Decide what (if anything) makes a file universally browser-playable. Returns """Decide what (if anything) makes a file universally browser-playable. Returns
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3 (needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded.""" in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
@@ -443,15 +509,30 @@ def browser_transcode_flags(vcodec: str | None, acodec: str | None, is_mp4: bool
return (rv or ra or not is_mp4), rv, ra return (rv or ra or not is_mp4), rv, ra
def build_browser_safe_cmd(src: Path, dest: Path, reencode_video: bool, reencode_audio: bool) -> list[str]: def build_browser_safe_cmd(
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool
) -> list[str]:
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the """ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
pipe:1` streams machine-readable progress for run_ffmpeg.""" pipe:1` streams machine-readable progress for run_ffmpeg."""
cmd = [ cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "ffmpeg",
"-progress", "pipe:1", "-nostats", "-i", str(src), "-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-progress",
"pipe:1",
"-nostats",
"-i",
str(src),
] ]
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"] if reencode_video else ["-c:v", "copy"] cmd += (
["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"]
if reencode_video
else ["-c:v", "copy"]
)
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"] cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
cmd += ["-movflags", "+faststart", str(dest)] cmd += ["-movflags", "+faststart", str(dest)]
return cmd return cmd
@@ -486,25 +567,63 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
fps = geom["count"] / dur fps = geom["count"] / dur
vf = f"fps={fps:.6f},scale={_SB_TILE_W}:-2,tile={geom['cols']}x{geom['rows']}" vf = f"fps={fps:.6f},scale={_SB_TILE_W}:-2,tile={geom['cols']}x{geom['rows']}"
return [ return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "ffmpeg",
"-i", str(src), "-frames:v", "1", "-q:v", "5", "-vf", vf, str(dest), "-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-i",
str(src),
"-frames:v",
"1",
"-q:v",
"5",
"-vf",
vf,
str(dest),
] ]
def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]: def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
"""Grab one representative frame as a poster JPEG. `-ss` before `-i` seeks fast.""" """Grab one representative frame as a poster JPEG. `-ss` before `-i` seeks fast."""
return [ return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "ffmpeg",
"-ss", f"{max(0.0, at_s):.3f}", "-i", str(src), "-y",
"-frames:v", "1", "-q:v", "3", "-vf", "scale=640:-2", str(dest), "-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-ss",
f"{max(0.0, at_s):.3f}",
"-i",
str(src),
"-frames:v",
"1",
"-q:v",
"3",
"-vf",
"scale=640:-2",
str(dest),
] ]
def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]: def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]:
"""Decode a downloaded thumbnail (webp/png/…) to a single baseline JPEG frame.""" """Decode a downloaded thumbnail (webp/png/…) to a single baseline JPEG frame."""
return [ return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", "ffmpeg",
"-i", str(src), "-frames:v", "1", "-update", "1", str(dest), "-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-i",
str(src),
"-frames:v",
"1",
"-update",
"1",
str(dest),
] ]
@@ -550,7 +669,10 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
return None return None
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s) err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
if err is not None: if err is not None:
log.info("thumbnail->jpg conversion failed (%s); poster falls back to a cut frame", str(err)[:120]) log.info(
"thumbnail->jpg conversion failed (%s); poster falls back to a cut frame",
str(err)[:120],
)
return None return None
return dest if dest.exists() else None return dest if dest.exists() else None
+2 -3
View File
@@ -13,6 +13,7 @@ spec shape (all keys optional; defaults applied by `normalize`):
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort) vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
""" """
import hashlib import hashlib
import json import json
@@ -215,8 +216,6 @@ def build_ydl_opts(
# jpg/png thumbnail directly and converts anything else to PNG (not the crashing mjpeg2jpeg-to-jpg # jpg/png thumbnail directly and converts anything else to PNG (not the crashing mjpeg2jpeg-to-jpg
# path), so it never needed the convertor and does not reintroduce the crash. # path), so it never needed the convertor and does not reintroduce the crash.
if s["embed_thumbnail"]: if s["embed_thumbnail"]:
opts["postprocessors"].append( opts["postprocessors"].append({"key": "EmbedThumbnail", "already_have_thumbnail": True})
{"key": "EmbedThumbnail", "already_have_thumbnail": True}
)
return opts return opts
+39 -15
View File
@@ -15,6 +15,7 @@ Five passes each run:
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota. Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
""" """
import logging import logging
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -40,9 +41,7 @@ _ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight do
def _holders(db: Session, asset_id: int) -> list[int]: def _holders(db: Session, asset_id: int) -> list[int]:
return list( return list(
db.execute( db.execute(
select(DownloadJob.user_id) select(DownloadJob.user_id).where(DownloadJob.asset_id == asset_id).distinct()
.where(DownloadJob.asset_id == asset_id)
.distinct()
).scalars() ).scalars()
) )
@@ -101,7 +100,9 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
pass pass
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
for d in sorted( for d in sorted(
(p for p in entry.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True (p for p in entry.rglob("*") if p.is_dir()),
key=lambda p: len(p.parts),
reverse=True,
): ):
try: try:
d.rmdir() d.rmdir()
@@ -121,7 +122,8 @@ def run_download_gc(db: Session) -> dict:
# 1. Pre-expiry warnings. # 1. Pre-expiry warnings.
warn_before = now + timedelta(days=grace_days) warn_before = now + timedelta(days=grace_days)
soon = db.execute( soon = (
db.execute(
select(MediaAsset).where( select(MediaAsset).where(
MediaAsset.status == "ready", MediaAsset.status == "ready",
MediaAsset.gc_notified.is_(False), MediaAsset.gc_notified.is_(False),
@@ -129,7 +131,10 @@ def run_download_gc(db: Session) -> dict:
MediaAsset.expires_at > now, MediaAsset.expires_at > now,
MediaAsset.expires_at <= warn_before, MediaAsset.expires_at <= warn_before,
) )
).scalars().all() )
.scalars()
.all()
)
for asset in soon: for asset in soon:
_notify_holders(db, asset, "download_expiring") _notify_holders(db, asset, "download_expiring")
asset.gc_notified = True asset.gc_notified = True
@@ -138,13 +143,17 @@ def run_download_gc(db: Session) -> dict:
db.commit() db.commit()
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL). # 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
dead = db.execute( dead = (
db.execute(
select(MediaAsset).where( select(MediaAsset).where(
MediaAsset.status.in_(("ready", "error")), MediaAsset.status.in_(("ready", "error")),
MediaAsset.expires_at.is_not(None), MediaAsset.expires_at.is_not(None),
MediaAsset.expires_at <= now, MediaAsset.expires_at <= now,
) )
).scalars().all() )
.scalars()
.all()
)
for asset in dead: for asset in dead:
_delete_asset(db, asset, "download_expired") _delete_asset(db, asset, "download_expired")
expired += 1 expired += 1
@@ -154,17 +163,24 @@ def run_download_gc(db: Session) -> dict:
# 3. LRU eviction over the total cache cap. # 3. LRU eviction over the total cache cap.
cap = sysconfig.get_int(db, "download_total_max_bytes") cap = sysconfig.get_int(db, "download_total_max_bytes")
if cap > 0: if cap > 0:
total = db.execute( total = (
db.execute(
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where( select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
MediaAsset.status == "ready" MediaAsset.status == "ready"
) )
).scalar() or 0 ).scalar()
or 0
)
if total > cap: if total > cap:
candidates = db.execute( candidates = (
db.execute(
select(MediaAsset) select(MediaAsset)
.where(MediaAsset.status == "ready") .where(MediaAsset.status == "ready")
.order_by(MediaAsset.last_access_at.asc().nulls_first()) .order_by(MediaAsset.last_access_at.asc().nulls_first())
).scalars().all() )
.scalars()
.all()
)
for asset in candidates: for asset in candidates:
if total <= cap: if total <= cap:
break break
@@ -176,13 +192,17 @@ def run_download_gc(db: Session) -> dict:
# 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still- # 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still-
# referenced errored row to pending on re-enqueue, so these only accumulate once every # referenced errored row to pending on re-enqueue, so these only accumulate once every
# holder is gone; they carry no expires_at, so passes 1-3 never touch them. # holder is gone; they carry no expires_at, so passes 1-3 never touch them.
orphans = db.execute( orphans = (
db.execute(
select(MediaAsset).where( select(MediaAsset).where(
MediaAsset.status == "error", MediaAsset.status == "error",
MediaAsset.ref_count == 0, MediaAsset.ref_count == 0,
MediaAsset.rel_path.is_(None), MediaAsset.rel_path.is_(None),
) )
).scalars().all() )
.scalars()
.all()
)
for asset in orphans: for asset in orphans:
db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id
reaped += 1 reaped += 1
@@ -194,7 +214,11 @@ def run_download_gc(db: Session) -> dict:
swept = _sweep_orphan_files(db, settings.download_root) swept = _sweep_orphan_files(db, settings.download_root)
result = { result = {
"warned": warned, "expired": expired, "evicted": evicted, "reaped": reaped, "swept": swept "warned": warned,
"expired": expired,
"evicted": evicted,
"reaped": reaped,
"swept": swept,
} }
log.info("download_gc %s", result) log.info("download_gc %s", result)
return result return result
+2
View File
@@ -1,4 +1,5 @@
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline.""" """Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
from pathlib import Path from pathlib import Path
from sqlalchemy import text from sqlalchemy import text
@@ -10,6 +11,7 @@ from app.models import DownloadJob, MediaAsset
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) ------- # --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
def _set_job(job_id: int, **fields) -> None: def _set_job(job_id: int, **fields) -> None:
with SessionLocal() as db: with SessionLocal() as db:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
+7 -4
View File
@@ -6,6 +6,7 @@ require a short-lived signed **grant** on the file request — because a `<video
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL, header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
so the raw password never rides in the media URL (or server logs). so the raw password never rides in the media URL (or server logs).
""" """
import hashlib import hashlib
import hmac import hmac
import secrets import secrets
@@ -27,10 +28,9 @@ def is_expired(link: DownloadLink) -> bool:
# --- signed grants (password-protected file access) ---------------------------------------- # --- signed grants (password-protected file access) ----------------------------------------
def _sign(msg: str) -> str: def _sign(msg: str) -> str:
return hmac.new( return hmac.new(settings.secret_key.encode(), msg.encode(), hashlib.sha256).hexdigest()[:32]
settings.secret_key.encode(), msg.encode(), hashlib.sha256
).hexdigest()[:32]
def make_grant(token: str, password_version: int, now: float | None = None) -> str: def make_grant(token: str, password_version: int, now: float | None = None) -> str:
@@ -58,6 +58,7 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
# --- serialization ------------------------------------------------------------------------- # --- serialization -------------------------------------------------------------------------
def owner_view(link: DownloadLink, base_url: str = "") -> dict: def owner_view(link: DownloadLink, base_url: str = "") -> dict:
"""What the file owner sees when managing their links.""" """What the file owner sees when managing their links."""
return { return {
@@ -72,7 +73,9 @@ def owner_view(link: DownloadLink, base_url: str = "") -> dict:
} }
def public_meta(link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None) -> dict: def public_meta(
link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None
) -> dict:
"""Metadata for the public watch page (only ever returned once access is authorized). """Metadata for the public watch page (only ever returned once access is authorized).
Resolves the owner's per-download display overrides (custom title/channel) over the asset's Resolves the owner's per-download display overrides (custom title/channel) over the asset's
+2 -3
View File
@@ -6,6 +6,7 @@ This injects per-video OG/Twitter tags into the served HTML so a shared link unf
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak). usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
""" """
import html import html
import re import re
from pathlib import Path from pathlib import Path
@@ -71,9 +72,7 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
def _watch_tags(token: str, db: Session) -> str | None: def _watch_tags(token: str, db: Session) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card.""" """Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute( link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
select(DownloadLink).where(DownloadLink.token == token)
).scalar_one_or_none()
if link is None or linksmod.is_expired(link): if link is None or linksmod.is_expired(link):
return None return None
url = f"{settings.app_base}/watch/{token}" url = f"{settings.app_base}/watch/{token}"
+51 -18
View File
@@ -1,4 +1,5 @@
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess.""" """Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
import logging import logging
import re import re
import shutil import shutil
@@ -57,9 +58,7 @@ _YTDLP_TEMP_RE = re.compile(
) )
def _notify_download_done( def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None:
db, user_id: int | None, title: str | None, job_id, *, kind: str
) -> None:
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish """Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
@@ -247,7 +246,9 @@ def _make_pp_hook(job_id: int):
def hook(d: dict) -> None: def hook(d: dict) -> None:
if d.get("status") in ("started", "processing"): if d.get("status") in ("started", "processing"):
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None) _set_job(
job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None
)
return hook return hook
@@ -269,7 +270,9 @@ def _produced_file(info: dict, staging: Path) -> Path:
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
working files (see `_YTDLP_TEMP_RE`).""" working files (see `_YTDLP_TEMP_RE`)."""
req = (info.get("requested_downloads") or [{}])[0] req = (info.get("requested_downloads") or [{}])[0]
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename") fp = (
req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
)
if fp: if fp:
produced = Path(str(fp)) produced = Path(str(fp))
if produced.is_file(): if produced.is_file():
@@ -310,7 +313,9 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
last_exc: Exception | None = None last_exc: Exception | None = None
for i, clients in enumerate(client_sets): for i, clients in enumerate(client_sets):
opts = formats.build_ydl_opts( opts = formats.build_ydl_opts(
spec, outtmpl, _make_progress_hook(job_id, asset_id), spec,
outtmpl,
_make_progress_hook(job_id, asset_id),
extra=adapter.ydl_opts_extra(clients), extra=adapter.ydl_opts_extra(clients),
) )
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)] opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
@@ -324,7 +329,12 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
except yt_dlp.utils.DownloadError as exc: except yt_dlp.utils.DownloadError as exc:
last_exc = exc last_exc = exc
if i < len(client_sets) - 1 and adapter.retriable(str(exc)): if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70]) log.info(
"job %s: clients %s failed (%s); retrying with fallback",
job_id,
clients,
str(exc)[:70],
)
for p in list(staging.iterdir()): # drop partials before the retry for p in list(staging.iterdir()): # drop partials before the retry
try: try:
p.unlink() p.unlink()
@@ -363,7 +373,9 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
dest = produced.with_name(produced.stem + ".browser.mp4") dest = produced.with_name(produced.stem + ".browser.mp4")
action = "re-encode" if (reenc_v or reenc_a) else "remux" action = "re-encode" if (reenc_v or reenc_a) else "remux"
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4) log.info(
"job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4
)
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None) _set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long # Prefer the info duration; probe the file when the extractor didn't provide one, so a long
# re-encode still shows real progress instead of a frozen 0%. # re-encode still shows real progress instead of a frozen 0%.
@@ -436,8 +448,12 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
asset.width = int(info["width"]) if info.get("width") else None asset.width = int(info["width"]) if info.get("width") else None
asset.height = int(info["height"]) if info.get("height") else None asset.height = int(info["height"]) if info.get("height") else None
asset.container = ext asset.container = ext
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None asset.vcodec = (
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
)
asset.acodec = (
info.get("acodec") if info.get("acodec") not in (None, "none") else None
)
asset.title = meta.title asset.title = meta.title
asset.uploader = meta.uploader asset.uploader = meta.uploader
asset.uploader_url = adapter.uploader_url(info) asset.uploader_url = adapter.uploader_url(info)
@@ -456,8 +472,13 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify). # Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
owner_id = job.user_id if job else None owner_id = job.user_id if job else None
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None, fields = {
"eta_s": None} "status": JobStatus.DONE,
"progress": 100,
"phase": None,
"speed_bps": None,
"eta_s": None,
}
if job and not job.display_name and meta.title: if job and not job.display_name and meta.title:
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early). # display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
fields["display_name"] = meta.title[:255] fields["display_name"] = meta.title[:255]
@@ -484,8 +505,12 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten # Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
# with `error` (error also holds the asset → would re-open the double-release class). # with `error` (error also holds the asset → would re-open the double-release class).
service.finalize_if_running( service.finalize_if_running(
db, job_id, status=JobStatus.ERROR, error=msg, db,
error_code=dlerrors.classify(msg), phase=None, job_id,
status=JobStatus.ERROR,
error=msg,
error_code=dlerrors.classify(msg),
phase=None,
) )
db.commit() db.commit()
log.warning("job %s failed: %s", job_id, msg) log.warning("job %s failed: %s", job_id, msg)
@@ -501,8 +526,12 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
asset.error = msg asset.error = msg
# Atomic fail: only while still running (see the download error path). # Atomic fail: only while still running (see the download error path).
service.finalize_if_running( service.finalize_if_running(
db, job_id, status=JobStatus.ERROR, error=msg, db,
error_code=dlerrors.classify(msg), phase=None, job_id,
status=JobStatus.ERROR,
error=msg,
error_code=dlerrors.classify(msg),
phase=None,
) )
db.commit() db.commit()
log.warning("edit job %s failed: %s", job_id, msg) log.warning("edit job %s failed: %s", job_id, msg)
@@ -517,7 +546,9 @@ def _process_edit(job_id: int, asset_id: int) -> None:
user_id = job.user_id if job else None user_id = job.user_id if job else None
src_ready = src is not None and src.status == "ready" and bool(src.rel_path) src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
src_rel = src.rel_path if src else None src_rel = src.rel_path if src else None
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None) src_w, src_h, src_dur = (
(src.width, src.height, src.duration_s) if src else (None, None, None)
)
if not src_ready or not src_rel: if not src_ready or not src_rel:
_fail_edit(job_id, asset_id, "The source download is no longer available.") _fail_edit(job_id, asset_id, "The source download is no longer available.")
@@ -541,7 +572,9 @@ def _process_edit(job_id: int, asset_id: int) -> None:
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None) _set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
if edit_spec.get("segments"): if edit_spec.get("segments"):
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging) cmd, prep = editmod.build_concat_plan(
src_path, dest_staging, edit_spec, out_ext, staging
)
for p, content in prep.items(): for p, content in prep.items():
p.write_text(content, encoding="utf-8") p.write_text(content, encoding="utf-8")
else: else:
+1
View File
@@ -8,6 +8,7 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads). max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
""" """
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import func, select from sqlalchemy import func, select
+1
View File
@@ -10,6 +10,7 @@ Contract: the asset is already claimed ('downloading') by the parent. This proce
writes the terminal job/asset state itself (done / error, or a cooperative pause via the in-flight writes the terminal job/asset state itself (done / error, or a cooperative pause via the in-flight
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job. progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
""" """
import logging import logging
import sys import sys
+3 -1
View File
@@ -6,6 +6,7 @@ cache hit (asset already ready) — marks the job done instantly with no re-down
handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping. the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
@@ -166,7 +167,8 @@ def enqueue(
source_ref=source_ref, source_ref=source_ref,
profile_id=profile_id, profile_id=profile_id,
profile_snapshot=spec, profile_snapshot=spec,
display_name=display_name or (asset.title[:255] if asset.status == "ready" and asset.title else None), display_name=display_name
or (asset.title[:255] if asset.status == "ready" and asset.title else None),
status="queued", status="queued",
queue_pos=next_queue_pos(db), queue_pos=next_queue_pos(db),
) )
+24 -7
View File
@@ -10,6 +10,7 @@ Plus a Kodi/Plex-readable `<basename>.nfo` (episodedetails) and `<basename>.jpg`
a Plex library using a metadata agent picks up rich info. Channel = "show", video = episode, a Plex library using a metadata agent picks up rich info. Channel = "show", video = episode,
date-based — the community-standard mapping for YouTube-in-Plex. date-based — the community-standard mapping for YouTube-in-Plex.
""" """
import re import re
import shutil import shutil
import unicodedata import unicodedata
@@ -36,9 +37,27 @@ _DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
# that accented names show in non-UTF-8 tools. # that accented names show in non-UTF-8 tools.
_FOLD_MAP = str.maketrans( _FOLD_MAP = str.maketrans(
{ {
"ß": "ss", "ẞ": "SS", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ß": "ss",
"ð": "d", "Ð": "D", "þ": "th", "Þ": "Th", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE", "ẞ": "SS",
"ı": "i", "İ": "I", "·": " ", "–": "-", "—": "-", "ø": "o",
"Ø": "O",
"ł": "l",
"Ł": "L",
"đ": "d",
"Đ": "D",
"ð": "d",
"Ð": "D",
"þ": "th",
"Þ": "Th",
"æ": "ae",
"Æ": "AE",
"œ": "oe",
"Œ": "OE",
"ı": "i",
"İ": "I",
"·": " ",
"–": "-",
"—": "-",
} }
) )
@@ -174,7 +193,7 @@ def _nfo_xml(meta: MediaMeta) -> str:
f" <premiered>{aired}</premiered>", f" <premiered>{aired}</premiered>",
f" <year>{year}</year>", f" <year>{year}</year>",
f" <plot>{escape(meta.description or '')}</plot>", f" <plot>{escape(meta.description or '')}</plot>",
f" <uniqueid type=\"{escape(meta.uniqueid_type)}\" default=\"true\">{escape(meta.video_id)}</uniqueid>", f' <uniqueid type="{escape(meta.uniqueid_type)}" default="true">{escape(meta.video_id)}</uniqueid>',
] ]
if meta.duration_s: if meta.duration_s:
parts.append(f" <runtime>{round(meta.duration_s / 60)}</runtime>") parts.append(f" <runtime>{round(meta.duration_s / 60)}</runtime>")
@@ -190,9 +209,7 @@ def _sidecar(media: Path, ext: str) -> Path:
return media.with_suffix(ext) return media.with_suffix(ext)
def write_sidecars( def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None) -> bool:
download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None
) -> bool:
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written.""" """Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
media = abs_path(download_root, rel) media = abs_path(download_root, rel)
try: try:
+6 -13
View File
@@ -4,6 +4,7 @@ Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't conf
or a send errors, we log and return False so the caller can fall back to in-app or a send errors, we log and return False so the caller can fall back to in-app
notifications — email is never load-bearing for the app's core flow. notifications — email is never load-bearing for the app's core flow.
""" """
import logging import logging
import smtplib import smtplib
import ssl import ssl
@@ -77,8 +78,7 @@ def send_access_approved(email: str) -> bool:
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n" "Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
f"{settings.app_base}\n\n" f"{settings.app_base}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n" "If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode" "— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
) )
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin) return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
@@ -90,8 +90,7 @@ def send_access_denied(email: str) -> bool:
"Thanks for your interest in Siftlode. After review, your access request wasn't approved " "Thanks for your interest in Siftlode. After review, your access request wasn't approved "
"this time.\n\n" "this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n" "If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode" "— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
) )
return _send([email], "About your Siftlode access request", body, reply_to=admin) return _send([email], "About your Siftlode access request", body, reply_to=admin)
@@ -113,9 +112,7 @@ def send_account_unsuspended(to: str, operator: str | None) -> bool:
body = ( body = (
"Hi,\n\n" "Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign " "Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
"in again." "in again." + contact + "\n\n— Siftlode"
+ contact
+ "\n\n— Siftlode"
) )
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator) return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
@@ -130,9 +127,7 @@ def send_account_deleted(to: str, operator: str | None) -> bool:
"Hi,\n\n" "Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, " "Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n" "playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode." "Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
+ contact
+ "\n\n— Siftlode"
) )
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator) return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
@@ -147,9 +142,7 @@ def send_account_suspended(to: str, operator: str | None) -> bool:
body = ( body = (
"Hi,\n\n" "Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently " "A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n" "suspended, so access was denied.\n\n" + contact + "— Siftlode"
+ contact
+ "— Siftlode"
) )
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator) return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
+51 -27
View File
@@ -144,9 +144,7 @@ def filtered_query(
state = aliased(VideoState) state = aliased(VideoState)
status_expr = func.coalesce(state.status, "new") status_expr = func.coalesce(state.status, "new")
query = select(*feed_columns(user, state)).join( query = select(*feed_columns(user, state)).join(Channel, Channel.id == Video.channel_id)
Channel, Channel.id == Video.channel_id
)
if scope == "all": if scope == "all":
# Whole shared catalog; subscription is optional (only for priority sort). # Whole shared catalog; subscription is optional (only for priority sort).
@@ -181,9 +179,7 @@ def filtered_query(
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join. # exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
state_required = show in ("watched", "hidden", "in_progress") state_required = show in ("watched", "hidden", "in_progress")
join_state = query.join if state_required else query.outerjoin join_state = query.join if state_required else query.outerjoin
query = join_state( query = join_state(state, and_(state.video_id == Video.id, state.user_id == user.id))
state, and_(state.video_id == Video.id, state.user_id == user.id)
)
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/ # Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
# abandoned). They're either deleted after a grace period or unhidden if they recover; # abandoned). They're either deleted after a grace period or unhidden if they recover;
@@ -197,9 +193,7 @@ def filtered_query(
# asking for scope=all + channel_ids (the channel is in this accessible set). # asking for scope=all + channel_ids (the channel is in this accessible set).
accessible_explore = ( accessible_explore = (
select(Subscription.channel_id).where(Subscription.user_id == user.id) select(Subscription.channel_id).where(Subscription.user_id == user.id)
).union( ).union(select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id))
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
)
query = query.where( query = query.where(
or_( or_(
Video.via_explore.is_(False), Video.via_explore.is_(False),
@@ -244,13 +238,9 @@ def filtered_query(
query = query.where(Video.duration_seconds <= max_duration) query = query.where(Video.duration_seconds <= max_duration)
if max_age_days is not None: if max_age_days is not None:
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400 cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
query = query.where( query = query.where(Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc))
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
)
if published_after is not None: if published_after is not None:
start = datetime.combine( start = datetime.combine(published_after, datetime.min.time(), tzinfo=timezone.utc)
published_after, datetime.min.time(), tzinfo=timezone.utc
)
query = query.where(Video.published_at >= start) query = query.where(Video.published_at >= start)
if published_before is not None: if published_before is not None:
end = datetime.combine( end = datetime.combine(
@@ -314,9 +304,7 @@ def filtered_query(
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids))) .having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
) )
else: else:
sub = select(ChannelTag.channel_id).where( sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
ChannelTag.tag_id.in_(ids), visible
)
query = query.where(Video.channel_id.in_(sub)) query = query.where(Video.channel_id.in_(sub))
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
@@ -403,7 +391,9 @@ def feed_summary(db: Session, user: User, **params) -> dict:
if cat == exclude_cat: if cat == exclude_cat:
continue continue
if tag_modes.get(cat, "or") == "and" and len(sel) > 1: if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags if (
not sel <= ch_tags
): # conjunctive: channel must carry ALL this category's selected tags
return False return False
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
return False return False
@@ -443,19 +433,52 @@ def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
"oldest": [{**pub, "asc": True}], "oldest": [{**pub, "asc": True}],
"views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}], "views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}],
"views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}], "views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}],
"duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}], "duration_desc": [
"duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}], {"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}
],
"duration_asc": [
{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}
],
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}], "title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}],
"title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}], "title_desc": [
"subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}], {"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}
"subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}], ],
"subscribers": [
{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}
],
"subscribers_asc": [
{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}
],
# Your per-channel priority (set in the channel manager), newest first within a tier. # Your per-channel priority (set in the channel manager), newest first within a tier.
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. # coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
"priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub], "priority": [
"priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub], {
"expr": func.coalesce(Subscription.priority, 0),
"asc": False,
"nullable": False,
"kind": "num",
},
pub,
],
"priority_asc": [
{
"expr": func.coalesce(Subscription.priority, 0),
"asc": True,
"nullable": False,
"kind": "num",
},
pub,
],
# Deterministic per-seed shuffle: the same seed yields the same total order, so # Deterministic per-seed shuffle: the same seed yields the same total order, so
# the cursor stays valid across pages of one shuffled run. # the cursor stays valid across pages of one shuffled run.
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}], "shuffle": [
{
"expr": func.md5(func.concat(Video.id, str(seed))),
"asc": True,
"nullable": False,
"kind": "str",
}
],
} }
# Relevance (search-result ranking) only exists when there's a query to rank against; # Relevance (search-result ranking) only exists when there's a query to rank against;
# without `rank_expr` it falls through to newest. Ties break by newest, then id. # without `rank_expr` it falls through to newest. Ties break by newest, then id.
@@ -490,6 +513,7 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
`newest` pages went 51.8k -> ~0.5k buffers). A NULLABLE key can't take that bound (the `newest` pages went 51.8k -> ~0.5k buffers). A NULLABLE key can't take that bound (the
NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure
NULL-aware OR form.""" NULL-aware OR form."""
def eq(k): def eq(k):
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"] return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
+2 -6
View File
@@ -12,9 +12,7 @@ from fastapi import FastAPI, HTTPException
_siftlode_logger = logging.getLogger("siftlode") _siftlode_logger = logging.getLogger("siftlode")
if not _siftlode_logger.handlers: if not _siftlode_logger.handlers:
_handler = logging.StreamHandler(sys.stdout) _handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter( _handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s"))
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
)
_siftlode_logger.addHandler(_handler) _siftlode_logger.addHandler(_handler)
_siftlode_logger.setLevel(logging.INFO) _siftlode_logger.setLevel(logging.INFO)
_siftlode_logger.propagate = False _siftlode_logger.propagate = False
@@ -140,9 +138,7 @@ async def setup_gate(request, call_next):
with SessionLocal() as db: with SessionLocal() as db:
configured = state.is_configured_cached(db) configured = state.is_configured_cached(db)
if not configured: if not configured:
return JSONResponse( return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
{"detail": "This instance isn't set up yet."}, status_code=503
)
return await call_next(request) return await call_next(request)
+101 -183
View File
@@ -26,9 +26,7 @@ class TimestampMixin:
"""Adds a server-defaulted `created_at`. Models that index created_at or order it """Adds a server-defaulted `created_at`. Models that index created_at or order it
differently (QuotaEvent, Notification, Message) keep their own column on purpose.""" differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
DateTime(timezone=True), server_default=func.now()
)
class UpdatedAtMixin: class UpdatedAtMixin:
@@ -51,18 +49,14 @@ class User(Base, TimestampMixin):
password_hash: Mapped[str | None] = mapped_column(String(255)) password_hash: Mapped[str | None] = mapped_column(String(255))
# Email ownership proven (Google sign-in is implicitly verified; password registration # Email ownership proven (Google sign-in is implicitly verified; password registration
# verifies via a link). Login requires this AND is_active. # verifies via a link). Login requires this AND is_active.
email_verified: Mapped[bool] = mapped_column( email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# Admin-approved & enabled. A pending password registration starts inactive; admin # Admin-approved & enabled. A pending password registration starts inactive; admin
# approval activates it. A deactivated account can't log in. # approval activates it. A deactivated account can't log in.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended # Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
# account can't sign in by any method and its live sessions are rejected; the user is told # account can't sign in by any method and its live sessions are rejected; the user is told
# why and pointed at the operator's contact. The demo account is never suspendable. # why and pointed at the operator's contact. The demo account is never suspendable.
is_suspended: Mapped[bool] = mapped_column( is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password # Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under; # change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those # current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
@@ -95,9 +89,7 @@ class OAuthToken(Base, UpdatedAtMixin):
__tablename__ = "oauth_tokens" __tablename__ = "oauth_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True)
ForeignKey("users.id", ondelete="CASCADE"), unique=True
)
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk. # Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
refresh_token_enc: Mapped[str | None] = mapped_column(String) refresh_token_enc: Mapped[str | None] = mapped_column(String)
access_token: Mapped[str | None] = mapped_column(String) access_token: Mapped[str | None] = mapped_column(String)
@@ -115,9 +107,7 @@ class AuthToken(Base, TimestampMixin):
__tablename__ = "auth_tokens" __tablename__ = "auth_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset" kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
@@ -133,9 +123,7 @@ class Invite(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# pending | approved | denied # pending | approved | denied
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
String(16), default="pending", server_default="pending"
)
note: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text)
requested_at: Mapped[datetime] = mapped_column( requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
@@ -189,9 +177,7 @@ class Channel(Base, TimestampMixin):
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
backfill_cursor: Mapped[str | None] = mapped_column(String(128)) backfill_cursor: Mapped[str | None] = mapped_column(String(128))
backfill_done: Mapped[bool] = mapped_column( backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# True when this channel only entered the catalog because a live YouTube search # True when this channel only entered the catalog because a live YouTube search
# surfaced one of its videos (nobody subscribes to it / it has no organic upload sync). # surfaced one of its videos (nobody subscribes to it / it has no organic upload sync).
# Lets the Library hide search-only "noise" by default. # Lets the Library hide search-only "noise" by default.
@@ -218,9 +204,7 @@ class Subscription(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),) __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE")
)
channel_id: Mapped[str] = mapped_column( channel_id: Mapped[str] = mapped_column(
ForeignKey("channels.id", ondelete="CASCADE"), index=True ForeignKey("channels.id", ondelete="CASCADE"), index=True
) )
@@ -231,9 +215,7 @@ class Subscription(Base, TimestampMixin):
# Per-user opt-in to full-history (deep) backfill of this channel. Default off so a # Per-user opt-in to full-history (deep) backfill of this channel. Default off so a
# new user's unique channels don't trigger a big shared-quota burst; the deep # new user's unique channels don't trigger a big shared-quota burst; the deep
# scheduler only picks up channels at least one user has requested. # scheduler only picks up channels at least one user has requested.
deep_requested: Mapped[bool] = mapped_column( deep_requested: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user: Mapped["User"] = relationship(back_populates="subscriptions") user: Mapped["User"] = relationship(back_populates="subscriptions")
@@ -255,9 +237,7 @@ class Video(Base, TimestampMixin):
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it # (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed # serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`. # sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
channel_id: Mapped[str] = mapped_column( channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
ForeignKey("channels.id", ondelete="CASCADE")
)
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles) title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@@ -289,27 +269,19 @@ class Video(Base, TimestampMixin):
# boolean btrees and replaced the useful ones with partial indexes over the target subset # boolean btrees and replaced the useful ones with partial indexes over the target subset
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the # (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
# search_vector GIN. # search_vector GIN.
is_short: Mapped[bool] = mapped_column( is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# Whether the youtube.com/shorts/<id> probe has run for this video. # Whether the youtube.com/shorts/<id> probe has run for this video.
shorts_probed: Mapped[bool] = mapped_column( shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# True when this video first entered the catalog via a live YouTube search (not an # True when this video first entered the catalog via a live YouTube search (not an
# organic channel/playlist sync). Used to hide search-discovered "noise" from the # organic channel/playlist sync). Used to hide search-discovered "noise" from the
# Library by default; left False by the organic ingest paths. # Library by default; left False by the organic ingest paths.
via_search: Mapped[bool] = mapped_column( via_search: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# True when this video entered the catalog because a user EXPLORED its (un-subscribed) # True when this video entered the catalog because a user EXPLORED its (un-subscribed)
# channel. Like via_search it's hidden from the shared Library by default, but it's # channel. Like via_search it's hidden from the shared Library by default, but it's
# additionally PER-USER private: a via_explore video is only visible to users who # additionally PER-USER private: a via_explore video is only visible to users who
# explored or subscribe to its channel (a gate in the feed query), so one user's # explored or subscribe to its channel (a gate in the feed query), so one user's
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths. # curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
via_explore: Mapped[bool] = mapped_column( via_explore: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# none | live | upcoming | premiere | was_live # none | live | upcoming | premiere | was_live
live_status: Mapped[str] = mapped_column( live_status: Mapped[str] = mapped_column(
String(16), default="none", server_default="none", index=True String(16), default="none", server_default="none", index=True
@@ -319,7 +291,9 @@ class Video(Base, TimestampMixin):
# maintenance/validation job to decide whether a video is playable anywhere. # maintenance/validation job to decide whether a video is playable anywhere.
embeddable: Mapped[bool | None] = mapped_column(Boolean) embeddable: Mapped[bool | None] = mapped_column(Boolean)
privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private
upload_status: Mapped[str | None] = mapped_column(String(16)) # processed|failed|rejected|deleted upload_status: Mapped[str | None] = mapped_column(
String(16)
) # processed|failed|rejected|deleted
# Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation # Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation
# (least-recently-checked first); unavailable_since marks a video as currently # (least-recently-checked first); unavailable_since marks a video as currently
# unplayable (hidden from the feed immediately, hard-deleted after a grace period); # unplayable (hidden from the feed immediately, hard-deleted after a grace period);
@@ -327,12 +301,8 @@ class Video(Base, TimestampMixin):
# last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial # last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the # ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3). # revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
last_checked_at: Mapped[datetime | None] = mapped_column( last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
DateTime(timezone=True) unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
)
unavailable_since: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True)
)
unavailable_reason: Mapped[str | None] = mapped_column(String(24)) unavailable_reason: Mapped[str | None] = mapped_column(String(24))
# Weighted full-text search document (DB-generated, never written by the app): title (A) > # Weighted full-text search document (DB-generated, never written by the app): title (A) >
@@ -366,9 +336,7 @@ class Tag(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64))
color: Mapped[str | None] = mapped_column(String(16)) color: Mapped[str | None] = mapped_column(String(16))
# language | topic | other # language | topic | other
category: Mapped[str] = mapped_column( category: Mapped[str] = mapped_column(String(16), default="other", server_default="other")
String(16), default="other", server_default="other"
)
class ChannelTag(Base): class ChannelTag(Base):
@@ -376,21 +344,15 @@ class ChannelTag(Base):
regenerated freely; source='user' rows are never touched by the system.""" regenerated freely; source='user' rows are never touched by the system."""
__tablename__ = "channel_tags" __tablename__ = "channel_tags"
__table_args__ = ( __table_args__ = (UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),)
UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
channel_id: Mapped[str] = mapped_column( channel_id: Mapped[str] = mapped_column(
ForeignKey("channels.id", ondelete="CASCADE"), index=True ForeignKey("channels.id", ondelete="CASCADE"), index=True
) )
tag_id: Mapped[int] = mapped_column( tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), index=True)
ForeignKey("tags.id", ondelete="CASCADE"), index=True
)
# NULL = system/auto link visible to everyone; set = a specific user's link. # NULL = system/auto link visible to everyone; set = a specific user's link.
user_id: Mapped[int | None] = mapped_column( user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE")
)
source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto") source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
confidence: Mapped[float | None] = mapped_column(Float) confidence: Mapped[float | None] = mapped_column(Float)
@@ -403,12 +365,8 @@ class VideoState(Base, UpdatedAtMixin):
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),) __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE") video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
)
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status) # new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -433,12 +391,8 @@ class SearchFind(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),) __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE") video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
)
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
class ExploredChannel(Base, TimestampMixin): class ExploredChannel(Base, TimestampMixin):
@@ -453,14 +407,10 @@ class ExploredChannel(Base, TimestampMixin):
(from TimestampMixin) is the grace clock; a re-visit refreshes it.""" (from TimestampMixin) is the grace clock; a re-visit refreshes it."""
__tablename__ = "explored_channels" __tablename__ = "explored_channels"
__table_args__ = ( __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),)
UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE")
)
channel_id: Mapped[str] = mapped_column( channel_id: Mapped[str] = mapped_column(
ForeignKey("channels.id", ondelete="CASCADE"), index=True ForeignKey("channels.id", ondelete="CASCADE"), index=True
) )
@@ -472,14 +422,10 @@ class BlockedChannel(Base, TimestampMixin):
views — a personal "never show me this channel again" that doesn't affect other users.""" views — a personal "never show me this channel again" that doesn't affect other users."""
__tablename__ = "blocked_channels" __tablename__ = "blocked_channels"
__table_args__ = ( __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),)
UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE")
)
channel_id: Mapped[str] = mapped_column( channel_id: Mapped[str] = mapped_column(
ForeignKey("channels.id", ondelete="CASCADE"), index=True ForeignKey("channels.id", ondelete="CASCADE"), index=True
) )
@@ -544,16 +490,12 @@ class AppState(Base):
__tablename__ = "app_state" __tablename__ = "app_state"
id: Mapped[int] = mapped_column(primary_key=True, default=1) id: Mapped[int] = mapped_column(primary_key=True, default=1)
sync_paused: Mapped[bool] = mapped_column( sync_paused: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# First-run install wizard. `configured` is False on a fresh instance (the app runs in # First-run install wizard. `configured` is False on a fresh instance (the app runs in
# setup mode: only the wizard works) and flipped True when the wizard finishes. While # setup mode: only the wizard works) and flipped True when the wizard finishes. While
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard; # unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
# the raw token + URL are printed to the container logs on each startup. # the raw token + URL are printed to the container logs on each startup.
configured: Mapped[bool] = mapped_column( configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
setup_token_hash: Mapped[str | None] = mapped_column(String(64)) setup_token_hash: Mapped[str | None] = mapped_column(String(64))
@@ -599,14 +541,10 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
__tablename__ = "playlists" __tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
source: Mapped[str] = mapped_column( source: Mapped[str] = mapped_column(String(16), default="local", server_default="local")
String(16), default="local", server_default="local"
)
yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
# True when the current items/name differ from `synced_fingerprint` (unpushed edits). # True when the current items/name differ from `synced_fingerprint` (unpushed edits).
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
@@ -626,21 +564,13 @@ class PlaylistItem(Base):
"""A video's membership in a playlist, with its order within that playlist.""" """A video's membership in a playlist, with its order within that playlist."""
__tablename__ = "playlist_items" __tablename__ = "playlist_items"
__table_args__ = ( __table_args__ = (UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),)
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
playlist_id: Mapped[int] = mapped_column( playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"))
ForeignKey("playlists.id", ondelete="CASCADE") video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
)
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
added_at: Mapped[datetime] = mapped_column( added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
DateTime(timezone=True), server_default=func.now()
)
playlist: Mapped["Playlist"] = relationship(back_populates="items") playlist: Mapped["Playlist"] = relationship(back_populates="items")
@@ -659,16 +589,12 @@ class SavedView(Base, TimestampMixin, UpdatedAtMixin):
) )
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE")
)
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed") source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
name: Mapped[str] = mapped_column(String(80)) name: Mapped[str] = mapped_column(String(80))
filters: Mapped[dict] = mapped_column(JSON) filters: Mapped[dict] = mapped_column(JSON)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_default: Mapped[bool] = mapped_column( is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
class Notification(Base): class Notification(Base):
@@ -686,19 +612,13 @@ class Notification(Base):
__tablename__ = "notifications" __tablename__ = "notifications"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance" type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance"
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
body: Mapped[str | None] = mapped_column(Text) body: Mapped[str | None] = mapped_column(Text)
data: Mapped[dict | None] = mapped_column(JSON) data: Mapped[dict | None] = mapped_column(JSON)
read: Mapped[bool] = mapped_column( read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
Boolean, default=False, server_default="false", index=True dismissed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
)
dismissed: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True DateTime(timezone=True), server_default=func.now(), index=True
) )
@@ -724,9 +644,7 @@ class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column( kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
String(16), default="user", server_default="user"
)
# NULL for system messages (no human sender). # NULL for system messages (no human sender).
sender_id: Mapped[int | None] = mapped_column( sender_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True ForeignKey("users.id", ondelete="CASCADE"), index=True
@@ -786,13 +704,9 @@ class MediaAsset(Base, TimestampMixin):
container: Mapped[str | None] = mapped_column(String(16)) container: Mapped[str | None] = mapped_column(String(16))
vcodec: Mapped[str | None] = mapped_column(String(32)) vcodec: Mapped[str | None] = mapped_column(String(32))
acodec: Mapped[str | None] = mapped_column(String(32)) acodec: Mapped[str | None] = mapped_column(String(32))
nfo_written: Mapped[bool] = mapped_column( nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run. # Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
gc_notified: Mapped[bool] = mapped_column( gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
# Denormalized naming/metadata for the Plex tree + ad-hoc display. # Denormalized naming/metadata for the Plex tree + ad-hoc display.
title: Mapped[str | None] = mapped_column(Text) title: Mapped[str | None] = mapped_column(Text)
uploader: Mapped[str | None] = mapped_column(String(255)) uploader: Mapped[str | None] = mapped_column(String(255))
@@ -828,9 +742,7 @@ class DownloadProfile(Base, TimestampMixin):
) # NULL for a builtin preset ) # NULL for a builtin preset
name: Mapped[str] = mapped_column(String(80)) name: Mapped[str] = mapped_column(String(80))
spec: Mapped[dict] = mapped_column(JSON) spec: Mapped[dict] = mapped_column(JSON)
is_builtin: Mapped[bool] = mapped_column( is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0") sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
@@ -855,18 +767,14 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),) __table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
asset_id: Mapped[int | None] = mapped_column( asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
) )
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url" source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
source_ref: Mapped[str] = mapped_column(String(512)) source_ref: Mapped[str] = mapped_column(String(512))
# "download" (default) or "edit" (a per-user trim/crop derivative of another job). # "download" (default) or "edit" (a per-user trim/crop derivative of another job).
job_kind: Mapped[str] = mapped_column( job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
String(16), default="download", server_default="download"
)
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted). # For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
source_job_id: Mapped[int | None] = mapped_column( source_job_id: Mapped[int | None] = mapped_column(
ForeignKey("download_jobs.id", ondelete="SET NULL") ForeignKey("download_jobs.id", ondelete="SET NULL")
@@ -895,7 +803,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
eta_s: Mapped[int | None] = mapped_column(Integer) eta_s: Mapped[int | None] = mapped_column(Integer)
phase: Mapped[str | None] = mapped_column(String(32)) phase: Mapped[str | None] = mapped_column(String(32))
error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail) error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail)
error_code: Mapped[str | None] = mapped_column(String(24)) # category, see downloads.errors.classify error_code: Mapped[str | None] = mapped_column(
String(24)
) # category, see downloads.errors.classify
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0") queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
@@ -904,20 +814,14 @@ class DownloadShare(Base, TimestampMixin):
default). Since assets are already deduped/shared on disk, a share is just an ACL row.""" default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
__tablename__ = "download_shares" __tablename__ = "download_shares"
__table_args__ = ( __table_args__ = (UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),)
UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
job_id: Mapped[int] = mapped_column( job_id: Mapped[int] = mapped_column(ForeignKey("download_jobs.id", ondelete="CASCADE"))
ForeignKey("download_jobs.id", ondelete="CASCADE")
)
from_user_id: Mapped[int] = mapped_column( from_user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True ForeignKey("users.id", ondelete="CASCADE"), index=True
) )
to_user_id: Mapped[int] = mapped_column( to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
class DownloadQuota(Base, UpdatedAtMixin): class DownloadQuota(Base, UpdatedAtMixin):
@@ -933,9 +837,7 @@ class DownloadQuota(Base, UpdatedAtMixin):
max_bytes: Mapped[int] = mapped_column(BigInteger) max_bytes: Mapped[int] = mapped_column(BigInteger)
max_concurrent: Mapped[int] = mapped_column(Integer) max_concurrent: Mapped[int] = mapped_column(Integer)
max_jobs: Mapped[int] = mapped_column(Integer) max_jobs: Mapped[int] = mapped_column(Integer)
unlimited: Mapped[bool] = mapped_column( unlimited: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
class DownloadLink(Base, TimestampMixin): class DownloadLink(Base, TimestampMixin):
@@ -954,12 +856,8 @@ class DownloadLink(Base, TimestampMixin):
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
) )
token: Mapped[str] = mapped_column(String(64), unique=True) token: Mapped[str] = mapped_column(String(64), unique=True)
created_by: Mapped[int | None] = mapped_column( created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE") allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
)
allow_download: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
password_hash: Mapped[str | None] = mapped_column(Text) password_hash: Mapped[str | None] = mapped_column(Text)
# Bumped whenever the password changes; folded into the signed watch grant so rotating the # Bumped whenever the password changes; folded into the signed watch grant so rotating the
# password immediately invalidates any grants issued under the old one (instead of them living # password immediately invalidates any grants issued under the old one (instead of them living
@@ -1046,10 +944,14 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
title: Mapped[str] = mapped_column(String(512)) title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text) summary: Mapped[str | None] = mapped_column(Text)
year: Mapped[int | None] = mapped_column(Integer) year: Mapped[int | None] = mapped_column(Integer)
thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored) thumb_key: Mapped[str | None] = mapped_column(
String(512)
) # Plex thumb path (proxied, not stored)
art_key: Mapped[str | None] = mapped_column(String(512)) art_key: Mapped[str | None] = mapped_column(String(512))
child_count: Mapped[int | None] = mapped_column(Integer) # seasons child_count: Mapped[int | None] = mapped_column(Integer) # seasons
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Plex collections this show is in collection_keys: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # Plex collections this show is in
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem). # Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb) rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
@@ -1062,15 +964,21 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector people_text: Mapped[str | None] = mapped_column(
Text
) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating) imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text) tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}] full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive; # --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive;
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) --- # served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
tmdb_id: Mapped[int | None] = mapped_column(Integer) tmdb_id: Mapped[int | None] = mapped_column(Integer)
@@ -1080,8 +988,12 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
budget: Mapped[int | None] = mapped_column(BigInteger) budget: Mapped[int | None] = mapped_column(BigInteger)
revenue: Mapped[int | None] = mapped_column(BigInteger) revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}] crew: Mapped[list | None] = mapped_column(
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str] JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
@@ -1198,10 +1110,14 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating) imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text) tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}] full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB # --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating # backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched; # above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
@@ -1214,12 +1130,18 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none) budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
revenue: Mapped[int | None] = mapped_column(BigInteger) revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}] crew: Mapped[list | None] = mapped_column(
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str] JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time. # [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB backdrop file_paths [str] (crossfade pool, S8) backdrops: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB backdrop file_paths [str] (crossfade pool, S8)
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512)) title_hu: Mapped[str | None] = mapped_column(String(512))
@@ -1273,7 +1195,9 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
place_of_birth: Mapped[str | None] = mapped_column(Text) place_of_birth: Mapped[str | None] = mapped_column(Text)
known_for_department: Mapped[str | None] = mapped_column(String(64)) known_for_department: Mapped[str | None] = mapped_column(String(64))
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
known_for: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{tmdb_id, media_type, title}] known_for: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{tmdb_id, media_type, title}]
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -1309,9 +1233,7 @@ class PlexState(Base, UpdatedAtMixin):
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),) __table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
ForeignKey("users.id", ondelete="CASCADE")
)
item_id: Mapped[int] = mapped_column( item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
) )
@@ -1321,9 +1243,7 @@ class PlexState(Base, UpdatedAtMixin):
) )
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
synced_to_plex: Mapped[bool] = mapped_column( synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
Boolean, default=False, server_default="false"
)
class PlexLink(Base, UpdatedAtMixin): class PlexLink(Base, UpdatedAtMixin):
@@ -1378,9 +1298,7 @@ class PlexPlaylistItem(Base):
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),) __table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
playlist_id: Mapped[int] = mapped_column( playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
ForeignKey("plex_playlists.id", ondelete="CASCADE")
)
item_id: Mapped[int] = mapped_column( item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
) )
+2 -3
View File
@@ -5,6 +5,7 @@ survive reloads/devices and are produced server-side. First producer is the main
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
table can't grow without bound for a heavy user. table can't grow without bound for a heavy user.
""" """
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -37,9 +38,7 @@ def create_notification(
transaction can pass commit=False and commit once themselves.""" transaction can pass commit=False and commit once themselves."""
if title is not None and len(title) > TITLE_MAX_LEN: if title is not None and len(title) > TITLE_MAX_LEN:
title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis
notif = Notification( notif = Notification(user_id=user_id, type=type, title=title, body=body, data=data)
user_id=user_id, type=type, title=title, body=body, data=data
)
db.add(notif) db.add(notif)
if commit: if commit:
db.commit() db.commit()
+11 -3
View File
@@ -5,6 +5,7 @@ this client — Siftlode plays the LOCAL physical file (see app.plex.paths). Ple
metadata, image proxying, and (P5) optional watch-state write-back. Requests JSON via the metadata, image proxying, and (P5) optional watch-state write-back. Requests JSON via the
``Accept: application/json`` header (Plex serves XML by default). ``Accept: application/json`` header (Plex serves XML by default).
""" """
import logging import logging
from urllib.parse import quote from urllib.parse import quote
@@ -163,7 +164,9 @@ class PlexClient:
self._write("PUT", f"/library/collections/{collection_rating_key}/items?uri={uri}") self._write("PUT", f"/library/collections/{collection_rating_key}/items?uri={uri}")
def remove_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None: def remove_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None:
self._write("DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}") self._write(
"DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}"
)
def rename_collection(self, section_key: str, collection_rating_key: str, title: str) -> None: def rename_collection(self, section_key: str, collection_rating_key: str, title: str) -> None:
self._write( self._write(
@@ -221,7 +224,11 @@ class PlexClient:
return self._get("/accounts").get("Account", []) or [] return self._get("/accounts").get("Account", []) or []
def watch_history( def watch_history(
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500, max_pages: int = 40 self,
min_viewed_at: int = 0,
account_id: int | None = None,
size: int = 500,
max_pages: int = 40,
) -> list[dict]: ) -> list[dict]:
"""The watch-history rows (ratingKey / viewedAt / accountID / type) newer than `min_viewed_at` """The watch-history rows (ratingKey / viewedAt / accountID / type) newer than `min_viewed_at`
(epoch seconds), newest first — works without Plex Pass. The efficient "what changed since T" (epoch seconds), newest first — works without Plex Pass. The efficient "what changed since T"
@@ -255,7 +262,8 @@ class PlexClient:
else: else:
log.warning( log.warning(
"watch_history hit the %d-page scan cap (since=%s); reconcile will catch the tail", "watch_history hit the %d-page scan cap (since=%s); reconcile will catch the tail",
max_pages, min_viewed_at, max_pages,
min_viewed_at,
) )
return out return out
+1 -3
View File
@@ -38,9 +38,7 @@ def rich_detail(meta: dict) -> dict:
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
"content_rating": meta.get("contentRating"), "content_rating": meta.get("contentRating"),
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
"directors": [ "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")
][:4],
"studio": meta.get("studio"), "studio": meta.get("studio"),
"tagline": meta.get("tagline"), "tagline": meta.get("tagline"),
"cast": people.cast_from_meta(meta), "cast": people.cast_from_meta(meta),
+1 -3
View File
@@ -63,6 +63,4 @@ def write(key: str, data: bytes, ct: str) -> None:
def response(data: bytes, ct: str) -> Response: def response(data: bytes, ct: str) -> Response:
return Response( return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
)
+2 -3
View File
@@ -1,4 +1,5 @@
"""Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place.""" """Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place."""
from typing import TypeVar from typing import TypeVar
from sqlalchemy import select from sqlalchemy import select
@@ -11,6 +12,4 @@ def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None:
"""The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex """The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex
tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call
sites keep their `Model | None` static type.""" sites keep their `Model | None` static type."""
return db.execute( return db.execute(select(model).where(model.rating_key == str(rating_key))).scalars().first()
select(model).where(model.rating_key == str(rating_key))
).scalars().first()
+1
View File
@@ -7,6 +7,7 @@ is admin-configured (sysconfig ``plex_path_map``) as newline/comma-separated ``p
pairs; the longest matching Plex prefix wins. An empty map means the paths are identical on both pairs; the longest matching Plex prefix wins. An empty map means the paths are identical on both
sides (Plex and Siftlode see the same filesystem). sides (Plex and Siftlode see the same filesystem).
""" """
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session

Some files were not shown because too many files have changed in this diff Show More