Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a8503496 | ||
|
|
a389306885 | ||
|
|
32c05897f4 | ||
|
|
2e319be569 | ||
|
|
852b227ea2 | ||
|
|
06b85586e6 | ||
|
|
fc32188fd2 | ||
|
|
9538fb1b7a | ||
|
|
5051809940 | ||
|
|
64dde6fd36 | ||
|
|
38921fb997 | ||
|
|
2d1a7ff374 | ||
|
|
361ca7872e | ||
|
|
81b7660232 | ||
|
|
7caa2636e4 | ||
|
|
1e894d378d | ||
|
|
6fb369ffcc | ||
|
|
a3500340cf | ||
|
|
b1832afd18 | ||
|
|
434a98d6ed | ||
|
|
cc1f196a2c | ||
|
|
e3dac34bb0 | ||
|
|
938135bb59 | ||
|
|
7d2bee59a3 | ||
|
|
6af238c2ea | ||
|
|
b71b33654a | ||
|
|
5d99a5f253 | ||
|
|
c0d869089d | ||
|
|
15f5a5694a | ||
|
|
14a44bff6e | ||
|
|
37d11b602f | ||
|
|
da3cb5e694 | ||
|
|
1e5232f47a | ||
|
|
dddaeb9a38 | ||
|
|
70a74f8629 | ||
|
|
8f1309fa18 | ||
|
|
b7a4eaff8d | ||
|
|
a20e27800c | ||
|
|
fa7a09a420 | ||
|
|
f983a491c0 | ||
|
|
f85dc12819 | ||
|
|
9798e506cf | ||
|
|
a60164edc4 | ||
|
|
6c7038cf1f | ||
|
|
f8e144a658 | ||
|
|
b4984dc38b | ||
|
|
21c9b34540 | ||
|
|
041cd526b7 | ||
|
|
9477fea3dc | ||
|
|
e4b19b5320 | ||
|
|
7692253636 | ||
|
|
e90603d4db | ||
|
|
dd76e9dcf7 | ||
|
|
fca989c098 | ||
|
|
7ebbf45921 | ||
|
|
829cadf5fb | ||
|
|
8f08073564 | ||
|
|
af08a7850b | ||
|
|
d2193ccd1b | ||
|
|
cda70164ba | ||
|
|
5e1cc4d981 | ||
|
|
4ee452b854 | ||
|
|
edbc9dede5 | ||
|
|
53ca4e2849 | ||
|
|
1801a58f6b | ||
|
|
011acd919e | ||
|
|
45d8b47366 | ||
|
|
c3820a0b44 | ||
|
|
c7b393901f | ||
|
|
f1e14c94c5 | ||
|
|
ddb171fca3 | ||
|
|
ec3bbc78dc | ||
|
|
1be1dc9d06 | ||
|
|
6109e0c6f1 | ||
|
|
5851ddd329 | ||
|
|
ad0cf5827b | ||
|
|
f6192aa6bf | ||
|
|
6b4febcc95 | ||
|
|
cb5ac09a80 | ||
|
|
bad2aae753 | ||
|
|
8f6714e678 | ||
|
|
13fb2b4e61 | ||
|
|
5316f48af2 | ||
|
|
0c746f5bcd | ||
|
|
caced8664c | ||
|
|
29f2c9059e | ||
|
|
b83248c510 | ||
|
|
2b5de89eda | ||
|
|
ff45db70ae | ||
|
|
7d40a8780d | ||
|
|
d56a791c04 | ||
|
|
187b8007f8 | ||
|
|
f10915c223 | ||
|
|
f9b63745f9 | ||
|
|
5b5e76b73d | ||
|
|
91be3d0535 | ||
|
|
3bde114b14 | ||
|
|
fca3fa13ef | ||
|
|
df60bc5ff5 | ||
|
|
99d63d272e | ||
|
|
482dd28006 | ||
|
|
0b7180ce8f | ||
|
|
d2f2085f1f | ||
|
|
062015ba27 | ||
|
|
0401dae689 | ||
|
|
e8a1c252a9 | ||
|
|
fa8bdf83a8 | ||
|
|
868ff533ee | ||
|
|
00bf9a39f7 | ||
|
|
640a8efeed | ||
|
|
23ac884b42 | ||
|
|
1579a81395 | ||
|
|
4fe6f38f1b | ||
|
|
94a608053a | ||
|
|
aea99cc71a | ||
|
|
180ab56379 | ||
|
|
0b592345e6 | ||
|
|
4800df4208 | ||
|
|
8bfc01684f | ||
|
|
48aa1a25f5 |
@@ -4,3 +4,6 @@
|
|||||||
#
|
#
|
||||||
# Prettier one-time repo-wide format pass (R2 S1b, 2026-07-21)
|
# Prettier one-time repo-wide format pass (R2 S1b, 2026-07-21)
|
||||||
51c36c2071728d2cd7ae580b7bfdf21f6867a347
|
51c36c2071728d2cd7ae580b7bfdf21f6867a347
|
||||||
|
|
||||||
|
# ruff format one-time backend + scripts pass (line-length 100, 2026-08-23)
|
||||||
|
b1832afd180866b922521148603db447020c57a4
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ jobs:
|
|||||||
- run: pip install --no-cache-dir -r backend/requirements.lock -r backend/requirements-dev.txt
|
- run: pip install --no-cache-dir -r backend/requirements.lock -r backend/requirements-dev.txt
|
||||||
- name: ruff
|
- name: ruff
|
||||||
run: python -m ruff check --config backend/ruff.toml backend scripts
|
run: python -m ruff check --config backend/ruff.toml backend scripts
|
||||||
|
- name: ruff format --check
|
||||||
|
run: python -m ruff format --check --config backend/ruff.toml backend scripts
|
||||||
- name: pytest
|
- name: pytest
|
||||||
run: python -m pytest
|
run: python -m pytest
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ provider.
|
|||||||
|
|
||||||
## The gate — never merge or ship red
|
## The gate — never merge or ship red
|
||||||
|
|
||||||
The local gate is **ruff + typecheck (3 tsc projects) + eslint + prettier --check + knip + vitest +
|
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
|
||||||
pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||||
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||||
|
|
||||||
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` and `pytest` (the DB-lane needs Postgres).
|
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
||||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
@@ -26,12 +26,33 @@ pytest**. It runs on every `publish`, which **refuses to build on failure — th
|
|||||||
keep commits logical and Conventional-Commit-styled.
|
keep commits logical and Conventional-Commit-styled.
|
||||||
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||||
being AI-written is the intended, visible exception; commits are not.)
|
being AI-written is the intended, visible exception; commits are not.)
|
||||||
- **Release is a deliberate, user-owned step** (`siftlode release` from the feature branch: merge to
|
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
|
||||||
`main` → publish → deploy → verify `/api/version`). Do not self-ship; bring work to a green,
|
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||||
committed state and stop for the user's go-ahead.
|
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
||||||
|
go-ahead first, then run it yourself.
|
||||||
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||||
should assume or leak them.
|
should assume or leak them.
|
||||||
|
|
||||||
|
## Collaboration — how to work with the human
|
||||||
|
|
||||||
|
The human is the product owner: they bring ideas and requirements. You are the technical lead — you own
|
||||||
|
how it gets built. Concretely:
|
||||||
|
|
||||||
|
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
||||||
|
work) and get a go-ahead before writing code.
|
||||||
|
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
||||||
|
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
|
||||||
|
behind each option and the weight of the decision. These decision points are where the product owner
|
||||||
|
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||||
|
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||||
|
choose from before building.
|
||||||
|
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||||
|
arguments, push back or propose an alternative — don't comply blindly.
|
||||||
|
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||||
|
bugs; do a real visual check of any UI change.
|
||||||
|
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||||
|
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||||
|
|
||||||
## Architecture shape (don't fight it)
|
## Architecture shape (don't fight it)
|
||||||
|
|
||||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||||
@@ -79,5 +100,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*A fuller written-conventions doc (`docs/development.md`) is planned; once it lands, this file slims to a
|
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||||
pointer plus the gate/workflow/no-attribution rules. Until then, the table above is the source of truth.*
|
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
||||||
|
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0001_initial
|
|||||||
Revises:
|
Revises:
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0002_ingest_core
|
|||||||
Revises: 0001_initial
|
Revises: 0001_initial
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0003_tags
|
|||||||
Revises: 0002_ingest_core
|
Revises: 0002_ingest_core
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0004_feed_state_prefs
|
|||||||
Revises: 0003_tags
|
Revises: 0003_tags
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0005_shorts_probed
|
|||||||
Revises: 0004_feed_state_prefs
|
Revises: 0004_feed_state_prefs
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0006_app_state
|
|||||||
Revises: 0005_shorts_probed
|
Revises: 0005_shorts_probed
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0007_deep_requested
|
|||||||
Revises: 0006_app_state
|
Revises: 0006_app_state
|
||||||
Create Date: 2026-06-11
|
Create Date: 2026-06-11
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0008_invites
|
|||||||
Revises: 0007_deep_requested
|
Revises: 0007_deep_requested
|
||||||
Create Date: 2026-06-12
|
Create Date: 2026-06-12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -22,9 +23,7 @@ def upgrade() -> None:
|
|||||||
"invites",
|
"invites",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("email", sa.String(length=320), nullable=False),
|
sa.Column("email", sa.String(length=320), nullable=False),
|
||||||
sa.Column(
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||||
"status", sa.String(length=16), nullable=False, server_default="pending"
|
|
||||||
),
|
|
||||||
sa.Column("note", sa.Text(), nullable=True),
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"requested_at",
|
"requested_at",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0009_quota_events
|
|||||||
Revises: 0008_invites
|
Revises: 0008_invites
|
||||||
Create Date: 2026-06-12
|
Create Date: 2026-06-12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0010_watch_progress
|
|||||||
Revises: 0009_quota_events
|
Revises: 0009_quota_events
|
||||||
Create Date: 2026-06-14
|
Create Date: 2026-06-14
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0011_playlists
|
|||||||
Revises: 0010_watch_progress
|
Revises: 0010_watch_progress
|
||||||
Create Date: 2026-06-15
|
Create Date: 2026-06-15
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -26,16 +27,10 @@ def upgrade() -> None:
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
sa.Column(
|
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
|
||||||
"kind", sa.String(length=16), nullable=False, server_default="user"
|
sa.Column("source", sa.String(length=16), nullable=False, server_default="local"),
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"source", sa.String(length=16), nullable=False, server_default="local"
|
|
||||||
),
|
|
||||||
sa.Column("yt_playlist_id", sa.String(length=64), nullable=True),
|
sa.Column("yt_playlist_id", sa.String(length=64), nullable=True),
|
||||||
sa.Column(
|
sa.Column("dirty", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
"dirty", sa.Boolean(), nullable=False, server_default="false"
|
|
||||||
),
|
|
||||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Create Date: 2026-06-15
|
|||||||
Migrates the old per-video status='saved' into membership of a built-in 'watch_later'
|
Migrates the old per-video status='saved' into membership of a built-in 'watch_later'
|
||||||
playlist (one per user who had saved videos), then demotes those states to 'new'.
|
playlist (one per user who had saved videos), then demotes those states to 'new'.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ a YouTube read. Backfills the baseline for already-clean linked playlists (the c
|
|||||||
state == the synced state for them); dirty ones are left NULL so they stay dirty until the
|
state == the synced state for them); dirty ones are left NULL so they stay dirty until the
|
||||||
next real sync establishes a baseline.
|
next real sync establishes a baseline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
@@ -32,24 +33,18 @@ def _fingerprint(name: str, ids: list[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
op.add_column("playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True))
|
||||||
"playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True)
|
|
||||||
)
|
|
||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
# Backfill clean, YouTube-linked playlists: their current state IS the synced state.
|
# Backfill clean, YouTube-linked playlists: their current state IS the synced state.
|
||||||
rows = bind.execute(
|
rows = bind.execute(
|
||||||
sa.text(
|
sa.text("SELECT id, name FROM playlists WHERE yt_playlist_id IS NOT NULL AND dirty = false")
|
||||||
"SELECT id, name FROM playlists "
|
|
||||||
"WHERE yt_playlist_id IS NOT NULL AND dirty = false"
|
|
||||||
)
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for pid, name in rows:
|
for pid, name in rows:
|
||||||
ids = [
|
ids = [
|
||||||
r[0]
|
r[0]
|
||||||
for r in bind.execute(
|
for r in bind.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"SELECT video_id FROM playlist_items "
|
"SELECT video_id FROM playlist_items WHERE playlist_id = :pid ORDER BY position"
|
||||||
"WHERE playlist_id = :pid ORDER BY position"
|
|
||||||
),
|
),
|
||||||
{"pid": pid},
|
{"pid": pid},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Adds the shared demo-account plumbing:
|
|||||||
The demo user row itself is created lazily on first demo login (works across all three DBs
|
The demo user row itself is created lazily on first demo login (works across all three DBs
|
||||||
without a data migration), so this only adds the schema.
|
without a data migration), so this only adds the schema.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -47,9 +48,7 @@ def upgrade() -> None:
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
op.create_index(
|
op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True)
|
||||||
"ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Create Date: 2026-06-16
|
|||||||
Adds scheduler_settings: a per-job run-interval override (minutes), editable from the admin
|
Adds scheduler_settings: a per-job run-interval override (minutes), editable from the admin
|
||||||
Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
|
Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ the validation lifecycle (last_checked_at for rolling re-validation; unavailable
|
|||||||
unavailable_reason to flag a video as currently unplayable — hidden from the feed
|
unavailable_reason to flag a video as currently unplayable — hidden from the feed
|
||||||
immediately, hard-deleted after a grace period). Purely additive; all columns nullable.
|
immediately, hard-deleted after a grace period). Purely additive; all columns nullable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -26,7 +27,9 @@ def upgrade() -> None:
|
|||||||
op.add_column("videos", sa.Column("privacy_status", sa.String(length=16), nullable=True))
|
op.add_column("videos", sa.Column("privacy_status", sa.String(length=16), nullable=True))
|
||||||
op.add_column("videos", sa.Column("upload_status", sa.String(length=16), nullable=True))
|
op.add_column("videos", sa.Column("upload_status", sa.String(length=16), nullable=True))
|
||||||
op.add_column("videos", sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True))
|
op.add_column("videos", sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
op.add_column("videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True))
|
op.add_column(
|
||||||
|
"videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
op.add_column("videos", sa.Column("unavailable_reason", sa.String(length=24), nullable=True))
|
op.add_column("videos", sa.Column("unavailable_reason", sa.String(length=24), nullable=True))
|
||||||
op.create_index("ix_videos_last_checked_at", "videos", ["last_checked_at"])
|
op.create_index("ix_videos_last_checked_at", "videos", ["last_checked_at"])
|
||||||
op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"])
|
op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"])
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ JSON payload column is named `data` (SQLAlchemy's declarative Base reserves `met
|
|||||||
First producer is the maintenance job's batched "videos removed" notice. Coexists with the
|
First producer is the maintenance job's batched "videos removed" notice. Coexists with the
|
||||||
existing client-side transient bell.
|
existing client-side transient bell.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -36,18 +37,14 @@ def upgrade() -> None:
|
|||||||
sa.Column("data", sa.JSON(), nullable=True),
|
sa.Column("data", sa.JSON(), nullable=True),
|
||||||
sa.Column("read", sa.Boolean(), nullable=False, server_default="false"),
|
sa.Column("read", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"),
|
sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
sa.Column(
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
|
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
|
||||||
op.create_index("ix_notifications_type", "notifications", ["type"])
|
op.create_index("ix_notifications_type", "notifications", ["type"])
|
||||||
op.create_index("ix_notifications_read", "notifications", ["read"])
|
op.create_index("ix_notifications_read", "notifications", ["read"])
|
||||||
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
|
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
|
||||||
# Fast unread-badge count + inbox listing per user.
|
# Fast unread-badge count + inbox listing per user.
|
||||||
op.create_index(
|
op.create_index("ix_notifications_user_read", "notifications", ["user_id", "read"])
|
||||||
"ix_notifications_user_read", "notifications", ["user_id", "read"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds app_state.maintenance_revalidate_batch: an admin-tunable override (from the
|
|||||||
dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
|
dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
|
||||||
env/config default. Purely additive.
|
env/config default. Purely additive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ channels.list?mine=true and cached. Used to exclude the user's own channel from
|
|||||||
playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
|
playlist-discovery list (you don't subscribe to yourself). NULL = not resolved yet.
|
||||||
Purely additive.
|
Purely additive.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ stored historical values to the canonical scheme defined in app.quota.QuotaActio
|
|||||||
fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
|
fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
|
||||||
are NOT touched.)
|
are NOT touched.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds the system_config table: admin overrides for operational config that otherw
|
|||||||
from env/config defaults (see app.sysconfig for the registry of known keys). Secret values
|
from env/config defaults (see app.sysconfig for the registry of known keys). Secret values
|
||||||
are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults.
|
are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ google_sub nullable (password-only accounts have no Google sub until they link),
|
|||||||
single-use auth_tokens table for email verification + password reset. Existing rows are all
|
single-use auth_tokens table for email verification + password reset. Existing rows are all
|
||||||
Google/demo accounts → backfilled email_verified=true (is_active defaults true).
|
Google/demo accounts → backfilled email_verified=true (is_active defaults true).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds users.is_suspended — an admin-controlled access block, distinct from is_a
|
|||||||
approval lifecycle). A suspended account can't sign in (password or Google) and any live
|
approval lifecycle). A suspended account can't sign in (password or Google) and any live
|
||||||
session is rejected; the user is told why and given the operator's contact.
|
session is rejected; the user is told why and given the operator's contact.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ email_verified=false (the callback didn't set it). Backfill those so the flag re
|
|||||||
matters because password-login gates on email_verified, so a Google user who later adds a password
|
matters because password-login gates on email_verified, so a Google user who later adds a password
|
||||||
must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
|
must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds app_state.configured + setup_token_hash for the first-run install wizard. E
|
|||||||
(any users present) are marked configured so they never see the wizard; a genuinely fresh DB (no
|
(any users present) are marked configured so they never see the wizard; a genuinely fresh DB (no
|
||||||
users) starts unconfigured and boots into setup mode.
|
users) starts unconfigured and boots into setup mode.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds the `messages` table for direct user-to-user messaging (notification module
|
|||||||
A conversation is the set of rows between two users; both FKs cascade so a GDPR account
|
A conversation is the set of rows between two users; both FKs cascade so a GDPR account
|
||||||
erasure removes that user's messages.
|
erasure removes that user's messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ hold each user's public key + passphrase-wrapped private key (the server can nev
|
|||||||
Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
|
Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
|
||||||
prod has none).
|
prod has none).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ the Library by default:
|
|||||||
- channels.from_search — this channel exists only because search surfaced one of its videos
|
- channels.from_search — this channel exists only because search surfaced one of its videos
|
||||||
Both default false (every existing row is organic), indexed for the feed's exclude filter.
|
Both default false (every existing row is organic), indexed for the feed's exclude filter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -25,16 +26,12 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"videos",
|
"videos",
|
||||||
sa.Column(
|
sa.Column("via_search", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
"via_search", sa.Boolean(), nullable=False, server_default="false"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
op.create_index("ix_videos_via_search", "videos", ["via_search"])
|
op.create_index("ix_videos_via_search", "videos", ["via_search"])
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"channels",
|
"channels",
|
||||||
sa.Column(
|
sa.Column("from_search", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
"from_search", sa.Boolean(), nullable=False, server_default="false"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
op.create_index("ix_channels_from_search", "channels", ["from_search"])
|
op.create_index("ix_channels_from_search", "channels", ["from_search"])
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ query wraps both sides in unaccent(); this migration just makes the function ava
|
|||||||
`unaccent` ships with the official postgres image (postgresql-contrib). The DB role is a
|
`unaccent` ships with the official postgres image (postgresql-contrib). The DB role is a
|
||||||
superuser in our deployments, so CREATE EXTENSION succeeds; IF NOT EXISTS keeps it idempotent.
|
superuser in our deployments, so CREATE EXTENSION succeeds; IF NOT EXISTS keeps it idempotent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ live YouTube search. This lets "videos I searched for" appear in that user's Min
|
|||||||
Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as
|
Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as
|
||||||
search-discovered by anyone, for the shared Library's Source filter).
|
search-discovered by anyone, for the shared Library's Source filter).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ search stays accent-insensitive ("tiesto" matches "Tiësto"). A GIN expression i
|
|||||||
query uses the exact same expression so the planner picks the index. `unaccent` is already
|
query uses the exact same expression so the planner picks the index. `unaccent` is already
|
||||||
enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
|
enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -26,9 +27,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.execute(
|
op.execute("CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)")
|
||||||
"CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)"
|
|
||||||
)
|
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple
|
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ title-only expression index from 0031 is dropped (superseded). Existing rows get
|
|||||||
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
|
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
|
||||||
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
|
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -48,9 +49,7 @@ def upgrade() -> None:
|
|||||||
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
|
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
|
||||||
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
|
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
|
||||||
)
|
)
|
||||||
op.execute(
|
op.execute("CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)")
|
||||||
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ un-subscribed channels):
|
|||||||
access to its ephemeral videos and drives the grace-period purge of un-kept channels.
|
access to its ephemeral videos and drives the grace-period purge of un-kept channels.
|
||||||
All new flags default false / NULL, so every existing row stays organic.
|
All new flags default false / NULL, so every existing row stays organic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -30,9 +31,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
|
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
|
||||||
op.add_column(
|
op.add_column("channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
"channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)
|
|
||||||
)
|
|
||||||
op.add_column("channels", sa.Column("banner_url", sa.String(length=1024), nullable=True))
|
op.add_column("channels", sa.Column("banner_url", sa.String(length=1024), nullable=True))
|
||||||
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
|
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
|
||||||
op.add_column(
|
op.add_column(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds the `blocked_channels` table: a per-user blocklist. Videos from a blocked c
|
|||||||
never shown in that user's live YouTube search (and not ingested), and are hidden from their
|
never shown in that user's live YouTube search (and not ingested), and are hidden from their
|
||||||
feed / explore views. Per-user, so one user's block doesn't affect anyone else.
|
feed / explore views. Per-user, so one user's block doesn't affect anyone else.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adds the `saved_views` table: per-user named snapshots of the feed's filter/sort
|
|||||||
row stores the serialized FeedFilters (JSON), a sidebar `position` for drag-reorder, and an
|
row stores the serialized FeedFilters (JSON), a sidebar `position` for drag-reorder, and an
|
||||||
`is_default` flag (at most one per user, enforced by the API) for the view applied on load.
|
`is_default` flag (at most one per user, enforced by the API) for the view applied on load.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -26,9 +27,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
sa.Column("name", sa.String(length=80), nullable=False),
|
sa.Column("name", sa.String(length=80), nullable=False),
|
||||||
sa.Column("filters", sa.JSON(), nullable=False),
|
sa.Column("filters", sa.JSON(), nullable=False),
|
||||||
sa.Column(
|
sa.Column("position", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
||||||
"position", sa.Integer(), server_default=sa.text("0"), nullable=False
|
|
||||||
),
|
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"is_default",
|
"is_default",
|
||||||
sa.Boolean(),
|
sa.Boolean(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ FOR UPDATE SKIP LOCKED, live progress written back for polling); `download_profi
|
|||||||
reusable format presets (builtins seeded in the next migration); `download_shares` grant a file
|
reusable format presets (builtins seeded in the next migration); `download_shares` grant a file
|
||||||
to another user; `download_quotas` hold admin-set per-user limits (default via sysconfig).
|
to another user; `download_quotas` hold admin-set per-user limits (default via sysconfig).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -47,9 +48,16 @@ def upgrade() -> None:
|
|||||||
sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False),
|
sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"),
|
sa.UniqueConstraint(
|
||||||
|
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_media_assets_status", "media_assets", ["status"])
|
op.create_index("ix_media_assets_status", "media_assets", ["status"])
|
||||||
|
|
||||||
@@ -61,7 +69,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("spec", sa.JSON(), nullable=False),
|
sa.Column("spec", sa.JSON(), nullable=False),
|
||||||
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
|
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
|
||||||
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
@@ -84,8 +97,18 @@ def upgrade() -> None:
|
|||||||
sa.Column("phase", sa.String(length=32), nullable=True),
|
sa.Column("phase", sa.String(length=32), nullable=True),
|
||||||
sa.Column("error", sa.Text(), nullable=True),
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
sa.Column("queue_pos", sa.Integer(), server_default="0", nullable=False),
|
sa.Column("queue_pos", sa.Integer(), server_default="0", nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"),
|
||||||
sa.ForeignKeyConstraint(["profile_id"], ["download_profiles.id"], ondelete="SET NULL"),
|
sa.ForeignKeyConstraint(["profile_id"], ["download_profiles.id"], ondelete="SET NULL"),
|
||||||
@@ -102,7 +125,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("job_id", sa.Integer(), nullable=False),
|
sa.Column("job_id", sa.Integer(), nullable=False),
|
||||||
sa.Column("from_user_id", sa.Integer(), nullable=False),
|
sa.Column("from_user_id", sa.Integer(), nullable=False),
|
||||||
sa.Column("to_user_id", sa.Integer(), nullable=False),
|
sa.Column("to_user_id", sa.Integer(), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["from_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
@@ -120,7 +148,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("max_concurrent", sa.Integer(), nullable=False),
|
sa.Column("max_concurrent", sa.Integer(), nullable=False),
|
||||||
sa.Column("max_jobs", sa.Integer(), nullable=False),
|
sa.Column("max_jobs", sa.Integer(), nullable=False),
|
||||||
sa.Column("unlimited", sa.Boolean(), server_default="false", nullable=False),
|
sa.Column("unlimited", sa.Boolean(), server_default="false", nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("user_id"),
|
sa.PrimaryKeyConstraint("user_id"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ definition consumed by app.downloads.formats: mode ("av" audio+video / "v" video
|
|||||||
the embed_*/sponsorblock postprocessor flags. Names are English (the UI default); the frontend
|
the embed_*/sponsorblock postprocessor flags. Names are English (the UI default); the frontend
|
||||||
localizes builtin labels by a stable key derived from the name.
|
localizes builtin labels by a stable key derived from the name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Create Date: 2026-07-03
|
|||||||
The download GC warns each holder once, a grace window before an asset's TTL expiry; this flag
|
The download GC warns each holder once, a grace window before an asset's TTL expiry; this flag
|
||||||
records that so subsequent GC runs don't re-notify until the asset is deleted.
|
records that so subsequent GC runs don't re-notify until the asset is deleted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ display-friendly form (emoji stripped, ALL-CAPS de-shouted, trailing SEO hashtag
|
|||||||
see app.titles). Reversible: original_title preserves the source, and title can be re-derived.
|
see app.titles). Reversible: original_title preserves the source, and title can be re-derived.
|
||||||
The generated search_vector column regenerates automatically as each title is updated.
|
The generated search_vector column regenerates automatically as each title is updated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -45,5 +46,7 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
conn.execute(sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL"))
|
conn.execute(
|
||||||
|
sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL")
|
||||||
|
)
|
||||||
op.drop_column("videos", "original_title")
|
op.drop_column("videos", "original_title")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ rows so both fresh installs (0037 then this) and existing DBs converge on the sa
|
|||||||
jobs are unaffected (they snapshot their spec; profile_id just SET NULLs if it pointed at a
|
jobs are unaffected (they snapshot their spec; profile_id just SET NULLs if it pointed at a
|
||||||
replaced row).
|
replaced row).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ still hangs off a `media_assets` row (with `source_kind='edit'` and a per-user c
|
|||||||
existing file-serve / ref-count / GC / quota machinery is reused unchanged. `source_job_id` /
|
existing file-serve / ref-count / GC / quota machinery is reused unchanged. `source_job_id` /
|
||||||
`source_asset_id` point at the parent download; `edit_spec` is the trim/crop recipe.
|
`source_asset_id` point at the parent download; `edit_spec` is the trim/crop recipe.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -24,16 +25,10 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"download_jobs",
|
"download_jobs",
|
||||||
sa.Column(
|
sa.Column("job_kind", sa.String(length=16), server_default="download", nullable=False),
|
||||||
"job_kind", sa.String(length=16), server_default="download", nullable=False
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True)
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True)
|
|
||||||
)
|
)
|
||||||
|
op.add_column("download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True))
|
||||||
op.add_column("download_jobs", sa.Column("edit_spec", sa.JSON(), nullable=True))
|
op.add_column("download_jobs", sa.Column("edit_spec", sa.JSON(), nullable=True))
|
||||||
op.create_foreign_key(
|
op.create_foreign_key(
|
||||||
"fk_download_jobs_source_job",
|
"fk_download_jobs_source_job",
|
||||||
@@ -54,12 +49,8 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_constraint(
|
op.drop_constraint("fk_download_jobs_source_asset", "download_jobs", type_="foreignkey")
|
||||||
"fk_download_jobs_source_asset", "download_jobs", type_="foreignkey"
|
op.drop_constraint("fk_download_jobs_source_job", "download_jobs", type_="foreignkey")
|
||||||
)
|
|
||||||
op.drop_constraint(
|
|
||||||
"fk_download_jobs_source_job", "download_jobs", type_="foreignkey"
|
|
||||||
)
|
|
||||||
op.drop_column("download_jobs", "edit_spec")
|
op.drop_column("download_jobs", "edit_spec")
|
||||||
op.drop_column("download_jobs", "source_asset_id")
|
op.drop_column("download_jobs", "source_asset_id")
|
||||||
op.drop_column("download_jobs", "source_job_id")
|
op.drop_column("download_jobs", "source_job_id")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ can watch the file on the public `/watch/{token}` player page — no account nee
|
|||||||
per-link controls: an expiry, an "allow download" toggle, and an (argon2-hashed) password. Revoke
|
per-link controls: an expiry, an "allow download" toggle, and an (argon2-hashed) password. Revoke
|
||||||
= delete the row. Distinct from `download_shares` (an ACL grant to another *registered* user).
|
= delete the row. Distinct from `download_shares` (an ACL grant to another *registered* user).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -31,7 +32,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("password_hash", sa.Text(), nullable=True),
|
sa.Column("password_hash", sa.Text(), nullable=True),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("view_count", sa.Integer(), server_default="0", nullable=False),
|
sa.Column("view_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"),
|
||||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Stores yt-dlp's canonical `webpage_url` for a download's source, so the Download
|
|||||||
clean "downloaded from" reference (copyable + openable). Nullable — filled by the worker on
|
clean "downloaded from" reference (copyable + openable). Nullable — filled by the worker on
|
||||||
extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref.
|
extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Plex server (playback is from the local physical file; Plex is metadata only). T
|
|||||||
The generated `search_vector` columns + their GIN indexes are added via raw SQL (same approach
|
The generated `search_vector` columns + their GIN indexes are added via raw SQL (same approach
|
||||||
as migration 0032), reusing the `unaccent_simple` text-search config from migration 0031.
|
as migration 0032), reusing the `unaccent_simple` text-search config from migration 0031.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -52,8 +53,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||||
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
|
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
|
||||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_libraries_plex_key", "plex_libraries", ["plex_key"], unique=True)
|
op.create_index("ix_plex_libraries_plex_key", "plex_libraries", ["plex_key"], unique=True)
|
||||||
|
|
||||||
@@ -61,7 +66,12 @@ def upgrade() -> None:
|
|||||||
"plex_shows",
|
"plex_shows",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
|
"library_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.Column("title", sa.String(length=512), nullable=False),
|
sa.Column("title", sa.String(length=512), nullable=False),
|
||||||
sa.Column("summary", sa.Text(), nullable=True),
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
sa.Column("year", sa.Integer(), nullable=True),
|
sa.Column("year", sa.Integer(), nullable=True),
|
||||||
@@ -69,8 +79,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("art_key", sa.String(length=512), nullable=True),
|
sa.Column("art_key", sa.String(length=512), nullable=True),
|
||||||
sa.Column("child_count", sa.Integer(), nullable=True),
|
sa.Column("child_count", sa.Integer(), nullable=True),
|
||||||
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_shows_rating_key", "plex_shows", ["rating_key"], unique=True)
|
op.create_index("ix_plex_shows_rating_key", "plex_shows", ["rating_key"], unique=True)
|
||||||
op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"])
|
op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"])
|
||||||
@@ -81,12 +95,21 @@ def upgrade() -> None:
|
|||||||
"plex_seasons",
|
"plex_seasons",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||||
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
|
"show_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_shows.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.Column("title", sa.String(length=255), nullable=True),
|
sa.Column("title", sa.String(length=255), nullable=True),
|
||||||
sa.Column("season_number", sa.Integer(), nullable=True),
|
sa.Column("season_number", sa.Integer(), nullable=True),
|
||||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_seasons_rating_key", "plex_seasons", ["rating_key"], unique=True)
|
op.create_index("ix_plex_seasons_rating_key", "plex_seasons", ["rating_key"], unique=True)
|
||||||
op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"])
|
op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"])
|
||||||
@@ -96,10 +119,25 @@ def upgrade() -> None:
|
|||||||
"plex_items",
|
"plex_items",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
|
"library_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||||
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=True),
|
sa.Column(
|
||||||
sa.Column("season_id", sa.Integer(), sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"), nullable=True),
|
"show_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_shows.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"season_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
sa.Column("season_number", sa.Integer(), nullable=True),
|
sa.Column("season_number", sa.Integer(), nullable=True),
|
||||||
sa.Column("episode_number", sa.Integer(), nullable=True),
|
sa.Column("episode_number", sa.Integer(), nullable=True),
|
||||||
sa.Column("title", sa.String(length=512), nullable=False),
|
sa.Column("title", sa.String(length=512), nullable=False),
|
||||||
@@ -119,8 +157,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("probed_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("probed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("markers", sa.JSON(), nullable=True),
|
sa.Column("markers", sa.JSON(), nullable=True),
|
||||||
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_items_rating_key", "plex_items", ["rating_key"], unique=True)
|
op.create_index("ix_plex_items_rating_key", "plex_items", ["rating_key"], unique=True)
|
||||||
op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"])
|
op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"])
|
||||||
@@ -137,14 +179,23 @@ def upgrade() -> None:
|
|||||||
op.create_table(
|
op.create_table(
|
||||||
"plex_states",
|
"plex_states",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
sa.Column("item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False),
|
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"item_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.Column("status", sa.String(length=12), server_default="new", nullable=False),
|
sa.Column("status", sa.String(length=12), server_default="new", nullable=False),
|
||||||
sa.Column("position_seconds", sa.Integer(), server_default="0", nullable=False),
|
sa.Column("position_seconds", sa.Integer(), server_default="0", nullable=False),
|
||||||
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("synced_to_plex", sa.Boolean(), server_default="false", nullable=False),
|
sa.Column("synced_to_plex", sa.Boolean(), server_default="false", nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
sa.UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),
|
sa.UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_states_user_id", "plex_states", ["user_id"])
|
op.create_index("ix_plex_states_user_id", "plex_states", ["user_id"])
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Plex section listing (no per-item API calls): rating (audienceRating ~ IMDb), co
|
|||||||
release date, and GIN-indexed genre / director / cast arrays. These also double as the content
|
release date, and GIN-indexed genre / director / cast arrays. These also double as the content
|
||||||
signals a future watch-habit recommender would use.
|
signals a future watch-habit recommender would use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -33,9 +34,13 @@ def upgrade() -> None:
|
|||||||
op.create_index("ix_plex_items_rating", "plex_items", ["rating"])
|
op.create_index("ix_plex_items_rating", "plex_items", ["rating"])
|
||||||
op.create_index("ix_plex_items_content_rating", "plex_items", ["content_rating"])
|
op.create_index("ix_plex_items_content_rating", "plex_items", ["content_rating"])
|
||||||
op.create_index("ix_plex_items_studio", "plex_items", ["studio"])
|
op.create_index("ix_plex_items_studio", "plex_items", ["studio"])
|
||||||
op.create_index("ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"])
|
op.create_index(
|
||||||
|
"ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"]
|
||||||
|
)
|
||||||
op.create_index("ix_plex_items_genres_gin", "plex_items", ["genres"], postgresql_using="gin")
|
op.create_index("ix_plex_items_genres_gin", "plex_items", ["genres"], postgresql_using="gin")
|
||||||
op.create_index("ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin")
|
op.create_index(
|
||||||
|
"ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin"
|
||||||
|
)
|
||||||
op.create_index("ix_plex_items_cast_gin", "plex_items", ["cast_names"], postgresql_using="gin")
|
op.create_index("ix_plex_items_cast_gin", "plex_items", ["cast_names"], postgresql_using="gin")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ finds titles by an actor/director name and ranks those above films that merely m
|
|||||||
their synopsis. A generated column's expression can't be altered in place, so we drop + recreate it
|
their synopsis. A generated column's expression can't be altered in place, so we drop + recreate it
|
||||||
(and its GIN index). `people_text` is populated on the next Plex sync.
|
(and its GIN index). `people_text` is populated on the next Plex sync.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ A `plex_collections` table holds each collection's card metadata; membership is
|
|||||||
combinable filter and a title's "collection strip" is a pure local lookup. `editable` marks the
|
combinable filter and a title's "collection strip" is a pure local lookup. `editable` marks the
|
||||||
(curated) collections we may later write back to Plex; everything is read-only by default.
|
(curated) collections we may later write back to Plex; everything is read-only by default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -27,7 +28,12 @@ def upgrade() -> None:
|
|||||||
"plex_collections",
|
"plex_collections",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
|
"library_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
sa.Column("title", sa.String(length=512), nullable=False),
|
sa.Column("title", sa.String(length=512), nullable=False),
|
||||||
sa.Column("summary", sa.Text(), nullable=True),
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||||
@@ -35,16 +41,32 @@ def upgrade() -> None:
|
|||||||
sa.Column("smart", sa.Boolean(), nullable=False, server_default="false"),
|
sa.Column("smart", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
sa.Column("editable", sa.Boolean(), nullable=False, server_default="false"),
|
sa.Column("editable", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True)
|
|
||||||
op.create_index("ix_plex_collections_library_id", "plex_collections", ["library_id"])
|
op.create_index("ix_plex_collections_library_id", "plex_collections", ["library_id"])
|
||||||
|
|
||||||
op.add_column("plex_items", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
op.add_column("plex_items", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
||||||
op.create_index("ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin")
|
op.create_index(
|
||||||
|
"ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin"
|
||||||
|
)
|
||||||
op.add_column("plex_shows", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
op.add_column("plex_shows", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
||||||
op.create_index("ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin")
|
op.create_index(
|
||||||
|
"ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Plex account (like plex_states) — the "your own lists" counterpart to the shar
|
|||||||
collections. `plex_playlists` is owned by a user; `plex_playlist_items` holds the ordered entries
|
collections. `plex_playlists` is owned by a user; `plex_playlist_items` holds the ordered entries
|
||||||
(`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync.
|
(`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -24,11 +25,23 @@ def upgrade() -> None:
|
|||||||
op.create_table(
|
op.create_table(
|
||||||
"plex_playlists",
|
"plex_playlists",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
sa.Column(
|
||||||
|
"user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
sa.Column("title", sa.String(length=512), nullable=False),
|
sa.Column("title", sa.String(length=512), nullable=False),
|
||||||
sa.Column("plex_rating_key", sa.String(length=32), nullable=True),
|
sa.Column("plex_rating_key", sa.String(length=32), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"])
|
op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"])
|
||||||
|
|
||||||
@@ -36,12 +49,16 @@ def upgrade() -> None:
|
|||||||
"plex_playlist_items",
|
"plex_playlist_items",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"playlist_id", sa.Integer(),
|
"playlist_id",
|
||||||
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"), nullable=False,
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
),
|
),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"item_id", sa.Integer(),
|
"item_id",
|
||||||
sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False,
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_items.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
),
|
),
|
||||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),
|
sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ and captures yt-dlp's canonical channel/uploader URL so an auto-filled channel b
|
|||||||
overrides (like display_name — display metadata only, never touches the on-disk file).
|
overrides (like display_name — display metadata only, never touches the on-disk file).
|
||||||
- download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
|
- download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -26,7 +27,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column("media_assets", sa.Column("uploader_url", sa.Text(), nullable=True))
|
op.add_column("media_assets", sa.Column("uploader_url", sa.Text(), nullable=True))
|
||||||
op.add_column("download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True))
|
op.add_column(
|
||||||
|
"download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True)
|
||||||
|
)
|
||||||
op.add_column("download_jobs", sa.Column("display_uploader_url", sa.Text(), nullable=True))
|
op.add_column("download_jobs", sa.Column("display_uploader_url", sa.Text(), nullable=True))
|
||||||
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), nullable=True))
|
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ download shows a blank image box. The worker now extracts a representative frame
|
|||||||
saves it as a poster sidecar; `poster_path` (relative to download_root) records it so the card /
|
saves it as a poster sidecar; `poster_path` (relative to download_root) records it so the card /
|
||||||
watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
|
watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ state with a Plex account. The owner MVP uses the server admin token (uses_admin
|
|||||||
token); a personal token column is reserved for later friend-account linking (PIN OAuth). See the
|
token); a personal token column is reserved for later friend-account linking (PIN OAuth). See the
|
||||||
PlexLink model docstring.
|
PlexLink model docstring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ blob folded into the generated `search_vector` (weight B). This lets the TV grid
|
|||||||
sorted like the movie grid. A generated column's expression can't be altered in place, so the
|
sorted like the movie grid. A generated column's expression can't be altered in place, so the
|
||||||
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
|
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -57,9 +58,13 @@ def upgrade() -> None:
|
|||||||
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
|
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
|
||||||
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
|
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
|
||||||
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
|
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
|
||||||
op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"])
|
op.create_index(
|
||||||
|
"ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"]
|
||||||
|
)
|
||||||
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
|
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
|
||||||
op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin")
|
op.create_index(
|
||||||
|
"ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin"
|
||||||
|
)
|
||||||
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
|
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
|
||||||
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
|
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ current_user rejects any cookie whose epoch is stale. Bumped on password reset,
|
|||||||
or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of
|
or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of
|
||||||
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
|
staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0054_audit_log
|
|||||||
Revises: 0053_user_session_epoch
|
Revises: 0053_user_session_epoch
|
||||||
Create Date: 2026-07-12
|
Create Date: 2026-07-12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0055_channel_keywords
|
|||||||
Revises: 0054_audit_log
|
Revises: 0054_audit_log
|
||||||
Create Date: 2026-07-12
|
Create Date: 2026-07-12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0056_plex_people
|
|||||||
Revises: 0055_channel_keywords
|
Revises: 0055_channel_keywords
|
||||||
Create Date: 2026-07-19
|
Create Date: 2026-07-19
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -23,8 +24,12 @@ def upgrade() -> None:
|
|||||||
# A representative proxied headshot URL (or NULL when none is available). Captured
|
# A representative proxied headshot URL (or NULL when none is available). Captured
|
||||||
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
|
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
|
||||||
sa.Column("photo_thumb", sa.Text(), nullable=True),
|
sa.Column("photo_thumb", sa.Text(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
|
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 0057_plex_ppl_enriched
|
|||||||
Revises: 0056_plex_people
|
Revises: 0056_plex_people
|
||||||
Create Date: 2026-07-19
|
Create Date: 2026-07-19
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -17,7 +18,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
for tbl in ("plex_items", "plex_shows"):
|
for tbl in ("plex_items", "plex_shows"):
|
||||||
op.add_column(tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True))
|
op.add_column(
|
||||||
|
tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Best still one click away. Re-seeds the builtin rows so fresh installs and exist
|
|||||||
(same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
|
(same mechanism as 0040). Existing jobs are unaffected (they snapshot their spec; a profile_id that
|
||||||
pointed at a replaced row just SET NULLs).
|
pointed at a replaced row just SET NULLs).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ did NOT invalidate outstanding grants — they lived out their 6h TTL. Fold a pe
|
|||||||
into the signed grant and bump it on every password change, so a rotation revokes old grants at once.
|
into the signed grant and bump it on every password change, so a rotation revokes old grants at once.
|
||||||
Small table (one row per share link), so this is a quick add-column with a default.
|
Small table (one row per share link), so this is a quick add-column with a default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ This one-time data fixup rewrites those scalars to SQL NULL so the columns are u
|
|||||||
forward-only: a SQL NULL is indistinguishable from the old JSON `null` to every reader (the guards
|
forward-only: a SQL NULL is indistinguishable from the old JSON `null` to every reader (the guards
|
||||||
treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change.
|
treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -25,9 +26,7 @@ _COLS = ("genres", "directors", "cast_names", "collection_keys")
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
for table in _TABLES:
|
for table in _TABLES:
|
||||||
for col in _COLS:
|
for col in _COLS:
|
||||||
op.execute(
|
op.execute(f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'")
|
||||||
f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ small category code (`unavailable` / `login_required` / `geo_blocked` / `format_
|
|||||||
a retry makes sense, keeping the raw text as a technical-detail disclosure. Nullable; only set on the
|
a retry makes sense, keeping the raw text as a technical-detail disclosure. Nullable; only set on the
|
||||||
error path. Forward-only — an existing errored row simply has NULL until its next failure/retry.
|
error path. Forward-only — an existing errored row simply has NULL until its next failure/retry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Redundant index -> the composite that already covers it:
|
|||||||
ix_plex_states_user_id <- uq_plex_user_item (user_id, item_id)
|
ix_plex_states_user_id <- uq_plex_user_item (user_id, item_id)
|
||||||
ix_plex_playlist_items_playlist_id <- uq_plex_playlist_item (playlist_id, item_id)
|
ix_plex_playlist_items_playlist_id <- uq_plex_playlist_item (playlist_id, item_id)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ OVERLAY library is the Hungarian-metadata twin of another section: it is never b
|
|||||||
its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap:
|
its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap:
|
||||||
metadata-only, no table rewrite.
|
metadata-only, no table rewrite.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Run BEFORE the first HU overlay sync: while the *_hu columns are all NULL the ge
|
|||||||
is trivial. ADD only (no drop/rebuild of the EN search_vector). NOTE: adding a STORED generated
|
is trivial. ADD only (no drop/rebuild of the EN search_vector). NOTE: adding a STORED generated
|
||||||
column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod.
|
column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ so the data step below resets `people_enriched_at` to NULL on movie/show rows: t
|
|||||||
each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table
|
each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table
|
||||||
rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
|
rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ schema allowed NULL — a model/schema drift the new migration drift-test (E1 S2
|
|||||||
server_default has filled every row since day one, so no NULLs exist; enforcing NOT NULL just makes the
|
server_default has filled every row since day one, so no NULLs exist; enforcing NOT NULL just makes the
|
||||||
schema match the contract the model already promises. Pure DDL, reversible.
|
schema match the contract the model already promises. Pure DDL, reversible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ CONCURRENTLY (in alembic's autocommit_block, since it can't run inside a transac
|
|||||||
these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
|
these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
|
||||||
downgrade->re-upgrade round-trip idempotent.
|
downgrade->re-upgrade round-trip idempotent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Note: `SET NOT NULL` validates with a brief ACCESS EXCLUSIVE lock + one table sc
|
|||||||
CONCURRENTLY). On the prod row count (~235k) that is sub-second; still, it is a short write-blocking lock
|
CONCURRENTLY). On the prod row count (~235k) that is sub-second; still, it is a short write-blocking lock
|
||||||
rather than the ~0 of 0067's CONCURRENTLY index build.
|
rather than the ~0 of 0067's CONCURRENTLY index build.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -27,9 +28,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe).
|
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe).
|
||||||
op.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
|
op.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
|
||||||
op.alter_column(
|
op.alter_column("videos", "published_at", nullable=False, server_default=sa.text("now()"))
|
||||||
"videos", "published_at", nullable=False, server_default=sa.text("now()")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ biggest table, same lookups. live_status is left alone (a separate finding).
|
|||||||
All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
|
All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
|
||||||
downgrade->re-upgrade idempotent.
|
downgrade->re-upgrade idempotent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ hand-rolled override-or-default reader in `app.state`, duplicating the sysconfig
|
|||||||
now a sysconfig key (`system_config` store), so every admin knob lives in one place. This migration
|
now a sysconfig key (`system_config` store), so every admin knob lives in one place. This migration
|
||||||
carries any existing override into the sysconfig store, then drops the dead column. Reversible.
|
carries any existing override into the sysconfig store, then drops the dead column. Reversible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""plex TMDB enrichment columns (E5 S2)
|
||||||
|
|
||||||
|
Additive TMDB-sourced fields on plex_items + plex_shows — keywords, full crew, budget/revenue, TMDb
|
||||||
|
community score, production companies/countries, recommendations, and a backdrop-path pool — plus a
|
||||||
|
`tmdb_id` cache and a `tmdb_enriched_at` watermark. Filled by the new TMDB enrichment job (matched by
|
||||||
|
the stored canonical `guid`), SERVED FROM THE ROW (never a live TMDB call). Distinct from the existing
|
||||||
|
Plex-sourced `imdb_rating`/`rating`, which are left untouched. Plus `plex_people.tmdb_person_id` (the S2
|
||||||
|
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
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0071_plex_tmdb_fields"
|
||||||
|
down_revision: Union[str, None] = "0070_fold_maintenance_batch"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_TABLES = ("plex_items", "plex_shows")
|
||||||
|
|
||||||
|
# (name, type) — same set on both movie/leaf rows and show rows. budget/revenue stay NULL for shows.
|
||||||
|
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
|
||||||
|
("tmdb_id", sa.Integer()),
|
||||||
|
("tmdb_enriched_at", sa.DateTime(timezone=True)),
|
||||||
|
("tmdb_rating", sa.Float()),
|
||||||
|
("tmdb_vote_count", sa.Integer()),
|
||||||
|
("budget", sa.BigInteger()),
|
||||||
|
("revenue", sa.BigInteger()),
|
||||||
|
("keywords", postgresql.JSONB()),
|
||||||
|
("crew", postgresql.JSONB()),
|
||||||
|
("studios", postgresql.JSONB()),
|
||||||
|
("production_countries", postgresql.JSONB()),
|
||||||
|
("recommendations", postgresql.JSONB()),
|
||||||
|
("backdrops", postgresql.JSONB()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for t in _TABLES:
|
||||||
|
for name, type_ in _COLS:
|
||||||
|
op.add_column(t, sa.Column(name, type_, nullable=True))
|
||||||
|
op.add_column("plex_people", sa.Column("tmdb_person_id", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("plex_people", "tmdb_person_id")
|
||||||
|
for t in _TABLES:
|
||||||
|
for name, _ in reversed(_COLS):
|
||||||
|
op.drop_column(t, name)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""plex person TMDB bio columns (E5 S6)
|
||||||
|
|
||||||
|
Additive TMDB-sourced person fields on plex_people — biography, birth/death dates, place of birth,
|
||||||
|
known-for department, also-known-as, and a `known_for` credit pool ([{tmdb_id, media_type, title}]
|
||||||
|
resolved to library rows by guid at serve time) — plus a `tmdb_enriched_at` watermark. Filled the
|
||||||
|
first time a person page is opened (from /person/{tmdb_person_id}), then SERVED FROM THE ROW. The
|
||||||
|
`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
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0072_plex_person_bio"
|
||||||
|
down_revision: Union[str, None] = "0071_plex_tmdb_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
|
||||||
|
("biography", sa.Text()),
|
||||||
|
("birthday", sa.String(length=10)),
|
||||||
|
("deathday", sa.String(length=10)),
|
||||||
|
("place_of_birth", sa.Text()),
|
||||||
|
("known_for_department", sa.String(length=64)),
|
||||||
|
("also_known_as", postgresql.JSONB()),
|
||||||
|
("known_for", postgresql.JSONB()),
|
||||||
|
("tmdb_enriched_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name, type_ in _COLS:
|
||||||
|
op.add_column("plex_people", sa.Column(name, type_, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for name, _ in reversed(_COLS):
|
||||||
|
op.drop_column("plex_people", name)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""saved_views.source (ask 4: per-module saved views)
|
||||||
|
|
||||||
|
Saved "smart views" were feed-only; the Plex module now has them too. Add a `source` discriminator
|
||||||
|
(default "feed" so every existing row stays a feed view) and widen the per-user unique name constraint
|
||||||
|
to (user_id, source, name) so a feed view and a Plex view can share a name. `source` is NOT NULL with a
|
||||||
|
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
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0073_saved_view_source"
|
||||||
|
down_revision: Union[str, None] = "0072_plex_person_bio"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"saved_views",
|
||||||
|
sa.Column("source", sa.String(length=16), nullable=False, server_default="feed"),
|
||||||
|
)
|
||||||
|
op.drop_constraint("uq_user_saved_view", "saved_views", type_="unique")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_user_source_saved_view", "saved_views", ["user_id", "source", "name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# LOSSY / effectively forward-only: upgrade lets a user hold a feed view and a plex view of the same
|
||||||
|
# name, so re-creating the narrow (user_id, name) constraint can raise IntegrityError on such a pair.
|
||||||
|
# De-dup by dropping the non-feed rows first (they can't exist in the pre-0073 schema anyway) so the
|
||||||
|
# narrow constraint re-applies cleanly for the drift test's downgrade→re-upgrade cycle.
|
||||||
|
op.execute("DELETE FROM saved_views WHERE source <> 'feed'")
|
||||||
|
op.drop_constraint("uq_user_source_saved_view", "saved_views", type_="unique")
|
||||||
|
op.create_unique_constraint("uq_user_saved_view", "saved_views", ["user_id", "name"])
|
||||||
|
op.drop_column("saved_views", "source")
|
||||||
@@ -9,6 +9,7 @@ runs are always logged. Secrets are never stored (config changes log the key nam
|
|||||||
mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops
|
mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops
|
||||||
its audit row too).
|
its audit row too).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|||||||
+6
-9
@@ -41,8 +41,7 @@ log = logging.getLogger("siftlode.auth")
|
|||||||
def google_enabled(db: Session) -> bool:
|
def google_enabled(db: Session) -> bool:
|
||||||
"""Whether Sign in with Google is available (both client id + secret resolve to non-empty)."""
|
"""Whether Sign in with Google is available (both client id + secret resolve to non-empty)."""
|
||||||
return bool(
|
return bool(
|
||||||
sysconfig.get_str(db, "google_client_id")
|
sysconfig.get_str(db, "google_client_id") and sysconfig.get_str(db, "google_client_secret")
|
||||||
and sysconfig.get_str(db, "google_client_secret")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,7 +124,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
|||||||
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
|
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
|
||||||
# TOKEN_ENCRYPTION_KEY mismatch can't 500 the purge — a best-effort revoke degrades to "skip". (B-core-2)
|
# TOKEN_ENCRYPTION_KEY mismatch can't 500 the purge — a best-effort revoke degrades to "skip". (B-core-2)
|
||||||
google_token = (
|
google_token = (
|
||||||
(decrypt_optional(tok.refresh_token_enc) or decrypt_optional(tok.access_token)) if tok else None
|
(decrypt_optional(tok.refresh_token_enc) or decrypt_optional(tok.access_token))
|
||||||
|
if tok
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
||||||
db.execute(delete(User).where(User.id == user_id))
|
db.execute(delete(User).where(User.id == user_id))
|
||||||
@@ -197,9 +198,7 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def optional_current_user(
|
def optional_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||||
request: Request, db: Session = Depends(get_db)
|
|
||||||
) -> User | None:
|
|
||||||
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
|
"""Like `current_user` but returns None instead of raising 401 when there's no valid session.
|
||||||
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
|
Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to
|
||||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
|
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
|
||||||
@@ -215,9 +214,7 @@ def require_human(user: User = Depends(current_user)) -> User:
|
|||||||
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
||||||
where there's no scope check to lean on."""
|
where there's no scope check to lean on."""
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||||
status_code=403, detail="Not available in the demo account."
|
|
||||||
)
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def player_client_fallback_list(self) -> list[str]:
|
def player_client_fallback_list(self) -> list[str]:
|
||||||
return self._clients(self.download_player_clients_fallback)
|
return self._clients(self.download_player_clients_fallback)
|
||||||
|
|
||||||
# How many downloads the worker runs concurrently (claimed via FOR UPDATE SKIP LOCKED).
|
# How many downloads the worker runs concurrently (claimed via FOR UPDATE SKIP LOCKED).
|
||||||
download_worker_concurrency: int = 2
|
download_worker_concurrency: int = 2
|
||||||
# How often (empty pending queue) the worker polls for a new job, in seconds.
|
# How often (empty pending queue) the worker polls for a new job, in seconds.
|
||||||
@@ -245,6 +246,20 @@ class Settings(BaseSettings):
|
|||||||
# local disk, so the default is fine there.
|
# local disk, so the default is fine there.
|
||||||
plex_hls_dir: str = ""
|
plex_hls_dir: str = ""
|
||||||
|
|
||||||
|
# --- TMDB enrichment (E5 S2; optional — enriches the Plex catalog from TMDB) ---
|
||||||
|
# TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the
|
||||||
|
# admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget /
|
||||||
|
# revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the
|
||||||
|
# stored canonical guid, and SERVED FROM THE DB — never a live TMDB call at serve time.
|
||||||
|
tmdb_api_key: str = ""
|
||||||
|
tmdb_api_base: str = "https://api.themoviedb.org/3"
|
||||||
|
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
||||||
|
tmdb_language: str = "en-US"
|
||||||
|
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
|
||||||
|
tmdb_enrich_interval_min: int = 120
|
||||||
|
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
||||||
|
tmdb_enrich_batch: int = 100
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allowed_email_set(self) -> set[str]:
|
def allowed_email_set(self) -> set[str]:
|
||||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||||
|
|||||||
+7
-2
@@ -4,6 +4,7 @@ Shared by the admin's manual reset endpoint (routes/admin.py) and the scheduled
|
|||||||
(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an
|
(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an
|
||||||
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
|
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -49,13 +50,17 @@ def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
|
|||||||
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
|
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
|
||||||
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
|
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
|
||||||
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
|
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
|
||||||
top = db.execute(
|
top = (
|
||||||
|
db.execute(
|
||||||
select(Video.channel_id)
|
select(Video.channel_id)
|
||||||
.where(Video.channel_id.is_not(None))
|
.where(Video.channel_id.is_not(None))
|
||||||
.group_by(Video.channel_id)
|
.group_by(Video.channel_id)
|
||||||
.order_by(func.count().desc())
|
.order_by(func.count().desc())
|
||||||
.limit(n)
|
.limit(n)
|
||||||
).scalars().all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for channel_id in top:
|
for channel_id in top:
|
||||||
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
|
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
|
||||||
return len(top)
|
return len(top)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Adapters are stateless singletons; get one with `resolve_adapter(source_kind, so
|
|||||||
Resolution works both before extraction (from source_kind / URL host) and after (from the yt-dlp
|
Resolution works both before extraction (from source_kind / URL host) and after (from the yt-dlp
|
||||||
`extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter.
|
`extractor_key` in the info dict), so a non-YouTube "url" job still lands on the right adapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -140,8 +141,10 @@ def resolve_adapter(
|
|||||||
def host_is(*domains: str) -> bool:
|
def host_is(*domains: str) -> bool:
|
||||||
return any(_host_is(h, *domains) for h in hosts)
|
return any(_host_is(h, *domains) for h in hosts)
|
||||||
|
|
||||||
if source_kind == "youtube" or "youtube" in extractor or host_is(
|
if (
|
||||||
"youtube.com", "youtu.be", "youtube-nocookie.com"
|
source_kind == "youtube"
|
||||||
|
or "youtube" in extractor
|
||||||
|
or host_is("youtube.com", "youtu.be", "youtube-nocookie.com")
|
||||||
):
|
):
|
||||||
return _YOUTUBE
|
return _YOUTUBE
|
||||||
if "facebook" in extractor or host_is("facebook.com", "fb.watch"):
|
if "facebook" in extractor or host_is("facebook.com", "fb.watch"):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ URL may have expired — those just get a locally-generated poster instead).
|
|||||||
Run once per environment:
|
Run once per environment:
|
||||||
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|||||||
+151
-29
@@ -20,6 +20,7 @@ Also builds the editor **filmstrip** — a single tiled sprite of the source vid
|
|||||||
scrub track. Cached on the source asset's `storyboard_path` (reserved in phase 1) and reusable
|
scrub track. Cached on the source asset's `storyboard_path` (reserved in phase 1) and reusable
|
||||||
for a player hover-preview later.
|
for a player hover-preview later.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -40,6 +41,7 @@ class EditAborted(Exception):
|
|||||||
|
|
||||||
# --- edit spec ------------------------------------------------------------------------------
|
# --- edit spec ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
||||||
|
|
||||||
@@ -140,6 +142,7 @@ def clip_duration(spec: dict, source_duration: int | None) -> int | None:
|
|||||||
|
|
||||||
# --- ffmpeg: trim / crop --------------------------------------------------------------------
|
# --- ffmpeg: trim / crop --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]:
|
def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]:
|
||||||
"""ffmpeg command for a trim/crop edit. `-progress pipe:1` streams machine-readable progress.
|
"""ffmpeg command for a trim/crop edit. `-progress pipe:1` streams machine-readable progress.
|
||||||
|
|
||||||
@@ -153,8 +156,15 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
|||||||
reencode = needs_reencode(spec)
|
reencode = needs_reencode(spec)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
"ffmpeg",
|
||||||
"-progress", "pipe:1", "-nostats",
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-progress",
|
||||||
|
"pipe:1",
|
||||||
|
"-nostats",
|
||||||
]
|
]
|
||||||
if start:
|
if start:
|
||||||
cmd += ["-ss", f"{start:.3f}"]
|
cmd += ["-ss", f"{start:.3f}"]
|
||||||
@@ -170,8 +180,16 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
|||||||
if crop:
|
if crop:
|
||||||
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
|
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
|
||||||
cmd += [
|
cmd += [
|
||||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
"-c:v",
|
||||||
"-c:a", "aac", "-b:a", "192k",
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"20",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
"192k",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
|
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
|
||||||
@@ -204,8 +222,15 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
|||||||
crop = spec.get("crop")
|
crop = spec.get("crop")
|
||||||
prep: dict[Path, str] = {}
|
prep: dict[Path, str] = {}
|
||||||
head = [
|
head = [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
"ffmpeg",
|
||||||
"-progress", "pipe:1", "-nostats",
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-progress",
|
||||||
|
"pipe:1",
|
||||||
|
"-nostats",
|
||||||
]
|
]
|
||||||
|
|
||||||
if needs_reencode(spec):
|
if needs_reencode(spec):
|
||||||
@@ -218,9 +243,24 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
|||||||
labels.append(f"[v{i}][a{i}]")
|
labels.append(f"[v{i}][a{i}]")
|
||||||
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
|
parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]")
|
||||||
cmd = head + [
|
cmd = head + [
|
||||||
"-i", str(src), "-filter_complex", ";".join(parts),
|
"-i",
|
||||||
"-map", "[v]", "-map", "[a]",
|
str(src),
|
||||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-b:a", "192k",
|
"-filter_complex",
|
||||||
|
";".join(parts),
|
||||||
|
"-map",
|
||||||
|
"[v]",
|
||||||
|
"-map",
|
||||||
|
"[a]",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"20",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
"192k",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
listp = staging / "concat.txt"
|
listp = staging / "concat.txt"
|
||||||
@@ -314,7 +354,9 @@ def _stop(proc: subprocess.Popen) -> None:
|
|||||||
escalate_kill(proc, proc.terminate, proc.kill, grace_s=5, label="ffmpeg")
|
escalate_kill(proc, proc.terminate, proc.kill, grace_s=5, label="ffmpeg")
|
||||||
|
|
||||||
|
|
||||||
def run_ffmpeg(cmd, total_s, on_progress, should_cancel, stall_timeout_s=_FFMPEG_STALL_TIMEOUT_S) -> None:
|
def run_ffmpeg(
|
||||||
|
cmd, total_s, on_progress, should_cancel, stall_timeout_s=_FFMPEG_STALL_TIMEOUT_S
|
||||||
|
) -> None:
|
||||||
"""Run an ffmpeg edit, reporting 0–100 progress and honouring cooperative cancel.
|
"""Run an ffmpeg edit, reporting 0–100 progress and honouring cooperative cancel.
|
||||||
|
|
||||||
Both pipes are drained by threads (see `_drain_tail`) and the main loop polls on a 1s tick, so
|
Both pipes are drained by threads (see `_drain_tail`) and the main loop polls on a 1s tick, so
|
||||||
@@ -411,9 +453,20 @@ def probe_codecs(path: Path) -> tuple[str | None, str | None]:
|
|||||||
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
|
[
|
||||||
"-of", "json", str(path)],
|
"ffprobe",
|
||||||
capture_output=True, text=True, timeout=30, check=True,
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_type,codec_name",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
str(path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=True,
|
||||||
).stdout
|
).stdout
|
||||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
|
||||||
return None, None
|
return None, None
|
||||||
@@ -425,16 +478,29 @@ def probe_duration(path: Path) -> int:
|
|||||||
re-encode when the yt-dlp info dict carried no `duration` (some URL/live extractions)."""
|
re-encode when the yt-dlp info dict carried no `duration` (some URL/live extractions)."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
[
|
||||||
"-of", "default=nw=1:nk=1", str(path)],
|
"ffprobe",
|
||||||
capture_output=True, text=True, timeout=30, check=True,
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=nw=1:nk=1",
|
||||||
|
str(path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=True,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
return int(float(out)) if out else 0
|
return int(float(out)) if out else 0
|
||||||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def browser_transcode_flags(vcodec: str | None, acodec: str | None, is_mp4: bool) -> tuple[bool, bool, bool]:
|
def browser_transcode_flags(
|
||||||
|
vcodec: str | None, acodec: str | None, is_mp4: bool
|
||||||
|
) -> tuple[bool, bool, bool]:
|
||||||
"""Decide what (if anything) makes a file universally browser-playable. Returns
|
"""Decide what (if anything) makes a file universally browser-playable. Returns
|
||||||
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
|
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
|
||||||
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
|
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
|
||||||
@@ -443,15 +509,30 @@ def browser_transcode_flags(vcodec: str | None, acodec: str | None, is_mp4: bool
|
|||||||
return (rv or ra or not is_mp4), rv, ra
|
return (rv or ra or not is_mp4), rv, ra
|
||||||
|
|
||||||
|
|
||||||
def build_browser_safe_cmd(src: Path, dest: Path, reencode_video: bool, reencode_audio: bool) -> list[str]:
|
def build_browser_safe_cmd(
|
||||||
|
src: Path, dest: Path, reencode_video: bool, reencode_audio: bool
|
||||||
|
) -> list[str]:
|
||||||
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
||||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
||||||
pipe:1` streams machine-readable progress for run_ffmpeg."""
|
pipe:1` streams machine-readable progress for run_ffmpeg."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
"ffmpeg",
|
||||||
"-progress", "pipe:1", "-nostats", "-i", str(src),
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-progress",
|
||||||
|
"pipe:1",
|
||||||
|
"-nostats",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
]
|
]
|
||||||
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"] if reencode_video else ["-c:v", "copy"]
|
cmd += (
|
||||||
|
["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"]
|
||||||
|
if reencode_video
|
||||||
|
else ["-c:v", "copy"]
|
||||||
|
)
|
||||||
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
||||||
cmd += ["-movflags", "+faststart", str(dest)]
|
cmd += ["-movflags", "+faststart", str(dest)]
|
||||||
return cmd
|
return cmd
|
||||||
@@ -486,25 +567,63 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
|
|||||||
fps = geom["count"] / dur
|
fps = geom["count"] / dur
|
||||||
vf = f"fps={fps:.6f},scale={_SB_TILE_W}:-2,tile={geom['cols']}x{geom['rows']}"
|
vf = f"fps={fps:.6f},scale={_SB_TILE_W}:-2,tile={geom['cols']}x{geom['rows']}"
|
||||||
return [
|
return [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
"ffmpeg",
|
||||||
"-i", str(src), "-frames:v", "1", "-q:v", "5", "-vf", vf, str(dest),
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-q:v",
|
||||||
|
"5",
|
||||||
|
"-vf",
|
||||||
|
vf,
|
||||||
|
str(dest),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
|
def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
|
||||||
"""Grab one representative frame as a poster JPEG. `-ss` before `-i` seeks fast."""
|
"""Grab one representative frame as a poster JPEG. `-ss` before `-i` seeks fast."""
|
||||||
return [
|
return [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
"ffmpeg",
|
||||||
"-ss", f"{max(0.0, at_s):.3f}", "-i", str(src),
|
"-y",
|
||||||
"-frames:v", "1", "-q:v", "3", "-vf", "scale=640:-2", str(dest),
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-ss",
|
||||||
|
f"{max(0.0, at_s):.3f}",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-q:v",
|
||||||
|
"3",
|
||||||
|
"-vf",
|
||||||
|
"scale=640:-2",
|
||||||
|
str(dest),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]:
|
def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]:
|
||||||
"""Decode a downloaded thumbnail (webp/png/…) to a single baseline JPEG frame."""
|
"""Decode a downloaded thumbnail (webp/png/…) to a single baseline JPEG frame."""
|
||||||
return [
|
return [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
|
"ffmpeg",
|
||||||
"-i", str(src), "-frames:v", "1", "-update", "1", str(dest),
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-update",
|
||||||
|
"1",
|
||||||
|
str(dest),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -550,7 +669,10 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
|
|||||||
return None
|
return None
|
||||||
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
|
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
log.info("thumbnail->jpg conversion failed (%s); poster falls back to a cut frame", str(err)[:120])
|
log.info(
|
||||||
|
"thumbnail->jpg conversion failed (%s); poster falls back to a cut frame",
|
||||||
|
str(err)[:120],
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
return dest if dest.exists() else None
|
return dest if dest.exists() else None
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ spec shape (all keys optional; defaults applied by `normalize`):
|
|||||||
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
||||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -215,8 +216,6 @@ def build_ydl_opts(
|
|||||||
# jpg/png thumbnail directly and converts anything else to PNG (not the crashing mjpeg2jpeg-to-jpg
|
# jpg/png thumbnail directly and converts anything else to PNG (not the crashing mjpeg2jpeg-to-jpg
|
||||||
# path), so it never needed the convertor and does not reintroduce the crash.
|
# path), so it never needed the convertor and does not reintroduce the crash.
|
||||||
if s["embed_thumbnail"]:
|
if s["embed_thumbnail"]:
|
||||||
opts["postprocessors"].append(
|
opts["postprocessors"].append({"key": "EmbedThumbnail", "already_have_thumbnail": True})
|
||||||
{"key": "EmbedThumbnail", "already_have_thumbnail": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
return opts
|
return opts
|
||||||
|
|||||||
+39
-15
@@ -15,6 +15,7 @@ Five passes each run:
|
|||||||
|
|
||||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -40,9 +41,7 @@ _ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight do
|
|||||||
def _holders(db: Session, asset_id: int) -> list[int]:
|
def _holders(db: Session, asset_id: int) -> list[int]:
|
||||||
return list(
|
return list(
|
||||||
db.execute(
|
db.execute(
|
||||||
select(DownloadJob.user_id)
|
select(DownloadJob.user_id).where(DownloadJob.asset_id == asset_id).distinct()
|
||||||
.where(DownloadJob.asset_id == asset_id)
|
|
||||||
.distinct()
|
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +100,9 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
pass
|
pass
|
||||||
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
|
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
|
||||||
for d in sorted(
|
for d in sorted(
|
||||||
(p for p in entry.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True
|
(p for p in entry.rglob("*") if p.is_dir()),
|
||||||
|
key=lambda p: len(p.parts),
|
||||||
|
reverse=True,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
d.rmdir()
|
d.rmdir()
|
||||||
@@ -121,7 +122,8 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
|
|
||||||
# 1. Pre-expiry warnings.
|
# 1. Pre-expiry warnings.
|
||||||
warn_before = now + timedelta(days=grace_days)
|
warn_before = now + timedelta(days=grace_days)
|
||||||
soon = db.execute(
|
soon = (
|
||||||
|
db.execute(
|
||||||
select(MediaAsset).where(
|
select(MediaAsset).where(
|
||||||
MediaAsset.status == "ready",
|
MediaAsset.status == "ready",
|
||||||
MediaAsset.gc_notified.is_(False),
|
MediaAsset.gc_notified.is_(False),
|
||||||
@@ -129,7 +131,10 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
MediaAsset.expires_at > now,
|
MediaAsset.expires_at > now,
|
||||||
MediaAsset.expires_at <= warn_before,
|
MediaAsset.expires_at <= warn_before,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for asset in soon:
|
for asset in soon:
|
||||||
_notify_holders(db, asset, "download_expiring")
|
_notify_holders(db, asset, "download_expiring")
|
||||||
asset.gc_notified = True
|
asset.gc_notified = True
|
||||||
@@ -138,13 +143,17 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
||||||
dead = db.execute(
|
dead = (
|
||||||
|
db.execute(
|
||||||
select(MediaAsset).where(
|
select(MediaAsset).where(
|
||||||
MediaAsset.status.in_(("ready", "error")),
|
MediaAsset.status.in_(("ready", "error")),
|
||||||
MediaAsset.expires_at.is_not(None),
|
MediaAsset.expires_at.is_not(None),
|
||||||
MediaAsset.expires_at <= now,
|
MediaAsset.expires_at <= now,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for asset in dead:
|
for asset in dead:
|
||||||
_delete_asset(db, asset, "download_expired")
|
_delete_asset(db, asset, "download_expired")
|
||||||
expired += 1
|
expired += 1
|
||||||
@@ -154,17 +163,24 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
# 3. LRU eviction over the total cache cap.
|
# 3. LRU eviction over the total cache cap.
|
||||||
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
||||||
if cap > 0:
|
if cap > 0:
|
||||||
total = db.execute(
|
total = (
|
||||||
|
db.execute(
|
||||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||||
MediaAsset.status == "ready"
|
MediaAsset.status == "ready"
|
||||||
)
|
)
|
||||||
).scalar() or 0
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
if total > cap:
|
if total > cap:
|
||||||
candidates = db.execute(
|
candidates = (
|
||||||
|
db.execute(
|
||||||
select(MediaAsset)
|
select(MediaAsset)
|
||||||
.where(MediaAsset.status == "ready")
|
.where(MediaAsset.status == "ready")
|
||||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||||
).scalars().all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for asset in candidates:
|
for asset in candidates:
|
||||||
if total <= cap:
|
if total <= cap:
|
||||||
break
|
break
|
||||||
@@ -176,13 +192,17 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
# 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still-
|
# 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still-
|
||||||
# referenced errored row to pending on re-enqueue, so these only accumulate once every
|
# referenced errored row to pending on re-enqueue, so these only accumulate once every
|
||||||
# holder is gone; they carry no expires_at, so passes 1-3 never touch them.
|
# holder is gone; they carry no expires_at, so passes 1-3 never touch them.
|
||||||
orphans = db.execute(
|
orphans = (
|
||||||
|
db.execute(
|
||||||
select(MediaAsset).where(
|
select(MediaAsset).where(
|
||||||
MediaAsset.status == "error",
|
MediaAsset.status == "error",
|
||||||
MediaAsset.ref_count == 0,
|
MediaAsset.ref_count == 0,
|
||||||
MediaAsset.rel_path.is_(None),
|
MediaAsset.rel_path.is_(None),
|
||||||
)
|
)
|
||||||
).scalars().all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for asset in orphans:
|
for asset in orphans:
|
||||||
db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id
|
db.delete(asset) # no file to unlink; FK SET NULL clears any stray job.asset_id
|
||||||
reaped += 1
|
reaped += 1
|
||||||
@@ -194,7 +214,11 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
swept = _sweep_orphan_files(db, settings.download_root)
|
swept = _sweep_orphan_files(db, settings.download_root)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"warned": warned, "expired": expired, "evicted": evicted, "reaped": reaped, "swept": swept
|
"warned": warned,
|
||||||
|
"expired": expired,
|
||||||
|
"evicted": evicted,
|
||||||
|
"reaped": reaped,
|
||||||
|
"swept": swept,
|
||||||
}
|
}
|
||||||
log.info("download_gc %s", result)
|
log.info("download_gc %s", result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
|
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -10,6 +11,7 @@ from app.models import DownloadJob, MediaAsset
|
|||||||
|
|
||||||
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
||||||
|
|
||||||
|
|
||||||
def _set_job(job_id: int, **fields) -> None:
|
def _set_job(job_id: int, **fields) -> None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require a short-lived signed **grant** on the file request — because a `<video
|
|||||||
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
||||||
so the raw password never rides in the media URL (or server logs).
|
so the raw password never rides in the media URL (or server logs).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
@@ -27,10 +28,9 @@ def is_expired(link: DownloadLink) -> bool:
|
|||||||
|
|
||||||
# --- signed grants (password-protected file access) ----------------------------------------
|
# --- signed grants (password-protected file access) ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _sign(msg: str) -> str:
|
def _sign(msg: str) -> str:
|
||||||
return hmac.new(
|
return hmac.new(settings.secret_key.encode(), msg.encode(), hashlib.sha256).hexdigest()[:32]
|
||||||
settings.secret_key.encode(), msg.encode(), hashlib.sha256
|
|
||||||
).hexdigest()[:32]
|
|
||||||
|
|
||||||
|
|
||||||
def make_grant(token: str, password_version: int, now: float | None = None) -> str:
|
def make_grant(token: str, password_version: int, now: float | None = None) -> str:
|
||||||
@@ -58,6 +58,7 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
|||||||
|
|
||||||
# --- serialization -------------------------------------------------------------------------
|
# --- serialization -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
||||||
"""What the file owner sees when managing their links."""
|
"""What the file owner sees when managing their links."""
|
||||||
return {
|
return {
|
||||||
@@ -72,7 +73,9 @@ def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def public_meta(link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None) -> dict:
|
def public_meta(
|
||||||
|
link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None
|
||||||
|
) -> dict:
|
||||||
"""Metadata for the public watch page (only ever returned once access is authorized).
|
"""Metadata for the public watch page (only ever returned once access is authorized).
|
||||||
|
|
||||||
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ This injects per-video OG/Twitter tags into the served HTML so a shared link unf
|
|||||||
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
|
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
|
||||||
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
|
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -71,9 +72,7 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
|
|||||||
|
|
||||||
def _watch_tags(token: str, db: Session) -> str | None:
|
def _watch_tags(token: str, db: Session) -> str | None:
|
||||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
||||||
link = db.execute(
|
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||||
select(DownloadLink).where(DownloadLink.token == token)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if link is None or linksmod.is_expired(link):
|
if link is None or linksmod.is_expired(link):
|
||||||
return None
|
return None
|
||||||
url = f"{settings.app_base}/watch/{token}"
|
url = f"{settings.app_base}/watch/{token}"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -57,9 +58,7 @@ _YTDLP_TEMP_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_download_done(
|
def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None:
|
||||||
db, user_id: int | None, title: str | None, job_id, *, kind: str
|
|
||||||
) -> None:
|
|
||||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
||||||
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
|
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
|
||||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||||
@@ -247,7 +246,9 @@ def _make_pp_hook(job_id: int):
|
|||||||
|
|
||||||
def hook(d: dict) -> None:
|
def hook(d: dict) -> None:
|
||||||
if d.get("status") in ("started", "processing"):
|
if d.get("status") in ("started", "processing"):
|
||||||
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None)
|
_set_job(
|
||||||
|
job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None
|
||||||
|
)
|
||||||
|
|
||||||
return hook
|
return hook
|
||||||
|
|
||||||
@@ -269,7 +270,9 @@ def _produced_file(info: dict, staging: Path) -> Path:
|
|||||||
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
||||||
working files (see `_YTDLP_TEMP_RE`)."""
|
working files (see `_YTDLP_TEMP_RE`)."""
|
||||||
req = (info.get("requested_downloads") or [{}])[0]
|
req = (info.get("requested_downloads") or [{}])[0]
|
||||||
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
fp = (
|
||||||
|
req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
||||||
|
)
|
||||||
if fp:
|
if fp:
|
||||||
produced = Path(str(fp))
|
produced = Path(str(fp))
|
||||||
if produced.is_file():
|
if produced.is_file():
|
||||||
@@ -310,7 +313,9 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
|||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for i, clients in enumerate(client_sets):
|
for i, clients in enumerate(client_sets):
|
||||||
opts = formats.build_ydl_opts(
|
opts = formats.build_ydl_opts(
|
||||||
spec, outtmpl, _make_progress_hook(job_id, asset_id),
|
spec,
|
||||||
|
outtmpl,
|
||||||
|
_make_progress_hook(job_id, asset_id),
|
||||||
extra=adapter.ydl_opts_extra(clients),
|
extra=adapter.ydl_opts_extra(clients),
|
||||||
)
|
)
|
||||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||||
@@ -324,7 +329,12 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
|||||||
except yt_dlp.utils.DownloadError as exc:
|
except yt_dlp.utils.DownloadError as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
||||||
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70])
|
log.info(
|
||||||
|
"job %s: clients %s failed (%s); retrying with fallback",
|
||||||
|
job_id,
|
||||||
|
clients,
|
||||||
|
str(exc)[:70],
|
||||||
|
)
|
||||||
for p in list(staging.iterdir()): # drop partials before the retry
|
for p in list(staging.iterdir()): # drop partials before the retry
|
||||||
try:
|
try:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
@@ -363,7 +373,9 @@ def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict
|
|||||||
|
|
||||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
dest = produced.with_name(produced.stem + ".browser.mp4")
|
||||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
||||||
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
|
log.info(
|
||||||
|
"job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4
|
||||||
|
)
|
||||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
||||||
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
||||||
# re-encode still shows real progress instead of a frozen 0%.
|
# re-encode still shows real progress instead of a frozen 0%.
|
||||||
@@ -436,8 +448,12 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
asset.width = int(info["width"]) if info.get("width") else None
|
asset.width = int(info["width"]) if info.get("width") else None
|
||||||
asset.height = int(info["height"]) if info.get("height") else None
|
asset.height = int(info["height"]) if info.get("height") else None
|
||||||
asset.container = ext
|
asset.container = ext
|
||||||
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
asset.vcodec = (
|
||||||
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||||
|
)
|
||||||
|
asset.acodec = (
|
||||||
|
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
|
)
|
||||||
asset.title = meta.title
|
asset.title = meta.title
|
||||||
asset.uploader = meta.uploader
|
asset.uploader = meta.uploader
|
||||||
asset.uploader_url = adapter.uploader_url(info)
|
asset.uploader_url = adapter.uploader_url(info)
|
||||||
@@ -456,8 +472,13 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
fields = {
|
||||||
"eta_s": None}
|
"status": JobStatus.DONE,
|
||||||
|
"progress": 100,
|
||||||
|
"phase": None,
|
||||||
|
"speed_bps": None,
|
||||||
|
"eta_s": None,
|
||||||
|
}
|
||||||
if job and not job.display_name and meta.title:
|
if job and not job.display_name and meta.title:
|
||||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||||
fields["display_name"] = meta.title[:255]
|
fields["display_name"] = meta.title[:255]
|
||||||
@@ -484,8 +505,12 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||||
service.finalize_if_running(
|
service.finalize_if_running(
|
||||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
db,
|
||||||
error_code=dlerrors.classify(msg), phase=None,
|
job_id,
|
||||||
|
status=JobStatus.ERROR,
|
||||||
|
error=msg,
|
||||||
|
error_code=dlerrors.classify(msg),
|
||||||
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("job %s failed: %s", job_id, msg)
|
log.warning("job %s failed: %s", job_id, msg)
|
||||||
@@ -501,8 +526,12 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
|||||||
asset.error = msg
|
asset.error = msg
|
||||||
# Atomic fail: only while still running (see the download error path).
|
# Atomic fail: only while still running (see the download error path).
|
||||||
service.finalize_if_running(
|
service.finalize_if_running(
|
||||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
db,
|
||||||
error_code=dlerrors.classify(msg), phase=None,
|
job_id,
|
||||||
|
status=JobStatus.ERROR,
|
||||||
|
error=msg,
|
||||||
|
error_code=dlerrors.classify(msg),
|
||||||
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("edit job %s failed: %s", job_id, msg)
|
log.warning("edit job %s failed: %s", job_id, msg)
|
||||||
@@ -517,7 +546,9 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
user_id = job.user_id if job else None
|
user_id = job.user_id if job else None
|
||||||
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
||||||
src_rel = src.rel_path if src else None
|
src_rel = src.rel_path if src else None
|
||||||
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None)
|
src_w, src_h, src_dur = (
|
||||||
|
(src.width, src.height, src.duration_s) if src else (None, None, None)
|
||||||
|
)
|
||||||
|
|
||||||
if not src_ready or not src_rel:
|
if not src_ready or not src_rel:
|
||||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||||
@@ -541,7 +572,9 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
|
|
||||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||||
if edit_spec.get("segments"):
|
if edit_spec.get("segments"):
|
||||||
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging)
|
cmd, prep = editmod.build_concat_plan(
|
||||||
|
src_path, dest_staging, edit_spec, out_ext, staging
|
||||||
|
)
|
||||||
for p, content in prep.items():
|
for p, content in prep.items():
|
||||||
p.write_text(content, encoding="utf-8")
|
p.write_text(content, encoding="utf-8")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
|
|||||||
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
||||||
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Contract: the asset is already claimed ('downloading') by the parent. This proce
|
|||||||
writes the terminal job/asset state itself (done / error, or a cooperative pause via the in-flight
|
writes the terminal job/asset state itself (done / error, or a cooperative pause via the in-flight
|
||||||
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
|
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ cache hit (asset already ready) — marks the job done instantly with no re-down
|
|||||||
handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and
|
handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and
|
||||||
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
@@ -166,7 +167,8 @@ def enqueue(
|
|||||||
source_ref=source_ref,
|
source_ref=source_ref,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
profile_snapshot=spec,
|
profile_snapshot=spec,
|
||||||
display_name=display_name or (asset.title[:255] if asset.status == "ready" and asset.title else None),
|
display_name=display_name
|
||||||
|
or (asset.title[:255] if asset.status == "ready" and asset.title else None),
|
||||||
status="queued",
|
status="queued",
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Plus a Kodi/Plex-readable `<basename>.nfo` (episodedetails) and `<basename>.jpg`
|
|||||||
a Plex library using a metadata agent picks up rich info. Channel = "show", video = episode,
|
a Plex library using a metadata agent picks up rich info. Channel = "show", video = episode,
|
||||||
date-based — the community-standard mapping for YouTube-in-Plex.
|
date-based — the community-standard mapping for YouTube-in-Plex.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import unicodedata
|
import unicodedata
|
||||||
@@ -36,9 +37,27 @@ _DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
|
|||||||
# that accented names show in non-UTF-8 tools.
|
# that accented names show in non-UTF-8 tools.
|
||||||
_FOLD_MAP = str.maketrans(
|
_FOLD_MAP = str.maketrans(
|
||||||
{
|
{
|
||||||
"ß": "ss", "ẞ": "SS", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D",
|
"ß": "ss",
|
||||||
"ð": "d", "Ð": "D", "þ": "th", "Þ": "Th", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE",
|
"ẞ": "SS",
|
||||||
"ı": "i", "İ": "I", "·": " ", "–": "-", "—": "-",
|
"ø": "o",
|
||||||
|
"Ø": "O",
|
||||||
|
"ł": "l",
|
||||||
|
"Ł": "L",
|
||||||
|
"đ": "d",
|
||||||
|
"Đ": "D",
|
||||||
|
"ð": "d",
|
||||||
|
"Ð": "D",
|
||||||
|
"þ": "th",
|
||||||
|
"Þ": "Th",
|
||||||
|
"æ": "ae",
|
||||||
|
"Æ": "AE",
|
||||||
|
"œ": "oe",
|
||||||
|
"Œ": "OE",
|
||||||
|
"ı": "i",
|
||||||
|
"İ": "I",
|
||||||
|
"·": " ",
|
||||||
|
"–": "-",
|
||||||
|
"—": "-",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,7 +193,7 @@ def _nfo_xml(meta: MediaMeta) -> str:
|
|||||||
f" <premiered>{aired}</premiered>",
|
f" <premiered>{aired}</premiered>",
|
||||||
f" <year>{year}</year>",
|
f" <year>{year}</year>",
|
||||||
f" <plot>{escape(meta.description or '')}</plot>",
|
f" <plot>{escape(meta.description or '')}</plot>",
|
||||||
f" <uniqueid type=\"{escape(meta.uniqueid_type)}\" default=\"true\">{escape(meta.video_id)}</uniqueid>",
|
f' <uniqueid type="{escape(meta.uniqueid_type)}" default="true">{escape(meta.video_id)}</uniqueid>',
|
||||||
]
|
]
|
||||||
if meta.duration_s:
|
if meta.duration_s:
|
||||||
parts.append(f" <runtime>{round(meta.duration_s / 60)}</runtime>")
|
parts.append(f" <runtime>{round(meta.duration_s / 60)}</runtime>")
|
||||||
@@ -190,9 +209,7 @@ def _sidecar(media: Path, ext: str) -> Path:
|
|||||||
return media.with_suffix(ext)
|
return media.with_suffix(ext)
|
||||||
|
|
||||||
|
|
||||||
def write_sidecars(
|
def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None) -> bool:
|
||||||
download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None
|
|
||||||
) -> bool:
|
|
||||||
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
|
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
|
||||||
media = abs_path(download_root, rel)
|
media = abs_path(download_root, rel)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+6
-13
@@ -4,6 +4,7 @@ Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't conf
|
|||||||
or a send errors, we log and return False so the caller can fall back to in-app
|
or a send errors, we log and return False so the caller can fall back to in-app
|
||||||
notifications — email is never load-bearing for the app's core flow.
|
notifications — email is never load-bearing for the app's core flow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
@@ -77,8 +78,7 @@ def send_access_approved(email: str) -> bool:
|
|||||||
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
||||||
f"{settings.app_base}\n\n"
|
f"{settings.app_base}\n\n"
|
||||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||||
"— Siftlode"
|
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||||
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
|
||||||
)
|
)
|
||||||
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
||||||
|
|
||||||
@@ -90,8 +90,7 @@ def send_access_denied(email: str) -> bool:
|
|||||||
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
|
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
|
||||||
"this time.\n\n"
|
"this time.\n\n"
|
||||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||||
"— Siftlode"
|
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||||
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
|
||||||
)
|
)
|
||||||
return _send([email], "About your Siftlode access request", body, reply_to=admin)
|
return _send([email], "About your Siftlode access request", body, reply_to=admin)
|
||||||
|
|
||||||
@@ -113,9 +112,7 @@ def send_account_unsuspended(to: str, operator: str | None) -> bool:
|
|||||||
body = (
|
body = (
|
||||||
"Hi,\n\n"
|
"Hi,\n\n"
|
||||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
||||||
"in again."
|
"in again." + contact + "\n\n— Siftlode"
|
||||||
+ contact
|
|
||||||
+ "\n\n— Siftlode"
|
|
||||||
)
|
)
|
||||||
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
|
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
|
||||||
|
|
||||||
@@ -130,9 +127,7 @@ def send_account_deleted(to: str, operator: str | None) -> bool:
|
|||||||
"Hi,\n\n"
|
"Hi,\n\n"
|
||||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||||
"Thanks for having used Siftlode."
|
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
|
||||||
+ contact
|
|
||||||
+ "\n\n— Siftlode"
|
|
||||||
)
|
)
|
||||||
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
|
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
|
||||||
|
|
||||||
@@ -147,9 +142,7 @@ def send_account_suspended(to: str, operator: str | None) -> bool:
|
|||||||
body = (
|
body = (
|
||||||
"Hi,\n\n"
|
"Hi,\n\n"
|
||||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||||
"suspended, so access was denied.\n\n"
|
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
|
||||||
+ contact
|
|
||||||
+ "— Siftlode"
|
|
||||||
)
|
)
|
||||||
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
||||||
|
|
||||||
|
|||||||
+61
-36
@@ -117,7 +117,7 @@ def filtered_query(
|
|||||||
user: User,
|
user: User,
|
||||||
*,
|
*,
|
||||||
tags: list[int],
|
tags: list[int],
|
||||||
tag_mode: str,
|
tag_modes: dict[str, str],
|
||||||
channel_ids: list[str],
|
channel_ids: list[str],
|
||||||
q: str | None,
|
q: str | None,
|
||||||
min_duration: int | None,
|
min_duration: int | None,
|
||||||
@@ -144,9 +144,7 @@ def filtered_query(
|
|||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
status_expr = func.coalesce(state.status, "new")
|
status_expr = func.coalesce(state.status, "new")
|
||||||
|
|
||||||
query = select(*feed_columns(user, state)).join(
|
query = select(*feed_columns(user, state)).join(Channel, Channel.id == Video.channel_id)
|
||||||
Channel, Channel.id == Video.channel_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if scope == "all":
|
if scope == "all":
|
||||||
# Whole shared catalog; subscription is optional (only for priority sort).
|
# Whole shared catalog; subscription is optional (only for priority sort).
|
||||||
@@ -181,9 +179,7 @@ def filtered_query(
|
|||||||
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
|
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
|
||||||
state_required = show in ("watched", "hidden", "in_progress")
|
state_required = show in ("watched", "hidden", "in_progress")
|
||||||
join_state = query.join if state_required else query.outerjoin
|
join_state = query.join if state_required else query.outerjoin
|
||||||
query = join_state(
|
query = join_state(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
state, and_(state.video_id == Video.id, state.user_id == user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
|
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
|
||||||
# abandoned). They're either deleted after a grace period or unhidden if they recover;
|
# abandoned). They're either deleted after a grace period or unhidden if they recover;
|
||||||
@@ -197,9 +193,7 @@ def filtered_query(
|
|||||||
# asking for scope=all + channel_ids (the channel is in this accessible set).
|
# asking for scope=all + channel_ids (the channel is in this accessible set).
|
||||||
accessible_explore = (
|
accessible_explore = (
|
||||||
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||||
).union(
|
).union(select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id))
|
||||||
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
|
|
||||||
)
|
|
||||||
query = query.where(
|
query = query.where(
|
||||||
or_(
|
or_(
|
||||||
Video.via_explore.is_(False),
|
Video.via_explore.is_(False),
|
||||||
@@ -244,13 +238,9 @@ def filtered_query(
|
|||||||
query = query.where(Video.duration_seconds <= max_duration)
|
query = query.where(Video.duration_seconds <= max_duration)
|
||||||
if max_age_days is not None:
|
if max_age_days is not None:
|
||||||
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
||||||
query = query.where(
|
query = query.where(Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc))
|
||||||
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
|
||||||
)
|
|
||||||
if published_after is not None:
|
if published_after is not None:
|
||||||
start = datetime.combine(
|
start = datetime.combine(published_after, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
published_after, datetime.min.time(), tzinfo=timezone.utc
|
|
||||||
)
|
|
||||||
query = query.where(Video.published_at >= start)
|
query = query.where(Video.published_at >= start)
|
||||||
if published_before is not None:
|
if published_before is not None:
|
||||||
end = datetime.combine(
|
end = datetime.combine(
|
||||||
@@ -293,9 +283,10 @@ def filtered_query(
|
|||||||
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
||||||
|
|
||||||
if tags:
|
if tags:
|
||||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
|
# AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
|
||||||
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
|
# category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
|
||||||
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
|
||||||
|
# sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||||
# test_feed_summary_matches_count_plus_facets).
|
# test_feed_summary_matches_count_plus_facets).
|
||||||
by_category = tags_by_category(db, tags)
|
by_category = tags_by_category(db, tags)
|
||||||
# Facet counting drops the category being counted so its own chips don't zero each
|
# Facet counting drops the category being counted so its own chips don't zero each
|
||||||
@@ -305,7 +296,7 @@ def filtered_query(
|
|||||||
by_category.pop(exclude_tag_category, None)
|
by_category.pop(exclude_tag_category, None)
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
for category, ids in by_category.items():
|
for category, ids in by_category.items():
|
||||||
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||||
sub = (
|
sub = (
|
||||||
select(ChannelTag.channel_id)
|
select(ChannelTag.channel_id)
|
||||||
.where(ChannelTag.tag_id.in_(ids), visible)
|
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||||
@@ -313,9 +304,7 @@ def filtered_query(
|
|||||||
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sub = select(ChannelTag.channel_id).where(
|
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
|
||||||
ChannelTag.tag_id.in_(ids), visible
|
|
||||||
)
|
|
||||||
query = query.where(Video.channel_id.in_(sub))
|
query = query.where(Video.channel_id.in_(sub))
|
||||||
|
|
||||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||||
@@ -363,8 +352,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
||||||
old /facets exclude_tag_category logic exactly).
|
old /facets exclude_tag_category logic exactly).
|
||||||
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
||||||
within, topic-AND requires ALL selected topics)."""
|
within, a category in "and" mode requires ALL its selected tags)."""
|
||||||
tag_mode = params.get("tag_mode", "or")
|
tag_modes = params.get("tag_modes", {})
|
||||||
|
|
||||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||||
# is present to facet over.
|
# is present to facet over.
|
||||||
@@ -401,8 +390,10 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
for cat, sel in selected_by_cat.items():
|
for cat, sel in selected_by_cat.items():
|
||||||
if cat == exclude_cat:
|
if cat == exclude_cat:
|
||||||
continue
|
continue
|
||||||
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
|
if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
|
||||||
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
|
if (
|
||||||
|
not sel <= ch_tags
|
||||||
|
): # conjunctive: channel must carry ALL this category's selected tags
|
||||||
return False
|
return False
|
||||||
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||||
return False
|
return False
|
||||||
@@ -416,7 +407,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
for category in ("language", "topic", "other"):
|
for category in ("language", "topic", "other"):
|
||||||
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
||||||
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
||||||
exclude = None if (category == "topic" and tag_mode == "and") else category
|
exclude = None if tag_modes.get(category, "or") == "and" else category
|
||||||
for cid, ch_tags in channel_tags.items():
|
for cid, ch_tags in channel_tags.items():
|
||||||
if not passes(ch_tags, exclude):
|
if not passes(ch_tags, exclude):
|
||||||
continue
|
continue
|
||||||
@@ -442,19 +433,52 @@ def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
|||||||
"oldest": [{**pub, "asc": True}],
|
"oldest": [{**pub, "asc": True}],
|
||||||
"views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}],
|
"views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}],
|
||||||
"views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}],
|
"views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}],
|
||||||
"duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}],
|
"duration_desc": [
|
||||||
"duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}],
|
{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}
|
||||||
|
],
|
||||||
|
"duration_asc": [
|
||||||
|
{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}
|
||||||
|
],
|
||||||
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}],
|
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}],
|
||||||
"title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}],
|
"title_desc": [
|
||||||
"subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}],
|
{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}
|
||||||
"subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}],
|
],
|
||||||
|
"subscribers": [
|
||||||
|
{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}
|
||||||
|
],
|
||||||
|
"subscribers_asc": [
|
||||||
|
{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}
|
||||||
|
],
|
||||||
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
||||||
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
|
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
|
||||||
"priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub],
|
"priority": [
|
||||||
"priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub],
|
{
|
||||||
|
"expr": func.coalesce(Subscription.priority, 0),
|
||||||
|
"asc": False,
|
||||||
|
"nullable": False,
|
||||||
|
"kind": "num",
|
||||||
|
},
|
||||||
|
pub,
|
||||||
|
],
|
||||||
|
"priority_asc": [
|
||||||
|
{
|
||||||
|
"expr": func.coalesce(Subscription.priority, 0),
|
||||||
|
"asc": True,
|
||||||
|
"nullable": False,
|
||||||
|
"kind": "num",
|
||||||
|
},
|
||||||
|
pub,
|
||||||
|
],
|
||||||
# Deterministic per-seed shuffle: the same seed yields the same total order, so
|
# Deterministic per-seed shuffle: the same seed yields the same total order, so
|
||||||
# the cursor stays valid across pages of one shuffled run.
|
# the cursor stays valid across pages of one shuffled run.
|
||||||
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
|
"shuffle": [
|
||||||
|
{
|
||||||
|
"expr": func.md5(func.concat(Video.id, str(seed))),
|
||||||
|
"asc": True,
|
||||||
|
"nullable": False,
|
||||||
|
"kind": "str",
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
# Relevance (search-result ranking) only exists when there's a query to rank against;
|
# Relevance (search-result ranking) only exists when there's a query to rank against;
|
||||||
# without `rank_expr` it falls through to newest. Ties break by newest, then id.
|
# without `rank_expr` it falls through to newest. Ties break by newest, then id.
|
||||||
@@ -489,6 +513,7 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
|
|||||||
`newest` pages went 51.8k -> ~0.5k buffers). A NULLABLE key can't take that bound (the
|
`newest` pages went 51.8k -> ~0.5k buffers). A NULLABLE key can't take that bound (the
|
||||||
NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure
|
NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure
|
||||||
NULL-aware OR form."""
|
NULL-aware OR form."""
|
||||||
|
|
||||||
def eq(k):
|
def eq(k):
|
||||||
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -12,9 +12,7 @@ from fastapi import FastAPI, HTTPException
|
|||||||
_siftlode_logger = logging.getLogger("siftlode")
|
_siftlode_logger = logging.getLogger("siftlode")
|
||||||
if not _siftlode_logger.handlers:
|
if not _siftlode_logger.handlers:
|
||||||
_handler = logging.StreamHandler(sys.stdout)
|
_handler = logging.StreamHandler(sys.stdout)
|
||||||
_handler.setFormatter(
|
_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s"))
|
||||||
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
|
|
||||||
)
|
|
||||||
_siftlode_logger.addHandler(_handler)
|
_siftlode_logger.addHandler(_handler)
|
||||||
_siftlode_logger.setLevel(logging.INFO)
|
_siftlode_logger.setLevel(logging.INFO)
|
||||||
_siftlode_logger.propagate = False
|
_siftlode_logger.propagate = False
|
||||||
@@ -140,9 +138,7 @@ async def setup_gate(request, call_next):
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
configured = state.is_configured_cached(db)
|
configured = state.is_configured_cached(db)
|
||||||
if not configured:
|
if not configured:
|
||||||
return JSONResponse(
|
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
|
||||||
{"detail": "This instance isn't set up yet."}, status_code=503
|
|
||||||
)
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+151
-182
@@ -26,9 +26,7 @@ class TimestampMixin:
|
|||||||
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
||||||
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatedAtMixin:
|
class UpdatedAtMixin:
|
||||||
@@ -51,18 +49,14 @@ class User(Base, TimestampMixin):
|
|||||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||||
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
||||||
# verifies via a link). Login requires this AND is_active.
|
# verifies via a link). Login requires this AND is_active.
|
||||||
email_verified: Mapped[bool] = mapped_column(
|
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# Admin-approved & enabled. A pending password registration starts inactive; admin
|
# Admin-approved & enabled. A pending password registration starts inactive; admin
|
||||||
# approval activates it. A deactivated account can't log in.
|
# approval activates it. A deactivated account can't log in.
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
||||||
# account can't sign in by any method and its live sessions are rejected; the user is told
|
# account can't sign in by any method and its live sessions are rejected; the user is told
|
||||||
# why and pointed at the operator's contact. The demo account is never suspendable.
|
# why and pointed at the operator's contact. The demo account is never suspendable.
|
||||||
is_suspended: Mapped[bool] = mapped_column(
|
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
||||||
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
||||||
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
|
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
|
||||||
@@ -95,9 +89,7 @@ class OAuthToken(Base, UpdatedAtMixin):
|
|||||||
__tablename__ = "oauth_tokens"
|
__tablename__ = "oauth_tokens"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True)
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True
|
|
||||||
)
|
|
||||||
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
|
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
|
||||||
refresh_token_enc: Mapped[str | None] = mapped_column(String)
|
refresh_token_enc: Mapped[str | None] = mapped_column(String)
|
||||||
access_token: Mapped[str | None] = mapped_column(String)
|
access_token: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -115,9 +107,7 @@ class AuthToken(Base, TimestampMixin):
|
|||||||
__tablename__ = "auth_tokens"
|
__tablename__ = "auth_tokens"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
|
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
|
||||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -133,9 +123,7 @@ class Invite(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
# pending | approved | denied
|
# pending | approved | denied
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
|
||||||
String(16), default="pending", server_default="pending"
|
|
||||||
)
|
|
||||||
note: Mapped[str | None] = mapped_column(Text)
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
requested_at: Mapped[datetime] = mapped_column(
|
requested_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
@@ -189,9 +177,7 @@ class Channel(Base, TimestampMixin):
|
|||||||
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
||||||
backfill_done: Mapped[bool] = mapped_column(
|
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# True when this channel only entered the catalog because a live YouTube search
|
# True when this channel only entered the catalog because a live YouTube search
|
||||||
# surfaced one of its videos (nobody subscribes to it / it has no organic upload sync).
|
# surfaced one of its videos (nobody subscribes to it / it has no organic upload sync).
|
||||||
# Lets the Library hide search-only "noise" by default.
|
# Lets the Library hide search-only "noise" by default.
|
||||||
@@ -218,9 +204,7 @@ class Subscription(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),)
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
@@ -231,9 +215,7 @@ class Subscription(Base, TimestampMixin):
|
|||||||
# Per-user opt-in to full-history (deep) backfill of this channel. Default off so a
|
# Per-user opt-in to full-history (deep) backfill of this channel. Default off so a
|
||||||
# new user's unique channels don't trigger a big shared-quota burst; the deep
|
# new user's unique channels don't trigger a big shared-quota burst; the deep
|
||||||
# scheduler only picks up channels at least one user has requested.
|
# scheduler only picks up channels at least one user has requested.
|
||||||
deep_requested: Mapped[bool] = mapped_column(
|
deep_requested: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
||||||
@@ -255,9 +237,7 @@ class Video(Base, TimestampMixin):
|
|||||||
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it
|
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it
|
||||||
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed
|
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed
|
||||||
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
|
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||||
ForeignKey("channels.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
||||||
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -289,27 +269,19 @@ class Video(Base, TimestampMixin):
|
|||||||
# boolean btrees and replaced the useful ones with partial indexes over the target subset
|
# boolean btrees and replaced the useful ones with partial indexes over the target subset
|
||||||
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
|
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
|
||||||
# search_vector GIN.
|
# search_vector GIN.
|
||||||
is_short: Mapped[bool] = mapped_column(
|
is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||||
shorts_probed: Mapped[bool] = mapped_column(
|
shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# True when this video first entered the catalog via a live YouTube search (not an
|
# True when this video first entered the catalog via a live YouTube search (not an
|
||||||
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
||||||
# Library by default; left False by the organic ingest paths.
|
# Library by default; left False by the organic ingest paths.
|
||||||
via_search: Mapped[bool] = mapped_column(
|
via_search: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
||||||
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
||||||
# additionally PER-USER private: a via_explore video is only visible to users who
|
# additionally PER-USER private: a via_explore video is only visible to users who
|
||||||
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
||||||
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||||
via_explore: Mapped[bool] = mapped_column(
|
via_explore: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# none | live | upcoming | premiere | was_live
|
# none | live | upcoming | premiere | was_live
|
||||||
live_status: Mapped[str] = mapped_column(
|
live_status: Mapped[str] = mapped_column(
|
||||||
String(16), default="none", server_default="none", index=True
|
String(16), default="none", server_default="none", index=True
|
||||||
@@ -319,7 +291,9 @@ class Video(Base, TimestampMixin):
|
|||||||
# maintenance/validation job to decide whether a video is playable anywhere.
|
# maintenance/validation job to decide whether a video is playable anywhere.
|
||||||
embeddable: Mapped[bool | None] = mapped_column(Boolean)
|
embeddable: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private
|
privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private
|
||||||
upload_status: Mapped[str | None] = mapped_column(String(16)) # processed|failed|rejected|deleted
|
upload_status: Mapped[str | None] = mapped_column(
|
||||||
|
String(16)
|
||||||
|
) # processed|failed|rejected|deleted
|
||||||
# Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation
|
# Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation
|
||||||
# (least-recently-checked first); unavailable_since marks a video as currently
|
# (least-recently-checked first); unavailable_since marks a video as currently
|
||||||
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
||||||
@@ -327,12 +301,8 @@ class Video(Base, TimestampMixin):
|
|||||||
# last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial
|
# last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial
|
||||||
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
|
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
|
||||||
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
|
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
DateTime(timezone=True)
|
unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
)
|
|
||||||
unavailable_since: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True)
|
|
||||||
)
|
|
||||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
|
|
||||||
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
|
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
|
||||||
@@ -366,9 +336,7 @@ class Tag(Base, TimestampMixin):
|
|||||||
name: Mapped[str] = mapped_column(String(64))
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
color: Mapped[str | None] = mapped_column(String(16))
|
color: Mapped[str | None] = mapped_column(String(16))
|
||||||
# language | topic | other
|
# language | topic | other
|
||||||
category: Mapped[str] = mapped_column(
|
category: Mapped[str] = mapped_column(String(16), default="other", server_default="other")
|
||||||
String(16), default="other", server_default="other"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelTag(Base):
|
class ChannelTag(Base):
|
||||||
@@ -376,21 +344,15 @@ class ChannelTag(Base):
|
|||||||
regenerated freely; source='user' rows are never touched by the system."""
|
regenerated freely; source='user' rows are never touched by the system."""
|
||||||
|
|
||||||
__tablename__ = "channel_tags"
|
__tablename__ = "channel_tags"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),)
|
||||||
UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
tag_id: Mapped[int] = mapped_column(
|
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), index=True)
|
||||||
ForeignKey("tags.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
# NULL = system/auto link visible to everyone; set = a specific user's link.
|
# NULL = system/auto link visible to everyone; set = a specific user's link.
|
||||||
user_id: Mapped[int | None] = mapped_column(
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
|
source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
|
||||||
confidence: Mapped[float | None] = mapped_column(Float)
|
confidence: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
@@ -403,12 +365,8 @@ class VideoState(Base, UpdatedAtMixin):
|
|||||||
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),)
|
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||||
)
|
|
||||||
video_id: Mapped[str] = mapped_column(
|
|
||||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
|
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
|
||||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -433,12 +391,8 @@ class SearchFind(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),)
|
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||||
)
|
|
||||||
video_id: Mapped[str] = mapped_column(
|
|
||||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExploredChannel(Base, TimestampMixin):
|
class ExploredChannel(Base, TimestampMixin):
|
||||||
@@ -453,14 +407,10 @@ class ExploredChannel(Base, TimestampMixin):
|
|||||||
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
||||||
|
|
||||||
__tablename__ = "explored_channels"
|
__tablename__ = "explored_channels"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),)
|
||||||
UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
@@ -472,14 +422,10 @@ class BlockedChannel(Base, TimestampMixin):
|
|||||||
views — a personal "never show me this channel again" that doesn't affect other users."""
|
views — a personal "never show me this channel again" that doesn't affect other users."""
|
||||||
|
|
||||||
__tablename__ = "blocked_channels"
|
__tablename__ = "blocked_channels"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),)
|
||||||
UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
@@ -544,16 +490,12 @@ class AppState(Base):
|
|||||||
__tablename__ = "app_state"
|
__tablename__ = "app_state"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||||
sync_paused: Mapped[bool] = mapped_column(
|
sync_paused: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
|
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
|
||||||
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
|
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
|
||||||
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
|
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
|
||||||
# the raw token + URL are printed to the container logs on each startup.
|
# the raw token + URL are printed to the container logs on each startup.
|
||||||
configured: Mapped[bool] = mapped_column(
|
configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
|
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
@@ -599,14 +541,10 @@ class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
__tablename__ = "playlists"
|
__tablename__ = "playlists"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
source: Mapped[str] = mapped_column(
|
source: Mapped[str] = mapped_column(String(16), default="local", server_default="local")
|
||||||
String(16), default="local", server_default="local"
|
|
||||||
)
|
|
||||||
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
# True when the current items/name differ from `synced_fingerprint` (unpushed edits).
|
# True when the current items/name differ from `synced_fingerprint` (unpushed edits).
|
||||||
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
@@ -626,45 +564,37 @@ class PlaylistItem(Base):
|
|||||||
"""A video's membership in a playlist, with its order within that playlist."""
|
"""A video's membership in a playlist, with its order within that playlist."""
|
||||||
|
|
||||||
__tablename__ = "playlist_items"
|
__tablename__ = "playlist_items"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),)
|
||||||
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
playlist_id: Mapped[int] = mapped_column(
|
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"))
|
||||||
ForeignKey("playlists.id", ondelete="CASCADE")
|
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||||
)
|
|
||||||
video_id: Mapped[str] = mapped_column(
|
|
||||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
added_at: Mapped[datetime] = mapped_column(
|
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
|
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A per-user named snapshot of the feed's filter/sort state (a "smart view").
|
"""A per-user named snapshot of a module's filter/sort state (a "smart view").
|
||||||
|
|
||||||
`filters` is the serialized FeedFilters JSON the frontend applies with one click.
|
`source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's
|
||||||
`position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
|
serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar
|
||||||
applied automatically on load — at most one per user, enforced by the API on set."""
|
(drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most
|
||||||
|
one per (user, source), enforced by the API on set. The name is unique per (user, source)."""
|
||||||
|
|
||||||
__tablename__ = "saved_views"
|
__tablename__ = "saved_views"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(String(80))
|
name: Mapped[str] = mapped_column(String(80))
|
||||||
filters: Mapped[dict] = mapped_column(JSON)
|
filters: Mapped[dict] = mapped_column(JSON)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
is_default: Mapped[bool] = mapped_column(
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base):
|
class Notification(Base):
|
||||||
@@ -682,19 +612,13 @@ class Notification(Base):
|
|||||||
__tablename__ = "notifications"
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance"
|
type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance"
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
body: Mapped[str | None] = mapped_column(Text)
|
body: Mapped[str | None] = mapped_column(Text)
|
||||||
data: Mapped[dict | None] = mapped_column(JSON)
|
data: Mapped[dict | None] = mapped_column(JSON)
|
||||||
read: Mapped[bool] = mapped_column(
|
read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
|
||||||
Boolean, default=False, server_default="false", index=True
|
dismissed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
)
|
|
||||||
dismissed: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True
|
DateTime(timezone=True), server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
@@ -720,9 +644,7 @@ class Message(Base):
|
|||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
kind: Mapped[str] = mapped_column(
|
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
String(16), default="user", server_default="user"
|
|
||||||
)
|
|
||||||
# NULL for system messages (no human sender).
|
# NULL for system messages (no human sender).
|
||||||
sender_id: Mapped[int | None] = mapped_column(
|
sender_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
@@ -782,13 +704,9 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
container: Mapped[str | None] = mapped_column(String(16))
|
container: Mapped[str | None] = mapped_column(String(16))
|
||||||
vcodec: Mapped[str | None] = mapped_column(String(32))
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
acodec: Mapped[str | None] = mapped_column(String(32))
|
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
nfo_written: Mapped[bool] = mapped_column(
|
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||||
gc_notified: Mapped[bool] = mapped_column(
|
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
||||||
title: Mapped[str | None] = mapped_column(Text)
|
title: Mapped[str | None] = mapped_column(Text)
|
||||||
uploader: Mapped[str | None] = mapped_column(String(255))
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||||
@@ -824,9 +742,7 @@ class DownloadProfile(Base, TimestampMixin):
|
|||||||
) # NULL for a builtin preset
|
) # NULL for a builtin preset
|
||||||
name: Mapped[str] = mapped_column(String(80))
|
name: Mapped[str] = mapped_column(String(80))
|
||||||
spec: Mapped[dict] = mapped_column(JSON)
|
spec: Mapped[dict] = mapped_column(JSON)
|
||||||
is_builtin: Mapped[bool] = mapped_column(
|
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
@@ -851,18 +767,14 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
|
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
asset_id: Mapped[int | None] = mapped_column(
|
asset_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
||||||
)
|
)
|
||||||
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
source_ref: Mapped[str] = mapped_column(String(512))
|
source_ref: Mapped[str] = mapped_column(String(512))
|
||||||
# "download" (default) or "edit" (a per-user trim/crop derivative of another job).
|
# "download" (default) or "edit" (a per-user trim/crop derivative of another job).
|
||||||
job_kind: Mapped[str] = mapped_column(
|
job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
|
||||||
String(16), default="download", server_default="download"
|
|
||||||
)
|
|
||||||
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
||||||
source_job_id: Mapped[int | None] = mapped_column(
|
source_job_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
||||||
@@ -891,7 +803,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
eta_s: Mapped[int | None] = mapped_column(Integer)
|
eta_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
phase: Mapped[str | None] = mapped_column(String(32))
|
phase: Mapped[str | None] = mapped_column(String(32))
|
||||||
error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail)
|
error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail)
|
||||||
error_code: Mapped[str | None] = mapped_column(String(24)) # category, see downloads.errors.classify
|
error_code: Mapped[str | None] = mapped_column(
|
||||||
|
String(24)
|
||||||
|
) # category, see downloads.errors.classify
|
||||||
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
@@ -900,20 +814,14 @@ class DownloadShare(Base, TimestampMixin):
|
|||||||
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
||||||
|
|
||||||
__tablename__ = "download_shares"
|
__tablename__ = "download_shares"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),)
|
||||||
UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
job_id: Mapped[int] = mapped_column(
|
job_id: Mapped[int] = mapped_column(ForeignKey("download_jobs.id", ondelete="CASCADE"))
|
||||||
ForeignKey("download_jobs.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
from_user_id: Mapped[int] = mapped_column(
|
from_user_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
to_user_id: Mapped[int] = mapped_column(
|
to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadQuota(Base, UpdatedAtMixin):
|
class DownloadQuota(Base, UpdatedAtMixin):
|
||||||
@@ -929,9 +837,7 @@ class DownloadQuota(Base, UpdatedAtMixin):
|
|||||||
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
max_concurrent: Mapped[int] = mapped_column(Integer)
|
max_concurrent: Mapped[int] = mapped_column(Integer)
|
||||||
max_jobs: Mapped[int] = mapped_column(Integer)
|
max_jobs: Mapped[int] = mapped_column(Integer)
|
||||||
unlimited: Mapped[bool] = mapped_column(
|
unlimited: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadLink(Base, TimestampMixin):
|
class DownloadLink(Base, TimestampMixin):
|
||||||
@@ -950,12 +856,8 @@ class DownloadLink(Base, TimestampMixin):
|
|||||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
token: Mapped[str] = mapped_column(String(64), unique=True)
|
token: Mapped[str] = mapped_column(String(64), unique=True)
|
||||||
created_by: Mapped[int | None] = mapped_column(
|
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
)
|
|
||||||
allow_download: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
|
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
|
||||||
# password immediately invalidates any grants issued under the old one (instead of them living
|
# password immediately invalidates any grants issued under the old one (instead of them living
|
||||||
@@ -1042,10 +944,14 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
title: Mapped[str] = mapped_column(String(512))
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
summary: Mapped[str | None] = mapped_column(Text)
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
year: Mapped[int | None] = mapped_column(Integer)
|
year: Mapped[int | None] = mapped_column(Integer)
|
||||||
thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored)
|
thumb_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(512)
|
||||||
|
) # Plex thumb path (proxied, not stored)
|
||||||
art_key: Mapped[str | None] = mapped_column(String(512))
|
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
||||||
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Plex collections this show is in
|
collection_keys: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # Plex collections this show is in
|
||||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||||
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
||||||
@@ -1058,15 +964,39 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
people_text: Mapped[str | None] = mapped_column(
|
||||||
|
Text
|
||||||
|
) # cast+directors, folded into search_vector
|
||||||
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
||||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||||
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating)
|
imdb_rating: Mapped[float | None] = mapped_column(
|
||||||
|
Float
|
||||||
|
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||||
tagline: Mapped[str | None] = mapped_column(Text)
|
tagline: Mapped[str | None] = mapped_column(Text)
|
||||||
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
|
full_cast: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # ordered [{name, role, thumb}]
|
||||||
|
# --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive;
|
||||||
|
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
|
||||||
|
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||||
|
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
budget: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||||
|
crew: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # [{name, job, tmdb_id, thumb}]
|
||||||
|
studios: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # TMDB production companies [str]
|
||||||
|
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||||
|
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched,
|
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched,
|
||||||
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
|
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
|
||||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||||
@@ -1180,10 +1110,38 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
||||||
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||||
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating)
|
imdb_rating: Mapped[float | None] = mapped_column(
|
||||||
|
Float
|
||||||
|
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||||
tagline: Mapped[str | None] = mapped_column(Text)
|
tagline: Mapped[str | None] = mapped_column(Text)
|
||||||
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
|
full_cast: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # ordered [{name, role, thumb}]
|
||||||
|
# --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
|
||||||
|
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
|
||||||
|
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
|
||||||
|
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
|
||||||
|
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
|
||||||
|
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||||
|
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
|
||||||
|
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||||
|
crew: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # [{name, job, tmdb_id, thumb}]
|
||||||
|
studios: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # TMDB production companies [str]
|
||||||
|
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||||
|
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
|
||||||
|
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
backdrops: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # TMDB backdrop file_paths [str] (crossfade pool, S8)
|
||||||
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
|
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
|
||||||
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
||||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||||
@@ -1224,6 +1182,23 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
|
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
|
||||||
|
# TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person
|
||||||
|
# biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person.
|
||||||
|
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person
|
||||||
|
# page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
|
||||||
|
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
|
||||||
|
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
|
||||||
|
biography: Mapped[str | None] = mapped_column(Text)
|
||||||
|
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
|
||||||
|
deathday: Mapped[str | None] = mapped_column(String(10))
|
||||||
|
place_of_birth: Mapped[str | None] = mapped_column(Text)
|
||||||
|
known_for_department: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||||
|
known_for: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # [{tmdb_id, media_type, title}]
|
||||||
|
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
@@ -1258,9 +1233,7 @@ class PlexState(Base, UpdatedAtMixin):
|
|||||||
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
ForeignKey("users.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
item_id: Mapped[int] = mapped_column(
|
item_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
@@ -1270,9 +1243,7 @@ class PlexState(Base, UpdatedAtMixin):
|
|||||||
)
|
)
|
||||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
synced_to_plex: Mapped[bool] = mapped_column(
|
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
Boolean, default=False, server_default="false"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlexLink(Base, UpdatedAtMixin):
|
class PlexLink(Base, UpdatedAtMixin):
|
||||||
@@ -1327,9 +1298,7 @@ class PlexPlaylistItem(Base):
|
|||||||
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
playlist_id: Mapped[int] = mapped_column(
|
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
|
||||||
ForeignKey("plex_playlists.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
item_id: Mapped[int] = mapped_column(
|
item_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ survive reloads/devices and are produced server-side. First producer is the main
|
|||||||
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
|
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
|
||||||
table can't grow without bound for a heavy user.
|
table can't grow without bound for a heavy user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -37,9 +38,7 @@ def create_notification(
|
|||||||
transaction can pass commit=False and commit once themselves."""
|
transaction can pass commit=False and commit once themselves."""
|
||||||
if title is not None and len(title) > TITLE_MAX_LEN:
|
if title is not None and len(title) > TITLE_MAX_LEN:
|
||||||
title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis
|
title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis
|
||||||
notif = Notification(
|
notif = Notification(user_id=user_id, type=type, title=title, body=body, data=data)
|
||||||
user_id=user_id, type=type, title=title, body=body, data=data
|
|
||||||
)
|
|
||||||
db.add(notif)
|
db.add(notif)
|
||||||
if commit:
|
if commit:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -6,14 +6,32 @@ router. Pure functions — no DB, no request state."""
|
|||||||
from app.models import PlexItem, PlexShow, PlexState
|
from app.models import PlexItem, PlexShow, PlexState
|
||||||
|
|
||||||
|
|
||||||
|
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
||||||
|
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
||||||
|
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
||||||
|
# tried to use it, so gate it on the stored key and let the client fall back to the poster.
|
||||||
|
def _art_url(rating_key: str, art_key: str | None) -> str | None:
|
||||||
|
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
|
||||||
|
|
||||||
|
|
||||||
|
# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards
|
||||||
|
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve
|
||||||
|
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
|
||||||
|
def _card_summary(summary: str | None) -> str | None:
|
||||||
|
return (summary or "")[:300] or None
|
||||||
|
|
||||||
|
|
||||||
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
"type": "movie",
|
"type": "movie",
|
||||||
"title": it.title,
|
"title": it.title,
|
||||||
"year": it.year,
|
"year": it.year,
|
||||||
|
"rating": it.rating,
|
||||||
|
"summary": _card_summary(it.summary),
|
||||||
"duration_seconds": it.duration_s,
|
"duration_seconds": it.duration_s,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
|
"art": _art_url(it.rating_key, it.art_key),
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
@@ -26,7 +44,10 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
|
|||||||
"type": "show",
|
"type": "show",
|
||||||
"title": sh.title,
|
"title": sh.title,
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
|
"rating": sh.rating,
|
||||||
|
"summary": _card_summary(sh.summary),
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
|
"art": _art_url(sh.rating_key, sh.art_key),
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user