style: apply ruff format across backend + scripts (line-length 100)
Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it.
This commit is contained in:
@@ -4,6 +4,7 @@ Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0002_ingest_core
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0003_tags
|
||||
Revises: 0002_ingest_core
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0004_feed_state_prefs
|
||||
Revises: 0003_tags
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0005_shorts_probed
|
||||
Revises: 0004_feed_state_prefs
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0006_app_state
|
||||
Revises: 0005_shorts_probed
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0007_deep_requested
|
||||
Revises: 0006_app_state
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0008_invites
|
||||
Revises: 0007_deep_requested
|
||||
Create Date: 2026-06-12
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -22,9 +23,7 @@ def upgrade() -> None:
|
||||
"invites",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("email", sa.String(length=320), nullable=False),
|
||||
sa.Column(
|
||||
"status", sa.String(length=16), nullable=False, server_default="pending"
|
||||
),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"requested_at",
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0009_quota_events
|
||||
Revises: 0008_invites
|
||||
Create Date: 2026-06-12
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0010_watch_progress
|
||||
Revises: 0009_quota_events
|
||||
Create Date: 2026-06-14
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0011_playlists
|
||||
Revises: 0010_watch_progress
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -26,16 +27,10 @@ def upgrade() -> None:
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"kind", sa.String(length=16), nullable=False, server_default="user"
|
||||
),
|
||||
sa.Column(
|
||||
"source", sa.String(length=16), nullable=False, server_default="local"
|
||||
),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
|
||||
sa.Column("source", sa.String(length=16), nullable=False, server_default="local"),
|
||||
sa.Column("yt_playlist_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"dirty", sa.Boolean(), nullable=False, server_default="false"
|
||||
),
|
||||
sa.Column("dirty", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
|
||||
@@ -7,6 +7,7 @@ Create Date: 2026-06-15
|
||||
Migrates the old per-video status='saved' into membership of a built-in 'watch_later'
|
||||
playlist (one per user who had saved videos), then demotes those states to 'new'.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -11,6 +11,7 @@ a YouTube read. Backfills the baseline for already-clean linked playlists (the c
|
||||
state == the synced state for them); dirty ones are left NULL so they stay dirty until the
|
||||
next real sync establishes a baseline.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Sequence, Union
|
||||
|
||||
@@ -32,24 +33,18 @@ def _fingerprint(name: str, ids: list[str]) -> str:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column("playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True))
|
||||
bind = op.get_bind()
|
||||
# Backfill clean, YouTube-linked playlists: their current state IS the synced state.
|
||||
rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT id, name FROM playlists "
|
||||
"WHERE yt_playlist_id IS NOT NULL AND dirty = false"
|
||||
)
|
||||
sa.text("SELECT id, name FROM playlists WHERE yt_playlist_id IS NOT NULL AND dirty = false")
|
||||
).fetchall()
|
||||
for pid, name in rows:
|
||||
ids = [
|
||||
r[0]
|
||||
for r in bind.execute(
|
||||
sa.text(
|
||||
"SELECT video_id FROM playlist_items "
|
||||
"WHERE playlist_id = :pid ORDER BY position"
|
||||
"SELECT video_id FROM playlist_items WHERE playlist_id = :pid ORDER BY position"
|
||||
),
|
||||
{"pid": pid},
|
||||
).fetchall()
|
||||
|
||||
@@ -11,6 +11,7 @@ Adds the shared demo-account plumbing:
|
||||
The demo user row itself is created lazily on first demo login (works across all three DBs
|
||||
without a data migration), so this only adds the schema.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -47,9 +48,7 @@ def upgrade() -> None:
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True
|
||||
)
|
||||
op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ Create Date: 2026-06-16
|
||||
Adds scheduler_settings: a per-job run-interval override (minutes), editable from the admin
|
||||
Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -10,6 +10,7 @@ the validation lifecycle (last_checked_at for rolling re-validation; unavailable
|
||||
unavailable_reason to flag a video as currently unplayable — hidden from the feed
|
||||
immediately, hard-deleted after a grace period). Purely additive; all columns nullable.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -26,7 +27,9 @@ def upgrade() -> None:
|
||||
op.add_column("videos", sa.Column("privacy_status", sa.String(length=16), nullable=True))
|
||||
op.add_column("videos", sa.Column("upload_status", sa.String(length=16), nullable=True))
|
||||
op.add_column("videos", sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(
|
||||
"videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column("videos", sa.Column("unavailable_reason", sa.String(length=24), nullable=True))
|
||||
op.create_index("ix_videos_last_checked_at", "videos", ["last_checked_at"])
|
||||
op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"])
|
||||
|
||||
@@ -9,6 +9,7 @@ JSON payload column is named `data` (SQLAlchemy's declarative Base reserves `met
|
||||
First producer is the maintenance job's batched "videos removed" notice. Coexists with the
|
||||
existing client-side transient bell.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -36,18 +37,14 @@ def upgrade() -> None:
|
||||
sa.Column("data", sa.JSON(), nullable=True),
|
||||
sa.Column("read", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
|
||||
op.create_index("ix_notifications_type", "notifications", ["type"])
|
||||
op.create_index("ix_notifications_read", "notifications", ["read"])
|
||||
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
|
||||
# Fast unread-badge count + inbox listing per user.
|
||||
op.create_index(
|
||||
"ix_notifications_user_read", "notifications", ["user_id", "read"]
|
||||
)
|
||||
op.create_index("ix_notifications_user_read", "notifications", ["user_id", "read"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds app_state.maintenance_revalidate_batch: an admin-tunable override (from the
|
||||
dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
|
||||
env/config default. Purely additive.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -9,6 +9,7 @@ channels.list?mine=true and cached. Used to exclude the user's own channel from
|
||||
playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
|
||||
Purely additive.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -10,6 +10,7 @@ stored historical values to the canonical scheme defined in app.quota.QuotaActio
|
||||
fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
|
||||
are NOT touched.)
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds the system_config table: admin overrides for operational config that otherw
|
||||
from env/config defaults (see app.sysconfig for the registry of known keys). Secret values
|
||||
are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -9,6 +9,7 @@ google_sub nullable (password-only accounts have no Google sub until they link),
|
||||
single-use auth_tokens table for email verification + password reset. Existing rows are all
|
||||
Google/demo accounts → backfilled email_verified=true (is_active defaults true).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds users.is_suspended — an admin-controlled access block, distinct from is_a
|
||||
approval lifecycle). A suspended account can't sign in (password or Google) and any live
|
||||
session is rejected; the user is told why and given the operator's contact.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -9,6 +9,7 @@ email_verified=false (the callback didn't set it). Backfill those so the flag re
|
||||
matters because password-login gates on email_verified, so a Google user who later adds a password
|
||||
must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds app_state.configured + setup_token_hash for the first-run install wizard. E
|
||||
(any users present) are marked configured so they never see the wizard; a genuinely fresh DB (no
|
||||
users) starts unconfigured and boots into setup mode.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds the `messages` table for direct user-to-user messaging (notification module
|
||||
A conversation is the set of rows between two users; both FKs cascade so a GDPR account
|
||||
erasure removes that user's messages.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -12,6 +12,7 @@ hold each user's public key + passphrase-wrapped private key (the server can nev
|
||||
Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
|
||||
prod has none).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -11,6 +11,7 @@ the Library by default:
|
||||
- channels.from_search — this channel exists only because search surfaced one of its videos
|
||||
Both default false (every existing row is organic), indexed for the feed's exclude filter.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -25,16 +26,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"videos",
|
||||
sa.Column(
|
||||
"via_search", sa.Boolean(), nullable=False, server_default="false"
|
||||
),
|
||||
sa.Column("via_search", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
op.create_index("ix_videos_via_search", "videos", ["via_search"])
|
||||
op.add_column(
|
||||
"channels",
|
||||
sa.Column(
|
||||
"from_search", sa.Boolean(), nullable=False, server_default="false"
|
||||
),
|
||||
sa.Column("from_search", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
op.create_index("ix_channels_from_search", "channels", ["from_search"])
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ query wraps both sides in unaccent(); this migration just makes the function ava
|
||||
`unaccent` ships with the official postgres image (postgresql-contrib). The DB role is a
|
||||
superuser in our deployments, so CREATE EXTENSION succeeds; IF NOT EXISTS keeps it idempotent.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -9,6 +9,7 @@ live YouTube search. This lets "videos I searched for" appear in that user's Min
|
||||
Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as
|
||||
search-discovered by anyone, for the shared Library's Source filter).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -15,6 +15,7 @@ search stays accent-insensitive ("tiesto" matches "Tiësto"). A GIN expression i
|
||||
query uses the exact same expression so the planner picks the index. `unaccent` is already
|
||||
enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -26,9 +27,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)"
|
||||
)
|
||||
op.execute("CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)")
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple
|
||||
|
||||
@@ -21,6 +21,7 @@ title-only expression index from 0031 is dropped (superseded). Existing rows get
|
||||
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
|
||||
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -48,9 +49,7 @@ def upgrade() -> None:
|
||||
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
|
||||
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
|
||||
)
|
||||
op.execute("CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -17,6 +17,7 @@ un-subscribed channels):
|
||||
access to its ephemeral videos and drives the grace-period purge of un-kept channels.
|
||||
All new flags default false / NULL, so every existing row stays organic.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -30,9 +31,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
|
||||
op.add_column(
|
||||
"channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column("channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("channels", sa.Column("banner_url", sa.String(length=1024), nullable=True))
|
||||
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
|
||||
op.add_column(
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds the `blocked_channels` table: a per-user blocklist. Videos from a blocked c
|
||||
never shown in that user's live YouTube search (and not ingested), and are hidden from their
|
||||
feed / explore views. Per-user, so one user's block doesn't affect anyone else.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -8,6 +8,7 @@ Adds the `saved_views` table: per-user named snapshots of the feed's filter/sort
|
||||
row stores the serialized FeedFilters (JSON), a sidebar `position` for drag-reorder, and an
|
||||
`is_default` flag (at most one per user, enforced by the API) for the view applied on load.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -26,9 +27,7 @@ def upgrade() -> None:
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=80), nullable=False),
|
||||
sa.Column("filters", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"position", sa.Integer(), server_default=sa.text("0"), nullable=False
|
||||
),
|
||||
sa.Column("position", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
|
||||
@@ -10,6 +10,7 @@ FOR UPDATE SKIP LOCKED, live progress written back for polling); `download_profi
|
||||
reusable format presets (builtins seeded in the next migration); `download_shares` grant a file
|
||||
to another user; `download_quotas` hold admin-set per-user limits (default via sysconfig).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -47,9 +48,16 @@ def upgrade() -> None:
|
||||
sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"),
|
||||
sa.UniqueConstraint(
|
||||
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_media_assets_status", "media_assets", ["status"])
|
||||
|
||||
@@ -61,7 +69,12 @@ def upgrade() -> None:
|
||||
sa.Column("spec", sa.JSON(), nullable=False),
|
||||
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
@@ -84,8 +97,18 @@ def upgrade() -> None:
|
||||
sa.Column("phase", sa.String(length=32), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("queue_pos", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["profile_id"], ["download_profiles.id"], ondelete="SET NULL"),
|
||||
@@ -102,7 +125,12 @@ def upgrade() -> None:
|
||||
sa.Column("job_id", sa.Integer(), nullable=False),
|
||||
sa.Column("from_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("to_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
@@ -120,7 +148,12 @@ def upgrade() -> None:
|
||||
sa.Column("max_concurrent", sa.Integer(), nullable=False),
|
||||
sa.Column("max_jobs", sa.Integer(), nullable=False),
|
||||
sa.Column("unlimited", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ definition consumed by app.downloads.formats: mode ("av" audio+video / "v" video
|
||||
the embed_*/sponsorblock postprocessor flags. Names are English (the UI default); the frontend
|
||||
localizes builtin labels by a stable key derived from the name.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -7,6 +7,7 @@ Create Date: 2026-07-03
|
||||
The download GC warns each holder once, a grace window before an asset's TTL expiry; this flag
|
||||
records that so subsequent GC runs don't re-notify until the asset is deleted.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -9,6 +9,7 @@ display-friendly form (emoji stripped, ALL-CAPS de-shouted, trailing SEO hashtag
|
||||
see app.titles). Reversible: original_title preserves the source, and title can be re-derived.
|
||||
The generated search_vector column regenerates automatically as each title is updated.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -45,5 +46,7 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL"))
|
||||
conn.execute(
|
||||
sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL")
|
||||
)
|
||||
op.drop_column("videos", "original_title")
|
||||
|
||||
@@ -11,6 +11,7 @@ rows so both fresh installs (0037 then this) and existing DBs converge on the sa
|
||||
jobs are unaffected (they snapshot their spec; profile_id just SET NULLs if it pointed at a
|
||||
replaced row).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -10,6 +10,7 @@ still hangs off a `media_assets` row (with `source_kind='edit'` and a per-user c
|
||||
existing file-serve / ref-count / GC / quota machinery is reused unchanged. `source_job_id` /
|
||||
`source_asset_id` point at the parent download; `edit_spec` is the trim/crop recipe.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -24,16 +25,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"download_jobs",
|
||||
sa.Column(
|
||||
"job_kind", sa.String(length=16), server_default="download", nullable=False
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True)
|
||||
sa.Column("job_kind", sa.String(length=16), server_default="download", nullable=False),
|
||||
)
|
||||
op.add_column("download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True))
|
||||
op.add_column("download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True))
|
||||
op.add_column("download_jobs", sa.Column("edit_spec", sa.JSON(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_download_jobs_source_job",
|
||||
@@ -54,12 +49,8 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_download_jobs_source_asset", "download_jobs", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_download_jobs_source_job", "download_jobs", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint("fk_download_jobs_source_asset", "download_jobs", type_="foreignkey")
|
||||
op.drop_constraint("fk_download_jobs_source_job", "download_jobs", type_="foreignkey")
|
||||
op.drop_column("download_jobs", "edit_spec")
|
||||
op.drop_column("download_jobs", "source_asset_id")
|
||||
op.drop_column("download_jobs", "source_job_id")
|
||||
|
||||
@@ -9,6 +9,7 @@ can watch the file on the public `/watch/{token}` player page — no account nee
|
||||
per-link controls: an expiry, an "allow download" toggle, and an (argon2-hashed) password. Revoke
|
||||
= delete the row. Distinct from `download_shares` (an ACL grant to another *registered* user).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -31,7 +32,12 @@ def upgrade() -> None:
|
||||
sa.Column("password_hash", sa.Text(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("view_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
|
||||
@@ -8,6 +8,7 @@ Stores yt-dlp's canonical `webpage_url` for a download's source, so the Download
|
||||
clean "downloaded from" reference (copyable + openable). Nullable — filled by the worker on
|
||||
extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -18,6 +18,7 @@ Plex server (playback is from the local physical file; Plex is metadata only). T
|
||||
The generated `search_vector` columns + their GIN indexes are added via raw SQL (same approach
|
||||
as migration 0032), reusing the `unaccent_simple` text-search config from migration 0031.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -52,8 +53,12 @@ def upgrade() -> None:
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_plex_libraries_plex_key", "plex_libraries", ["plex_key"], unique=True)
|
||||
|
||||
@@ -61,7 +66,12 @@ def upgrade() -> None:
|
||||
"plex_shows",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"library_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
@@ -69,8 +79,12 @@ def upgrade() -> None:
|
||||
sa.Column("art_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("child_count", sa.Integer(), nullable=True),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_plex_shows_rating_key", "plex_shows", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"])
|
||||
@@ -81,12 +95,21 @@ def upgrade() -> None:
|
||||
"plex_seasons",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"show_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_shows.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("season_number", sa.Integer(), nullable=True),
|
||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_plex_seasons_rating_key", "plex_seasons", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"])
|
||||
@@ -96,10 +119,25 @@ def upgrade() -> None:
|
||||
"plex_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"library_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("season_id", sa.Integer(), sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column(
|
||||
"show_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_shows.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"season_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("season_number", sa.Integer(), nullable=True),
|
||||
sa.Column("episode_number", sa.Integer(), nullable=True),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
@@ -119,8 +157,12 @@ def upgrade() -> None:
|
||||
sa.Column("probed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("markers", sa.JSON(), nullable=True),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_plex_items_rating_key", "plex_items", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"])
|
||||
@@ -137,14 +179,23 @@ def upgrade() -> None:
|
||||
op.create_table(
|
||||
"plex_states",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"item_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(length=12), server_default="new", nullable=False),
|
||||
sa.Column("position_seconds", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("synced_to_plex", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),
|
||||
)
|
||||
op.create_index("ix_plex_states_user_id", "plex_states", ["user_id"])
|
||||
|
||||
@@ -9,6 +9,7 @@ Plex section listing (no per-item API calls): rating (audienceRating ~ IMDb), co
|
||||
release date, and GIN-indexed genre / director / cast arrays. These also double as the content
|
||||
signals a future watch-habit recommender would use.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -33,9 +34,13 @@ def upgrade() -> None:
|
||||
op.create_index("ix_plex_items_rating", "plex_items", ["rating"])
|
||||
op.create_index("ix_plex_items_content_rating", "plex_items", ["content_rating"])
|
||||
op.create_index("ix_plex_items_studio", "plex_items", ["studio"])
|
||||
op.create_index("ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"])
|
||||
op.create_index(
|
||||
"ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"]
|
||||
)
|
||||
op.create_index("ix_plex_items_genres_gin", "plex_items", ["genres"], postgresql_using="gin")
|
||||
op.create_index("ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin")
|
||||
op.create_index(
|
||||
"ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin"
|
||||
)
|
||||
op.create_index("ix_plex_items_cast_gin", "plex_items", ["cast_names"], postgresql_using="gin")
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ finds titles by an actor/director name and ranks those above films that merely m
|
||||
their synopsis. A generated column's expression can't be altered in place, so we drop + recreate it
|
||||
(and its GIN index). `people_text` is populated on the next Plex sync.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -10,6 +10,7 @@ A `plex_collections` table holds each collection's card metadata; membership is
|
||||
combinable filter and a title's "collection strip" is a pure local lookup. `editable` marks the
|
||||
(curated) collections we may later write back to Plex; everything is read-only by default.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -27,7 +28,12 @@ def upgrade() -> None:
|
||||
"plex_collections",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"library_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||
@@ -35,16 +41,32 @@ def upgrade() -> None:
|
||||
sa.Column("smart", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("editable", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True
|
||||
)
|
||||
op.create_index("ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_collections_library_id", "plex_collections", ["library_id"])
|
||||
|
||||
op.add_column("plex_items", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
||||
op.create_index("ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin")
|
||||
op.create_index(
|
||||
"ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin"
|
||||
)
|
||||
op.add_column("plex_shows", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
||||
op.create_index("ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin")
|
||||
op.create_index(
|
||||
"ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ Plex account (like plex_states) — the "your own lists" counterpart to the shar
|
||||
collections. `plex_playlists` is owned by a user; `plex_playlist_items` holds the ordered entries
|
||||
(`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -24,11 +25,23 @@ def upgrade() -> None:
|
||||
op.create_table(
|
||||
"plex_playlists",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
sa.Column("plex_rating_key", sa.String(length=32), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"])
|
||||
|
||||
@@ -36,12 +49,16 @@ def upgrade() -> None:
|
||||
"plex_playlist_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"playlist_id", sa.Integer(),
|
||||
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"), nullable=False,
|
||||
"playlist_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"item_id", sa.Integer(),
|
||||
sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False,
|
||||
"item_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),
|
||||
|
||||
@@ -13,6 +13,7 @@ and captures yt-dlp's canonical channel/uploader URL so an auto-filled channel b
|
||||
overrides (like display_name — display metadata only, never touches the on-disk file).
|
||||
- download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -26,7 +27,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("media_assets", sa.Column("uploader_url", sa.Text(), nullable=True))
|
||||
op.add_column("download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True))
|
||||
op.add_column(
|
||||
"download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True)
|
||||
)
|
||||
op.add_column("download_jobs", sa.Column("display_uploader_url", sa.Text(), nullable=True))
|
||||
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ download shows a blank image box. The worker now extracts a representative frame
|
||||
saves it as a poster sidecar; `poster_path` (relative to download_root) records it so the card /
|
||||
watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -5,6 +5,7 @@ state with a Plex account. The owner MVP uses the server admin token (uses_admin
|
||||
token); a personal token column is reserved for later friend-account linking (PIN OAuth). See the
|
||||
PlexLink model docstring.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -11,6 +11,7 @@ blob folded into the generated `search_vector` (weight B). This lets the TV grid
|
||||
sorted like the movie grid. A generated column's expression can't be altered in place, so the
|
||||
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -57,9 +58,13 @@ def upgrade() -> None:
|
||||
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
|
||||
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
|
||||
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
|
||||
op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"])
|
||||
op.create_index(
|
||||
"ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"]
|
||||
)
|
||||
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
|
||||
op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin")
|
||||
op.create_index(
|
||||
"ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin"
|
||||
)
|
||||
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
|
||||
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ current_user rejects any cookie whose epoch is stale. Bumped on password reset,
|
||||
or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of
|
||||
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0054_audit_log
|
||||
Revises: 0053_user_session_epoch
|
||||
Create Date: 2026-07-12
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0055_channel_keywords
|
||||
Revises: 0054_audit_log
|
||||
Create Date: 2026-07-12
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0056_plex_people
|
||||
Revises: 0055_channel_keywords
|
||||
Create Date: 2026-07-19
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -23,8 +24,12 @@ def upgrade() -> None:
|
||||
# A representative proxied headshot URL (or NULL when none is available). Captured
|
||||
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
|
||||
sa.Column("photo_thumb", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 0057_plex_ppl_enriched
|
||||
Revises: 0056_plex_people
|
||||
Create Date: 2026-07-19
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -17,7 +18,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
for tbl in ("plex_items", "plex_shows"):
|
||||
op.add_column(tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(
|
||||
tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ Best still one click away. Re-seeds the builtin rows so fresh installs and exist
|
||||
(same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
|
||||
pointed at a replaced row just SET NULLs).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -10,6 +10,7 @@ did NOT invalidate outstanding grants — they lived out their 6h TTL. Fold a pe
|
||||
into the signed grant and bump it on every password change, so a rotation revokes old grants at once.
|
||||
Small table (one row per share link), so this is a quick add-column with a default.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -9,6 +9,7 @@ This one-time data fixup rewrites those scalars to SQL NULL so the columns are u
|
||||
forward-only: a SQL NULL is indistinguishable from the old JSON `null` to every reader (the guards
|
||||
treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
@@ -25,9 +26,7 @@ _COLS = ("genres", "directors", "cast_names", "collection_keys")
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
for col in _COLS:
|
||||
op.execute(
|
||||
f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'"
|
||||
)
|
||||
op.execute(f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ small category code (`unavailable` / `login_required` / `geo_blocked` / `format_
|
||||
a retry makes sense, keeping the raw text as a technical-detail disclosure. Nullable; only set on the
|
||||
error path. Forward-only — an existing errored row simply has NULL until its next failure/retry.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -17,6 +17,7 @@ Redundant index -> the composite that already covers it:
|
||||
ix_plex_states_user_id <- uq_plex_user_item (user_id, item_id)
|
||||
ix_plex_playlist_items_playlist_id <- uq_plex_playlist_item (playlist_id, item_id)
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -5,6 +5,7 @@ OVERLAY library is the Hungarian-metadata twin of another section: it is never b
|
||||
its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap:
|
||||
metadata-only, no table rewrite.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -9,6 +9,7 @@ Run BEFORE the first HU overlay sync: while the *_hu columns are all NULL the ge
|
||||
is trivial. ADD only (no drop/rebuild of the EN search_vector). NOTE: adding a STORED generated
|
||||
column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -11,6 +11,7 @@ so the data step below resets `people_enriched_at` to NULL on movie/show rows: t
|
||||
each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table
|
||||
rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -6,6 +6,7 @@ schema allowed NULL — a model/schema drift the new migration drift-test (E1 S2
|
||||
server_default has filled every row since day one, so no NULLs exist; enforcing NOT NULL just makes the
|
||||
schema match the contract the model already promises. Pure DDL, reversible.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -22,6 +22,7 @@ CONCURRENTLY (in alembic's autocommit_block, since it can't run inside a transac
|
||||
these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
|
||||
downgrade->re-upgrade round-trip idempotent.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -13,6 +13,7 @@ Note: `SET NOT NULL` validates with a brief ACCESS EXCLUSIVE lock + one table sc
|
||||
CONCURRENTLY). On the prod row count (~235k) that is sub-second; still, it is a short write-blocking lock
|
||||
rather than the ~0 of 0067's CONCURRENTLY index build.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -27,9 +28,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe).
|
||||
op.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
|
||||
op.alter_column(
|
||||
"videos", "published_at", nullable=False, server_default=sa.text("now()")
|
||||
)
|
||||
op.alter_column("videos", "published_at", nullable=False, server_default=sa.text("now()"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -18,6 +18,7 @@ biggest table, same lookups. live_status is left alone (a separate finding).
|
||||
All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
|
||||
downgrade->re-upgrade idempotent.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
@@ -5,6 +5,7 @@ hand-rolled override-or-default reader in `app.state`, duplicating the sysconfig
|
||||
now a sysconfig key (`system_config` store), so every admin knob lives in one place. This migration
|
||||
carries any existing override into the sysconfig store, then drops the dead column. Reversible.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -8,6 +8,7 @@ Plex-sourced `imdb_rating`/`rating`, which are left untouched. Plus `plex_people
|
||||
foundation for S6's person bios). ADD-only, all nullable → no table rewrite, no ACCESS EXCLUSIVE lock.
|
||||
No watermark reset needed: `tmdb_enriched_at` starts NULL, so every eligible row is picked up once.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -7,6 +7,7 @@ first time a person page is opened (from /person/{tmdb_person_id}), then SERVED
|
||||
`tmdb_person_id` link itself already exists (0071). ADD-only, all nullable → no table rewrite, no
|
||||
ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -6,6 +6,7 @@ to (user_id, source, name) so a feed view and a Plex view can share a name. `sou
|
||||
server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
|
||||
small per-user table.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -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
|
||||
its audit row too).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
+6
-9
@@ -41,8 +41,7 @@ log = logging.getLogger("siftlode.auth")
|
||||
def google_enabled(db: Session) -> bool:
|
||||
"""Whether Sign in with Google is available (both client id + secret resolve to non-empty)."""
|
||||
return bool(
|
||||
sysconfig.get_str(db, "google_client_id")
|
||||
and sysconfig.get_str(db, "google_client_secret")
|
||||
sysconfig.get_str(db, "google_client_id") 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
|
||||
# TOKEN_ENCRYPTION_KEY mismatch can't 500 the purge — a best-effort revoke degrades to "skip". (B-core-2)
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
def optional_current_user(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
) -> User | None:
|
||||
def optional_current_user(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.
|
||||
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)."""
|
||||
@@ -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
|
||||
where there's no scope check to lean on."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not available in the demo account."
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ class Settings(BaseSettings):
|
||||
@property
|
||||
def player_client_fallback_list(self) -> list[str]:
|
||||
return self._clients(self.download_player_clients_fallback)
|
||||
|
||||
# How many downloads the worker runs concurrently (claimed via FOR UPDATE SKIP LOCKED).
|
||||
download_worker_concurrency: int = 2
|
||||
# How often (empty pending queue) the worker polls for a new job, in seconds.
|
||||
|
||||
+7
-2
@@ -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
|
||||
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
|
||||
"""
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
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
|
||||
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
|
||||
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
|
||||
top = db.execute(
|
||||
top = (
|
||||
db.execute(
|
||||
select(Video.channel_id)
|
||||
.where(Video.channel_id.is_not(None))
|
||||
.group_by(Video.channel_id)
|
||||
.order_by(func.count().desc())
|
||||
.limit(n)
|
||||
).scalars().all()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for channel_id in top:
|
||||
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
|
||||
return len(top)
|
||||
|
||||
@@ -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
|
||||
`extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.config import settings
|
||||
@@ -140,8 +141,10 @@ def resolve_adapter(
|
||||
def host_is(*domains: str) -> bool:
|
||||
return any(_host_is(h, *domains) for h in hosts)
|
||||
|
||||
if source_kind == "youtube" or "youtube" in extractor or host_is(
|
||||
"youtube.com", "youtu.be", "youtube-nocookie.com"
|
||||
if (
|
||||
source_kind == "youtube"
|
||||
or "youtube" in extractor
|
||||
or host_is("youtube.com", "youtu.be", "youtube-nocookie.com")
|
||||
):
|
||||
return _YOUTUBE
|
||||
if "facebook" in extractor or host_is("facebook.com", "fb.watch"):
|
||||
|
||||
@@ -10,6 +10,7 @@ URL may have expired — those just get a locally-generated poster instead).
|
||||
Run once per environment:
|
||||
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import yt_dlp
|
||||
|
||||
+151
-29
@@ -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
|
||||
for a player hover-preview later.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -40,6 +41,7 @@ class EditAborted(Exception):
|
||||
|
||||
# --- edit spec ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
"""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 --------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -153,8 +156,15 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
||||
reencode = needs_reencode(spec)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-progress", "pipe:1", "-nostats",
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
"-nostats",
|
||||
]
|
||||
if start:
|
||||
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:
|
||||
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
|
||||
cmd += [
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
]
|
||||
else:
|
||||
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")
|
||||
prep: dict[Path, str] = {}
|
||||
head = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-progress", "pipe:1", "-nostats",
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
"-nostats",
|
||||
]
|
||||
|
||||
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}]")
|
||||
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
|
||||
cmd = head + [
|
||||
"-i", str(src), "-filter_complex", ";".join(parts),
|
||||
"-map", "[v]", "-map", "[a]",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-b:a", "192k",
|
||||
"-i",
|
||||
str(src),
|
||||
"-filter_complex",
|
||||
";".join(parts),
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
]
|
||||
else:
|
||||
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")
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
|
||||
"-of", "json", str(path)],
|
||||
capture_output=True, text=True, timeout=30, check=True,
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=True,
|
||||
).stdout
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
||||
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)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=nw=1:nk=1", str(path)],
|
||||
capture_output=True, text=True, timeout=30, check=True,
|
||||
[
|
||||
"ffprobe",
|
||||
"-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()
|
||||
return int(float(out)) if out else 0
|
||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
||||
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
|
||||
(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."""
|
||||
@@ -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
|
||||
|
||||
|
||||
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
|
||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
||||
pipe:1` streams machine-readable progress for run_ffmpeg."""
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-progress", "pipe:1", "-nostats", "-i", str(src),
|
||||
"ffmpeg",
|
||||
"-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 += ["-movflags", "+faststart", str(dest)]
|
||||
return cmd
|
||||
@@ -486,25 +567,63 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
|
||||
fps = geom["count"] / dur
|
||||
vf = f"fps={fps:.6f},scale={_SB_TILE_W}:-2,tile={geom['cols']}x{geom['rows']}"
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-i", str(src), "-frames:v", "1", "-q:v", "5", "-vf", vf, str(dest),
|
||||
"ffmpeg",
|
||||
"-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]:
|
||||
"""Grab one representative frame as a poster JPEG. `-ss` before `-i` seeks fast."""
|
||||
return [
|
||||
"ffmpeg", "-y", "-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),
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-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]:
|
||||
"""Decode a downloaded thumbnail (webp/png/…) to a single baseline JPEG frame."""
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-i", str(src), "-frames:v", "1", "-update", "1", str(dest),
|
||||
"ffmpeg",
|
||||
"-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
|
||||
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
|
||||
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 dest if dest.exists() else None
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ spec shape (all keys optional; defaults applied by `normalize`):
|
||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
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
|
||||
# path), so it never needed the convertor and does not reintroduce the crash.
|
||||
if s["embed_thumbnail"]:
|
||||
opts["postprocessors"].append(
|
||||
{"key": "EmbedThumbnail", "already_have_thumbnail": True}
|
||||
)
|
||||
opts["postprocessors"].append({"key": "EmbedThumbnail", "already_have_thumbnail": True})
|
||||
|
||||
return opts
|
||||
|
||||
+39
-15
@@ -15,6 +15,7 @@ Five passes each run:
|
||||
|
||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
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]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(DownloadJob.user_id)
|
||||
.where(DownloadJob.asset_id == asset_id)
|
||||
.distinct()
|
||||
select(DownloadJob.user_id).where(DownloadJob.asset_id == asset_id).distinct()
|
||||
).scalars()
|
||||
)
|
||||
|
||||
@@ -101,7 +100,9 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
||||
pass
|
||||
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
|
||||
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:
|
||||
d.rmdir()
|
||||
@@ -121,7 +122,8 @@ def run_download_gc(db: Session) -> dict:
|
||||
|
||||
# 1. Pre-expiry warnings.
|
||||
warn_before = now + timedelta(days=grace_days)
|
||||
soon = db.execute(
|
||||
soon = (
|
||||
db.execute(
|
||||
select(MediaAsset).where(
|
||||
MediaAsset.status == "ready",
|
||||
MediaAsset.gc_notified.is_(False),
|
||||
@@ -129,7 +131,10 @@ def run_download_gc(db: Session) -> dict:
|
||||
MediaAsset.expires_at > now,
|
||||
MediaAsset.expires_at <= warn_before,
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in soon:
|
||||
_notify_holders(db, asset, "download_expiring")
|
||||
asset.gc_notified = True
|
||||
@@ -138,13 +143,17 @@ def run_download_gc(db: Session) -> dict:
|
||||
db.commit()
|
||||
|
||||
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
||||
dead = db.execute(
|
||||
dead = (
|
||||
db.execute(
|
||||
select(MediaAsset).where(
|
||||
MediaAsset.status.in_(("ready", "error")),
|
||||
MediaAsset.expires_at.is_not(None),
|
||||
MediaAsset.expires_at <= now,
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in dead:
|
||||
_delete_asset(db, asset, "download_expired")
|
||||
expired += 1
|
||||
@@ -154,17 +163,24 @@ def run_download_gc(db: Session) -> dict:
|
||||
# 3. LRU eviction over the total cache cap.
|
||||
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
||||
if cap > 0:
|
||||
total = db.execute(
|
||||
total = (
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||
MediaAsset.status == "ready"
|
||||
)
|
||||
).scalar() or 0
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
if total > cap:
|
||||
candidates = db.execute(
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(MediaAsset)
|
||||
.where(MediaAsset.status == "ready")
|
||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||
).scalars().all()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in candidates:
|
||||
if total <= cap:
|
||||
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-
|
||||
# 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.
|
||||
orphans = db.execute(
|
||||
orphans = (
|
||||
db.execute(
|
||||
select(MediaAsset).where(
|
||||
MediaAsset.status == "error",
|
||||
MediaAsset.ref_count == 0,
|
||||
MediaAsset.rel_path.is_(None),
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in orphans:
|
||||
db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id
|
||||
reaped += 1
|
||||
@@ -194,7 +214,11 @@ def run_download_gc(db: Session) -> dict:
|
||||
swept = _sweep_orphan_files(db, settings.download_root)
|
||||
|
||||
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)
|
||||
return result
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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) -------
|
||||
|
||||
|
||||
def _set_job(job_id: int, **fields) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
|
||||
@@ -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,
|
||||
so the raw password never rides in the media URL (or server logs).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
@@ -27,10 +28,9 @@ def is_expired(link: DownloadLink) -> bool:
|
||||
|
||||
# --- signed grants (password-protected file access) ----------------------------------------
|
||||
|
||||
|
||||
def _sign(msg: str) -> str:
|
||||
return hmac.new(
|
||||
settings.secret_key.encode(), msg.encode(), hashlib.sha256
|
||||
).hexdigest()[:32]
|
||||
return hmac.new(settings.secret_key.encode(), msg.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
|
||||
|
||||
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 -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
||||
"""What the file owner sees when managing their links."""
|
||||
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).
|
||||
|
||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||
|
||||
@@ -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
|
||||
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
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:
|
||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
||||
link = db.execute(
|
||||
select(DownloadLink).where(DownloadLink.token == token)
|
||||
).scalar_one_or_none()
|
||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||
if link is None or linksmod.is_expired(link):
|
||||
return None
|
||||
url = f"{settings.app_base}/watch/{token}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
@@ -57,9 +58,7 @@ _YTDLP_TEMP_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _notify_download_done(
|
||||
db, user_id: int | None, title: str | None, job_id, *, kind: str
|
||||
) -> None:
|
||||
def _notify_download_done(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
|
||||
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
|
||||
@@ -247,7 +246,9 @@ def _make_pp_hook(job_id: int):
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
working files (see `_YTDLP_TEMP_RE`)."""
|
||||
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:
|
||||
produced = Path(str(fp))
|
||||
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
|
||||
for i, clients in enumerate(client_sets):
|
||||
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),
|
||||
)
|
||||
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:
|
||||
last_exc = 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
|
||||
try:
|
||||
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")
|
||||
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)
|
||||
# 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%.
|
||||
@@ -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.height = int(info["height"]) if info.get("height") else None
|
||||
asset.container = ext
|
||||
asset.vcodec = 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.vcodec = (
|
||||
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.uploader = meta.uploader
|
||||
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)
|
||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||
owner_id = job.user_id if job else None
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
||||
"eta_s": None}
|
||||
fields = {
|
||||
"status": JobStatus.DONE,
|
||||
"progress": 100,
|
||||
"phase": None,
|
||||
"speed_bps": None,
|
||||
"eta_s": None,
|
||||
}
|
||||
if job and not job.display_name and meta.title:
|
||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||
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
|
||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
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
|
||||
# Atomic fail: only while still running (see the download error path).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
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
|
||||
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_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:
|
||||
_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)
|
||||
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():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
|
||||
@@ -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
|
||||
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
@@ -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
|
||||
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
@@ -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
|
||||
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
@@ -166,7 +167,8 @@ def enqueue(
|
||||
source_ref=source_ref,
|
||||
profile_id=profile_id,
|
||||
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",
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
date-based — the community-standard mapping for YouTube-in-Plex.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import unicodedata
|
||||
@@ -36,9 +37,27 @@ _DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
|
||||
# that accented names show in non-UTF-8 tools.
|
||||
_FOLD_MAP = str.maketrans(
|
||||
{
|
||||
"ß": "ss", "ẞ": "SS", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D",
|
||||
"ð": "d", "Ð": "D", "þ": "th", "Þ": "Th", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE",
|
||||
"ı": "i", "İ": "I", "·": " ", "–": "-", "—": "-",
|
||||
"ß": "ss",
|
||||
"ẞ": "SS",
|
||||
"ø": "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" <year>{year}</year>",
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
def write_sidecars(
|
||||
download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None
|
||||
) -> bool:
|
||||
def write_sidecars(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."""
|
||||
media = abs_path(download_root, rel)
|
||||
try:
|
||||
|
||||
+6
-13
@@ -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
|
||||
notifications — email is never load-bearing for the app's core flow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
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"
|
||||
f"{settings.app_base}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode"
|
||||
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
"— Siftlode" + (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)
|
||||
|
||||
@@ -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 "
|
||||
"this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode"
|
||||
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
)
|
||||
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 = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
||||
"in again."
|
||||
+ contact
|
||||
+ "\n\n— Siftlode"
|
||||
"in again." + contact + "\n\n— Siftlode"
|
||||
)
|
||||
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"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode."
|
||||
+ contact
|
||||
+ "\n\n— Siftlode"
|
||||
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
|
||||
)
|
||||
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 = (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n"
|
||||
+ contact
|
||||
+ "— Siftlode"
|
||||
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
||||
|
||||
|
||||
+51
-27
@@ -144,9 +144,7 @@ def filtered_query(
|
||||
state = aliased(VideoState)
|
||||
status_expr = func.coalesce(state.status, "new")
|
||||
|
||||
query = select(*feed_columns(user, state)).join(
|
||||
Channel, Channel.id == Video.channel_id
|
||||
)
|
||||
query = select(*feed_columns(user, state)).join(Channel, Channel.id == Video.channel_id)
|
||||
|
||||
if scope == "all":
|
||||
# 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.
|
||||
state_required = show in ("watched", "hidden", "in_progress")
|
||||
join_state = query.join if state_required else query.outerjoin
|
||||
query = join_state(
|
||||
state, and_(state.video_id == Video.id, state.user_id == user.id)
|
||||
)
|
||||
query = join_state(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||
|
||||
# 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;
|
||||
@@ -197,9 +193,7 @@ def filtered_query(
|
||||
# asking for scope=all + channel_ids (the channel is in this accessible set).
|
||||
accessible_explore = (
|
||||
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||
).union(
|
||||
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
|
||||
)
|
||||
).union(select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id))
|
||||
query = query.where(
|
||||
or_(
|
||||
Video.via_explore.is_(False),
|
||||
@@ -244,13 +238,9 @@ def filtered_query(
|
||||
query = query.where(Video.duration_seconds <= max_duration)
|
||||
if max_age_days is not None:
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
||||
query = query.where(
|
||||
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||
)
|
||||
query = query.where(Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc))
|
||||
if published_after is not None:
|
||||
start = datetime.combine(
|
||||
published_after, datetime.min.time(), tzinfo=timezone.utc
|
||||
)
|
||||
start = datetime.combine(published_after, datetime.min.time(), tzinfo=timezone.utc)
|
||||
query = query.where(Video.published_at >= start)
|
||||
if published_before is not None:
|
||||
end = datetime.combine(
|
||||
@@ -314,9 +304,7 @@ def filtered_query(
|
||||
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
||||
)
|
||||
else:
|
||||
sub = select(ChannelTag.channel_id).where(
|
||||
ChannelTag.tag_id.in_(ids), visible
|
||||
)
|
||||
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
|
||||
query = query.where(Video.channel_id.in_(sub))
|
||||
|
||||
# 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:
|
||||
continue
|
||||
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
|
||||
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||
return False
|
||||
@@ -443,19 +433,52 @@ def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||
"oldest": [{**pub, "asc": True}],
|
||||
"views": [{"expr": Video.view_count, "asc": False, "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_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}],
|
||||
"duration_desc": [
|
||||
{"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_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}],
|
||||
"subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}],
|
||||
"subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}],
|
||||
"title_desc": [
|
||||
{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}
|
||||
],
|
||||
"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.
|
||||
# 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_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub],
|
||||
"priority": [
|
||||
{
|
||||
"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
|
||||
# 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;
|
||||
# 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
|
||||
NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure
|
||||
NULL-aware OR form."""
|
||||
|
||||
def eq(k):
|
||||
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
||||
|
||||
|
||||
+2
-6
@@ -12,9 +12,7 @@ from fastapi import FastAPI, HTTPException
|
||||
_siftlode_logger = logging.getLogger("siftlode")
|
||||
if not _siftlode_logger.handlers:
|
||||
_handler = logging.StreamHandler(sys.stdout)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
|
||||
)
|
||||
_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s"))
|
||||
_siftlode_logger.addHandler(_handler)
|
||||
_siftlode_logger.setLevel(logging.INFO)
|
||||
_siftlode_logger.propagate = False
|
||||
@@ -140,9 +138,7 @@ async def setup_gate(request, call_next):
|
||||
with SessionLocal() as db:
|
||||
configured = state.is_configured_cached(db)
|
||||
if not configured:
|
||||
return JSONResponse(
|
||||
{"detail": "This instance isn't set up yet."}, status_code=503
|
||||
)
|
||||
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
|
||||
+101
-183
@@ -26,9 +26,7 @@ class TimestampMixin:
|
||||
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
||||
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class UpdatedAtMixin:
|
||||
@@ -51,18 +49,14 @@ class User(Base, TimestampMixin):
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
||||
# verifies via a link). Login requires this AND is_active.
|
||||
email_verified: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Admin-approved & enabled. A pending password registration starts inactive; admin
|
||||
# approval activates it. A deactivated account can't log in.
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
# 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
|
||||
# why and pointed at the operator's contact. The demo account is never suspendable.
|
||||
is_suspended: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# 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;
|
||||
# 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"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True)
|
||||
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
|
||||
refresh_token_enc: 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"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=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)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# pending | approved | denied
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), default="pending", server_default="pending"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
requested_at: Mapped[datetime] = mapped_column(
|
||||
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))
|
||||
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
||||
backfill_done: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# 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).
|
||||
# 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"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
channel_id: Mapped[str] = mapped_column(
|
||||
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
|
||||
# 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.
|
||||
deep_requested: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
deep_requested: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
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
|
||||
# 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`.
|
||||
channel_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("channels.id", ondelete="CASCADE")
|
||||
)
|
||||
channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||
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
|
||||
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
|
||||
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
|
||||
# search_vector GIN.
|
||||
is_short: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||
shorts_probed: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# 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
|
||||
# Library by default; left False by the organic ingest paths.
|
||||
via_search: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
via_search: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# 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
|
||||
# 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
|
||||
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||
via_explore: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
via_explore: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# none | live | upcoming | premiere | was_live
|
||||
live_status: Mapped[str] = mapped_column(
|
||||
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.
|
||||
embeddable: Mapped[bool | None] = mapped_column(Boolean)
|
||||
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
|
||||
# (least-recently-checked first); unavailable_since marks a video as currently
|
||||
# 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
|
||||
# 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).
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True)
|
||||
)
|
||||
unavailable_since: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True)
|
||||
)
|
||||
last_checked_at: 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))
|
||||
|
||||
# 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))
|
||||
color: Mapped[str | None] = mapped_column(String(16))
|
||||
# language | topic | other
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(16), default="other", server_default="other"
|
||||
)
|
||||
category: Mapped[str] = mapped_column(String(16), default="other", server_default="other")
|
||||
|
||||
|
||||
class ChannelTag(Base):
|
||||
@@ -376,21 +344,15 @@ class ChannelTag(Base):
|
||||
regenerated freely; source='user' rows are never touched by the system."""
|
||||
|
||||
__tablename__ = "channel_tags"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
channel_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tags.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), index=True)
|
||||
# NULL = system/auto link visible to everyone; set = a specific user's link.
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
|
||||
confidence: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
@@ -403,12 +365,8 @@ class VideoState(Base, UpdatedAtMixin):
|
||||
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
video_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
|
||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -433,12 +391,8 @@ class SearchFind(Base, TimestampMixin):
|
||||
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
video_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
|
||||
class ExploredChannel(Base, TimestampMixin):
|
||||
@@ -453,14 +407,10 @@ class ExploredChannel(Base, TimestampMixin):
|
||||
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
||||
|
||||
__tablename__ = "explored_channels"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
channel_id: Mapped[str] = mapped_column(
|
||||
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."""
|
||||
|
||||
__tablename__ = "blocked_channels"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
channel_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
@@ -544,16 +490,12 @@ class AppState(Base):
|
||||
__tablename__ = "app_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||
sync_paused: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
sync_paused: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# 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
|
||||
# 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.
|
||||
configured: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
@@ -599,14 +541,10 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(16), default="local", server_default="local"
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(16), default="local", server_default="local")
|
||||
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# True when the current items/name differ from `synced_fingerprint` (unpushed edits).
|
||||
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
@@ -626,21 +564,13 @@ class PlaylistItem(Base):
|
||||
"""A video's membership in a playlist, with its order within that playlist."""
|
||||
|
||||
__tablename__ = "playlist_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("playlists.id", ondelete="CASCADE")
|
||||
)
|
||||
video_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"))
|
||||
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|
||||
|
||||
@@ -659,16 +589,12 @@ class SavedView(Base, TimestampMixin, UpdatedAtMixin):
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
filters: Mapped[dict] = mapped_column(JSON)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
is_default: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
@@ -686,19 +612,13 @@ class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance"
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
data: Mapped[dict | None] = mapped_column(JSON)
|
||||
read: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
)
|
||||
dismissed: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
|
||||
dismissed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
@@ -724,9 +644,7 @@ class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(
|
||||
String(16), default="user", server_default="user"
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
# NULL for system messages (no human sender).
|
||||
sender_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
@@ -786,13 +704,9 @@ class MediaAsset(Base, TimestampMixin):
|
||||
container: Mapped[str | None] = mapped_column(String(16))
|
||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||
nfo_written: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||
gc_notified: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
||||
title: Mapped[str | None] = mapped_column(Text)
|
||||
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||
@@ -828,9 +742,7 @@ class DownloadProfile(Base, TimestampMixin):
|
||||
) # NULL for a builtin preset
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
spec: Mapped[dict] = mapped_column(JSON)
|
||||
is_builtin: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
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"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
asset_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||
source_ref: Mapped[str] = mapped_column(String(512))
|
||||
# "download" (default) or "edit" (a per-user trim/crop derivative of another job).
|
||||
job_kind: Mapped[str] = mapped_column(
|
||||
String(16), default="download", server_default="download"
|
||||
)
|
||||
job_kind: Mapped[str] = mapped_column(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).
|
||||
source_job_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
||||
@@ -895,7 +803,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
eta_s: Mapped[int | None] = mapped_column(Integer)
|
||||
phase: Mapped[str | None] = mapped_column(String(32))
|
||||
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")
|
||||
|
||||
|
||||
@@ -904,20 +814,14 @@ class DownloadShare(Base, TimestampMixin):
|
||||
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
||||
|
||||
__tablename__ = "download_shares"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="CASCADE")
|
||||
)
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey("download_jobs.id", ondelete="CASCADE"))
|
||||
from_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
to_user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
|
||||
class DownloadQuota(Base, UpdatedAtMixin):
|
||||
@@ -933,9 +837,7 @@ class DownloadQuota(Base, UpdatedAtMixin):
|
||||
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
max_concurrent: Mapped[int] = mapped_column(Integer)
|
||||
max_jobs: Mapped[int] = mapped_column(Integer)
|
||||
unlimited: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
unlimited: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
class DownloadLink(Base, TimestampMixin):
|
||||
@@ -954,12 +856,8 @@ class DownloadLink(Base, TimestampMixin):
|
||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
allow_download: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||
# 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
|
||||
@@ -1046,10 +944,14 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
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))
|
||||
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)
|
||||
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||
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))
|
||||
directors: 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.
|
||||
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
|
||||
# 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
|
||||
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;
|
||||
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
@@ -1080,8 +988,12 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||
budget: 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]
|
||||
crew: Mapped[list | None] = mapped_column(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]
|
||||
crew: Mapped[list | None] = mapped_column(
|
||||
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]
|
||||
recommendations: 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))
|
||||
# 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.
|
||||
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
|
||||
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
|
||||
# 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;
|
||||
@@ -1214,12 +1130,18 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
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}]
|
||||
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str]
|
||||
crew: Mapped[list | None] = mapped_column(
|
||||
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]
|
||||
# [{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))
|
||||
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
|
||||
# (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))
|
||||
@@ -1273,7 +1195,9 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||
place_of_birth: Mapped[str | None] = mapped_column(Text)
|
||||
known_for_department: Mapped[str | None] = mapped_column(String(64))
|
||||
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))
|
||||
|
||||
|
||||
@@ -1309,9 +1233,7 @@ class PlexState(Base, UpdatedAtMixin):
|
||||
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
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))
|
||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
synced_to_plex: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
class PlexLink(Base, UpdatedAtMixin):
|
||||
@@ -1378,9 +1298,7 @@ class PlexPlaylistItem(Base):
|
||||
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_playlists.id", ondelete="CASCADE")
|
||||
)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
|
||||
@@ -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
|
||||
table can't grow without bound for a heavy user.
|
||||
"""
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -37,9 +38,7 @@ def create_notification(
|
||||
transaction can pass commit=False and commit once themselves."""
|
||||
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
|
||||
notif = Notification(
|
||||
user_id=user_id, type=type, title=title, body=body, data=data
|
||||
)
|
||||
notif = Notification(user_id=user_id, type=type, title=title, body=body, data=data)
|
||||
db.add(notif)
|
||||
if commit:
|
||||
db.commit()
|
||||
|
||||
@@ -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
|
||||
``Accept: application/json`` header (Plex serves XML by default).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -163,7 +164,9 @@ class PlexClient:
|
||||
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:
|
||||
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:
|
||||
self._write(
|
||||
@@ -221,7 +224,11 @@ class PlexClient:
|
||||
return self._get("/accounts").get("Account", []) or []
|
||||
|
||||
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]:
|
||||
"""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"
|
||||
@@ -255,7 +262,8 @@ class PlexClient:
|
||||
else:
|
||||
log.warning(
|
||||
"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
|
||||
|
||||
|
||||
@@ -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,
|
||||
"content_rating": meta.get("contentRating"),
|
||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||
"directors": [
|
||||
d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")
|
||||
][:4],
|
||||
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
||||
"studio": meta.get("studio"),
|
||||
"tagline": meta.get("tagline"),
|
||||
"cast": people.cast_from_meta(meta),
|
||||
|
||||
@@ -63,6 +63,4 @@ def write(key: str, data: bytes, ct: str) -> None:
|
||||
|
||||
|
||||
def response(data: bytes, ct: str) -> Response:
|
||||
return Response(
|
||||
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
|
||||
)
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place."""
|
||||
|
||||
from typing import TypeVar
|
||||
|
||||
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
|
||||
tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call
|
||||
sites keep their `Model | None` static type."""
|
||||
return db.execute(
|
||||
select(model).where(model.rating_key == str(rating_key))
|
||||
).scalars().first()
|
||||
return db.execute(select(model).where(model.rating_key == str(rating_key))).scalars().first()
|
||||
|
||||
@@ -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
|
||||
sides (Plex and Siftlode see the same filesystem).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user