From b1832afd180866b922521148603db447020c57a4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 07:43:06 +0200 Subject: [PATCH] style: apply ruff format across backend + scripts (line-length 100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it. --- backend/alembic/versions/0001_initial.py | 1 + backend/alembic/versions/0002_ingest_core.py | 1 + backend/alembic/versions/0003_tags.py | 1 + .../alembic/versions/0004_feed_state_prefs.py | 1 + .../alembic/versions/0005_shorts_probed.py | 1 + backend/alembic/versions/0006_app_state.py | 1 + .../alembic/versions/0007_deep_requested.py | 1 + backend/alembic/versions/0008_invites.py | 5 +- backend/alembic/versions/0009_quota_events.py | 1 + .../alembic/versions/0010_watch_progress.py | 1 + backend/alembic/versions/0011_playlists.py | 13 +- backend/alembic/versions/0012_watch_later.py | 1 + .../versions/0013_playlist_fingerprint.py | 13 +- backend/alembic/versions/0014_demo_account.py | 5 +- .../versions/0015_scheduler_settings.py | 1 + .../versions/0016_video_maintenance.py | 5 +- .../alembic/versions/0017_notifications.py | 9 +- .../0018_maintenance_batch_setting.py | 1 + .../versions/0019_user_yt_channel_id.py | 1 + .../versions/0020_rename_quota_actions.py | 1 + .../alembic/versions/0021_system_config.py | 1 + .../alembic/versions/0022_password_auth.py | 1 + .../alembic/versions/0023_user_suspension.py | 1 + .../versions/0024_google_email_verified.py | 1 + .../alembic/versions/0025_install_wizard.py | 1 + backend/alembic/versions/0026_messages.py | 1 + backend/alembic/versions/0027_message_e2ee.py | 1 + .../versions/0028_live_search_provenance.py | 9 +- .../alembic/versions/0029_unaccent_search.py | 1 + backend/alembic/versions/0030_search_finds.py | 1 + backend/alembic/versions/0031_title_fts.py | 5 +- .../alembic/versions/0032_search_document.py | 5 +- .../alembic/versions/0033_channel_explore.py | 5 +- .../alembic/versions/0034_blocked_channels.py | 1 + backend/alembic/versions/0035_saved_views.py | 5 +- .../alembic/versions/0036_download_center.py | 47 ++- .../0037_download_builtin_profiles.py | 1 + .../versions/0038_asset_gc_notified.py | 1 + .../versions/0039_video_title_normalize.py | 5 +- .../versions/0040_reset_builtin_profiles.py | 1 + .../alembic/versions/0041_download_edit.py | 21 +- .../alembic/versions/0042_download_links.py | 8 +- .../alembic/versions/0043_asset_source_url.py | 1 + backend/alembic/versions/0044_plex_catalog.py | 83 ++++- .../alembic/versions/0045_plex_filter_meta.py | 9 +- .../versions/0046_plex_people_search.py | 1 + .../alembic/versions/0047_plex_collections.py | 34 ++- .../alembic/versions/0048_plex_playlists.py | 31 +- .../versions/0049_download_display_meta.py | 5 +- backend/alembic/versions/0050_asset_poster.py | 1 + backend/alembic/versions/0051_plex_link.py | 1 + .../alembic/versions/0052_plex_show_meta.py | 9 +- .../versions/0053_user_session_epoch.py | 1 + backend/alembic/versions/0054_audit_log.py | 1 + .../alembic/versions/0055_channel_keywords.py | 1 + backend/alembic/versions/0056_plex_people.py | 9 +- .../versions/0057_plex_ppl_enriched.py | 5 +- .../alembic/versions/0058_dl_default_720p.py | 1 + .../versions/0059_dl_link_pw_version.py | 1 + .../0060_plex_jsonb_null_normalize.py | 5 +- .../versions/0061_dl_job_error_code.py | 1 + .../versions/0062_drop_redundant_idx.py | 1 + .../versions/0063_plex_library_overlay.py | 1 + .../versions/0064_plex_hu_overlay_cols.py | 1 + .../versions/0065_plex_detail_fields.py | 1 + .../versions/0066_notif_created_at_notnull.py | 1 + .../versions/0067_feed_sort_indexes.py | 1 + .../versions/0068_published_at_not_null.py | 5 +- .../versions/0069_job_index_hygiene.py | 1 + .../versions/0070_fold_maintenance_batch.py | 1 + .../alembic/versions/0071_plex_tmdb_fields.py | 1 + .../alembic/versions/0072_plex_person_bio.py | 1 + .../versions/0073_saved_view_source.py | 1 + backend/app/audit.py | 1 + backend/app/auth.py | 15 +- backend/app/config.py | 1 + backend/app/demo.py | 19 +- backend/app/downloads/adapters.py | 7 +- backend/app/downloads/backfill.py | 1 + backend/app/downloads/edit.py | 180 +++++++++-- backend/app/downloads/formats.py | 5 +- backend/app/downloads/gc.py | 94 +++--- backend/app/downloads/jobdb.py | 2 + backend/app/downloads/links.py | 11 +- backend/app/downloads/og.py | 5 +- backend/app/downloads/pipeline.py | 71 +++-- backend/app/downloads/quota.py | 1 + backend/app/downloads/runner.py | 1 + backend/app/downloads/service.py | 4 +- backend/app/downloads/storage.py | 31 +- backend/app/email.py | 19 +- backend/app/feedquery.py | 78 +++-- backend/app/main.py | 8 +- backend/app/models.py | 284 +++++++----------- backend/app/notifications.py | 5 +- backend/app/plex/client.py | 14 +- backend/app/plex/detail.py | 4 +- backend/app/plex/imgcache.py | 4 +- backend/app/plex/lookups.py | 5 +- backend/app/plex/paths.py | 3 +- backend/app/plex/people.py | 4 +- backend/app/plex/query.py | 12 +- backend/app/plex/rollup.py | 16 +- backend/app/plex/stream.py | 105 +++++-- backend/app/plex/subtitles.py | 7 +- backend/app/plex/sync.py | 92 +++--- backend/app/plex/tmdb.py | 25 +- backend/app/plex/watch_sync.py | 91 +++--- backend/app/progress.py | 1 + backend/app/quota.py | 7 +- backend/app/ratelimit.py | 1 + backend/app/realtime.py | 1 + backend/app/routes/_common.py | 1 + backend/app/routes/admin.py | 87 ++++-- backend/app/routes/audit.py | 5 +- backend/app/routes/auth.py | 50 +-- backend/app/routes/channels.py | 90 +++--- backend/app/routes/config.py | 13 +- backend/app/routes/downloads.py | 57 ++-- backend/app/routes/feed.py | 17 +- backend/app/routes/me.py | 6 +- backend/app/routes/messages.py | 35 +-- backend/app/routes/notifications.py | 17 +- backend/app/routes/playlists.py | 29 +- backend/app/routes/plex/_shared.py | 12 +- backend/app/routes/plex/admin.py | 32 +- backend/app/routes/plex/browse.py | 48 ++- backend/app/routes/plex/collections.py | 97 +++--- backend/app/routes/plex/detail.py | 95 +++--- backend/app/routes/plex/discover.py | 161 ++++++---- backend/app/routes/plex/people.py | 58 ++-- backend/app/routes/plex/playback.py | 84 +++--- backend/app/routes/plex/playlists.py | 48 ++- backend/app/routes/public.py | 4 +- backend/app/routes/quota.py | 12 +- backend/app/routes/saved_views.py | 14 +- backend/app/routes/scheduler.py | 26 +- backend/app/routes/search.py | 31 +- backend/app/routes/setup.py | 1 + backend/app/routes/sync.py | 24 +- backend/app/scheduler.py | 27 +- backend/app/security.py | 1 + backend/app/state.py | 2 + backend/app/sync/autotag.py | 5 +- backend/app/sync/explore.py | 23 +- backend/app/sync/maintenance.py | 11 +- backend/app/sync/playlists.py | 13 +- backend/app/sync/runner.py | 9 +- backend/app/sync/subscriptions.py | 12 +- backend/app/sync/videos.py | 30 +- backend/app/sysconfig.py | 82 ++++- backend/app/titles.py | 87 +++++- backend/app/userscope.py | 1 + backend/app/utils.py | 2 + backend/app/worker.py | 47 ++- backend/app/youtube/client.py | 19 +- backend/app/youtube/rss.py | 1 + backend/app/youtube/search_scrape.py | 14 +- backend/app/youtube/shorts.py | 5 +- backend/tests/conftest.py | 1 + backend/tests/test_adapters.py | 29 +- backend/tests/test_auth_session.py | 14 +- backend/tests/test_channel_backfill.py | 9 +- backend/tests/test_channel_tags.py | 1 + backend/tests/test_config_commit_ownership.py | 9 +- backend/tests/test_db_lane.py | 1 + backend/tests/test_download_errors.py | 1 + backend/tests/test_download_limits.py | 19 +- backend/tests/test_download_output.py | 27 +- backend/tests/test_download_ref_count.py | 22 +- backend/tests/test_download_transitions.py | 18 +- backend/tests/test_downloads_finalize.py | 9 +- backend/tests/test_feed_cursor.py | 5 +- backend/tests/test_feed_keyset.py | 78 +++-- backend/tests/test_feed_views.py | 73 +++-- backend/tests/test_ffmpeg_callbacks.py | 9 +- backend/tests/test_formats.py | 1 + backend/tests/test_gc.py | 1 + backend/tests/test_links.py | 1 + backend/tests/test_migrations.py | 10 +- backend/tests/test_naming.py | 12 +- backend/tests/test_notifications.py | 9 +- backend/tests/test_oauth_redirect.py | 5 +- backend/tests/test_og.py | 1 + backend/tests/test_plex_collections_filter.py | 162 +++++++--- backend/tests/test_plex_detail.py | 39 ++- backend/tests/test_plex_discover.py | 160 +++++++--- backend/tests/test_plex_facets.py | 15 +- backend/tests/test_plex_facets_grid_parity.py | 114 +++++-- .../tests/test_plex_field_scoped_search.py | 67 ++++- backend/tests/test_plex_jsonb_normalize.py | 19 +- backend/tests/test_plex_keyword_filter.py | 27 +- backend/tests/test_plex_lang.py | 57 ++-- backend/tests/test_plex_overlay.py | 37 ++- backend/tests/test_plex_person.py | 56 +++- backend/tests/test_plex_person_bio.py | 81 +++-- backend/tests/test_plex_query_conversion.py | 227 ++++++++++---- backend/tests/test_plex_stream.py | 1 + backend/tests/test_plex_sync_prune.py | 61 +++- backend/tests/test_plex_sync_txn.py | 9 +- backend/tests/test_plex_tmdb.py | 106 +++++-- backend/tests/test_quota_ownership.py | 16 +- backend/tests/test_request_models.py | 3 + backend/tests/test_run_ffmpeg.py | 25 +- backend/tests/test_saved_views_source.py | 13 +- backend/tests/test_scheduler.py | 1 + backend/tests/test_search_scrape_ingest.py | 1 + backend/tests/test_security.py | 1 + backend/tests/test_smoke.py | 1 + backend/tests/test_storage.py | 6 +- backend/tests/test_sync_guards.py | 5 +- backend/tests/test_thumbnail_poster.py | 16 +- backend/tests/test_titles.py | 5 +- backend/tests/test_userscope.py | 1 + backend/tests/test_utils.py | 1 + backend/tests/test_worker_supervisor.py | 3 + scripts/e2e_seed.py | 9 +- 217 files changed, 3149 insertions(+), 1744 deletions(-) diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py index 3db52a65..9bce64e3 100644 --- a/backend/alembic/versions/0001_initial.py +++ b/backend/alembic/versions/0001_initial.py @@ -4,6 +4,7 @@ Revision ID: 0001_initial Revises: Create Date: 2026-06-11 """ + from typing import Sequence, Union from alembic import op diff --git a/backend/alembic/versions/0002_ingest_core.py b/backend/alembic/versions/0002_ingest_core.py index 5c05bad9..7121aff1 100644 --- a/backend/alembic/versions/0002_ingest_core.py +++ b/backend/alembic/versions/0002_ingest_core.py @@ -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 diff --git a/backend/alembic/versions/0003_tags.py b/backend/alembic/versions/0003_tags.py index 1dafaf99..5261ae45 100644 --- a/backend/alembic/versions/0003_tags.py +++ b/backend/alembic/versions/0003_tags.py @@ -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 diff --git a/backend/alembic/versions/0004_feed_state_prefs.py b/backend/alembic/versions/0004_feed_state_prefs.py index a8ca4072..04e6e069 100644 --- a/backend/alembic/versions/0004_feed_state_prefs.py +++ b/backend/alembic/versions/0004_feed_state_prefs.py @@ -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 diff --git a/backend/alembic/versions/0005_shorts_probed.py b/backend/alembic/versions/0005_shorts_probed.py index a336cd74..96c6633f 100644 --- a/backend/alembic/versions/0005_shorts_probed.py +++ b/backend/alembic/versions/0005_shorts_probed.py @@ -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 diff --git a/backend/alembic/versions/0006_app_state.py b/backend/alembic/versions/0006_app_state.py index 2720e2aa..850e35b2 100644 --- a/backend/alembic/versions/0006_app_state.py +++ b/backend/alembic/versions/0006_app_state.py @@ -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 diff --git a/backend/alembic/versions/0007_deep_requested.py b/backend/alembic/versions/0007_deep_requested.py index 734f4739..ef43c0aa 100644 --- a/backend/alembic/versions/0007_deep_requested.py +++ b/backend/alembic/versions/0007_deep_requested.py @@ -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 diff --git a/backend/alembic/versions/0008_invites.py b/backend/alembic/versions/0008_invites.py index abbaafc6..f169e978 100644 --- a/backend/alembic/versions/0008_invites.py +++ b/backend/alembic/versions/0008_invites.py @@ -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", diff --git a/backend/alembic/versions/0009_quota_events.py b/backend/alembic/versions/0009_quota_events.py index 740956f7..e49a36c7 100644 --- a/backend/alembic/versions/0009_quota_events.py +++ b/backend/alembic/versions/0009_quota_events.py @@ -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 diff --git a/backend/alembic/versions/0010_watch_progress.py b/backend/alembic/versions/0010_watch_progress.py index a18cc10a..1ebf2a94 100644 --- a/backend/alembic/versions/0010_watch_progress.py +++ b/backend/alembic/versions/0010_watch_progress.py @@ -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 diff --git a/backend/alembic/versions/0011_playlists.py b/backend/alembic/versions/0011_playlists.py index efd5c05f..cd09cbbc 100644 --- a/backend/alembic/versions/0011_playlists.py +++ b/backend/alembic/versions/0011_playlists.py @@ -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", diff --git a/backend/alembic/versions/0012_watch_later.py b/backend/alembic/versions/0012_watch_later.py index fd5a847c..69b7c160 100644 --- a/backend/alembic/versions/0012_watch_later.py +++ b/backend/alembic/versions/0012_watch_later.py @@ -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 diff --git a/backend/alembic/versions/0013_playlist_fingerprint.py b/backend/alembic/versions/0013_playlist_fingerprint.py index 7ec6dbc8..07a49187 100644 --- a/backend/alembic/versions/0013_playlist_fingerprint.py +++ b/backend/alembic/versions/0013_playlist_fingerprint.py @@ -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() diff --git a/backend/alembic/versions/0014_demo_account.py b/backend/alembic/versions/0014_demo_account.py index 3d9a8f31..9186b545 100644 --- a/backend/alembic/versions/0014_demo_account.py +++ b/backend/alembic/versions/0014_demo_account.py @@ -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: diff --git a/backend/alembic/versions/0015_scheduler_settings.py b/backend/alembic/versions/0015_scheduler_settings.py index 5a7f5904..b4bde6cc 100644 --- a/backend/alembic/versions/0015_scheduler_settings.py +++ b/backend/alembic/versions/0015_scheduler_settings.py @@ -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 diff --git a/backend/alembic/versions/0016_video_maintenance.py b/backend/alembic/versions/0016_video_maintenance.py index 7eff8ca7..01a47ba1 100644 --- a/backend/alembic/versions/0016_video_maintenance.py +++ b/backend/alembic/versions/0016_video_maintenance.py @@ -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"]) diff --git a/backend/alembic/versions/0017_notifications.py b/backend/alembic/versions/0017_notifications.py index 057544e2..a22c609d 100644 --- a/backend/alembic/versions/0017_notifications.py +++ b/backend/alembic/versions/0017_notifications.py @@ -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: diff --git a/backend/alembic/versions/0018_maintenance_batch_setting.py b/backend/alembic/versions/0018_maintenance_batch_setting.py index 07b9f9ec..33f1aed3 100644 --- a/backend/alembic/versions/0018_maintenance_batch_setting.py +++ b/backend/alembic/versions/0018_maintenance_batch_setting.py @@ -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 diff --git a/backend/alembic/versions/0019_user_yt_channel_id.py b/backend/alembic/versions/0019_user_yt_channel_id.py index 3976927b..de3fe136 100644 --- a/backend/alembic/versions/0019_user_yt_channel_id.py +++ b/backend/alembic/versions/0019_user_yt_channel_id.py @@ -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 diff --git a/backend/alembic/versions/0020_rename_quota_actions.py b/backend/alembic/versions/0020_rename_quota_actions.py index a427f6d2..0874064d 100644 --- a/backend/alembic/versions/0020_rename_quota_actions.py +++ b/backend/alembic/versions/0020_rename_quota_actions.py @@ -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 diff --git a/backend/alembic/versions/0021_system_config.py b/backend/alembic/versions/0021_system_config.py index 59816754..a818b70a 100644 --- a/backend/alembic/versions/0021_system_config.py +++ b/backend/alembic/versions/0021_system_config.py @@ -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 diff --git a/backend/alembic/versions/0022_password_auth.py b/backend/alembic/versions/0022_password_auth.py index decb5492..a9da3fb9 100644 --- a/backend/alembic/versions/0022_password_auth.py +++ b/backend/alembic/versions/0022_password_auth.py @@ -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 diff --git a/backend/alembic/versions/0023_user_suspension.py b/backend/alembic/versions/0023_user_suspension.py index 51f20fa0..e26ffa3a 100644 --- a/backend/alembic/versions/0023_user_suspension.py +++ b/backend/alembic/versions/0023_user_suspension.py @@ -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 diff --git a/backend/alembic/versions/0024_google_email_verified.py b/backend/alembic/versions/0024_google_email_verified.py index 7b7af531..242938f9 100644 --- a/backend/alembic/versions/0024_google_email_verified.py +++ b/backend/alembic/versions/0024_google_email_verified.py @@ -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 diff --git a/backend/alembic/versions/0025_install_wizard.py b/backend/alembic/versions/0025_install_wizard.py index a3026d80..7fad6a32 100644 --- a/backend/alembic/versions/0025_install_wizard.py +++ b/backend/alembic/versions/0025_install_wizard.py @@ -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 diff --git a/backend/alembic/versions/0026_messages.py b/backend/alembic/versions/0026_messages.py index 0ebb22bc..761bfa83 100644 --- a/backend/alembic/versions/0026_messages.py +++ b/backend/alembic/versions/0026_messages.py @@ -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 diff --git a/backend/alembic/versions/0027_message_e2ee.py b/backend/alembic/versions/0027_message_e2ee.py index 2d72fa01..5285509f 100644 --- a/backend/alembic/versions/0027_message_e2ee.py +++ b/backend/alembic/versions/0027_message_e2ee.py @@ -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 diff --git a/backend/alembic/versions/0028_live_search_provenance.py b/backend/alembic/versions/0028_live_search_provenance.py index 31471930..cc1b3903 100644 --- a/backend/alembic/versions/0028_live_search_provenance.py +++ b/backend/alembic/versions/0028_live_search_provenance.py @@ -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"]) diff --git a/backend/alembic/versions/0029_unaccent_search.py b/backend/alembic/versions/0029_unaccent_search.py index adc7f666..b680efd1 100644 --- a/backend/alembic/versions/0029_unaccent_search.py +++ b/backend/alembic/versions/0029_unaccent_search.py @@ -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 diff --git a/backend/alembic/versions/0030_search_finds.py b/backend/alembic/versions/0030_search_finds.py index 975027a8..90ead7f3 100644 --- a/backend/alembic/versions/0030_search_finds.py +++ b/backend/alembic/versions/0030_search_finds.py @@ -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 diff --git a/backend/alembic/versions/0031_title_fts.py b/backend/alembic/versions/0031_title_fts.py index ea16ac95..a65983a8 100644 --- a/backend/alembic/versions/0031_title_fts.py +++ b/backend/alembic/versions/0031_title_fts.py @@ -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 diff --git a/backend/alembic/versions/0032_search_document.py b/backend/alembic/versions/0032_search_document.py index 3683df42..21739034 100644 --- a/backend/alembic/versions/0032_search_document.py +++ b/backend/alembic/versions/0032_search_document.py @@ -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: diff --git a/backend/alembic/versions/0033_channel_explore.py b/backend/alembic/versions/0033_channel_explore.py index d12187b4..41d414d9 100644 --- a/backend/alembic/versions/0033_channel_explore.py +++ b/backend/alembic/versions/0033_channel_explore.py @@ -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( diff --git a/backend/alembic/versions/0034_blocked_channels.py b/backend/alembic/versions/0034_blocked_channels.py index ebcb85c9..c65a6718 100644 --- a/backend/alembic/versions/0034_blocked_channels.py +++ b/backend/alembic/versions/0034_blocked_channels.py @@ -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 diff --git a/backend/alembic/versions/0035_saved_views.py b/backend/alembic/versions/0035_saved_views.py index 4a43a80e..8c8db612 100644 --- a/backend/alembic/versions/0035_saved_views.py +++ b/backend/alembic/versions/0035_saved_views.py @@ -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(), diff --git a/backend/alembic/versions/0036_download_center.py b/backend/alembic/versions/0036_download_center.py index b9f06771..d0a76402 100644 --- a/backend/alembic/versions/0036_download_center.py +++ b/backend/alembic/versions/0036_download_center.py @@ -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"), ) diff --git a/backend/alembic/versions/0037_download_builtin_profiles.py b/backend/alembic/versions/0037_download_builtin_profiles.py index d8742a01..0e488465 100644 --- a/backend/alembic/versions/0037_download_builtin_profiles.py +++ b/backend/alembic/versions/0037_download_builtin_profiles.py @@ -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 diff --git a/backend/alembic/versions/0038_asset_gc_notified.py b/backend/alembic/versions/0038_asset_gc_notified.py index 19b317fd..d1e5a2c4 100644 --- a/backend/alembic/versions/0038_asset_gc_notified.py +++ b/backend/alembic/versions/0038_asset_gc_notified.py @@ -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 diff --git a/backend/alembic/versions/0039_video_title_normalize.py b/backend/alembic/versions/0039_video_title_normalize.py index c8351c39..233ca250 100644 --- a/backend/alembic/versions/0039_video_title_normalize.py +++ b/backend/alembic/versions/0039_video_title_normalize.py @@ -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") diff --git a/backend/alembic/versions/0040_reset_builtin_profiles.py b/backend/alembic/versions/0040_reset_builtin_profiles.py index ed3de027..349c5517 100644 --- a/backend/alembic/versions/0040_reset_builtin_profiles.py +++ b/backend/alembic/versions/0040_reset_builtin_profiles.py @@ -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 diff --git a/backend/alembic/versions/0041_download_edit.py b/backend/alembic/versions/0041_download_edit.py index d077a122..63ce8b94 100644 --- a/backend/alembic/versions/0041_download_edit.py +++ b/backend/alembic/versions/0041_download_edit.py @@ -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") diff --git a/backend/alembic/versions/0042_download_links.py b/backend/alembic/versions/0042_download_links.py index be492185..a6045e21 100644 --- a/backend/alembic/versions/0042_download_links.py +++ b/backend/alembic/versions/0042_download_links.py @@ -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"), diff --git a/backend/alembic/versions/0043_asset_source_url.py b/backend/alembic/versions/0043_asset_source_url.py index fd98285f..73e273aa 100644 --- a/backend/alembic/versions/0043_asset_source_url.py +++ b/backend/alembic/versions/0043_asset_source_url.py @@ -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 diff --git a/backend/alembic/versions/0044_plex_catalog.py b/backend/alembic/versions/0044_plex_catalog.py index f0909443..2b4b370a 100644 --- a/backend/alembic/versions/0044_plex_catalog.py +++ b/backend/alembic/versions/0044_plex_catalog.py @@ -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"]) diff --git a/backend/alembic/versions/0045_plex_filter_meta.py b/backend/alembic/versions/0045_plex_filter_meta.py index f47c03af..16c2be82 100644 --- a/backend/alembic/versions/0045_plex_filter_meta.py +++ b/backend/alembic/versions/0045_plex_filter_meta.py @@ -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") diff --git a/backend/alembic/versions/0046_plex_people_search.py b/backend/alembic/versions/0046_plex_people_search.py index e8854c93..00c4513b 100644 --- a/backend/alembic/versions/0046_plex_people_search.py +++ b/backend/alembic/versions/0046_plex_people_search.py @@ -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 diff --git a/backend/alembic/versions/0047_plex_collections.py b/backend/alembic/versions/0047_plex_collections.py index 684e627d..c30e7692 100644 --- a/backend/alembic/versions/0047_plex_collections.py +++ b/backend/alembic/versions/0047_plex_collections.py @@ -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: diff --git a/backend/alembic/versions/0048_plex_playlists.py b/backend/alembic/versions/0048_plex_playlists.py index 68097cb2..471bc768 100644 --- a/backend/alembic/versions/0048_plex_playlists.py +++ b/backend/alembic/versions/0048_plex_playlists.py @@ -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"), diff --git a/backend/alembic/versions/0049_download_display_meta.py b/backend/alembic/versions/0049_download_display_meta.py index 51c63357..c0cc195c 100644 --- a/backend/alembic/versions/0049_download_display_meta.py +++ b/backend/alembic/versions/0049_download_display_meta.py @@ -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)) diff --git a/backend/alembic/versions/0050_asset_poster.py b/backend/alembic/versions/0050_asset_poster.py index c3332778..7c210b7b 100644 --- a/backend/alembic/versions/0050_asset_poster.py +++ b/backend/alembic/versions/0050_asset_poster.py @@ -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 diff --git a/backend/alembic/versions/0051_plex_link.py b/backend/alembic/versions/0051_plex_link.py index aaf7e0ab..7ed221b0 100644 --- a/backend/alembic/versions/0051_plex_link.py +++ b/backend/alembic/versions/0051_plex_link.py @@ -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 diff --git a/backend/alembic/versions/0052_plex_show_meta.py b/backend/alembic/versions/0052_plex_show_meta.py index 523421c6..b7e92687 100644 --- a/backend/alembic/versions/0052_plex_show_meta.py +++ b/backend/alembic/versions/0052_plex_show_meta.py @@ -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). diff --git a/backend/alembic/versions/0053_user_session_epoch.py b/backend/alembic/versions/0053_user_session_epoch.py index 6f5c8386..ae0d91af 100644 --- a/backend/alembic/versions/0053_user_session_epoch.py +++ b/backend/alembic/versions/0053_user_session_epoch.py @@ -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 diff --git a/backend/alembic/versions/0054_audit_log.py b/backend/alembic/versions/0054_audit_log.py index 5f839380..85331422 100644 --- a/backend/alembic/versions/0054_audit_log.py +++ b/backend/alembic/versions/0054_audit_log.py @@ -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 diff --git a/backend/alembic/versions/0055_channel_keywords.py b/backend/alembic/versions/0055_channel_keywords.py index 0a2877c8..438ecaee 100644 --- a/backend/alembic/versions/0055_channel_keywords.py +++ b/backend/alembic/versions/0055_channel_keywords.py @@ -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 diff --git a/backend/alembic/versions/0056_plex_people.py b/backend/alembic/versions/0056_plex_people.py index 7c7f6927..05db18ea 100644 --- a/backend/alembic/versions/0056_plex_people.py +++ b/backend/alembic/versions/0056_plex_people.py @@ -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) diff --git a/backend/alembic/versions/0057_plex_ppl_enriched.py b/backend/alembic/versions/0057_plex_ppl_enriched.py index 528d8554..9c0ef389 100644 --- a/backend/alembic/versions/0057_plex_ppl_enriched.py +++ b/backend/alembic/versions/0057_plex_ppl_enriched.py @@ -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: diff --git a/backend/alembic/versions/0058_dl_default_720p.py b/backend/alembic/versions/0058_dl_default_720p.py index 6d8e1a91..51662ce7 100644 --- a/backend/alembic/versions/0058_dl_default_720p.py +++ b/backend/alembic/versions/0058_dl_default_720p.py @@ -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 diff --git a/backend/alembic/versions/0059_dl_link_pw_version.py b/backend/alembic/versions/0059_dl_link_pw_version.py index fdd764b1..0a51344a 100644 --- a/backend/alembic/versions/0059_dl_link_pw_version.py +++ b/backend/alembic/versions/0059_dl_link_pw_version.py @@ -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 diff --git a/backend/alembic/versions/0060_plex_jsonb_null_normalize.py b/backend/alembic/versions/0060_plex_jsonb_null_normalize.py index b87e99eb..4cd3a009 100644 --- a/backend/alembic/versions/0060_plex_jsonb_null_normalize.py +++ b/backend/alembic/versions/0060_plex_jsonb_null_normalize.py @@ -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: diff --git a/backend/alembic/versions/0061_dl_job_error_code.py b/backend/alembic/versions/0061_dl_job_error_code.py index e4ea75fb..cc0057ad 100644 --- a/backend/alembic/versions/0061_dl_job_error_code.py +++ b/backend/alembic/versions/0061_dl_job_error_code.py @@ -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 diff --git a/backend/alembic/versions/0062_drop_redundant_idx.py b/backend/alembic/versions/0062_drop_redundant_idx.py index 198e980d..12ba712c 100644 --- a/backend/alembic/versions/0062_drop_redundant_idx.py +++ b/backend/alembic/versions/0062_drop_redundant_idx.py @@ -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 diff --git a/backend/alembic/versions/0063_plex_library_overlay.py b/backend/alembic/versions/0063_plex_library_overlay.py index 8fdca10f..1718ed1a 100644 --- a/backend/alembic/versions/0063_plex_library_overlay.py +++ b/backend/alembic/versions/0063_plex_library_overlay.py @@ -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 diff --git a/backend/alembic/versions/0064_plex_hu_overlay_cols.py b/backend/alembic/versions/0064_plex_hu_overlay_cols.py index 2d433531..275bcb61 100644 --- a/backend/alembic/versions/0064_plex_hu_overlay_cols.py +++ b/backend/alembic/versions/0064_plex_hu_overlay_cols.py @@ -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 diff --git a/backend/alembic/versions/0065_plex_detail_fields.py b/backend/alembic/versions/0065_plex_detail_fields.py index c5b62be6..dc57a5e9 100644 --- a/backend/alembic/versions/0065_plex_detail_fields.py +++ b/backend/alembic/versions/0065_plex_detail_fields.py @@ -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 diff --git a/backend/alembic/versions/0066_notif_created_at_notnull.py b/backend/alembic/versions/0066_notif_created_at_notnull.py index 486f55b9..0b8f12db 100644 --- a/backend/alembic/versions/0066_notif_created_at_notnull.py +++ b/backend/alembic/versions/0066_notif_created_at_notnull.py @@ -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 diff --git a/backend/alembic/versions/0067_feed_sort_indexes.py b/backend/alembic/versions/0067_feed_sort_indexes.py index 07f1e22b..63c60f02 100644 --- a/backend/alembic/versions/0067_feed_sort_indexes.py +++ b/backend/alembic/versions/0067_feed_sort_indexes.py @@ -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 diff --git a/backend/alembic/versions/0068_published_at_not_null.py b/backend/alembic/versions/0068_published_at_not_null.py index 1258a240..7fe11975 100644 --- a/backend/alembic/versions/0068_published_at_not_null.py +++ b/backend/alembic/versions/0068_published_at_not_null.py @@ -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: diff --git a/backend/alembic/versions/0069_job_index_hygiene.py b/backend/alembic/versions/0069_job_index_hygiene.py index 59370115..d4ce54f9 100644 --- a/backend/alembic/versions/0069_job_index_hygiene.py +++ b/backend/alembic/versions/0069_job_index_hygiene.py @@ -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 diff --git a/backend/alembic/versions/0070_fold_maintenance_batch.py b/backend/alembic/versions/0070_fold_maintenance_batch.py index 830bd60a..1f3071a9 100644 --- a/backend/alembic/versions/0070_fold_maintenance_batch.py +++ b/backend/alembic/versions/0070_fold_maintenance_batch.py @@ -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 diff --git a/backend/alembic/versions/0071_plex_tmdb_fields.py b/backend/alembic/versions/0071_plex_tmdb_fields.py index 5342f108..dbd22ba4 100644 --- a/backend/alembic/versions/0071_plex_tmdb_fields.py +++ b/backend/alembic/versions/0071_plex_tmdb_fields.py @@ -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 diff --git a/backend/alembic/versions/0072_plex_person_bio.py b/backend/alembic/versions/0072_plex_person_bio.py index 5ef253fd..368221cc 100644 --- a/backend/alembic/versions/0072_plex_person_bio.py +++ b/backend/alembic/versions/0072_plex_person_bio.py @@ -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 diff --git a/backend/alembic/versions/0073_saved_view_source.py b/backend/alembic/versions/0073_saved_view_source.py index 6b2a9061..565cf7f0 100644 --- a/backend/alembic/versions/0073_saved_view_source.py +++ b/backend/alembic/versions/0073_saved_view_source.py @@ -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 diff --git a/backend/app/audit.py b/backend/app/audit.py index 9c564c1f..cba72f66 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -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 diff --git a/backend/app/auth.py b/backend/app/auth.py index 0cf9f50b..66ddc602 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index ec5d571e..f01f7216 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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. diff --git a/backend/app/demo.py b/backend/app/demo.py index 80f2d10f..3cb09cd5 100644 --- a/backend/app/demo.py +++ b/backend/app/demo.py @@ -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( - 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() + 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() + ) for channel_id in top: db.add(Subscription(user_id=demo.id, channel_id=channel_id)) return len(top) diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py index 24282bf3..30c85919 100644 --- a/backend/app/downloads/adapters.py +++ b/backend/app/downloads/adapters.py @@ -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"): diff --git a/backend/app/downloads/backfill.py b/backend/app/downloads/backfill.py index 49fd5462..76d8258c 100644 --- a/backend/app/downloads/backfill.py +++ b/backend/app/downloads/backfill.py @@ -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 diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index 45337dad..7f4d6a7a 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -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 diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py index 6ffbd46b..47a5090b 100644 --- a/backend/app/downloads/formats.py +++ b/backend/app/downloads/formats.py @@ -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 diff --git a/backend/app/downloads/gc.py b/backend/app/downloads/gc.py index 9d20a1f8..9ce499af 100644 --- a/backend/app/downloads/gc.py +++ b/backend/app/downloads/gc.py @@ -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,15 +122,19 @@ def run_download_gc(db: Session) -> dict: # 1. Pre-expiry warnings. warn_before = now + timedelta(days=grace_days) - soon = db.execute( - select(MediaAsset).where( - MediaAsset.status == "ready", - MediaAsset.gc_notified.is_(False), - MediaAsset.expires_at.is_not(None), - MediaAsset.expires_at > now, - MediaAsset.expires_at <= warn_before, + soon = ( + db.execute( + select(MediaAsset).where( + MediaAsset.status == "ready", + MediaAsset.gc_notified.is_(False), + MediaAsset.expires_at.is_not(None), + 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( - select(MediaAsset).where( - MediaAsset.status.in_(("ready", "error")), - MediaAsset.expires_at.is_not(None), - MediaAsset.expires_at <= now, + 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( - select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where( - MediaAsset.status == "ready" - ) - ).scalar() or 0 + total = ( + db.execute( + select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where( + MediaAsset.status == "ready" + ) + ).scalar() + or 0 + ) if total > cap: - candidates = db.execute( - select(MediaAsset) - .where(MediaAsset.status == "ready") - .order_by(MediaAsset.last_access_at.asc().nulls_first()) - ).scalars().all() + candidates = ( + db.execute( + select(MediaAsset) + .where(MediaAsset.status == "ready") + .order_by(MediaAsset.last_access_at.asc().nulls_first()) + ) + .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( - select(MediaAsset).where( - MediaAsset.status == "error", - MediaAsset.ref_count == 0, - MediaAsset.rel_path.is_(None), + 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 diff --git a/backend/app/downloads/jobdb.py b/backend/app/downloads/jobdb.py index ff10eac9..a0935749 100644 --- a/backend/app/downloads/jobdb.py +++ b/backend/app/downloads/jobdb.py @@ -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) diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 70aad0e0..38dc2038 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -6,6 +6,7 @@ require a short-lived signed **grant** on the file request — because a `