Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
909732b3c1 | ||
|
|
215f022fe3 | ||
|
|
6ea750a698 | ||
|
|
c5f121139b | ||
|
|
b04b2cbd62 | ||
|
|
3a5212cd8a | ||
|
|
b376373af5 | ||
|
|
8206384c91 | ||
|
|
e27656ee7e | ||
|
|
d73cdcee97 | ||
|
|
5ce292cd28 | ||
|
|
ad6ccbac05 | ||
|
|
531eb2eef1 | ||
|
|
2cf1b688d9 | ||
|
|
3b0e365e26 | ||
|
|
533571f572 | ||
|
|
4b6db47083 | ||
|
|
5229fd96be | ||
|
|
238e6f8a61 | ||
|
|
a35eece652 | ||
|
|
b703234c17 | ||
|
|
dbb4065e7b | ||
|
|
e82f87acb0 | ||
|
|
e9c60f72cd | ||
|
|
3a688fa0f5 | ||
|
|
d66174ed16 | ||
|
|
c85ae42b31 | ||
|
|
cb9a89682a | ||
|
|
b842e4e871 | ||
|
|
5082636f15 | ||
|
|
13b7577d9f | ||
|
|
fad656988a | ||
|
|
3021be5166 | ||
|
|
c288ba147b | ||
|
|
fe6308d844 | ||
|
|
24448e7c6f | ||
|
|
d13483a593 | ||
|
|
3bab2e7dc7 | ||
|
|
1b1e3cdcf2 | ||
|
|
27c2fcbd2e | ||
|
|
911527a548 | ||
|
|
8fd91b7b15 | ||
|
|
45c0ee7d58 | ||
|
|
4b1f6d7ec2 | ||
|
|
51bf780100 | ||
|
|
3ada34073b | ||
|
|
d74f2a9ca1 | ||
|
|
a8dddc8c24 | ||
|
|
460bdec406 | ||
|
|
66181d1394 | ||
|
|
0a1fef28ac | ||
|
|
fe572d9207 | ||
|
|
c8247c0dde | ||
|
|
722c37a6d0 | ||
|
|
5ba5679239 | ||
|
|
926e5e9899 | ||
|
|
9251918e08 | ||
|
|
2b6d968833 | ||
|
|
95e6b2c3d9 | ||
|
|
80c55fbf1b | ||
|
|
011f269152 | ||
|
|
dcb244dd04 | ||
|
|
08a9f4e255 | ||
|
|
1673134c36 | ||
|
|
27427eef32 | ||
|
|
a248ca6c13 | ||
|
|
620f84dba4 | ||
|
|
2efa7d5a75 | ||
|
|
827df7494c | ||
|
|
86712b6b0e | ||
|
|
90567718b2 | ||
|
|
c31d7f9d65 | ||
|
|
db56f25387 | ||
|
|
4f58d15d3f | ||
|
|
2c66ce1cf4 | ||
|
|
ce45445d17 | ||
|
|
383d394e32 | ||
|
|
06e8291896 | ||
|
|
db6b60029f | ||
|
|
02134799cc | ||
|
|
c8bc843517 | ||
|
|
759a7536b9 | ||
|
|
b2d34e6e6c | ||
|
|
c335d54929 | ||
|
|
7188ad7d71 | ||
|
|
ebbae6794c | ||
|
|
c9baf68b16 | ||
|
|
5bb101e493 | ||
|
|
54a6bbf07f | ||
|
|
8b2260c9a8 | ||
|
|
815083bcc4 | ||
|
|
96509fe356 | ||
|
|
132fb56428 | ||
|
|
f6bce23a94 | ||
|
|
fb8fb980e5 | ||
|
|
6707550513 | ||
|
|
078194731b | ||
|
|
1c4f0042d2 | ||
|
|
d22c64f4dd | ||
|
|
85aa917b38 | ||
|
|
1a1665b8ec | ||
|
|
bf99444ded | ||
|
|
07e0990ecf | ||
|
|
e2ed2bf874 | ||
|
|
b9eb8458b3 | ||
|
|
891507dc75 | ||
|
|
e6894ac0fc | ||
|
|
663a7e8012 | ||
|
|
93192dadd8 | ||
|
|
5b48018e92 | ||
|
|
8312c6d592 | ||
|
|
e1d325b942 | ||
|
|
977ba244fa | ||
|
|
89d18b46a6 | ||
|
|
c27cfdcb9c | ||
|
|
a867145bcb | ||
|
|
4d4eea2c54 | ||
|
|
c489b60c8b | ||
|
|
b4bf71ea64 | ||
|
|
72df11f2c0 | ||
|
|
0a16bc40ae | ||
|
|
520b0afb90 | ||
|
|
e301632765 | ||
|
|
50157cc2fd | ||
|
|
f221c1962a | ||
|
|
d466ae915b | ||
|
|
14598ec320 | ||
|
|
b16cf7668c | ||
|
|
3438aeb695 | ||
|
|
c003a48244 | ||
|
|
8a2334e198 | ||
|
|
1f010328df | ||
|
|
77638f7a0b | ||
|
|
2cff4ad954 | ||
|
|
8776586ca3 | ||
|
|
92a17aba83 | ||
|
|
fc53bc03bc | ||
|
|
6df257d7df | ||
|
|
5572499b56 | ||
|
|
76738f8ea9 | ||
|
|
568bf93510 | ||
|
|
942154cf49 | ||
|
|
3eb87e7108 | ||
|
|
851b97b09d | ||
|
|
ee94d1949a | ||
|
|
4f076b5d45 | ||
|
|
ece74a42ab | ||
|
|
600d5c39da | ||
|
|
4c7f73af9d | ||
|
|
5f42ff61c8 | ||
|
|
6dd82ef5e2 | ||
|
|
75f4efde98 | ||
|
|
c0059a0c4b | ||
|
|
a29cadf03e | ||
|
|
83886494ce | ||
|
|
4eaa55385f | ||
|
|
7541b1b016 | ||
|
|
24ad17fc7d | ||
|
|
48bd038c5f | ||
|
|
0fce732497 | ||
|
|
7e94da72b9 | ||
|
|
fadb4f17a9 | ||
|
|
57a81c5643 | ||
|
|
dae0b80f6a | ||
|
|
75093458c9 | ||
|
|
5db6dc8ee6 | ||
|
|
45a9ceae25 | ||
|
|
5249c41edc | ||
|
|
c3664826fe | ||
|
|
c9b8fe15a3 | ||
|
|
1044eaf8a9 | ||
|
|
ca949d22de | ||
|
|
20178f0ec4 | ||
|
|
49a9319f84 | ||
|
|
70d56ab939 | ||
|
|
3865689c26 | ||
|
|
c32429ba69 | ||
|
|
a504550196 | ||
|
|
5095047c81 | ||
|
|
4e70f168b1 | ||
|
|
7f17f488ab | ||
|
|
d1704a5519 | ||
|
|
121ebeebc1 | ||
|
|
ee5290ba01 | ||
|
|
dd6a666ad1 | ||
|
|
392099770a | ||
|
|
2aa3ac6e85 | ||
|
|
891dc628f3 | ||
|
|
0a35f2a3b1 | ||
|
|
d25a132495 | ||
|
|
61d3a1a189 | ||
|
|
d482a5e8e2 | ||
|
|
2064373ac2 | ||
|
|
0cda5b6dd4 | ||
|
|
f3c94641a7 | ||
|
|
59b27467ab | ||
|
|
02feaa9a1c | ||
|
|
c1058f1d5a | ||
|
|
23f16b69df | ||
|
|
c813814c3c | ||
|
|
ed6facb703 | ||
|
|
d44d8d6dda | ||
|
|
b20e9a3947 | ||
|
|
6073a006b8 | ||
|
|
dba4758c59 | ||
|
|
68cfc7e25d | ||
|
|
00886d92fe | ||
|
|
e7d2eb8cf6 | ||
|
|
e886890d6e | ||
|
|
5561d9a379 | ||
|
|
2768ab5bdd | ||
|
|
8c79590165 | ||
|
|
eef3ac9c5c | ||
|
|
4ba7c966d7 | ||
|
|
513dd8175a | ||
|
|
c4298b0a73 | ||
|
|
5ca2a0e6ee | ||
|
|
5257d57196 | ||
|
|
58ad95263c | ||
|
|
96ad1ef306 | ||
|
|
d1bbe4524c | ||
|
|
eecc195b7e | ||
|
|
2f901ad643 | ||
|
|
1f357b0c21 | ||
|
|
fb962682eb | ||
|
|
4e71723a07 | ||
|
|
4aaaaa7860 | ||
|
|
c73d8be094 | ||
|
|
30545d0d88 | ||
|
|
ac702192c9 | ||
|
|
24c47d12bd | ||
|
|
ab76166878 | ||
|
|
a42f346a32 | ||
|
|
f2fe0fc0df | ||
|
|
ca707fd390 | ||
|
|
2eb20ca227 | ||
|
|
adaa7dc23c | ||
|
|
91f6a77fa3 | ||
|
|
36653886d5 | ||
|
|
7e681e112e | ||
|
|
3642397f63 | ||
|
|
6d8b7be6d8 | ||
|
|
cca3315836 | ||
|
|
7f08c510bf | ||
|
|
a499f00209 | ||
|
|
c2bf8fb7f6 | ||
|
|
11d35910fd | ||
|
|
27f48b7a8b | ||
|
|
bc856ab2af | ||
|
|
0dfb4a984b | ||
|
|
5bcd1d8d8a | ||
|
|
f49789665e | ||
|
|
0e077b84c8 | ||
|
|
ce8ccfc3c0 | ||
|
|
33dec56e45 |
+11
-9
@@ -28,7 +28,7 @@ ALLOWED_EMAILS=
|
|||||||
# Admin emails (subset of the above) get the admin role.
|
# Admin emails (subset of the above) get the admin role.
|
||||||
ADMIN_EMAILS=
|
ADMIN_EMAILS=
|
||||||
|
|
||||||
# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
|
# Logging. LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
|
||||||
# the worker/download child read; the API's running level is the admin Configuration page's
|
# the worker/download child read; the API's running level is the admin Configuration page's
|
||||||
# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in
|
# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in
|
||||||
# any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
|
# any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
|
||||||
@@ -68,18 +68,20 @@ SMTP_FROM=
|
|||||||
|
|
||||||
# --- Scheduler ---
|
# --- Scheduler ---
|
||||||
# The background scheduler (subscription sync, backfill, enrichment) runs inside the app.
|
# The background scheduler (subscription sync, backfill, enrichment) runs inside the app.
|
||||||
# DATABASE_URL is set for you by docker-compose to the bundled `db` service. If you ever run
|
# DATABASE_URL is set for you by docker-compose to the bundled `db` service. Run ONE app instance
|
||||||
# more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one
|
# per database: a starting instance closes every run still open as interrupted (it assumes the
|
||||||
# of them and false on the rest, to avoid double quota use and write races.
|
# previous process is gone), and "is this job already running" is held in the process. A second
|
||||||
|
# instance would cut the first one's runs short and could run the same job twice.
|
||||||
|
# SCHEDULER_ENABLED=false keeps the recurring jobs off; "run now" still works.
|
||||||
SCHEDULER_ENABLED=true
|
SCHEDULER_ENABLED=true
|
||||||
|
|
||||||
# --- Download center ---
|
# --- Files library & downloads ---
|
||||||
# The Download Center adds a `worker` container (runs the yt-dlp/ffmpeg job loop) and a small
|
# Downloading adds a `worker` container (runs the yt-dlp/ffmpeg job loop) and a small
|
||||||
# `bgutil-pot` sidecar (mints YouTube tokens) — both come up automatically with docker compose.
|
# `bgutil-pot` sidecar (mints YouTube tokens) — both come up automatically with docker compose.
|
||||||
# Downloaded media defaults to a Docker-managed named volume. Set DOWNLOAD_HOST_PATH to a host
|
# The Files library defaults to a Docker-managed named volume. Set FILES_HOST_PATH to a host
|
||||||
# directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there.
|
# directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there.
|
||||||
# The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 <dir>.
|
# The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 <dir>.
|
||||||
# DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
# FILES_HOST_PATH=/mnt/media/youtube
|
||||||
|
|
||||||
# --- Plex integration (optional) ---
|
# --- Plex integration (optional) ---
|
||||||
# The optional Plex module browses/plays your Plex library, playing the LOCAL physical file
|
# The optional Plex module browses/plays your Plex library, playing the LOCAL physical file
|
||||||
@@ -89,6 +91,6 @@ SCHEDULER_ENABLED=true
|
|||||||
# to /plex-media inside the container (the local side of the admin "path map", whose default is
|
# to /plex-media inside the container (the local side of the admin "path map", whose default is
|
||||||
# /data=/plex-media). Leave unset if you don't use Plex.
|
# /data=/plex-media). Leave unset if you don't use Plex.
|
||||||
# PLEX_MEDIA_HOST_PATH=/mnt/media
|
# PLEX_MEDIA_HOST_PATH=/mnt/media
|
||||||
# Optional: scratch dir for on-the-fly HLS remux segments. Defaults to DOWNLOAD_ROOT; point it at
|
# Optional: scratch dir for on-the-fly HLS remux segments. Defaults to FILES_ROOT; point it at
|
||||||
# fast local storage if your download volume is slow.
|
# fast local storage if your download volume is slow.
|
||||||
# PLEX_HLS_DIR=/var/tmp/plex-hls
|
# PLEX_HLS_DIR=/var/tmp/plex-hls
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
# whole-repo reformatting does not bury the real authorship of each line.
|
# whole-repo reformatting does not bury the real authorship of each line.
|
||||||
# Configure once per clone: git config blame.ignoreRevsFile .git-blame-ignore-revs
|
# Configure once per clone: git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
#
|
#
|
||||||
# Prettier one-time repo-wide format pass (R2 S1b, 2026-07-21)
|
# Prettier one-time repo-wide format pass (2026-07-21)
|
||||||
51c36c2071728d2cd7ae580b7bfdf21f6867a347
|
51c36c2071728d2cd7ae580b7bfdf21f6867a347
|
||||||
|
|
||||||
# ruff format one-time backend + scripts pass (line-length 100, 2026-08-23)
|
# ruff format one-time backend + scripts pass (line-length 100, 2026-08-23)
|
||||||
b1832afd180866b922521148603db447020c57a4
|
b1832afd180866b922521148603db447020c57a4
|
||||||
|
|
||||||
|
# Plan-code purge from comments, one-time (2026-10-04)
|
||||||
|
4aaaaa786017278460e0b4f94cc0d3374e0e37e2
|
||||||
|
|||||||
+3
-2
@@ -29,9 +29,9 @@ frontend/e2e/.auth/
|
|||||||
*.sql.gz
|
*.sql.gz
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
# Download center: locally downloaded media (the DOWNLOAD_ROOT bind mount at the repo root).
|
# Download center: locally downloaded media (the FILES_ROOT bind mount at the repo root).
|
||||||
# Anchored with a leading slash so it does NOT also ignore the backend/app/downloads package.
|
# Anchored with a leading slash so it does NOT also ignore the backend/app/downloads package.
|
||||||
/downloads/
|
/files/
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -42,3 +42,4 @@ Thumbs.db
|
|||||||
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane
|
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane
|
||||||
# reads or regenerates them, so a committed one is stale from the commit that adds it.
|
# reads or regenerates them, so a committed one is stale from the commit that adds it.
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
frontend/e2e/.speed/
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# The CVE gate's allowlist (E32). The publish gate scans the built image and the committed tree with
|
# The CVE gate's allowlist. The publish gate scans the built image and the committed tree with
|
||||||
# Trivy and fails on any HIGH or CRITICAL finding that has a fix available. A finding that cannot be
|
# Trivy and fails on any HIGH or CRITICAL finding that has a fix available. A finding that cannot be
|
||||||
# fixed yet is let through ONLY by an entry here, and every entry carries both:
|
# fixed yet is let through ONLY by an entry here, and every entry carries both:
|
||||||
# statement — why it is acceptable for now (what is exposed, what is not)
|
# statement — why it is acceptable for now (what is exposed, what is not)
|
||||||
|
|||||||
+156
@@ -7,6 +7,162 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.108.1 — 2026-10-20
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Development: the backend is split into four units (downloads, reader, catalog, Plex), each owning its tables. A contract test holds what each one reads and writes, and that every query names whose rows it asks for.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Watch later: saving the first two videos at once no longer makes two lists. An existing second list is merged into the first, its videos and its comfort-shelf pin with it.
|
||||||
|
- Playback: the first resume point of a video or a Plex item no longer fails when it is saved together with the watched mark, a whole season marked at once, or a Plex sync.
|
||||||
|
|
||||||
|
## 0.108.0 — 2026-10-18
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Activity: one admin page for background runs, admin actions, the server log and quota, with one time range, account and search. A run opens to its outcome and its log lines, an admin action to its request's lines; old audit and log links open their tab.
|
||||||
|
- Scheduler: each job shows its last three runs (a run cut short by a restart is marked) and a button opens all of them; turning on a channel's full history is now a run of its own, listed under Ad-hoc runs.
|
||||||
|
- Configuration: how long the run history is kept (Activity › Run history, 180 days by default). Stats keeps your own numbers; the system quota view moved to Activity › Quota.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Configuration: saving a setting with the value already in force now keeps it, instead of dropping the change.
|
||||||
|
- Configuration: the unused SponsorBlock default setting is gone.
|
||||||
|
|
||||||
|
## 0.107.3 — 2026-10-17
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Development: the end-to-end suite runs the WCAG A and AA accessibility checks over every page and the player, and every shell script is linted in the gate.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Readability: muted text in the light themes is a shade darker, and the item count, the labels and the selected Files folder sit on a solid tint, so text on the frosted-glass bars stays readable (4.5:1) in every scheme.
|
||||||
|
|
||||||
|
## 0.107.2 — 2026-10-17
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Development: the gate script can list its checks (scripts/check.sh --list) and run just one (--only), and the backend tests run on every CPU core, each worker with its own database.
|
||||||
|
|
||||||
|
## 0.107.1 — 2026-10-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists.
|
||||||
|
|
||||||
|
## 0.107.0 — 2026-10-15
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- API tokens: Settings › API tokens gives one machine of yours a key to the Files folders you pick — or, in one click, to a Hub folder for swapping files with it. Signing out your other sessions or changing your password revokes every token.
|
||||||
|
- Share by link: a link can now open a whole folder of yours — its page lists what is in it and plays a video above the list.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Uploads: a finished upload is announced once, and shows in Files at once.
|
||||||
|
|
||||||
|
## 0.106.2 — 2026-10-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Pictures: a thumbnail, poster or channel picture that fails to load is retried, and meanwhile the card shows its empty frame instead of a broken-image mark.
|
||||||
|
|
||||||
|
## 0.106.1 — 2026-10-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plex player: clicking outside the Settings panel closes the panel again instead of closing the player.
|
||||||
|
|
||||||
|
## 0.106.0 — 2026-10-13
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Files: one library for everything you keep — downloads, clips and your own uploads of any file — in a folder per site plus folders of your own, each keeping its files as long as you choose. Documents, pictures and text open in a viewer.
|
||||||
|
- Downloads: paste a link anywhere on a page to download it, and the tray shows what is running and what is done. The Downloads page is gone: your files are in Files, and your download settings are under Settings › Downloads.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Self-hosting: the library is now mounted at /files (FILES_HOST_PATH). Old settings keep working; if you used the default volume, copy it over once — see the upgrade notes.
|
||||||
|
|
||||||
|
## 0.105.0 — 2026-10-10
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Clips: mark a range of what you're watching — a YouTube video or a Plex item — set its ends to the tenth, crop it to a frame drawn on the picture, and cut it straight into your downloads.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Downloads: a download, a clip or an editor clip that doesn't fit in the storage you have left says so, and is never stored over your cap; YouTube downloads reach their format's height again.
|
||||||
|
- Player: focus rings, the chosen track or tab, sliders and the progress bar's head are amber on the dark player in every theme, so they show in a light theme too.
|
||||||
|
|
||||||
|
## 0.104.0 — 2026-10-10
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- YouTube: a card's menu filters the feed to its channel and back, and the player gives the video more room — its side panel shuts on demand.
|
||||||
|
- Plex: a card's picture plays it and its title opens its page; a show card puts on the episode it would continue or start, and an episode links up to its show.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plex: summaries written as HTML read as clean text, and a Surprise draw stays put until you reshuffle. A video marked watched in the player stays out of an Unwatched feed. Plus smaller keyboard and focus fixes.
|
||||||
|
|
||||||
|
## 0.103.0 — 2026-10-08
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Plex: “Add to collection…” in a movie's or show's menu ticks the title into or out of your collections, or names a new one with it inside — one step instead of a trip to the Collections page.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plex player: subtitles rise above the control bar while it shows and slide back when it hides, so the bar never covers them — on an iPad too.
|
||||||
|
- iPad: the header search fits the touch screen, tooltips no longer stick, the Plex player page no longer scrolls or shows a blue band, and the fullscreen button stays hidden where the browser cannot really go fullscreen.
|
||||||
|
- The playlist, collection and tag menus say when their list is loading or failed to load (with a retry) instead of showing it as empty, and work from the keyboard. Plus smaller fixes in the YouTube player's title line.
|
||||||
|
|
||||||
|
## 0.102.0 — 2026-10-08
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream.
|
||||||
|
|
||||||
|
## 0.101.0 — 2026-10-06
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- A shared link plays on any phone, iPhone and iPad included: downloads, clips and uploads are kept in a format every phone plays, and an upload in another format is converted after it arrives.
|
||||||
|
- A shared link opens much faster: its page no longer loads the whole app.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The YouTube player keeps its size on a short window and when maximised — before, it could shrink to a thin strip. Plus smaller fixes around uploads and clips.
|
||||||
|
|
||||||
|
## 0.100.0 — 2026-10-05
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Direct messages are gone: the Messages page, the chat dock and their encryption keys were removed with their stored messages, and the privacy policy no longer describes end-to-end encrypted chat.
|
||||||
|
- The shared demo account is gone, with its sign-in entry and its whitelist.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Sign-in security without email: when a Google sign-in takes over an account someone else registered first, or makes it an admin through the admin list, its password is cleared and its sessions end. The owner sets a new one in Settings.
|
||||||
|
- Pressing Back onto a page that no longer exists opens the feed instead of breaking the app.
|
||||||
|
- Behind the scenes: tidier checks and comments across the code.
|
||||||
|
|
||||||
|
## 0.99.2 — 2026-10-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Settings: the download worker's concurrency and the log level now take effect within seconds — before, the worker ignored both until it was rebuilt.
|
||||||
|
- The YouTube quota history no longer grows forever: the daily cleanup prunes it after a year (Configuration → Quota → Quota history kept).
|
||||||
|
- The live-search button no longer says a search spends the API quota: with the default website source the search itself is free. It names the cost when the official API is selected.
|
||||||
|
|
||||||
## 0.99.1 — 2026-10-04
|
## 0.99.1 — 2026-10-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ be developed that way. This file is the rules of the road: read it before you to
|
|||||||
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
||||||
worse than no rule — if you change how the code works, change this file in the same commit.
|
worse than no rule — if you change how the code works, change this file in the same commit.
|
||||||
|
|
||||||
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a files library with downloads). It is a
|
||||||
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
||||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
||||||
router-free navigation provider.
|
router-free navigation provider.
|
||||||
|
|
||||||
## The gate — never merge or ship red
|
## The gate — never merge or ship red
|
||||||
|
|
||||||
The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
|
The local gate is **ruff (`check` + `format --check`) + shellcheck + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
|
||||||
eslint + prettier --check + knip (both modes) + vitest + vite build + pytest + pip-audit + npm audit**, then a coverage report (no threshold). It runs
|
eslint + prettier --check + knip + vitest + vite build + pytest + pip-audit + npm audit**. It runs
|
||||||
before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
|
before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
|
||||||
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
|
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
|
||||||
green **before** any merge, release, or handing work to review.
|
green **before** any merge, release, or handing work to review.
|
||||||
|
|
||||||
- Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's `siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate.
|
- Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's `siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate.
|
||||||
- **There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it.
|
- **There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it.
|
||||||
- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every file and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: a new code file or any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
|
- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every scope and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
|
||||||
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`.
|
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` (no burn-down mandate, but none was ever triaged: a `reportOptional*` entry in code you touch may be a real bug). A run that fixed some rewrites it — commit that.
|
||||||
- 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
|
||||||
@@ -55,17 +55,26 @@ how it gets built. Concretely:
|
|||||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
- **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.
|
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
|
- **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, and run DESIGN.md's four-step Conformance check.
|
bugs; do a real visual check of any UI change, and run DESIGN.md's Conformance check (a copy-only
|
||||||
|
change runs its step 4 alone: every state the new text renders in, in both languages).
|
||||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
- **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.
|
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/`,
|
||||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
`downloads/`, `reader/`, `discover/`, `catalog/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`, `jobruns`,
|
||||||
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`, `collation`,
|
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
|
||||||
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||||
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
|
The domain is four **units**, each owning its tables: Capture (`downloads/`), Reader (`reader/`, `discover/`, the playlist
|
||||||
|
sync), Catalog (`catalog/`, the `sync/` ingests) and Plex (`plex/`). A unit's route does no database work: it reads through
|
||||||
|
the unit's `queries`, writes through its `service` (which flushes) and commits. A unit's code reads another unit's tables
|
||||||
|
only along a declared edge and writes them only through that unit's service; core reaches a unit's tables through its
|
||||||
|
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
||||||
|
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
||||||
|
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
||||||
|
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
|
||||||
|
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
|
||||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||||
@@ -80,39 +89,42 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
|
|
||||||
## Canonical patterns — when you have a choice, pick THIS
|
## Canonical patterns — when you have a choice, pick THIS
|
||||||
|
|
||||||
| Area | Use | Not |
|
| Area | Use | Not |
|
||||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||||
| Request body | a Pydantic model | `payload: dict` |
|
| Request body | a Pydantic model | `payload: dict` |
|
||||||
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
|
||||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
|
||||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
||||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||||
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||||
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
||||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
|
||||||
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||||
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
|
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
||||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
|
||||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
||||||
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
|
||||||
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
|
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
|
||||||
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
|
||||||
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
|
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
|
||||||
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
|
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
|
||||||
|
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
|
||||||
|
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), a box or font size (the spacing scale or rem,
|
||||||
|
never a px-frozen `w-[Npx]` / `text-[Npx]`: the text-size slider scales the root font), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
||||||
|
|
||||||
## House rules that bite (learned from real bugs)
|
## House rules that bite (learned from real bugs)
|
||||||
|
|
||||||
@@ -125,7 +137,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
"no results". Reads degrade honestly; writes are conditional (`UPDATE … WHERE status='running'`).
|
"no results". Reads degrade honestly; writes are conditional (`UPDATE … WHERE status='running'`).
|
||||||
- **i18n parity.** HU/EN, zero key drift. Every user-facing string is translated.
|
- **i18n parity.** HU/EN, zero key drift. Every user-facing string is translated.
|
||||||
- **Comment the "why", keep it current.** This codebase uses comments as its architecture doc; a stale
|
- **Comment the "why", keep it current.** This codebase uses comments as its architecture doc; a stale
|
||||||
"why" is a liability, so update it when the behavior changes.
|
"why" is a liability, so update it when the behavior changes. Say the why itself, never the plan
|
||||||
|
step that produced it: no epic, sprint or review-round ids in the code (`planCodes.contract.test.ts`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -39,24 +39,30 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
|
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
|
||||||
# `chown -R /app` would rewrite every file into a duplicate layer). /downloads must be appuser-owned
|
# `chown -R /app` would rewrite every file into a duplicate layer). /files must be appuser-owned
|
||||||
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
|
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
|
||||||
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
|
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
|
||||||
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
||||||
RUN adduser --disabled-password --gecos "" appuser \
|
RUN adduser --disabled-password --gecos "" appuser \
|
||||||
&& mkdir -p /downloads \
|
&& mkdir -p /files \
|
||||||
&& chown appuser /app /downloads
|
&& chown appuser /app /files
|
||||||
|
|
||||||
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
|
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode.
|
||||||
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
||||||
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
|
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
|
||||||
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
|
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
|
||||||
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
|
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
|
||||||
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||||
# `apt-get upgrade` (E32): the base image is a snapshot, and the Debian security fixes released since it
|
# `apt-get upgrade`: the base image is a snapshot, and the Debian security fixes released since it
|
||||||
# was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it.
|
# was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it.
|
||||||
|
# APT_REFRESH: the upgrade only runs when this layer is rebuilt, and a build cache keeps it for as long
|
||||||
|
# as nothing above it changes — a publish shipped an image whose curl and librsvg were a release behind
|
||||||
|
# fixes already in the archive. The release build passes the day here, so the layer is rebuilt at
|
||||||
|
# least daily; empty (the default, a self-hosted build) leaves the cache alone.
|
||||||
ARG DENO_VERSION=v2.9.1
|
ARG DENO_VERSION=v2.9.1
|
||||||
RUN apt-get update \
|
ARG APT_REFRESH=
|
||||||
|
RUN echo "apt refresh: ${APT_REFRESH:-cached}" \
|
||||||
|
&& apt-get update \
|
||||||
&& apt-get upgrade -y --no-install-recommends \
|
&& apt-get upgrade -y --no-install-recommends \
|
||||||
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
|
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
|
||||||
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
||||||
@@ -81,7 +87,7 @@ RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nCo
|
|||||||
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
||||||
# byte-reproducible and the exact versions are the ones the gate's pip-audit scanned — not whatever
|
# byte-reproducible and the exact versions are the ones the gate's pip-audit scanned — not whatever
|
||||||
# floats up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
# floats up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
||||||
# pip itself is removed once it has installed the lock (E32): nothing runs it at runtime, and it carries
|
# pip itself is removed once it has installed the lock: nothing runs it at runtime, and it carries
|
||||||
# its own VENDORED copies of urllib3, msgpack and setuptools, which Trivy reports as the image's own —
|
# its own VENDORED copies of urllib3, msgpack and setuptools, which Trivy reports as the image's own —
|
||||||
# an attack surface and a CVE stream for a tool the running app never calls.
|
# an attack surface and a CVE stream for a tool the running app never calls.
|
||||||
COPY backend/requirements.lock .
|
COPY backend/requirements.lock .
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ blocker and SponsorBlock keep working.
|
|||||||
tags to slice the feed by.
|
tags to slice the feed by.
|
||||||
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
||||||
- **In-app player** with resume, plus keyboard/scroll controls.
|
- **In-app player** with resume, plus keyboard/scroll controls.
|
||||||
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
- **Files** — one library for everything you keep: paste a link anywhere in the app to download it
|
||||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
|
with yt-dlp (format presets, per-user storage quota, the running downloads in a tray), upload your
|
||||||
to your device, share with another user, or hand out a public watch link.
|
own files, sort them into folders, trim / crop / split & join videos in a built-in editor, play them
|
||||||
|
where you left off, then save to your device, share with another user, or hand out a public watch
|
||||||
|
link that plays on any phone.
|
||||||
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
||||||
movies and shows alongside your feed, with watch state kept in sync.
|
movies and shows alongside your feed, with watch state kept in sync.
|
||||||
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||||
|
|||||||
@@ -437,14 +437,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 12,
|
|
||||||
"endColumn": 13,
|
|
||||||
"lineCount": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportAttributeAccessIssue",
|
"code": "reportAttributeAccessIssue",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -638,41 +630,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/admin_logs.py": [
|
|
||||||
{
|
|
||||||
"code": "reportReturnType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 15,
|
|
||||||
"endColumn": 47,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/audit.py": [
|
"./app/routes/audit.py": [
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 17,
|
|
||||||
"endColumn": 98,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportAssignmentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 17,
|
|
||||||
"endColumn": 98,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 22,
|
|
||||||
"endColumn": 97,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportAttributeAccessIssue",
|
"code": "reportAttributeAccessIssue",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -682,48 +640,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/channels.py": [
|
|
||||||
{
|
|
||||||
"code": "reportReturnType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 11,
|
|
||||||
"endColumn": 11,
|
|
||||||
"lineCount": 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 15,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 49,
|
|
||||||
"endColumn": 56,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportAttributeAccessIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 38,
|
|
||||||
"endColumn": 44,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportAttributeAccessIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 33,
|
|
||||||
"endColumn": 39,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/discover.py": [
|
"./app/routes/discover.py": [
|
||||||
{
|
{
|
||||||
"code": "reportInvalidTypeForm",
|
"code": "reportInvalidTypeForm",
|
||||||
@@ -734,415 +650,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/downloads.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 29,
|
|
||||||
"endColumn": 33,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 37,
|
|
||||||
"endColumn": 47,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 29,
|
|
||||||
"endColumn": 33,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 40,
|
|
||||||
"endColumn": 50,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 17,
|
|
||||||
"endColumn": 19,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 82,
|
|
||||||
"endColumn": 84,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 87,
|
|
||||||
"endColumn": 89,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 30,
|
|
||||||
"endColumn": 32,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 49,
|
|
||||||
"endColumn": 54,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 29,
|
|
||||||
"endColumn": 33,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 40,
|
|
||||||
"endColumn": 50,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/messages.py": [
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 11,
|
|
||||||
"endColumn": 43,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 23,
|
|
||||||
"endColumn": 27,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 13,
|
|
||||||
"endColumn": 5,
|
|
||||||
"lineCount": 7
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 15,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportReturnType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 15,
|
|
||||||
"endColumn": 72,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 30,
|
|
||||||
"endColumn": 48,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 41,
|
|
||||||
"endColumn": 44,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 36,
|
|
||||||
"endColumn": 38,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 71,
|
|
||||||
"endColumn": 73,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 30,
|
|
||||||
"endColumn": 39,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalMemberAccess",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 27,
|
|
||||||
"endColumn": 29,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/playlists.py": [
|
|
||||||
{
|
|
||||||
"code": "reportOptionalOperand",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 9,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalOperand",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 9,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 13,
|
|
||||||
"endColumn": 5,
|
|
||||||
"lineCount": 7
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 15,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 16,
|
|
||||||
"endColumn": 5,
|
|
||||||
"lineCount": 11
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 15,
|
|
||||||
"lineCount": 9
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 13,
|
|
||||||
"endColumn": 5,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 15,
|
|
||||||
"lineCount": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 12,
|
|
||||||
"endColumn": 31,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 23,
|
|
||||||
"endColumn": 27,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 12,
|
|
||||||
"endColumn": 34,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 26,
|
|
||||||
"endColumn": 30,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 12,
|
|
||||||
"endColumn": 28,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 23,
|
|
||||||
"endColumn": 27,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/plex/browse.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 26,
|
|
||||||
"endColumn": 61,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 26,
|
|
||||||
"endColumn": 61,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 11,
|
|
||||||
"endColumn": 13,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 25,
|
|
||||||
"endColumn": 27,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 11,
|
|
||||||
"endColumn": 13,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 25,
|
|
||||||
"endColumn": 27,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 15,
|
|
||||||
"endColumn": 17,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 28,
|
|
||||||
"endColumn": 30,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 15,
|
|
||||||
"endColumn": 17,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportOptionalSubscript",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 28,
|
|
||||||
"endColumn": 30,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/plex/collections.py": [
|
"./app/routes/plex/collections.py": [
|
||||||
{
|
|
||||||
"code": "reportAssignmentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 11,
|
|
||||||
"endColumn": 39,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 76,
|
|
||||||
"endColumn": 80,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 75,
|
|
||||||
"endColumn": 79,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 80,
|
|
||||||
"endColumn": 84,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -1200,143 +708,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/plex/discover.py": [
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 4,
|
|
||||||
"endColumn": 63,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 18,
|
|
||||||
"endColumn": 48,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportCallIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 4,
|
|
||||||
"endColumn": 63,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 18,
|
|
||||||
"endColumn": 48,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 39,
|
|
||||||
"endColumn": 45,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 38,
|
|
||||||
"endColumn": 43,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/plex/playback.py": [
|
|
||||||
{
|
|
||||||
"code": "reportAttributeAccessIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 6,
|
|
||||||
"endColumn": 14,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 51,
|
|
||||||
"endColumn": 54,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 51,
|
|
||||||
"endColumn": 54,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/plex/playlists.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 77,
|
|
||||||
"endColumn": 83,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 77,
|
|
||||||
"endColumn": 83,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 9,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 8,
|
|
||||||
"endColumn": 9,
|
|
||||||
"lineCount": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportAttributeAccessIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 10,
|
|
||||||
"endColumn": 18,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/public.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 57,
|
|
||||||
"endColumn": 71,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/scheduler.py": [
|
"./app/routes/scheduler.py": [
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 42,
|
|
||||||
"endColumn": 55,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportOptionalMemberAccess",
|
"code": "reportOptionalMemberAccess",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -1354,42 +726,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/search.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 25,
|
|
||||||
"endColumn": 31,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportAttributeAccessIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 6,
|
|
||||||
"endColumn": 14,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/sync.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 42,
|
|
||||||
"endColumn": 65,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportAttributeAccessIssue",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 42,
|
|
||||||
"endColumn": 50,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/sync/explore.py": [
|
"./app/sync/explore.py": [
|
||||||
{
|
{
|
||||||
"code": "reportAttributeAccessIssue",
|
"code": "reportAttributeAccessIssue",
|
||||||
@@ -1553,14 +889,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 38,
|
|
||||||
"endColumn": 43,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"range": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy import engine_from_config, pool
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.logs import configure_logging
|
from app.logs import configure_logging
|
||||||
import app.models # noqa: F401 (import so models register on Base.metadata)
|
import app.tables # noqa: F401 (import so every table registers on Base.metadata)
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Create Date: 2026-07-07
|
|||||||
|
|
||||||
Some sources (a direct media URL like a reddit v.redd.it HLS manifest) carry no thumbnail, so the
|
Some sources (a direct media URL like a reddit v.redd.it HLS manifest) carry no thumbnail, so the
|
||||||
download shows a blank image box. The worker now extracts a representative frame with ffmpeg and
|
download shows a blank image box. The worker now extracts a representative frame with ffmpeg and
|
||||||
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 files_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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""messages: drop direct messaging (the owner's decision, 2026-10-04)
|
||||||
|
|
||||||
|
Chat — end-to-end encrypted direct messages plus the server's welcome note — was never used: prod held
|
||||||
|
two welcome notes, no message between people, and one key. Its tables go with its code. The rows are
|
||||||
|
not migrated anywhere; the nightly backup holds them.
|
||||||
|
|
||||||
|
The downgrade restores the schema as it stood (0026 + 0027), empty: a message can't be brought back
|
||||||
|
from a migration, and the E2EE key blobs were only ever readable by their owner's browser anyway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0097_drop_messages"
|
||||||
|
down_revision: str | None = "0096_videos_sort_indexes"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("message_keys")
|
||||||
|
op.drop_index("ix_messages_created_at", table_name="messages")
|
||||||
|
op.drop_index("ix_messages_recipient_id", table_name="messages")
|
||||||
|
op.drop_index("ix_messages_sender_id", table_name="messages")
|
||||||
|
op.drop_table("messages")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"messages",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"sender_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"recipient_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("body", sa.Text(), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
|
||||||
|
sa.Column("ciphertext", sa.Text(), nullable=True),
|
||||||
|
sa.Column("iv", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_messages_sender_id", "messages", ["sender_id"])
|
||||||
|
op.create_index("ix_messages_recipient_id", "messages", ["recipient_id"])
|
||||||
|
op.create_index("ix_messages_created_at", "messages", ["created_at"])
|
||||||
|
op.create_table(
|
||||||
|
"message_keys",
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("public_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("wrapped_private_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("salt", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("wrap_iv", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("key_check", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""demo: drop the shared demo account (the owner's decision, 2026-10-04)
|
||||||
|
|
||||||
|
The demo account — one shared user that whitelisted emails entered from the login page, reset on a
|
||||||
|
schedule — goes with its code. Prod held the demo user and 10 whitelist rows.
|
||||||
|
|
||||||
|
The upgrade deletes the demo user first (its playlists, states and subscriptions cascade; audit and
|
||||||
|
quota rows keep their history with the actor nulled, as for any deleted account), then the traces
|
||||||
|
that only the demo code could render: its audit rows (whitelist edits, manual resets, and every row
|
||||||
|
about the reset job — its runs and its interval changes), the reset job's interval override and its
|
||||||
|
completion notices. Nothing is migrated anywhere; the nightly backup holds the rows.
|
||||||
|
|
||||||
|
The downgrade restores the schema as it stood (0014), empty: the demo user is created lazily on its
|
||||||
|
first sign-in, so an empty `demo_whitelist` is the whole of its state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0098_drop_demo_account"
|
||||||
|
down_revision: str | None = "0097_drop_messages"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("DELETE FROM users WHERE is_demo")
|
||||||
|
op.execute(
|
||||||
|
"DELETE FROM audit_log WHERE action IN ('demo_add', 'demo_remove', 'demo_reset')"
|
||||||
|
" OR (target_type = 'job' AND target_id = 'demo_reset')"
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM scheduler_settings WHERE job_id = 'demo_reset'")
|
||||||
|
op.execute("DELETE FROM notifications WHERE data->>'job_id' = 'demo_reset'")
|
||||||
|
op.drop_index("ix_demo_whitelist_email", table_name="demo_whitelist")
|
||||||
|
op.drop_table("demo_whitelist")
|
||||||
|
op.drop_index("ix_users_is_demo", table_name="users")
|
||||||
|
op.drop_column("users", "is_demo")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("is_demo", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_is_demo", "users", ["is_demo"])
|
||||||
|
op.create_table(
|
||||||
|
"demo_whitelist",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("email", sa.String(length=320), nullable=False),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("added_by", sa.String(length=320), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""library: a resume point per user and library item
|
||||||
|
|
||||||
|
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
|
||||||
|
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
|
||||||
|
status: the library has no "watched". It starts empty; the downgrade drops it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0099_library_states"
|
||||||
|
down_revision: str | None = "0098_drop_demo_account"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"library_states",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"job_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("position_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_library_states_job_id", table_name="library_states")
|
||||||
|
op.drop_table("library_states")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""plex_items: drop `markers`, a column nothing writes or reads
|
||||||
|
|
||||||
|
Intro/credits markers are read live from Plex for the item the player opens (with Plex's `final` flag
|
||||||
|
on the block that ends it); this cached copy was never filled — every row is NULL. The downgrade
|
||||||
|
restores the empty column.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0100_drop_plex_item_markers"
|
||||||
|
down_revision: str | None = "0099_library_states"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("plex_items", "markers")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("plex_items", sa.Column("markers", sa.JSON(), nullable=True))
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Plex summaries and taglines as plain text
|
||||||
|
|
||||||
|
Revision ID: 0101_plex_text_plain
|
||||||
|
Revises: 0100_drop_plex_item_markers
|
||||||
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
|
Some Plex agents write a summary as HTML (`<p>…</p>`, `á`), and the app showed the tags as
|
||||||
|
text. The sync now stores every summary and tagline through `plain_text` (app/plex/plaintext.py);
|
||||||
|
this cleans the rows it wrote before, through the same function, so nothing waits for a full resync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
|
revision: str = "0101_plex_text_plain"
|
||||||
|
down_revision: str | None = "0100_drop_plex_item_markers"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
_COLUMNS = {
|
||||||
|
"plex_items": ("summary", "summary_hu", "tagline", "tagline_hu"),
|
||||||
|
"plex_shows": ("summary", "summary_hu", "tagline", "tagline_hu"),
|
||||||
|
"plex_collections": ("summary",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table, columns in _COLUMNS.items():
|
||||||
|
for col in columns:
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(f"SELECT id, {col} FROM {table} WHERE {col} ~ '[<&]'")
|
||||||
|
).fetchall()
|
||||||
|
updates = [
|
||||||
|
{"id": rid, "v": clean}
|
||||||
|
for rid, value in rows
|
||||||
|
if (clean := plain_text(value)) != value
|
||||||
|
]
|
||||||
|
if updates:
|
||||||
|
conn.execute(sa.text(f"UPDATE {table} SET {col} = :v WHERE id = :id"), updates)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass # the markup is not wanted back; the next sync would clean it again
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""The Files library: folders, and a file's retention on the user's job
|
||||||
|
|
||||||
|
Revision ID: 0102_file_folders
|
||||||
|
Revises: 0101_plex_text_plain
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
|
||||||
|
Every download, clip and upload of a user now sits in a folder of their Files library
|
||||||
|
(`app.downloads.folders`): a source folder made per origin (YouTube, Plex, uploads, one per other
|
||||||
|
site) or one of their own. How long a file stays moves from the shared asset to each user's job
|
||||||
|
(`download_jobs.expires_at`, stamped from the folder's `retention_days`), so one user keeping a file
|
||||||
|
no longer depends on another's copy; the asset's own `expires_at` / `gc_notified` go.
|
||||||
|
|
||||||
|
This places every existing job in its source folder and carries each done job's expiry over from
|
||||||
|
the asset it holds (an upload keeps none). The source-key rule is copied here, frozen, from
|
||||||
|
`folders.source_key_for` as it is today — a migration must not follow the code's later changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0102_file_folders"
|
||||||
|
down_revision: str | None = "0101_plex_text_plain"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
_FRONT_DOORS = ("www.", "m.", "mobile.", "web.")
|
||||||
|
_YOUTUBE_HOSTS = {"youtube.com", "youtu.be", "youtube-nocookie.com", "music.youtube.com"}
|
||||||
|
_DEFAULT_RETENTION_DAYS = 30 # config.download_retention_days on the day this was written
|
||||||
|
|
||||||
|
|
||||||
|
def _site_key(url: str) -> str:
|
||||||
|
host = (urlsplit(url).hostname or "").lower().rstrip(".")
|
||||||
|
for door in _FRONT_DOORS:
|
||||||
|
if host.startswith(door) and host.count(".") > 1:
|
||||||
|
host = host[len(door) :]
|
||||||
|
break
|
||||||
|
if host in _YOUTUBE_HOSTS:
|
||||||
|
return "youtube"
|
||||||
|
return f"site:{host or 'unknown'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_key(kind: str | None, ref: str | None) -> str:
|
||||||
|
if kind == "youtube":
|
||||||
|
return "youtube"
|
||||||
|
if kind == "url" and ref:
|
||||||
|
return _site_key(ref)
|
||||||
|
return "upload"
|
||||||
|
|
||||||
|
|
||||||
|
def _key(job, jobs_by_id, assets) -> str:
|
||||||
|
kind = job.source_kind
|
||||||
|
if kind == "youtube":
|
||||||
|
return "youtube"
|
||||||
|
if kind == "upload":
|
||||||
|
return "upload"
|
||||||
|
if kind == "clip":
|
||||||
|
spec = job.edit_spec or {}
|
||||||
|
return "plex" if spec.get("source") == "plex" else "youtube"
|
||||||
|
if kind == "url":
|
||||||
|
return _site_key(job.source_ref)
|
||||||
|
if kind == "edit":
|
||||||
|
parent = jobs_by_id.get(job.source_job_id)
|
||||||
|
if parent is not None and parent.id != job.id:
|
||||||
|
return _key(parent, jobs_by_id, assets)
|
||||||
|
asset = assets.get(job.source_asset_id)
|
||||||
|
if asset is not None:
|
||||||
|
return _asset_key(asset.source_kind, asset.source_ref)
|
||||||
|
return "upload"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"file_folders",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("kind", sa.String(16), nullable=False),
|
||||||
|
sa.Column("source_key", sa.String(260)), # "site:" + a host (<= 253)
|
||||||
|
sa.Column("name", sa.String(255)),
|
||||||
|
sa.Column("retention_days", sa.Integer()),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "source_key", name="uq_file_folder_source"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
|
||||||
|
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)",
|
||||||
|
name="ck_file_folder_kind",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_file_folders_user_id", "file_folders", ["user_id"])
|
||||||
|
op.create_index(
|
||||||
|
"uq_file_folder_own_name",
|
||||||
|
"file_folders",
|
||||||
|
["user_id", sa.text("lower(name)")],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("name IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"download_jobs",
|
||||||
|
sa.Column(
|
||||||
|
"folder_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("file_folders.id", ondelete="RESTRICT"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"download_jobs",
|
||||||
|
sa.Column("kept", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column("download_jobs", sa.Column("expires_at", sa.DateTime(timezone=True)))
|
||||||
|
op.add_column(
|
||||||
|
"download_jobs",
|
||||||
|
sa.Column("expiry_warned", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_jobs_folder_id", "download_jobs", ["folder_id"])
|
||||||
|
op.create_index("ix_download_jobs_expires_at", "download_jobs", ["expires_at"])
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
raw = conn.execute(
|
||||||
|
sa.text("SELECT value FROM system_config WHERE key = 'download_retention_days'")
|
||||||
|
).scalar()
|
||||||
|
retention = int(raw) if raw not in (None, "") else _DEFAULT_RETENTION_DAYS
|
||||||
|
|
||||||
|
jobs = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT j.id, j.user_id, j.source_kind, j.source_ref, j.edit_spec, j.source_job_id,"
|
||||||
|
" j.source_asset_id, j.status, j.asset_id, a.expires_at AS asset_expires_at"
|
||||||
|
" FROM download_jobs j LEFT JOIN media_assets a ON a.id = j.asset_id"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
jobs_by_id = {j.id: j for j in jobs}
|
||||||
|
assets = {
|
||||||
|
a.id: a
|
||||||
|
for a in conn.execute(sa.text("SELECT id, source_kind, source_ref FROM media_assets"))
|
||||||
|
}
|
||||||
|
|
||||||
|
folder_ids: dict[tuple[int, str], int] = {}
|
||||||
|
for job in jobs:
|
||||||
|
key = _key(job, jobs_by_id, assets)
|
||||||
|
slot = (job.user_id, key)
|
||||||
|
if slot not in folder_ids:
|
||||||
|
folder_ids[slot] = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO file_folders (user_id, kind, source_key, retention_days)"
|
||||||
|
" VALUES (:u, 'source', :k, :r) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": job.user_id, "k": key, "r": None if key == "upload" else retention},
|
||||||
|
).scalar_one()
|
||||||
|
# A done file keeps the expiry it had (its asset's); an upload had none and keeps none.
|
||||||
|
expires = job.asset_expires_at if job.status == "done" and key != "upload" else None
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE download_jobs SET folder_id = :f, expires_at = :e WHERE id = :id"),
|
||||||
|
{"f": folder_ids[slot], "e": expires, "id": job.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("media_assets", "expires_at")
|
||||||
|
op.drop_column("media_assets", "gc_notified")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("media_assets", sa.Column("expires_at", sa.DateTime(timezone=True)))
|
||||||
|
op.add_column(
|
||||||
|
"media_assets",
|
||||||
|
sa.Column("gc_notified", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
# The asset goes when its last holder would: the latest of its holders' expiries (a holder that
|
||||||
|
# never expires keeps the asset unexpired).
|
||||||
|
op.execute(
|
||||||
|
"UPDATE media_assets a SET expires_at = sub.e FROM ("
|
||||||
|
" SELECT asset_id, CASE WHEN bool_or(expires_at IS NULL) THEN NULL ELSE max(expires_at) END"
|
||||||
|
" AS e FROM download_jobs WHERE asset_id IS NOT NULL AND status = 'done' GROUP BY asset_id"
|
||||||
|
") sub WHERE a.id = sub.asset_id AND a.source_kind <> 'upload'"
|
||||||
|
)
|
||||||
|
op.drop_index("ix_download_jobs_expires_at", table_name="download_jobs")
|
||||||
|
op.drop_index("ix_download_jobs_folder_id", table_name="download_jobs")
|
||||||
|
op.drop_column("download_jobs", "expiry_warned")
|
||||||
|
op.drop_column("download_jobs", "expires_at")
|
||||||
|
op.drop_column("download_jobs", "kept")
|
||||||
|
op.drop_column("download_jobs", "folder_id")
|
||||||
|
op.drop_index("uq_file_folder_own_name", table_name="file_folders")
|
||||||
|
op.drop_index("ix_file_folders_user_id", table_name="file_folders")
|
||||||
|
op.drop_table("file_folders")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""The Files library: a file's SHA-256, and the expired rows retired
|
||||||
|
|
||||||
|
Revision ID: 0103_files_sha256
|
||||||
|
Revises: 0102_file_folders
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
|
||||||
|
`media_assets.sha256` holds the file's hash once it is known (an upload is hashed while it streams
|
||||||
|
in, any other file on the first ask), so a machine fetching a file can check what it got.
|
||||||
|
|
||||||
|
An expired file now leaves its user's library (`downloads.gc`). Before, an expired download stayed
|
||||||
|
as a fileless "expired · download again" row (a done job with no asset). Those rows hold nothing on
|
||||||
|
disk and the library no longer shows them, so they are deleted here rather than left behind
|
||||||
|
unseen; their shares and public links go with them (ON DELETE CASCADE), and those already served
|
||||||
|
nothing. The downgrade cannot bring them back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0103_files_sha256"
|
||||||
|
down_revision: str | None = "0102_file_folders"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("media_assets", sa.Column("sha256", sa.String(64)))
|
||||||
|
op.execute("DELETE FROM download_jobs WHERE status = 'done' AND asset_id IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("media_assets", "sha256")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""A file's "Added" is when it became done
|
||||||
|
|
||||||
|
Revision ID: 0104_job_ready_at
|
||||||
|
Revises: 0103_files_sha256
|
||||||
|
Create Date: 2026-10-12
|
||||||
|
|
||||||
|
`download_jobs.ready_at` is when a job became done, and the Files library sorts and dates by it: a
|
||||||
|
download that failed for days and then went through used to sit days down the list, under its
|
||||||
|
request date. Every path to done stamps it, held by `ck_download_jobs_ready_at`.
|
||||||
|
|
||||||
|
A done job from before has no record of that moment (its "ready to watch" notice never named the
|
||||||
|
job, so it cannot be matched back), so it takes its request date, which is where it already sat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0104_job_ready_at"
|
||||||
|
down_revision: str | None = "0103_files_sha256"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("download_jobs", sa.Column("ready_at", sa.DateTime(timezone=True)))
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE download_jobs SET ready_at = created_at WHERE status = 'done'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_download_jobs_ready_at", "download_jobs", "status <> 'done' OR ready_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_download_jobs_ready_at", "download_jobs", type_="check")
|
||||||
|
op.drop_column("download_jobs", "ready_at")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""A file remembers where it came from
|
||||||
|
|
||||||
|
Revision ID: 0105_job_source_key
|
||||||
|
Revises: 0104_job_ready_at
|
||||||
|
Create Date: 2026-10-12
|
||||||
|
|
||||||
|
`download_jobs.source_key` is the origin a file came from — the source folder it may go back to —
|
||||||
|
recorded when it is placed. An edit used to work its origin out through its parent each time, and
|
||||||
|
once the parent was deleted or expired it fell back to "upload": moved into an own folder, it could
|
||||||
|
no longer go back to its folder, and deleting that folder sent it to Uploads to stay forever.
|
||||||
|
|
||||||
|
A file in a source folder takes that folder's key. One already in an own folder is left NULL and
|
||||||
|
keeps being worked out (`folders.source_key_for`), as before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0105_job_source_key"
|
||||||
|
down_revision: str | None = "0104_job_ready_at"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("download_jobs", sa.Column("source_key", sa.String(260)))
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE download_jobs j SET source_key = f.source_key
|
||||||
|
FROM file_folders f
|
||||||
|
WHERE f.id = j.folder_id AND f.kind = 'source'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("download_jobs", "source_key")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Machine access to the Files library: API tokens bound to folders, and the Hub folder
|
||||||
|
|
||||||
|
Revision ID: 0106_api_tokens
|
||||||
|
Revises: 0105_job_source_key
|
||||||
|
Create Date: 2026-10-14
|
||||||
|
|
||||||
|
`api_tokens` is a user's token for a machine (a script, the hub): only its SHA-256 is kept, plus the
|
||||||
|
first characters to recognise it by, and whether its user was told it is about to run out. `api_token_grants` says what it may do in which folder — list,
|
||||||
|
read, write, delete — so a token reaches nothing it was not given, folder by folder.
|
||||||
|
|
||||||
|
A third folder kind, `hub`: the one folder a user exchanges files with their hub machine through.
|
||||||
|
Like a source folder its name is the client's to render; like an own folder a file may be moved in.
|
||||||
|
One per user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0106_api_tokens"
|
||||||
|
down_revision: str | None = "0105_job_source_key"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
_KIND_OLD = (
|
||||||
|
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
|
||||||
|
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
|
||||||
|
)
|
||||||
|
_KIND_NEW = _KIND_OLD + " OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check")
|
||||||
|
op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_NEW)
|
||||||
|
op.create_index(
|
||||||
|
"uq_file_folder_hub",
|
||||||
|
"file_folders",
|
||||||
|
["user_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("kind = 'hub'"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"api_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.String(80), nullable=False),
|
||||||
|
sa.Column("prefix", sa.String(16), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("expiry_warned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"api_token_grants",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"token_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("api_tokens.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"folder_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("file_folders.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("can_list", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("can_delete", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("api_token_grants")
|
||||||
|
op.drop_table("api_tokens")
|
||||||
|
op.drop_index("uq_file_folder_hub", table_name="file_folders")
|
||||||
|
# A Hub folder may hold files (their foreign key RESTRICTs a delete): it becomes an own folder,
|
||||||
|
# named "Hub <id>" — or "Hub <id> (2)", … when its user already has a folder of that name,
|
||||||
|
# which `uq_file_folder_own_name` (per user, case-blind) would refuse.
|
||||||
|
conn = op.get_bind()
|
||||||
|
hubs = conn.execute(sa.text("SELECT id, user_id FROM file_folders WHERE kind = 'hub'")).all()
|
||||||
|
for folder_id, user_id in hubs:
|
||||||
|
name, n = f"Hub {folder_id}", 1
|
||||||
|
while conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM file_folders WHERE user_id = :u AND lower(name) = lower(:n)"),
|
||||||
|
{"u": user_id, "n": name},
|
||||||
|
).first():
|
||||||
|
n += 1
|
||||||
|
name = f"Hub {folder_id} ({n})"
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE file_folders SET kind = 'own', name = :n WHERE id = :i"),
|
||||||
|
{"n": name, "i": folder_id},
|
||||||
|
)
|
||||||
|
op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check")
|
||||||
|
op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_OLD)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Share-link tokens no longer stored in the clear
|
||||||
|
|
||||||
|
Revision ID: 0107_link_token_at_rest
|
||||||
|
Revises: 0106_api_tokens
|
||||||
|
Create Date: 2026-10-15
|
||||||
|
|
||||||
|
A `/watch` link's token is the credential itself, and it sat in `download_links.token` as written,
|
||||||
|
so a copy of the database was every shared file. Now a link is found by the token's SHA-256
|
||||||
|
(`token_hash`), and the token is kept only Fernet-encrypted (`token_enc`, TOKEN_ENCRYPTION_KEY) so its
|
||||||
|
owner can copy the same link again. Without a key the copy is not kept: the link still opens, its
|
||||||
|
address just cannot be shown again (`app/downloads/links.py`).
|
||||||
|
|
||||||
|
Every existing link is converted here, so the addresses already sent keep working. The downgrade
|
||||||
|
writes the tokens back from their encrypted copies; a link with none left (no key, or a key since
|
||||||
|
changed) gets a fresh random token, which is the same as revoking it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
revision: str = "0107_link_token_at_rest"
|
||||||
|
down_revision: str | None = "0106_api_tokens"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("download_links", sa.Column("token_hash", sa.String(64)))
|
||||||
|
op.add_column("download_links", sa.Column("token_enc", sa.Text()))
|
||||||
|
conn = op.get_bind()
|
||||||
|
sealing = security.can_encrypt()
|
||||||
|
for link_id, token in conn.execute(sa.text("SELECT id, token FROM download_links")).fetchall():
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE download_links SET token_hash = :h, token_enc = :e WHERE id = :id"),
|
||||||
|
{
|
||||||
|
"id": link_id,
|
||||||
|
"h": hashlib.sha256(token.encode()).hexdigest(),
|
||||||
|
"e": security.encrypt(token) if sealing else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
op.alter_column("download_links", "token_hash", nullable=False)
|
||||||
|
op.create_unique_constraint("uq_download_link_token_hash", "download_links", ["token_hash"])
|
||||||
|
op.drop_constraint("uq_download_link_token", "download_links", type_="unique")
|
||||||
|
op.drop_column("download_links", "token")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("download_links", sa.Column("token", sa.String(64)))
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(sa.text("SELECT id, token_enc FROM download_links")).fetchall()
|
||||||
|
for link_id, enc in rows:
|
||||||
|
token = security.decrypt_optional(enc) or secrets.token_urlsafe(24)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE download_links SET token = :t WHERE id = :id"),
|
||||||
|
{"id": link_id, "t": token},
|
||||||
|
)
|
||||||
|
op.alter_column("download_links", "token", nullable=False)
|
||||||
|
op.create_unique_constraint("uq_download_link_token", "download_links", ["token"])
|
||||||
|
op.drop_constraint("uq_download_link_token_hash", "download_links", type_="unique")
|
||||||
|
op.drop_column("download_links", "token_enc")
|
||||||
|
op.drop_column("download_links", "token_hash")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""A share link may open a folder
|
||||||
|
|
||||||
|
Revision ID: 0108_folder_links
|
||||||
|
Revises: 0107_link_token_at_rest
|
||||||
|
Create Date: 2026-10-15
|
||||||
|
|
||||||
|
A `/watch` link opened one file. Now it may open one of its owner's own folders instead: the link's
|
||||||
|
page lists what is in the folder when it is opened. `folder_id` sits beside `job_id`, exactly one of
|
||||||
|
the two set; a folder's links go with the folder.
|
||||||
|
|
||||||
|
The downgrade has nowhere to keep a folder link, so it deletes them: they stop opening.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0108_folder_links"
|
||||||
|
down_revision: str | None = "0107_link_token_at_rest"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"download_links",
|
||||||
|
sa.Column("folder_id", sa.Integer(), sa.ForeignKey("file_folders.id", ondelete="CASCADE")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_links_folder_id", "download_links", ["folder_id"])
|
||||||
|
op.alter_column("download_links", "job_id", nullable=True)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_download_link_target", "download_links", "(job_id IS NULL) <> (folder_id IS NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DELETE FROM download_links WHERE folder_id IS NOT NULL")
|
||||||
|
op.drop_constraint("ck_download_link_target", "download_links", type_="check")
|
||||||
|
op.alter_column("download_links", "job_id", nullable=False)
|
||||||
|
op.drop_index("ix_download_links_folder_id", table_name="download_links")
|
||||||
|
op.drop_column("download_links", "folder_id")
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Background runs get a durable history
|
||||||
|
|
||||||
|
Revision ID: 0109_job_runs
|
||||||
|
Revises: 0108_folder_links
|
||||||
|
Create Date: 2026-10-17
|
||||||
|
|
||||||
|
What a scheduler job last did lived in the API process's memory, gone at every restart; the audit
|
||||||
|
log kept a row for a run only when it was manual, failed, or changed something. Now every run is a
|
||||||
|
`job_runs` row, written when it starts and when it ends. A run's quota spend points at it
|
||||||
|
(`quota_events.job_run_id`), and so do the lines it logged (`log_events.run_id`, no foreign key: the
|
||||||
|
log writer runs behind the event).
|
||||||
|
|
||||||
|
Runs are not admin actions, so the audit log stops carrying them: the existing `job_run` audit rows
|
||||||
|
MOVE into `job_runs` (start = end = when the row was written, the only time it kept), and leave the
|
||||||
|
audit log. The downgrade moves every run back into the audit log as such a row (one still running as
|
||||||
|
interrupted, what the audit row has no column for in its `after`), then drops the table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0109_job_runs"
|
||||||
|
down_revision: str | None = "0108_folder_links"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"job_runs",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||||
|
sa.Column("job", sa.String(40), nullable=False),
|
||||||
|
sa.Column("trigger", sa.String(16), nullable=False),
|
||||||
|
sa.Column("actor_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("target", sa.String(128)),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("summary", sa.Text()),
|
||||||
|
sa.Column("result", postgresql.JSONB()),
|
||||||
|
sa.Column("error", sa.Text()),
|
||||||
|
sa.Column("failure", postgresql.JSONB()),
|
||||||
|
sa.Column("quota_units", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('running', 'ok', 'error', 'skipped', 'interrupted')",
|
||||||
|
name="ck_job_runs_status",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"trigger IN ('scheduled', 'manual', 'ad_hoc')", name="ck_job_runs_trigger"
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(status = 'running') = (finished_at IS NULL)", name="ck_job_runs_finished"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_job_runs_job_started", "job_runs", ["job", "started_at"])
|
||||||
|
op.create_index("ix_job_runs_actor_id", "job_runs", ["actor_id"])
|
||||||
|
op.create_index("ix_job_runs_started_at", "job_runs", ["started_at"])
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"quota_events",
|
||||||
|
sa.Column("job_run_id", sa.BigInteger(), sa.ForeignKey("job_runs.id", ondelete="SET NULL")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_quota_events_job_run_id", "quota_events", ["job_run_id"])
|
||||||
|
|
||||||
|
op.add_column("log_events", sa.Column("run_id", sa.BigInteger()))
|
||||||
|
op.create_index(
|
||||||
|
"ix_log_events_run",
|
||||||
|
"log_events",
|
||||||
|
["run_id"],
|
||||||
|
postgresql_where=sa.text("run_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The audit trail's runs move over: the three statuses the scheduler wrote, and `interrupted`,
|
||||||
|
# which a downgrade writes back (as it does a run's trigger, target, spend and start, which the
|
||||||
|
# audit row has no column for: its actor alone cannot say "manual" once that admin is deleted),
|
||||||
|
# so a rollback and a redeploy keep every run as it was. A status the run table does
|
||||||
|
# not know would fail the CHECK.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at, finished_at,
|
||||||
|
summary, result, error, failure, quota_units)
|
||||||
|
SELECT target_id,
|
||||||
|
coalesce(after::jsonb ->> 'trigger',
|
||||||
|
CASE WHEN actor_id IS NULL THEN 'scheduled' ELSE 'manual' END),
|
||||||
|
actor_id,
|
||||||
|
after::jsonb ->> 'target',
|
||||||
|
after::jsonb ->> 'status',
|
||||||
|
coalesce((after::jsonb ->> 'started_at')::timestamptz, created_at),
|
||||||
|
created_at,
|
||||||
|
CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb ->> 'summary' END,
|
||||||
|
CASE WHEN after::jsonb ->> 'status' <> 'error' THEN after::jsonb -> 'result' END,
|
||||||
|
CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb ->> 'summary' END,
|
||||||
|
CASE WHEN after::jsonb ->> 'status' = 'error' THEN after::jsonb -> 'result' END,
|
||||||
|
coalesce((after::jsonb ->> 'quota_units')::int, 0)
|
||||||
|
FROM audit_log
|
||||||
|
WHERE action = 'job_run'
|
||||||
|
AND target_id IS NOT NULL
|
||||||
|
AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped', 'interrupted')
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM audit_log
|
||||||
|
WHERE action = 'job_run'
|
||||||
|
AND target_id IS NOT NULL
|
||||||
|
AND after::jsonb ->> 'status' IN ('ok', 'error', 'skipped', 'interrupted')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# A run still `running` is one the rollback stopped: it goes back as interrupted. What the audit
|
||||||
|
# row's own columns cannot say rides in `after` (its trigger, target, spend and start), so the
|
||||||
|
# upgrade restores them.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after, created_at)
|
||||||
|
SELECT actor_id,
|
||||||
|
'job_run',
|
||||||
|
'job',
|
||||||
|
job,
|
||||||
|
left(job || ': ' || st
|
||||||
|
|| coalesce(' — ' || CASE WHEN st = 'error' THEN error ELSE summary END, ''),
|
||||||
|
255),
|
||||||
|
(jsonb_build_object(
|
||||||
|
'status', st,
|
||||||
|
'summary', CASE WHEN st = 'error' THEN error ELSE summary END)
|
||||||
|
|| CASE
|
||||||
|
WHEN st = 'error' AND failure IS NOT NULL
|
||||||
|
THEN jsonb_build_object('result', failure)
|
||||||
|
WHEN st <> 'error' AND result IS NOT NULL
|
||||||
|
THEN jsonb_build_object('result', result)
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END
|
||||||
|
|| jsonb_build_object('trigger', trigger, 'quota_units', units,
|
||||||
|
'started_at', started_at)
|
||||||
|
|| CASE WHEN target IS NOT NULL
|
||||||
|
THEN jsonb_build_object('target', target) ELSE '{}'::jsonb END)::json,
|
||||||
|
coalesce(finished_at, started_at)
|
||||||
|
FROM (SELECT *, CASE WHEN status = 'running' THEN 'interrupted' ELSE status END AS st,
|
||||||
|
-- A run stopped mid-way has not summed its spend yet: sum it as the startup
|
||||||
|
-- sweep would, before the link to it is dropped.
|
||||||
|
CASE WHEN status = 'running'
|
||||||
|
THEN (SELECT coalesce(sum(q.units), 0) FROM quota_events q
|
||||||
|
WHERE q.job_run_id = job_runs.id)
|
||||||
|
ELSE quota_units END AS units
|
||||||
|
FROM job_runs) AS r
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.drop_index("ix_log_events_run", table_name="log_events")
|
||||||
|
op.drop_column("log_events", "run_id")
|
||||||
|
op.drop_index("ix_quota_events_job_run_id", table_name="quota_events")
|
||||||
|
op.drop_column("quota_events", "job_run_id")
|
||||||
|
op.drop_index("ix_job_runs_started_at", table_name="job_runs")
|
||||||
|
op.drop_index("ix_job_runs_actor_id", table_name="job_runs")
|
||||||
|
op.drop_index("ix_job_runs_job_started", table_name="job_runs")
|
||||||
|
op.drop_table("job_runs")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Job intervals move into sysconfig; the unread SponsorBlock default goes
|
||||||
|
|
||||||
|
Revision ID: 0110_intervals_in_sysconfig
|
||||||
|
Revises: 0109_job_runs
|
||||||
|
Create Date: 2026-10-18
|
||||||
|
|
||||||
|
An admin's interval override lived in its own table, `scheduler_settings`, beside the one store every
|
||||||
|
other admin knob uses. Each override becomes the `job_interval_<job>` sysconfig key and the table
|
||||||
|
goes. Only the jobs that still exist are copied: a row for any other id never did anything.
|
||||||
|
|
||||||
|
The `sponsorblock_default` setting was offered on the Configuration page but nothing ever read it, so
|
||||||
|
its key is gone and its stored value with it — the downgrade cannot bring that value back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0110_intervals_in_sysconfig"
|
||||||
|
down_revision: str | None = "0109_job_runs"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
# The interval jobs as of this revision (a migration does not read the live code's list).
|
||||||
|
_JOBS = (
|
||||||
|
"rss_poll",
|
||||||
|
"enrich",
|
||||||
|
"backfill",
|
||||||
|
"autotag",
|
||||||
|
"shorts",
|
||||||
|
"subscriptions",
|
||||||
|
"playlist_sync",
|
||||||
|
"maintenance",
|
||||||
|
"explore_cleanup",
|
||||||
|
"download_gc",
|
||||||
|
"plex_sync",
|
||||||
|
"plex_watch_sync",
|
||||||
|
"plex_watch_reconcile",
|
||||||
|
"tmdb_enrich",
|
||||||
|
"people_enrich",
|
||||||
|
"audit_gc",
|
||||||
|
"log_gc",
|
||||||
|
)
|
||||||
|
_PREFIX = "job_interval_"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO system_config (key, value, encrypted)"
|
||||||
|
" SELECT :prefix || job_id, interval_minutes::text, false FROM scheduler_settings"
|
||||||
|
" WHERE interval_minutes IS NOT NULL AND job_id IN :jobs"
|
||||||
|
" ON CONFLICT (key) DO NOTHING"
|
||||||
|
).bindparams(sa.bindparam("jobs", expanding=True), prefix=_PREFIX, jobs=list(_JOBS))
|
||||||
|
)
|
||||||
|
op.drop_table("scheduler_settings")
|
||||||
|
op.execute("DELETE FROM system_config WHERE key = 'sponsorblock_default'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"scheduler_settings",
|
||||||
|
sa.Column("job_id", sa.String(40), primary_key=True),
|
||||||
|
sa.Column("interval_minutes", sa.Integer(), nullable=False),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO scheduler_settings (job_id, interval_minutes)"
|
||||||
|
" SELECT substr(key, :start), value::int FROM system_config WHERE key IN :keys"
|
||||||
|
).bindparams(
|
||||||
|
sa.bindparam("keys", expanding=True),
|
||||||
|
start=len(_PREFIX) + 1,
|
||||||
|
keys=[_PREFIX + j for j in _JOBS],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text("DELETE FROM system_config WHERE key IN :keys").bindparams(
|
||||||
|
sa.bindparam("keys", expanding=True), keys=[_PREFIX + j for j in _JOBS]
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""An audit row keeps the request that made it
|
||||||
|
|
||||||
|
Revision ID: 0111_audit_request_id
|
||||||
|
Revises: 0110_intervals_in_sysconfig
|
||||||
|
Create Date: 2026-10-18
|
||||||
|
|
||||||
|
The activity page opens an admin action's log lines from its row: `audit_log.request_id` is the
|
||||||
|
request the change was made in, the same id `log_events.request_id` carries. Older rows have none —
|
||||||
|
the request that made them was never recorded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0111_audit_request_id"
|
||||||
|
down_revision: str | None = "0110_intervals_in_sysconfig"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("audit_log", sa.Column("request_id", sa.String(64)))
|
||||||
|
op.create_index("ix_audit_log_request_id", "audit_log", ["request_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_audit_log_request_id", table_name="audit_log")
|
||||||
|
op.drop_column("audit_log", "request_id")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""One Watch later list per user, held by the database
|
||||||
|
|
||||||
|
Revision ID: 0112_one_watch_later
|
||||||
|
Revises: 0111_audit_request_id
|
||||||
|
Create Date: 2026-10-20
|
||||||
|
|
||||||
|
Watch later was made on a user's first save by a read then an insert, so two first saves at once
|
||||||
|
each made a list, and the reader then picked between them at random. A partial unique index holds
|
||||||
|
"one per user" now. An instance that already has a second list keeps the oldest: the others' videos
|
||||||
|
move into it (after its own, in their order; one it already holds is not added twice), a Home pin
|
||||||
|
of one of them moves to it (unless it is pinned itself; of several, the first on the shelf), then
|
||||||
|
they go — a pin left on them goes with them, as the kept list already holds one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0112_one_watch_later"
|
||||||
|
down_revision: str | None = "0111_audit_request_id"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
_EXTRA = """
|
||||||
|
SELECT p.id, k.keep_id
|
||||||
|
FROM playlists p
|
||||||
|
JOIN (
|
||||||
|
SELECT user_id, min(id) AS keep_id FROM playlists WHERE kind = 'watch_later' GROUP BY user_id
|
||||||
|
) k ON k.user_id = p.user_id
|
||||||
|
WHERE p.kind = 'watch_later' AND p.id <> k.keep_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO playlist_items (playlist_id, video_id, position, added_at)
|
||||||
|
SELECT e.keep_id, i.video_id,
|
||||||
|
(SELECT coalesce(max(position), -1) FROM playlist_items WHERE playlist_id = e.keep_id)
|
||||||
|
+ row_number() OVER (PARTITION BY e.keep_id ORDER BY i.playlist_id, i.position, i.id),
|
||||||
|
i.added_at
|
||||||
|
FROM playlist_items i
|
||||||
|
JOIN ({_EXTRA}) e ON e.id = i.playlist_id
|
||||||
|
ON CONFLICT ON CONSTRAINT uq_playlist_video DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE comfort_pins c SET playlist_id = m.keep_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (e.keep_id) p.id AS pin_id, e.keep_id
|
||||||
|
FROM comfort_pins p
|
||||||
|
JOIN ({_EXTRA}) e ON e.id = p.playlist_id
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM comfort_pins k WHERE k.playlist_id = e.keep_id)
|
||||||
|
ORDER BY e.keep_id, p.position, p.id
|
||||||
|
) m
|
||||||
|
WHERE c.id = m.pin_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(f"DELETE FROM playlists WHERE id IN (SELECT id FROM ({_EXTRA}) e)")
|
||||||
|
op.create_index(
|
||||||
|
"uq_playlist_watch_later",
|
||||||
|
"playlists",
|
||||||
|
["user_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("kind = 'watch_later'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_playlist_watch_later", table_name="playlists")
|
||||||
+63
-20
@@ -1,9 +1,8 @@
|
|||||||
# Backend conventions
|
# Backend conventions
|
||||||
|
|
||||||
The rules below are already followed across `backend/app` — they lived only in a few module
|
The rules below are already followed across `backend/app` — they lived only in a few module
|
||||||
docstrings (`demo.py`, `userscope.py`, `routes/_common.py`) until this file collected them (E3 S3,
|
docstrings (`userscope.py`, `routes/_common.py`) until this file collected them. When you add code,
|
||||||
B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do
|
match these so the next reader doesn't have to guess "which pattern do I copy".
|
||||||
I copy".
|
|
||||||
|
|
||||||
## Module layering
|
## Module layering
|
||||||
|
|
||||||
@@ -13,15 +12,18 @@ Three layers, by *who* runs the code — not by feature:
|
|||||||
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
||||||
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
||||||
|
|
||||||
2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
|
2. **The units and the other domain packages — logic shared by more than one caller.** A unit's
|
||||||
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
|
database work (below), and anything the **scheduler or the worker** runs (not just a request), lives
|
||||||
route, the worker, and the scheduler share one definition. Examples from this codebase:
|
in a domain package, so the route, the worker, and the scheduler share one definition. Examples from
|
||||||
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
|
this codebase: `downloads/service.py` owns enqueue + the job state transitions (routes call it);
|
||||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
|
`plex/sync.py` is run by both the admin route and the scheduler; `downloads/pipeline.py` is the
|
||||||
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
|
worker's child pipeline; `issues/bundle.py` is the one definition of a bug report's files, for the
|
||||||
|
zip export and the tracker.
|
||||||
|
|
||||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth` (with `apitokens`, a machine's
|
||||||
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
|
folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`, `jobruns` (every
|
||||||
|
background run's durable row, its words in `runstatus`),
|
||||||
|
`notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging
|
||||||
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
|
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
|
||||||
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
|
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
|
||||||
reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core
|
reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core
|
||||||
@@ -32,19 +34,59 @@ Three layers, by *who* runs the code — not by feature:
|
|||||||
a layer — into the relevant domain package, or a flat core module if it's cross-cutting. Don't
|
a layer — into the relevant domain package, or a flat core module if it's cross-cutting. Don't
|
||||||
pre-abstract for a single caller; don't leave shared logic duplicated once there are two.
|
pre-abstract for a single caller; don't leave shared logic duplicated once there are two.
|
||||||
|
|
||||||
|
## Units
|
||||||
|
|
||||||
|
The domain is four units, each owning its tables (`models.py` in its package):
|
||||||
|
|
||||||
|
| Unit | Package | Its routes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Capture | `downloads/`, the worker | `downloads.py`, `files.py`, `public.py` |
|
||||||
|
| Reader | `reader/`, `discover/`, `feedquery.py`, the playlist sync and tag suggestions in `sync/` | `feed.py`, `playlists.py`, `saved_views.py`, `discover.py`, `tags.py` |
|
||||||
|
| Catalog | `catalog/`, the ingests in `sync/` | `channels.py`, `search.py`, `sync.py` |
|
||||||
|
| Plex | `plex/` | `plex/` |
|
||||||
|
|
||||||
|
- **A unit's route does no database work.** It validates, reads through the unit's `queries.py`,
|
||||||
|
writes through its `service.py`, serializes, and commits. It may name a unit's model as a type
|
||||||
|
(under `TYPE_CHECKING`), never use one as a value, and it passes a kind (`"movie"`), not a model, to
|
||||||
|
a query.
|
||||||
|
- **A unit's code keeps to its edges.** It reads another unit's tables only along a declared edge
|
||||||
|
(Capture and Reader read Catalog's and Plex's: the feed joins `Video` with `VideoState` in one
|
||||||
|
query) and writes them only through the owner's service. Core code reaches a unit's tables through
|
||||||
|
that unit's `queries`.
|
||||||
|
- **Whose a row is, `userscope` says** — in every module, core included: `owned_by(Model.user_id,
|
||||||
|
user)` for a filter, `owned_row(db, Model, id, user)` for a row by id (someone else's row is the
|
||||||
|
same answer as a missing one). A unit's `queries.own_x` returns None and its route answers
|
||||||
|
`not_found`; a core route calls `owned_or_404`. A rule `owned_row` cannot say — a job its owner
|
||||||
|
shared, a built-in profile nobody owns, a folder read under `FOR SHARE` — is written out in the
|
||||||
|
unit's own code, beside the reason.
|
||||||
|
|
||||||
|
All of it is held by `tests/test_unit_fences.py`, with no exemptions: the unit map, each unit's
|
||||||
|
packages and edges (`UNIT_PACKAGES`, `READS`) and what may commit (`UNIT_COMMITS`, `CORE_COMMITS`)
|
||||||
|
are listed there. It reads the code's AST, so it holds the forms written here: an owner column
|
||||||
|
compared inline (a model's, an import alias's or an `aliased()` one's, by `==`, `!=`, `.in_()`, or a
|
||||||
|
`filter_by(user_id=…)`), a model built or an `insert`/`update`/`delete` (under any import name, of a
|
||||||
|
model or its `__table__`) across units, and a `.commit()` call off the lists all fail it. What an AST
|
||||||
|
cannot tell is review's to catch: an ORM write on another unit's loaded row (an attribute, a
|
||||||
|
`db.delete`), SQL in `text()`, a column or model reached another way (bound to a variable, through
|
||||||
|
`__table__.c` or a module path), and a commit made by a `begin()` block or a `commit` handed in.
|
||||||
|
|
||||||
## Transaction ownership
|
## Transaction ownership
|
||||||
|
|
||||||
This follows CLAUDE.md's canonical rule verbatim — **"the handler/job-wrapper commits; helpers flush
|
This follows CLAUDE.md's canonical rule — **the handler commits; a unit's `service` and every helper
|
||||||
(only `quota` owns its session)"** — so read it that way, not as an exception to it. The commit belongs
|
flush** — so read it that way, not as an exception to it. The commit belongs to the **operation
|
||||||
to the **operation owner**; anything called *inside* another transaction only stages its change
|
owner**; anything called *inside* another transaction only stages its change
|
||||||
(`db.add` / `flush`) and lets the owner commit, so the whole operation is one transaction.
|
(`db.add` / `flush`) and lets the owner commit, so the whole operation is one transaction.
|
||||||
`audit.record()` is the reference: it adds the audit row and the caller commits it together with the
|
`audit.record()` is the reference: it adds the audit row and the caller commits it together with the
|
||||||
mutation it records, so an audit entry can never exist without its change (or vice-versa).
|
mutation it records, so an audit entry can never exist without its change (or vice-versa).
|
||||||
|
|
||||||
- The **operation owner** is usually the route handler. A `service.*` operation the handler delegates a
|
- The **operation owner** is the route handler, or a job. A unit's `service` flushes even when it
|
||||||
whole transition to (e.g. `service.cancel`) *is* that owner and commits its one transition — it isn't
|
makes a whole transition (`service.cancel`): the handler commits it, together with whatever else the
|
||||||
a mid-operation helper. A helper that owns its OWN short-lived session (the worker's `jobdb` writes,
|
request changed. What owns its OWN session commits it: `quota` and `jobruns`' run rows, the
|
||||||
the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
|
scheduler's jobs, a unit's jobs (the worker's `jobdb` writes, the syncs and ingests that commit per
|
||||||
|
page or batch so a later failure loses nothing already stored), and a few core steps that must land
|
||||||
|
whatever their caller does next (a token's last-used stamp, a refreshed YouTube grant) — the fence's
|
||||||
|
`UNIT_COMMITS` and `CORE_COMMITS` name each with its reason, `test_route_conventions` a route's own
|
||||||
|
committing helpers. Everything else flushes.
|
||||||
- `SessionLocal` is `expire_on_commit=False`: after a raw `UPDATE` + commit, an ORM object you already
|
- `SessionLocal` is `expire_on_commit=False`: after a raw `UPDATE` + commit, an ORM object you already
|
||||||
loaded is **stale**. Read the fresh value from the row (RETURNING, or a re-`SELECT`), not the ORM
|
loaded is **stale**. Read the fresh value from the row (RETURNING, or a re-`SELECT`), not the ORM
|
||||||
attribute — see `service.decr_ref_count`.
|
attribute — see `service.decr_ref_count`.
|
||||||
@@ -66,8 +108,9 @@ mutation it records, so an audit entry can never exist without its change (or vi
|
|||||||
- **`app.config.settings`** — boot/env config (DB URL, download root, worker concurrency): read once at
|
- **`app.config.settings`** — boot/env config (DB URL, download root, worker concurrency): read once at
|
||||||
startup, not user-tunable at runtime.
|
startup, not user-tunable at runtime.
|
||||||
- **`sysconfig.SPECS`** — the runtime admin knobs (DB-override-or-default). A new admin-tunable value
|
- **`sysconfig.SPECS`** — the runtime admin knobs (DB-override-or-default). A new admin-tunable value
|
||||||
goes in `SPECS`; don't invent a fourth store. (`state.py`'s `maintenance_batch` predates this and is
|
goes in `SPECS`; don't invent a fourth store. Each spec states when a change takes effect
|
||||||
slated to fold into `sysconfig` — do not add more like it.)
|
(`applies`, required): `live` keys are read where they are used, never at import (test-held). The
|
||||||
|
scheduler's job intervals are `SPECS` keys too, edited on the Scheduler page, not the Configuration page.
|
||||||
|
|
||||||
## The gate
|
## The gate
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Machine access to the Files library: API tokens, and what each one may do where.
|
||||||
|
|
||||||
|
A user makes a token for a machine of theirs — their hub, a script — and it reaches their library as
|
||||||
|
`Authorization: Bearer sfl_…`. Only on `/api/files`: the routes there take `files_caller` instead of
|
||||||
|
`current_user`, and nothing else does (a test holds the route table to that), so a token can never
|
||||||
|
manage the account, the tokens, or anything outside the library. And only in the folders its grants
|
||||||
|
name, with the rights they give: list, read, write, delete. Every route asks the one question,
|
||||||
|
`FilesCaller.may(folder_id, right)`; a signed-in user may everything in their own folders.
|
||||||
|
|
||||||
|
The token is `sfl_` + 32 random bytes in base62 + a CRC32 of them: a mistyped or cut-off token is
|
||||||
|
refused without a database read, and a secret scanner can tell one by its shape. Only its SHA-256 is
|
||||||
|
kept (`security.hash_token`) — the user sees it once, when it is made — with its first characters to
|
||||||
|
tell tokens apart by. It is looked up by that hash on every request: no signed claims, so a deleted
|
||||||
|
token stops working at once. A token about to run out warns its user once, a week before
|
||||||
|
(`warn_expiring`), so a machine does not lose its key unannounced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import zlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from sqlalchemy import delete, or_, select, update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.userscope import owned_by
|
||||||
|
from app import audit, debugsession, logs
|
||||||
|
from app.audit import AuditAction
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import ApiToken, ApiTokenGrant, User
|
||||||
|
from app.notifications import create_notification
|
||||||
|
from app.ratelimit import RateLimiter, client_key
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.security import hash_token
|
||||||
|
from app.utils import iso
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
PREFIX = "sfl_"
|
||||||
|
_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
_BODY_BYTES = 32
|
||||||
|
_BODY_LEN = 43 # 62**43 > 2**256: every 32-byte value fits
|
||||||
|
_CHECK_LEN = 6 # 62**6 > 2**32: every CRC32 fits
|
||||||
|
TOKEN_LEN = len(PREFIX) + _BODY_LEN + _CHECK_LEN
|
||||||
|
# What a token is shown by once it is made: enough to tell a user's tokens apart, far too little to
|
||||||
|
# narrow a guess (8 of its 43 random characters).
|
||||||
|
SHOWN_LEN = len(PREFIX) + 8
|
||||||
|
|
||||||
|
# A machine that runs away (a loop in a script) is held back per token; a caller trying tokens is
|
||||||
|
# held back per client, counting only the refusals.
|
||||||
|
_calls = RateLimiter(600, 60)
|
||||||
|
_bad = RateLimiter(20, 300)
|
||||||
|
# How stale `last_used_at` may get before a request writes it again: a busy script costs one write a
|
||||||
|
# minute, not one per request.
|
||||||
|
_TOUCH_EVERY = timedelta(minutes=1)
|
||||||
|
# How long before a token runs out its user is told (`warn_expiring`); the token list marks it
|
||||||
|
# "expires soon" for the same span.
|
||||||
|
EXPIRY_WARNING = timedelta(days=7)
|
||||||
|
|
||||||
|
|
||||||
|
class Right(StrEnum):
|
||||||
|
LIST = "list"
|
||||||
|
READ = "read"
|
||||||
|
WRITE = "write"
|
||||||
|
DELETE = "delete"
|
||||||
|
|
||||||
|
|
||||||
|
# Each right's column on a grant. A dict over the enum, so a new right without a column fails here.
|
||||||
|
GRANT_COLUMNS = {
|
||||||
|
Right.LIST: "can_list",
|
||||||
|
Right.READ: "can_read",
|
||||||
|
Right.WRITE: "can_write",
|
||||||
|
Right.DELETE: "can_delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _b62(value: int, width: int) -> str:
|
||||||
|
out = []
|
||||||
|
for _ in range(width):
|
||||||
|
value, digit = divmod(value, 62)
|
||||||
|
out.append(_ALPHABET[digit])
|
||||||
|
return "".join(reversed(out))
|
||||||
|
|
||||||
|
|
||||||
|
def _check(body: str) -> str:
|
||||||
|
return _b62(zlib.crc32(body.encode()), _CHECK_LEN)
|
||||||
|
|
||||||
|
|
||||||
|
def mint() -> str:
|
||||||
|
"""A new token, in full. The caller stores its hash and shows it to the user, once."""
|
||||||
|
body = _b62(int.from_bytes(secrets.token_bytes(_BODY_BYTES)), _BODY_LEN)
|
||||||
|
return PREFIX + body + _check(body)
|
||||||
|
|
||||||
|
|
||||||
|
def well_formed(raw: str) -> bool:
|
||||||
|
"""Whether `raw` has a token's shape and its checksum adds up."""
|
||||||
|
if len(raw) != TOKEN_LEN or not raw.startswith(PREFIX):
|
||||||
|
return False
|
||||||
|
body, check = raw[len(PREFIX) : -_CHECK_LEN], raw[-_CHECK_LEN:]
|
||||||
|
# The check too: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it.
|
||||||
|
if any(c not in _ALPHABET for c in body + check):
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(_check(body), check)
|
||||||
|
|
||||||
|
|
||||||
|
def grant_rights(grant: ApiTokenGrant) -> frozenset[Right]:
|
||||||
|
return frozenset(r for r, col in GRANT_COLUMNS.items() if getattr(grant, col))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FilesCaller:
|
||||||
|
"""Who is asking the Files library, and where they may do what. `grants` is None for a signed-in
|
||||||
|
user (everything in their own folders); for a token, folder id → its rights there."""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
token_id: int | None = None
|
||||||
|
token_prefix: str | None = None
|
||||||
|
grants: Mapping[int, frozenset[Right]] | None = None
|
||||||
|
|
||||||
|
def may(self, folder_id: int | None, right: Right) -> bool:
|
||||||
|
if self.grants is None:
|
||||||
|
return True
|
||||||
|
return folder_id is not None and right in self.grants.get(folder_id, frozenset())
|
||||||
|
|
||||||
|
def folders_with(self, right: Right) -> set[int] | None:
|
||||||
|
"""The folders `right` is granted in; None = any of the user's own."""
|
||||||
|
if self.grants is None:
|
||||||
|
return None
|
||||||
|
return {f for f, rights in self.grants.items() if right in rights}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def via(self) -> str | None:
|
||||||
|
"""How an audit row says a token did it, None when the user did."""
|
||||||
|
return None if self.token_prefix is None else f"api token {self.token_prefix}…"
|
||||||
|
|
||||||
|
|
||||||
|
def bearer_token(request: Request) -> str | None:
|
||||||
|
"""The token of an `Authorization: Bearer` header; None when the request carries none (another
|
||||||
|
scheme counts as none — it is not ours to judge)."""
|
||||||
|
scheme, _, value = request.headers.get("authorization", "").partition(" ")
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
return None
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def session_caller(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||||
|
"""The signed-in user, or None when the request brings a token instead: a token wins over a
|
||||||
|
cookie, so a machine's request is judged by its grants alone, whatever else it carries."""
|
||||||
|
if bearer_token(request) is not None:
|
||||||
|
return None
|
||||||
|
return current_user(request, db)
|
||||||
|
|
||||||
|
|
||||||
|
def files_caller(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(session_caller),
|
||||||
|
) -> FilesCaller:
|
||||||
|
"""The `/api/files` caller: a signed-in user, or a valid token's user with its grants."""
|
||||||
|
if user is not None:
|
||||||
|
return FilesCaller(user=user)
|
||||||
|
return _token_caller(request, db, bearer_token(request) or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_token(key: str, reason: str) -> Exception:
|
||||||
|
_bad.allow(key)
|
||||||
|
log.info(
|
||||||
|
"a request brought an API token that does not hold",
|
||||||
|
extra=logs.event("apitoken.refused", reason=reason),
|
||||||
|
)
|
||||||
|
return refused(401, "not_authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
|
||||||
|
key = client_key(request)
|
||||||
|
if _bad.exhausted(key):
|
||||||
|
raise refused(429, "api_token_rate_limited")
|
||||||
|
if not well_formed(raw):
|
||||||
|
raise _refuse_token(key, "malformed")
|
||||||
|
token = db.execute(
|
||||||
|
select(ApiToken).where(ApiToken.token_hash == hash_token(raw))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if token is None:
|
||||||
|
raise _refuse_token(key, "unknown")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if token.expires_at is not None and token.expires_at <= now:
|
||||||
|
raise _refuse_token(key, "expired")
|
||||||
|
user = db.get(User, token.user_id)
|
||||||
|
if user is None or not user.is_active or user.is_suspended:
|
||||||
|
raise _refuse_token(key, "account")
|
||||||
|
if not _calls.allow(str(token.id)):
|
||||||
|
raise refused(429, "api_token_rate_limited")
|
||||||
|
grants = {
|
||||||
|
g.folder_id: grant_rights(g)
|
||||||
|
for g in db.execute(select(ApiTokenGrant).where(ApiTokenGrant.token_id == token.id))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
caller = FilesCaller(user=user, token_id=token.id, token_prefix=token.prefix, grants=grants)
|
||||||
|
db.execute(
|
||||||
|
update(ApiToken)
|
||||||
|
.where(
|
||||||
|
ApiToken.id == token.id,
|
||||||
|
or_(ApiToken.last_used_at.is_(None), ApiToken.last_used_at < now - _TOUCH_EVERY),
|
||||||
|
)
|
||||||
|
.values(last_used_at=now)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
# Committed here, ahead of the route: a read-only route never commits, and a write route must
|
||||||
|
# not carry this along into a rollback of its own.
|
||||||
|
db.commit()
|
||||||
|
logs.set_user(user.id, *debugsession.level_for(user, None))
|
||||||
|
request.state.user_id = user.id
|
||||||
|
return caller
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all(db: Session, user_id: int) -> int:
|
||||||
|
"""Revoke every token of `user_id`, each with its audit row (NOT committed). A token is a
|
||||||
|
credential like a session, so whatever ends every session of an account — a password reset or
|
||||||
|
change, "log out everywhere", a revoked untrusted password — ends its tokens too: one minted
|
||||||
|
from a stolen session must not outlive the owner's answer to that theft. Returns how many."""
|
||||||
|
gone = (
|
||||||
|
db.execute(
|
||||||
|
delete(ApiToken).where(owned_by(ApiToken.user_id, user_id)).returning(ApiToken.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for token_id in gone:
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
AuditAction.API_TOKEN_REVOKE,
|
||||||
|
target_type="api_token",
|
||||||
|
target_id=token_id,
|
||||||
|
summary="revoked with every session of the account",
|
||||||
|
)
|
||||||
|
return len(gone)
|
||||||
|
|
||||||
|
|
||||||
|
def expiring(token: ApiToken, now: datetime) -> bool:
|
||||||
|
"""Whether `token` runs out within the warning span (and has not yet)."""
|
||||||
|
return token.expires_at is not None and now < token.expires_at <= now + EXPIRY_WARNING
|
||||||
|
|
||||||
|
|
||||||
|
def warn_expiring(db: Session) -> int:
|
||||||
|
"""Tell each user whose token runs out within `EXPIRY_WARNING` — once per token: the mark and the
|
||||||
|
notice commit together, and a token made again (a new row) warns afresh. Returns how many."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hits = db.execute(
|
||||||
|
update(ApiToken)
|
||||||
|
.where(
|
||||||
|
ApiToken.expiry_warned.is_(False),
|
||||||
|
ApiToken.expires_at.is_not(None),
|
||||||
|
ApiToken.expires_at > now,
|
||||||
|
ApiToken.expires_at <= now + EXPIRY_WARNING,
|
||||||
|
)
|
||||||
|
.values(expiry_warned=True)
|
||||||
|
.returning(ApiToken.id, ApiToken.user_id, ApiToken.name, ApiToken.expires_at)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
).all()
|
||||||
|
for token_id, user_id, name, expires_at in hits:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
"api_token_expiring",
|
||||||
|
title=f"API token {name} expires soon", # English fallback; the client names it from data
|
||||||
|
data={"token_id": token_id, "name": name, "expires_at": iso(expires_at)},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return len(hits)
|
||||||
+32
-15
@@ -1,9 +1,9 @@
|
|||||||
"""Admin/scheduler audit trail: an append-only record of who changed what and when.
|
"""Admin audit trail: an append-only record of who changed what and when.
|
||||||
|
|
||||||
Design (locked): log at ACTION / job-run granularity, NEVER per-row — a scheduler run that
|
Design (locked): log at ACTION granularity, NEVER per-row — an action that touches thousands of
|
||||||
touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are
|
rows is ONE audit row summarising the outcome. Background runs are not actions: they have their
|
||||||
only logged when they actually changed something (or errored); all admin actions and all manual
|
own history (app/jobruns.py). Secrets are never stored (config changes log the key name only).
|
||||||
runs are always logged. Secrets are never stored (config changes log the key name only).
|
Each row keeps the request that made it, so the request's log lines are one click away.
|
||||||
|
|
||||||
`record()` only db.add()s the row — the caller commits it in the SAME transaction as the
|
`record()` only db.add()s the row — the caller commits it in the SAME transaction as the
|
||||||
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
|
||||||
@@ -15,6 +15,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.models import AuditLog
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ class AuditAction:
|
|||||||
USER_SUSPEND = "user_suspend"
|
USER_SUSPEND = "user_suspend"
|
||||||
USER_UNSUSPEND = "user_unsuspend"
|
USER_UNSUSPEND = "user_unsuspend"
|
||||||
USER_DELETE = "user_delete"
|
USER_DELETE = "user_delete"
|
||||||
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are
|
# An admin let a stuck registrant in, or re-sent their confirmation link. Both are
|
||||||
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
||||||
USER_ACTIVATE = "user_activate"
|
USER_ACTIVATE = "user_activate"
|
||||||
USER_VERIFY_RESEND = "user_verify_resend"
|
USER_VERIFY_RESEND = "user_verify_resend"
|
||||||
@@ -36,12 +37,9 @@ class AuditAction:
|
|||||||
INVITE_DENY = "invite_deny"
|
INVITE_DENY = "invite_deny"
|
||||||
INVITE_ADD = "invite_add"
|
INVITE_ADD = "invite_add"
|
||||||
INVITE_DELETE = "invite_delete"
|
INVITE_DELETE = "invite_delete"
|
||||||
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
|
# The admin chose to tell someone their address is whitelisted. Adding the address is
|
||||||
# silent, so this is the act that actually reaches a person — and the one worth a record.
|
# silent, so this is the act that actually reaches a person — and the one worth a record.
|
||||||
INVITE_SEND = "invite_send"
|
INVITE_SEND = "invite_send"
|
||||||
DEMO_ADD = "demo_add"
|
|
||||||
DEMO_REMOVE = "demo_remove"
|
|
||||||
DEMO_RESET = "demo_reset"
|
|
||||||
DISCOVERY_PURGE = "discovery_purge"
|
DISCOVERY_PURGE = "discovery_purge"
|
||||||
# Config
|
# Config
|
||||||
CONFIG_SET = "config_set"
|
CONFIG_SET = "config_set"
|
||||||
@@ -51,16 +49,35 @@ class AuditAction:
|
|||||||
MAINTENANCE_TUNE = "maintenance_tune"
|
MAINTENANCE_TUNE = "maintenance_tune"
|
||||||
SYNC_PAUSE = "sync_pause"
|
SYNC_PAUSE = "sync_pause"
|
||||||
SYNC_RESUME = "sync_resume"
|
SYNC_RESUME = "sync_resume"
|
||||||
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
|
||||||
JOB_RUN = "job_run"
|
|
||||||
# Per-user download cookie/auth credential set or removed (the value is NEVER recorded)
|
# Per-user download cookie/auth credential set or removed (the value is NEVER recorded)
|
||||||
DOWNLOAD_CREDENTIAL_SET = "download_credential_set"
|
DOWNLOAD_CREDENTIAL_SET = "download_credential_set"
|
||||||
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
||||||
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
||||||
AUDIT_CLEAR = "audit_clear"
|
AUDIT_CLEAR = "audit_clear"
|
||||||
# E16: a user sent a debug session's issue to the tracker — the one act that takes their log
|
# A user sent a debug session's issue to the tracker — the one act that takes their log
|
||||||
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
|
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
|
||||||
ISSUE_CREATE = "issue_create"
|
ISSUE_CREATE = "issue_create"
|
||||||
|
# The Files library (`routes.files`): what a user put in, took out, kept or reorganised. Ids
|
||||||
|
# only — a file's or folder's name is the user's own text, not the admin's to read here.
|
||||||
|
FILE_UPLOAD = "file_upload"
|
||||||
|
FILE_DELETE = "file_delete"
|
||||||
|
FILE_KEEP = "file_keep"
|
||||||
|
FILE_UNKEEP = "file_unkeep"
|
||||||
|
FILE_MOVE = "file_move"
|
||||||
|
FILE_FOLDER_CREATE = "file_folder_create"
|
||||||
|
FILE_FOLDER_RENAME = "file_folder_rename"
|
||||||
|
FILE_FOLDER_RETENTION = "file_folder_retention"
|
||||||
|
FILE_FOLDER_DELETE = "file_folder_delete"
|
||||||
|
# A machine's token to the library (`routes.tokens`): made (its folders and rights) or revoked.
|
||||||
|
# What a token then did is the file rows above, their summary naming it.
|
||||||
|
API_TOKEN_CREATE = "api_token_create"
|
||||||
|
API_TOKEN_REVOKE = "api_token_revoke"
|
||||||
|
# A public `/watch` link to a file or a folder (`routes.downloads`): made, its settings changed,
|
||||||
|
# revoked.
|
||||||
|
# Never its token or password — only whether it has one.
|
||||||
|
DOWNLOAD_LINK_CREATE = "download_link_create"
|
||||||
|
DOWNLOAD_LINK_UPDATE = "download_link_update"
|
||||||
|
DOWNLOAD_LINK_REVOKE = "download_link_revoke"
|
||||||
|
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
@@ -86,16 +103,16 @@ def record(
|
|||||||
summary=(summary or None) and summary[:255],
|
summary=(summary or None) and summary[:255],
|
||||||
before=before,
|
before=before,
|
||||||
after=after,
|
after=after,
|
||||||
|
request_id=logs.current_request_id(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def gc(db: Session, retention_days: int) -> int:
|
def gc(db: Session, retention_days: int) -> int:
|
||||||
"""Delete audit rows older than `retention_days`. Returns the number removed. A
|
"""Delete audit rows older than `retention_days`. Returns the number removed. A
|
||||||
retention_days <= 0 disables pruning (keep forever)."""
|
retention_days <= 0 disables pruning (keep forever). Flushes; the job commits."""
|
||||||
if retention_days <= 0:
|
if retention_days <= 0:
|
||||||
return 0
|
return 0
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||||
result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
|
result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
|
||||||
db.commit()
|
|
||||||
return result.rowcount or 0
|
return result.rowcount or 0
|
||||||
|
|||||||
+12
-48
@@ -4,6 +4,7 @@ from sqlalchemy import delete, func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
|
from app.userscope import owned_by
|
||||||
from app import debugsession
|
from app import debugsession
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import logs
|
from app import logs
|
||||||
@@ -24,8 +25,6 @@ def operator_contact() -> str | None:
|
|||||||
return next(iter(sorted(settings.admin_email_set)), None)
|
return next(iter(sorted(settings.admin_email_set)), None)
|
||||||
|
|
||||||
|
|
||||||
DEMO_GOOGLE_SUB = "__demo__"
|
|
||||||
DEMO_EMAIL = "demo@siftlode.local"
|
|
||||||
MAX_SESSION_ACCOUNTS = 6
|
MAX_SESSION_ACCOUNTS = 6
|
||||||
|
|
||||||
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
|
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
|
||||||
@@ -69,7 +68,7 @@ def has_write_scope(user: User) -> bool:
|
|||||||
def env_allowed_canonicals() -> set[str]:
|
def env_allowed_canonicals() -> set[str]:
|
||||||
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
|
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
|
||||||
|
|
||||||
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string,
|
Canonicalising only the Invite branch of `is_allowed` left this one matching the literal string,
|
||||||
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
|
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
|
||||||
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
|
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
|
||||||
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
|
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
|
||||||
@@ -129,7 +128,7 @@ def can_activate(db: Session, user: User) -> bool:
|
|||||||
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
|
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
|
||||||
button that appears and a number that counts must never disagree about who is stuck.
|
button that appears and a number that counts must never disagree about who is stuck.
|
||||||
"""
|
"""
|
||||||
if user.is_demo or user.is_suspended or user.is_active:
|
if user.is_suspended or user.is_active:
|
||||||
return False
|
return False
|
||||||
return is_allowed(db, user.email)
|
return is_allowed(db, user.email)
|
||||||
|
|
||||||
@@ -147,7 +146,6 @@ def count_pending_activations(db: Session) -> int:
|
|||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(User).where(
|
select(User).where(
|
||||||
User.is_demo.is_(False),
|
|
||||||
User.is_suspended.is_(False),
|
User.is_suspended.is_(False),
|
||||||
User.is_active.is_(False),
|
User.is_active.is_(False),
|
||||||
)
|
)
|
||||||
@@ -188,24 +186,6 @@ def find_user_by_email(db: Session, email: str) -> User | None:
|
|||||||
return candidates[0] if len(candidates) == 1 else None
|
return candidates[0] if len(candidates) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_demo_user(db: Session) -> User:
|
|
||||||
"""The one shared demo user, created on first use. No OAuth token / YouTube scope, so
|
|
||||||
has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it."""
|
|
||||||
user = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
|
|
||||||
if user is None:
|
|
||||||
user = User(
|
|
||||||
google_sub=DEMO_GOOGLE_SUB,
|
|
||||||
email=DEMO_EMAIL,
|
|
||||||
display_name="Demo",
|
|
||||||
role="user",
|
|
||||||
is_demo=True,
|
|
||||||
preferences={"language": "en"},
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
db.commit()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def revoke_google_token(token: str | None) -> None:
|
def revoke_google_token(token: str | None) -> None:
|
||||||
"""Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh
|
"""Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh
|
||||||
token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes
|
token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes
|
||||||
@@ -251,7 +231,7 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
|||||||
# request's access line, a queued row, a later background task would otherwise put rows back
|
# request's access line, a queued row, a later background task would otherwise put rows back
|
||||||
# under the id after this DELETE (logs.forget_user).
|
# under the id after this DELETE (logs.forget_user).
|
||||||
logs.forget_user(user_id)
|
logs.forget_user(user_id)
|
||||||
db.execute(delete(LogEvent).where(LogEvent.user_id == user_id))
|
db.execute(delete(LogEvent).where(owned_by(LogEvent.user_id, user_id)))
|
||||||
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
|
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
|
||||||
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
|
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
|
||||||
# account was just erased — and prod really does carry approved pairs for one mailbox.
|
# account was just erased — and prod really does carry approved pairs for one mailbox.
|
||||||
@@ -278,8 +258,7 @@ ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
|||||||
|
|
||||||
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
|
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
|
||||||
"""Which account this connection acts as, and whether that's the session's default account.
|
"""Which account this connection acts as, and whether that's the session's default account.
|
||||||
Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this
|
Takes an HTTPConnection rather than a Request: it reads only the session and the headers.
|
||||||
exact per-tab resolution instead of re-implementing it.
|
|
||||||
|
|
||||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
||||||
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
|
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
|
||||||
@@ -297,8 +276,8 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
|
|||||||
"""
|
"""
|
||||||
default_id = conn.session.get("user_id")
|
default_id = conn.session.get("user_id")
|
||||||
wallet = conn.session.get("account_ids") or []
|
wallet = conn.session.get("account_ids") or []
|
||||||
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
|
# Header for normal XHR; ?account= for a context that can't set headers (a plain <a> file
|
||||||
# plain <a> file download). Both are wallet-gated below.
|
# download). Both are wallet-gated below.
|
||||||
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
||||||
if hdr:
|
if hdr:
|
||||||
try:
|
try:
|
||||||
@@ -362,12 +341,12 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
|||||||
if default_id and default_id not in accounts:
|
if default_id and default_id not in accounts:
|
||||||
accounts.append(default_id)
|
accounts.append(default_id)
|
||||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||||
# Attribute this request's log lines to the account (E22). Here, not in the request middleware:
|
# Attribute this request's log lines to the account. Here, not in the request middleware:
|
||||||
# this is the one place a session becomes an account, and the middleware runs outside both the
|
# this is the one place a session becomes an account, and the middleware runs outside both the
|
||||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||||
# (it lives on the ASGI scope), so the access-log line can read it back. The user's consented
|
# (it lives on the ASGI scope), so the access-log line can read it back. The user's consented
|
||||||
# diagnostics level rides along: their requests are logged — and stored — in that detail (S5).
|
# diagnostics level rides along: their requests are logged — and stored — in that detail.
|
||||||
# So does a debug session the request names (E16): only the caller's own open one counts, and a
|
# So does a debug session the request names: only the caller's own open one counts, and a
|
||||||
# header naming anything else is ignored rather than refused — a stale tab must keep working.
|
# header naming anything else is ignored rather than refused — a stale tab must keep working.
|
||||||
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
|
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
|
||||||
level, until = debugsession.level_for(user, session)
|
level, until = debugsession.level_for(user, session)
|
||||||
@@ -403,25 +382,10 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def require_human(user: User = Depends(current_user)) -> User:
|
def require_write_scope(user: User = Depends(current_user)) -> User:
|
||||||
"""Reject the shared demo account from actions that need a real Google/YouTube identity
|
|
||||||
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
|
|
||||||
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
|
||||||
where there's no scope check to lean on."""
|
|
||||||
if user.is_demo:
|
|
||||||
# Structured, because `req` leaves 403 to the caller and `notifyActionError` only shows a
|
|
||||||
# STRUCTURED detail — a plain string here reached the demo user as their caller's generic
|
|
||||||
# "couldn't do that", with no hint that the demo account is the reason (round 6). Affordances
|
|
||||||
# this gate sits behind are not all UI-gated on `is_demo` (the comfort shelf is not).
|
|
||||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def require_write_scope(user: User = Depends(require_human)) -> User:
|
|
||||||
"""Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit).
|
"""Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit).
|
||||||
Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)`
|
Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)`
|
||||||
checks that had drifted into three different 403 messages across channels/playlists. Layers on
|
checks that had drifted into three different 403 messages across channels/playlists."""
|
||||||
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
|
||||||
if not has_write_scope(user):
|
if not has_write_scope(user):
|
||||||
raise refused(403, "youtube_editing_off")
|
raise refused(403, "youtube_editing_off")
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
|||||||
# a say.
|
# a say.
|
||||||
#
|
#
|
||||||
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
|
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
|
||||||
# and the owner check resolves first). The upload route did NOT until P1: it declared
|
# and the owner check resolves first). The upload route did NOT at first: it declared
|
||||||
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
|
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
|
||||||
# per-file allowance to temp before `require_human` refused them — this middleware bounded that
|
# per-file allowance to temp before the auth dependency refused them — this middleware bounded that
|
||||||
# number but could not stop the writing. It now reads its own body from a dependency declared after
|
# number but could not stop the writing. It now reads its own body from a dependency declared after
|
||||||
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
|
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
|
||||||
#
|
#
|
||||||
@@ -73,12 +73,12 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
|
|||||||
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
|
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
|
||||||
# default, so listing it could only widen what it accepts.
|
# default, so listing it could only widen what it accepts.
|
||||||
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
||||||
# Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for —
|
# Ingesting a local file. The body IS the file now — no multipart envelope to allow for —
|
||||||
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
|
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
|
||||||
# `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an
|
# `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an
|
||||||
# admin can only ever LOWER the route's cap below the edge, never raise it past one.
|
# admin can only ever LOWER the route's cap below the edge, never raise it past one.
|
||||||
#
|
#
|
||||||
# WHICH LAYER ANSWERS, measured rather than assumed (deep-review round 1):
|
# WHICH LAYER ANSWERS, measured rather than assumed:
|
||||||
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so
|
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so
|
||||||
# `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own
|
# `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own
|
||||||
# figure, after draining the rest of the body so the client can read it.
|
# figure, after draining the rest of the body so the client can read it.
|
||||||
@@ -92,8 +92,8 @@ ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
|||||||
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
|
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
|
||||||
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
|
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
|
||||||
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
|
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
|
||||||
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
|
# sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports.
|
||||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
|
||||||
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Catalog's tables: the shared YouTube world every user reads — channels and their videos — and
|
||||||
|
each user's footprint in it: what they subscribe to, block, explored or found by search. Only this
|
||||||
|
unit writes them; another unit reads them along a declared edge, or asks this unit's service."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Computed,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
from app.models import TimestampMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Channel(Base, TimestampMixin):
|
||||||
|
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
||||||
|
for everyone), so its expensive metadata is fetched and stored only once."""
|
||||||
|
|
||||||
|
__tablename__ = "channels"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True) # "UC..." channel id
|
||||||
|
title: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
handle: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
video_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
# Channel "About" metadata (fetched with the same channels.list call, no extra quota):
|
||||||
|
# lifetime view count, the channel's join date, banner image, and external links.
|
||||||
|
total_view_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
banner_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
external_links: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
country: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
# Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string,
|
||||||
|
# multi-word tags quoted; the client parses it into chips for the About tab.
|
||||||
|
keywords: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
# Sync bookkeeping.
|
||||||
|
details_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))
|
||||||
|
# When the newest uploads were last read through the Data API because the RSS feed would not
|
||||||
|
# answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock.
|
||||||
|
feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# True when this channel only entered the catalog because a live YouTube search
|
||||||
|
# surfaced one of its videos (nobody subscribes to it / it has no organic upload sync).
|
||||||
|
# Lets the Library hide search-only "noise" by default.
|
||||||
|
from_search: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", index=True
|
||||||
|
)
|
||||||
|
# True when this channel exists only because a user EXPLORED it (opened its page without
|
||||||
|
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
|
||||||
|
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
|
||||||
|
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
|
||||||
|
# subscribes (the "keep" signal promotes it to organic).
|
||||||
|
from_explore: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
||||||
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(Base, TimestampMixin):
|
||||||
|
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
||||||
|
|
||||||
|
__tablename__ = "subscriptions"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
channel_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# YouTube's subscription resource id, needed to unsubscribe via the API.
|
||||||
|
yt_subscription_id: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# Per-user opt-in to full-history (deep) backfill of this channel. Default off so a
|
||||||
|
# new user's unique channels don't trigger a big shared-quota burst; the deep
|
||||||
|
# scheduler only picks up channels at least one user has requested.
|
||||||
|
deep_requested: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
||||||
|
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
||||||
|
|
||||||
|
|
||||||
|
# Video.live_status values that mean "currently a live/upcoming broadcast" — transient states
|
||||||
|
# hidden from feeds/search and revisited by the refresh job until they settle into a VOD.
|
||||||
|
LIVE_OR_UPCOMING = ("live", "upcoming")
|
||||||
|
|
||||||
|
|
||||||
|
class Video(Base, TimestampMixin):
|
||||||
|
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
||||||
|
|
||||||
|
__tablename__ = "videos"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
||||||
|
# No standalone channel_id index: the composite `ix_videos_channel_pub_cover`
|
||||||
|
# (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it
|
||||||
|
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed
|
||||||
|
# sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only
|
||||||
|
# Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only).
|
||||||
|
# Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
|
||||||
|
channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||||
|
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
||||||
|
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key
|
||||||
|
# forced the feed's keyset predicate into a non-sargable `... OR published_at IS NULL` disjunction
|
||||||
|
# that blocked the ix_videos_published_at_desc range (deep pages scanned from the top and filtered —
|
||||||
|
# PERF-15). server_default now() is the safety net for the rare date-less ingest (the enrich fills
|
||||||
|
# the real date); the ix_videos_published_at index stays for the `oldest` sort.
|
||||||
|
published_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), index=True, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
# The feed's views / duration / title sorts each ride a migration-defined index in their exact
|
||||||
|
# ORDER BY, one per direction (and per ICU collation for title): migration 0096, held to
|
||||||
|
# `feedquery.order_by` by test_feed_sort_indexes.
|
||||||
|
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
view_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
like_count: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
detected_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
# Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the
|
||||||
|
# snippet we already request. Indexed into search_vector so the feed search matches the
|
||||||
|
# uploader's own keywords, not just words in the title.
|
||||||
|
keywords: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Accumulated distinct live-search queries that surfaced this video (across all users) —
|
||||||
|
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
|
||||||
|
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
||||||
|
search_terms: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# No btree here or on the flag columns below: migration 0069 (PERF-3) dropped the never-picked
|
||||||
|
# boolean btrees and replaced the useful ones with partial indexes over the target subset
|
||||||
|
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
|
||||||
|
# search_vector GIN.
|
||||||
|
is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||||
|
shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# True when this video first entered the catalog via a live YouTube search (not an
|
||||||
|
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
||||||
|
# Library by default; left False by the organic ingest paths.
|
||||||
|
via_search: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
||||||
|
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
||||||
|
# additionally PER-USER private: a via_explore video is only visible to users who
|
||||||
|
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
||||||
|
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||||
|
via_explore: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# none | live | upcoming | premiere | was_live
|
||||||
|
live_status: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="none", server_default="none", index=True
|
||||||
|
)
|
||||||
|
enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# videos.list?part=status fields (populated during enrichment); used by the
|
||||||
|
# maintenance/validation job to decide whether a video is playable anywhere.
|
||||||
|
embeddable: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private
|
||||||
|
upload_status: Mapped[str | None] = mapped_column(
|
||||||
|
String(16)
|
||||||
|
) # processed|failed|rejected|deleted
|
||||||
|
# Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation
|
||||||
|
# (least-recently-checked first); unavailable_since marks a video as currently
|
||||||
|
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
||||||
|
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
|
||||||
|
# last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial
|
||||||
|
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
|
||||||
|
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
|
||||||
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
|
|
||||||
|
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
|
||||||
|
# creator keywords + search queries (B) > truncated description (C). ts_rank honours the
|
||||||
|
# weights so title matches outrank description matches. Backed by a GIN index (migration
|
||||||
|
# 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031.
|
||||||
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
|
||||||
|
"coalesce(search_terms, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFind(Base, TimestampMixin):
|
||||||
|
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
||||||
|
|
||||||
|
Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source
|
||||||
|
filter — distinct from the GLOBAL `Video.via_search` provenance, which marks a video as
|
||||||
|
search-discovered by *anyone* and drives the shared Library's Source filter."""
|
||||||
|
|
||||||
|
__tablename__ = "search_finds"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExploredChannel(Base, TimestampMixin):
|
||||||
|
"""Per-user record of a channel the user opened the page of WITHOUT subscribing.
|
||||||
|
|
||||||
|
Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the
|
||||||
|
user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the
|
||||||
|
explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period
|
||||||
|
purge: a from_explore channel with no live ExploredChannel row (and no subscription / no
|
||||||
|
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
|
||||||
|
signal — it promotes the channel to organic and these rows become irrelevant. `created_at`
|
||||||
|
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
||||||
|
|
||||||
|
__tablename__ = "explored_channels"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_explored_channel"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
channel_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedChannel(Base, TimestampMixin):
|
||||||
|
"""Per-user channel blocklist. Videos from a blocked channel are excluded from this user's
|
||||||
|
live YouTube search (and not ingested on their behalf) and hidden from their feed / explore
|
||||||
|
views — a personal "never show me this channel again" that doesn't affect other users."""
|
||||||
|
|
||||||
|
__tablename__ = "blocked_channels"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
channel_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Catalog's reads: channels and videos, and each user's footprint in them (subscriptions, blocks,
|
||||||
|
explorations), for the HTTP layer and for the core code that asks about them.
|
||||||
|
|
||||||
|
A user's row is read through `userscope`, so someone else's is the same answer as a missing one.
|
||||||
|
Catalog does not read another unit's tables: what it needs from Reader (a playlist's channels, a
|
||||||
|
user's tag links) it asks `reader.queries`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Select, and_, case, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.catalog.models import (
|
||||||
|
LIVE_OR_UPCOMING,
|
||||||
|
BlockedChannel,
|
||||||
|
Channel,
|
||||||
|
ExploredChannel,
|
||||||
|
Subscription,
|
||||||
|
Video,
|
||||||
|
)
|
||||||
|
from app.collation import collated
|
||||||
|
from app.models import User
|
||||||
|
from app.sync.explore import followed_channel
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
|
# --- channels --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def channel(db: Session, channel_id: str) -> Channel | None:
|
||||||
|
return db.get(Channel, channel_id)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_title(db: Session, channel_id: str) -> str | None:
|
||||||
|
return db.scalar(select(Channel.title).where(Channel.id == channel_id))
|
||||||
|
|
||||||
|
|
||||||
|
def channel_titles(db: Session, ids: set[str]) -> dict[str, str]:
|
||||||
|
"""Each channel's title by id; a channel with no title yet is left out."""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(select(Channel.id, Channel.title).where(Channel.id.in_(ids)))
|
||||||
|
return {cid: title for cid, title in rows.tuples() if title is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def channel_aggregates(channel_ids: list[str]) -> Select:
|
||||||
|
"""Per-channel stored count, last upload and total duration, in one grouped pass. Every column it
|
||||||
|
reads is in `ix_videos_channel_pub_cover` (migration 0095), so Postgres answers it from the index
|
||||||
|
alone; a test holds that (test_channel_list_index_only)."""
|
||||||
|
return (
|
||||||
|
select(
|
||||||
|
Video.channel_id,
|
||||||
|
func.count(Video.id),
|
||||||
|
func.max(Video.published_at),
|
||||||
|
func.coalesce(func.sum(Video.duration_seconds), 0),
|
||||||
|
)
|
||||||
|
.where(Video.channel_id.in_(channel_ids))
|
||||||
|
.group_by(Video.channel_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregates(db: Session, channel_ids: list[str]) -> list[tuple[str, int, datetime | None, int]]:
|
||||||
|
"""`channel_aggregates`, run: (channel id, stored videos, last upload, total seconds)."""
|
||||||
|
if not channel_ids:
|
||||||
|
return []
|
||||||
|
return list(db.execute(channel_aggregates(channel_ids)).tuples())
|
||||||
|
|
||||||
|
|
||||||
|
def video_counts(db: Session, channel_id: str) -> tuple[int, int, int]:
|
||||||
|
"""A channel's stored videos, its Shorts and its live/upcoming ones, in one grouped pass."""
|
||||||
|
n_all, n_short, n_live = db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Video.id),
|
||||||
|
func.count(Video.id).filter(Video.is_short.is_(True)),
|
||||||
|
func.count(Video.id).filter(Video.live_status.in_(LIVE_OR_UPCOMING)),
|
||||||
|
).where(Video.channel_id == channel_id)
|
||||||
|
).one()
|
||||||
|
return int(n_all or 0), int(n_short or 0), int(n_live or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def deep_queued(db: Session, channel_ids: list[str]) -> set[str]:
|
||||||
|
"""Which of these channels at least one user (any user) has asked full history for."""
|
||||||
|
if not channel_ids:
|
||||||
|
return set()
|
||||||
|
return set(
|
||||||
|
db.execute(
|
||||||
|
select(Subscription.channel_id)
|
||||||
|
.where(
|
||||||
|
Subscription.channel_id.in_(channel_ids),
|
||||||
|
Subscription.deep_requested.is_(True),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def followed(db: Session, channel_id: str) -> bool:
|
||||||
|
"""Whether anyone subscribes to the channel."""
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(Subscription.id).where(Subscription.channel_id == channel_id).limit(1)
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- a user's subscriptions, blocks and explorations ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def subscriptions(db: Session, user: User, lang: str) -> list[tuple[Subscription, Channel]]:
|
||||||
|
"""The user's subscriptions with their channels, highest priority first, then by title in the
|
||||||
|
reader's alphabet."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(Subscription, Channel)
|
||||||
|
.join(Channel, Channel.id == Subscription.channel_id)
|
||||||
|
.where(owned_by(Subscription.user_id, user))
|
||||||
|
.order_by(Subscription.priority.desc(), collated(Channel.title, lang))
|
||||||
|
).tuples()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def subscription(db: Session, user: User, channel_id: str) -> Subscription | None:
|
||||||
|
return db.execute(
|
||||||
|
select(Subscription).where(
|
||||||
|
owned_by(Subscription.user_id, user), Subscription.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def subscribed_channels(user: User):
|
||||||
|
"""The channel ids the user subscribes to, as a subquery."""
|
||||||
|
return select(Subscription.channel_id).where(owned_by(Subscription.user_id, user))
|
||||||
|
|
||||||
|
|
||||||
|
def exploration(db: Session, user: User, channel_id: str) -> ExploredChannel | None:
|
||||||
|
return db.execute(
|
||||||
|
select(ExploredChannel).where(
|
||||||
|
owned_by(ExploredChannel.user_id, user), ExploredChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def is_blocked(db: Session, user: User, channel_id: str) -> bool:
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
owned_by(BlockedChannel.user_id, user), BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def blocked_ids(db: Session, user: User) -> set[str]:
|
||||||
|
return set(
|
||||||
|
db.scalars(select(BlockedChannel.channel_id).where(owned_by(BlockedChannel.user_id, user)))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def blocked_channels(db: Session, user: User, lang: str) -> list[Channel]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(Channel)
|
||||||
|
.join(BlockedChannel, BlockedChannel.channel_id == Channel.id)
|
||||||
|
.where(owned_by(BlockedChannel.user_id, user))
|
||||||
|
.order_by(collated(Channel.title, lang))
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- sync progress ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_totals(db: Session, user: User) -> dict:
|
||||||
|
"""The instance's catalog size and this user's subscription count (the sync status card)."""
|
||||||
|
return {
|
||||||
|
"subscriptions": db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Subscription)
|
||||||
|
.where(owned_by(Subscription.user_id, user))
|
||||||
|
),
|
||||||
|
"channels_total": db.scalar(select(func.count()).select_from(Channel)),
|
||||||
|
"channels_backfilling": db.scalar(
|
||||||
|
select(func.count()).select_from(Channel).where(Channel.backfill_done.is_(False))
|
||||||
|
),
|
||||||
|
"videos_total": db.scalar(select(func.count()).select_from(Video)),
|
||||||
|
"pending_enrich": db.scalar(
|
||||||
|
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def my_progress(db: Session, user: User) -> tuple[int, int, int, int]:
|
||||||
|
"""Over the channels this user subscribes to: how many, how many have details, how many their
|
||||||
|
recent uploads, how many their whole history."""
|
||||||
|
total, details_synced, recent_synced, all_deep_done = db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Channel.id),
|
||||||
|
func.count(Channel.details_synced_at),
|
||||||
|
func.count(Channel.recent_synced_at),
|
||||||
|
func.sum(case((Channel.backfill_done.is_(True), 1), else_=0)),
|
||||||
|
)
|
||||||
|
.select_from(Channel)
|
||||||
|
.join(
|
||||||
|
Subscription,
|
||||||
|
and_(Subscription.channel_id == Channel.id, owned_by(Subscription.user_id, user)),
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
return total or 0, details_synced or 0, recent_synced or 0, int(all_deep_done or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def my_deep_requested(db: Session, user: User) -> list[Channel]:
|
||||||
|
"""The channels this user asked full history for."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(Channel).join(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Channel.id,
|
||||||
|
owned_by(Subscription.user_id, user),
|
||||||
|
Subscription.deep_requested.is_(True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def my_video_count(db: Session, user: User) -> int:
|
||||||
|
"""The videos of the channels this user subscribes to and has not hidden."""
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(Video.id)).join(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
owned_by(Subscription.user_id, user),
|
||||||
|
Subscription.hidden.is_(False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def video_total(db: Session) -> int:
|
||||||
|
return db.scalar(select(func.count(Video.id))) or 0
|
||||||
|
|
||||||
|
|
||||||
|
def deep_backlog(db: Session) -> list[Channel]:
|
||||||
|
"""Every channel someone asked full history for that does not have it yet."""
|
||||||
|
deep_requested = (
|
||||||
|
select(Subscription.id)
|
||||||
|
.where(Subscription.channel_id == Channel.id, Subscription.deep_requested.is_(True))
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(Channel).where(Channel.backfill_done.is_(False), deep_requested)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def work_queue(db: Session) -> dict:
|
||||||
|
"""What the catalog's jobs still have queued, instance-wide (the scheduler card)."""
|
||||||
|
return {
|
||||||
|
# The same selection run_recent_backfill works through, or the card counts channels the
|
||||||
|
# job will never touch and never reaches zero.
|
||||||
|
"channels_recent_pending": db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Channel)
|
||||||
|
.where(Channel.recent_synced_at.is_(None), followed_channel())
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
"videos_pending_enrich": db.scalar(
|
||||||
|
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
"videos_pending_shorts": db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Video)
|
||||||
|
.where(Video.shorts_probed.is_(False), Video.enriched_at.is_not(None))
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
"videos_live_refresh": db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Video)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Video.live_status.in_(("live", "upcoming")),
|
||||||
|
and_(Video.live_status == "was_live", Video.duration_seconds.is_(None)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Live YouTube search, the catalog's side: what a page of results leaves in the shared catalog.
|
||||||
|
|
||||||
|
A result becomes a catalog row so it renders with the normal feed cards and the in-app player and
|
||||||
|
gains per-user state. The ingest runs in steps with a YouTube call between each, and each step's
|
||||||
|
rows are committed before the next call: an enrich that fails rolls back to the stubs, which stay
|
||||||
|
(the FK targets for what follows, backfilled later). Results are flagged (`videos.via_search`,
|
||||||
|
`channels.from_search`) so the Library can hide this search-discovered noise; Shorts and
|
||||||
|
live/upcoming videos are never kept. The HTTP layer (`routes/search.py`) runs the paging, the caps
|
||||||
|
and the refusals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.engine import CursorResult
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs, quota, sysconfig
|
||||||
|
from app.catalog import queries
|
||||||
|
from app.catalog.models import LIVE_OR_UPCOMING, Channel, SearchFind, Subscription, Video
|
||||||
|
from app.models import User
|
||||||
|
from app.reader import queries as reader_queries
|
||||||
|
from app.sync.subscriptions import apply_channel_details
|
||||||
|
from app.sync.videos import apply_video_details, insert_stubs, parse_dt
|
||||||
|
from app.userscope import owned_by
|
||||||
|
from app.utils import like_escape
|
||||||
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.youtube.shorts import make_client, probe_is_short
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_shorts(db: Session, videos: Sequence[Video]) -> None:
|
||||||
|
"""Confirm/deny Shorts for the freshly-ingested videos so none slip into the feed.
|
||||||
|
|
||||||
|
Mirrors the scheduler's run_shorts_classification, but scoped to just these rows and run
|
||||||
|
synchronously (the search is an explicit, awaited action). Non-candidates (too long, no
|
||||||
|
duration, or live) are finalised without a probe; the rest hit youtube.com/shorts (no
|
||||||
|
quota). Undetermined probes are left for the scheduler to retry."""
|
||||||
|
probe_max = sysconfig.get_int(db, "shorts_probe_max_seconds")
|
||||||
|
to_probe: list[Video] = []
|
||||||
|
for v in videos:
|
||||||
|
if v.shorts_probed:
|
||||||
|
continue
|
||||||
|
if v.enriched_at is None:
|
||||||
|
# Enrichment failed (proxy blip) or YouTube omitted the row — duration is unknown,
|
||||||
|
# so DON'T finalise shorts_probed off a null duration; leave it for the scheduler
|
||||||
|
# (which only classifies enriched rows) to retry. Otherwise a real Short would leak.
|
||||||
|
continue
|
||||||
|
if v.live_status != "none" or v.duration_seconds is None or v.duration_seconds > probe_max:
|
||||||
|
v.is_short = False
|
||||||
|
v.shorts_probed = True
|
||||||
|
else:
|
||||||
|
to_probe.append(v)
|
||||||
|
if to_probe:
|
||||||
|
with make_client() as client:
|
||||||
|
|
||||||
|
def work(v: Video):
|
||||||
|
return v, probe_is_short(client, v.id)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||||
|
for v, result in pool.map(work, to_probe):
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
v.is_short = result
|
||||||
|
v.shorts_probed = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ingest_candidates(
|
||||||
|
db: Session, yt: YouTubeClient, user: User, candidates: list[dict]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Materialise one page of search candidates into the catalog (channel stubs + enrich,
|
||||||
|
video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
|
||||||
|
video ids — non-Short, non-live — in the input relevance order."""
|
||||||
|
ids = [it["id"] for it in candidates]
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 1) Channels are the FK target — create stubs for unknown ones (flagged from_search),
|
||||||
|
# then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
|
||||||
|
channel_ids = list({it["channel_id"] for it in candidates})
|
||||||
|
existing = set(db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all())
|
||||||
|
new_channel_ids = [c for c in channel_ids if c not in existing]
|
||||||
|
if new_channel_ids:
|
||||||
|
titles = {it["channel_id"]: it["channel_title"] for it in candidates}
|
||||||
|
for cid in new_channel_ids:
|
||||||
|
db.add(Channel(id=cid, title=titles.get(cid), from_search=True))
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
|
||||||
|
details = yt.get_channels(new_channel_ids)
|
||||||
|
apply_channel_details(db, details, new_channel_ids)
|
||||||
|
db.commit()
|
||||||
|
except YouTubeError:
|
||||||
|
# The stubs remain and the details can be backfilled later — but until they are, those
|
||||||
|
# channels sit in the results with no thumbnail and no subscriber count, looking like
|
||||||
|
# channels that have neither.
|
||||||
|
log.warning(
|
||||||
|
"the channels found by a search could not be enriched",
|
||||||
|
extra=logs.event("search.channel_enrich_failed", channels=len(new_channel_ids)),
|
||||||
|
)
|
||||||
|
db.rollback() # details can be backfilled later; the stubs remain
|
||||||
|
|
||||||
|
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
|
||||||
|
# NOTHING), so an organically-synced video stays organic.
|
||||||
|
# published_at is NOT NULL (migration 0068); the scrape source exposes no publish date
|
||||||
|
# (only a relative "3 years ago"), so fall back to now() to satisfy the constraint — the
|
||||||
|
# enrich in step 3 overwrites it with the video's real snippet date.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
insert_stubs(
|
||||||
|
db,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": it["id"],
|
||||||
|
"channel_id": it["channel_id"],
|
||||||
|
"title": it["title"],
|
||||||
|
"published_at": parse_dt(it["published_at"]) or now,
|
||||||
|
"thumbnail_url": it["thumbnail_url"],
|
||||||
|
"via_search": True,
|
||||||
|
}
|
||||||
|
for it in candidates
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live.
|
||||||
|
videos = db.scalars(select(Video).where(Video.id.in_(ids))).all()
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
||||||
|
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
|
||||||
|
except YouTubeError:
|
||||||
|
# Every candidate then stays unenriched, and an unenriched video cannot be judged live or
|
||||||
|
# Short — so it is dropped. A working search returns nothing and reads as "no results".
|
||||||
|
log.warning(
|
||||||
|
"the videos found by a search could not be enriched",
|
||||||
|
extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
|
||||||
|
)
|
||||||
|
detail_by_id = {}
|
||||||
|
enriched_at = datetime.now(
|
||||||
|
timezone.utc
|
||||||
|
) # when THIS enrich ran (distinct from the stub placeholder)
|
||||||
|
for v in videos:
|
||||||
|
item = detail_by_id.get(v.id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
apply_video_details(v, item)
|
||||||
|
v.enriched_at = enriched_at
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 4) Confirm/deny Shorts (no quota) so none enter via search.
|
||||||
|
classify_shorts(db, videos)
|
||||||
|
|
||||||
|
keep = {
|
||||||
|
v.id
|
||||||
|
for v in videos
|
||||||
|
if v.enriched_at is not None and not v.is_short and v.live_status not in LIVE_OR_UPCOMING
|
||||||
|
}
|
||||||
|
return [vid for vid in ids if vid in keep]
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_term(db: Session, video_ids: list[str], term: str) -> None:
|
||||||
|
"""Append `term` to each video's space-joined `search_terms`, unless it is already one of them.
|
||||||
|
|
||||||
|
"Already one of them" is a whole-term match, as a literal: a substring test skipped `cat` on a
|
||||||
|
video already found for `category`, and an unescaped one read the `_` in `my_channel` as a
|
||||||
|
wildcard — either way the term was never folded in, and local search lost that relevance."""
|
||||||
|
padded = func.concat(" ", func.coalesce(Video.search_terms, ""), " ")
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(Video.id.in_(video_ids))
|
||||||
|
.where(padded.notilike(f"% {like_escape(term)} %"))
|
||||||
|
.values(
|
||||||
|
search_terms=func.trim(
|
||||||
|
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def new_first(db: Session, user: User, ids: list[str]) -> list[str]:
|
||||||
|
"""Reorder results so genuinely-new discoveries come first and things the user already has
|
||||||
|
(a subscribed channel, or a video they've watched / in-progress / saved / playlisted) sink
|
||||||
|
to the back — keeping the relevance order within each group."""
|
||||||
|
if not ids:
|
||||||
|
return ids
|
||||||
|
have = set(
|
||||||
|
db.scalars(
|
||||||
|
select(Video.id).where(
|
||||||
|
Video.id.in_(ids), Video.channel_id.in_(queries.subscribed_channels(user))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) | reader_queries.held_by(db, user, ids)
|
||||||
|
return [i for i in ids if i not in have] + [i for i in ids if i in have]
|
||||||
|
|
||||||
|
|
||||||
|
def record_finds(db: Session, user: User, video_ids: list[str], term: str) -> None:
|
||||||
|
"""These results as THIS user's search finds (the Mine feed, the Source filter), and the term
|
||||||
|
folded into each one's `search_terms` so local full-text search inherits YouTube's relevance.
|
||||||
|
Idempotent."""
|
||||||
|
db.execute(
|
||||||
|
pg_insert(SearchFind)
|
||||||
|
.values([{"user_id": user.id, "video_id": vid} for vid in video_ids])
|
||||||
|
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
||||||
|
)
|
||||||
|
_fold_term(db, video_ids, term)
|
||||||
|
|
||||||
|
|
||||||
|
def clear(db: Session, user: User, video_ids: list[str]) -> int:
|
||||||
|
"""Discard these results "as if never added": drop this user's finds of them, then delete the
|
||||||
|
ones nobody kept — a `via_search` video no reader holds (`reader.queries.kept_by_a_reader`),
|
||||||
|
whose channel nobody subscribes to, and that no other user still has as a find. Returns how
|
||||||
|
many videos went."""
|
||||||
|
db.execute(
|
||||||
|
delete(SearchFind).where(
|
||||||
|
owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
|
||||||
|
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
|
||||||
|
result = db.execute(
|
||||||
|
delete(Video).where(
|
||||||
|
Video.id.in_(video_ids),
|
||||||
|
Video.via_search.is_(True),
|
||||||
|
~reader_queries.kept_by_a_reader(Video.id),
|
||||||
|
~subscribed,
|
||||||
|
~other_find,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.rowcount if isinstance(result, CursorResult) else 0
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Catalog's writes a request makes: a user's subscription settings, what they explore or block, a
|
||||||
|
subscription recorded or dropped, a channel's sync markers reset.
|
||||||
|
|
||||||
|
Every function here flushes and leaves the commit to its caller (the handler). The YouTube side of a
|
||||||
|
subscribe or an unsubscribe is the handler's (it needs the user's client and its refusals); what
|
||||||
|
lands here is the local record of it. The ingest jobs (`sync/`, `catalog/search`) write the
|
||||||
|
catalog's shared rows themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete, update
|
||||||
|
from sqlalchemy.engine import CursorResult
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.catalog import queries
|
||||||
|
from app.catalog.models import BlockedChannel, Channel, ExploredChannel, Subscription, Video
|
||||||
|
from app.models import User
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
|
|
||||||
|
def remember_own_channel(user: User, channel_id: str) -> None:
|
||||||
|
"""The user's own YouTube channel, so discovery never offers it to them."""
|
||||||
|
user.yt_channel_id = channel_id
|
||||||
|
|
||||||
|
|
||||||
|
def update_subscription(
|
||||||
|
db: Session,
|
||||||
|
sub: Subscription,
|
||||||
|
*,
|
||||||
|
priority: int | None = None,
|
||||||
|
hidden: bool | None = None,
|
||||||
|
deep_requested: bool | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Change the settings given (None leaves one as it is). Returns whether full history was just
|
||||||
|
turned ON — the caller then pulls the channel now rather than at the scheduler's next tick."""
|
||||||
|
turned_on = False
|
||||||
|
if priority is not None:
|
||||||
|
sub.priority = priority
|
||||||
|
if hidden is not None:
|
||||||
|
sub.hidden = hidden
|
||||||
|
if deep_requested is not None:
|
||||||
|
# Turning it off won't undo already-fetched videos; it stops further deep paging if nobody
|
||||||
|
# else still wants it.
|
||||||
|
turned_on = deep_requested and not sub.deep_requested
|
||||||
|
sub.deep_requested = deep_requested
|
||||||
|
db.flush()
|
||||||
|
return turned_on
|
||||||
|
|
||||||
|
|
||||||
|
def set_deep_all(db: Session, user: User, on: bool) -> int:
|
||||||
|
"""Every one of the user's channels in (or out of) full-history backfill; how many rows moved."""
|
||||||
|
result = db.execute(
|
||||||
|
update(Subscription).where(owned_by(Subscription.user_id, user)).values(deep_requested=on)
|
||||||
|
)
|
||||||
|
return result.rowcount if isinstance(result, CursorResult) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def explore(db: Session, user: User, channel: Channel) -> None:
|
||||||
|
"""Record (or refresh the grace clock of) the user's exploration of a channel, and flag the
|
||||||
|
channel ephemeral unless someone follows it — the cleanup job's keep rule reads both."""
|
||||||
|
existing = queries.exploration(db, user, channel.id)
|
||||||
|
if existing is None:
|
||||||
|
db.add(ExploredChannel(user_id=user.id, channel_id=channel.id))
|
||||||
|
else:
|
||||||
|
existing.created_at = datetime.now(timezone.utc)
|
||||||
|
if not channel.from_explore and not queries.followed(db, channel.id):
|
||||||
|
channel.from_explore = True
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_backfill(db: Session, channel: Channel) -> None:
|
||||||
|
"""Fetch the channel again from scratch: its backfill markers cleared, and full history asked
|
||||||
|
for on EVERY subscription to it — the deep scheduler gates on an EXISTS over those, so the admin
|
||||||
|
who resets it need not subscribe themselves."""
|
||||||
|
channel.backfill_done = False
|
||||||
|
channel.backfill_cursor = None
|
||||||
|
channel.recent_synced_at = None
|
||||||
|
db.execute(
|
||||||
|
update(Subscription)
|
||||||
|
.where(Subscription.channel_id == channel.id)
|
||||||
|
.values(deep_requested=True)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def record_subscription(db: Session, user: User, channel: Channel, yt_subscription_id: str) -> None:
|
||||||
|
"""The subscription YouTube now holds, recorded here. Subscribing is the "keep" signal: an
|
||||||
|
explored channel becomes an organic one, so the cleanup job leaves it alone and its videos join
|
||||||
|
the shared Library like any subscribed channel's uploads."""
|
||||||
|
db.add(
|
||||||
|
Subscription(
|
||||||
|
user_id=user.id,
|
||||||
|
channel_id=channel.id,
|
||||||
|
yt_subscription_id=yt_subscription_id or None,
|
||||||
|
subscribed_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if channel.from_explore:
|
||||||
|
channel.from_explore = False
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(Video.channel_id == channel.id, Video.via_explore.is_(True))
|
||||||
|
.values(via_explore=False)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def drop_subscription(db: Session, sub: Subscription) -> None:
|
||||||
|
db.delete(sub)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def block(db: Session, user: User, channel_id: str) -> None:
|
||||||
|
"""Block a channel for this user (idempotent), and end any exploration of it so the cleanup job
|
||||||
|
can reclaim it."""
|
||||||
|
if queries.is_blocked(db, user, channel_id):
|
||||||
|
return
|
||||||
|
db.add(BlockedChannel(user_id=user.id, channel_id=channel_id))
|
||||||
|
db.execute(
|
||||||
|
delete(ExploredChannel).where(
|
||||||
|
owned_by(ExploredChannel.user_id, user), ExploredChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def unblock(db: Session, user: User, channel_id: str) -> None:
|
||||||
|
db.execute(
|
||||||
|
delete(BlockedChannel).where(
|
||||||
|
owned_by(BlockedChannel.user_id, user), BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Alphabetical order in the reader's language (E30 S3).
|
"""Alphabetical order in the reader's language.
|
||||||
|
|
||||||
Why not the database default: the Postgres image is Alpine, and musl's `en_US.utf8` collation is BYTE
|
Why not the database default: the Postgres image is Alpine, and musl's `en_US.utf8` collation is BYTE
|
||||||
order — every accented initial sorted after "Z" ("Zorró < Életrajzi < Ötödik"), in every A→Z list the
|
order — every accented initial sorted after "Z" ("Zorró < Életrajzi < Ötödik"), in every A→Z list the
|
||||||
|
|||||||
+33
-23
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, get_args, get_origin
|
from typing import Literal, get_args, get_origin
|
||||||
|
|
||||||
from pydantic import Field, ValidationInfo, field_validator, model_validator
|
from pydantic import AliasChoices, Field, ValidationInfo, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||||
@@ -146,8 +146,6 @@ class Settings(BaseSettings):
|
|||||||
autotag_interval_minutes: int = 30
|
autotag_interval_minutes: int = 30
|
||||||
subscriptions_resync_minutes: int = 360
|
subscriptions_resync_minutes: int = 360
|
||||||
playlist_sync_minutes: int = 360
|
playlist_sync_minutes: int = 360
|
||||||
# How often the shared demo account is auto-reset to a clean baseline (communal sandbox).
|
|
||||||
demo_reset_minutes: int = 720
|
|
||||||
# --- Maintenance / validation job ---
|
# --- Maintenance / validation job ---
|
||||||
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
|
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
|
||||||
# are in days because that's the granularity that matters; the rolling re-validation
|
# are in days because that's the granularity that matters; the rolling re-validation
|
||||||
@@ -173,9 +171,13 @@ class Settings(BaseSettings):
|
|||||||
feed_default_hidden_live: str = "live,upcoming"
|
feed_default_hidden_live: str = "live,upcoming"
|
||||||
|
|
||||||
# --- Download center (yt-dlp) ---
|
# --- Download center (yt-dlp) ---
|
||||||
# Root directory (inside the container) for downloaded media — a Docker volume/bind mount.
|
# Root directory (inside the container) for the files library — a Docker volume/bind mount.
|
||||||
# Env-only (a mount path can't live in the DB). The worker lays a Plex-style tree under it.
|
# Env-only (a mount path can't live in the DB). The worker lays a Plex-style tree under it.
|
||||||
download_root: str = "/downloads"
|
# DOWNLOAD_ROOT is its old name: an install still on the old compose file (mounted at
|
||||||
|
# /downloads, DOWNLOAD_ROOT=/downloads) keeps finding its files after it pulls a new image.
|
||||||
|
files_root: str = Field(
|
||||||
|
default="/files", validation_alias=AliasChoices("FILES_ROOT", "DOWNLOAD_ROOT", "files_root")
|
||||||
|
)
|
||||||
# Whether THIS process runs the download worker loop. On for the dedicated worker
|
# Whether THIS process runs the download worker loop. On for the dedicated worker
|
||||||
# container, off for the API (which keeps the scheduler). Mirrors scheduler_enabled.
|
# container, off for the API (which keeps the scheduler). Mirrors scheduler_enabled.
|
||||||
worker_enabled: bool = False
|
worker_enabled: bool = False
|
||||||
@@ -183,15 +185,18 @@ class Settings(BaseSettings):
|
|||||||
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
||||||
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
|
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
|
||||||
download_pot_base_url: str = "http://bgutil-pot:4416"
|
download_pot_base_url: str = "http://bgutil-pot:4416"
|
||||||
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
|
# yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one
|
||||||
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
|
# client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil
|
||||||
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
|
# sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died
|
||||||
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
|
# with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback
|
||||||
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
|
# below served 360p only, so every download came out at 360p without failing. On a
|
||||||
|
# bot/DRM/format failure the worker retries with the FALLBACK set — the POT-capable web clients
|
||||||
|
# (web needs the token + Deno) plus android, whose 360p is the last resort. Both admin-tunable
|
||||||
|
# (env) when YouTube shifts what works.
|
||||||
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
|
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
|
||||||
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
||||||
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
|
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
|
||||||
download_player_clients: str = "android_vr"
|
download_player_clients: str = "tv_simply"
|
||||||
download_player_clients_fallback: str = "web_safari,web,android"
|
download_player_clients_fallback: str = "web_safari,web,android"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -244,7 +249,7 @@ class Settings(BaseSettings):
|
|||||||
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
||||||
download_default_max_concurrent: int = 2
|
download_default_max_concurrent: int = 2
|
||||||
download_default_max_jobs: int = 50
|
download_default_max_jobs: int = 50
|
||||||
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
|
# Per-file cap for local uploads. Unlike a download (whose size is unknown until the worker
|
||||||
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
|
||||||
# user's overall byte footprint is still governed by download_default_max_bytes.
|
# user's overall byte footprint is still governed by download_default_max_bytes.
|
||||||
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
|
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
|
||||||
@@ -260,12 +265,17 @@ class Settings(BaseSettings):
|
|||||||
# Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs.
|
# Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs.
|
||||||
audit_retention_days: int = 180
|
audit_retention_days: int = 180
|
||||||
audit_gc_minutes: int = 1440
|
audit_gc_minutes: int = 1440
|
||||||
|
# YouTube quota history (quota_events): days kept (0 = forever). A year, so the admin usage view's
|
||||||
|
# longest window (365 days) stays whole; the same daily cleanup job prunes it.
|
||||||
|
quota_retention_days: int = 365
|
||||||
|
# Background-run history (job_runs): days kept (0 = forever). The audit log's 180, which is how
|
||||||
|
# long a run lived there before it moved here: ~1.8k rows a day at the default intervals
|
||||||
|
# (measured 2026-10-17), ~320k at the limit. The same daily cleanup job prunes it.
|
||||||
|
job_run_retention_days: int = 180
|
||||||
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
|
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
|
||||||
download_layout: DownloadLayout = "plex"
|
download_layout: DownloadLayout = "plex"
|
||||||
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
|
||||||
sponsorblock_default: bool = False
|
|
||||||
|
|
||||||
# --- Diagnostics / logging (E22) ---
|
# --- Diagnostics / logging ---
|
||||||
# BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at
|
# BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at
|
||||||
# startup and on change without a restart. This is what a process logs at before it can reach
|
# startup and on change without a restart. This is what a process logs at before it can reach
|
||||||
# the database — and the only knob the worker/runner children read, since they are short-lived.
|
# the database — and the only knob the worker/runner children read, since they are short-lived.
|
||||||
@@ -298,7 +308,7 @@ class Settings(BaseSettings):
|
|||||||
plex_path_map: str = ""
|
plex_path_map: str = ""
|
||||||
# Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections.
|
# Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections.
|
||||||
plex_libraries: str = ""
|
plex_libraries: str = ""
|
||||||
# HU-overlay mapping (E5 S5): comma-separated `huSectionKey:enSectionKey` pairs. Each named HU
|
# HU-overlay mapping: comma-separated `huSectionKey:enSectionKey` pairs. Each named HU
|
||||||
# section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN
|
# section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN
|
||||||
# twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1".
|
# twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1".
|
||||||
plex_overlay_map: str = ""
|
plex_overlay_map: str = ""
|
||||||
@@ -308,7 +318,7 @@ class Settings(BaseSettings):
|
|||||||
plex_sync_interval_min: int = 360
|
plex_sync_interval_min: int = 360
|
||||||
# How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C):
|
# How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C):
|
||||||
# a cheap history + on-deck pull plus a re-push of any states that failed their immediate push.
|
# a cheap history + on-deck pull plus a re-push of any states that failed their immediate push.
|
||||||
# 15 min (S2): with Plex now the source of truth (prompt write-back on the ~10s checkpoint +
|
# 15 min: with Plex now the source of truth (prompt write-back on the ~10s checkpoint +
|
||||||
# Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes.
|
# Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes.
|
||||||
plex_watch_sync_interval_min: int = 15
|
plex_watch_sync_interval_min: int = 15
|
||||||
# How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan
|
# How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan
|
||||||
@@ -323,7 +333,7 @@ class Settings(BaseSettings):
|
|||||||
# each one is a whole segmented copy of the film on the scratch volume, written faster than
|
# each one is a whole segmented copy of the film on the scratch volume, written faster than
|
||||||
# realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
|
# realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
|
||||||
plex_max_sessions: int = 8
|
plex_max_sessions: int = 8
|
||||||
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
# --- Transcode (playable="transcode") re-encode tuning ---
|
||||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
||||||
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||||
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
|
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
|
||||||
@@ -341,12 +351,12 @@ class Settings(BaseSettings):
|
|||||||
plex_transcode_crf: int = 20
|
plex_transcode_crf: int = 20
|
||||||
plex_transcode_max_height: int = 1080
|
plex_transcode_max_height: int = 1080
|
||||||
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
||||||
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
|
# dir under files_root. On localdev the files_root is a slow Windows bind-mount, so point
|
||||||
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's files_root is fast
|
||||||
# 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 enrichment (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
|
# 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 /
|
# 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
|
# revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the
|
||||||
@@ -356,7 +366,7 @@ class Settings(BaseSettings):
|
|||||||
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
||||||
tmdb_language: str = "en-US"
|
tmdb_language: str = "en-US"
|
||||||
|
|
||||||
# --- Bug reports (E16): where a debug session's issue goes. All three empty = no tracker; the
|
# --- Bug reports: where a debug session's issue goes. All three empty = no tracker; the
|
||||||
# composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's
|
# composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's
|
||||||
# log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). ---
|
# log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). ---
|
||||||
issues_gitea_url: str = ""
|
issues_gitea_url: str = ""
|
||||||
@@ -366,7 +376,7 @@ class Settings(BaseSettings):
|
|||||||
tmdb_enrich_interval_min: int = 120
|
tmdb_enrich_interval_min: int = 120
|
||||||
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
||||||
tmdb_enrich_batch: int = 100
|
tmdb_enrich_batch: int = 100
|
||||||
# How often the people job (E30 S6) fetches a batch of person bios from TMDB and portraits through Plex,
|
# How often the people job fetches a batch of person bios from TMDB and portraits through Plex,
|
||||||
# in minutes, and how many of each per run: ~18k linked people at 300 per 10 min is about ten hours
|
# in minutes, and how many of each per run: ~18k linked people at 300 per 10 min is about ten hours
|
||||||
# once, then only the newcomers.
|
# once, then only the newcomers.
|
||||||
people_enrich_interval_min: int = 10
|
people_enrich_interval_min: int = 10
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from.
|
"""Debug sessions: Start → reproduce → Stop, and the evidence a bug report is built from.
|
||||||
|
|
||||||
While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth
|
While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth
|
||||||
dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
|
dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
|
||||||
@@ -26,6 +26,7 @@ from uuid import uuid4
|
|||||||
from sqlalchemy import and_, delete, func, not_, select, update
|
from sqlalchemy import and_, delete, func, not_, select, update
|
||||||
from sqlalchemy.orm import Session, defer
|
from sqlalchemy.orm import Session, defer
|
||||||
|
|
||||||
|
from app.userscope import owned_by, owned_row
|
||||||
from app import diagnostics
|
from app import diagnostics
|
||||||
from app.models import DebugScreenshot, DebugSession, LogEvent, User
|
from app.models import DebugScreenshot, DebugSession, LogEvent, User
|
||||||
|
|
||||||
@@ -76,7 +77,9 @@ def open_session(db: Session, user: User) -> DebugSession | None:
|
|||||||
|
|
||||||
def _unclosed(db: Session, user: User) -> DebugSession | None:
|
def _unclosed(db: Session, user: User) -> DebugSession | None:
|
||||||
return db.scalar(
|
return db.scalar(
|
||||||
select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None))
|
select(DebugSession).where(
|
||||||
|
owned_by(DebugSession.user_id, user), DebugSession.ended_at.is_(None)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,12 +129,8 @@ def from_header(db: Session, user: User, value: str | None) -> DebugSession | No
|
|||||||
"""The session a request names, if it is the caller's own and still live; else None."""
|
"""The session a request names, if it is the caller's own and still live; else None."""
|
||||||
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
|
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
|
||||||
return None
|
return None
|
||||||
session = db.get(DebugSession, value)
|
session = owned_row(db, DebugSession, value, user)
|
||||||
if (
|
if session is None or not is_live(session, datetime.now(timezone.utc)):
|
||||||
session is None
|
|
||||||
or session.user_id != user.id
|
|
||||||
or not is_live(session, datetime.now(timezone.utc))
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@@ -269,7 +268,7 @@ def set_delivery(db: Session, session: DebugSession, state: dict, token: str) ->
|
|||||||
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
|
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
|
||||||
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
|
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
|
||||||
that lost its lease came back and stomped the take-over send's committed links, then put its
|
that lost its lease came back and stomped the take-over send's committed links, then put its
|
||||||
own dead token back on an idle session (deep-review round 4). `release_send` has always made
|
own dead token back on an idle session. `release_send` has always made
|
||||||
the same check; this is the other half of it."""
|
the same check; this is the other half of it."""
|
||||||
if claim_owner(db, session) != token:
|
if claim_owner(db, session) != token:
|
||||||
raise SendClaimLost(session.id)
|
raise SendClaimLost(session.id)
|
||||||
@@ -354,7 +353,7 @@ def stored_screenshot_bytes(db: Session, user_id: int) -> int:
|
|||||||
db.scalar(
|
db.scalar(
|
||||||
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
|
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
|
||||||
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
|
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
|
||||||
.where(DebugSession.user_id == user_id)
|
.where(owned_by(DebugSession.user_id, user_id))
|
||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Demo-account service: reset the shared demo sandbox to a clean, seeded baseline.
|
|
||||||
|
|
||||||
Shared by the admin's manual reset endpoint (routes/admin.py) and the scheduled demo-reset job
|
|
||||||
(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an
|
|
||||||
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models import Playlist, PlaylistItem, Subscription, User, Video, VideoState
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_demo_playlists(db: Session, demo: User) -> int:
|
|
||||||
"""Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't
|
|
||||||
empty — gives visitors something to play with. Best-effort: skips silently if the
|
|
||||||
catalog is too small."""
|
|
||||||
recent = (
|
|
||||||
db.execute(
|
|
||||||
select(Video.id)
|
|
||||||
.where(Video.is_short.is_(False), Video.live_status == "none")
|
|
||||||
.order_by(Video.published_at.desc().nullslast())
|
|
||||||
.limit(60)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if not recent:
|
|
||||||
return 0
|
|
||||||
samples = [
|
|
||||||
("Fresh picks", recent[0:10]),
|
|
||||||
("Quick watch", recent[10:18]),
|
|
||||||
("Saved for later", recent[18:26]),
|
|
||||||
]
|
|
||||||
created = 0
|
|
||||||
for pos, (name, vids) in enumerate(samples):
|
|
||||||
if not vids:
|
|
||||||
continue
|
|
||||||
pl = Playlist(user_id=demo.id, name=name, position=pos)
|
|
||||||
db.add(pl)
|
|
||||||
db.flush()
|
|
||||||
for ipos, vid in enumerate(vids):
|
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos))
|
|
||||||
created += 1
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
|
|
||||||
"""Seed the demo with a handful of catalog channels (the ones with the most videos) so its
|
|
||||||
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
|
|
||||||
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
|
|
||||||
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
|
|
||||||
top = (
|
|
||||||
db.execute(
|
|
||||||
select(Video.channel_id)
|
|
||||||
.where(Video.channel_id.is_not(None))
|
|
||||||
.group_by(Video.channel_id)
|
|
||||||
.order_by(func.count().desc())
|
|
||||||
.limit(n)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for channel_id in top:
|
|
||||||
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
|
|
||||||
return len(top)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_demo_state(db: Session, demo: User) -> int:
|
|
||||||
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
|
|
||||||
back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns
|
|
||||||
the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo
|
|
||||||
reset (see scheduler)."""
|
|
||||||
db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
|
|
||||||
# Playlist items cascade on playlist delete (ondelete="CASCADE").
|
|
||||||
db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
|
|
||||||
demo.preferences = {"language": "en"}
|
|
||||||
db.commit()
|
|
||||||
seeded = _seed_demo_playlists(db, demo)
|
|
||||||
_seed_demo_subscriptions(db, demo)
|
|
||||||
db.commit()
|
|
||||||
return seeded
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""A user's diagnostics level (E22 S5): what they consented to, and for how long.
|
"""A user's diagnostics level: what they consented to, and for how long.
|
||||||
|
|
||||||
Three levels, named in the UI Alap / Részletes / Teljes:
|
Three levels, named in the UI Alap / Részletes / Teljes:
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ Three levels, named in the UI Alap / Részletes / Teljes:
|
|||||||
|
|
||||||
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
|
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
|
||||||
never taken from the client, and an expired level reads as INFO without anything having to clear it.
|
never taken from the client, and an expired level reads as INFO without anything having to clear it.
|
||||||
The shared demo account cannot raise its level — its log would mix every visitor's session.
|
|
||||||
|
|
||||||
Core layer: imports `models` and `sysconfig`.
|
Core layer: imports `models` and `sysconfig`.
|
||||||
"""
|
"""
|
||||||
@@ -27,7 +26,7 @@ DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
|
|||||||
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
|
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
|
||||||
BASE: DiagnosticsLevel = "INFO"
|
BASE: DiagnosticsLevel = "INFO"
|
||||||
ELEVATION_HOURS = 24
|
ELEVATION_HOURS = 24
|
||||||
# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other
|
# How long a debug session records at most (app/debugsession.py). Here, beside the other
|
||||||
# consent window, because the start dialog's consent text states it and reads it from `state`.
|
# consent window, because the start dialog's consent text states it and reads it from `state`.
|
||||||
SESSION_MINUTES = 60
|
SESSION_MINUTES = 60
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""E24 — the read-model behind the Home surface ("Mit nézzek most?").
|
"""The read-model behind the Home surface ("Mit nézzek most?").
|
||||||
|
|
||||||
The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own
|
The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own
|
||||||
comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule
|
comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The time budget — E24's replacement for "what mood are you in?".
|
"""The time budget — the replacement for "what mood are you in?".
|
||||||
|
|
||||||
`random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's
|
`random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's
|
||||||
later BA-interview note (#23) says the mood half is not what makes this work: the friction is
|
later BA-interview note (#23) says the mood half is not what makes this work: the friction is
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ to the client, which has both the native payload and the translations.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from app.discover.budget import remaining_seconds
|
from app.discover.budget import remaining_seconds
|
||||||
from app.models import Channel, Playlist
|
from app.catalog.models import Channel
|
||||||
|
from app.reader.models import Playlist
|
||||||
|
|
||||||
# Plex artwork is a poster for the things you browse and a landscape still for the things you play.
|
# Plex artwork is a poster for the things you browse and a landscape still for the things you play.
|
||||||
_POSTER_KINDS = ("movie", "show")
|
_POSTER_KINDS = ("movie", "show")
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.discover import cards, queries
|
from app.discover import cards, queries
|
||||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
from app.models import User
|
||||||
|
from app.plex.models import PlexItem, PlexShow
|
||||||
|
from app.catalog.models import Channel, Video
|
||||||
|
from app.reader.models import ComfortPin, Playlist
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import leaf_card, show_card
|
from app.plex.cards import SHOW_NEW, leaf_card, show_card
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.userscope import owned_by, owned_row
|
||||||
|
|
||||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||||
PIN_COLUMNS: dict[str, str] = {
|
PIN_COLUMNS: dict[str, str] = {
|
||||||
@@ -46,8 +50,8 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
|
|||||||
translation belongs here rather than in five call sites.
|
translation belongs here rather than in five call sites.
|
||||||
|
|
||||||
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
|
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
|
||||||
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the
|
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before a
|
||||||
E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
|
review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
|
||||||
all — a constant that called itself the one vocabulary while nothing spoke it."""
|
all — a constant that called itself the one vocabulary while nothing spoke it."""
|
||||||
col = PIN_COLUMNS.get(kind)
|
col = PIN_COLUMNS.get(kind)
|
||||||
if col is None:
|
if col is None:
|
||||||
@@ -68,11 +72,11 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
|
|||||||
# such pin target", which is the same answer this function gives for one that does not exist.
|
# such pin target", which is the same answer this function gives for one that does not exist.
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
pl = db.get(Playlist, pid)
|
|
||||||
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
|
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
|
||||||
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
|
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
|
||||||
# the same reason `owned_or_404` 404s rather than 403s.
|
# the same reason `owned_or_404` 404s rather than 403s.
|
||||||
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
|
pl = owned_row(db, Playlist, pid, user)
|
||||||
|
return (col, pl.id) if pl is not None else None
|
||||||
|
|
||||||
|
|
||||||
def reorder(db: Session, user: User, ids: list[int]) -> None:
|
def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||||
@@ -92,7 +96,7 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
|
|||||||
# old slots — and one global lock order means two reorders cannot deadlock.
|
# old slots — and one global lock order means two reorders cannot deadlock.
|
||||||
locked = db.execute(
|
locked = db.execute(
|
||||||
select(ComfortPin)
|
select(ComfortPin)
|
||||||
.where(ComfortPin.user_id == user.id)
|
.where(owned_by(ComfortPin.user_id, user.id))
|
||||||
.order_by(ComfortPin.id)
|
.order_by(ComfortPin.id)
|
||||||
.with_for_update()
|
.with_for_update()
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
@@ -117,7 +121,7 @@ def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
|||||||
return list(
|
return list(
|
||||||
db.execute(
|
db.execute(
|
||||||
select(ComfortPin)
|
select(ComfortPin)
|
||||||
.where(ComfortPin.user_id == user.id)
|
.where(owned_by(ComfortPin.user_id, user.id))
|
||||||
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -132,17 +136,44 @@ def next_position(db: Session, user: User) -> int:
|
|||||||
deletes the row, and every FK cascade silently removes a pin when the thing it points at goes
|
deletes the row, and every FK cascade silently removes a pin when the thing it points at goes
|
||||||
away — so past the first hole a count under-counts and would drop the new pin into the MIDDLE of
|
away — so past the first hole a count under-counts and would drop the new pin into the MIDDLE of
|
||||||
the shelf, or onto a position another pin already holds (nothing in the schema forbids that).
|
the shelf, or onto a position another pin already holds (nothing in the schema forbids that).
|
||||||
Same idiom as `routes/playlists._next_playlist_position`."""
|
Same idiom as a new playlist's place in the rail (`reader.service`)."""
|
||||||
return (
|
return (
|
||||||
db.scalar(
|
db.scalar(
|
||||||
select(func.coalesce(func.max(ComfortPin.position), -1)).where(
|
select(func.coalesce(func.max(ComfortPin.position), -1)).where(
|
||||||
ComfortPin.user_id == user.id
|
owned_by(ComfortPin.user_id, user.id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
+ 1
|
+ 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def own_pin(db: Session, user: User, pin_id: int) -> ComfortPin | None:
|
||||||
|
return owned_row(db, ComfortPin, pin_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
def pin_of(db: Session, user: User, column: str, value: object) -> ComfortPin | None:
|
||||||
|
"""The user's pin of this thing (`column`/`value` from `resolve_ref`), if it is on the shelf."""
|
||||||
|
return db.execute(
|
||||||
|
select(ComfortPin).where(
|
||||||
|
owned_by(ComfortPin.user_id, user), getattr(ComfortPin, column) == value
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def add(db: Session, user: User, column: str, value: object) -> ComfortPin:
|
||||||
|
"""A new pin at the end of the shelf. A second pin of the same thing (another tab, at the same
|
||||||
|
moment) raises `IntegrityError` at the flush: the unique constraint is the guard."""
|
||||||
|
pin = ComfortPin(user_id=user.id, position=next_position(db, user), **{column: value})
|
||||||
|
db.add(pin)
|
||||||
|
db.flush()
|
||||||
|
return pin
|
||||||
|
|
||||||
|
|
||||||
|
def remove(db: Session, pin: ComfortPin) -> None:
|
||||||
|
db.delete(pin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Batch:
|
class _Batch:
|
||||||
"""What the shelf resolves for every pinned channel and playlist in one go."""
|
"""What the shelf resolves for every pinned channel and playlist in one go."""
|
||||||
@@ -203,8 +234,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s
|
|||||||
sh = db.get(PlexShow, pin.plex_show_id)
|
sh = db.get(PlexShow, pin.plex_show_id)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
return None
|
return None
|
||||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
|
||||||
card = cards.from_plex(show_card(sh, status, lang=lang))
|
card = cards.from_plex(show_card(sh, state, lang=lang))
|
||||||
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
||||||
nxt = cards.from_plex(ep) if ep else None
|
nxt = cards.from_plex(ep) if ep else None
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Two rules shape this module:
|
|||||||
**Reuse the query layer, not the route.** The YouTube half calls `feedquery.filtered_query` /
|
**Reuse the query layer, not the route.** The YouTube half calls `feedquery.filtered_query` /
|
||||||
`sort_keys`, so the Home surface inherits the feed's own definitions of subscribed, blocked,
|
`sort_keys`, so the Home surface inherits the feed's own definitions of subscribed, blocked,
|
||||||
explore-visible and in-progress for free, and cannot drift from them. The Plex half calls
|
explore-visible and in-progress for free, and cannot drift from them. The Plex half calls
|
||||||
`plex.rollup` and the `plex.cards` serializers rather than `routes/plex/discover.py`: that route
|
`plex.rollup` and the `plex.cards` serializers rather than `plex/discover.py`: that read-model
|
||||||
narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a
|
narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a
|
||||||
sidebar and wrong for a landing page that does not.
|
sidebar and wrong for a landing page that does not.
|
||||||
|
|
||||||
@@ -29,22 +29,17 @@ from app.feedquery import (
|
|||||||
serialize,
|
serialize,
|
||||||
sort_keys,
|
sort_keys,
|
||||||
)
|
)
|
||||||
from app.models import (
|
from app.models import User
|
||||||
Channel,
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||||
PlaylistItem,
|
from app.catalog.models import Channel, Video
|
||||||
PlexItem,
|
from app.reader.models import PlaylistItem, VideoState
|
||||||
PlexShow,
|
|
||||||
PlexState,
|
|
||||||
User,
|
|
||||||
Video,
|
|
||||||
VideoState,
|
|
||||||
)
|
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
||||||
from app.plex.query import apply_show_state_filter, content_libs
|
from app.plex.query import apply_show_state_filter, content_libs
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||||
# (`routes/plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
|
# (`plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
|
||||||
# is a rail. One definition, because a comment was all that held two copies in step.
|
# is a rail. One definition, because a comment was all that held two copies in step.
|
||||||
RAIL_LIMIT = 20
|
RAIL_LIMIT = 20
|
||||||
|
|
||||||
@@ -98,7 +93,7 @@ def yt_in_progress(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[tup
|
|||||||
q = (
|
q = (
|
||||||
select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at"))
|
select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at"))
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.join(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
.join(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
|
||||||
.where(
|
.where(
|
||||||
st.position_seconds > 0,
|
st.position_seconds > 0,
|
||||||
st.status.notin_(("watched", "hidden")),
|
st.status.notin_(("watched", "hidden")),
|
||||||
@@ -141,7 +136,7 @@ def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
|
|||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(*feed_columns(user, st))
|
select(*feed_columns(user, st))
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
.outerjoin(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
|
||||||
.where(Video.id == video_id)
|
.where(Video.id == video_id)
|
||||||
).first()
|
).first()
|
||||||
return serialize(row) if row is not None else None
|
return serialize(row) if row is not None else None
|
||||||
@@ -184,7 +179,7 @@ def playlists_summary(
|
|||||||
select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
|
select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
|
||||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
|
.outerjoin(st, and_(st.video_id == Video.id, owned_by(st.user_id, user)))
|
||||||
.where(PlaylistItem.playlist_id.in_(playlist_ids))
|
.where(PlaylistItem.playlist_id.in_(playlist_ids))
|
||||||
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
|
.order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
|
||||||
).all()
|
).all()
|
||||||
@@ -209,7 +204,7 @@ def _unseen_movie_conds(uid: int):
|
|||||||
.select_from(PlexState)
|
.select_from(PlexState)
|
||||||
.where(
|
.where(
|
||||||
PlexState.item_id == PlexItem.id,
|
PlexState.item_id == PlexItem.id,
|
||||||
PlexState.user_id == uid,
|
owned_by(PlexState.user_id, uid),
|
||||||
or_(
|
or_(
|
||||||
PlexState.status.in_(("watched", "hidden")),
|
PlexState.status.in_(("watched", "hidden")),
|
||||||
# Started counts as "not a surprise" too — see the note in `yt_surprise`.
|
# Started counts as "not a surprise" too — see the note in `yt_surprise`.
|
||||||
@@ -226,7 +221,7 @@ def plex_in_progress(
|
|||||||
) -> list[tuple[object, dict]]:
|
) -> list[tuple[object, dict]]:
|
||||||
"""In-progress movies + partially-watched shows as (progressed_at, card).
|
"""In-progress movies + partially-watched shows as (progressed_at, card).
|
||||||
|
|
||||||
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
|
Mirrors `plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
|
||||||
NULL status still counts as started, so `notin_` alone would drop it), and the same shared
|
NULL status still counts as started, so `notin_` alone would drop it), and the same shared
|
||||||
`rollup.agg_subquery` + `apply_show_state_filter` for shows, so the two rails cannot disagree on
|
`rollup.agg_subquery` + `apply_show_state_filter` for shows, so the two rails cannot disagree on
|
||||||
what "in progress" means. What it does NOT inherit is that route's `LibraryFilters` narrowing:
|
what "in progress" means. What it does NOT inherit is that route's `LibraryFilters` narrowing:
|
||||||
@@ -237,7 +232,9 @@ def plex_in_progress(
|
|||||||
if movie_libs:
|
if movie_libs:
|
||||||
mq = (
|
mq = (
|
||||||
select(PlexItem, PlexState)
|
select(PlexItem, PlexState)
|
||||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
.join(
|
||||||
|
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
PlexItem.kind == "movie",
|
PlexItem.kind == "movie",
|
||||||
PlexItem.library_id.in_(movie_libs),
|
PlexItem.library_id.in_(movie_libs),
|
||||||
@@ -256,16 +253,20 @@ def plex_in_progress(
|
|||||||
if show_libs:
|
if show_libs:
|
||||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||||
sq = apply_show_state_filter(
|
sq = apply_show_state_filter(
|
||||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||||
|
prog, prog.c.show_id == PlexShow.id
|
||||||
|
),
|
||||||
prog.c.total,
|
prog.c.total,
|
||||||
prog.c.watched,
|
prog.c.watched,
|
||||||
prog.c.inprog,
|
prog.c.inprog,
|
||||||
"in_progress",
|
"in_progress",
|
||||||
).where(PlexShow.library_id.in_(show_libs))
|
).where(PlexShow.library_id.in_(show_libs))
|
||||||
for sh, resumed_at in db.execute(
|
for sh, resumed_at, total, watched, inprog in db.execute(
|
||||||
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
||||||
).all():
|
).all():
|
||||||
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
out.append(
|
||||||
|
(resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang))
|
||||||
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -327,7 +328,9 @@ def next_episode(db: Session, uid: int, show_id: int, *, lang: str) -> dict | No
|
|||||||
)
|
)
|
||||||
base = (
|
base = (
|
||||||
select(PlexItem, PlexState)
|
select(PlexItem, PlexState)
|
||||||
.outerjoin(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
.outerjoin(
|
||||||
|
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||||
|
)
|
||||||
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.discover import cards, queries
|
from app.discover import cards, queries
|
||||||
from app.discover.queries import RAIL_LIMIT
|
from app.discover.queries import RAIL_LIMIT
|
||||||
from app.models import PlexShow, User
|
from app.models import User
|
||||||
|
from app.plex.models import PlexShow
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex.rollup import NO_TS
|
from app.plex.rollup import NO_TS
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ def surprise(
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
|
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
|
||||||
|
|
||||||
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
|
Interleaved rather than concatenated — the same alternation `plex/discover.py` uses for its
|
||||||
movie/show split — so a short pool never comes back all-YouTube or all-Plex just because one side
|
movie/show split — so a short pool never comes back all-YouTube or all-Plex just because one side
|
||||||
happened to be queried first.
|
happened to be queried first.
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter):
|
|||||||
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
|
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
|
||||||
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
|
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
|
||||||
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
|
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
|
||||||
# rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every
|
# rather than a hard failure. Our primary set is tv_simply ALONE, which cannot carry cookies either
|
||||||
|
# (measured on yt-dlp 2026.08.19: `SUPPORTS_COOKIES` False), so without this filter every
|
||||||
# signed-in download would burn its first attempt that way before reaching the POT-backed web
|
# signed-in download would burn its first attempt that way before reaching the POT-backed web
|
||||||
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
|
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
|
||||||
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
|
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
|
||||||
@@ -156,7 +157,7 @@ class YouTubeAdapter(SourceAdapter):
|
|||||||
|
|
||||||
class FacebookAdapter(SourceAdapter):
|
class FacebookAdapter(SourceAdapter):
|
||||||
key = "facebook"
|
key = "facebook"
|
||||||
# Measured (E11.5, yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request
|
# Measured (yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request
|
||||||
# with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a
|
# with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a
|
||||||
# SECOND attempt behind that one error, not the default: the links that already download with the
|
# SECOND attempt behind that one error, not the default: the links that already download with the
|
||||||
# cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without
|
# cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without
|
||||||
@@ -166,7 +167,7 @@ class FacebookAdapter(SourceAdapter):
|
|||||||
_POST_PAGE_MAX_BYTES = 16 * 1024 * 1024
|
_POST_PAGE_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
def resolve_url(self, url: str, ydl_opts: dict) -> str:
|
def resolve_url(self, url: str, ydl_opts: dict) -> str:
|
||||||
# Measured (E11.5 S4): given a group post's link, yt-dlp either finds no formats or — worse —
|
# Measured: given a group post's link, yt-dlp either finds no formats or — worse —
|
||||||
# downloads ANOTHER video from the page's feed data. The post's own video is the one Video node
|
# downloads ANOTHER video from the page's feed data. The post's own video is the one Video node
|
||||||
# in the page scripts that carry the post id; its watch link downloads (plain first, then the
|
# in the page scripts that carry the post id; its watch link downloads (plain first, then the
|
||||||
# Chrome retry above). Needs the cookie: signed out, the page is the login wall.
|
# Chrome retry above). Needs the cookie: signed out, the page is the login wall.
|
||||||
@@ -176,7 +177,7 @@ class FacebookAdapter(SourceAdapter):
|
|||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES)
|
raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES)
|
||||||
page = raw.decode("utf-8", "replace")
|
page = raw.decode("utf-8", "replace")
|
||||||
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
|
# Measured: a refused or expired cookie gets the login page (~450 KB, a login
|
||||||
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
|
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
|
||||||
if _quoted(post_id) not in page:
|
if _quoted(post_id) not in page:
|
||||||
raise StatedFailure(POST_NOT_SHOWN_MESSAGE)
|
raise StatedFailure(POST_NOT_SHOWN_MESSAGE)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.db import SessionLocal
|
|||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, service
|
from app.downloads import formats, service
|
||||||
from app.models import MediaAsset
|
from app.downloads.models import MediaAsset
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ def run() -> dict:
|
|||||||
if a.poster_path is None:
|
if a.poster_path is None:
|
||||||
# Record a self-hosted poster for every download (the reliable og:image source):
|
# Record a self-hosted poster for every download (the reliable og:image source):
|
||||||
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
||||||
rel = editmod.ensure_poster(a, settings.download_root)
|
rel = editmod.ensure_poster(a, settings.files_root)
|
||||||
if rel:
|
if rel:
|
||||||
a.poster_path = rel
|
a.poster_path = rel
|
||||||
filled_poster += 1
|
filled_poster += 1
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Ad-hoc clips: a marked range of a YouTube video or a Plex item, cut into a new per-user asset.
|
||||||
|
|
||||||
|
A clip never needs the whole source first. A YouTube clip fetches only its range (yt-dlp's
|
||||||
|
`download_ranges`; measured: 3-7 s whether the video runs four minutes or an hour, and the fetched
|
||||||
|
range starts on the marked frame), and a Plex clip reads the library file in place. Either way the
|
||||||
|
range then goes through ONE re-encode built here, so every clip has the same shape whatever its
|
||||||
|
source: frame-exact at both ends, H.264 at most 1080p (an HDR source tonemapped to SDR — kept as
|
||||||
|
10-bit PQ it played washed out), stereo AAC, in an .mp4 that plays on any phone.
|
||||||
|
|
||||||
|
A clip may also crop the picture to a frame the user drew over the playing video. The frame is
|
||||||
|
given in fractions of the box it was drawn in, with that box's aspect: the YouTube iframe never
|
||||||
|
tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16),
|
||||||
|
and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped.
|
||||||
|
|
||||||
|
The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its
|
||||||
|
signature is the asset's cache key, so marking the same range again is an instant cache hit, and a
|
||||||
|
different range or audio track is a different clip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||||
|
from app.plex.sync import HDR_TRANSFERS
|
||||||
|
|
||||||
|
SOURCES = ("youtube", "plex")
|
||||||
|
# Owner's cap: a clip is a moment, not a way to export a whole film to a public link.
|
||||||
|
MAX_CLIP_S = 600
|
||||||
|
MIN_CLIP_S = 1
|
||||||
|
MAX_HEIGHT = 1080
|
||||||
|
# What yt-dlp fetches the range in (formats.normalize fills the rest): the clip's own ceiling, so the
|
||||||
|
# re-encode never upscales and never throws away a better source than it keeps.
|
||||||
|
YOUTUBE_FETCH_SPEC = {"mode": "av", "max_height": MAX_HEIGHT, "container": "mp4"}
|
||||||
|
# Bump when the spec → output-bytes mapping changes, so a cached clip re-derives.
|
||||||
|
_SIG_VERSION = 1
|
||||||
|
|
||||||
|
_YOUTUBE_ID = re.compile(r"[A-Za-z0-9_-]{11}")
|
||||||
|
_RATING_KEY = re.compile(r"[0-9]{1,32}")
|
||||||
|
_MAX_AUDIO_TRACK = 63
|
||||||
|
# HDR → SDR as measured on the owner's 4K HDR files (2026-10-10): linearise, tonemap (hable),
|
||||||
|
# convert the primaries and transfer to BT.709 and back to 8-bit 4:2:0.
|
||||||
|
_TONEMAP = (
|
||||||
|
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,"
|
||||||
|
"zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||||
|
)
|
||||||
|
_SCALE = f"scale=-2:'min({MAX_HEIGHT},ih)'"
|
||||||
|
# A crop frame is at least this share of its box on each side (the player's frame holds the same
|
||||||
|
# floor), and what is left of it on the real picture at least _MIN_VISIBLE of it.
|
||||||
|
MIN_CROP = 0.1
|
||||||
|
_MIN_VISIBLE = 0.05
|
||||||
|
# The box aspects a frame may be drawn in: a 9:16 short to a 21:9 film, with room either side.
|
||||||
|
_CROP_ASPECTS = (0.25, 4.0)
|
||||||
|
# Float slack on the frame's far edges (x + w, y + h), which the client rounds to 4 decimals.
|
||||||
|
_EDGE_SLACK = 1e-3
|
||||||
|
|
||||||
|
|
||||||
|
class ClipError(Exception):
|
||||||
|
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
||||||
|
message names (the cap itself, so the client never says a stale one)."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str, **params: float):
|
||||||
|
self.reason = reason
|
||||||
|
self.params = params
|
||||||
|
super().__init__(reason)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(
|
||||||
|
source: str,
|
||||||
|
ref: str,
|
||||||
|
start_s: float,
|
||||||
|
end_s: float,
|
||||||
|
audio_track: int | None = None,
|
||||||
|
crop: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
|
||||||
|
decimals, so the same marks always give the same cache key. Raises ClipError."""
|
||||||
|
if source not in SOURCES:
|
||||||
|
raise ClipError("bad_source")
|
||||||
|
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
|
||||||
|
if not isinstance(ref, str) or not pattern.fullmatch(ref):
|
||||||
|
raise ClipError("bad_source")
|
||||||
|
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
|
||||||
|
raise ClipError("bad_range")
|
||||||
|
start, end = round(start_s, 3), round(end_s, 3)
|
||||||
|
if end - start < MIN_CLIP_S:
|
||||||
|
raise ClipError("too_short")
|
||||||
|
if end - start > MAX_CLIP_S:
|
||||||
|
raise ClipError("too_long", limit_s=MAX_CLIP_S)
|
||||||
|
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
|
||||||
|
if source == "plex" and audio_track is not None:
|
||||||
|
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
|
||||||
|
raise ClipError("bad_audio")
|
||||||
|
spec["audio_track"] = audio_track
|
||||||
|
frame = _crop(crop)
|
||||||
|
if frame is not None:
|
||||||
|
spec["crop"] = frame
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _crop(raw: dict | None) -> dict | None:
|
||||||
|
"""A crop frame, canonical — or None for no crop, which a frame over the whole box also is (the
|
||||||
|
same clip as one cut without a frame, so the same cache key)."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
x, y, w, h, aspect = (float(raw[k]) for k in ("x", "y", "w", "h", "aspect"))
|
||||||
|
except (KeyError, TypeError, ValueError) as e:
|
||||||
|
raise ClipError("bad_crop") from e
|
||||||
|
if not all(math.isfinite(v) for v in (x, y, w, h, aspect)):
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
lo, hi = _CROP_ASPECTS
|
||||||
|
if not lo <= aspect <= hi or x < 0 or y < 0:
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
if w < MIN_CROP - _EDGE_SLACK or h < MIN_CROP - _EDGE_SLACK:
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
if x + w > 1 + _EDGE_SLACK or y + h > 1 + _EDGE_SLACK:
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
frame = {"x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y), "aspect": aspect}
|
||||||
|
frame = {k: round(v, 4) for k, v in frame.items()}
|
||||||
|
if (frame["x"], frame["y"], frame["w"], frame["h"]) == (0, 0, 1, 1):
|
||||||
|
return None
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def duration(spec: dict) -> float:
|
||||||
|
return spec["end_s"] - spec["start_s"]
|
||||||
|
|
||||||
|
|
||||||
|
def sig(spec: dict) -> str:
|
||||||
|
"""The clip's cache signature: the spec itself, versioned."""
|
||||||
|
body = json.dumps({**spec, "_v": _SIG_VERSION}, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(body.encode()).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def asset_ref(user_id: int, spec: dict) -> str:
|
||||||
|
"""The asset's `source_ref`. The user id makes a clip private to whoever cut it, like an edit:
|
||||||
|
never dedup-shared, never cache-served to someone else."""
|
||||||
|
return f"{user_id}:{spec['source']}:{spec['ref']}"
|
||||||
|
|
||||||
|
|
||||||
|
def reference_url(spec: dict) -> str | None:
|
||||||
|
"""Where a YouTube clip came from: its video at the clip's start. A Plex clip has no public page."""
|
||||||
|
if spec.get("source") != "youtube" or not spec.get("ref"):
|
||||||
|
return None
|
||||||
|
return f"https://www.youtube.com/watch?v={spec['ref']}&t={int(spec.get('start_s') or 0)}s"
|
||||||
|
|
||||||
|
|
||||||
|
def is_hdr(probe: dict) -> bool:
|
||||||
|
"""Whether the first video stream of an ffprobe `-show_streams` JSON carries an HDR transfer."""
|
||||||
|
for s in probe.get("streams") or []:
|
||||||
|
if s.get("codec_type") == "video":
|
||||||
|
# PQ / HLG: an 8-bit SDR encode must tonemap them (the one set the Plex stream uses).
|
||||||
|
return s.get("color_transfer") in HDR_TRANSFERS
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def display_aspect(probe: dict) -> float | None:
|
||||||
|
"""The picture's shape as a player shows it, from an ffprobe `-show_streams` JSON: the first video
|
||||||
|
stream's width × its sample aspect ratio over its height, turned a quarter when the stream says
|
||||||
|
it is rotated (ffmpeg turns such a stream upright before any filter of ours, so a crop is cut
|
||||||
|
from the upright picture). None when the stream has no size."""
|
||||||
|
for s in probe.get("streams") or []:
|
||||||
|
if s.get("codec_type") != "video":
|
||||||
|
continue
|
||||||
|
w, h = s.get("width"), s.get("height")
|
||||||
|
if not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0:
|
||||||
|
return None
|
||||||
|
dar = w * (_ratio(s.get("sample_aspect_ratio")) or 1.0) / h
|
||||||
|
return 1 / dar if _quarter_turned(s) else dar
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio(v: object) -> float | None:
|
||||||
|
"""'32:27' → 1.185…; '0:1', 'N/A', a missing or malformed value → None."""
|
||||||
|
if not isinstance(v, str) or ":" not in v:
|
||||||
|
return None
|
||||||
|
num, _, den = v.partition(":")
|
||||||
|
try:
|
||||||
|
n, d = float(num), float(den)
|
||||||
|
except ValueError: # silent-ok: a malformed ratio reads as none given; the caller takes 1:1
|
||||||
|
return None
|
||||||
|
return n / d if n > 0 and d > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _quarter_turned(stream: dict) -> bool:
|
||||||
|
rotation: object = (stream.get("tags") or {}).get("rotate")
|
||||||
|
for sd in stream.get("side_data_list") or []:
|
||||||
|
if "rotation" in sd:
|
||||||
|
rotation = sd["rotation"]
|
||||||
|
try:
|
||||||
|
return round(float(str(rotation))) % 180 == 90
|
||||||
|
except ValueError: # silent-ok: no (None) or a malformed rotation: the stream is upright
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def crop_filter(crop: dict, dar: float) -> str | None:
|
||||||
|
"""The ffmpeg `crop` for a frame drawn in a box of `crop["aspect"]`, over a picture of display
|
||||||
|
aspect `dar` fitted into that box as a player fits it (whole, centred, bars on the short sides).
|
||||||
|
The frame's part over a bar is dropped: "" when what is left is the whole picture, None when too
|
||||||
|
little of the frame lies on the picture at all.
|
||||||
|
|
||||||
|
In fractions of the input's own width and height, so a sample aspect ratio other than 1:1 (an
|
||||||
|
anamorphic DVD) needs nothing of its own, and floored to even pixels: an even 4:2:0 frame, and a
|
||||||
|
window that never runs past the picture's far edge."""
|
||||||
|
box = crop["aspect"]
|
||||||
|
pic_w, pic_h = (1.0, box / dar) if dar > box else (dar / box, 1.0)
|
||||||
|
|
||||||
|
def span(start: float, size: float, extent: float) -> tuple[float, float]:
|
||||||
|
origin = (1 - extent) / 2
|
||||||
|
a = min(1.0, max(0.0, (start - origin) / extent))
|
||||||
|
b = min(1.0, max(0.0, (start + size - origin) / extent))
|
||||||
|
return a, b - a
|
||||||
|
|
||||||
|
x, w = span(crop["x"], crop["w"], pic_w)
|
||||||
|
y, h = span(crop["y"], crop["h"], pic_h)
|
||||||
|
if w < _MIN_VISIBLE or h < _MIN_VISIBLE:
|
||||||
|
return None
|
||||||
|
if w > 1 - 1e-4 and h > 1 - 1e-4:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def px(axis: str, f: float) -> str:
|
||||||
|
return f"trunc({axis}*{f:.6f}/2)*2"
|
||||||
|
|
||||||
|
return f"crop={px('iw', w)}:{px('ih', h)}:{px('iw', x)}:{px('ih', y)}"
|
||||||
|
|
||||||
|
|
||||||
|
def audio_track_count(probe: dict) -> int:
|
||||||
|
return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio")
|
||||||
|
|
||||||
|
|
||||||
|
def build_cmd(
|
||||||
|
src: Path,
|
||||||
|
dest: Path,
|
||||||
|
start_s: float,
|
||||||
|
dur_s: float,
|
||||||
|
*,
|
||||||
|
audio_track: int | None,
|
||||||
|
hdr: bool,
|
||||||
|
threads: int,
|
||||||
|
crop: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
"""ffmpeg for one clip: an input seek (decodes from the keyframe before `start_s` and drops the
|
||||||
|
frames up to it, so the clip starts on the marked frame), the first video stream, the chosen audio
|
||||||
|
track (the first when none was chosen; none at all is fine), no subtitles, data or chapters.
|
||||||
|
A `crop` (crop_filter) comes first: the tonemap and the scale then work on the frame it keeps."""
|
||||||
|
vf = ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME])
|
||||||
|
audio = f"0:a:{audio_track}" if audio_track is not None else "0:a:0?"
|
||||||
|
return [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-progress",
|
||||||
|
"pipe:1",
|
||||||
|
"-nostats",
|
||||||
|
"-ss",
|
||||||
|
f"{start_s:.3f}",
|
||||||
|
*INPUT_GUARD,
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-t",
|
||||||
|
f"{dur_s:.3f}",
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
audio,
|
||||||
|
"-sn",
|
||||||
|
"-dn",
|
||||||
|
# A source's chapters would ride into the .mp4 as a data track of their own (an mkv's
|
||||||
|
# chapter list came out as `bin_data`); a clip of a moment has no chapters to keep.
|
||||||
|
"-map_chapters",
|
||||||
|
"-1",
|
||||||
|
"-vf",
|
||||||
|
vf,
|
||||||
|
*X264_ARGS,
|
||||||
|
"-threads",
|
||||||
|
str(threads),
|
||||||
|
*AAC_ARGS,
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
str(dest),
|
||||||
|
]
|
||||||
@@ -30,10 +30,12 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.downloads.adapters import _host_is
|
from app.downloads.adapters import _host_is
|
||||||
from app.models import DownloadCredential, MediaAsset
|
from app.downloads.models import DownloadCredential, MediaAsset
|
||||||
from app.security import decrypt_optional, encrypt
|
from app.security import decrypt_optional, encrypt
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -156,7 +158,7 @@ def salt_for_enqueue(db: Session, user_id: int, url: str | None) -> str | None:
|
|||||||
def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredential | None:
|
def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredential | None:
|
||||||
return db.execute(
|
return db.execute(
|
||||||
select(DownloadCredential).where(
|
select(DownloadCredential).where(
|
||||||
DownloadCredential.user_id == user_id, DownloadCredential.host_key == host_key
|
owned_by(DownloadCredential.user_id, user_id), DownloadCredential.host_key == host_key
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
@@ -213,7 +215,7 @@ def status_list(db: Session, user_id: int) -> list[dict]:
|
|||||||
have = {
|
have = {
|
||||||
c.host_key: c
|
c.host_key: c
|
||||||
for c in db.execute(
|
for c in db.execute(
|
||||||
select(DownloadCredential).where(DownloadCredential.user_id == user_id)
|
select(DownloadCredential).where(owned_by(DownloadCredential.user_id, user_id))
|
||||||
).scalars()
|
).scalars()
|
||||||
}
|
}
|
||||||
out = []
|
out = []
|
||||||
@@ -249,8 +251,8 @@ def usable_cookie(
|
|||||||
|
|
||||||
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
|
||||||
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
|
||||||
InvalidToken) also yields None; the download then fails honestly on the login wall (S3's
|
InvalidToken) also yields None; the download then fails honestly on the login wall (the
|
||||||
`login_required`)."""
|
`login_required` category)."""
|
||||||
host_key = host_key_for_url(url)
|
host_key = host_key_for_url(url)
|
||||||
if host_key is None:
|
if host_key is None:
|
||||||
return None
|
return None
|
||||||
@@ -259,6 +261,28 @@ def usable_cookie(
|
|||||||
return None
|
return None
|
||||||
if not is_authed_asset(asset, spec, user_id, url):
|
if not is_authed_asset(asset, spec, user_id, url):
|
||||||
return None # shared asset — never attach a cookie to a non-private identity
|
return None # shared asset — never attach a cookie to a non-private identity
|
||||||
|
return _readable(cred, user_id, host_key)
|
||||||
|
|
||||||
|
|
||||||
|
def usable_clip_cookie(
|
||||||
|
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict
|
||||||
|
) -> str | None:
|
||||||
|
"""`usable_cookie` for an ad-hoc clip's range fetch. The second gate is the clip's own identity:
|
||||||
|
a clip asset is private by construction (`clip.asset_ref` names its user, see downloads/clip),
|
||||||
|
so a cookie goes with it exactly when that asset is THIS user's clip of `clip_spec`. Without it a
|
||||||
|
video YouTube gates signed out could never be clipped, while its download goes through."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return None
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
if cred is None:
|
||||||
|
return None
|
||||||
|
if asset.source_kind != "clip" or asset.source_ref != clipmod.asset_ref(user_id, clip_spec):
|
||||||
|
return None
|
||||||
|
return _readable(cred, user_id, host_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _readable(cred: DownloadCredential, user_id: int, host_key: str) -> str | None:
|
||||||
raw = decrypt_optional(cred.cookies_enc)
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
if not raw:
|
if not raw:
|
||||||
log.warning(
|
log.warning(
|
||||||
@@ -284,6 +308,14 @@ def resolve_cookiefile(
|
|||||||
return _write_cookiefile(raw, staging) if raw else None
|
return _write_cookiefile(raw, staging) if raw else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_clip_cookiefile(
|
||||||
|
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict, staging: Path
|
||||||
|
) -> Path | None:
|
||||||
|
"""`resolve_cookiefile` for an ad-hoc clip (`usable_clip_cookie`)."""
|
||||||
|
raw = usable_clip_cookie(db, user_id, url, asset, clip_spec)
|
||||||
|
return _write_cookiefile(raw, staging) if raw else None
|
||||||
|
|
||||||
|
|
||||||
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||||
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||||
|
|
||||||
|
|||||||
+75
-117
@@ -188,6 +188,51 @@ def clip_duration(spec: dict, source_duration: int | None) -> int | None:
|
|||||||
|
|
||||||
# --- ffmpeg: trim / crop --------------------------------------------------------------------
|
# --- ffmpeg: trim / crop --------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Every libx264 / aac re-encode in this package uses these, so a re-encoded file is always the
|
||||||
|
# phone-safe shape (`playable`): `-pix_fmt yuv420p` pins 8-bit 4:2:0 — without it libx264 keeps a
|
||||||
|
# 10-bit or 4:4:4 source's pixel format and writes H.264 High 10 / High 4:4:4, which browsers decode
|
||||||
|
# only patchily (the one safe H.264 is 8-bit 4:2:0).
|
||||||
|
X264_ARGS = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p"]
|
||||||
|
AAC_ARGS = ["-c:a", "aac", "-b:a", "192k"]
|
||||||
|
# The last step of every video filter chain a libx264 re-encode runs: 4:2:0 halves the chroma, so
|
||||||
|
# libx264 refuses an odd width or height ("width not divisible by 2") — measured on an odd source
|
||||||
|
# that is not 4:2:0 already (a 4:4:4 or RGB screen capture, 1365x767). A crop of a 4:2:0 source is
|
||||||
|
# rounded to its chroma grid by ffmpeg itself; this holds every other path to the encoder to the same
|
||||||
|
# even frame. Rounding down to even costs at most one pixel row/column.
|
||||||
|
EVEN_FRAME = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
||||||
|
|
||||||
|
# The demuxers ffmpeg/ffprobe may use to open a library file — single-file containers only. Without
|
||||||
|
# it ffmpeg picks the demuxer from the file's CONTENT and NAME, and an upload's name is the user's:
|
||||||
|
# a playlist-type demuxer (HLS, DASH, concat) opens whatever local paths its entries name, so a
|
||||||
|
# crafted upload could copy another user's clip or a Plex file into its own converted asset. A file
|
||||||
|
# no demuxer here can open fails the probe, and an unprobeable upload is a download-only 'file'.
|
||||||
|
MEDIA_DEMUXERS = (
|
||||||
|
"mov", # mp4, m4a, mov, 3gp
|
||||||
|
"matroska", # mkv, webm
|
||||||
|
"mpegts",
|
||||||
|
"mpeg",
|
||||||
|
"avi",
|
||||||
|
"asf",
|
||||||
|
"flv",
|
||||||
|
"ogg",
|
||||||
|
"mp3",
|
||||||
|
"aac",
|
||||||
|
"ac3",
|
||||||
|
"eac3",
|
||||||
|
"flac",
|
||||||
|
"wav",
|
||||||
|
"w64",
|
||||||
|
"aiff",
|
||||||
|
"caf",
|
||||||
|
"amr",
|
||||||
|
)
|
||||||
|
INPUT_GUARD = ["-format_whitelist", ",".join(MEDIA_DEMUXERS)]
|
||||||
|
# A downloaded thumbnail is the remote site's bytes under the suffix yt-dlp gave it (`.png`, `.webp`,
|
||||||
|
# `.jpg` — `pipeline._THUMB_SUFFIXES`): only the single-image demuxers those open, so a "thumbnail"
|
||||||
|
# that is really a playlist cannot pull a local file into the poster. Anything else fails, and the
|
||||||
|
# poster falls back to a cut frame (`ensure_poster`).
|
||||||
|
THUMB_GUARD = ["-format_whitelist", "png_pipe,jpeg_pipe,webp_pipe"]
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -214,7 +259,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
|||||||
]
|
]
|
||||||
if start:
|
if start:
|
||||||
cmd += ["-ss", f"{start:.3f}"]
|
cmd += ["-ss", f"{start:.3f}"]
|
||||||
cmd += ["-i", str(src)]
|
cmd += [*INPUT_GUARD, "-i", str(src)]
|
||||||
if end is not None:
|
if end is not None:
|
||||||
dur = end - start
|
dur = end - start
|
||||||
if dur > 0:
|
if dur > 0:
|
||||||
@@ -222,21 +267,9 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
|||||||
|
|
||||||
if reencode:
|
if reencode:
|
||||||
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
|
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
|
||||||
# the exact cut). A crop adds the filter; an accurate trim re-encodes with no filter.
|
# the exact cut). A crop adds the filter; an accurate trim only evens the frame.
|
||||||
if crop:
|
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else ""
|
||||||
cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"]
|
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
|
||||||
cmd += [
|
|
||||||
"-c:v",
|
|
||||||
"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"]
|
||||||
|
|
||||||
@@ -284,11 +317,14 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
|||||||
parts, labels = [], []
|
parts, labels = [], []
|
||||||
for i, s in enumerate(segs):
|
for i, s in enumerate(segs):
|
||||||
st, en = s["start_s"], s["end_s"]
|
st, en = s["start_s"], s["end_s"]
|
||||||
parts.append(f"[0:v]trim=start={st}:end={en},setpts=PTS-STARTPTS{cropf}[v{i}]")
|
parts.append(
|
||||||
|
f"[0:v]trim=start={st}:end={en},setpts=PTS-STARTPTS{cropf},{EVEN_FRAME}[v{i}]"
|
||||||
|
)
|
||||||
parts.append(f"[0:a]atrim=start={st}:end={en},asetpts=PTS-STARTPTS[a{i}]")
|
parts.append(f"[0:a]atrim=start={st}:end={en},asetpts=PTS-STARTPTS[a{i}]")
|
||||||
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 + [
|
||||||
|
*INPUT_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-filter_complex",
|
"-filter_complex",
|
||||||
@@ -297,26 +333,23 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
|||||||
"[v]",
|
"[v]",
|
||||||
"-map",
|
"-map",
|
||||||
"[a]",
|
"[a]",
|
||||||
"-c:v",
|
*X264_ARGS,
|
||||||
"libx264",
|
*AAC_ARGS,
|
||||||
"-preset",
|
|
||||||
"veryfast",
|
|
||||||
"-crf",
|
|
||||||
"20",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
listp = staging / "concat.txt"
|
listp = staging / "concat.txt"
|
||||||
lines = []
|
lines = ["ffconcat version 1.0"]
|
||||||
for s in segs:
|
for s in segs:
|
||||||
lines.append(f"file '{_concat_quote(src)}'")
|
lines.append(f"file '{_concat_quote(src)}'")
|
||||||
|
# ffmpeg hands the list's whitelist (which must name `concat` to read the list) down to
|
||||||
|
# the file it names, so a library file whose bytes are a concat list would be read as one
|
||||||
|
# and open the paths inside it. Each entry's own whitelist replaces the inherited one.
|
||||||
|
lines.append(f"option format_whitelist {INPUT_GUARD[1]}")
|
||||||
lines.append(f"inpoint {s['start_s']}")
|
lines.append(f"inpoint {s['start_s']}")
|
||||||
lines.append(f"outpoint {s['end_s']}")
|
lines.append(f"outpoint {s['end_s']}")
|
||||||
prep[listp] = "\n".join(lines) + "\n"
|
prep[listp] = "\n".join(lines) + "\n"
|
||||||
cmd = head + ["-f", "concat", "-safe", "0", "-i", str(listp), "-c", "copy"]
|
guard = ["-format_whitelist", "concat," + INPUT_GUARD[1]]
|
||||||
|
cmd = head + ["-f", "concat", "-safe", "0", *guard, "-i", str(listp), "-c", "copy"]
|
||||||
|
|
||||||
if out_ext in ("mp4", "m4a", "mov"):
|
if out_ext in ("mp4", "m4a", "mov"):
|
||||||
cmd += ["-movflags", "+faststart"]
|
cmd += ["-movflags", "+faststart"]
|
||||||
@@ -474,14 +507,7 @@ def run_ffmpeg(
|
|||||||
raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
|
||||||
|
|
||||||
|
|
||||||
# --- ffmpeg: universal browser playability --------------------------------------------------
|
# --- ffprobe ---------------------------------------------------------------------------------
|
||||||
# A downloaded file must play in EVERY browser, whatever the source shipped. Universal = H.264
|
|
||||||
# video + AAC/MP3 audio in an mp4 container: H.264 is the only video codec all browsers decode
|
|
||||||
# (VP9/AV1 fail on Safari/iOS; HEVC fails on Chrome/Firefox), and mp4/AAC is the safe pairing.
|
|
||||||
# Anything else is fixed post-download: re-encode the offending stream(s), or just remux when the
|
|
||||||
# codecs are already fine but the container isn't.
|
|
||||||
_BROWSER_SAFE_VCODEC = ("h264",)
|
|
||||||
_BROWSER_SAFE_ACODEC = ("aac", "mp3")
|
|
||||||
|
|
||||||
|
|
||||||
def run_ffprobe_json(
|
def run_ffprobe_json(
|
||||||
@@ -493,10 +519,10 @@ def run_ffprobe_json(
|
|||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||||
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||||
package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`,
|
package: the shared media probe (`probe.probe_json`: the stored facts and the phone-safe rule) goes
|
||||||
used by both the download worker's finalize and the upload ingest) both go through it, so the
|
through it, so the
|
||||||
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
|
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
|
||||||
args = ["ffprobe", "-v", "error"]
|
args = ["ffprobe", "-v", "error", *INPUT_GUARD]
|
||||||
if show_streams:
|
if show_streams:
|
||||||
args.append("-show_streams")
|
args.append("-show_streams")
|
||||||
if show_format:
|
if show_format:
|
||||||
@@ -517,35 +543,6 @@ def run_ffprobe_json(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _codecs_from_streams(streams: list) -> tuple[str | None, str | None]:
|
|
||||||
"""(video_codec, audio_codec) lowercased from an ffprobe JSON stream list — the first of each kind.
|
|
||||||
|
|
||||||
Parsed from the JSON `streams` list, NOT ffprobe's flat `default=nw=1` output: there the fields
|
|
||||||
arrive in ffprobe's own struct order, which puts `codec_name` BEFORE `codec_type`, so a line-by-line
|
|
||||||
"remember the last codec_type" parser attributes every name to the PREVIOUS stream. Measured on real
|
|
||||||
downloads: an ordinary H.264+AAC mp4 probed as `v=aac a=None` — the browser-playability check then
|
|
||||||
believed the video stream was AAC and re-encoded a perfectly fine file on every download. Reading each
|
|
||||||
stream dict by its own `codec_type` is immune to that ordering."""
|
|
||||||
vcodec = acodec = None
|
|
||||||
for s in streams:
|
|
||||||
name = (s.get("codec_name") or "").lower() or None
|
|
||||||
ctype = (s.get("codec_type") or "").lower()
|
|
||||||
if ctype == "video" and vcodec is None:
|
|
||||||
vcodec = name
|
|
||||||
elif ctype == "audio" and acodec is None:
|
|
||||||
acodec = name
|
|
||||||
return vcodec, acodec
|
|
||||||
|
|
||||||
|
|
||||||
def probe_codecs(path: Path) -> tuple[str | None, str | None]:
|
|
||||||
"""Return (video_codec, audio_codec) lowercased via one ffprobe call; None for a missing
|
|
||||||
stream or if ffprobe fails (a failed probe is treated as 'leave it alone' upstream)."""
|
|
||||||
data = run_ffprobe_json(path, entries="stream=codec_type,codec_name")
|
|
||||||
if data is None:
|
|
||||||
return None, None
|
|
||||||
return _codecs_from_streams(data.get("streams") or [])
|
|
||||||
|
|
||||||
|
|
||||||
def probe_duration(path: Path) -> int:
|
def probe_duration(path: Path) -> int:
|
||||||
"""Media duration in whole seconds via ffprobe, or 0 if unknown — a progress denominator for a
|
"""Media duration in whole seconds via ffprobe, or 0 if unknown — a progress denominator for a
|
||||||
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)."""
|
||||||
@@ -555,6 +552,7 @@ def probe_duration(path: Path) -> int:
|
|||||||
"ffprobe",
|
"ffprobe",
|
||||||
"-v",
|
"-v",
|
||||||
"error",
|
"error",
|
||||||
|
*INPUT_GUARD,
|
||||||
"-show_entries",
|
"-show_entries",
|
||||||
"format=duration",
|
"format=duration",
|
||||||
"-of",
|
"-of",
|
||||||
@@ -577,49 +575,6 @@ def probe_duration(path: Path) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def browser_transcode_flags(
|
|
||||||
vcodec: str | None, acodec: str | None, is_mp4: bool
|
|
||||||
) -> tuple[bool, bool, bool]:
|
|
||||||
"""Decide what (if anything) makes a file universally browser-playable. Returns
|
|
||||||
(needs_change, reencode_video, reencode_audio). needs_change=False => already H.264 + AAC/MP3
|
|
||||||
in mp4. A None codec (no such stream, or a failed probe) is never re-encoded."""
|
|
||||||
rv = bool(vcodec) and vcodec not in _BROWSER_SAFE_VCODEC
|
|
||||||
ra = bool(acodec) and acodec not in _BROWSER_SAFE_ACODEC
|
|
||||||
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, threads: int | None = None
|
|
||||||
) -> list[str]:
|
|
||||||
"""ffmpeg command that lands `src` as a browser-safe mp4 (faststart): re-encode only the
|
|
||||||
stream(s) that need it (libx264 / aac), stream-copy the rest, always into mp4. `-progress
|
|
||||||
pipe:1` streams machine-readable progress for run_ffmpeg. `threads` caps the encoder so a
|
|
||||||
re-encode can't claim every core on the shared box (see the worker-child de-prioritization)."""
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-nostdin",
|
|
||||||
"-progress",
|
|
||||||
"pipe:1",
|
|
||||||
"-nostats",
|
|
||||||
"-i",
|
|
||||||
str(src),
|
|
||||||
]
|
|
||||||
cmd += (
|
|
||||||
["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"]
|
|
||||||
if reencode_video
|
|
||||||
else ["-c:v", "copy"]
|
|
||||||
)
|
|
||||||
cmd += ["-c:a", "aac", "-b:a", "192k"] if reencode_audio else ["-c:a", "copy"]
|
|
||||||
if threads and threads > 0:
|
|
||||||
cmd += ["-threads", str(threads)]
|
|
||||||
cmd += ["-movflags", "+faststart", str(dest)]
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
# --- filmstrip (editor scrub track) ---------------------------------------------------------
|
# --- filmstrip (editor scrub track) ---------------------------------------------------------
|
||||||
|
|
||||||
_SB_COLS = 12
|
_SB_COLS = 12
|
||||||
@@ -655,6 +610,7 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
|
|||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"error",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
|
*INPUT_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -678,6 +634,7 @@ def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
|
|||||||
"-nostdin",
|
"-nostdin",
|
||||||
"-ss",
|
"-ss",
|
||||||
f"{max(0.0, at_s):.3f}",
|
f"{max(0.0, at_s):.3f}",
|
||||||
|
*INPUT_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -699,6 +656,7 @@ def build_thumbnail_jpg_cmd(src: Path, dest: Path) -> list[str]:
|
|||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"error",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
|
*THUMB_GUARD,
|
||||||
"-i",
|
"-i",
|
||||||
str(src),
|
str(src),
|
||||||
"-frames:v",
|
"-frames:v",
|
||||||
@@ -734,7 +692,7 @@ def _step_failure(err: BaseException) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _is_jpeg(path: Path) -> bool:
|
def _is_jpeg(path: Path) -> bool:
|
||||||
"""True if the file starts with the JPEG SOI marker (FF D8 FF)."""
|
"""True if the file starts with the JPEG SOI marker (0xFF 0xD8 0xFF)."""
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
return f.read(3) == b"\xff\xd8\xff"
|
return f.read(3) == b"\xff\xd8\xff"
|
||||||
@@ -774,7 +732,7 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
|
|||||||
return dest if dest.exists() else None
|
return dest if dest.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
def ensure_poster(asset, files_root: str, timeout_s: int = 30) -> str | None:
|
||||||
"""Generate a poster frame for a ready asset that has no thumbnail (e.g. a direct media URL
|
"""Generate a poster frame for a ready asset that has no thumbnail (e.g. a direct media URL
|
||||||
like a reddit HLS manifest, which yt-dlp can't get a thumbnail for). Written as the media's
|
like a reddit HLS manifest, which yt-dlp can't get a thumbnail for). Written as the media's
|
||||||
`<base>.jpg` sidecar — so Plex uses it too and `delete_asset_files` cleans it up — and returned
|
`<base>.jpg` sidecar — so Plex uses it too and `delete_asset_files` cleans it up — and returned
|
||||||
@@ -782,7 +740,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
|||||||
short clip) dodges black intro frames."""
|
short clip) dodges black intro frames."""
|
||||||
if not asset.rel_path:
|
if not asset.rel_path:
|
||||||
return None
|
return None
|
||||||
root = Path(download_root)
|
root = Path(files_root)
|
||||||
src = root / asset.rel_path
|
src = root / asset.rel_path
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
return None
|
return None
|
||||||
@@ -798,7 +756,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
|||||||
return rel if dest.exists() else None
|
return rel if dest.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict | None:
|
def ensure_storyboard(asset, files_root: str, timeout_s: int = 90) -> dict | None:
|
||||||
"""Return the filmstrip meta for a ready source asset, generating the sprite on first use.
|
"""Return the filmstrip meta for a ready source asset, generating the sprite on first use.
|
||||||
|
|
||||||
Best-effort + time-bounded: for a very long/high-res source the one-pass decode can be slow,
|
Best-effort + time-bounded: for a very long/high-res source the one-pass decode can be slow,
|
||||||
@@ -810,7 +768,7 @@ def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict |
|
|||||||
if not asset.rel_path:
|
if not asset.rel_path:
|
||||||
return None
|
return None
|
||||||
geom = storyboard_geometry(asset.duration_s)
|
geom = storyboard_geometry(asset.duration_s)
|
||||||
root = Path(download_root)
|
root = Path(files_root)
|
||||||
rel = f".storyboards/asset-{asset.id}.jpg"
|
rel = f".storyboards/asset-{asset.id}.jpg"
|
||||||
dest = root / rel
|
dest = root / rel
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
|
|||||||
"details" disclosure. This is the same string-matching approach the worker already trusts for
|
"details" disclosure. This is the same string-matching approach the worker already trusts for
|
||||||
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
||||||
|
|
||||||
**The failure catalog** (`_CATALOG`, E11.5). A category is generic: "Sign-in required" cannot tell a
|
**The failure catalog** (`_CATALOG`). A category is generic: "Sign-in required" cannot tell a
|
||||||
Facebook user that the cookie they already gave was refused, and yt-dlp words several different
|
Facebook user that the cookie they already gave was refused, and yt-dlp words several different
|
||||||
Facebook walls identically (`Cannot parse data`). A catalog entry is scoped to ONE host and may also
|
Facebook walls identically (`Cannot parse data`). A catalog entry is scoped to ONE host and may also
|
||||||
depend on whether a cookie was attached and on the kind of link, and names a `detail` code the UI has
|
depend on whether a cookie was attached and on the kind of link, and names a `detail` code the UI has
|
||||||
@@ -37,9 +37,10 @@ from app.downloads.credentials import host_key_for_url
|
|||||||
|
|
||||||
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
|
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
|
||||||
#
|
#
|
||||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
|
||||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
|
||||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
# each one, because a message and its rule drifting apart is exactly how a
|
||||||
|
# category silently becomes unreachable.
|
||||||
#
|
#
|
||||||
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
|
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
|
||||||
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
||||||
@@ -49,10 +50,24 @@ from app.downloads.credentials import host_key_for_url
|
|||||||
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
||||||
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
||||||
#
|
#
|
||||||
# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`, E11.5 S4) are adapters.py's
|
# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`) are adapters.py's
|
||||||
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||||
# that gives the site's sentence.
|
# that gives the site's sentence.
|
||||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||||
|
# A download, a cut clip, or an upload's converted file, held to the space left, which is not zero:
|
||||||
|
# "full" would be untrue. All three classify by the one phrase they share.
|
||||||
|
DOWNLOAD_TOO_BIG_MESSAGE = (
|
||||||
|
"The video is larger than the download storage you have left — free up space, then try again."
|
||||||
|
)
|
||||||
|
CLIP_TOO_BIG_MESSAGE = (
|
||||||
|
"The clip is larger than the download storage you have left — "
|
||||||
|
"free up space or cut a shorter clip, then try again."
|
||||||
|
)
|
||||||
|
CONVERTED_TOO_BIG_MESSAGE = (
|
||||||
|
"The converted file is larger than the download storage you have left — "
|
||||||
|
"free up space, then try again."
|
||||||
|
)
|
||||||
|
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
||||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||||
|
|
||||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||||
@@ -61,6 +76,7 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||||
("download storage is full", "quota_exceeded"),
|
("download storage is full", "quota_exceeded"),
|
||||||
|
("larger than the download storage you have left", "quota_exceeded"),
|
||||||
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||||
@@ -98,6 +114,8 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
# An edit's source download went away — our own wording, and MORE specific than the generic
|
# An edit's source download went away — our own wording, and MORE specific than the generic
|
||||||
# "no longer available" below, so it must be matched first.
|
# "no longer available" below, so it must be matched first.
|
||||||
("source download is no longer available", "source_missing"),
|
("source download is no longer available", "source_missing"),
|
||||||
|
# An upload's original went away before its conversion ran — our wording (UPLOAD_MISSING_MESSAGE).
|
||||||
|
(UPLOAD_MISSING_MESSAGE.lower().rstrip("."), "upload_missing"),
|
||||||
("source file is missing", "source_missing"),
|
("source file is missing", "source_missing"),
|
||||||
# Private / deleted / removed / terminated.
|
# Private / deleted / removed / terminated.
|
||||||
("private video", "unavailable"),
|
("private video", "unavailable"),
|
||||||
@@ -126,6 +144,13 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
("postprocessing", "postprocess"),
|
("postprocessing", "postprocess"),
|
||||||
("produced no output file", "postprocess"),
|
("produced no output file", "postprocess"),
|
||||||
("no output file", "postprocess"),
|
("no output file", "postprocess"),
|
||||||
|
# yt-dlp's own verdict that its extractor did not understand the site's answer: it appends this
|
||||||
|
# request for a bug report to an UNEXPECTED extraction failure, never to an expected one (a private,
|
||||||
|
# removed or geo-blocked video says so in words matched above). Measured 2026-10-12: a TikTok link
|
||||||
|
# failed with it on every attempt under yt-dlp 2026.07.04 and downloads under 2026.08.19 — the
|
||||||
|
# site changed and a newer yt-dlp is the cure. After the media-format and post-processing rules,
|
||||||
|
# whose texts can carry the same request; before the network ones, which are about the transport.
|
||||||
|
("please report this issue", "site_changed"),
|
||||||
# Network / transport.
|
# Network / transport.
|
||||||
("unable to download", "network"),
|
("unable to download", "network"),
|
||||||
("http error", "network"),
|
("http error", "network"),
|
||||||
@@ -263,6 +288,15 @@ def classify(msg: str | None) -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_conversion(msg: str | None) -> str:
|
||||||
|
"""The category of an upload's failed conversion. The generic ffmpeg categories speak of a
|
||||||
|
download ("the video downloaded but…"), which an upload never was: a failed encode or an
|
||||||
|
unrecognised failure of one is `convert_failed`; a category of its own (a full quota, a missing
|
||||||
|
original) stays as it is."""
|
||||||
|
code = classify(msg)
|
||||||
|
return "convert_failed" if code in ("postprocess", "unknown") else code
|
||||||
|
|
||||||
|
|
||||||
def diagnose(
|
def diagnose(
|
||||||
msg: str | None, *, url: str | None = None, cookie_attached: bool = False
|
msg: str | None, *, url: str | None = None, cookie_attached: bool = False
|
||||||
) -> Diagnosis:
|
) -> Diagnosis:
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""The folders of the Files library — where each of a user's files sits.
|
||||||
|
|
||||||
|
A folder is a row, never a directory: on disk a file stays where the download layout put it (one
|
||||||
|
shared asset can be held by several users), so a rename or a move is a column update. Three kinds:
|
||||||
|
|
||||||
|
- a SOURCE folder is made automatically, one per origin a user has files from (YouTube, Plex clips,
|
||||||
|
uploads, one per other site). Its `source_key` names the origin; the client renders its name
|
||||||
|
from the key, in the reader's language.
|
||||||
|
- an OWN folder is the user's, with the name they gave it.
|
||||||
|
- the HUB folder (one per user, made when they first give a machine a token for it, `hub_folder`) is
|
||||||
|
where they swap files with that machine. Its name, like a source folder's, is the client's; a file
|
||||||
|
may be moved in like into an own folder, and it keeps a file until it is deleted.
|
||||||
|
|
||||||
|
Every file of a user is in exactly one folder. The folder a new file lands in is decided here
|
||||||
|
(`source_key_for` + `source_folder`), at every place a job is created, so the rule lives once. A file
|
||||||
|
moves only into one of the user's own folders, their Hub folder, or back to its own source folder
|
||||||
|
(`move`): a YouTube file in the TikTok folder would be a lie the folder's name tells.
|
||||||
|
|
||||||
|
Retention is the folder's: a source folder keeps a file `download_retention_days` (the value when the
|
||||||
|
folder was made; the user can change it), Uploads, own folders and the Hub keep it until it is deleted. A
|
||||||
|
done file carries its own `expires_at`, stamped by `done_expiry` when it finishes — unless the user
|
||||||
|
kept it — and its `ready_at`, the moment it finished (`stamp_done`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.downloads.models import DownloadJob, FileFolder, MediaAsset
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
|
|
||||||
|
class FolderKind(StrEnum):
|
||||||
|
SOURCE = "source"
|
||||||
|
OWN = "own"
|
||||||
|
HUB = "hub"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceKey(StrEnum):
|
||||||
|
"""The fixed origins. Any other site is `site:<host>` (see `site_key`)."""
|
||||||
|
|
||||||
|
YOUTUBE = "youtube"
|
||||||
|
PLEX = "plex"
|
||||||
|
UPLOAD = "upload"
|
||||||
|
|
||||||
|
|
||||||
|
SITE_PREFIX = "site:"
|
||||||
|
|
||||||
|
# A host's leading label that only says "the same site, another front door".
|
||||||
|
_FRONT_DOORS = ("www.", "m.", "mobile.", "web.")
|
||||||
|
_YOUTUBE_HOSTS = {"youtube.com", "youtu.be", "youtube-nocookie.com", "music.youtube.com"}
|
||||||
|
|
||||||
|
|
||||||
|
def site_key(url: str) -> str:
|
||||||
|
"""The source key of a page on some site: `site:<host>` without its front door (www., m.),
|
||||||
|
or the YouTube key for a YouTube address that arrived as a plain URL."""
|
||||||
|
host = (urlsplit(url).hostname or "").lower().rstrip(".")
|
||||||
|
for door in _FRONT_DOORS:
|
||||||
|
if host.startswith(door) and host.count(".") > 1:
|
||||||
|
host = host[len(door) :]
|
||||||
|
break
|
||||||
|
if host in _YOUTUBE_HOSTS:
|
||||||
|
return SourceKey.YOUTUBE
|
||||||
|
return f"{SITE_PREFIX}{host or 'unknown'}"
|
||||||
|
|
||||||
|
|
||||||
|
def source_key_for(db: Session, job: DownloadJob) -> str:
|
||||||
|
"""The origin a job's file came from — the source folder it belongs in.
|
||||||
|
|
||||||
|
The one recorded when it was placed, else: youtube → YouTube; a clip → the clip's source
|
||||||
|
(YouTube or Plex); an upload → Uploads; a URL → its site; an edit → wherever its parent's file
|
||||||
|
came from (its parent job, or, once that is deleted, the parent's asset)."""
|
||||||
|
if job.source_key:
|
||||||
|
return job.source_key
|
||||||
|
kind = job.source_kind
|
||||||
|
if kind == "youtube":
|
||||||
|
return SourceKey.YOUTUBE
|
||||||
|
if kind == "upload":
|
||||||
|
return SourceKey.UPLOAD
|
||||||
|
if kind == "clip":
|
||||||
|
return (
|
||||||
|
SourceKey.PLEX if (job.edit_spec or {}).get("source") == "plex" else SourceKey.YOUTUBE
|
||||||
|
)
|
||||||
|
if kind == "url":
|
||||||
|
return site_key(job.source_ref)
|
||||||
|
if kind == "edit":
|
||||||
|
parent = db.get(DownloadJob, job.source_job_id) if job.source_job_id else None
|
||||||
|
if parent is not None and parent.id != job.id:
|
||||||
|
return source_key_for(db, parent)
|
||||||
|
asset = db.get(MediaAsset, job.source_asset_id) if job.source_asset_id else None
|
||||||
|
if asset is not None:
|
||||||
|
if asset.source_kind == "youtube":
|
||||||
|
return SourceKey.YOUTUBE
|
||||||
|
if asset.source_kind == "upload":
|
||||||
|
return SourceKey.UPLOAD
|
||||||
|
if asset.source_kind == "url":
|
||||||
|
return site_key(asset.source_ref)
|
||||||
|
return SourceKey.UPLOAD
|
||||||
|
|
||||||
|
|
||||||
|
def source_folder(db: Session, user_id: int, key: str) -> FileFolder:
|
||||||
|
"""The user's source folder for `key`, made on first use. A race on the unique (user, key) is
|
||||||
|
settled by re-selecting, inside a SAVEPOINT so the caller's own pending writes survive."""
|
||||||
|
stmt = select(FileFolder).where(
|
||||||
|
owned_by(FileFolder.user_id, user_id), FileFolder.source_key == key
|
||||||
|
)
|
||||||
|
folder = db.execute(stmt).scalar_one_or_none()
|
||||||
|
if folder is not None:
|
||||||
|
return folder
|
||||||
|
retention = (
|
||||||
|
None if key == SourceKey.UPLOAD else sysconfig.get_int(db, "download_retention_days")
|
||||||
|
)
|
||||||
|
folder = FileFolder(
|
||||||
|
user_id=user_id, kind=FolderKind.SOURCE, source_key=key, retention_days=retention
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(folder)
|
||||||
|
db.flush()
|
||||||
|
# silent-ok: the expected outcome of the race the savepoint exists for — another request made
|
||||||
|
# the same folder first; re-selecting it is the whole handling.
|
||||||
|
except IntegrityError:
|
||||||
|
folder = db.execute(stmt).scalar_one()
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def hub_folder(db: Session, user_id: int) -> FileFolder:
|
||||||
|
"""The user's Hub folder, made on first use; a race on its one-per-user index is settled like
|
||||||
|
`source_folder`'s."""
|
||||||
|
stmt = select(FileFolder).where(
|
||||||
|
owned_by(FileFolder.user_id, user_id), FileFolder.kind == FolderKind.HUB
|
||||||
|
)
|
||||||
|
folder = db.execute(stmt).scalar_one_or_none()
|
||||||
|
if folder is not None:
|
||||||
|
return folder
|
||||||
|
folder = FileFolder(user_id=user_id, kind=FolderKind.HUB)
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(folder)
|
||||||
|
db.flush()
|
||||||
|
# silent-ok: another request made the Hub folder first; re-selecting it is the whole handling.
|
||||||
|
except IntegrityError:
|
||||||
|
folder = db.execute(stmt).scalar_one()
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def place(db: Session, job: DownloadJob) -> None:
|
||||||
|
"""Put a new job's file in its source folder, and record that origin on it. Called wherever a
|
||||||
|
job is created; the job must carry its `user_id`, `source_kind` and source refs already."""
|
||||||
|
key = source_key_for(db, job)
|
||||||
|
job.source_key = key
|
||||||
|
job.folder_id = source_folder(db, job.user_id, key).id
|
||||||
|
|
||||||
|
|
||||||
|
def done_expiry(db: Session, folder_id: int | None, kept: bool) -> datetime | None:
|
||||||
|
"""When a file that finishes now goes: its folder's retention from now, or never (kept, or a
|
||||||
|
folder that keeps files)."""
|
||||||
|
if kept or folder_id is None:
|
||||||
|
return None
|
||||||
|
# Read under a share lock, held to the caller's commit: a retention change (`set_retention` locks
|
||||||
|
# the folder) waits for this file and restamps it too, or this read waits for the change and
|
||||||
|
# takes the new value — never the old value stamped on a file the change did not see.
|
||||||
|
days = db.execute(
|
||||||
|
select(FileFolder.retention_days)
|
||||||
|
.where(FileFolder.id == folder_id)
|
||||||
|
.with_for_update(read=True)
|
||||||
|
).scalar()
|
||||||
|
return None if days is None else datetime.now(timezone.utc) + timedelta(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_done(db: Session, job: DownloadJob) -> None:
|
||||||
|
"""Stamp a job that just became done (in this session): when it did, and its expiry."""
|
||||||
|
job.ready_at = datetime.now(timezone.utc)
|
||||||
|
job.expires_at = done_expiry(db, job.folder_id, job.kept)
|
||||||
|
job.expiry_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
class FolderError(Exception):
|
||||||
|
"""A folder change the rules refuse; `reason` is the refusal the route answers with."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str) -> None:
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
|
||||||
|
"""The user's folder `folder_id` that a file is about to be put in — held (FOR SHARE) until the
|
||||||
|
caller's transaction ends, so a delete of the folder waits for the file and then takes it home
|
||||||
|
with the rest (`delete_own` locks it FOR UPDATE), never failing on its foreign key. `hold=False`
|
||||||
|
only looks (a check ahead of a long wait, which must not hold a lock through it). Another user's
|
||||||
|
folder answers like a missing one."""
|
||||||
|
stmt = select(FileFolder).where(FileFolder.id == folder_id)
|
||||||
|
if hold:
|
||||||
|
stmt = stmt.with_for_update(read=True)
|
||||||
|
folder = db.execute(stmt.execution_options(populate_existing=True)).scalar_one_or_none()
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise FolderError("file_folder_gone")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def upload_target(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
|
||||||
|
"""The folder an upload asked to land in, if it may: one of the user's own folders, their Hub
|
||||||
|
folder, or their Uploads folder. A file put in a site's folder would claim an origin it does not
|
||||||
|
have."""
|
||||||
|
folder = own_folder(db, user_id, folder_id, hold=hold)
|
||||||
|
if folder.kind == FolderKind.SOURCE and folder.source_key != SourceKey.UPLOAD:
|
||||||
|
raise FolderError("file_folder_not_its_source")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def restamp(db: Session, job: DownloadJob) -> None:
|
||||||
|
"""Start a file's clock again from now: it moved to another folder, or its user stopped keeping
|
||||||
|
it. A file not done yet gets its expiry when it finishes (`stamp_done`), so it has none now."""
|
||||||
|
done = job.status == JobStatus.DONE
|
||||||
|
job.expires_at = done_expiry(db, job.folder_id, job.kept) if done else None
|
||||||
|
job.expiry_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
def set_kept(db: Session, job: DownloadJob, kept: bool) -> None:
|
||||||
|
"""Keep a file until its user deletes it, or hand it back to its folder's retention."""
|
||||||
|
job.kept = kept
|
||||||
|
restamp(db, job)
|
||||||
|
|
||||||
|
|
||||||
|
def move(db: Session, job: DownloadJob, folder: FileFolder) -> None:
|
||||||
|
"""Put a file in `folder` — one of its user's own folders, their Hub folder, or the source folder
|
||||||
|
it came from.
|
||||||
|
The caller has checked that `folder` is the job's user's."""
|
||||||
|
if folder.kind == FolderKind.SOURCE and folder.source_key != source_key_for(db, job):
|
||||||
|
raise FolderError("file_folder_not_its_source")
|
||||||
|
if folder.id == job.folder_id:
|
||||||
|
return
|
||||||
|
job.folder_id = folder.id
|
||||||
|
restamp(db, job)
|
||||||
|
|
||||||
|
|
||||||
|
def _hold_for_change(db: Session, folder: FileFolder) -> None:
|
||||||
|
"""Lock `folder` FOR UPDATE and read it afresh into the same object: a change waits out a file
|
||||||
|
being put in it or stamped from it right now (`own_folder`, `done_expiry`), and works from what
|
||||||
|
another change that landed first left, not from what the caller read before the wait. The row is
|
||||||
|
consumed on purpose: an ORM result refreshes the object only as its rows are fetched."""
|
||||||
|
fresh = db.execute(
|
||||||
|
select(FileFolder)
|
||||||
|
.where(FileFolder.id == folder.id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if fresh is None:
|
||||||
|
raise FolderError("file_folder_gone")
|
||||||
|
|
||||||
|
|
||||||
|
def set_retention(db: Session, folder: FileFolder, days: int | None) -> int | None:
|
||||||
|
"""Change how long `folder` keeps a file (None = until deleted). The done files already in it
|
||||||
|
move by the difference — each keeps the age it has — but none ends up closer than the GC's
|
||||||
|
warning window, so a shortened retention still warns before it takes anything. Returns the
|
||||||
|
retention it replaced (equal to `days` when nothing changed)."""
|
||||||
|
_hold_for_change(db, folder) # so the file arriving now is among the ones restamped below
|
||||||
|
old = folder.retention_days
|
||||||
|
if old == days:
|
||||||
|
return old
|
||||||
|
folder.retention_days = days
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
floor = now + timedelta(days=sysconfig.get_int(db, "download_gc_grace_days"))
|
||||||
|
# Locked as they are read: one moved out or deleted meanwhile is no longer this folder's to time.
|
||||||
|
jobs = db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(
|
||||||
|
DownloadJob.folder_id == folder.id,
|
||||||
|
DownloadJob.status == JobStatus.DONE,
|
||||||
|
DownloadJob.kept.is_(False),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalars()
|
||||||
|
for job in jobs:
|
||||||
|
if days is None:
|
||||||
|
job.expires_at = None
|
||||||
|
elif old is None or job.expires_at is None:
|
||||||
|
job.expires_at = max(now + timedelta(days=days), floor)
|
||||||
|
else:
|
||||||
|
job.expires_at = max(job.expires_at + timedelta(days=days - old), floor)
|
||||||
|
job.expiry_warned = False
|
||||||
|
return old
|
||||||
|
|
||||||
|
|
||||||
|
def delete_own(db: Session, folder: FileFolder) -> int:
|
||||||
|
"""Delete one of a user's own folders. Its files are not deleted: each goes back to the source
|
||||||
|
folder it came from, its clock starting there now. Returns how many moved."""
|
||||||
|
if folder.kind != FolderKind.OWN:
|
||||||
|
raise FolderError("file_folder_not_own")
|
||||||
|
_hold_for_change(db, folder) # so the file arriving now is taken home too
|
||||||
|
# Locked as they are read: a file moved out meanwhile stays where it went, and one deleted
|
||||||
|
# meanwhile is not written back (its gone row would fail the delete).
|
||||||
|
jobs = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.folder_id == folder.id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for job in jobs:
|
||||||
|
job.folder_id = source_folder(db, job.user_id, source_key_for(db, job)).id
|
||||||
|
restamp(db, job)
|
||||||
|
db.flush() # the files leave before the folder goes: its foreign key RESTRICTs the delete
|
||||||
|
db.delete(folder)
|
||||||
|
return len(jobs)
|
||||||
|
|
||||||
|
|
||||||
|
def create_own(db: Session, user_id: int, name: str) -> FileFolder:
|
||||||
|
"""A new folder of the user's own; FolderError("file_folder_name_taken") when they already have
|
||||||
|
one by that name (the unique (user, lower(name)))."""
|
||||||
|
folder = FileFolder(user_id=user_id, kind=FolderKind.OWN, name=name)
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(folder)
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError as e:
|
||||||
|
raise FolderError("file_folder_name_taken") from e
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def renamable(folder: FileFolder) -> bool:
|
||||||
|
"""Only a folder of the user's own making takes a name: a source folder's is its origin's."""
|
||||||
|
return folder.kind == FolderKind.OWN
|
||||||
|
|
||||||
|
|
||||||
|
def rename(db: Session, folder: FileFolder, name: str) -> None:
|
||||||
|
"""Rename one of the user's own folders (a source folder's name is its origin's)."""
|
||||||
|
if not renamable(folder):
|
||||||
|
raise FolderError("file_folder_not_own")
|
||||||
|
folder.name = name
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError as e:
|
||||||
|
raise FolderError("file_folder_name_taken") from e
|
||||||
@@ -13,7 +13,7 @@ spec shape (all keys optional; defaults + browser-playability clamp applied by `
|
|||||||
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
|
||||||
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||||
|
|
||||||
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the
|
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser; the
|
||||||
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
|
|||||||
return _PATH_RE.sub("…", text)
|
return _PATH_RE.sub("…", text)
|
||||||
|
|
||||||
|
|
||||||
|
# yt-dlp's HTTP downloader, refusing a file announced larger than `max_filesize`, logs this line and
|
||||||
|
# RETURNS — no exception and no file (measured; test_formats holds it against the pinned yt-dlp).
|
||||||
|
_OVER_CAP_LINE = "File is larger than max-filesize"
|
||||||
|
_OVER_CAP: contextvars.ContextVar[list[bool] | None] = contextvars.ContextVar(
|
||||||
|
"formats_over_cap", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def watching_size_cap() -> Iterator[list[bool]]:
|
||||||
|
"""While yt-dlp works inside it: a one-slot flag, set when it refused a file over the size cap
|
||||||
|
(`build_ydl_opts(max_bytes=…)`). It then returns as if it had downloaded, and all the caller
|
||||||
|
would find is no file — a processing fault, to anyone reading it, for what is the owner's
|
||||||
|
storage. A context variable, like `hiding`: yt-dlp's logger calls run on the extraction's thread."""
|
||||||
|
flag = [False]
|
||||||
|
token = _OVER_CAP.set(flag)
|
||||||
|
try:
|
||||||
|
yield flag
|
||||||
|
finally:
|
||||||
|
_OVER_CAP.reset(token)
|
||||||
|
|
||||||
|
|
||||||
class _YdlLog:
|
class _YdlLog:
|
||||||
"""yt-dlp's logger protocol, pointed at ours.
|
"""yt-dlp's logger protocol, pointed at ours.
|
||||||
|
|
||||||
@@ -124,6 +146,8 @@ class _YdlLog:
|
|||||||
is the one line whose level should say so."""
|
is the one line whose level should say so."""
|
||||||
|
|
||||||
def _emit(self, level: int, msg: str) -> None:
|
def _emit(self, level: int, msg: str) -> None:
|
||||||
|
if _OVER_CAP_LINE in msg and (over := _OVER_CAP.get()) is not None:
|
||||||
|
over[0] = True
|
||||||
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
|
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
|
||||||
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
|
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
|
||||||
# a record that has args, this way the question never arises — and it is the shape ruff's
|
# a record that has args, this way the question never arises — and it is the shape ruff's
|
||||||
@@ -182,7 +206,9 @@ _DEFAULTS = {
|
|||||||
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
# v4: EVERY download is clamped to the mp4/H.264/AAC compat profile (no more mkv/webm/VP9/AV1/Opus
|
||||||
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
# opt-out), so any asset cached under an old non-compat spec — or a stale non-H.264 mp4 that slipped
|
||||||
# the v3 net — must re-derive through the current browser-safe guarantee.
|
# the v3 net — must re-derive through the current browser-safe guarantee.
|
||||||
_SIG_VERSION = 4
|
# v5: the phone-safe rule (`playable`) also judges the pixel format, so a cached 10-bit / 4:4:4 H.264
|
||||||
|
# asset (which v4 let through as "H.264") re-derives as 8-bit 4:2:0 instead of being reused.
|
||||||
|
_SIG_VERSION = 5
|
||||||
# Audio-only downloads version on their own, so a change to the audio bytes doesn't re-download every
|
# Audio-only downloads version on their own, so a change to the audio bytes doesn't re-download every
|
||||||
# cached video. v5: an extracted .mp3/.m4a is stored as itself instead of remuxed into an .mp4 that
|
# cached video. v5: an extracted .mp3/.m4a is stored as itself instead of remuxed into an .mp4 that
|
||||||
# iPad's WebKit refuses to play, so a cached remuxed audio asset must re-derive.
|
# iPad's WebKit refuses to play, so a cached remuxed audio asset must re-derive.
|
||||||
@@ -217,14 +243,15 @@ def normalize(spec: dict) -> dict:
|
|||||||
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||||
if s["mode"] == "a":
|
if s["mode"] == "a":
|
||||||
s["embed_subs"] = False
|
s["embed_subs"] = False
|
||||||
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY
|
# Browser-playability guarantee: the Download module exists to download → share → PLAY
|
||||||
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
|
||||||
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
|
# universally-safe combo is mp4 + H.264 + AAC (an .mp3/.m4a for audio), so we CLAMP the spec to
|
||||||
# point every path (picker, stored profile, raw API) flows through — instead of only hiding
|
# it HERE — the single point every path (picker, stored profile, raw API) flows through — instead
|
||||||
# options in the UI. Non-mp4 containers (mkv doesn't play at all; webm/VP9/AV1 fail iOS) and any
|
# of only hiding options in the UI. Non-mp4 containers (mkv plays nowhere on iOS; AV1 fails there,
|
||||||
# explicit vcodec are coerced to the compat profile; Opus audio (Safari-flaky as a bare file)
|
# and webm/VP9 plays only on recent devices) and any explicit vcodec are coerced to the compat
|
||||||
# falls back to AAC/m4a. A source that exposes no H.264 is still caught by the worker's
|
# profile; Opus audio falls back to AAC/m4a. A source that exposes no H.264 (or a 10-bit one) is
|
||||||
# browser-safe re-encode (`_ensure_browser_playable`), so playback is guaranteed end-to-end.
|
# still caught by the worker's phone-safe step (`downloads.playable`), so playback is guaranteed
|
||||||
|
# end-to-end.
|
||||||
s["container"] = "mp4"
|
s["container"] = "mp4"
|
||||||
s["vcodec"] = None
|
s["vcodec"] = None
|
||||||
if s["audio_format"] not in ("m4a", "mp3"):
|
if s["audio_format"] not in ("m4a", "mp3"):
|
||||||
@@ -274,12 +301,15 @@ def build_ydl_opts(
|
|||||||
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
|
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
|
||||||
generic options below. Everything here is source-agnostic.
|
generic options below. Everything here is source-agnostic.
|
||||||
|
|
||||||
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
`cookiefile` is the acting user's per-host cookies.txt path, attached ONLY by the worker
|
||||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||||
merge so a source adapter can never clobber it.
|
merge so a source adapter can never clobber it.
|
||||||
|
|
||||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
|
||||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
a file announced larger than it rather than filling the disk and only then being caught by the
|
||||||
|
quota accounting. Its ffmpeg downloader (a `download_ranges` fetch) does not read it, nor does
|
||||||
|
a fragmented download (YouTube's formats: measured, 718 MB fetched with 1 MB left) — so the
|
||||||
|
pipeline also holds every produced file to what is left by its real size."""
|
||||||
s = normalize(spec)
|
s = normalize(spec)
|
||||||
h = s["max_height"]
|
h = s["max_height"]
|
||||||
opts: dict = {
|
opts: dict = {
|
||||||
@@ -329,7 +359,7 @@ def build_ydl_opts(
|
|||||||
if max_bytes and max_bytes > 0:
|
if max_bytes and max_bytes > 0:
|
||||||
opts["max_filesize"] = max_bytes
|
opts["max_filesize"] = max_bytes
|
||||||
|
|
||||||
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
# The acting user's per-host cookie. AFTER the adapter merge on purpose: an adapter must not
|
||||||
# be able to override the authenticated session the worker resolved for this specific user.
|
# be able to override the authenticated session the worker resolved for this specific user.
|
||||||
if cookiefile:
|
if cookiefile:
|
||||||
opts["cookiefile"] = cookiefile
|
opts["cookiefile"] = cookiefile
|
||||||
@@ -351,7 +381,7 @@ def build_ydl_opts(
|
|||||||
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
# inside mp4 — a VP9/AV1-in-mp4 file shows a broken player there — so H.264 is selected at the
|
||||||
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
# SELECTION level, not merely preferred: a sort key can't conjure an H.264 stream a source
|
||||||
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
# doesn't expose as video-only. A source that ships only non-H.264 is caught after download by
|
||||||
# the worker's browser-safe re-encode (`_ensure_browser_playable`).
|
# the worker's phone-safe step (`downloads.playable`).
|
||||||
if s["mode"] == "v":
|
if s["mode"] == "v":
|
||||||
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
# Video-only: keep it audio-free. Prefer an H.264 video-only stream, then any video-only,
|
||||||
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
# and only fall to a progressive (audio-bearing) `best` as a last resort.
|
||||||
|
|||||||
+233
-97
@@ -1,82 +1,167 @@
|
|||||||
"""Download retention garbage collector (a scheduler job).
|
"""Download retention garbage collector (a scheduler job).
|
||||||
|
|
||||||
Five passes each run:
|
Six passes each run:
|
||||||
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
1. Pre-expiry warning — a done file whose `expires_at` (its folder's retention, `folders`) falls
|
||||||
once (gc_notified flag) so a download isn't silently deleted.
|
within the grace window, and that its user did not keep, warns its user once (`expiry_warned`).
|
||||||
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
2. Expiry — such a file past its `expires_at` leaves the library: the job is deleted and its hold
|
||||||
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
|
released, so the shared file goes with its last holder.
|
||||||
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed
|
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed ready
|
||||||
ready assets until back under it.
|
assets (never an upload, never one a user kept) until back under it; each holder's file leaves
|
||||||
|
their library, with a notice.
|
||||||
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
||||||
passes 1-3 skip them; benign row-count hygiene).
|
passes 1-3 skip them; benign row-count hygiene).
|
||||||
5. Orphan FILE sweep — delete on-disk files/dirs no surviving asset owns (cruft left when a
|
5. Orphan FILE sweep — delete on-disk files/dirs no surviving asset owns (cruft left when a
|
||||||
re-download renames an asset, e.g. old accented channel dirs), skipping system trees and
|
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
|
||||||
anything newer than an hour so an in-flight download is never touched.
|
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
|
||||||
|
so an in-flight download is never touched.
|
||||||
|
6. Token expiry warning — a machine's token for the library about to run out warns its user once
|
||||||
|
(`apitokens.warn_expiring`): the library's other clock.
|
||||||
|
|
||||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
Runs in the API process (which mounts FILES_ROOT). Pure disk/DB work — no YouTube quota.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import apitokens, logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import storage
|
from app.downloads import quota, service, storage
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.downloads.status import JobStatus
|
||||||
|
from app.downloads.models import DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
|
# The one dot-prefixed top-level tree the orphan sweep enters. The others are not part of the asset
|
||||||
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
|
# layout (user clips, Plex HLS/img caches, in-progress staging, editor storyboards) and are skipped.
|
||||||
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
|
_UPLOAD_TREE = ".uploads"
|
||||||
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
||||||
|
|
||||||
|
|
||||||
def _holders(db: Session, asset_id: int) -> list[int]:
|
def _notify(db: Session, job: DownloadJob, asset: MediaAsset | None, ntype: str) -> None:
|
||||||
return list(
|
title = job.display_name or (asset.title if asset else None) or "download"
|
||||||
db.execute(
|
create_notification(
|
||||||
select(DownloadJob.user_id).where(DownloadJob.asset_id == asset_id).distinct()
|
db,
|
||||||
).scalars()
|
user_id=job.user_id,
|
||||||
|
type=ntype,
|
||||||
|
title=title, # English fallback; the client renders a translated message from data
|
||||||
|
data={
|
||||||
|
"kind": ntype,
|
||||||
|
"title": title,
|
||||||
|
"video_id": asset.source_ref if asset else None,
|
||||||
|
"job_id": job.id,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_holders(db: Session, asset: MediaAsset, ntype: str) -> None:
|
def _relock_expired(
|
||||||
title = asset.title or "download"
|
db: Session, job_id: int, asset_id: int | None, now: datetime
|
||||||
for uid in _holders(db, asset.id):
|
) -> DownloadJob | None:
|
||||||
create_notification(
|
"""Re-read an expired job under the lock order the user routes take (`own_job_for_update`: the
|
||||||
db,
|
asset, then the job) and say whether it is still to go. Between the pass's SELECT and here a
|
||||||
user_id=uid,
|
user may have deleted or re-downloaded it (dropping it again would release its asset a second
|
||||||
type=ntype,
|
time, freeing a file another job still holds) or kept or moved it (it is no longer expired)."""
|
||||||
title=title, # English fallback; the client renders a translated message from data
|
if asset_id is not None:
|
||||||
data={"kind": ntype, "title": asset.title, "video_id": asset.source_ref},
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
commit=False,
|
job = db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
still = (
|
||||||
|
job is not None
|
||||||
|
and job.asset_id == asset_id
|
||||||
|
and job.status == JobStatus.DONE
|
||||||
|
and not job.kept
|
||||||
|
and job.expires_at is not None
|
||||||
|
and job.expires_at <= now
|
||||||
|
)
|
||||||
|
return job if still else None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_warned(db: Session, job_id: int, expires_at: datetime) -> DownloadJob | None:
|
||||||
|
"""Mark one file warned if it still goes when the pass saw it go — not deleted, kept, moved or
|
||||||
|
re-timed since (each of those sets a fresh expiry, and its own warning to come) — and return it.
|
||||||
|
The UPDATE's row lock holds it so until the commit."""
|
||||||
|
hit = db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(
|
||||||
|
DownloadJob.id == job_id,
|
||||||
|
DownloadJob.status == JobStatus.DONE,
|
||||||
|
DownloadJob.kept.is_(False),
|
||||||
|
DownloadJob.expiry_warned.is_(False),
|
||||||
|
DownloadJob.expires_at == expires_at,
|
||||||
)
|
)
|
||||||
|
.values(expiry_warned=True)
|
||||||
|
.returning(DownloadJob.id)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return db.get(DownloadJob, job_id) if hit is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
|
def _on_disk(db: Session) -> int:
|
||||||
if asset.rel_path:
|
"""The bytes the cache holds now (every asset whose file is on disk)."""
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
return (
|
||||||
_notify_holders(db, asset, ntype)
|
db.execute(
|
||||||
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
def _relock_evictable(db: Session, asset_id: int) -> tuple[MediaAsset | None, list[DownloadJob]]:
|
||||||
"""Delete media/sidecar files (and now-empty dirs) under DOWNLOAD_ROOT that no `ready` asset
|
"""Re-read an eviction candidate and its holders under their locks (the asset, then its jobs —
|
||||||
|
the order the user routes take): between the pass's SELECT and here a holder may have kept it,
|
||||||
|
or every holder deleted it."""
|
||||||
|
asset = db.execute(
|
||||||
|
select(MediaAsset)
|
||||||
|
.where(MediaAsset.id == asset_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
holders = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.asset_id == asset_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return asset, list(holders)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_job(db: Session, job: DownloadJob, ntype: str) -> None:
|
||||||
|
"""Take a file out of its user's library: tell them, release the job's hold on the asset (the
|
||||||
|
file goes with the last holder), delete the row (its shares and links cascade)."""
|
||||||
|
_notify(db, job, db.get(MediaAsset, job.asset_id) if job.asset_id else None, ntype)
|
||||||
|
service.release_asset(db, job)
|
||||||
|
db.delete(job)
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep_orphan_files(db: Session, files_root: str) -> int:
|
||||||
|
"""Delete media/sidecar files (and now-empty dirs) under FILES_ROOT that no `ready` asset
|
||||||
owns — cruft left when a re-download changes an asset's on-disk name (e.g. an old accented
|
owns — cruft left when a re-download changes an asset's on-disk name (e.g. an old accented
|
||||||
channel dir, or a poster from a superseded naming scheme). Skips the dot-prefixed system trees
|
channel dir, or a poster from a superseded naming scheme) — or, under `.uploads`, that no upload
|
||||||
and only removes files older than `_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download
|
row names at all. Skips the other dot-prefixed system trees and only removes files older than
|
||||||
is never deleted, and only files NOT owned by a surviving asset."""
|
`_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download is never deleted, and only files
|
||||||
root = Path(download_root)
|
NOT owned by a surviving asset."""
|
||||||
|
root = Path(files_root)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return 0
|
return 0
|
||||||
keep: set[Path] = set()
|
keep: set[Path] = set()
|
||||||
|
# An upload's file is on disk from ingest on, whatever its state (a queued conversion's original,
|
||||||
|
# a failed one kept for Retry), so every upload row keeps its file — not only a ready one.
|
||||||
for rel in db.execute(
|
for rel in db.execute(
|
||||||
select(MediaAsset.rel_path).where(
|
select(MediaAsset.rel_path).where(
|
||||||
MediaAsset.status == "ready", MediaAsset.rel_path.is_not(None)
|
or_(MediaAsset.status == "ready", MediaAsset.source_kind == "upload"),
|
||||||
|
MediaAsset.rel_path.is_not(None),
|
||||||
)
|
)
|
||||||
).scalars():
|
).scalars():
|
||||||
media = (root / rel).resolve()
|
media = (root / rel).resolve()
|
||||||
@@ -99,7 +184,10 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
|||||||
|
|
||||||
unreclaimed = 0
|
unreclaimed = 0
|
||||||
for entry in root.iterdir():
|
for entry in root.iterdir():
|
||||||
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
|
# .edits / .plex-* / .staging / .storyboards + any dotfile — but `.uploads` is swept: a
|
||||||
|
# conversion stopped between placing the new file and the commit that points at it (or the
|
||||||
|
# unlink of the original after it) leaves a file no row names, and nothing else finds it.
|
||||||
|
if entry.name.startswith(".") and entry.name != _UPLOAD_TREE:
|
||||||
continue
|
continue
|
||||||
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
files = [entry] if entry.is_file() else [p for p in entry.rglob("*") if p.is_file()]
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -143,74 +231,95 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
||||||
warned = expired = evicted = reaped = 0
|
warned = expired = evicted = reaped = 0
|
||||||
|
|
||||||
# 1. Pre-expiry warnings.
|
# 1. Pre-expiry warnings — one per file, the first run inside the grace window. Each mark is
|
||||||
|
# conditional on the expiry the SELECT saw and committed on its own: a file deleted since is
|
||||||
|
# passed over (a write to its gone row would fail the whole run), and one kept, moved or re-timed
|
||||||
|
# since keeps its fresh expiry unwarned.
|
||||||
warn_before = now + timedelta(days=grace_days)
|
warn_before = now + timedelta(days=grace_days)
|
||||||
soon = (
|
soon = db.execute(
|
||||||
db.execute(
|
select(DownloadJob.id, DownloadJob.expires_at).where(
|
||||||
select(MediaAsset).where(
|
DownloadJob.status == JobStatus.DONE,
|
||||||
MediaAsset.status == "ready",
|
DownloadJob.kept.is_(False),
|
||||||
MediaAsset.gc_notified.is_(False),
|
DownloadJob.expiry_warned.is_(False),
|
||||||
MediaAsset.expires_at.is_not(None),
|
DownloadJob.expires_at.is_not(None),
|
||||||
MediaAsset.expires_at > now,
|
DownloadJob.expires_at > now,
|
||||||
MediaAsset.expires_at <= warn_before,
|
DownloadJob.expires_at <= warn_before,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.scalars()
|
).all()
|
||||||
.all()
|
db.rollback() # the pass-1 snapshot holds no locks; each mark takes its own below
|
||||||
)
|
for job_id, expires_at in soon:
|
||||||
for asset in soon:
|
job = _mark_warned(db, job_id, expires_at)
|
||||||
_notify_holders(db, asset, "download_expiring")
|
if job is not None:
|
||||||
asset.gc_notified = True
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
warned += 1
|
_notify(db, job, asset, "download_expiring")
|
||||||
if soon:
|
warned += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
# 2. Expiry — a file past its folder's retention leaves the library (warned in pass 1).
|
||||||
dead = (
|
# Each file is re-checked under its locks and committed on its own: the release unlinks the
|
||||||
db.execute(
|
# file at once, so it must never act on what the SELECT saw a while ago.
|
||||||
select(MediaAsset).where(
|
dead = db.execute(
|
||||||
MediaAsset.status.in_(("ready", "error")),
|
select(DownloadJob.id, DownloadJob.asset_id).where(
|
||||||
MediaAsset.expires_at.is_not(None),
|
DownloadJob.status == JobStatus.DONE,
|
||||||
MediaAsset.expires_at <= now,
|
DownloadJob.kept.is_(False),
|
||||||
)
|
DownloadJob.expires_at.is_not(None),
|
||||||
|
DownloadJob.expires_at <= now,
|
||||||
)
|
)
|
||||||
.scalars()
|
).all()
|
||||||
.all()
|
db.rollback() # the pass-2 snapshot holds no locks; each file takes its own below
|
||||||
)
|
for job_id, asset_id in dead:
|
||||||
for asset in dead:
|
job = _relock_expired(db, job_id, asset_id, now)
|
||||||
_delete_asset(db, asset, "download_expired")
|
if job is not None:
|
||||||
expired += 1
|
_drop_job(db, job, "download_expired")
|
||||||
if dead:
|
expired += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 3. LRU eviction over the total cache cap.
|
# 3. LRU eviction over the total cache cap: the disk's safety net. Never an upload (no source to
|
||||||
|
# fetch again) and never a file anyone kept; an evicted file leaves every holder's library.
|
||||||
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 = (
|
if _on_disk(db) > cap:
|
||||||
db.execute(
|
kept_assets = select(DownloadJob.asset_id).where(
|
||||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None)
|
||||||
MediaAsset.status == "ready"
|
)
|
||||||
)
|
candidate_ids = (
|
||||||
).scalar()
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
if total > cap:
|
|
||||||
candidates = (
|
|
||||||
db.execute(
|
db.execute(
|
||||||
select(MediaAsset)
|
select(MediaAsset.id)
|
||||||
.where(MediaAsset.status == "ready")
|
.where(
|
||||||
|
MediaAsset.status == "ready",
|
||||||
|
MediaAsset.source_kind != "upload",
|
||||||
|
MediaAsset.id.not_in(kept_assets),
|
||||||
|
)
|
||||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for asset in candidates:
|
db.rollback()
|
||||||
if total <= cap:
|
for asset_id in candidate_ids:
|
||||||
|
# Counted afresh before each: a file deleted since (this one, an upload, a candidate
|
||||||
|
# further down) has freed its space, and no other user's file goes for that space.
|
||||||
|
if _on_disk(db) <= cap:
|
||||||
break
|
break
|
||||||
total -= asset.size_bytes or 0
|
# Locked and re-read like an expiry: since the SELECT a holder may have kept it,
|
||||||
_delete_asset(db, asset, "download_evicted")
|
# every holder deleted it, or someone asked for it again (a fresh cache hit).
|
||||||
|
asset, holders = _relock_evictable(db, asset_id)
|
||||||
|
if (
|
||||||
|
asset is None
|
||||||
|
or asset.status != "ready"
|
||||||
|
or any(j.kept for j in holders)
|
||||||
|
or (asset.last_access_at is not None and asset.last_access_at > now)
|
||||||
|
):
|
||||||
|
db.commit()
|
||||||
|
continue
|
||||||
|
for job in holders:
|
||||||
|
_notify(db, job, asset, "download_evicted")
|
||||||
|
db.delete(job)
|
||||||
|
if asset.rel_path:
|
||||||
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
|
db.delete(asset)
|
||||||
evicted += 1
|
evicted += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 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
|
||||||
@@ -232,9 +341,35 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
if orphans:
|
if orphans:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# 4b. Reap unheld uploads. An upload carries no expires_at, is never evicted and never re-enqueued,
|
||||||
|
# so the passes above never free one that lost its last holder outside `release_asset` — a
|
||||||
|
# conversion that finished after its job was cancelled, or one cancelled mid-run (the asset
|
||||||
|
# was `downloading` then). Its file, ready or still the unconverted original, is at `rel_path`.
|
||||||
|
unheld = (
|
||||||
|
db.execute(
|
||||||
|
select(MediaAsset).where(
|
||||||
|
MediaAsset.source_kind == "upload",
|
||||||
|
MediaAsset.ref_count == 0,
|
||||||
|
MediaAsset.status != "downloading",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for asset in unheld:
|
||||||
|
if asset.rel_path: # no holder is left to tell, so no notice — just the file and the row
|
||||||
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
|
db.delete(asset)
|
||||||
|
reaped += 1
|
||||||
|
if unheld:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# 5. Orphan FILE sweep — remove on-disk cruft no surviving asset owns (old accented dirs, stale
|
# 5. Orphan FILE sweep — remove on-disk cruft no surviving asset owns (old accented dirs, stale
|
||||||
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
||||||
swept = _sweep_orphan_files(db, settings.download_root)
|
swept = _sweep_orphan_files(db, settings.files_root)
|
||||||
|
|
||||||
|
# 6. A machine's token about to run out.
|
||||||
|
tokens_warned = apitokens.warn_expiring(db)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"warned": warned,
|
"warned": warned,
|
||||||
@@ -242,6 +377,7 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
"evicted": evicted,
|
"evicted": evicted,
|
||||||
"reaped": reaped,
|
"reaped": reaped,
|
||||||
"swept": swept,
|
"swept": swept,
|
||||||
|
"tokens_warned": tokens_warned,
|
||||||
}
|
}
|
||||||
log.info("download_gc %s", result)
|
log.info("download_gc %s", result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.downloads.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) -------
|
||||||
@@ -68,8 +68,8 @@ def _reset_asset_pending(asset_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _staging_dir(asset_id: int) -> Path:
|
def _staging_dir(asset_id: int) -> Path:
|
||||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
return Path(settings.files_root) / ".staging" / f"asset-{asset_id}"
|
||||||
|
|
||||||
|
|
||||||
def _edit_staging_dir(asset_id: int) -> Path:
|
def _edit_staging_dir(asset_id: int) -> Path:
|
||||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
return Path(settings.files_root) / ".staging" / f"edit-{asset_id}"
|
||||||
|
|||||||
+194
-12
@@ -1,27 +1,91 @@
|
|||||||
"""Public "share by link" helpers for the download center.
|
"""Public "share by link" helpers for the download center.
|
||||||
|
|
||||||
A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one
|
A DownloadLink is a capability URL: the unguessable token grants anyone read access to one
|
||||||
download's file on the login-free `/watch/{token}` page. Password-protected links additionally
|
download's file, or to the files in one of its owner's own folders (`folder_files`), on the
|
||||||
require a short-lived signed **grant** on the file request — because a `<video src>` can't send a
|
login-free `/watch/{token}` page. So the token is never stored as written:
|
||||||
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
the link is found by its SHA-256, and the owner's copy is Fernet-encrypted — a database copy alone
|
||||||
so the raw password never rides in the media URL (or server logs).
|
opens nothing. Without that copy (no TOKEN_ENCRYPTION_KEY, or a key since changed) the link still
|
||||||
|
opens; only its address cannot be shown again, and the owner makes a new link to share.
|
||||||
|
|
||||||
|
Password-protected links additionally require a short-lived signed **grant** on the file request —
|
||||||
|
because a `<video src>` can't send a header, the password is exchanged once at `/unlock` for an HMAC
|
||||||
|
grant appended to the file URL, so the raw password never rides in the media URL (or server logs).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy import update as sql_update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit, security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import DownloadLink
|
from app.downloads import storage
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||||
|
from app.models import User
|
||||||
|
from app.utils import iso
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
|
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
|
||||||
|
# The most files a folder link lists: as many as the owner's own Files list shows
|
||||||
|
# (`queries.MAX_LIST`; a test holds the two equal — `queries` imports this module).
|
||||||
|
FOLDER_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
def new_token() -> str:
|
def new_token() -> str:
|
||||||
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
||||||
|
|
||||||
|
|
||||||
|
def seal(link: DownloadLink, token: str) -> None:
|
||||||
|
"""Store `token` on `link` the only ways it is kept: its hash, and its encrypted copy."""
|
||||||
|
link.token_hash = security.hash_token(token)
|
||||||
|
link.token_enc = security.encrypt(token) if security.can_encrypt() else None
|
||||||
|
|
||||||
|
|
||||||
|
def find(db: Session, token: str) -> DownloadLink | None:
|
||||||
|
"""The link a `/watch` token names, by its hash."""
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadLink).where(DownloadLink.token_hash == security.hash_token(token))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def recover(link: DownloadLink) -> str | None:
|
||||||
|
"""The link's token from its encrypted copy, or None when there is none this key can read."""
|
||||||
|
return security.decrypt_optional(link.token_enc)
|
||||||
|
|
||||||
|
|
||||||
|
def in_folder(folder: FileFolder) -> tuple:
|
||||||
|
"""Which jobs a link to `folder` reaches: the done files in it NOW, the folder owner's. A file
|
||||||
|
moved out stops opening through the link at its next request; one moved in starts."""
|
||||||
|
return (
|
||||||
|
DownloadJob.folder_id == folder.id,
|
||||||
|
owned_by(DownloadJob.user_id, folder.user_id),
|
||||||
|
DownloadJob.status == JobStatus.DONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def folder_files(db: Session, folder: FileFolder) -> list[tuple[DownloadJob, MediaAsset]]:
|
||||||
|
"""What a link to `folder` shows — its page lists exactly these, and its link card counts them:
|
||||||
|
each file `in_folder` whose asset is ready and whose bytes are on disk (so the list never offers
|
||||||
|
a dead row), newest first, at most `FOLDER_MAX`."""
|
||||||
|
rows = db.execute(
|
||||||
|
select(DownloadJob, MediaAsset)
|
||||||
|
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
|
.where(*in_folder(folder), MediaAsset.status == "ready")
|
||||||
|
.order_by(DownloadJob.ready_at.desc().nulls_last(), DownloadJob.id.desc())
|
||||||
|
.limit(FOLDER_MAX)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
(job, asset)
|
||||||
|
for job, asset in rows
|
||||||
|
if storage.safe_abs_path(settings.files_root, asset.rel_path) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def is_expired(link: DownloadLink) -> bool:
|
def is_expired(link: DownloadLink) -> bool:
|
||||||
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -55,18 +119,22 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
if exp < datetime.now(timezone.utc).timestamp():
|
if exp < datetime.now(timezone.utc).timestamp():
|
||||||
return False
|
return False
|
||||||
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
|
# As bytes: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it, and
|
||||||
|
# `sig` is whatever the URL carried.
|
||||||
|
return hmac.compare_digest(sig.encode(), _sign(f"{token}.{exp}.{password_version}").encode())
|
||||||
|
|
||||||
|
|
||||||
# --- serialization -------------------------------------------------------------------------
|
# --- serialization -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
|
||||||
"""What the file owner sees when managing their links."""
|
"""What the file owner sees when managing their links. `token` is the one just made (it is in
|
||||||
|
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
|
||||||
|
token = token or recover(link)
|
||||||
return {
|
return {
|
||||||
"id": link.id,
|
"id": link.id,
|
||||||
"token": link.token,
|
"token": token,
|
||||||
"url": f"{base_url}/watch/{link.token}",
|
"url": f"{base_url}/watch/{token}" if token else None,
|
||||||
"allow_download": link.allow_download,
|
"allow_download": link.allow_download,
|
||||||
"has_password": link.password_hash is not None,
|
"has_password": link.password_hash is not None,
|
||||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||||
@@ -106,3 +174,117 @@ def public_meta(
|
|||||||
"file_url": file_url,
|
"file_url": file_url,
|
||||||
"poster_url": poster_url,
|
"poster_url": poster_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- the owner's writes --------------------------------------------------------------------
|
||||||
|
# Each flushes and records its audit row; the handler commits.
|
||||||
|
|
||||||
|
|
||||||
|
def expiry(days: int | None) -> datetime | None:
|
||||||
|
"""None/0/negative → no expiry; otherwise N days out, capped at ~10 years."""
|
||||||
|
if not days or days <= 0:
|
||||||
|
return None
|
||||||
|
return datetime.now(timezone.utc) + timedelta(days=min(days, 3650))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_view(link: DownloadLink) -> dict:
|
||||||
|
"""A link's settings as the audit records them: whether it has a password, never which."""
|
||||||
|
return {
|
||||||
|
"allow_download": link.allow_download,
|
||||||
|
"has_password": link.password_hash is not None,
|
||||||
|
"expires_at": iso(link.expires_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
password: str,
|
||||||
|
allow_download: bool,
|
||||||
|
expires_days: int | None,
|
||||||
|
**target: int,
|
||||||
|
) -> tuple[DownloadLink, str]:
|
||||||
|
"""A link to `target` (`job_id=` or `folder_id=`, already checked to be the user's), and the
|
||||||
|
token it is opened by — which is in hand only now."""
|
||||||
|
token = new_token()
|
||||||
|
link = DownloadLink(
|
||||||
|
**target,
|
||||||
|
created_by=user.id,
|
||||||
|
allow_download=allow_download,
|
||||||
|
password_hash=security.hash_password(password) if password else None,
|
||||||
|
expires_at=expiry(expires_days),
|
||||||
|
)
|
||||||
|
seal(link, token)
|
||||||
|
db.add(link)
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_LINK_CREATE,
|
||||||
|
target_type="download_link",
|
||||||
|
target_id=link.id,
|
||||||
|
after={**target, **audit_view(link)},
|
||||||
|
)
|
||||||
|
return link, token
|
||||||
|
|
||||||
|
|
||||||
|
def update(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
link: DownloadLink,
|
||||||
|
*,
|
||||||
|
allow_download: bool | None = None,
|
||||||
|
expires_days: int | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
sent: frozenset[str] = frozenset(),
|
||||||
|
) -> None:
|
||||||
|
"""Change what the caller sent (`sent` names the fields, so `expires_days=None` can mean "never
|
||||||
|
expires"). Setting, clearing or changing the password bumps its version, so any grant issued
|
||||||
|
under the previous one stops working now rather than living out its TTL."""
|
||||||
|
before = audit_view(link)
|
||||||
|
if "allow_download" in sent:
|
||||||
|
link.allow_download = bool(allow_download)
|
||||||
|
if "expires_days" in sent:
|
||||||
|
link.expires_at = expiry(expires_days)
|
||||||
|
if "password" in sent:
|
||||||
|
pw = (password or "").strip()
|
||||||
|
link.password_hash = security.hash_password(pw) if pw else None
|
||||||
|
link.password_version += 1
|
||||||
|
after = audit_view(link)
|
||||||
|
if after != before or "password" in sent:
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_LINK_UPDATE,
|
||||||
|
target_type="download_link",
|
||||||
|
target_id=link.id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(db: Session, user: User, link: DownloadLink) -> None:
|
||||||
|
link_id = link.id
|
||||||
|
db.delete(link)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
|
||||||
|
target_type="download_link",
|
||||||
|
target_id=link_id,
|
||||||
|
after={"job_id": link.job_id} if link.job_id is not None else {"folder_id": link.folder_id},
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def count_view(db: Session, link: DownloadLink) -> None:
|
||||||
|
"""One more opening of the link. In SQL, not `+= 1` on the loaded row: two opens at once would
|
||||||
|
both write the same count."""
|
||||||
|
db.execute(
|
||||||
|
sql_update(DownloadLink)
|
||||||
|
.where(DownloadLink.id == link.id)
|
||||||
|
.values(view_count=DownloadLink.view_count + 1)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Capture's tables: the downloaded files (`MediaAsset`), each user's jobs over them and the folders
|
||||||
|
they sit in, the shares and public links that hand them on, a user's quota, cookies and format
|
||||||
|
profiles, and where they stopped playing one. Only this unit writes them; another unit reads them
|
||||||
|
along a declared edge, or asks this unit's service."""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
from app.models import DISPLAY_TEXT_MAX, TimestampMixin, UpdatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryState(Base):
|
||||||
|
"""Where a user stopped in a library item (their own download, or one shared with them) — the
|
||||||
|
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
|
||||||
|
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
|
||||||
|
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
|
||||||
|
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
|
||||||
|
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
|
||||||
|
|
||||||
|
__tablename__ = "library_states"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
job_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
|
||||||
|
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAsset(Base, TimestampMixin):
|
||||||
|
"""A physical downloaded media file — the SHARED cache layer of the download center.
|
||||||
|
|
||||||
|
Mirrors the app's "shared catalog, private per-user state" model: one row per unique
|
||||||
|
``(source_kind, source_ref, format_sig)`` is the global cache. When a second user requests
|
||||||
|
the same video with a format that hashes to the same `format_sig`, their `download_job`
|
||||||
|
links to this ready asset instantly — no re-download, no cost. Only the SOURCE download is
|
||||||
|
shared; per-user edited derivatives (phase 2) are never cached here.
|
||||||
|
|
||||||
|
`rel_path` is relative to `settings.files_root` (the mount), so the physical filename is
|
||||||
|
system-decided and bound to the source id — never renamed (the user's custom name is display
|
||||||
|
metadata on `download_job`, applied only when downloading to their own machine). The naming
|
||||||
|
fields (`title`/`uploader`/`upload_date`) are denormalized so the Plex-tree path can be built
|
||||||
|
uniformly for both catalog videos and ad-hoc URLs. `ref_count` (active jobs pointing here) frees
|
||||||
|
the file with its last holder, and `last_access_at` orders the GC's LRU eviction. How long a file
|
||||||
|
stays is not the asset's: each holder's job carries its own `expires_at` (its library folder's)."""
|
||||||
|
|
||||||
|
__tablename__ = "media_assets"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
|
source_ref: Mapped[str] = mapped_column(String(512)) # video id, or full URL for ad-hoc
|
||||||
|
format_sig: Mapped[str] = mapped_column(String(64)) # hash of the output-affecting spec
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="pending", server_default="pending", index=True
|
||||||
|
) # "pending" | "ready" | "error"
|
||||||
|
rel_path: Mapped[str | None] = mapped_column(Text) # relative to files_root
|
||||||
|
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
|
||||||
|
# A locally-generated poster frame (ffmpeg), set only when the source had NO thumbnail so the
|
||||||
|
# card / watch page / link preview can still show an image. Relative to files_root.
|
||||||
|
poster_path: Mapped[str | None] = mapped_column(Text)
|
||||||
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
width: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
height: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
container: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
||||||
|
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
||||||
|
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
||||||
|
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
||||||
|
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
||||||
|
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
||||||
|
title: Mapped[str | None] = mapped_column(Text)
|
||||||
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
# yt-dlp's channel/uploader page URL (channel_url|uploader_url) — lets an auto-filled channel
|
||||||
|
# render as a clickable link. Null for sources without one (e.g. reddit) or older rows.
|
||||||
|
uploader_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
upload_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
|
||||||
|
# reference shown in the Files library. Null until the worker extracts it (older/queued rows
|
||||||
|
# fall back to a URL derived from source_kind+source_ref).
|
||||||
|
source_webpage_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# The file's SHA-256 (hex), so a machine fetching it can check what it got. Hashed while an upload
|
||||||
|
# streams in, otherwise on the first ask (`routes.files`); NULL = not hashed yet.
|
||||||
|
sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
@validates("rel_path")
|
||||||
|
def _forget_hash(self, _key: str, rel_path: str | None) -> str | None:
|
||||||
|
# A hash names the bytes at `rel_path`: a new file there (a conversion, a retry) is not them.
|
||||||
|
self.sha256 = None
|
||||||
|
return rel_path
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadProfile(Base, TimestampMixin):
|
||||||
|
"""A named download preset — a reusable format profile.
|
||||||
|
|
||||||
|
`user_id` NULL + `is_builtin` marks the shipped presets (Best MP4, 1080p, audio-only, …);
|
||||||
|
a user's saved custom profiles carry their `user_id`. `spec` is the format definition
|
||||||
|
(mode av/v/a, max_height, container, audio_format, codec prefs, embed subs/chapters/
|
||||||
|
thumbnail, sponsorblock) that `downloads.formats` turns into a yt-dlp selector + a cache
|
||||||
|
`format_sig`."""
|
||||||
|
|
||||||
|
__tablename__ = "download_profiles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
) # NULL for a builtin preset
|
||||||
|
name: Mapped[str] = mapped_column(String(80))
|
||||||
|
spec: Mapped[dict] = mapped_column(JSON)
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class FileFolder(Base, TimestampMixin):
|
||||||
|
"""A folder of the Files library (`app.downloads.folders`): a row, never a directory.
|
||||||
|
|
||||||
|
A SOURCE folder (`source_key` set: youtube / plex / upload / site:<host>) is made the first time a
|
||||||
|
user gets a file from that origin; an OWN folder (`name` set) is one the user made; the HUB folder
|
||||||
|
(neither set, one per user) is where they swap files with a machine of theirs. Every file —
|
||||||
|
a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a
|
||||||
|
file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it."""
|
||||||
|
|
||||||
|
__tablename__ = "file_folders"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "source_key", name="uq_file_folder_source"),
|
||||||
|
Index(
|
||||||
|
"uq_file_folder_own_name",
|
||||||
|
"user_id",
|
||||||
|
func.lower(text("name")),
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("name IS NOT NULL"),
|
||||||
|
),
|
||||||
|
# One Hub folder per user.
|
||||||
|
Index("uq_file_folder_hub", "user_id", unique=True, postgresql_where=text("kind = 'hub'")),
|
||||||
|
CheckConstraint(
|
||||||
|
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
|
||||||
|
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
|
||||||
|
" OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)",
|
||||||
|
name="ck_file_folder_kind",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16)) # folders.FolderKind
|
||||||
|
# "site:" + a host name (DNS caps one at 253 characters).
|
||||||
|
source_key: Mapped[str | None] = mapped_column(String(260))
|
||||||
|
name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||||
|
retention_days: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
def _ready_on_insert(context) -> datetime | None:
|
||||||
|
"""A job inserted already done (an upload kept as it came) was ready the moment it was made; one
|
||||||
|
that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`)."""
|
||||||
|
done = context.get_current_parameters().get("status") == "done"
|
||||||
|
return datetime.now(timezone.utc) if done else None
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
||||||
|
|
||||||
|
The worker claims `queued` jobs with ``FOR UPDATE SKIP LOCKED`` and writes live
|
||||||
|
`progress`/`speed_bps`/`eta_s`/`phase` here (the frontend polls via useLiveQuery: there is
|
||||||
|
no server push channel, and the worker is a separate process anyway). `profile_snapshot`
|
||||||
|
freezes the spec at enqueue so later edits/deletion of the profile don't change this job.
|
||||||
|
`display_name` is the user's own name for the file (display + the Content-Disposition on
|
||||||
|
local download); the on-disk asset is never renamed. `asset_id` links to the shared file
|
||||||
|
(SET NULL if the asset is GC'd — the job then shows as expired).
|
||||||
|
|
||||||
|
Phase 2 (editor): `job_kind='edit'` jobs derive a new per-user clip from an already-downloaded
|
||||||
|
ready job. They still hang off a `MediaAsset` (with `source_kind='edit'`, a per-user cache key)
|
||||||
|
so all the file-serve/ref-count/GC/quota machinery is reused; the worker branches on the
|
||||||
|
asset's `source_kind` and runs ffmpeg instead of yt-dlp. `source_job_id`/`source_asset_id`
|
||||||
|
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
||||||
|
|
||||||
|
__tablename__ = "download_jobs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_download_jobs_claim", "status", "queue_pos"),
|
||||||
|
# Every way a job becomes done stamps when (`folders.stamp_done`, `finalize_if_running`).
|
||||||
|
CheckConstraint(
|
||||||
|
"status <> 'done' OR ready_at IS NOT NULL", name="ck_download_jobs_ready_at"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
asset_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
|
source_ref: Mapped[str] = mapped_column(String(512))
|
||||||
|
# "download" (default), "edit" (a per-user trim/crop derivative of another job), "upload" (a
|
||||||
|
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`) or
|
||||||
|
# "clip" (a range cut straight from a YouTube video or a Plex item — see `downloads.clip`).
|
||||||
|
job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
|
||||||
|
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
||||||
|
source_job_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
source_asset_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("media_assets.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
|
||||||
|
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
|
||||||
|
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
|
||||||
|
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
profile_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||||
|
# Per-user display overrides + extras (like display_name — metadata only, never the file).
|
||||||
|
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
|
||||||
|
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
|
||||||
|
display_uploader: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||||||
|
display_uploader_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
extra_links: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="queued", server_default="queued", index=True
|
||||||
|
) # queued | running | paused | done | error | canceled
|
||||||
|
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
speed_bps: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
eta_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
phase: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail)
|
||||||
|
error_code: Mapped[str | None] = mapped_column(
|
||||||
|
String(24)
|
||||||
|
) # category, see downloads.errors.classify
|
||||||
|
# The host-specific failure-catalog entry on top of the category (`facebook.login_wall`), or NULL.
|
||||||
|
error_detail: Mapped[str | None] = mapped_column(String(48)) # see downloads.errors.diagnose
|
||||||
|
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
# The Files library: the folder this file sits in (`downloads.folders.place` at creation; an own
|
||||||
|
# folder is emptied back into the source folders before it is deleted, hence RESTRICT), whether
|
||||||
|
# the user keeps it past its folder's retention, and when it goes (stamped once done; NULL =
|
||||||
|
# never). `expiry_warned` marks the one warning the GC sends before it deletes the file.
|
||||||
|
folder_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("file_folders.id", ondelete="RESTRICT"), index=True
|
||||||
|
)
|
||||||
|
# The origin the file came from (the source folder's key), recorded where it is placed: an edit
|
||||||
|
# traces its origin through its parent, and that parent may be deleted or expire later — the
|
||||||
|
# file must still know which source folder it may go back to. NULL only for a file placed before
|
||||||
|
# the column existed outside a source folder; `folders.source_key_for` works those out.
|
||||||
|
source_key: Mapped[str | None] = mapped_column(String(260))
|
||||||
|
kept: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
# When the file became done — what the library's "Added" means: a download that failed for days
|
||||||
|
# and then went through is new to its user, so it sorts as new. `created_at` stays when it was
|
||||||
|
# asked for (the queue's order). Set again when a file is made anew (a retry, a re-download).
|
||||||
|
ready_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_ready_on_insert
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadShare(Base, TimestampMixin):
|
||||||
|
"""A user→user grant: `to_user` may access `from_user`'s downloaded file (private by
|
||||||
|
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
||||||
|
|
||||||
|
__tablename__ = "download_shares"
|
||||||
|
__table_args__ = (UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
job_id: Mapped[int] = mapped_column(ForeignKey("download_jobs.id", ondelete="CASCADE"))
|
||||||
|
from_user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQuota(Base, UpdatedAtMixin):
|
||||||
|
"""Per-user download limits (admin-managed). A row exists only when an admin customizes a
|
||||||
|
user; otherwise the sysconfig `download_default_*` values apply. `unlimited` bypasses the
|
||||||
|
byte cap entirely (e.g. for the admin's own account)."""
|
||||||
|
|
||||||
|
__tablename__ = "download_quotas"
|
||||||
|
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
max_concurrent: Mapped[int] = mapped_column(Integer)
|
||||||
|
max_jobs: Mapped[int] = mapped_column(Integer)
|
||||||
|
unlimited: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadLink(Base, TimestampMixin):
|
||||||
|
"""A public capability link to one download, or to one of its owner's own folders — the "share
|
||||||
|
by link" surface.
|
||||||
|
|
||||||
|
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
|
||||||
|
player page (Google-Drive style); a folder link opens the folder's list there, with what is in
|
||||||
|
the folder at the time it is opened. The token itself is never stored as written: a link is
|
||||||
|
found by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner
|
||||||
|
copies the link from again — absent without a key (`downloads/links.py`). Optional per-link
|
||||||
|
controls: `expires_at`, `allow_download` (stream-only vs downloadable), and a `password_hash`
|
||||||
|
(argon2, like a user password). Revoke = delete the row. Distinct from `DownloadShare`, which
|
||||||
|
grants a *registered* user access via the in-app "Shared with me" list."""
|
||||||
|
|
||||||
|
__tablename__ = "download_links"
|
||||||
|
__table_args__ = (
|
||||||
|
# A link opens exactly one thing: a file or a folder.
|
||||||
|
CheckConstraint("(job_id IS NULL) <> (folder_id IS NULL)", name="ck_download_link_target"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
job_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
folder_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||||
|
token_enc: Mapped[str | None] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
||||||
|
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
|
||||||
|
# password immediately invalidates any grants issued under the old one (instead of them living
|
||||||
|
# out the 6h grant TTL).
|
||||||
|
password_version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadCredential(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A user's stored login cookies for ONE auth-walled download host (Facebook, Instagram, …).
|
||||||
|
|
||||||
|
Per-user, per-host: each user pastes their OWN browser cookies.txt (Netscape format) for a host
|
||||||
|
where they're logged in (e.g. a closed Facebook group they belong to), and a download of a
|
||||||
|
yt-dlp-supported, auth-walled link on that host is retried with the ACTING user's cookie. The
|
||||||
|
blob is Fernet-encrypted at rest (`cookies_enc`) and is NEVER returned by any API/log/audit —
|
||||||
|
the routes are write-only (host + updated_at only). See `downloads.credentials`.
|
||||||
|
|
||||||
|
Two load-bearing security controls live around this row, NOT on it:
|
||||||
|
* **cache privacy** — a cookie-authed download SALTS its MediaAsset `format_sig` with the
|
||||||
|
user id, so the authed asset is private to that user and can never be cache-served to
|
||||||
|
another user (`downloads.credentials.cookie_salt`).
|
||||||
|
* **host scope (SEC-12)** — a cookie is attached ONLY when the download URL's netloc matches
|
||||||
|
this row's `host_key` (`downloads.credentials.host_key_for_url`), so an arbitrary `?url=`
|
||||||
|
can't exfiltrate the user's session to a look-alike host.
|
||||||
|
|
||||||
|
`host_key` is a short canonical key from the supported-host registry (e.g. "facebook"), not the
|
||||||
|
raw netloc — one credential can cover several domains of the same site (facebook.com/fb.watch)."""
|
||||||
|
|
||||||
|
__tablename__ = "download_credentials"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "host_key", name="uq_download_credential"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
host_key: Mapped[str] = mapped_column(String(64))
|
||||||
|
cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
||||||
+37
-10
@@ -11,13 +11,12 @@ import html
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import storage
|
from app.downloads import storage
|
||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||||
|
|
||||||
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
||||||
|
|
||||||
@@ -74,22 +73,25 @@ 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(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
link = linksmod.find(db, token)
|
||||||
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}"
|
||||||
|
folder = link.folder_id is not None
|
||||||
if link.password_hash:
|
if link.password_hash:
|
||||||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||||||
return "\n ".join(
|
return "\n ".join(
|
||||||
[
|
[
|
||||||
"<title>Siftlode</title>",
|
"<title>Siftlode</title>",
|
||||||
_tag("og:title", "Password-protected video"),
|
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
|
||||||
_tag("og:description", "Shared via Siftlode"),
|
_tag("og:description", "Shared via Siftlode"),
|
||||||
_tag("og:type", "video.other"),
|
_tag("og:type", "website" if folder else "video.other"),
|
||||||
_tag("og:url", url),
|
_tag("og:url", url),
|
||||||
_tag("og:site_name", "Siftlode"),
|
_tag("og:site_name", "Siftlode"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
if folder:
|
||||||
|
return _folder_tags(db, link, url)
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||||
if job is None or asset is None or asset.status != "ready":
|
if job is None or asset is None or asset.status != "ready":
|
||||||
@@ -135,7 +137,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
|||||||
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
|
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
|
||||||
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
|
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
|
||||||
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
|
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
|
||||||
poster_abs = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
poster_abs = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||||||
image_dims = _jpeg_size(poster_abs) if poster_abs else None
|
image_dims = _jpeg_size(poster_abs) if poster_abs else None
|
||||||
if image_dims:
|
if image_dims:
|
||||||
tags.append(_tag("og:image:width", str(image_dims[0])))
|
tags.append(_tag("og:image:width", str(image_dims[0])))
|
||||||
@@ -147,13 +149,38 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
|||||||
return "\n ".join(tags)
|
return "\n ".join(tags)
|
||||||
|
|
||||||
|
|
||||||
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
|
def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
|
||||||
"""Return index.html with the OG block replaced for this watch token, or None to serve the
|
"""A folder link's card: the folder's name and how many files it holds — never their names,
|
||||||
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
|
which the page shows only to whoever opens it."""
|
||||||
|
folder = db.get(FileFolder, link.folder_id)
|
||||||
|
if folder is None or not folder.name:
|
||||||
|
return None
|
||||||
|
count = len(linksmod.folder_files(db, folder)) # the page's own list, so the two agree
|
||||||
|
title = folder.name.strip()
|
||||||
|
about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
|
||||||
|
return "\n ".join(
|
||||||
|
[
|
||||||
|
f"<title>{html.escape(title)}</title>",
|
||||||
|
_tag("og:title", title),
|
||||||
|
_tag("og:description", about),
|
||||||
|
_tag("og:type", "website"),
|
||||||
|
_tag("og:url", url),
|
||||||
|
_tag("og:site_name", "Siftlode"),
|
||||||
|
_tag("twitter:title", title, attr="name"),
|
||||||
|
_tag("twitter:description", about, attr="name"),
|
||||||
|
_tag("twitter:card", "summary", attr="name"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
|
||||||
|
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
|
||||||
|
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
|
||||||
|
markers)."""
|
||||||
tags = _watch_tags(token, db)
|
tags = _watch_tags(token, db)
|
||||||
if tags is None:
|
if tags is None:
|
||||||
return None
|
return None
|
||||||
text = index_html.read_text(encoding="utf-8")
|
text = page_html.read_text(encoding="utf-8")
|
||||||
if "<!--OG:START-->" not in text:
|
if "<!--OG:START-->" not in text:
|
||||||
return None
|
return None
|
||||||
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
|
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
from yt_dlp.utils import download_range_func
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
|
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||||
|
from app.downloads import service, storage
|
||||||
from app.downloads import urlguard
|
from app.downloads import urlguard
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
@@ -25,7 +28,11 @@ from app.downloads.jobdb import (
|
|||||||
)
|
)
|
||||||
from app.downloads.stated import StatedFailure
|
from app.downloads.stated import StatedFailure
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX
|
||||||
|
from app.plex.models import PlexItem
|
||||||
|
from app.downloads.models import DownloadJob, MediaAsset
|
||||||
|
from app.plex import lookups as plex_lookups
|
||||||
|
from app.plex import paths as plex_paths
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.titles import normalize_title
|
from app.titles import normalize_title
|
||||||
|
|
||||||
@@ -36,6 +43,15 @@ class _Aborted(Exception):
|
|||||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||||
|
|
||||||
|
|
||||||
|
class _ClipFetchFailed(Exception):
|
||||||
|
"""A clip's YouTube range fetch failed: its message and the download catalog's diagnosis, so a
|
||||||
|
clip on YouTube's wall says what a download of the same video would."""
|
||||||
|
|
||||||
|
def __init__(self, msg: str, diagnosis: dlerrors.Diagnosis):
|
||||||
|
super().__init__(msg)
|
||||||
|
self.diagnosis = diagnosis
|
||||||
|
|
||||||
|
|
||||||
class _OwnerRefused(Exception):
|
class _OwnerRefused(Exception):
|
||||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||||
|
|
||||||
@@ -58,6 +74,10 @@ _DB_WRITE_INTERVAL_S = 1.0
|
|||||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||||
|
|
||||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||||
|
# The job phase while a file is brought into the phone-safe shape (`_ensure_phone_safe`) for a
|
||||||
|
# download or an upload. Its own word, not yt-dlp's "processing": it reports ffmpeg's real progress,
|
||||||
|
# so the queue row shows a % for it, where yt-dlp's post-steps only get an indeterminate pulse.
|
||||||
|
CONVERTING = "converting"
|
||||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||||
@@ -73,20 +93,28 @@ _YTDLP_TEMP_RE = re.compile(
|
|||||||
|
|
||||||
def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None:
|
def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None:
|
||||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
"""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 (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
|
||||||
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
|
scheduler._notify_done). `kind` ("download"/"edit"/"upload") lets the inbox pick accurate copy; the
|
||||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
caller BEFORE its commit; only the folder is read here, once, for the notice's "Open"."""
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
# The notice's "Open" lands on the file in its folder: both ride along (a folder the file
|
||||||
|
# has left since is the page's to notice — it then looks among every file).
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
create_notification(
|
create_notification(
|
||||||
db,
|
db,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
type="download_done",
|
type="download_done",
|
||||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||||
data={"title": title, "kind": kind},
|
data={
|
||||||
|
"title": title,
|
||||||
|
"kind": kind,
|
||||||
|
"job_id": job_id,
|
||||||
|
"folder_id": job.folder_id if job else None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -208,7 +236,7 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
|
|||||||
|
|
||||||
def cancelled() -> bool:
|
def cancelled() -> bool:
|
||||||
if not state["cancelled"] and allow():
|
if not state["cancelled"] and allow():
|
||||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
state["cancelled"] = _job_status(job_id) not in (JobStatus.RUNNING, None)
|
||||||
return state["cancelled"]
|
return state["cancelled"]
|
||||||
|
|
||||||
return cancelled
|
return cancelled
|
||||||
@@ -219,7 +247,7 @@ def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
|
|||||||
when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval
|
when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval
|
||||||
heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall)."""
|
heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall)."""
|
||||||
# Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the
|
# Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the
|
||||||
# round-1 concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat —
|
# original concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat —
|
||||||
# so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that
|
# so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that
|
||||||
# same interval, keeping active downloads at ~1 write/interval, not two.
|
# same interval, keeping active downloads at ~1 write/interval, not two.
|
||||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
||||||
@@ -328,7 +356,7 @@ def _media_info(info: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_with_fallback(
|
def _extract_with_fallback(
|
||||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None, section=None
|
||||||
):
|
):
|
||||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||||
@@ -341,10 +369,17 @@ def _extract_with_fallback(
|
|||||||
(adapters.POST_*_MESSAGE) instead of letting yt-dlp guess, and a failed page read fails the
|
(adapters.POST_*_MESSAGE) instead of letting yt-dlp guess, and a failed page read fails the
|
||||||
download as itself.
|
download as itself.
|
||||||
|
|
||||||
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
`cookiefile` is the acting user's per-host cookies.txt, already gated by
|
||||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
`credentials.resolve_cookiefile` (a clip: `resolve_clip_cookiefile`); passed to every attempt so
|
||||||
|
the fallback stays authenticated.
|
||||||
|
|
||||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. A
|
||||||
|
`section` fetch goes through yt-dlp's ffmpeg downloader, which does not read it: a clip holds
|
||||||
|
its own output to the cap (`_process_clip`).
|
||||||
|
|
||||||
|
`section` = (start_s, end_s) fetches only that range (an ad-hoc clip, see downloads/clip): the
|
||||||
|
range is stream-copied, and its first frame is the marked one (measured), so the clip's own
|
||||||
|
re-encode starts at 0 instead of re-fetching keyframes at the cut."""
|
||||||
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
||||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
||||||
@@ -370,6 +405,9 @@ def _extract_with_fallback(
|
|||||||
max_bytes=max_bytes,
|
max_bytes=max_bytes,
|
||||||
)
|
)
|
||||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||||
|
if section is not None:
|
||||||
|
opts["download_ranges"] = download_range_func([], [section])
|
||||||
|
opts["force_keyframes_at_cuts"] = False
|
||||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||||
@@ -406,58 +444,58 @@ def _extract_with_fallback(
|
|||||||
raise last_exc # unreachable (loop either returns or raises)
|
raise last_exc # unreachable (loop either returns or raises)
|
||||||
|
|
||||||
|
|
||||||
def _download_settings() -> tuple[str, int]:
|
def _download_layout() -> str:
|
||||||
"""Layout + retention days from the DB config (admin-overridable on the Configuration page),
|
"""The on-disk layout from the DB config (admin-overridable on the Configuration page), falling
|
||||||
falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect."""
|
back to the env default. Read here, not from `settings`, so an admin edit takes effect. (How long
|
||||||
|
a file stays is its folder's, stamped on the job when it is done — `folders.done_expiry`.)"""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
return (
|
return sysconfig.get_str(db, "download_layout")
|
||||||
sysconfig.get_str(db, "download_layout"),
|
|
||||||
sysconfig.get_int(db, "download_retention_days"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
|
def _ensure_phone_safe(
|
||||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
job_id: int,
|
||||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
produced: Path,
|
||||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
out_dir: Path,
|
||||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
phase: str,
|
||||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
total_s: int | None = None,
|
||||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk.
|
*,
|
||||||
|
keep_source: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Bring a file that is about to become a ready library asset into the phone-safe shape
|
||||||
|
(`playable`), writing any converted copy into `out_dir`. Returns the file to store — `produced`
|
||||||
|
itself when it already is that shape, or isn't media at all — and removes `produced` once a
|
||||||
|
converted copy exists, unless `keep_source` (an upload's original, which must outlive the
|
||||||
|
conversion until the database points at the new file). The job shows `phase` with real ffmpeg
|
||||||
|
progress while it converts.
|
||||||
|
|
||||||
An audio download (mode "a") is never touched: yt-dlp already extracted it to .mp3 / .m4a, which
|
Every ready path calls this — a download, an upload's worker conversion and an editor clip — so
|
||||||
every browser plays as audio/mpeg / audio/mp4. Remuxing it into .mp4 served an mp3 as video/mp4,
|
a share link can only ever serve the one shape every phone and browser plays."""
|
||||||
which iPad's WebKit refuses to play (prod job 268)."""
|
p = playable.plan(probe.probe_json(produced), produced.suffix)
|
||||||
if spec.get("mode") == "a" or spec.get("container") != "mp4" or spec.get("vcodec"):
|
if p is None or not p.needs_change:
|
||||||
return produced
|
return produced
|
||||||
vcodec, acodec = editmod.probe_codecs(produced)
|
dest = out_dir / f"{produced.stem}.safe.{p.ext}"
|
||||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
action = "re-encode" if (p.reencode_video or p.reencode_audio) else "remux"
|
||||||
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
|
|
||||||
if not needs:
|
|
||||||
return produced
|
|
||||||
|
|
||||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
|
||||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
|
||||||
log.info(
|
log.info(
|
||||||
"job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4
|
"job %s: %s (video=%s audio=%s) -> phone-safe .%s",
|
||||||
|
job_id,
|
||||||
|
action,
|
||||||
|
p.reencode_video,
|
||||||
|
p.reencode_audio,
|
||||||
|
p.ext,
|
||||||
|
extra=logs.event("download.phone_safe", job_id=job_id, action=action, ext=p.ext),
|
||||||
)
|
)
|
||||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
_set_job(job_id, phase=phase, 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
|
# A re-encode with no known length would hold a frozen 0%; probe the file for a denominator.
|
||||||
# re-encode still shows real progress instead of a frozen 0%.
|
|
||||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
|
||||||
editmod.run_ffmpeg(
|
editmod.run_ffmpeg(
|
||||||
editmod.build_browser_safe_cmd(
|
playable.build_cmd(produced, dest, p, threads=editmod.encode_thread_cap()),
|
||||||
produced, dest, reenc_v, reenc_a, threads=editmod.encode_thread_cap()
|
total_s=total_s or editmod.probe_duration(produced),
|
||||||
),
|
on_progress=_ffmpeg_progress_writer(job_id, phase),
|
||||||
total_s=total_s,
|
|
||||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
|
||||||
should_cancel=_cancel_poll(job_id),
|
should_cancel=_cancel_poll(job_id),
|
||||||
)
|
)
|
||||||
produced.unlink(missing_ok=True)
|
if not dest.exists():
|
||||||
# Keep the stored asset metadata truthful about what's now on disk.
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
if reenc_v:
|
if not keep_source:
|
||||||
info["vcodec"] = "h264"
|
produced.unlink(missing_ok=True)
|
||||||
if reenc_a:
|
|
||||||
info["acodec"] = "aac"
|
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
@@ -483,7 +521,7 @@ def _download(
|
|||||||
urlguard.check_url(url)
|
urlguard.check_url(url)
|
||||||
except urlguard.BlockedUrl as e:
|
except urlguard.BlockedUrl as e:
|
||||||
raise StatedFailure(f"Refusing to download from this address ({e.reason}).") from e
|
raise StatedFailure(f"Refusing to download from this address ({e.reason}).") from e
|
||||||
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
# Attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||||
remaining_bytes: int | None = None
|
remaining_bytes: int | None = None
|
||||||
with SessionLocal() as cdb:
|
with SessionLocal() as cdb:
|
||||||
@@ -495,8 +533,8 @@ def _download(
|
|||||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
|
||||||
# front instead.
|
# checks the announced size) instead.
|
||||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||||
@@ -505,8 +543,8 @@ def _download(
|
|||||||
# free space, which is what the `quota_exceeded` category tells them.
|
# free space, which is what the `quota_exceeded` category tells them.
|
||||||
if remaining_bytes == 0:
|
if remaining_bytes == 0:
|
||||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||||
info = _media_info(
|
with formats.watching_size_cap() as over_cap:
|
||||||
_extract_with_fallback(
|
fetched = _extract_with_fallback(
|
||||||
job_id,
|
job_id,
|
||||||
asset_id,
|
asset_id,
|
||||||
url,
|
url,
|
||||||
@@ -516,13 +554,30 @@ def _download(
|
|||||||
cookiefile=str(cookiefile) if cookiefile else None,
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
max_bytes=remaining_bytes,
|
max_bytes=remaining_bytes,
|
||||||
)
|
)
|
||||||
)
|
info = _media_info(dict(fetched))
|
||||||
|
# yt-dlp refused a file larger than what is left: it returns with no file, which would
|
||||||
|
# otherwise read as a processing fault. The owner's storage, so the owner's refusal.
|
||||||
|
if over_cap[0]:
|
||||||
|
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
|
||||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||||
|
|
||||||
produced = _produced_file(info, staging)
|
produced = _produced_file(info, staging)
|
||||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
produced = _ensure_phone_safe(
|
||||||
|
job_id,
|
||||||
|
produced,
|
||||||
|
staging,
|
||||||
|
CONVERTING,
|
||||||
|
int(info["duration"]) if info.get("duration") else None,
|
||||||
|
)
|
||||||
|
# Held to the cap by its real size, as a clip is: yt-dlp's `max_filesize` only stops a plain
|
||||||
|
# HTTP file that announces its size, and YouTube's fragmented formats never meet it
|
||||||
|
# (measured: 718 MB fetched with 1 MB left). Read again — other jobs may have landed.
|
||||||
|
with SessionLocal() as qdb:
|
||||||
|
room = quota.remaining_bytes(qdb, user_id)
|
||||||
|
if room is not None and produced.stat().st_size > room:
|
||||||
|
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
|
||||||
ext = produced.suffix.lstrip(".").lower()
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
||||||
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
|
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
|
||||||
@@ -541,19 +596,18 @@ def _download(
|
|||||||
thumbnail_url=info.get("thumbnail"),
|
thumbnail_url=info.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout, retention_days = _download_settings()
|
layout = _download_layout()
|
||||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||||
thumb = _find_thumbnail(staging, produced)
|
thumb = _find_thumbnail(staging, produced)
|
||||||
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
||||||
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
||||||
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
||||||
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
||||||
storage.place_file(produced, settings.download_root, rel)
|
storage.place_file(produced, settings.files_root, rel)
|
||||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
nfo_ok = storage.write_sidecars(settings.files_root, rel, meta, poster_jpg)
|
||||||
|
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
size = final.stat().st_size if final.exists() else None
|
size = final.stat().st_size if final.exists() else None
|
||||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
@@ -579,12 +633,11 @@ def _download(
|
|||||||
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
||||||
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
||||||
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
||||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||||
asset.source_webpage_url = info.get("webpage_url")
|
asset.source_webpage_url = info.get("webpage_url")
|
||||||
asset.nfo_written = nfo_ok
|
asset.nfo_written = nfo_ok
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
|
||||||
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
|
||||||
@@ -722,9 +775,19 @@ def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseExcep
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fail_edit(job_id: int, asset_id: int, msg: str, exc: BaseException | None = None) -> None:
|
def _fail_edit(
|
||||||
"""Fail an edit job, and say so ONCE — every way an edit fails comes through here, so this is
|
job_id: int,
|
||||||
where its line and its event code live. `exc` is the exception when there was one.
|
asset_id: int,
|
||||||
|
msg: str,
|
||||||
|
exc: BaseException | None = None,
|
||||||
|
*,
|
||||||
|
what: str = "edit",
|
||||||
|
diagnosis: dlerrors.Diagnosis | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fail a local ffmpeg job — an edit, a clip, or an upload's conversion (`what="upload"`) — and
|
||||||
|
say so ONCE: every way one fails comes through here, so this is where its line and its event code
|
||||||
|
(`<what>.job.failed`) live. `exc` is the exception when there was one; `diagnosis` replaces the
|
||||||
|
plain category when the failure was a fetch the download catalog can name (a clip's).
|
||||||
|
|
||||||
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
|
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
|
||||||
Logged after the write, as on the download error path."""
|
Logged after the write, as on the download error path."""
|
||||||
@@ -739,19 +802,25 @@ def _fail_edit(job_id: int, asset_id: int, msg: str, exc: BaseException | None =
|
|||||||
job_id,
|
job_id,
|
||||||
status=JobStatus.ERROR,
|
status=JobStatus.ERROR,
|
||||||
error=msg,
|
error=msg,
|
||||||
error_code=dlerrors.classify(msg),
|
error_code=diagnosis.code
|
||||||
|
if diagnosis
|
||||||
|
else dlerrors.classify_conversion(msg)
|
||||||
|
if what == "upload"
|
||||||
|
else dlerrors.classify(msg),
|
||||||
|
error_detail=diagnosis.detail if diagnosis else None,
|
||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
# `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
|
# `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
|
||||||
# built from the channel and the title.
|
# built from the channel and the title.
|
||||||
log.warning(
|
log.warning(
|
||||||
"edit job %s failed: %s",
|
"%s job %s failed: %s",
|
||||||
|
what,
|
||||||
job_id,
|
job_id,
|
||||||
formats.log_safe(msg),
|
formats.log_safe(msg),
|
||||||
extra={
|
extra={
|
||||||
**logs.event(
|
**logs.event(
|
||||||
"edit.job.failed",
|
f"{what}.job.failed",
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
**({"error_type": type(exc).__name__} if exc is not None else {}),
|
**({"error_type": type(exc).__name__} if exc is not None else {}),
|
||||||
@@ -777,7 +846,7 @@ def _process_edit(job_id: int, asset_id: int) -> 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.")
|
||||||
return
|
return
|
||||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
src_path = storage.abs_path(settings.files_root, src_rel)
|
||||||
if not src_path.exists():
|
if not src_path.exists():
|
||||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||||
return
|
return
|
||||||
@@ -811,13 +880,24 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
)
|
)
|
||||||
if not dest_staging.exists():
|
if not dest_staging.exists():
|
||||||
raise StatedFailure("ffmpeg produced no output file")
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
|
# A fast trim stream-copies the source's codecs and container, so the clip is only as
|
||||||
|
# playable as its source: hold it to the same rule as every other ready file.
|
||||||
|
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
||||||
|
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
|
||||||
|
# New storage on the owner's footprint, held to what is left by its real size, as a
|
||||||
|
# download and a player clip are.
|
||||||
|
if user_id is not None:
|
||||||
|
with SessionLocal() as qdb:
|
||||||
|
room = quota.remaining_bytes(qdb, user_id)
|
||||||
|
if room is not None and dest_staging.stat().st_size > room:
|
||||||
|
raise StatedFailure(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||||
|
|
||||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||||
storage.place_file(dest_staging, settings.download_root, rel)
|
storage.place_file(dest_staging, settings.files_root, rel)
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
size = final.stat().st_size if final.exists() else None
|
size = final.stat().st_size if final.exists() else None
|
||||||
_, retention_days = _download_settings()
|
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
|
||||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
_, _, out_w, out_h, _ = probe.probe_media(final)
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
@@ -827,14 +907,15 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
asset.size_bytes = size
|
asset.size_bytes = size
|
||||||
asset.container = out_ext
|
asset.container = out_ext
|
||||||
asset.duration_s = total_s or asset.duration_s
|
asset.duration_s = total_s or asset.duration_s
|
||||||
if crop:
|
if out_w and out_h:
|
||||||
|
asset.width, asset.height = out_w, out_h
|
||||||
|
elif crop:
|
||||||
asset.width = edit_spec["crop"]["w"]
|
asset.width = edit_spec["crop"]["w"]
|
||||||
asset.height = edit_spec["crop"]["h"]
|
asset.height = edit_spec["crop"]["h"]
|
||||||
else:
|
else:
|
||||||
asset.width, asset.height = src_w, src_h
|
asset.width, asset.height = src_w, src_h
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
@@ -867,3 +948,388 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
_fail_edit(job_id, asset_id, str(exc)[:500], exc)
|
_fail_edit(job_id, asset_id, str(exc)[:500], exc)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_room(user_id: int) -> int | None:
|
||||||
|
"""What the clip's owner may still store (`quota.remaining_bytes`), refusing at zero — see
|
||||||
|
`_download`: zero is a refusal, not "no limit"."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
remaining = quota.remaining_bytes(db, user_id)
|
||||||
|
if remaining == 0:
|
||||||
|
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_source(
|
||||||
|
job_id: int, asset_id: int, spec: dict, staging: Path, remaining: int | None
|
||||||
|
) -> tuple[Path, float, str | None]:
|
||||||
|
"""The file a clip is cut from, where in it the clip starts, and the source's title when only
|
||||||
|
the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at
|
||||||
|
0 there); a Plex clip reads the library file in place."""
|
||||||
|
if spec["source"] == "youtube":
|
||||||
|
url = service.source_url("youtube", spec["ref"])
|
||||||
|
cookiefile: Path | None = None
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
# Signed in when the user has a YouTube cookie, as their downloads are: YouTube gates
|
||||||
|
# single videos signed out ("not a bot"), and the clip is theirs alone.
|
||||||
|
if job and asset:
|
||||||
|
cookiefile = credentials.resolve_clip_cookiefile(
|
||||||
|
db, job.user_id, url, asset, spec, staging
|
||||||
|
)
|
||||||
|
_set_job(job_id, phase="downloading", progress=0)
|
||||||
|
try:
|
||||||
|
with formats.watching_size_cap() as over_cap:
|
||||||
|
info = _extract_with_fallback(
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
url,
|
||||||
|
formats.normalize(clipmod.YOUTUBE_FETCH_SPEC),
|
||||||
|
staging,
|
||||||
|
adapters.resolve_adapter("youtube", spec["ref"]),
|
||||||
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
|
max_bytes=remaining,
|
||||||
|
section=(spec["start_s"], spec["end_s"]),
|
||||||
|
)
|
||||||
|
# Should yt-dlp's own cap ever refuse the range (see `_download`): the owner's storage.
|
||||||
|
if over_cap[0]:
|
||||||
|
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||||
|
except (_Aborted, _OwnerRefused):
|
||||||
|
raise
|
||||||
|
except Exception as exc: # re-raised below with the download catalog's diagnosis
|
||||||
|
msg = str(exc)[:500]
|
||||||
|
raise _ClipFetchFailed(
|
||||||
|
msg, dlerrors.diagnose(msg, url=url, cookie_attached=cookiefile is not None)
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
# Never leave a plaintext session cookie on disk past the fetch (see `_download`).
|
||||||
|
if cookiefile is not None:
|
||||||
|
cookiefile.unlink(missing_ok=True)
|
||||||
|
info = _media_info(dict(info))
|
||||||
|
title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info))
|
||||||
|
return _produced_file(info, staging), 0.0, title
|
||||||
|
with SessionLocal() as db:
|
||||||
|
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
|
||||||
|
src = plex_paths.local_media_path(db, item.file_path) if item else None
|
||||||
|
if src is None:
|
||||||
|
raise StatedFailure("The library file is not reachable from here.")
|
||||||
|
return src, spec["start_s"], None
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_crop(spec: dict, data: dict) -> str:
|
||||||
|
"""The clip's crop filter, from its frame and the picture the file really holds ("" for none)."""
|
||||||
|
frame = spec.get("crop")
|
||||||
|
if not frame:
|
||||||
|
return ""
|
||||||
|
dar = clipmod.display_aspect(data)
|
||||||
|
if dar is None:
|
||||||
|
raise StatedFailure("The picture's size could not be read, so the crop cannot be placed.")
|
||||||
|
crop = clipmod.crop_filter(frame, dar)
|
||||||
|
if crop is None:
|
||||||
|
raise StatedFailure("The crop frame lies outside the picture.")
|
||||||
|
return crop
|
||||||
|
|
||||||
|
|
||||||
|
def _process_clip(job_id: int, asset_id: int) -> None:
|
||||||
|
"""Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip
|
||||||
|
shape, then into the user's library beside their edits."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
spec = dict(job.edit_spec or {})
|
||||||
|
user_id = job.user_id
|
||||||
|
staging = _edit_staging_dir(asset_id)
|
||||||
|
try:
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Refused before the fetch and the encode when the owner's storage is already full.
|
||||||
|
remaining = _clip_room(user_id)
|
||||||
|
src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging, remaining)
|
||||||
|
data = probe.probe_json(src)
|
||||||
|
if not data:
|
||||||
|
raise StatedFailure("The source file could not be read.")
|
||||||
|
audio_track = spec.get("audio_track")
|
||||||
|
tracks = clipmod.audio_track_count(data)
|
||||||
|
if tracks == 0:
|
||||||
|
# A silent source: the player still names its track 0, and the clip is silent too.
|
||||||
|
audio_track = None
|
||||||
|
elif audio_track is not None and audio_track >= tracks:
|
||||||
|
raise StatedFailure("The chosen audio track is not in the file.")
|
||||||
|
crop = _clip_crop(spec, data)
|
||||||
|
total_s = clipmod.duration(spec)
|
||||||
|
out = staging / "clip.mp4"
|
||||||
|
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||||
|
editmod.run_ffmpeg(
|
||||||
|
clipmod.build_cmd(
|
||||||
|
src,
|
||||||
|
out,
|
||||||
|
start,
|
||||||
|
total_s,
|
||||||
|
audio_track=audio_track,
|
||||||
|
hdr=clipmod.is_hdr(data),
|
||||||
|
threads=editmod.encode_thread_cap(),
|
||||||
|
crop=crop,
|
||||||
|
),
|
||||||
|
total_s,
|
||||||
|
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||||
|
should_cancel=_cancel_poll(job_id),
|
||||||
|
)
|
||||||
|
if not out.exists():
|
||||||
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
|
# The phone-safe shape by construction; still held to the rule every ready file meets.
|
||||||
|
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
|
||||||
|
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
|
||||||
|
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
|
||||||
|
# again, since other jobs of this owner may have landed during the encode.
|
||||||
|
remaining = _clip_room(user_id)
|
||||||
|
if remaining is not None and out.stat().st_size > remaining:
|
||||||
|
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||||
|
ext = out.suffix.lstrip(".").lower() or "mp4"
|
||||||
|
rel = storage.edit_rel_path(user_id, asset_id, ext)
|
||||||
|
storage.place_file(out, settings.files_root, rel)
|
||||||
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
|
vcodec, acodec, width, height, _ = probe.probe_media(final)
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if asset:
|
||||||
|
asset.status = "ready"
|
||||||
|
asset.rel_path = rel
|
||||||
|
asset.size_bytes = final.stat().st_size if final.exists() else None
|
||||||
|
asset.container = ext
|
||||||
|
asset.vcodec, asset.acodec, asset.width, asset.height = (
|
||||||
|
vcodec,
|
||||||
|
acodec,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
asset.duration_s = round(total_s)
|
||||||
|
asset.media_kind = mediakind.derive(vcodec, acodec)
|
||||||
|
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
|
||||||
|
# item has no public thumbnail at all.
|
||||||
|
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||||
|
asset.error = None
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
|
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
|
||||||
|
placeholder = service.clip_title(None, None)
|
||||||
|
if asset and fetched_title and asset.title == placeholder:
|
||||||
|
named = service.clip_title(fetched_title, None)
|
||||||
|
asset.title = named
|
||||||
|
if job_row := db.get(DownloadJob, job_id):
|
||||||
|
if job_row.display_name == placeholder:
|
||||||
|
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
|
||||||
|
title = asset.title if asset else None
|
||||||
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
|
db.commit()
|
||||||
|
if finalized:
|
||||||
|
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
||||||
|
log.info(
|
||||||
|
"clip job %s done: asset %s",
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
extra=logs.event(
|
||||||
|
"clip.job.done", job_id=job_id, asset_id=asset_id, source=spec["source"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (_Aborted, editmod.EditAborted):
|
||||||
|
# Paused/canceled mid-fetch or mid-encode: free the asset for a later retry.
|
||||||
|
_reset_asset_pending(asset_id)
|
||||||
|
log.info(
|
||||||
|
"clip job %s aborted (pause/cancel)",
|
||||||
|
job_id,
|
||||||
|
extra=logs.event("clip.job.aborted", job_id=job_id),
|
||||||
|
)
|
||||||
|
except _OwnerRefused as exc:
|
||||||
|
# Quota full: the job fails, the asset goes back to pending for a Retry once there is room
|
||||||
|
# (the download's handling; see `_download`).
|
||||||
|
msg = str(exc)[:500]
|
||||||
|
with SessionLocal() as db:
|
||||||
|
service.finalize_if_running(
|
||||||
|
db,
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.ERROR,
|
||||||
|
error=msg,
|
||||||
|
error_code=dlerrors.classify(msg),
|
||||||
|
error_detail=None,
|
||||||
|
phase=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
_reset_asset_pending(asset_id)
|
||||||
|
log.info(
|
||||||
|
"clip job %s refused for its owner: %s",
|
||||||
|
job_id,
|
||||||
|
formats.log_safe(msg),
|
||||||
|
extra=logs.event("clip.job.refused", job_id=job_id),
|
||||||
|
)
|
||||||
|
except _ClipFetchFailed as exc:
|
||||||
|
# silent-ok: not silent — `_fail_edit` is the report, with the fetch's diagnosis.
|
||||||
|
_fail_edit(job_id, asset_id, str(exc), exc, what="clip", diagnosis=exc.diagnosis)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||||
|
# silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
|
||||||
|
_fail_edit(job_id, asset_id, str(exc)[:500], exc, what="clip")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) -> None:
|
||||||
|
"""Refuse a converted upload that would push its owner over the storage cap: the upload was
|
||||||
|
charged at its original size, and a re-encode can come out larger. The original stays, counted,
|
||||||
|
for the owner to delete or keep."""
|
||||||
|
if owner_id is None:
|
||||||
|
return
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
|
||||||
|
raise StatedFailure(dlerrors.CONVERTED_TOO_BIG_MESSAGE)
|
||||||
|
|
||||||
|
|
||||||
|
def _unplace(placed: Path | None) -> None:
|
||||||
|
"""Remove a converted upload that was placed at its new rel but never committed: the row still
|
||||||
|
names the original, so nothing else would ever find this file."""
|
||||||
|
if placed is not None:
|
||||||
|
placed.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _conversion_landed(asset_id: int, new_rel: str) -> bool | None:
|
||||||
|
"""After the commit that makes a converted upload ready RAISED: did it land anyway? A commit can
|
||||||
|
reach the server and lose its answer (the connection drops after COMMIT). True/False by what the
|
||||||
|
row now says; None when the row cannot be read either."""
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
return asset is not None and asset.status == "ready" and asset.rel_path == new_rel
|
||||||
|
except Exception: # noqa: BLE001 — "cannot tell" is the answer, the caller acts on it
|
||||||
|
# silent-ok: the caller re-raises the commit's own error, which `_fail_edit` reports.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_original(job_id: int, src: Path) -> None:
|
||||||
|
"""Remove an upload's original once the converted file is committed in its place. Best effort:
|
||||||
|
the job is done and its file is ready, so a failure here must not fail it — the original is
|
||||||
|
named by no row now, and the GC's `.uploads` sweep takes it."""
|
||||||
|
try:
|
||||||
|
src.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning(
|
||||||
|
"upload job %s: original not removed, left to the GC sweep: %s",
|
||||||
|
job_id,
|
||||||
|
exc,
|
||||||
|
extra=logs.event("upload.original.unlink_failed", job_id=job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _free_unheld_upload(asset_id: int) -> bool:
|
||||||
|
"""Free an upload no job holds any more — cancelled or deleted while its conversion ran, when
|
||||||
|
`release_asset` had to leave it because the conversion still read its file. Once that has
|
||||||
|
stopped, free it as the release would have; left alone, its file would sit on disk counted by no
|
||||||
|
owner's footprint until the GC's upload pass. False when a job still holds it (paused)."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if asset is None or asset.ref_count != 0:
|
||||||
|
return False
|
||||||
|
if asset.rel_path:
|
||||||
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
|
db.delete(asset)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_upload(job_id: int, asset_id: int) -> None:
|
||||||
|
"""Convert a queued upload into the phone-safe shape (`playable`) and make it ready.
|
||||||
|
|
||||||
|
The original sits at the asset's `rel_path` (see `upload`); the converted file replaces it there
|
||||||
|
under the new extension, and only then is the original removed — so a failed or stopped
|
||||||
|
conversion leaves the original in place for Retry, and a delete still finds it by `rel_path`.
|
||||||
|
A converted file placed but never committed is removed again on the way out. When the commit's
|
||||||
|
own outcome is unknown neither file is removed: the row names one of them, and the GC's `.uploads`
|
||||||
|
sweep takes the one it does not — as it does an original whose unlink after the commit failed."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
old_rel = asset.rel_path if asset else None
|
||||||
|
old_ext = asset.container if asset else None
|
||||||
|
old_size = (asset.size_bytes or 0) if asset else 0
|
||||||
|
duration = asset.duration_s if asset else None
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
owner_id = job.user_id if job else None
|
||||||
|
src = storage.safe_abs_path(settings.files_root, old_rel) if old_rel else None
|
||||||
|
if old_rel is None or src is None or not src.exists():
|
||||||
|
_fail_edit(job_id, asset_id, dlerrors.UPLOAD_MISSING_MESSAGE, what="upload")
|
||||||
|
return
|
||||||
|
staging = _staging_dir(asset_id)
|
||||||
|
placed: Path | None = None # the converted file while it is at its new rel, uncommitted
|
||||||
|
try:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe = _ensure_phone_safe(job_id, src, staging, CONVERTING, duration, keep_source=True)
|
||||||
|
ext = safe.suffix.lstrip(".").lower()
|
||||||
|
new_rel = storage.with_ext(old_rel, old_ext, ext)
|
||||||
|
if safe != src:
|
||||||
|
_check_converted_fits(owner_id, old_size, safe.stat().st_size)
|
||||||
|
storage.place_file(safe, settings.files_root, new_rel)
|
||||||
|
if new_rel != old_rel:
|
||||||
|
placed = storage.abs_path(settings.files_root, new_rel)
|
||||||
|
final = storage.abs_path(settings.files_root, new_rel)
|
||||||
|
vcodec, acodec, width, height, dur = probe.probe_media(final)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if asset:
|
||||||
|
asset.status = "ready"
|
||||||
|
asset.rel_path = new_rel
|
||||||
|
asset.container = ext
|
||||||
|
asset.size_bytes = final.stat().st_size
|
||||||
|
asset.vcodec, asset.acodec = vcodec, acodec
|
||||||
|
asset.width, asset.height = width or asset.width, height or asset.height
|
||||||
|
asset.duration_s = dur or asset.duration_s
|
||||||
|
asset.error = None
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
title = asset.title if asset else None
|
||||||
|
finalized = service.finalize_if_running(
|
||||||
|
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
landed = _conversion_landed(asset_id, new_rel)
|
||||||
|
if landed is False:
|
||||||
|
raise # rolled back: the original is still the asset's file — fail as any step
|
||||||
|
# Landed, or cannot tell: the converted file may be the asset's file now, so it is
|
||||||
|
# never removed blind (the sweep takes whichever file no row names).
|
||||||
|
placed = None
|
||||||
|
if landed is None:
|
||||||
|
raise
|
||||||
|
db.rollback()
|
||||||
|
placed = None
|
||||||
|
if finalized:
|
||||||
|
_notify_download_done(db, owner_id, title, job_id, kind="upload")
|
||||||
|
if new_rel != old_rel:
|
||||||
|
_drop_original(job_id, src)
|
||||||
|
if not finalized:
|
||||||
|
_free_unheld_upload(asset_id) # finished after its job was cancelled or deleted
|
||||||
|
if finalized:
|
||||||
|
log.info(
|
||||||
|
"upload job %s converted: asset %s",
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
extra=logs.event("upload.job.done", job_id=job_id, asset_id=asset_id, ext=ext),
|
||||||
|
)
|
||||||
|
except editmod.EditAborted:
|
||||||
|
# Paused/canceled mid-encode: the original is untouched. A paused job keeps it for its
|
||||||
|
# resume; one cancelled or deleted holds it no more, so it is freed now.
|
||||||
|
_unplace(placed)
|
||||||
|
if not _free_unheld_upload(asset_id):
|
||||||
|
_reset_asset_pending(asset_id)
|
||||||
|
log.info(
|
||||||
|
"upload job %s conversion aborted (pause/cancel)",
|
||||||
|
job_id,
|
||||||
|
extra=logs.event("upload.job.aborted", job_id=job_id),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||||
|
# silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
|
||||||
|
_unplace(placed)
|
||||||
|
_fail_edit(job_id, asset_id, str(exc)[:500], exc, what="upload")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""The phone-safe rule: what a library file must be so it plays wherever a share link lands.
|
||||||
|
|
||||||
|
A shared `/watch` link is opened by whoever got it, on whatever they hold: an iPhone's WebKit, an
|
||||||
|
Android Chrome, a desktop Firefox. The one shape every one of them plays:
|
||||||
|
|
||||||
|
* video → H.264, 8-bit 4:2:0 (`yuv420p`, or its full-range twin `yuvj420p`), AAC audio, in .mp4
|
||||||
|
* audio only → MP3 in .mp3, or AAC in .m4a
|
||||||
|
|
||||||
|
Measured on the owner's iPhone + iPad (2026-10-05): AV1, Matroska, HEVC tagged `hev1` and MP3
|
||||||
|
inside an .mp4 all fail there. VP9/WebM, HEVC `hvc1` and 10-bit H.264 played on those devices, but
|
||||||
|
Chrome and Firefox do not decode HEVC and browsers decode 10-bit / 4:4:4 H.264 only patchily, so the
|
||||||
|
rule is the shape every browser plays, not the shape one device plays.
|
||||||
|
|
||||||
|
Every file that becomes a ready library asset passes through here — a download, an upload (the
|
||||||
|
worker converts it, see `upload`), and an editor clip — so a link can only ever serve this shape.
|
||||||
|
`plan` says what (if anything) must change; `build_cmd` is the one ffmpeg command that changes it:
|
||||||
|
re-encode only the stream that fails, copy the rest, or just remux when only the container is wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.downloads import edit as editmod
|
||||||
|
from app.downloads import probe
|
||||||
|
|
||||||
|
_SAFE_VCODEC = "h264"
|
||||||
|
_SAFE_PIX_FMTS = frozenset({"yuv420p", "yuvj420p"})
|
||||||
|
_VIDEO_EXT = "mp4"
|
||||||
|
# An audio-only file: the codec, and the extension it plays in. Anything else becomes AAC in .m4a.
|
||||||
|
_AUDIO_HOME = {"mp3": "mp3", "aac": "m4a"}
|
||||||
|
_AUDIO_FALLBACK_EXT = "m4a"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Plan:
|
||||||
|
"""What makes one file phone-safe. `ext` is the extension the safe file has (no dot)."""
|
||||||
|
|
||||||
|
ext: str
|
||||||
|
has_video: bool
|
||||||
|
reencode_video: bool
|
||||||
|
reencode_audio: bool
|
||||||
|
needs_change: bool
|
||||||
|
|
||||||
|
|
||||||
|
def plan(data: dict | None, ext: str) -> Plan | None:
|
||||||
|
"""The phone-safe plan for a file whose `probe.probe_json` is `data` and whose extension is
|
||||||
|
`ext`; None when it is not media at all (an unreadable probe, a still image, no audio or video
|
||||||
|
stream) — such a file is a download-only 'file' and never plays inline, so there is nothing to
|
||||||
|
make safe."""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
video, audio = probe.primary_streams(data)
|
||||||
|
ext = ext.lower().lstrip(".")
|
||||||
|
if video is not None:
|
||||||
|
rv = (video.get("codec_name") or "").lower() != _SAFE_VCODEC or (
|
||||||
|
video.get("pix_fmt") or ""
|
||||||
|
).lower() not in _SAFE_PIX_FMTS
|
||||||
|
ra = audio is not None and (audio.get("codec_name") or "").lower() != "aac"
|
||||||
|
return Plan(_VIDEO_EXT, True, rv, ra, rv or ra or ext != _VIDEO_EXT)
|
||||||
|
if audio is not None:
|
||||||
|
home = _AUDIO_HOME.get((audio.get("codec_name") or "").lower())
|
||||||
|
target = home or _AUDIO_FALLBACK_EXT
|
||||||
|
return Plan(target, False, False, home is None, home is None or ext != target)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_cmd(src: Path, dest: Path, p: Plan, threads: int | None = None) -> list[str]:
|
||||||
|
"""The ffmpeg command that writes `src` to `dest` (whose suffix is `p.ext`) in the phone-safe
|
||||||
|
shape: the failing stream(s) re-encoded with the package's shared encoder settings, the rest
|
||||||
|
stream-copied. An audio-only result drops any cover-art stream. `-progress pipe:1`
|
||||||
|
streams machine-readable progress; `threads` caps the encoder so a re-encode cannot claim every
|
||||||
|
core on the shared box (see the worker-child de-prioritization)."""
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-progress",
|
||||||
|
"pipe:1",
|
||||||
|
"-nostats",
|
||||||
|
*editmod.INPUT_GUARD,
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
]
|
||||||
|
# Exactly the two streams `plan` judged: `V` is a video stream that is not cover art. Nothing
|
||||||
|
# else rides along — a subtitle or data track the .mp4 muxer cannot take would fail the run.
|
||||||
|
if p.has_video:
|
||||||
|
cmd += ["-map", "0:V:0", "-map", "0:a:0?"]
|
||||||
|
if p.reencode_video:
|
||||||
|
cmd += ["-vf", editmod.EVEN_FRAME, *editmod.X264_ARGS]
|
||||||
|
else:
|
||||||
|
cmd += ["-c:v", "copy"]
|
||||||
|
else:
|
||||||
|
cmd += ["-map", "0:a:0"]
|
||||||
|
cmd += editmod.AAC_ARGS if p.reencode_audio else ["-c:a", "copy"]
|
||||||
|
if threads and threads > 0:
|
||||||
|
cmd += ["-threads", str(threads)]
|
||||||
|
if p.ext in ("mp4", "m4a"):
|
||||||
|
cmd += ["-movflags", "+faststart"]
|
||||||
|
cmd += [str(dest)]
|
||||||
|
return cmd
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""What a library file previews as — the single home of the preview allow-list.
|
||||||
|
|
||||||
|
A generic file (`media_kind == "file"`; video and audio have their players) previews by its stored
|
||||||
|
`container`, the upload's cleaned extension, and ONLY through this table: an image is shown as an
|
||||||
|
image, a PDF in the browser's own viewer, and a text format as plain text. Anything else has no
|
||||||
|
preview. The served Content-Type comes from here too, never from a guess on the name, and HTML / SVG
|
||||||
|
sit in the TEXT list on purpose: rendered on this origin, an uploaded page would run with the
|
||||||
|
reader's session. The route adds `CSP: sandbox` on top, so even a mislabelled file (a "png" that is
|
||||||
|
really a page) gets no script and no origin.
|
||||||
|
|
||||||
|
The serializer hands the kind to the client (`asset.preview`), so the library and the server can
|
||||||
|
never disagree on which files open a preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import codecs
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.downloads import mediakind
|
||||||
|
|
||||||
|
PreviewKind = Literal["image", "pdf", "text"]
|
||||||
|
|
||||||
|
IMAGE_TYPES: dict[str, str] = {
|
||||||
|
"png": "image/png",
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"avif": "image/avif",
|
||||||
|
"bmp": "image/bmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
PDF_TYPE = "application/pdf"
|
||||||
|
|
||||||
|
# Read as text: notes, data, subtitles, config and source. HTML and SVG belong here (see above).
|
||||||
|
TEXT_EXTS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"txt", "text", "md", "markdown", "log", "csv", "tsv", "json", "jsonl", "ndjson", "xml",
|
||||||
|
"yaml", "yml", "toml", "ini", "conf", "cfg", "properties", "env", "srt", "vtt", "ass", "ssa",
|
||||||
|
"sub", "nfo", "cue", "m3u", "m3u8", "html", "htm", "xhtml", "svg", "css", "scss", "less",
|
||||||
|
"js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "rb", "php", "pl", "lua", "go", "rs", "java",
|
||||||
|
"kt", "kts", "swift", "c", "h", "cc", "cpp", "hpp", "cs", "sh", "bash", "zsh", "fish", "ps1",
|
||||||
|
"bat", "cmd", "sql", "r", "dart", "diff", "patch", "tex", "rst", "adoc", "gradle", "dockerfile",
|
||||||
|
}
|
||||||
|
) # fmt: skip
|
||||||
|
|
||||||
|
TEXT_TYPE = "text/plain; charset=utf-8"
|
||||||
|
|
||||||
|
# A text preview is the file's head: enough to read, never a whole log loaded into a tab.
|
||||||
|
TEXT_PREVIEW_BYTES = 1 << 20
|
||||||
|
# A NUL byte in the first this-many bytes means the "text" is not text.
|
||||||
|
BINARY_SNIFF_BYTES = 8192
|
||||||
|
|
||||||
|
|
||||||
|
def kind(media_kind: str | None, container: str | None) -> PreviewKind | None:
|
||||||
|
"""The preview a library file gets, or None (a media file plays instead; others download)."""
|
||||||
|
if media_kind != mediakind.FILE:
|
||||||
|
return None
|
||||||
|
ext = (container or "").lower()
|
||||||
|
if ext in IMAGE_TYPES:
|
||||||
|
return "image"
|
||||||
|
if ext == "pdf":
|
||||||
|
return "pdf"
|
||||||
|
if ext in TEXT_EXTS:
|
||||||
|
return "text"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def media_type(preview: PreviewKind, container: str | None) -> str:
|
||||||
|
"""The Content-Type a preview is served with: from this table, never guessed from the name."""
|
||||||
|
if preview == "image":
|
||||||
|
return IMAGE_TYPES[(container or "").lower()]
|
||||||
|
if preview == "pdf":
|
||||||
|
return PDF_TYPE
|
||||||
|
return TEXT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# A text that is not UTF-8 is read as Central European Windows: what an old Hungarian subtitle or
|
||||||
|
# note is written in (its ő and ű are not in Latin-1).
|
||||||
|
FALLBACK_ENCODING = "cp1250"
|
||||||
|
_BOMS = (
|
||||||
|
(codecs.BOM_UTF8, "utf-8-sig"),
|
||||||
|
(codecs.BOM_UTF16_LE, "utf-16"),
|
||||||
|
(codecs.BOM_UTF16_BE, "utf-16"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_binary(head: bytes) -> bool:
|
||||||
|
"""Whether the start of a "text" file is binary after all (a NUL in it)."""
|
||||||
|
return b"\x00" in head[:BINARY_SNIFF_BYTES]
|
||||||
|
|
||||||
|
|
||||||
|
def text_utf8(head: bytes) -> bytes | None:
|
||||||
|
"""A text file's head as UTF-8, the one charset the preview is served in; None when it is not text.
|
||||||
|
A byte-order mark names its encoding (UTF-16 is full of NULs, so it is read before the binary
|
||||||
|
check); else UTF-8 is kept as it is — a character cut at the size cap included, the reader shows
|
||||||
|
that one as a replacement mark — and anything else is read as `FALLBACK_ENCODING`."""
|
||||||
|
for bom, encoding in _BOMS:
|
||||||
|
if head.startswith(bom):
|
||||||
|
return head.decode(encoding, errors="replace").encode()
|
||||||
|
if looks_binary(head):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
head.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as e: # silent-ok: not UTF-8 is an answer — read it as the fallback
|
||||||
|
if not (e.reason == "unexpected end of data" and e.end == len(head)):
|
||||||
|
return head.decode(FALLBACK_ENCODING, errors="replace").encode()
|
||||||
|
return head
|
||||||
@@ -34,6 +34,28 @@ _IMAGE_FORMATS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def primary_streams(data: dict) -> tuple[dict | None, dict | None]:
|
||||||
|
"""(video stream, audio stream) — the first of each kind — from a NON-None ffprobe JSON dict, by
|
||||||
|
the same reading the stored facts use: a still image / image-sequence container has neither (it
|
||||||
|
is a generic 'file'), and a video stream flagged `attached_pic` (an mp3/m4a's cover art) is not a
|
||||||
|
video track. The phone-safe rule (`playable`) judges exactly these two streams."""
|
||||||
|
fmt_names = {
|
||||||
|
n.strip() for n in ((data.get("format") or {}).get("format_name") or "").split(",")
|
||||||
|
}
|
||||||
|
if fmt_names & _IMAGE_FORMATS:
|
||||||
|
return None, None
|
||||||
|
video = audio = None
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
kind = stream.get("codec_type")
|
||||||
|
if kind == "video" and video is None:
|
||||||
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||||
|
continue # cover art, not a video track
|
||||||
|
video = stream
|
||||||
|
elif kind == "audio" and audio is None:
|
||||||
|
audio = stream
|
||||||
|
return video, audio
|
||||||
|
|
||||||
|
|
||||||
def _facts_from_probe(
|
def _facts_from_probe(
|
||||||
data: dict,
|
data: dict,
|
||||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
@@ -45,19 +67,11 @@ def _facts_from_probe(
|
|||||||
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||||
if fmt_names & _IMAGE_FORMATS:
|
if fmt_names & _IMAGE_FORMATS:
|
||||||
return None, None, None, None, None
|
return None, None, None, None, None
|
||||||
|
video, audio = primary_streams(data)
|
||||||
vcodec = acodec = None
|
vcodec = video.get("codec_name") if video else None
|
||||||
width = height = None
|
width = video.get("width") if video else None
|
||||||
for stream in data.get("streams", []):
|
height = video.get("height") if video else None
|
||||||
kind = stream.get("codec_type")
|
acodec = audio.get("codec_name") if audio else None
|
||||||
if kind == "video" and vcodec is None:
|
|
||||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
|
||||||
continue # cover art, not a video track
|
|
||||||
vcodec = stream.get("codec_name")
|
|
||||||
width = stream.get("width")
|
|
||||||
height = stream.get("height")
|
|
||||||
elif kind == "audio" and acodec is None:
|
|
||||||
acodec = stream.get("codec_name")
|
|
||||||
|
|
||||||
duration = None
|
duration = None
|
||||||
try:
|
try:
|
||||||
@@ -71,16 +85,29 @@ def _facts_from_probe(
|
|||||||
return vcodec, acodec, width, height, duration
|
return vcodec, acodec, width, height, duration
|
||||||
|
|
||||||
|
|
||||||
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
def probe_json(path: Path) -> dict | None:
|
||||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
"""The full ffprobe JSON (streams + format) of `path`, or None when ffprobe could not read it —
|
||||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
the one call both the stored facts (`facts`) and the phone-safe rule read from."""
|
||||||
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
|
return editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
|
||||||
|
|
||||||
|
def facts(
|
||||||
|
data: dict | None,
|
||||||
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) of a `probe_json` result; all None when it is None
|
||||||
|
(an unprobeable file is treated as a generic non-media file, so it becomes a download-only 'file'
|
||||||
|
rather than a broken 'video')."""
|
||||||
if data is None:
|
if data is None:
|
||||||
return None, None, None, None, None
|
return None, None, None, None, None
|
||||||
return _facts_from_probe(data)
|
return _facts_from_probe(data)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||||
|
or the file isn't playable media (a still image is a generic 'file', see `primary_streams`)."""
|
||||||
|
return facts(probe_json(path))
|
||||||
|
|
||||||
|
|
||||||
def resolve_media_facts(
|
def resolve_media_facts(
|
||||||
path: Path, info: dict
|
path: Path, info: dict
|
||||||
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
@@ -94,18 +121,18 @@ def resolve_media_facts(
|
|||||||
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
||||||
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
|
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
|
||||||
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
||||||
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
|
mislabelling an audio download as a broken in-app 'video'. When ffprobe could NOT read the file
|
||||||
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
at all, no codec is reported: the probe runs with the media demuxer whitelist, so a file it
|
||||||
dimensions and the duration still gap-fill from `info` in either case."""
|
cannot open is one the player and the editor cannot open either — and taking `info`'s codecs
|
||||||
|
for it would make it an editable 'video' whose bytes are whatever the remote site served. The
|
||||||
|
cosmetic dimensions and the duration still gap-fill from `info` in either case."""
|
||||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
|
||||||
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
|
||||||
info_w = int(info["width"]) if info.get("width") else None
|
info_w = int(info["width"]) if info.get("width") else None
|
||||||
info_h = int(info["height"]) if info.get("height") else None
|
info_h = int(info["height"]) if info.get("height") else None
|
||||||
info_d = int(info["duration"]) if info.get("duration") else None
|
info_d = int(info["duration"]) if info.get("duration") else None
|
||||||
if data is None:
|
if data is None:
|
||||||
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
# Unreadable to the guarded probe: a download-only 'file' (no codecs → media_kind FILE).
|
||||||
return info_v, info_a, info_w, info_h, info_d
|
return None, None, info_w, info_h, info_d
|
||||||
pv, pa, pw, ph, pd = _facts_from_probe(data)
|
pv, pa, pw, ph, pd = _facts_from_probe(data)
|
||||||
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
||||||
# gap-fills the width/height/duration the probe couldn't read.
|
# gap-fills the width/height/duration the probe couldn't read.
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""Capture's reads: what the library, the download tray, the share dialogs and the public watch pages
|
||||||
|
show. Each returns rows or plain values and writes nothing; whose a row is goes through
|
||||||
|
`userscope.owned_by` / `owned_row`, so a row of someone else's reads as a missing one.
|
||||||
|
|
||||||
|
The HTTP layer (`routes.downloads`, `routes.files`, `routes.public`) asks here and serializes; the
|
||||||
|
writes are the service's (`service`, `folders`, `links`, `quota`)."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.apitokens import FilesCaller, Right
|
||||||
|
from app.collation import collated
|
||||||
|
from app.downloads import folders, links, quota
|
||||||
|
from app.downloads.models import (
|
||||||
|
DownloadJob,
|
||||||
|
DownloadLink,
|
||||||
|
DownloadProfile,
|
||||||
|
DownloadQuota,
|
||||||
|
DownloadShare,
|
||||||
|
FileFolder,
|
||||||
|
LibraryState,
|
||||||
|
MediaAsset,
|
||||||
|
)
|
||||||
|
from app.downloads.status import FINISHED, JobStatus
|
||||||
|
from app.models import User
|
||||||
|
from app.userscope import owned_by, owned_row
|
||||||
|
from app.utils import like_escape
|
||||||
|
|
||||||
|
# Cap a listing so a heavy library can't return an unbounded result set (the old Download Center's
|
||||||
|
# own cap; far beyond any real library).
|
||||||
|
MAX_LIST = 500
|
||||||
|
|
||||||
|
|
||||||
|
# --- jobs and their files ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def own_job(db: Session, user: User, job_id: int) -> DownloadJob | None:
|
||||||
|
return owned_row(db, DownloadJob, job_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
def job(db: Session, job_id: int) -> DownloadJob | None:
|
||||||
|
"""A job by id, whoever's — for a public link, which already names its job."""
|
||||||
|
return db.get(DownloadJob, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob | None:
|
||||||
|
"""A file the caller may read: their own, or one shared with them."""
|
||||||
|
row = db.get(DownloadJob, job_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if row.user_id == user.id or shared_with(db, job_id, user) is not None:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob | None:
|
||||||
|
"""A file `caller` may read: as `accessible_job` for the user; for a token, one of the user's
|
||||||
|
own in a folder it may read."""
|
||||||
|
if caller.grants is None:
|
||||||
|
return accessible_job(db, caller.user, job_id)
|
||||||
|
row = own_job(db, caller.user, job_id)
|
||||||
|
if row is None or not caller.may(row.folder_id, Right.READ):
|
||||||
|
return None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def asset_of(db: Session, job: DownloadJob | None) -> MediaAsset | None:
|
||||||
|
"""The file under a job, if it has one yet."""
|
||||||
|
return db.get(MediaAsset, job.asset_id) if job is not None and job.asset_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def assets_for(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, MediaAsset]:
|
||||||
|
ids = {j.asset_id for j in jobs if j.asset_id}
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(select(MediaAsset).where(MediaAsset.id.in_(ids))).scalars()
|
||||||
|
return {a.id: a for a in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
|
||||||
|
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
|
||||||
|
ids = [j.id for j in jobs]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(
|
||||||
|
select(LibraryState.job_id, LibraryState.position_seconds).where(
|
||||||
|
owned_by(LibraryState.user_id, user), LibraryState.job_id.in_(ids)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {job_id: pos for job_id, pos in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]:
|
||||||
|
"""Where each file came from (its source folder's key), for the list's Source column, the
|
||||||
|
"back to its source folder" move and the download tray's site: a file in a source folder has its
|
||||||
|
folder's key; one moved into an own folder (or someone else's, shared) is traced back by
|
||||||
|
`folders.source_key_for`."""
|
||||||
|
ids = {j.folder_id for j in jobs if j.folder_id is not None}
|
||||||
|
by_id = (
|
||||||
|
{f.id: f for f in db.execute(select(FileFolder).where(FileFolder.id.in_(ids))).scalars()}
|
||||||
|
if ids
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
out: dict[int, str] = {}
|
||||||
|
for j in jobs:
|
||||||
|
folder = by_id.get(j.folder_id) if j.folder_id is not None else None
|
||||||
|
if folder is not None and folder.kind == folders.FolderKind.SOURCE and folder.source_key:
|
||||||
|
out[j.id] = folder.source_key
|
||||||
|
else:
|
||||||
|
out[j.id] = folders.source_key_for(db, j)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def queue(db: Session, user: User, recent: datetime) -> list[DownloadJob]:
|
||||||
|
"""The download tray: what is waiting, running, paused or failed, and what finished (done or
|
||||||
|
canceled) since `recent`. A done job is timed from when it became done, never from its last
|
||||||
|
write: a rename, a move or the GC's expiry warning touches an old file's row, and the tray would
|
||||||
|
announce it as just finished."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(
|
||||||
|
owned_by(DownloadJob.user_id, user),
|
||||||
|
DownloadJob.status.not_in(FINISHED)
|
||||||
|
| ((DownloadJob.status == JobStatus.DONE) & (DownloadJob.ready_at >= recent))
|
||||||
|
| ((DownloadJob.status == JobStatus.CANCELED) & (DownloadJob.updated_at >= recent)),
|
||||||
|
)
|
||||||
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
||||||
|
.limit(MAX_LIST)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def statuses_by_source(db: Session, user: User, statuses: Sequence[str]) -> list[tuple[str, str]]:
|
||||||
|
"""(source_ref, status) of each of `user`'s jobs in one of `statuses`."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob.source_ref, DownloadJob.status).where(
|
||||||
|
owned_by(DownloadJob.user_id, user), DownloadJob.status.in_(tuple(statuses))
|
||||||
|
)
|
||||||
|
).tuples()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def library(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
folder_id: int | None,
|
||||||
|
listable: set[int] | None,
|
||||||
|
shared: bool,
|
||||||
|
term: str,
|
||||||
|
sort: str,
|
||||||
|
order: str,
|
||||||
|
lang: str,
|
||||||
|
) -> list[DownloadJob]:
|
||||||
|
"""The caller's done files (optionally one folder's, or only in the folders a token may list),
|
||||||
|
or the ones shared with them; searched by name and channel, sorted by when they were added, by
|
||||||
|
name (in the reader's alphabet) or by size. A file is added to its owner's list when it is done,
|
||||||
|
not when it was asked for (a download that failed for days and then went through is new); one
|
||||||
|
shared with the caller, when it was shared."""
|
||||||
|
stmt = (
|
||||||
|
select(DownloadJob)
|
||||||
|
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
|
.where(DownloadJob.status == JobStatus.DONE)
|
||||||
|
)
|
||||||
|
if shared:
|
||||||
|
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
|
||||||
|
owned_by(DownloadShare.to_user_id, user)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(owned_by(DownloadJob.user_id, user))
|
||||||
|
if folder_id is not None:
|
||||||
|
stmt = stmt.where(DownloadJob.folder_id == folder_id)
|
||||||
|
elif listable is not None:
|
||||||
|
stmt = stmt.where(DownloadJob.folder_id.in_(listable))
|
||||||
|
name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "")
|
||||||
|
if term:
|
||||||
|
like = f"%{like_escape(term)}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
name.ilike(like),
|
||||||
|
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
key = {
|
||||||
|
"added": DownloadShare.created_at if shared else DownloadJob.ready_at,
|
||||||
|
"name": collated(name, lang),
|
||||||
|
"size": MediaAsset.size_bytes,
|
||||||
|
}[sort]
|
||||||
|
first = key.asc() if order == "asc" else key.desc()
|
||||||
|
tie = DownloadJob.id.asc() if order == "asc" else DownloadJob.id.desc()
|
||||||
|
return list(db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def recent_jobs(db: Session) -> list[DownloadJob]:
|
||||||
|
"""Everyone's newest jobs, for the admin's list."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
||||||
|
.limit(MAX_LIST)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- shares --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def shared_with(db: Session, job_id: int, user: User | int) -> DownloadShare | None:
|
||||||
|
"""The grant that hands this job to `user`, if there is one."""
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadShare).where(
|
||||||
|
DownloadShare.job_id == job_id, owned_by(DownloadShare.to_user_id, user)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def shared_at(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, datetime]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(DownloadShare.job_id, DownloadShare.created_at).where(
|
||||||
|
owned_by(DownloadShare.to_user_id, user),
|
||||||
|
DownloadShare.job_id.in_([j.id for j in jobs]),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {job_id: at for job_id, at in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# --- folders -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def own_folder(db: Session, user: User, folder_id: int) -> FileFolder | None:
|
||||||
|
return owned_row(db, FileFolder, folder_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
def folders_by_id(db: Session, ids: set[int]) -> dict[int, FileFolder]:
|
||||||
|
"""These folders, by id — for a reader that already knows which ones (a token's grants)."""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
return {f.id: f for f in db.execute(select(FileFolder).where(FileFolder.id.in_(ids))).scalars()}
|
||||||
|
|
||||||
|
|
||||||
|
def folders_with_totals(
|
||||||
|
db: Session, user: User, listable: set[int] | None
|
||||||
|
) -> list[tuple[FileFolder, int, int]]:
|
||||||
|
"""The user's folders (only `listable` ones, when given) with how many done files each holds and
|
||||||
|
their size. A source folder its files have all left stays (count 0)."""
|
||||||
|
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
|
||||||
|
stmt = select(
|
||||||
|
FileFolder,
|
||||||
|
func.count(DownloadJob.id),
|
||||||
|
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
||||||
|
).where(owned_by(FileFolder.user_id, user))
|
||||||
|
if listable is not None:
|
||||||
|
stmt = stmt.where(FileFolder.id.in_(listable))
|
||||||
|
rows = db.execute(
|
||||||
|
stmt.outerjoin(DownloadJob, done)
|
||||||
|
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
|
.group_by(FileFolder.id)
|
||||||
|
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
|
||||||
|
).tuples()
|
||||||
|
return [(f, count, int(size or 0)) for f, count, size in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
|
||||||
|
count, size = db.execute(
|
||||||
|
select(func.count(DownloadJob.id), func.coalesce(func.sum(MediaAsset.size_bytes), 0))
|
||||||
|
.select_from(DownloadJob)
|
||||||
|
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
|
.where(DownloadJob.folder_id == folder_id, DownloadJob.status == JobStatus.DONE)
|
||||||
|
).one()
|
||||||
|
return count, int(size)
|
||||||
|
|
||||||
|
|
||||||
|
def linked_folders(db: Session, folder_ids: list[int]) -> set[int]:
|
||||||
|
"""Which of these folders a live public link opens (one not past its expiry)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
rows = db.execute(
|
||||||
|
select(DownloadLink.folder_id).where(
|
||||||
|
DownloadLink.folder_id.in_(folder_ids),
|
||||||
|
or_(DownloadLink.expires_at.is_(None), DownloadLink.expires_at > now),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
return {folder_id for folder_id in rows if folder_id is not None}
|
||||||
|
|
||||||
|
|
||||||
|
# --- links ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def own_link(db: Session, user: User, link_id: int) -> DownloadLink | None:
|
||||||
|
"""A link to one of the user's files or folders."""
|
||||||
|
link = db.get(DownloadLink, link_id)
|
||||||
|
if link is None:
|
||||||
|
return None
|
||||||
|
target = (
|
||||||
|
owned_row(db, DownloadJob, link.job_id, user)
|
||||||
|
if link.job_id is not None
|
||||||
|
else owned_row(db, FileFolder, link.folder_id, user)
|
||||||
|
)
|
||||||
|
return link if target is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def links_to(
|
||||||
|
db: Session, *, job_id: int | None = None, folder_id: int | None = None
|
||||||
|
) -> list[DownloadLink]:
|
||||||
|
"""The links to one file or one folder, newest first."""
|
||||||
|
target = (
|
||||||
|
DownloadLink.job_id == job_id if job_id is not None else DownloadLink.folder_id == folder_id
|
||||||
|
)
|
||||||
|
return list(
|
||||||
|
db.execute(select(DownloadLink).where(target).order_by(DownloadLink.id.desc())).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def link_folder(db: Session, link: DownloadLink) -> FileFolder | None:
|
||||||
|
return db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def folder_job(db: Session, folder: FileFolder, job_id: int) -> DownloadJob | None:
|
||||||
|
"""One of the files a link to `folder` reaches (`links.in_folder`), while it is in it."""
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadJob).where(DownloadJob.id == job_id, *links.in_folder(folder))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def owner_suspended(db: Session, job: DownloadJob) -> bool:
|
||||||
|
"""The job's owner is suspended (or gone): the worker leaves their queued jobs where they are."""
|
||||||
|
owner = db.get(User, job.user_id)
|
||||||
|
return owner is None or owner.is_suspended
|
||||||
|
|
||||||
|
|
||||||
|
# --- profiles ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def profiles_for(db: Session, user: User) -> list[DownloadProfile]:
|
||||||
|
"""The built-in format profiles, then the user's own."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(DownloadProfile)
|
||||||
|
.where(DownloadProfile.is_builtin.is_(True) | owned_by(DownloadProfile.user_id, user))
|
||||||
|
.order_by(
|
||||||
|
DownloadProfile.is_builtin.desc(), DownloadProfile.sort_order, DownloadProfile.id
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def own_profile(db: Session, user: User, profile_id: int) -> DownloadProfile | None:
|
||||||
|
"""One of the user's own profiles (a built-in is no one's to change)."""
|
||||||
|
p = owned_row(db, DownloadProfile, profile_id, user)
|
||||||
|
return None if p is None or p.is_builtin else p
|
||||||
|
|
||||||
|
|
||||||
|
def usable_profile(db: Session, user: User, profile_id: int) -> DownloadProfile | None:
|
||||||
|
"""A profile the user may enqueue with: a built-in, or their own."""
|
||||||
|
p = db.get(DownloadProfile, profile_id)
|
||||||
|
if p is None or (not p.is_builtin and p.user_id != user.id):
|
||||||
|
return None
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def default_profile(db: Session) -> DownloadProfile | None:
|
||||||
|
"""The first built-in, for an enqueue that named no format."""
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadProfile)
|
||||||
|
.where(DownloadProfile.is_builtin.is_(True))
|
||||||
|
.order_by(DownloadProfile.sort_order)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# --- storage and quota (admin) -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def storage_totals(db: Session) -> tuple[int, int, int]:
|
||||||
|
"""(files on disk, their bytes, every asset row). On disk is `quota.ON_DISK`: ready files, and
|
||||||
|
uploads waiting for or failed in their conversion — the same disk the footprint and the GC cap
|
||||||
|
count."""
|
||||||
|
on_disk = db.execute(
|
||||||
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||||
|
).one()
|
||||||
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar_one()
|
||||||
|
return on_disk[0], int(on_disk[1]), total_assets
|
||||||
|
|
||||||
|
|
||||||
|
def done_files_per_user(db: Session) -> dict[int, int]:
|
||||||
|
"""How many finished files each user's Files lists."""
|
||||||
|
return dict(
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob.user_id, func.count())
|
||||||
|
.where(DownloadJob.status == JobStatus.DONE)
|
||||||
|
.group_by(DownloadJob.user_id)
|
||||||
|
)
|
||||||
|
.tuples()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def custom_quota(db: Session, user_id: int) -> DownloadQuota | None:
|
||||||
|
"""The admin's own limits for this user, if any were set."""
|
||||||
|
return db.get(DownloadQuota, user_id)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Per-user download limits + footprint accounting.
|
"""Per-user download limits + footprint accounting.
|
||||||
|
|
||||||
A user's *footprint* is the total size of the ready assets their library references (cache hits
|
A user's *footprint* is the total size of the ready assets their library references (cache hits
|
||||||
count — it's "how much disk you're responsible for", not "how much you personally downloaded").
|
count — it's "how much disk you're responsible for", not "how much you personally downloaded"). An
|
||||||
|
upload counts from the moment it lands, ready or not: its original is on disk while its conversion
|
||||||
|
waits, and stays there when the conversion fails (for Retry) — bytes a ready-only sum never saw.
|
||||||
Limits come from a per-user DownloadQuota row if the admin set one, else the sysconfig defaults.
|
Limits come from a per-user DownloadQuota row if the admin set one, else the sysconfig defaults.
|
||||||
`unlimited` (e.g. the admin's own account) bypasses the byte cap.
|
`unlimited` (e.g. the admin's own account) bypasses the byte cap.
|
||||||
|
|
||||||
@@ -13,21 +15,31 @@ the footprint as it stood when the job was created and earlier jobs may have fil
|
|||||||
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
|
||||||
|
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
from app.downloads.status import JobKind, JobStatus
|
||||||
|
from app.downloads.models import DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||||
# (error, canceled) don't count.
|
# (error, canceled) don't count.
|
||||||
_HOLDING = ("queued", "running", "paused", "done")
|
_HOLDING = (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE)
|
||||||
_RUNNING = ("queued", "running")
|
_RUNNING = (JobStatus.QUEUED, JobStatus.RUNNING)
|
||||||
|
# An asset whose file is on disk: a ready one, or an upload in any state (its original lands at
|
||||||
|
# ingest, see the module docstring). Every byte sum — a user's footprint, the GC's total-cap pass,
|
||||||
|
# the admin's storage view — reads it, so they all count the same disk.
|
||||||
|
ON_DISK = or_(
|
||||||
|
MediaAsset.status == "ready",
|
||||||
|
and_(MediaAsset.source_kind == "upload", MediaAsset.rel_path.is_not(None)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -56,7 +68,8 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
|||||||
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
|
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
|
||||||
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
|
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
|
||||||
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
|
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
|
||||||
# downloads, suspend them or set max_jobs=0, not max_concurrent=0.)
|
# downloads, suspend them — the worker leaves a suspended user's queued jobs unclaimed — or
|
||||||
|
# set max_jobs=0, not max_concurrent=0.)
|
||||||
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
|
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
|
||||||
return QuotaLimits(
|
return QuotaLimits(
|
||||||
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
||||||
@@ -67,12 +80,17 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
|||||||
|
|
||||||
|
|
||||||
def footprint(db: Session, user_id: int) -> int:
|
def footprint(db: Session, user_id: int) -> int:
|
||||||
"""Total bytes of the distinct ready assets this user's active jobs reference."""
|
"""Total bytes of the distinct assets this user's jobs hold on disk: the ready ones their active
|
||||||
|
jobs reference, and every upload of theirs — including one whose conversion failed, which keeps
|
||||||
|
its original (see the module docstring)."""
|
||||||
held_assets = (
|
held_assets = (
|
||||||
select(DownloadJob.asset_id)
|
select(DownloadJob.asset_id)
|
||||||
.where(
|
.where(
|
||||||
DownloadJob.user_id == user_id,
|
owned_by(DownloadJob.user_id, user_id),
|
||||||
DownloadJob.status.in_(_HOLDING),
|
or_(
|
||||||
|
DownloadJob.status.in_(_HOLDING),
|
||||||
|
and_(DownloadJob.job_kind == JobKind.UPLOAD, DownloadJob.status == JobStatus.ERROR),
|
||||||
|
),
|
||||||
DownloadJob.asset_id.is_not(None),
|
DownloadJob.asset_id.is_not(None),
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -80,7 +98,7 @@ def footprint(db: Session, user_id: int) -> int:
|
|||||||
return int(
|
return int(
|
||||||
db.execute(
|
db.execute(
|
||||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||||
MediaAsset.id.in_(held_assets), MediaAsset.status == "ready"
|
MediaAsset.id.in_(held_assets), ON_DISK
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
or 0
|
or 0
|
||||||
@@ -92,7 +110,7 @@ def _count(db: Session, user_id: int, states) -> int:
|
|||||||
db.execute(
|
db.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(DownloadJob)
|
.select_from(DownloadJob)
|
||||||
.where(DownloadJob.user_id == user_id, DownloadJob.status.in_(states))
|
.where(owned_by(DownloadJob.user_id, user_id), DownloadJob.status.in_(states))
|
||||||
).scalar()
|
).scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
@@ -111,7 +129,7 @@ def at_concurrency_limit(db: Session, user_id: int) -> bool:
|
|||||||
lim = resolve(db, user_id)
|
lim = resolve(db, user_id)
|
||||||
if lim.unlimited:
|
if lim.unlimited:
|
||||||
return False
|
return False
|
||||||
running = _count(db, user_id, ("running",))
|
running = _count(db, user_id, (JobStatus.RUNNING,))
|
||||||
return running >= lim.max_concurrent
|
return running >= lim.max_concurrent
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +178,18 @@ def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
|||||||
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||||
|
|
||||||
|
|
||||||
|
def fits_after_conversion(db: Session, user_id: int, old_bytes: int, new_bytes: int) -> bool:
|
||||||
|
"""Whether an upload converted from `old_bytes` (what it is charged at now) to `new_bytes` keeps
|
||||||
|
its owner within the cap. A re-encode can come out larger than the original, and nothing else
|
||||||
|
re-checks it once the upload was let in."""
|
||||||
|
if new_bytes <= old_bytes: # a smaller file never breaks a cap the larger one was let in under
|
||||||
|
return True
|
||||||
|
lim = resolve(db, user_id)
|
||||||
|
if lim.unlimited:
|
||||||
|
return True
|
||||||
|
return footprint(db, user_id) - old_bytes + new_bytes <= lim.max_bytes
|
||||||
|
|
||||||
|
|
||||||
def usage(db: Session, user_id: int) -> dict:
|
def usage(db: Session, user_id: int) -> dict:
|
||||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||||
lim = resolve(db, user_id)
|
lim = resolve(db, user_id)
|
||||||
@@ -172,3 +202,46 @@ def usage(db: Session, user_id: int) -> dict:
|
|||||||
"max_jobs": lim.max_jobs,
|
"max_jobs": lim.max_jobs,
|
||||||
"unlimited": lim.unlimited,
|
"unlimited": lim.unlimited,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_custom(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
max_concurrent: int | None = None,
|
||||||
|
max_jobs: int | None = None,
|
||||||
|
unlimited: bool | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""The admin's own limits for a user: starts from what applies now, then each value given (a
|
||||||
|
size floored at 0, a count at 1). The handler commits."""
|
||||||
|
row = db.get(DownloadQuota, user_id)
|
||||||
|
if row is None:
|
||||||
|
cur = resolve(db, user_id)
|
||||||
|
row = DownloadQuota(
|
||||||
|
user_id=user_id,
|
||||||
|
max_bytes=cur.max_bytes,
|
||||||
|
max_concurrent=cur.max_concurrent,
|
||||||
|
max_jobs=cur.max_jobs,
|
||||||
|
unlimited=cur.unlimited,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
if max_bytes is not None:
|
||||||
|
row.max_bytes = max(0, max_bytes)
|
||||||
|
if max_concurrent is not None:
|
||||||
|
row.max_concurrent = max(1, max_concurrent)
|
||||||
|
if max_jobs is not None:
|
||||||
|
row.max_jobs = max(1, max_jobs)
|
||||||
|
if unlimited is not None:
|
||||||
|
row.unlimited = unlimited
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_custom(db: Session, user_id: int) -> bool:
|
||||||
|
"""Back to the defaults; False when the user had no limits of their own."""
|
||||||
|
row = db.get(DownloadQuota, user_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import app.tables # noqa: F401 — a fresh interpreter: every table, before the first query
|
||||||
from app import logs
|
from app import logs
|
||||||
from app import logsink
|
from app import logsink
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.models import DownloadJob
|
from app.downloads.status import JobKind
|
||||||
|
from app.downloads.models import DownloadJob
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -95,12 +97,16 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
|
|||||||
(`cause=child_exit`)."""
|
(`cause=child_exit`)."""
|
||||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
||||||
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||||
from app.downloads.pipeline import _download, _process_edit
|
from app.downloads.pipeline import _convert_upload, _download, _process_clip, _process_edit
|
||||||
|
|
||||||
with formats.hiding(source_ref):
|
with formats.hiding(source_ref):
|
||||||
try:
|
try:
|
||||||
if job_kind == "edit":
|
if job_kind == JobKind.EDIT:
|
||||||
_process_edit(job_id, asset_id)
|
_process_edit(job_id, asset_id)
|
||||||
|
elif job_kind == JobKind.UPLOAD:
|
||||||
|
_convert_upload(job_id, asset_id)
|
||||||
|
elif job_kind == JobKind.CLIP:
|
||||||
|
_process_clip(job_id, asset_id)
|
||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||||
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
"""Enqueue + cache resolution for the download center (the per-user layer over MediaAsset).
|
"""Capture's writes: enqueue + cache resolution, the job transitions, and what the library's
|
||||||
|
dialogs change (profiles, resume points, shares, a file's display fields).
|
||||||
|
|
||||||
`enqueue` is the single entry point used by the API: it resolves (or creates) the shared
|
`enqueue` is the single entry point used by the API: it resolves (or creates) the shared
|
||||||
MediaAsset for the requested source+format, links a per-user DownloadJob to it, and — on a
|
MediaAsset for the requested source+format, links a per-user DownloadJob to it, and — on a
|
||||||
cache hit (asset already ready) — marks the job done instantly with no re-download. The worker
|
cache hit (asset already ready) — marks the job done instantly with no re-download. The worker
|
||||||
handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and
|
handles everything else. Every function here flushes and leaves the commit to its caller (the
|
||||||
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
handler); the folder, link and quota writes are `folders`, `links` and `quota`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete as delete_rows
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import credentials
|
from app.downloads import credentials
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, mediakind, quota, storage
|
from app.downloads import folders, formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobStatus
|
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
from app.models import DISPLAY_TEXT_MAX, User
|
||||||
|
from app.plex.models import PlexItem, PlexShow
|
||||||
|
from app.catalog.models import Channel, Video
|
||||||
|
from app.downloads.models import (
|
||||||
|
DownloadJob,
|
||||||
|
DownloadProfile,
|
||||||
|
DownloadShare,
|
||||||
|
LibraryState,
|
||||||
|
MediaAsset,
|
||||||
|
)
|
||||||
|
from app.plex import lookups as plex_lookups
|
||||||
|
from app.plex import paths as plex_paths
|
||||||
|
from app.userscope import owned_by, owned_row
|
||||||
|
|
||||||
|
|
||||||
class EditError(Exception):
|
class EditError(Exception):
|
||||||
@@ -38,12 +54,15 @@ def source_url(source_kind: str, source_ref: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def reference_url(job, asset) -> str | None:
|
def reference_url(job, asset) -> str | None:
|
||||||
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
|
"""The clean "downloaded from" URL for a job — shared by the Files library and the public
|
||||||
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
|
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
|
||||||
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
|
own earlier download, not a web page) and for a Plex clip (the library is not a public page); a
|
||||||
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else
|
||||||
if job is None or job.job_kind == "edit":
|
one derived from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
||||||
|
if job is None or job.job_kind == JobKind.EDIT:
|
||||||
return None
|
return None
|
||||||
|
if job.job_kind == JobKind.CLIP:
|
||||||
|
return clipmod.reference_url(job.edit_spec or {})
|
||||||
if asset is not None and asset.source_webpage_url:
|
if asset is not None and asset.source_webpage_url:
|
||||||
return asset.source_webpage_url
|
return asset.source_webpage_url
|
||||||
if job.source_kind in ("youtube", "url"):
|
if job.source_kind in ("youtube", "url"):
|
||||||
@@ -51,6 +70,23 @@ def reference_url(job, asset) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def library_left_behind(db: Session, files_root: str) -> bool:
|
||||||
|
"""True when the library names files and none of the newest of them is under `files_root` — a
|
||||||
|
fresh, empty volume mounted in the library's place (a compose file changed under an install, its
|
||||||
|
old volume left behind). The mount check cannot see that: the new volume is a real mount."""
|
||||||
|
rels = (
|
||||||
|
db.execute(
|
||||||
|
select(MediaAsset.rel_path)
|
||||||
|
.where(MediaAsset.status == "ready", MediaAsset.rel_path.is_not(None))
|
||||||
|
.order_by(MediaAsset.id.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return bool(rels) and not any(storage.abs_path(files_root, r).exists() for r in rels if r)
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_asset(
|
def get_or_create_asset(
|
||||||
db: Session, source_kind: str, source_ref: str, format_sig: str
|
db: Session, source_kind: str, source_ref: str, format_sig: str
|
||||||
) -> MediaAsset:
|
) -> MediaAsset:
|
||||||
@@ -63,7 +99,12 @@ def get_or_create_asset(
|
|||||||
MediaAsset.source_ref == source_ref,
|
MediaAsset.source_ref == source_ref,
|
||||||
MediaAsset.format_sig == format_sig,
|
MediaAsset.format_sig == format_sig,
|
||||||
)
|
)
|
||||||
asset = db.execute(stmt).scalar_one_or_none()
|
# Locked to the caller's commit: a release or the GC freeing this asset meanwhile (they lock it
|
||||||
|
# too) either waits for the new hold, or has deleted the row by the time this read returns — and
|
||||||
|
# the caller then gets a fresh one, never a job pointing at a row that is gone.
|
||||||
|
asset = db.execute(
|
||||||
|
stmt.with_for_update().execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
if asset is not None:
|
if asset is not None:
|
||||||
# A previously-FAILED shared asset must be re-attempted for the new job about to hold it —
|
# A previously-FAILED shared asset must be re-attempted for the new job about to hold it —
|
||||||
# reset it to pending (+clear the error) so the worker actually re-downloads instead of
|
# reset it to pending (+clear the error) so the worker actually re-downloads instead of
|
||||||
@@ -180,24 +221,25 @@ def enqueue(
|
|||||||
profile_snapshot=spec,
|
profile_snapshot=spec,
|
||||||
display_name=display_name
|
display_name=display_name
|
||||||
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
||||||
status="queued",
|
status=JobStatus.QUEUED,
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
incr_ref_count(db, asset.id)
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
# Cache hit: the file already exists — no worker round-trip needed.
|
# Cache hit: the file already exists — no worker round-trip needed.
|
||||||
if asset.status == "ready":
|
if asset.status == "ready":
|
||||||
job.status = "done"
|
job.status = JobStatus.DONE
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
|
folders.stamp_done(db, job)
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(job)
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
def clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||||
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
||||||
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
||||||
# rather than run through `display_text` — blank here means "no title", not None.
|
# rather than run through `display_text` — blank here means "no title", not None.
|
||||||
@@ -239,7 +281,7 @@ def enqueue_edit(
|
|||||||
ref = f"{user_id}:{src_asset.id}"[:512]
|
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||||
if not asset.title:
|
if not asset.title:
|
||||||
asset.title = _clip_title(src_asset.title, display_name)
|
asset.title = clip_title(src_asset.title, display_name)
|
||||||
asset.uploader = src_asset.uploader
|
asset.uploader = src_asset.uploader
|
||||||
asset.thumbnail_url = src_asset.thumbnail_url
|
asset.thumbnail_url = src_asset.thumbnail_url
|
||||||
asset.upload_date = src_asset.upload_date
|
asset.upload_date = src_asset.upload_date
|
||||||
@@ -251,7 +293,7 @@ def enqueue_edit(
|
|||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
job_kind="edit",
|
job_kind=JobKind.EDIT,
|
||||||
source_kind="edit",
|
source_kind="edit",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
source_job_id=source_job.id,
|
source_job_id=source_job.id,
|
||||||
@@ -259,19 +301,108 @@ def enqueue_edit(
|
|||||||
edit_spec=spec,
|
edit_spec=spec,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
status="queued",
|
status=JobStatus.QUEUED,
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
incr_ref_count(db, asset.id)
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||||
job.status = "done"
|
job.status = JobStatus.DONE
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
|
folders.stamp_done(db, job)
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(job)
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_source_facts(db: Session, spec: dict) -> dict:
|
||||||
|
"""Title, uploader, thumbnail and length of what a clip is cut from, read from our own catalog.
|
||||||
|
Raises ClipError when a Plex item is unknown or its file cannot be read here — said at enqueue,
|
||||||
|
rather than as a job that fails in the queue a minute later."""
|
||||||
|
if spec["source"] == "youtube":
|
||||||
|
video = db.get(Video, spec["ref"])
|
||||||
|
if video is None: # not in the catalog: titled "Clip" until yt-dlp names it (worker)
|
||||||
|
return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None}
|
||||||
|
channel = db.get(Channel, video.channel_id)
|
||||||
|
return {
|
||||||
|
"title": video.title,
|
||||||
|
"uploader": channel.title if channel else None,
|
||||||
|
"thumbnail_url": video.thumbnail_url,
|
||||||
|
"length_s": video.duration_seconds,
|
||||||
|
}
|
||||||
|
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
|
||||||
|
if item is None:
|
||||||
|
raise clipmod.ClipError("not_found")
|
||||||
|
if plex_paths.local_media_path(db, item.file_path) is None:
|
||||||
|
raise clipmod.ClipError("media_missing")
|
||||||
|
title = item.title
|
||||||
|
show = db.get(PlexShow, item.show_id) if item.show_id else None
|
||||||
|
if show is not None:
|
||||||
|
episode = (
|
||||||
|
f"S{item.season_number}·E{item.episode_number}"
|
||||||
|
if item.season_number is not None and item.episode_number is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
title = " — ".join(p for p in (show.title, episode, item.title) if p)
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"uploader": None,
|
||||||
|
# Plex art is served through our own authenticated routes, never a public URL; the worker
|
||||||
|
# cuts the clip's poster from the clip itself.
|
||||||
|
"thumbnail_url": None,
|
||||||
|
"length_s": item.duration_s,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_clip(
|
||||||
|
db: Session, user_id: int, spec: dict, display_name: str | None = None
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Queue an ad-hoc clip (`clipmod.normalize`d spec) for `user_id`: a NEW per-user asset cut
|
||||||
|
straight from the source — a YouTube range or a Plex library file (see downloads/clip).
|
||||||
|
|
||||||
|
Raises ClipError (unknown or unreachable source, a range past its end) or quota.QuotaExceeded."""
|
||||||
|
facts = _clip_source_facts(db, spec)
|
||||||
|
# One second of grace: a mark set on the last frame may round past the catalog's whole seconds.
|
||||||
|
if facts["length_s"] and spec["end_s"] > facts["length_s"] + 1:
|
||||||
|
raise clipmod.ClipError("past_end")
|
||||||
|
|
||||||
|
quota.check_enqueue(db, user_id)
|
||||||
|
|
||||||
|
ref = clipmod.asset_ref(user_id, spec)[:512]
|
||||||
|
asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec))
|
||||||
|
if not asset.title:
|
||||||
|
asset.title = clip_title(facts["title"], display_name)
|
||||||
|
asset.uploader = facts["uploader"]
|
||||||
|
asset.thumbnail_url = facts["thumbnail_url"]
|
||||||
|
asset.duration_s = round(clipmod.duration(spec))
|
||||||
|
asset.media_kind = mediakind.VIDEO
|
||||||
|
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
job_kind=JobKind.CLIP,
|
||||||
|
source_kind="clip",
|
||||||
|
source_ref=ref,
|
||||||
|
edit_spec=spec,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
queue_pos=next_queue_pos(db),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
|
if asset.status == "ready": # the same range was already cut for this user → instant
|
||||||
|
job.status = JobStatus.DONE
|
||||||
|
job.progress = 100
|
||||||
|
folders.stamp_done(db, job)
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
@@ -291,6 +422,8 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
|||||||
if not fields:
|
if not fields:
|
||||||
return False
|
return False
|
||||||
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
|
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
|
||||||
|
if fields.get("status") == JobStatus.DONE:
|
||||||
|
return _finalize_done(db, job_id, {**fields, "ready_at": datetime.now(timezone.utc)})
|
||||||
# A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the
|
# A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the
|
||||||
# rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this
|
# rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this
|
||||||
# is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound
|
# is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound
|
||||||
@@ -304,6 +437,41 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
|||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_done(db: Session, job_id: int, fields: dict) -> bool:
|
||||||
|
"""`finalize_if_running` for a job that finished: done is when a file's retention starts, its
|
||||||
|
folder's unless the user kept it. The expiry is written only while the job is still where it was
|
||||||
|
read from — a move, a keep, or its folder's delete (which sends its files home) landing while the
|
||||||
|
folder's retention was read sends the read round again, never stamping a folder's retention on a
|
||||||
|
file now in another one."""
|
||||||
|
while True:
|
||||||
|
held = db.execute(
|
||||||
|
select(DownloadJob.folder_id, DownloadJob.kept, DownloadJob.status).where(
|
||||||
|
DownloadJob.id == job_id
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
if held is None or held.status != JobStatus.RUNNING:
|
||||||
|
return False
|
||||||
|
stamped = {
|
||||||
|
**fields,
|
||||||
|
"expires_at": folders.done_expiry(db, held.folder_id, held.kept),
|
||||||
|
"expiry_warned": False,
|
||||||
|
}
|
||||||
|
hit = db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(
|
||||||
|
DownloadJob.id == job_id,
|
||||||
|
DownloadJob.status == JobStatus.RUNNING.value,
|
||||||
|
DownloadJob.folder_id.is_not_distinct_from(held.folder_id),
|
||||||
|
DownloadJob.kept == held.kept,
|
||||||
|
)
|
||||||
|
.values(**stamped)
|
||||||
|
.returning(DownloadJob.id)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if hit is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def release_asset(db: Session, job: DownloadJob) -> None:
|
def release_asset(db: Session, job: DownloadJob) -> None:
|
||||||
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
||||||
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
||||||
@@ -318,24 +486,32 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
|||||||
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
|
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
|
||||||
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
|
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
|
||||||
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
|
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
|
||||||
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
|
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass.
|
||||||
|
|
||||||
|
An UPLOAD is the exception: it is never shared or re-enqueued (its ref is unique), and while it
|
||||||
|
waits for or failed its phone-safe conversion the original file already sits at `rel_path` — so
|
||||||
|
it is freed at ref==0 whatever its state, except mid-conversion (`downloading`: the worker child
|
||||||
|
still reads it; the GC upload pass takes it once the child has stopped)."""
|
||||||
if not job.asset_id or job.status not in HOLDING:
|
if not job.asset_id or job.status not in HOLDING:
|
||||||
return
|
return
|
||||||
asset = db.get(MediaAsset, job.asset_id)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return
|
return
|
||||||
new_count = decr_ref_count(db, asset.id)
|
new_count = decr_ref_count(db, asset.id)
|
||||||
if new_count == 0 and asset.status == "ready":
|
freeable = asset.status == "ready" or (
|
||||||
|
asset.source_kind == "upload" and asset.status != "downloading"
|
||||||
|
)
|
||||||
|
if new_count == 0 and freeable:
|
||||||
if asset.rel_path:
|
if asset.rel_path:
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
db.delete(asset)
|
db.delete(asset)
|
||||||
|
|
||||||
|
|
||||||
# --- Job state transitions ---------------------------------------------------------------------
|
# --- Job state transitions ---------------------------------------------------------------------
|
||||||
# The single home for the pause/resume/cancel/delete/redownload transitions + their asset
|
# The single home for the pause/resume/cancel/delete/redownload transitions + their asset
|
||||||
# bookkeeping (release, failed-asset reset, fresh-asset rebuild), so the routes are thin callers and
|
# bookkeeping (release, failed-asset reset, fresh-asset rebuild), so the routes are thin callers and
|
||||||
# the worker/GC could share the same definitions (B-sub-8). Each commits its own transition; the
|
# the worker/GC could share the same definitions (B-sub-8). Each flushes its transition and the caller
|
||||||
# route keeps only auth + HTTP mapping + serialization.
|
# commits it; the route keeps only auth + HTTP mapping + serialization.
|
||||||
|
|
||||||
|
|
||||||
def pause(db: Session, job: DownloadJob) -> None:
|
def pause(db: Session, job: DownloadJob) -> None:
|
||||||
@@ -343,7 +519,7 @@ def pause(db: Session, job: DownloadJob) -> None:
|
|||||||
in any other state."""
|
in any other state."""
|
||||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING):
|
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING):
|
||||||
job.status = JobStatus.PAUSED
|
job.status = JobStatus.PAUSED
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def resume(db: Session, job: DownloadJob) -> None:
|
def resume(db: Session, job: DownloadJob) -> None:
|
||||||
@@ -367,7 +543,7 @@ def resume(db: Session, job: DownloadJob) -> None:
|
|||||||
asset.status = "pending"
|
asset.status = "pending"
|
||||||
asset.error = None
|
asset.error = None
|
||||||
job.status = JobStatus.QUEUED
|
job.status = JobStatus.QUEUED
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def _follow_the_cookie(db: Session, job: DownloadJob, asset: MediaAsset) -> MediaAsset:
|
def _follow_the_cookie(db: Session, job: DownloadJob, asset: MediaAsset) -> MediaAsset:
|
||||||
@@ -402,14 +578,14 @@ def cancel(db: Session, job: DownloadJob) -> None:
|
|||||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||||
release_asset(db, job) # release while the job still counts as holding
|
release_asset(db, job) # release while the job still counts as holding
|
||||||
job.status = JobStatus.CANCELED
|
job.status = JobStatus.CANCELED
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def delete(db: Session, job: DownloadJob) -> None:
|
def delete(db: Session, job: DownloadJob) -> None:
|
||||||
"""Delete a job, releasing its asset hold first (which frees the file iff this was the last holder)."""
|
"""Delete a job, releasing its asset hold first (which frees the file iff this was the last holder)."""
|
||||||
release_asset(db, job)
|
release_asset(db, job)
|
||||||
db.delete(job)
|
db.delete(job)
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def redownload(db: Session, job: DownloadJob) -> None:
|
def redownload(db: Session, job: DownloadJob) -> None:
|
||||||
@@ -456,4 +632,183 @@ def redownload(db: Session, job: DownloadJob) -> None:
|
|||||||
# name would survive the rebuild).
|
# name would survive the rebuild).
|
||||||
job.display_name = None
|
job.display_name = None
|
||||||
job.queue_pos = next_queue_pos(db) # tail of the queue, not its stale original slot
|
job.queue_pos = next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||||
db.commit()
|
job.expires_at = None # stamped again when the rebuild is done
|
||||||
|
job.expiry_warned = False
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the library's writes ------------------------------------------------------------------
|
||||||
|
# What the HTTP layer changes for a user: each flushes, the handler commits.
|
||||||
|
|
||||||
|
|
||||||
|
def lock_own_job(db: Session, user: User, job_id: int) -> DownloadJob | None:
|
||||||
|
"""One of the user's jobs, re-read under a row lock, for a transition that moves its asset's ref
|
||||||
|
count (resume, cancel, delete, redownload) or writes the status those decide on (pause). Two
|
||||||
|
overlapping requests for one job run one after the other and the second decides on the first's
|
||||||
|
committed result — never both releasing the same hold, and never a pause turning a job a cancel
|
||||||
|
just released back into a holder; either can free a file another job still holds. None: not
|
||||||
|
theirs, or gone.
|
||||||
|
|
||||||
|
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
||||||
|
(`finalize_if_running`), so the two can never deadlock."""
|
||||||
|
while True:
|
||||||
|
owned = owned_row(db, DownloadJob, job_id, user)
|
||||||
|
if owned is None:
|
||||||
|
return None
|
||||||
|
asset_id = owned.asset_id
|
||||||
|
if asset_id is not None:
|
||||||
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
|
job = db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
if job.asset_id == asset_id:
|
||||||
|
return job
|
||||||
|
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
||||||
|
|
||||||
|
|
||||||
|
def hold_for_share(db: Session, user: User, job_id: int) -> DownloadJob | None:
|
||||||
|
"""One of the user's files, held (FOR SHARE) for the rest of the transaction: a delete of it at
|
||||||
|
the same moment (the user's, or the retention sweep's) waits and takes the new link with it,
|
||||||
|
rather than failing the link's foreign key — or, done first, leaves None."""
|
||||||
|
if owned_row(db, DownloadJob, job_id, user) is None:
|
||||||
|
return None
|
||||||
|
return db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id)
|
||||||
|
.with_for_update(read=True)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def create_profile(db: Session, user: User, name: str, spec: dict) -> DownloadProfile:
|
||||||
|
"""A format profile of the user's own, after their others."""
|
||||||
|
maxpos = db.execute(
|
||||||
|
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
||||||
|
owned_by(DownloadProfile.user_id, user)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
p = DownloadProfile(
|
||||||
|
user_id=user.id, name=name[:80], spec=spec, is_builtin=False, sort_order=maxpos + 1
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def update_profile(profile: DownloadProfile, *, name: str | None, spec: dict | None) -> None:
|
||||||
|
"""Rename a profile and/or replace its spec; None leaves that one as it is."""
|
||||||
|
if name is not None:
|
||||||
|
profile.name = name[:80]
|
||||||
|
if spec is not None:
|
||||||
|
profile.spec = spec
|
||||||
|
|
||||||
|
|
||||||
|
def delete_profile(db: Session, profile: DownloadProfile) -> None:
|
||||||
|
db.delete(profile)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def set_position(db: Session, user: User, job_id: int, position: int) -> None:
|
||||||
|
"""The library player's resume point in a file the user can play; 0 clears it.
|
||||||
|
|
||||||
|
A clear is a statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon
|
||||||
|
of one stop may both clear it, and the second then deletes nothing rather than failing. A set is
|
||||||
|
an upsert, one row per (user, job) even when two checkpoints race, not a read-then-insert that
|
||||||
|
the second would fail on the unique key."""
|
||||||
|
if not position:
|
||||||
|
db.execute(
|
||||||
|
delete_rows(LibraryState).where(
|
||||||
|
owned_by(LibraryState.user_id, user), LibraryState.job_id == job_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.execute(
|
||||||
|
pg_insert(LibraryState)
|
||||||
|
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
constraint="uq_library_user_job",
|
||||||
|
set_={"position_seconds": position, "progress_updated_at": now},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def share(db: Session, job: DownloadJob, sender: User, recipient: User) -> bool:
|
||||||
|
"""Hand a file to another user, with a notice for them; False when it was already theirs."""
|
||||||
|
from app.notifications import create_notification
|
||||||
|
|
||||||
|
exists = db.execute(
|
||||||
|
select(DownloadShare.id).where(
|
||||||
|
DownloadShare.job_id == job.id, owned_by(DownloadShare.to_user_id, recipient)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if exists is not None:
|
||||||
|
return False
|
||||||
|
db.add(DownloadShare(job_id=job.id, from_user_id=sender.id, to_user_id=recipient.id))
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=recipient.id,
|
||||||
|
type="download_shared",
|
||||||
|
title=(job.display_name or "A download") + " was shared with you",
|
||||||
|
# The client names the notice in the reader's language from `data`; the title is the
|
||||||
|
# English fallback only.
|
||||||
|
data={
|
||||||
|
"kind": "download_shared",
|
||||||
|
"from": sender.email,
|
||||||
|
"job_id": job.id,
|
||||||
|
"title": job.display_name,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def unshare(db: Session, job_id: int, recipient: User) -> bool:
|
||||||
|
"""Take a file back from one user (its owner's act, or the recipient's own dismissal); False
|
||||||
|
when it was not theirs."""
|
||||||
|
row = db.execute(
|
||||||
|
select(DownloadShare).where(
|
||||||
|
DownloadShare.job_id == job_id, owned_by(DownloadShare.to_user_id, recipient)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# The display fields a user may set on their file; the file on disk keeps its own name.
|
||||||
|
DISPLAY_FIELDS = frozenset(
|
||||||
|
{"display_name", "display_uploader", "display_uploader_url", "extra_links"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_display(job: DownloadJob, **fields: object) -> None:
|
||||||
|
"""Set a file's display fields (each already cleaned by the caller); a field not given is left."""
|
||||||
|
unknown = set(fields) - DISPLAY_FIELDS
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"not a display field: {sorted(unknown)}")
|
||||||
|
for name, value in fields.items():
|
||||||
|
setattr(job, name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def touch(asset: MediaAsset) -> None:
|
||||||
|
"""The file was just served: the cache's idle clock starts again."""
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def store_sha256(db: Session, asset_id: int, digest: str) -> None:
|
||||||
|
"""Keep a file's hash once it is known — only where none is, so a concurrent hash of the same
|
||||||
|
file never overwrites the first."""
|
||||||
|
db.execute(
|
||||||
|
update(MediaAsset)
|
||||||
|
.where(MediaAsset.id == asset_id, MediaAsset.sha256.is_(None))
|
||||||
|
.values(sha256=digest)
|
||||||
|
)
|
||||||
|
|||||||
@@ -33,6 +33,27 @@ HOLDING = frozenset(
|
|||||||
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobKind(StrEnum):
|
||||||
|
"""What a job produces, which decides the worker path that runs it and what the library may do
|
||||||
|
with the result (only a DOWNLOAD has a web source to fetch again)."""
|
||||||
|
|
||||||
|
DOWNLOAD = "download" # yt-dlp fetches a source into a shared, deduplicated asset
|
||||||
|
EDIT = "edit" # a per-user trim/crop of a file already in the library
|
||||||
|
UPLOAD = "upload" # a file the user sent; the worker only converts it
|
||||||
|
CLIP = (
|
||||||
|
"clip" # a per-user range of a YouTube video or a Plex item, cut straight from the source
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# A job kind with no web source to fetch again: Re-download refuses these.
|
||||||
|
NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP})
|
||||||
|
|
||||||
|
|
||||||
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
||||||
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
||||||
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
||||||
|
|
||||||
|
|
||||||
|
# A job that will not move again on its own: the queue lists it only briefly after it got here.
|
||||||
|
FINISHED = frozenset({JobStatus.DONE, JobStatus.CANCELED})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ a Plex library using a metadata agent picks up rich info. Channel = "show", vide
|
|||||||
date-based — the community-standard mapping for YouTube-in-Plex.
|
date-based — the community-standard mapping for YouTube-in-Plex.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import unicodedata
|
import unicodedata
|
||||||
@@ -67,6 +68,57 @@ _FOLD_MAP = str.maketrans(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mount_points(mountinfo: str) -> list[str]:
|
||||||
|
"""The mount points a /proc/<pid>/mountinfo table lists (its fifth field, octal escapes undone)."""
|
||||||
|
points = []
|
||||||
|
for line in mountinfo.splitlines():
|
||||||
|
fields = line.split(" ")
|
||||||
|
if len(fields) > 4:
|
||||||
|
points.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), fields[4]))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _in_container() -> bool:
|
||||||
|
return Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mountinfo() -> str | None:
|
||||||
|
try:
|
||||||
|
return Path("/proc/self/mountinfo").read_text()
|
||||||
|
# silent-ok: no mount table to read (not Linux) — the caller then cannot tell, and refuses nothing.
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def files_root_problem(
|
||||||
|
root: str, mountinfo: str | None = None, in_container: bool | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Why `root` cannot be the files library's volume, or None when it can. In a container, a root
|
||||||
|
that is no mount of its own (nor inside one) is the image's empty directory: the library would
|
||||||
|
answer 404 for every file, and what a download writes would go when the container does — the
|
||||||
|
state an install is in when its compose file still mounts the library somewhere else.
|
||||||
|
|
||||||
|
Asked of the kernel's mount table, not by comparing devices: a storage driver that keeps the
|
||||||
|
container's root on the host's own disk (vfs, the fallback under Docker-in-LXC on older ZFS) gives
|
||||||
|
a mounted volume the same device as `/`. Outside a container a plain directory is a fine root."""
|
||||||
|
path = Path(root)
|
||||||
|
if not path.is_dir():
|
||||||
|
return f"{root} does not exist"
|
||||||
|
if in_container if in_container is not None else _in_container():
|
||||||
|
table = mountinfo if mountinfo is not None else _read_mountinfo()
|
||||||
|
if table is not None:
|
||||||
|
real = os.path.realpath(root)
|
||||||
|
mounted = any(
|
||||||
|
m != "/" and (real == m or real.startswith(m.rstrip("/") + "/"))
|
||||||
|
for m in _mount_points(table)
|
||||||
|
)
|
||||||
|
if not mounted:
|
||||||
|
return f"{root} is not a mounted volume (it is the container's own empty directory)"
|
||||||
|
if not os.access(path, os.W_OK):
|
||||||
|
return f"{root} is not writable"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _ascii_fold(text: str) -> str:
|
def _ascii_fold(text: str) -> str:
|
||||||
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
|
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
|
||||||
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
|
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
|
||||||
@@ -122,7 +174,7 @@ def display_filename(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
||||||
"""Path of the media file relative to DOWNLOAD_ROOT (forward slashes).
|
"""Path of the media file relative to FILES_ROOT (forward slashes).
|
||||||
|
|
||||||
`asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref,
|
`asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref,
|
||||||
format_sig), so the SAME video downloaded in two formats is two distinct assets — but without
|
format_sig), so the SAME video downloaded in two formats is two distinct assets — but without
|
||||||
@@ -144,7 +196,7 @@ def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | No
|
|||||||
|
|
||||||
|
|
||||||
def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
||||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a phase-2 editor derivative.
|
"""On-disk path (relative to FILES_ROOT) for a phase-2 editor derivative.
|
||||||
|
|
||||||
Kept in a per-user `.edits/` tree — device-downloadable via the file endpoint, but NOT part of
|
Kept in a per-user `.edits/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||||
the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster
|
the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster
|
||||||
@@ -161,20 +213,32 @@ def _clean_upload_ext(filename: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
|
"""On-disk path (relative to FILES_ROOT) for a locally-uploaded file.
|
||||||
|
|
||||||
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||||
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||||
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
episode sidecars). The name is system-decided and bound to the asset id (`_a{id}` keeps two
|
||||||
same-named uploads apart); the original filename is display metadata only. The extension is
|
same-named uploads apart); the original filename is display metadata only. The extension is
|
||||||
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
preserved (shell-safe, bounded) so the served file + its Content-Disposition carry a correct
|
||||||
suffix; an extensionless upload stays extensionless."""
|
suffix; an extensionless upload stays extensionless.
|
||||||
stem = sanitize(Path(filename).stem, 80)
|
|
||||||
|
The stem holds no dot, so the last dot of the name is always the extension's: every sibling
|
||||||
|
derived by swapping it (`with_suffix` — the poster, the converted file) keeps `_a{id}`. A dotted
|
||||||
|
stem with no extension ('Holiday.2024.ä') once lost it, and two such uploads shared one file."""
|
||||||
|
stem = sanitize(Path(filename).stem, 80).replace(".", "_")
|
||||||
ext = _clean_upload_ext(filename)
|
ext = _clean_upload_ext(filename)
|
||||||
suffix = f".{ext}" if ext else ""
|
suffix = f".{ext}" if ext else ""
|
||||||
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
return f".uploads/{user_id}/{stem}_a{asset_id}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def with_ext(rel: str, old_ext: str | None, ext: str) -> str:
|
||||||
|
"""`rel` with its extension — `old_ext`, the stored `container`, None for a bare name — swapped
|
||||||
|
for `ext`. Not `Path.with_suffix`: on a bare name whose stem holds a dot (an upload placed before
|
||||||
|
the stems lost their dots) that would take the stem's tail for the extension, and `_a{id}` with it."""
|
||||||
|
base = rel[: -len(old_ext) - 1] if old_ext and rel.endswith(f".{old_ext}") else rel
|
||||||
|
return f"{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
def upload_ext(filename: str) -> str | None:
|
def upload_ext(filename: str) -> str | None:
|
||||||
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
"""The stored `container` for an uploaded file: its shell-safe lowercased extension, or None when
|
||||||
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
the upload has no extension (a bare name). Shares `_clean_upload_ext` with `upload_rel_path` so the
|
||||||
@@ -230,32 +294,39 @@ def served_media_type(media_kind: str | None, container: str | None) -> str | No
|
|||||||
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
def download_filename(display_name: str, container: str | None, path: Path) -> str:
|
||||||
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
|
||||||
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
correct extension — the asset container, else the file's own suffix — without doubling it when the
|
||||||
name already ends in that extension. Shared by the private download and the public watch endpoints."""
|
name already ends in that extension. A file with no extension at all is served without one —
|
||||||
|
never as `name.` (Windows drops a trailing dot, so the saved file would not be the one named).
|
||||||
|
Shared by the private download and the public watch endpoints."""
|
||||||
ext = container or path.suffix.lstrip(".")
|
ext = container or path.suffix.lstrip(".")
|
||||||
base = display_filename(display_name)
|
base = display_filename(display_name)
|
||||||
|
if not ext:
|
||||||
|
return base
|
||||||
if base.lower().endswith(f".{ext.lower()}"):
|
if base.lower().endswith(f".{ext.lower()}"):
|
||||||
base = base[: -(len(ext) + 1)]
|
base = base[: -(len(ext) + 1)]
|
||||||
return f"{base}.{ext}"
|
return f"{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
def abs_path(download_root: str, rel: str) -> Path:
|
def abs_path(files_root: str, rel: str) -> Path:
|
||||||
return Path(download_root) / rel
|
return Path(files_root) / rel
|
||||||
|
|
||||||
|
|
||||||
def safe_abs_path(download_root: str, rel: str) -> Path | None:
|
def safe_abs_path(files_root: str, rel: str | None) -> Path | None:
|
||||||
"""The resolved absolute path for `rel`, but ONLY if it stays inside DOWNLOAD_ROOT and exists on
|
"""The resolved absolute path for `rel`, but ONLY if it stays inside FILES_ROOT and exists on
|
||||||
disk — otherwise None. Centralizes the path-traversal + existence guard every file-serving endpoint
|
disk — otherwise None (no `rel` at all included: an asset without a file has nothing to serve).
|
||||||
shares, so the containment check lives in exactly one place."""
|
Centralizes the path-traversal + existence guard every file-serving endpoint shares, so the
|
||||||
root = Path(download_root).resolve()
|
containment check lives in exactly one place."""
|
||||||
path = abs_path(download_root, rel).resolve()
|
if not rel:
|
||||||
|
return None
|
||||||
|
root = Path(files_root).resolve()
|
||||||
|
path = abs_path(files_root, rel).resolve()
|
||||||
if root not in path.parents or not path.exists():
|
if root not in path.parents or not path.exists():
|
||||||
return None
|
return None
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def place_file(staging_file: Path, download_root: str, rel: str) -> None:
|
def place_file(staging_file: Path, files_root: str, rel: str) -> None:
|
||||||
"""Move a completed staging file into its final location under DOWNLOAD_ROOT."""
|
"""Move a completed staging file into its final location under FILES_ROOT."""
|
||||||
dest = abs_path(download_root, rel)
|
dest = abs_path(files_root, rel)
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.move(str(staging_file), str(dest))
|
shutil.move(str(staging_file), str(dest))
|
||||||
|
|
||||||
@@ -289,9 +360,9 @@ def _sidecar(media: Path, ext: str) -> Path:
|
|||||||
return media.with_suffix(ext)
|
return media.with_suffix(ext)
|
||||||
|
|
||||||
|
|
||||||
def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None) -> bool:
|
def write_sidecars(files_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(files_root, rel)
|
||||||
try:
|
try:
|
||||||
_sidecar(media, ".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
|
_sidecar(media, ".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
|
||||||
if thumb_src and thumb_src.exists():
|
if thumb_src and thumb_src.exists():
|
||||||
@@ -307,9 +378,9 @@ def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Pat
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def delete_asset_files(download_root: str, rel: str) -> None:
|
def delete_asset_files(files_root: str, rel: str) -> None:
|
||||||
"""Remove the media file and its sidecars; prune now-empty parent dirs (best effort)."""
|
"""Remove the media file and its sidecars; prune now-empty parent dirs (best effort)."""
|
||||||
media = abs_path(download_root, rel)
|
media = abs_path(files_root, rel)
|
||||||
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
||||||
try:
|
try:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
@@ -323,7 +394,7 @@ def delete_asset_files(download_root: str, rel: str) -> None:
|
|||||||
extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
|
extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
|
||||||
)
|
)
|
||||||
# Prune empty Season/Channel dirs up to (but not including) the root.
|
# Prune empty Season/Channel dirs up to (but not including) the root.
|
||||||
root = Path(download_root).resolve()
|
root = Path(files_root).resolve()
|
||||||
parent = media.parent
|
parent = media.parent
|
||||||
while parent != root and root in parent.parents:
|
while parent != root and root in parent.parents:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
|
"""Local file ingest for the download center — the non-yt-dlp path into the media library.
|
||||||
|
|
||||||
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
||||||
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
||||||
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
download it bypasses yt-dlp: the file is already on the host, so ingest probes it and places it on
|
||||||
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
disk. A file that already is the phone-safe shape (`playable`) — or is not media at all — becomes an
|
||||||
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
already-`ready` MediaAsset + `done` DownloadJob in one step, the same "the file exists, mark it done
|
||||||
|
now" shortcut `service.enqueue` takes on a cache hit.
|
||||||
|
|
||||||
|
Media that is NOT phone-safe (an iPhone's HEVC .mov, a VP9 .webm, an .mkv) is placed as it came and
|
||||||
|
queued: the asset stays `pending` and the job `queued`, and the download worker converts it
|
||||||
|
(`pipeline._convert_upload`) — so a share link never hands a friend a file their phone cannot play.
|
||||||
|
Only the converted file is kept (the owner's choice: one file per upload). A 10-bit HDR original
|
||||||
|
becomes 8-bit H.264 with its colour tags kept — still tagged HDR (HLG/PQ, BT.2020), not tone-mapped
|
||||||
|
to SDR (the owner's choice too). Until then the original sits at the asset's `rel_path`, which is
|
||||||
|
what a failed conversion's Retry starts from again, and what a delete removes
|
||||||
|
(`service.release_asset`).
|
||||||
|
|
||||||
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
||||||
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
||||||
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
inline (see the force-download serving in the file/watch routes).
|
||||||
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
|
||||||
|
|
||||||
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
||||||
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
||||||
@@ -22,10 +31,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit
|
||||||
|
from app.audit import AuditAction
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import mediakind, probe, quota, service, storage
|
from app.downloads import folders, mediakind, playable, probe, quota, service, storage
|
||||||
from app.models import DownloadJob, MediaAsset, display_text
|
from app.downloads.status import JobKind, JobStatus
|
||||||
|
from app.models import display_text
|
||||||
|
from app.downloads.models import DownloadJob, MediaAsset
|
||||||
|
|
||||||
|
|
||||||
def _title_from_filename(filename: str) -> str:
|
def _title_from_filename(filename: str) -> str:
|
||||||
@@ -43,16 +56,35 @@ def ingest_upload(
|
|||||||
src_path: Path,
|
src_path: Path,
|
||||||
size: int,
|
size: int,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
sha256: str | None = None,
|
||||||
|
folder_id: int | None = None,
|
||||||
|
via: str | None = None,
|
||||||
) -> DownloadJob:
|
) -> DownloadJob:
|
||||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
||||||
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
`done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path`
|
||||||
|
is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the
|
||||||
|
hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for
|
||||||
|
(`folders.upload_target`), else its Uploads folder; `via` what the audit row names as the doer
|
||||||
|
when it was not the user themselves (an API token).
|
||||||
|
|
||||||
|
Raises folders.FolderError when that folder is not one an upload may land in (or is gone).
|
||||||
|
|
||||||
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||||
|
if folder_id is not None:
|
||||||
|
# Refused before any work; held only once the job is about to name it (below), not through
|
||||||
|
# the probe and the poster, which a rename or delete of the folder would wait out.
|
||||||
|
folders.upload_target(db, user_id, folder_id, hold=False)
|
||||||
|
|
||||||
vcodec, acodec, width, height, duration = probe.probe_media(src_path)
|
data = probe.probe_json(src_path)
|
||||||
|
vcodec, acodec, width, height, duration = probe.facts(data)
|
||||||
kind = mediakind.derive(vcodec, acodec)
|
kind = mediakind.derive(vcodec, acodec)
|
||||||
|
# Judged by the extension the file is PLACED under (and the worker re-judges), not the raw one:
|
||||||
|
# 'clip.mp4~' is stored as .mp4, and judging 'mp4~' queued a conversion with nothing to convert.
|
||||||
|
ext = storage.upload_ext(filename) or ""
|
||||||
|
safe = playable.plan(data, ext) if kind != mediakind.FILE else None
|
||||||
|
convert = safe is not None and safe.needs_change
|
||||||
|
|
||||||
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
||||||
# so `format_sig` is a constant marker rather than a format hash.
|
# so `format_sig` is a constant marker rather than a format hash.
|
||||||
@@ -64,19 +96,23 @@ def ingest_upload(
|
|||||||
db.flush() # need asset.id for the on-disk path
|
db.flush() # need asset.id for the on-disk path
|
||||||
|
|
||||||
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||||
storage.place_file(src_path, settings.download_root, rel)
|
storage.place_file(src_path, settings.files_root, rel)
|
||||||
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||||
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
# asset/job rows roll back and nothing names the placed file: the per-user delete path keys off
|
||||||
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
# asset.rel_path (which is gone), and the GC's `.uploads` sweep reaches it only an hour later. So
|
||||||
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
# roll back the on-disk side too — unless the commit itself raised after it landed (see below).
|
||||||
|
asset_id = asset.id
|
||||||
try:
|
try:
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
actual_size = final.stat().st_size if final.exists() else size
|
actual_size = final.stat().st_size if final.exists() else size
|
||||||
|
|
||||||
asset.status = "ready"
|
asset.status = "pending" if convert else "ready"
|
||||||
asset.rel_path = rel
|
asset.rel_path = rel
|
||||||
|
# The hash names the bytes that arrived: a conversion replaces them, so it is kept only
|
||||||
|
# when the file stays as it came.
|
||||||
|
asset.sha256 = None if convert else sha256
|
||||||
asset.size_bytes = actual_size
|
asset.size_bytes = actual_size
|
||||||
asset.container = storage.upload_ext(filename)
|
asset.container = ext or None
|
||||||
asset.vcodec = vcodec
|
asset.vcodec = vcodec
|
||||||
asset.acodec = acodec
|
asset.acodec = acodec
|
||||||
asset.width = width
|
asset.width = width
|
||||||
@@ -90,25 +126,44 @@ def ingest_upload(
|
|||||||
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
||||||
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
||||||
# server side even for a codec the browser can't play).
|
# server side even for a codec the browser can't play).
|
||||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||||
|
|
||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
job_kind="upload",
|
job_kind=JobKind.UPLOAD,
|
||||||
source_kind="upload",
|
source_kind="upload",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_text(display_name),
|
display_name=display_text(display_name),
|
||||||
status="done",
|
status=JobStatus.QUEUED if convert else JobStatus.DONE,
|
||||||
progress=100,
|
progress=0 if convert else 100,
|
||||||
queue_pos=service.next_queue_pos(db),
|
queue_pos=service.next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
|
if folder_id is not None:
|
||||||
|
job.folder_id = folders.upload_target(db, user_id, folder_id).id
|
||||||
|
if not convert:
|
||||||
|
folders.stamp_done(db, job)
|
||||||
service.incr_ref_count(db, asset.id)
|
service.incr_ref_count(db, asset.id)
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
AuditAction.FILE_UPLOAD,
|
||||||
|
target_type="file",
|
||||||
|
target_id=job.id,
|
||||||
|
summary=via,
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
if _row_landed(db, asset_id) is not False:
|
||||||
|
# The commit reached the server and lost its answer, or the row cannot be read: the file
|
||||||
|
# may be the asset's now, so it is never removed blind — the sweep takes it if no row
|
||||||
|
# names it.
|
||||||
|
raise
|
||||||
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
||||||
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
||||||
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
||||||
@@ -116,7 +171,17 @@ def ingest_upload(
|
|||||||
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
||||||
# when none was generated (audio/file, or a video whose poster never started).
|
# when none was generated (audio/file, or a video whose poster never started).
|
||||||
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
||||||
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
storage.abs_path(settings.files_root, leaked).unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
db.refresh(job)
|
db.refresh(job)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _row_landed(db: Session, asset_id: int) -> bool | None:
|
||||||
|
"""After the ingest transaction raised: is the asset row there anyway? A commit can reach the
|
||||||
|
server and lose its answer (the connection drops after COMMIT). None when it cannot be read."""
|
||||||
|
try:
|
||||||
|
return db.get(MediaAsset, asset_id) is not None
|
||||||
|
except Exception: # noqa: BLE001 — "cannot tell" is the answer, the caller acts on it
|
||||||
|
# silent-ok: the caller re-raises the ingest's own error.
|
||||||
|
return None
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str
|
|||||||
|
|
||||||
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
||||||
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
||||||
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
|
refuse them (the sign-in screen names that state; the mail must not contradict it).
|
||||||
|
|
||||||
The link branch is the point of the C option: in the common ordering — approve first, the person
|
The link branch is the point of the C option: in the common ordering — approve first, the person
|
||||||
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
||||||
@@ -253,7 +253,7 @@ def send_access_invitation(
|
|||||||
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
||||||
|
|
||||||
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
||||||
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
|
nobody has ever told us how to address its owner. That is why the send button asks.
|
||||||
"""
|
"""
|
||||||
admin, contact = _reply_contact(lang)
|
admin, contact = _reply_contact(lang)
|
||||||
body = mailtext.text(
|
body = mailtext.text(
|
||||||
|
|||||||
+122
-29
@@ -1,10 +1,11 @@
|
|||||||
"""Feed read-model: the pure query-building and row-serialization layer behind the feed.
|
"""Feed read-model: Reader's query-building, paging and row serialization behind the feed.
|
||||||
|
|
||||||
Extracted from `routes/feed.py` so the read-model has one home instead of living inside the
|
One home for the read-model rather than the router module: the feed routes, the playlist-detail
|
||||||
router module — the feed route handlers and the playlist-detail query share these helpers (the
|
query and Home share these helpers (the projection, the filter builder, and keyset paging), and the
|
||||||
projection, the filter builder, and keyset paging). Nothing here defines a route or reaches for
|
feed's routes only validate and answer — a page, its counts and its facets are built here. Nothing
|
||||||
request state; it's all `(db, user, params) -> query`, and a malformed cursor raises a plain
|
here defines a route or reaches for request state; it's all `(db, user, params) -> query`, and a
|
||||||
`ValueError` for the route handler to translate (the tsquery sanitizer lives in `app.utils`).
|
malformed cursor raises `BadCursor` for the route handler to translate (the tsquery sanitizer lives
|
||||||
|
in `app.utils`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -16,22 +17,19 @@ from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, s
|
|||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.collation import collated
|
from app.collation import collated
|
||||||
from app.models import (
|
from app.models import User
|
||||||
|
from app.catalog.models import (
|
||||||
LIVE_OR_UPCOMING,
|
LIVE_OR_UPCOMING,
|
||||||
BlockedChannel,
|
BlockedChannel,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelTag,
|
|
||||||
ExploredChannel,
|
ExploredChannel,
|
||||||
Playlist,
|
|
||||||
PlaylistItem,
|
|
||||||
SearchFind,
|
SearchFind,
|
||||||
Subscription,
|
Subscription,
|
||||||
Tag,
|
|
||||||
User,
|
|
||||||
Video,
|
Video,
|
||||||
VideoState,
|
|
||||||
)
|
)
|
||||||
|
from app.reader.models import ChannelTag, Playlist, PlaylistItem, Tag, VideoState
|
||||||
from app.utils import iso, like_escape, to_tsquery_str
|
from app.utils import iso, like_escape, to_tsquery_str
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
|
|
||||||
def _channel_url(channel_id: str, handle: str | None) -> str:
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
@@ -48,7 +46,7 @@ def _saved_expr(user: User):
|
|||||||
.select_from(PlaylistItem)
|
.select_from(PlaylistItem)
|
||||||
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||||
.where(
|
.where(
|
||||||
Playlist.user_id == user.id,
|
owned_by(Playlist.user_id, user.id),
|
||||||
Playlist.kind == "watch_later",
|
Playlist.kind == "watch_later",
|
||||||
PlaylistItem.video_id == Video.id,
|
PlaylistItem.video_id == Video.id,
|
||||||
)
|
)
|
||||||
@@ -103,13 +101,13 @@ def serialize(row) -> dict:
|
|||||||
|
|
||||||
def _tag_visible(user: User):
|
def _tag_visible(user: User):
|
||||||
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
|
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
|
||||||
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
return or_(ChannelTag.user_id.is_(None), owned_by(ChannelTag.user_id, user.id))
|
||||||
|
|
||||||
|
|
||||||
# The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives
|
# The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives
|
||||||
# a deleted tag). It filters like a category whose tags no channel carries — to nothing, in the SQL and
|
# a deleted tag). It filters like a category whose tags no channel carries — to nothing, in the SQL and
|
||||||
# in feed_summary's mirror alike — instead of the id being dropped, which showed the WHOLE feed under
|
# in feed_summary's mirror alike — instead of the id being dropped, which showed the WHOLE feed under
|
||||||
# the view's old name (E32 S2). No real category is called this (language/topic/other).
|
# the view's old name. No real category is called this (language/topic/other).
|
||||||
MISSING_TAG_CATEGORY = "missing"
|
MISSING_TAG_CATEGORY = "missing"
|
||||||
|
|
||||||
|
|
||||||
@@ -118,11 +116,11 @@ def tags_by_category(db: Session, user: User, tag_ids: list[int]) -> dict[str, l
|
|||||||
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
||||||
two can't disagree on which selected tag belongs to which category. An unknown id lands in
|
two can't disagree on which selected tag belongs to which category. An unknown id lands in
|
||||||
MISSING_TAG_CATEGORY — and so does another user's private tag: resolving it to its real category
|
MISSING_TAG_CATEGORY — and so does another user's private tag: resolving it to its real category
|
||||||
told the caller which foreign ids exist (E32 review)."""
|
told the caller which foreign ids exist."""
|
||||||
if not tag_ids:
|
if not tag_ids:
|
||||||
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
|
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
|
||||||
out: dict[str, list[int]] = {}
|
out: dict[str, list[int]] = {}
|
||||||
visible = or_(Tag.user_id.is_(None), Tag.user_id == user.id)
|
visible = or_(Tag.user_id.is_(None), owned_by(Tag.user_id, user.id))
|
||||||
for tid, cat in db.execute(
|
for tid, cat in db.execute(
|
||||||
select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids), visible)
|
select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids), visible)
|
||||||
).all():
|
).all():
|
||||||
@@ -142,7 +140,7 @@ def not_blocked_cond(user: User):
|
|||||||
position — and the one query that copied only PART of this predicate set is exactly how that
|
position — and the one query that copied only PART of this predicate set is exactly how that
|
||||||
video came back as the landing page's suggestion after the user had said "never again"."""
|
video came back as the landing page's suggestion after the user had said "never again"."""
|
||||||
return Video.channel_id.notin_(
|
return Video.channel_id.notin_(
|
||||||
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
select(BlockedChannel.channel_id).where(owned_by(BlockedChannel.user_id, user.id))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,7 +185,7 @@ def filtered_query(
|
|||||||
Subscription,
|
Subscription,
|
||||||
and_(
|
and_(
|
||||||
Subscription.channel_id == Video.channel_id,
|
Subscription.channel_id == Video.channel_id,
|
||||||
Subscription.user_id == user.id,
|
owned_by(Subscription.user_id, user.id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -199,12 +197,12 @@ def filtered_query(
|
|||||||
Subscription,
|
Subscription,
|
||||||
and_(
|
and_(
|
||||||
Subscription.channel_id == Video.channel_id,
|
Subscription.channel_id == Video.channel_id,
|
||||||
Subscription.user_id == user.id,
|
owned_by(Subscription.user_id, user.id),
|
||||||
Subscription.hidden.is_(False),
|
Subscription.hidden.is_(False),
|
||||||
),
|
),
|
||||||
).outerjoin(
|
).outerjoin(
|
||||||
SearchFind,
|
SearchFind,
|
||||||
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
|
and_(SearchFind.video_id == Video.id, owned_by(SearchFind.user_id, user.id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# The state-gated views (watched/hidden/in_progress) only ever return videos that HAVE a
|
# The state-gated views (watched/hidden/in_progress) only ever return videos that HAVE a
|
||||||
@@ -214,7 +212,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(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
query = join_state(state, and_(state.video_id == Video.id, owned_by(state.user_id, user)))
|
||||||
|
|
||||||
# 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;
|
||||||
@@ -227,8 +225,8 @@ def filtered_query(
|
|||||||
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
|
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
|
||||||
# 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(owned_by(Subscription.user_id, user.id))
|
||||||
).union(select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id))
|
).union(select(ExploredChannel.channel_id).where(owned_by(ExploredChannel.user_id, user.id)))
|
||||||
query = query.where(
|
query = query.where(
|
||||||
or_(
|
or_(
|
||||||
Video.via_explore.is_(False),
|
Video.via_explore.is_(False),
|
||||||
@@ -346,7 +344,7 @@ def filtered_query(
|
|||||||
if untagged:
|
if untagged:
|
||||||
query = query.where(
|
query = query.where(
|
||||||
Video.channel_id.notin_(
|
Video.channel_id.notin_(
|
||||||
select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id)
|
select(ChannelTag.channel_id).where(owned_by(ChannelTag.user_id, user.id))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -463,7 +461,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def feed_years(db: Session, user: User, **params) -> list[dict]:
|
def feed_years(db: Session, user: User, **params) -> list[dict]:
|
||||||
"""Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's
|
"""Per-year video counts for the current filter context: powers the year control's
|
||||||
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
|
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
|
||||||
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
|
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
|
||||||
published_after/before and max_age_days — so the year list is the full set of years available and
|
published_after/before and max_age_days — so the year list is the full set of years available and
|
||||||
@@ -559,7 +557,7 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) ->
|
|||||||
return table.get(sort) or table["newest"]
|
return table.get(sort) or table["newest"]
|
||||||
|
|
||||||
|
|
||||||
# Relevance ranks only the newest RANK_WINDOW matches (E31): ts_rank reads every match's tsvector, and
|
# Relevance ranks only the newest RANK_WINDOW matches: ts_rank reads every match's tsvector, and
|
||||||
# a broad word matches most of the library ("a" ranked 193k rows in 6.8 s on prod, against 1.1 s to just
|
# a broad word matches most of the library ("a" ranked 193k rows in 6.8 s on prod, against 1.1 s to just
|
||||||
# find them). The rows past the window keep rank 0, so they follow the ranked ones newest first; a word
|
# find them). The rows past the window keep rank 0, so they follow the ranked ones newest first; a word
|
||||||
# that broad has no meaningful relevance order anyway, and a specific one fits the window whole.
|
# that broad has no meaningful relevance order anyway, and a specific one fits the window whole.
|
||||||
@@ -671,7 +669,7 @@ def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str:
|
|||||||
def _cursor_dt(v):
|
def _cursor_dt(v):
|
||||||
"""A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and
|
"""A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and
|
||||||
windowed_relevance_rows compares them in Python, where a naive one beside an aware one raises — a
|
windowed_relevance_rows compares them in Python, where a naive one beside an aware one raises — a
|
||||||
500 for a hand-made cursor instead of the 400 (E31 review 2)."""
|
500 for a hand-made cursor instead of the 400."""
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
dt = datetime.fromisoformat(v)
|
dt = datetime.fromisoformat(v)
|
||||||
@@ -696,3 +694,98 @@ def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime |
|
|||||||
# Framework-free: the route handler translates this into an HTTP 400 so the
|
# Framework-free: the route handler translates this into an HTTP 400 so the
|
||||||
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
|
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
|
||||||
raise ValueError("Invalid feed cursor") from exc
|
raise ValueError("Invalid feed cursor") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class BadCursor(ValueError):
|
||||||
|
"""A `cursor` this module did not write (or one from another sort): the caller's bad input."""
|
||||||
|
|
||||||
|
|
||||||
|
def feed_page(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
params: dict,
|
||||||
|
*,
|
||||||
|
sort: str,
|
||||||
|
seed: int,
|
||||||
|
limit: int,
|
||||||
|
cursor: str | None,
|
||||||
|
lang: str,
|
||||||
|
) -> dict:
|
||||||
|
"""One page of the feed, and the cursor for the next. A malformed cursor raises `BadCursor`."""
|
||||||
|
query, rank_expr = filtered_query(db, user, **params)
|
||||||
|
keys = sort_keys(sort, seed, rank_expr, lang)
|
||||||
|
|
||||||
|
vals, cursor_id, window = None, None, None
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
vals, cursor_id, window = decode_cursor(keys, cursor)
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadCursor(str(e)) from e
|
||||||
|
ranked = sort == "relevance" and rank_expr is not None
|
||||||
|
if ranked and not cursor:
|
||||||
|
window = rank_window_start(db, query)
|
||||||
|
if ranked and window is not None:
|
||||||
|
keys = sort_keys(sort, seed, windowed_rank(rank_expr, window), lang)
|
||||||
|
|
||||||
|
# Expose each key column on the row so we can build the next cursor from the last item.
|
||||||
|
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
||||||
|
if vals is not None:
|
||||||
|
for k, v in zip(keys, vals, strict=True):
|
||||||
|
k["value"] = v
|
||||||
|
if ranked and window is not None:
|
||||||
|
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
|
||||||
|
else:
|
||||||
|
query = query.order_by(*order_by(keys))
|
||||||
|
if cursor_id is not None:
|
||||||
|
query = query.where(keyset_predicate(keys, cursor_id))
|
||||||
|
rows = db.execute(query.limit(limit + 1)).all()
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
page = rows[:limit]
|
||||||
|
return {
|
||||||
|
"items": [serialize(r) for r in page],
|
||||||
|
"next_cursor": (
|
||||||
|
encode_cursor(keys, page[-1], window if ranked else None)
|
||||||
|
if (has_more and page)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"has_more": has_more,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feed_count(db: Session, user: User, **params) -> int:
|
||||||
|
"""How many videos the filters match — the reference oracle `feed_summary`'s count is tested
|
||||||
|
against."""
|
||||||
|
query, _rank = filtered_query(db, user, **params)
|
||||||
|
return db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
|
|
||||||
|
|
||||||
|
def facet_counts(db: Session, user: User, **params) -> dict[int, int]:
|
||||||
|
"""Per-tag channel counts for the current filter context, one pass per category — the reference
|
||||||
|
oracle `feed_summary`'s facets are tested against.
|
||||||
|
|
||||||
|
Each category is counted with every other filter applied but its own selections ignored
|
||||||
|
(drill-down faceting: selecting one topic doesn't zero out the rest). A conjunctive (AND)
|
||||||
|
category keeps them applied, so each remaining chip narrows to channels that ALSO have every
|
||||||
|
selected tag, and tags that can't co-occur drop to zero."""
|
||||||
|
visible = _tag_visible(user)
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
for category in ("language", "topic", "other"):
|
||||||
|
conjunctive = params.get("tag_modes", {}).get(category) == "and"
|
||||||
|
base, _rank = filtered_query(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
**{**params, "exclude_tag_category": None if conjunctive else category},
|
||||||
|
)
|
||||||
|
channels = base.with_only_columns(Video.channel_id).distinct().subquery()
|
||||||
|
rows = db.execute(
|
||||||
|
select(ChannelTag.tag_id, func.count(func.distinct(ChannelTag.channel_id)))
|
||||||
|
.select_from(channels)
|
||||||
|
.join(ChannelTag, ChannelTag.channel_id == channels.c.channel_id)
|
||||||
|
.join(Tag, and_(Tag.id == ChannelTag.tag_id, Tag.category == category))
|
||||||
|
.where(visible)
|
||||||
|
.group_by(ChannelTag.tag_id)
|
||||||
|
).all()
|
||||||
|
for tag_id, count in rows:
|
||||||
|
counts[tag_id] = count
|
||||||
|
return counts
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go —
|
"""Bug reports built from debug sessions: the files an issue carries, and where they go —
|
||||||
downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded
|
downloaded as a zip, or sent to a Gitea issue tracker. The session itself — what is recorded
|
||||||
and tagged — is the core module `app/debugsession.py`."""
|
and tagged — is the core module `app/debugsession.py`."""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Gitea upload, so the two cannot drift apart.
|
|||||||
in their language; the server does not rewrite it);
|
in their language; the server does not rewrite it);
|
||||||
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
|
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
|
||||||
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
|
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
|
||||||
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
|
`.jsonl`: Gitea refuses that extension (measured);
|
||||||
* the screenshots, in the order they were taken.
|
* the screenshots, in the order they were taken.
|
||||||
|
|
||||||
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
|
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1
|
"""The Gitea issue tracker a debug session's issue goes to — measured against Gitea 1.27.1
|
||||||
before a line of this was written (plans/e16-debug-issue.md, S0).
|
before a line of this was written.
|
||||||
|
|
||||||
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
|
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
|
||||||
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
|
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
|
||||||
@@ -79,7 +79,7 @@ def target(db: Session) -> dict | None:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
|
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
|
||||||
# Takes the settings already read, not the session: reading them HERE opened a transaction one
|
# Takes the settings already read, not the session: reading them HERE opened a transaction one
|
||||||
# line after `deliver` had ended one, and held it across the calls below (deep-review round 4).
|
# line after `deliver` had ended one, and held it across the calls below.
|
||||||
url, repo, token = settings
|
url, repo, token = settings
|
||||||
if not (url and repo and token):
|
if not (url and repo and token):
|
||||||
raise GiteaError("not_configured")
|
raise GiteaError("not_configured")
|
||||||
@@ -203,7 +203,7 @@ def deliver(
|
|||||||
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
|
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
|
||||||
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
|
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
|
||||||
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
|
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
|
||||||
# with its snapshot pinned — across the repository check and the create (deep-review round 4
|
# with its snapshot pinned — across the repository check and the create (a review
|
||||||
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
|
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
|
||||||
checkpoint()
|
checkpoint()
|
||||||
with _client(settings) as (client, repo):
|
with _client(settings) as (client, repo):
|
||||||
@@ -223,7 +223,7 @@ def deliver(
|
|||||||
)
|
)
|
||||||
# Built ON the state read at the start so nothing already in it is dropped. The claim
|
# Built ON the state read at the start so nothing already in it is dropped. The claim
|
||||||
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
|
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
|
||||||
# any `sending` passed to it and stamps the row's own from `token` (round 4 — removing
|
# any `sending` passed to it and stamps the row's own from `token` (removing
|
||||||
# the spread left the whole suite green, because the claim never travelled here).
|
# the spread left the whole suite green, because the claim never travelled here).
|
||||||
state = {
|
state = {
|
||||||
**state,
|
**state,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""The durable history of background runs — the `job_runs` table.
|
||||||
|
|
||||||
|
A run is written twice: a `running` row when it starts, its outcome when it ends. Each write is its
|
||||||
|
OWN short session, committed at once, for the reason `quota.record_usage` gives: the run's own work
|
||||||
|
may roll back (its `except: db.rollback()`), and the history of what happened must not go with it —
|
||||||
|
nor may this bookkeeping commit the job's half-done session.
|
||||||
|
|
||||||
|
Only the API process runs jobs (the worker never starts the scheduler), and one API process serves a
|
||||||
|
database (`.env.example`, the Scheduler section: the single-run guard is in its memory too), so at its
|
||||||
|
startup nothing can still be running: `interrupt_leftovers` closes what a stopped process left open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select, union_all, update
|
||||||
|
from sqlalchemy.engine import CursorResult
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import JobRun, QuotaEvent
|
||||||
|
from app.runstatus import RunStatus, RunTrigger
|
||||||
|
|
||||||
|
|
||||||
|
def start(job: str, trigger: RunTrigger, actor_id: int | None, target: str | None = None) -> int:
|
||||||
|
"""Open a run's row; returns its id."""
|
||||||
|
with SessionLocal() as s:
|
||||||
|
row = JobRun(
|
||||||
|
job=job,
|
||||||
|
trigger=trigger,
|
||||||
|
actor_id=actor_id,
|
||||||
|
target=target,
|
||||||
|
status=RunStatus.RUNNING,
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
s.add(row)
|
||||||
|
s.commit()
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
|
||||||
|
def finish(
|
||||||
|
run_id: int,
|
||||||
|
status: RunStatus,
|
||||||
|
*,
|
||||||
|
summary: str | None = None,
|
||||||
|
result: dict | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
failure: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Close a run with its outcome and what it spent. Conditional on the row still running: a row
|
||||||
|
the startup sweep already called interrupted is not rewritten by a thread that outlived it."""
|
||||||
|
with SessionLocal() as s:
|
||||||
|
units = s.scalar(
|
||||||
|
select(func.coalesce(func.sum(QuotaEvent.units), 0)).where(
|
||||||
|
QuotaEvent.job_run_id == run_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.execute(
|
||||||
|
update(JobRun)
|
||||||
|
.where(JobRun.id == run_id, JobRun.status == RunStatus.RUNNING)
|
||||||
|
.values(
|
||||||
|
status=status,
|
||||||
|
finished_at=datetime.now(timezone.utc),
|
||||||
|
summary=summary,
|
||||||
|
result=result,
|
||||||
|
error=error,
|
||||||
|
failure=failure,
|
||||||
|
quota_units=units or 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def interrupt_leftovers() -> int:
|
||||||
|
"""Every row a previous process left `running` → `interrupted`, with what it spent until then
|
||||||
|
(its quota rows outlive it, as `finish` sums them). Called once at startup, before the scheduler
|
||||||
|
can open a new run. Returns how many."""
|
||||||
|
spent = (
|
||||||
|
select(func.coalesce(func.sum(QuotaEvent.units), 0))
|
||||||
|
.where(QuotaEvent.job_run_id == JobRun.id)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
with SessionLocal() as s:
|
||||||
|
res = s.execute(
|
||||||
|
update(JobRun)
|
||||||
|
.where(JobRun.status == RunStatus.RUNNING)
|
||||||
|
.values(
|
||||||
|
status=RunStatus.INTERRUPTED,
|
||||||
|
finished_at=datetime.now(timezone.utc),
|
||||||
|
quota_units=spent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
return res.rowcount if isinstance(res, CursorResult) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def recent_runs(db: Session, jobs: list[str], per_job: int) -> dict[str, list[JobRun]]:
|
||||||
|
"""Each of `jobs`' last `per_job` runs, newest first, by job id — one query, each job's part a
|
||||||
|
short walk of `ix_job_runs_job_started`, so a poll costs the same at any history length (a window
|
||||||
|
over the whole table grew with every run kept)."""
|
||||||
|
if not jobs:
|
||||||
|
return {}
|
||||||
|
newest = union_all(
|
||||||
|
*(
|
||||||
|
select(JobRun.id)
|
||||||
|
.where(JobRun.job == job)
|
||||||
|
.order_by(JobRun.started_at.desc(), JobRun.id.desc())
|
||||||
|
.limit(per_job)
|
||||||
|
for job in jobs
|
||||||
|
)
|
||||||
|
).subquery()
|
||||||
|
rows = db.execute(
|
||||||
|
select(JobRun)
|
||||||
|
.where(JobRun.id.in_(select(newest.c.id)))
|
||||||
|
.order_by(JobRun.job, JobRun.started_at.desc(), JobRun.id.desc())
|
||||||
|
).scalars()
|
||||||
|
out: dict[str, list[JobRun]] = {}
|
||||||
|
for r in rows:
|
||||||
|
out.setdefault(r.job, []).append(r)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def gc(db: Session, retention_days: int) -> int:
|
||||||
|
"""Delete finished runs older than `retention_days` (0 = keep forever). A run still going is
|
||||||
|
never reaped. The caller commits."""
|
||||||
|
if retention_days <= 0:
|
||||||
|
return 0
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||||
|
res = db.execute(
|
||||||
|
delete(JobRun).where(JobRun.started_at < cutoff, JobRun.status != RunStatus.RUNNING)
|
||||||
|
)
|
||||||
|
return res.rowcount if isinstance(res, CursorResult) else 0
|
||||||
+28
-14
@@ -10,8 +10,8 @@ the logs were both unjoinable and leaky. `configure_logging()` replaces all four
|
|||||||
Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
|
Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
|
||||||
ts · level · sev · source · logger · event · msg · attrs
|
ts · level · sev · source · logger · event · msg · attrs
|
||||||
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
|
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
|
||||||
`job_id`) and the app version. That shape is what E22.6's admin console filters on and what a Gitea
|
`job_id`, `run_id`) and the app version. That shape is what the admin log console filters on and
|
||||||
issue attachment will carry, so it is deliberately flat and stable.
|
what a Gitea issue attachment will carry, so it is deliberately flat and stable.
|
||||||
|
|
||||||
Call sites stay ordinary stdlib logging:
|
Call sites stay ordinary stdlib logging:
|
||||||
|
|
||||||
@@ -81,15 +81,28 @@ _debug_session: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|||||||
"log_debug_session", default=None
|
"log_debug_session", default=None
|
||||||
)
|
)
|
||||||
_job_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_job_id", default=None)
|
_job_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_job_id", default=None)
|
||||||
|
# The background run (app/jobruns.py) this context is doing; quota's spend is tagged with it too.
|
||||||
|
_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_run_id", default=None)
|
||||||
|
|
||||||
_CONTEXT_VARS = {
|
_CONTEXT_VARS = {
|
||||||
"request_id": _request_id,
|
"request_id": _request_id,
|
||||||
"user_id": _user_id,
|
"user_id": _user_id,
|
||||||
"debug_session_id": _debug_session,
|
"debug_session_id": _debug_session,
|
||||||
"job_id": _job_id,
|
"job_id": _job_id,
|
||||||
|
"run_id": _run_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def current_request_id() -> str | None:
|
||||||
|
"""The HTTP request this context is serving, if any — the request middleware sets it."""
|
||||||
|
return _request_id.get()
|
||||||
|
|
||||||
|
|
||||||
|
def current_run_id() -> int | None:
|
||||||
|
"""The background run this context is doing, if any — `bind(run_id=…)` sets it."""
|
||||||
|
return _run_id.get()
|
||||||
|
|
||||||
|
|
||||||
class _RequestScope:
|
class _RequestScope:
|
||||||
"""What the auth dependency learns about a request, for every line logged after it.
|
"""What the auth dependency learns about a request, for every line logged after it.
|
||||||
|
|
||||||
@@ -106,7 +119,7 @@ class _RequestScope:
|
|||||||
self.user_id: int | None = None
|
self.user_id: int | None = None
|
||||||
# The user's consented diagnostics level as a logging number; None at the base level.
|
# The user's consented diagnostics level as a logging number; None at the base level.
|
||||||
self.level: int | None = None
|
self.level: int | None = None
|
||||||
# The user's own open debug session this request named (E16), else None.
|
# The user's own open debug session this request named, else None.
|
||||||
self.debug_session_id: str | None = None
|
self.debug_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -122,11 +135,11 @@ def set_user(
|
|||||||
debug_session: str | None = None,
|
debug_session: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
|
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
|
||||||
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
|
consented diagnostics `level` until `until` ("INFO" = no elevation). Called from the
|
||||||
auth dependency — the one place the session is actually resolved into an account — rather than
|
auth dependency — the one place the session is actually resolved into an account — rather than
|
||||||
from the request middleware, which runs OUTSIDE the session middleware.
|
from the request middleware, which runs OUTSIDE the session middleware.
|
||||||
|
|
||||||
`debug_session` tags every line with the user's open debug session (E16). The caller has
|
`debug_session` tags every line with the user's open debug session. The caller has
|
||||||
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
|
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
|
||||||
|
|
||||||
The root logger's floor is tracked per elevation: a request without the session's header (an
|
The root logger's floor is tracked per elevation: a request without the session's header (an
|
||||||
@@ -334,7 +347,7 @@ _RECORD_STANDARD = frozenset(
|
|||||||
class ContextFilter(logging.Filter):
|
class ContextFilter(logging.Filter):
|
||||||
"""Copies the correlation contextvars onto the record. A Filter (not the formatter) so the
|
"""Copies the correlation contextvars onto the record. A Filter (not the formatter) so the
|
||||||
values are captured at emit time, before a QueueHandler can move the record to another
|
values are captured at emit time, before a QueueHandler can move the record to another
|
||||||
thread — the DB sink in S3 depends on that."""
|
thread — the DB sink depends on that."""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
for name, var in _CONTEXT_VARS.items():
|
for name, var in _CONTEXT_VARS.items():
|
||||||
@@ -355,7 +368,7 @@ class ContextFilter(logging.Filter):
|
|||||||
|
|
||||||
class LevelGate(logging.Filter):
|
class LevelGate(logging.Filter):
|
||||||
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request
|
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request
|
||||||
whose user raised their diagnostics level (E22 S5).
|
whose user raised their diagnostics level.
|
||||||
|
|
||||||
The root logger's level cannot do this alone: it is one number for the whole process, and the
|
The root logger's level cannot do this alone: it is one number for the whole process, and the
|
||||||
point is to log ONE user's requests in detail while everyone else's stay at the base level. So
|
point is to log ONE user's requests in detail while everyone else's stay at the base level. So
|
||||||
@@ -457,7 +470,7 @@ class TextFormatter(logging.Formatter):
|
|||||||
# --- configuration -------------------------------------------------------------------------
|
# --- configuration -------------------------------------------------------------------------
|
||||||
# Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can
|
# Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can
|
||||||
# lower the ROOT level to DEBUG/TRACE without drowning the output in SQL statements and HTTP chatter
|
# lower the ROOT level to DEBUG/TRACE without drowning the output in SQL statements and HTTP chatter
|
||||||
# — raising THESE is a deliberate, separate act (E22.6's console).
|
# — raising THESE is a deliberate, separate act (the admin log console).
|
||||||
_THIRD_PARTY_LEVELS = {
|
_THIRD_PARTY_LEVELS = {
|
||||||
"sqlalchemy": "WARNING",
|
"sqlalchemy": "WARNING",
|
||||||
"sqlalchemy.engine": "WARNING",
|
"sqlalchemy.engine": "WARNING",
|
||||||
@@ -481,7 +494,7 @@ def get_logger(name: str) -> logging.Logger:
|
|||||||
`siftlode.plex.sync`.
|
`siftlode.plex.sync`.
|
||||||
|
|
||||||
The names were literals before, and 25 of them covered 34 modules — six different Plex modules
|
The names were literals before, and 25 of them covered 34 modules — six different Plex modules
|
||||||
all called themselves `siftlode.plex`, so neither a level pinned per logger nor E22.6's console
|
all called themselves `siftlode.plex`, so neither a level pinned per logger nor the console's
|
||||||
filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one
|
filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one
|
||||||
name per module, it cannot drift from the file it sits in, and `test_logger_names.py` fails if a
|
name per module, it cannot drift from the file it sits in, and `test_logger_names.py` fails if a
|
||||||
module hand-rolls one again.
|
module hand-rolls one again.
|
||||||
@@ -562,12 +575,12 @@ def current_level() -> str:
|
|||||||
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
|
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
|
||||||
|
|
||||||
|
|
||||||
# --- per-user elevation (E22 S5) -----------------------------------------------------------
|
# --- per-user elevation -----------------------------------------------------------
|
||||||
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
|
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
|
||||||
# test, a script — so the gate lets everything through there, as it did before the gate existed.
|
# test, a script — so the gate lets everything through there, as it did before the gate existed.
|
||||||
_base_level = logging.NOTSET
|
_base_level = logging.NOTSET
|
||||||
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
|
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
|
||||||
# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
|
# own, by user id, and a debug session's, by (user id, session id) — its requests alone carry
|
||||||
# its level, so it is tracked apart from the user's own requests.
|
# its level, so it is tracked apart from the user's own requests.
|
||||||
_ElevationKey = int | tuple[int, str]
|
_ElevationKey = int | tuple[int, str]
|
||||||
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
|
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
|
||||||
@@ -667,7 +680,7 @@ def current_source() -> str:
|
|||||||
|
|
||||||
|
|
||||||
# --- request logging -----------------------------------------------------------------------
|
# --- request logging -----------------------------------------------------------------------
|
||||||
# A client may supply its own request id so a browser log line and a server line join up (E22 S4).
|
# A client may supply its own request id so a browser log line and a server line join up.
|
||||||
# Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to
|
# Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to
|
||||||
# write arbitrary text into the log.
|
# write arbitrary text into the log.
|
||||||
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||||
@@ -688,10 +701,11 @@ _POLLED_ROUTES = frozenset(
|
|||||||
{
|
{
|
||||||
"/api/me",
|
"/api/me",
|
||||||
"/api/me/notifications/unread_count",
|
"/api/me/notifications/unread_count",
|
||||||
"/api/messages/unread_count",
|
|
||||||
"/api/downloads",
|
"/api/downloads",
|
||||||
"/api/downloads/index",
|
"/api/downloads/index",
|
||||||
"/api/downloads/usage",
|
"/api/files",
|
||||||
|
"/api/files/folders",
|
||||||
|
"/api/files/usage",
|
||||||
"/api/sync/my-status",
|
"/api/sync/my-status",
|
||||||
"/api/admin/scheduler",
|
"/api/admin/scheduler",
|
||||||
"/api/admin/downloads",
|
"/api/admin/downloads",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The `log_events` sink (E22 S3): the stored copy of the log the diagnostics console reads.
|
"""The `log_events` sink: the stored copy of the log the diagnostics console reads.
|
||||||
|
|
||||||
stdout stays the complete log (docker keeps 3×50 MB of it); this table keeps the part worth querying
|
stdout stays the complete log (docker keeps 3×50 MB of it); this table keeps the part worth querying
|
||||||
for `log_retention_days`:
|
for `log_retention_days`:
|
||||||
@@ -6,7 +6,7 @@ for `log_retention_days`:
|
|||||||
* every WARNING and above,
|
* every WARNING and above,
|
||||||
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
|
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
|
||||||
static files),
|
static files),
|
||||||
* every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate`
|
* every line of a request whose user raised their diagnostics level (`logs.LevelGate`
|
||||||
marks them), which is the detail they consented to have kept,
|
marks them), which is the detail they consented to have kept,
|
||||||
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
|
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
|
||||||
traffic per client.
|
traffic per client.
|
||||||
@@ -347,8 +347,8 @@ def gc(db: Session, retention_days: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def serialize(row: LogEvent) -> dict:
|
def serialize(row: LogEvent) -> dict:
|
||||||
"""A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and
|
"""A stored row as the API sends it — one shape for every reader: the admin console and
|
||||||
a debug session's evidence list (E16)."""
|
a debug session's evidence list."""
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"ts": iso(row.ts),
|
"ts": iso(row.ts),
|
||||||
@@ -362,6 +362,7 @@ def serialize(row: LogEvent) -> dict:
|
|||||||
"request_id": row.request_id,
|
"request_id": row.request_id,
|
||||||
"user_id": row.user_id,
|
"user_id": row.user_id,
|
||||||
"job_id": row.job_id,
|
"job_id": row.job_id,
|
||||||
|
"run_id": row.run_id,
|
||||||
"debug_session_id": row.debug_session_id,
|
"debug_session_id": row.debug_session_id,
|
||||||
"version": row.version,
|
"version": row.version,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
|
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
|
||||||
recipient gets (E9.5 S7).
|
recipient gets.
|
||||||
|
|
||||||
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
|
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
|
||||||
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
|
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
|
||||||
@@ -53,7 +53,7 @@ def lang_for_user(user: User | None) -> str:
|
|||||||
|
|
||||||
def lang_for_email(db: Session, email: str) -> str:
|
def lang_for_email(db: Session, email: str) -> str:
|
||||||
"""The language for an address that may not have an account yet: their own account preference,
|
"""The language for an address that may not have an account yet: their own account preference,
|
||||||
else the language they filled our access-request form in, else English.
|
else the language their access request came in (the locale Google reported), else English.
|
||||||
|
|
||||||
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
|
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
|
||||||
carry no usable code instead of stopping at the first one — prod really does hold two accounts
|
carry no usable code instead of stopping at the first one — prod really does hold two accounts
|
||||||
@@ -82,8 +82,8 @@ def lang_for_email(db: Session, email: str) -> str:
|
|||||||
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
|
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
|
||||||
MAIL_TEXT: dict[str, dict[str, str]] = {
|
MAIL_TEXT: dict[str, dict[str, str]] = {
|
||||||
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
||||||
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
|
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days,
|
||||||
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts.
|
# and "expires in 168 hours" is a unit no reader converts.
|
||||||
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
|
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
|
||||||
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
|
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
|
||||||
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
|
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+41
-11
@@ -28,6 +28,9 @@ log = logs.get_logger(__name__)
|
|||||||
from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
|
from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
|
||||||
from app.config import settings # noqa: E402 — logging is configured first
|
from app.config import settings # noqa: E402 — logging is configured first
|
||||||
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
||||||
|
import app.tables # noqa: E402 — every table, before the first query
|
||||||
|
from app.downloads import service as download_service # noqa: E402 — logging is configured first
|
||||||
|
from app.downloads import storage # noqa: E402 — logging is configured first
|
||||||
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
|
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
|
||||||
from app.plex import subtitles as plex_subtitles # noqa: E402 — logging is configured first
|
from app.plex import subtitles as plex_subtitles # noqa: E402 — logging is configured first
|
||||||
from app.routes import ( # noqa: E402 — logging is configured first
|
from app.routes import ( # noqa: E402 — logging is configured first
|
||||||
@@ -42,20 +45,22 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
|||||||
discover as discover_routes,
|
discover as discover_routes,
|
||||||
downloads,
|
downloads,
|
||||||
feed,
|
feed,
|
||||||
|
files as files_routes,
|
||||||
health,
|
health,
|
||||||
me,
|
me,
|
||||||
messages,
|
|
||||||
notifications,
|
notifications,
|
||||||
playlists,
|
playlists,
|
||||||
plex as plex_routes,
|
plex as plex_routes,
|
||||||
public as public_routes,
|
public as public_routes,
|
||||||
quota,
|
quota,
|
||||||
|
runs as runs_routes,
|
||||||
saved_views,
|
saved_views,
|
||||||
scheduler as scheduler_routes,
|
scheduler as scheduler_routes,
|
||||||
search as search_routes,
|
search as search_routes,
|
||||||
setup as setup_routes,
|
setup as setup_routes,
|
||||||
sync,
|
sync,
|
||||||
tags,
|
tags,
|
||||||
|
tokens as tokens_routes,
|
||||||
version,
|
version,
|
||||||
)
|
)
|
||||||
from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first
|
from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first
|
||||||
@@ -64,6 +69,10 @@ from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 —
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
log.info("Siftlode starting up")
|
log.info("Siftlode starting up")
|
||||||
|
problem = storage.files_root_problem(settings.files_root)
|
||||||
|
if problem:
|
||||||
|
# The rest of the app still serves; the files library cannot (the worker refuses to start).
|
||||||
|
log.critical("The files library is not reachable: %s. See docs/self-hosting.md.", problem)
|
||||||
# The stored copy of the log (log_events). The entrypoint has already migrated the schema.
|
# The stored copy of the log (log_events). The entrypoint has already migrated the schema.
|
||||||
logsink.start()
|
logsink.start()
|
||||||
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
|
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
|
||||||
@@ -79,6 +88,18 @@ async def lifespan(app: FastAPI):
|
|||||||
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
||||||
# and `%s` of the exception does not say which of them raised.
|
# and `%s` of the exception does not say which of them raised.
|
||||||
log.exception("could not read the stored log level; staying at %s", current_level())
|
log.exception("could not read the stored log level; staying at %s", current_level())
|
||||||
|
if not problem:
|
||||||
|
try:
|
||||||
|
left = download_service.library_left_behind(db, settings.files_root)
|
||||||
|
except Exception: # a database not reachable yet: the check is a hint, never a gate
|
||||||
|
log.exception("could not check the files library against its database")
|
||||||
|
else:
|
||||||
|
if left:
|
||||||
|
log.critical(
|
||||||
|
"The files library is empty but the database names files: the volume "
|
||||||
|
"mounted at %s is not the one that holds them. See docs/self-hosting.md.",
|
||||||
|
settings.files_root,
|
||||||
|
)
|
||||||
if not state.is_configured(db):
|
if not state.is_configured(db):
|
||||||
token = state.rotate_setup_token(db)
|
token = state.rotate_setup_token(db)
|
||||||
url = f"{settings.app_base}/setup?token={token}"
|
url = f"{settings.app_base}/setup?token={token}"
|
||||||
@@ -132,7 +153,7 @@ async def _reap_hls_sessions() -> None:
|
|||||||
A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open
|
A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open
|
||||||
player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer
|
player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer
|
||||||
invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead
|
invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead
|
||||||
held one whole segmented copy of a film per item on the download volume (round 5)."""
|
held one whole segmented copy of a film per item on the download volume."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(_HLS_REAP_EVERY_S)
|
await asyncio.sleep(_HLS_REAP_EVERY_S)
|
||||||
try:
|
try:
|
||||||
@@ -188,8 +209,8 @@ async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse:
|
|||||||
NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or
|
NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or
|
||||||
an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection
|
an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection
|
||||||
search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not
|
search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not
|
||||||
hold: round 3 guarded the by-rating_key lookup and missed the rest. Any other DataError is left
|
hold: an earlier fix guarded the by-rating_key lookup and missed the rest. Any other DataError
|
||||||
alone — re-raised, it stays the 500 it always was."""
|
is left alone — re-raised, it stays the 500 it always was."""
|
||||||
if "NUL (0x00)" not in str(exc.orig):
|
if "NUL (0x00)" not in str(exc.orig):
|
||||||
raise exc
|
raise exc
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -268,7 +289,8 @@ async def static_cache_headers(request, call_next):
|
|||||||
# Baseline security response headers (SEC-1). A minimal, safe set that needs no per-route tuning:
|
# Baseline security response headers (SEC-1). A minimal, safe set that needs no per-route tuning:
|
||||||
# nosniff stops MIME-confusion, the Referrer-Policy keeps the (private) app URL from leaking to
|
# nosniff stops MIME-confusion, the Referrer-Policy keeps the (private) app URL from leaking to
|
||||||
# external sites the user follows, `frame-ancestors 'none'` blocks click-jacking by refusing to be
|
# external sites the user follows, `frame-ancestors 'none'` blocks click-jacking by refusing to be
|
||||||
# iframed anywhere (we never embed our own pages; we embed OTHERS, which this does not affect), and
|
# iframed anywhere (we embed OTHERS, which this does not affect; the one own answer the app frames, a
|
||||||
|
# library file's preview, sets its own `frame-ancestors 'self'` — these are defaults, `setdefault`), and
|
||||||
# Permissions-Policy turns off powerful features the app never uses. `X-Frame-Options: DENY` duplicates
|
# Permissions-Policy turns off powerful features the app never uses. `X-Frame-Options: DENY` duplicates
|
||||||
# the frame-ancestors intent for legacy engines that never implemented CSP frame-ancestors (belt and
|
# the frame-ancestors intent for legacy engines that never implemented CSP frame-ancestors (belt and
|
||||||
# suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy
|
# suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy
|
||||||
@@ -311,19 +333,21 @@ app.include_router(me.router)
|
|||||||
app.include_router(diagnostics_routes.router)
|
app.include_router(diagnostics_routes.router)
|
||||||
app.include_router(debug_sessions_routes.router)
|
app.include_router(debug_sessions_routes.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(messages.router)
|
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
app.include_router(playlists.router)
|
app.include_router(playlists.router)
|
||||||
app.include_router(saved_views.router)
|
app.include_router(saved_views.router)
|
||||||
app.include_router(plex_routes.router)
|
app.include_router(plex_routes.router)
|
||||||
app.include_router(downloads.router)
|
app.include_router(downloads.router)
|
||||||
app.include_router(downloads.admin_router)
|
app.include_router(downloads.admin_router)
|
||||||
|
app.include_router(files_routes.router)
|
||||||
|
app.include_router(tokens_routes.router)
|
||||||
app.include_router(public_routes.router)
|
app.include_router(public_routes.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
app.include_router(audit_routes.router)
|
app.include_router(audit_routes.router)
|
||||||
app.include_router(admin_logs_routes.router)
|
app.include_router(admin_logs_routes.router)
|
||||||
|
app.include_router(runs_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
@@ -341,6 +365,8 @@ STATIC_DIR = Path(__file__).parent / "static_spa"
|
|||||||
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
|
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
|
||||||
# hashed bundles under /assets can cache forever (a new build changes their filename).
|
# hashed bundles under /assets can cache forever (a new build changes their filename).
|
||||||
INDEX_HTML = STATIC_DIR / "index.html"
|
INDEX_HTML = STATIC_DIR / "index.html"
|
||||||
|
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
|
||||||
|
WATCH_HTML = STATIC_DIR / "watch.html"
|
||||||
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
||||||
app.mount(
|
app.mount(
|
||||||
"/assets",
|
"/assets",
|
||||||
@@ -356,16 +382,16 @@ async def index() -> FileResponse:
|
|||||||
|
|
||||||
@app.get("/watch/{token}")
|
@app.get("/watch/{token}")
|
||||||
async def watch_page(token: str):
|
async def watch_page(token: str):
|
||||||
"""Serve the SPA for a public share link, but with per-video Open Graph tags injected so the
|
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
|
||||||
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
|
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
|
||||||
ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall
|
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
|
||||||
back to the generic card."""
|
generic card."""
|
||||||
from app.downloads import og
|
from app.downloads import og
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rendered = og.render_watch_html(token, INDEX_HTML, db)
|
rendered = og.render_watch_html(token, WATCH_HTML, db)
|
||||||
if rendered is None:
|
if rendered is None:
|
||||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||||
return HTMLResponse(rendered, headers=INDEX_HEADERS)
|
return HTMLResponse(rendered, headers=INDEX_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@@ -383,4 +409,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
|
|||||||
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
|
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
|
||||||
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
|
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
|
||||||
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
|
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
|
||||||
|
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
|
||||||
|
# player's: WatchPage reads the token off the path and says when a link is gone.
|
||||||
|
if full_path.startswith("watch/"):
|
||||||
|
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||||
|
|||||||
+100
-1190
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ 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
|
||||||
|
|
||||||
|
from app.userscope import owned_by
|
||||||
from app.models import Notification
|
from app.models import Notification
|
||||||
|
|
||||||
# Keep at most this many READ notifications per user; older read ones are pruned. Unread
|
# Keep at most this many READ notifications per user; older read ones are pruned. Unread
|
||||||
@@ -48,11 +49,13 @@ def create_notification(
|
|||||||
|
|
||||||
def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
||||||
"""Delete a user's oldest READ notifications beyond `cap`. Returns the number removed.
|
"""Delete a user's oldest READ notifications beyond `cap`. Returns the number removed.
|
||||||
Idempotent and cheap; safe to call after marking things read."""
|
Idempotent and cheap; safe to call after marking things read. Flushes first, so a notice the
|
||||||
|
caller has just marked read counts; the caller commits."""
|
||||||
|
db.flush()
|
||||||
ids = (
|
ids = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(Notification.id)
|
select(Notification.id)
|
||||||
.where(Notification.user_id == user_id, Notification.read.is_(True))
|
.where(owned_by(Notification.user_id, user_id), Notification.read.is_(True))
|
||||||
.order_by(Notification.created_at.desc())
|
.order_by(Notification.created_at.desc())
|
||||||
.offset(cap)
|
.offset(cap)
|
||||||
)
|
)
|
||||||
@@ -63,5 +66,4 @@ def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
|
|||||||
return 0
|
return 0
|
||||||
# One bulk DELETE rather than a get+delete per row (the ids are already in hand).
|
# One bulk DELETE rather than a get+delete per row (the ids are already in hand).
|
||||||
db.execute(delete(Notification).where(Notification.id.in_(ids)))
|
db.execute(delete(Notification).where(Notification.id.in_(ids)))
|
||||||
db.commit()
|
|
||||||
return len(ids)
|
return len(ids)
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
|
CACHE_DIR = Path(settings.files_root) / ".plex-img-cache"
|
||||||
|
|||||||
@@ -3,7 +3,22 @@ playlists, and detail views render. Extracted from routes/plex/browse.py so the
|
|||||||
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||||
router. Pure functions — no DB, no request state."""
|
router. Pure functions — no DB, no request state."""
|
||||||
|
|
||||||
from app.models import PlexItem, PlexShow, PlexState
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||||
|
|
||||||
|
|
||||||
|
class ShowState(NamedTuple):
|
||||||
|
"""A show's watch state rolled up from its episodes, as its card carries it. `resumable`: an unwatched
|
||||||
|
episode has a resume point, the one the card's Continue resumes and its Restart replays from 0. A
|
||||||
|
show part watched with its next episode untouched has none: Continue and Restart would both play
|
||||||
|
that episode from its start, so the card offers Play alone."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
resumable: bool
|
||||||
|
|
||||||
|
|
||||||
|
SHOW_NEW = ShowState("new", False)
|
||||||
|
|
||||||
|
|
||||||
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
||||||
@@ -25,7 +40,7 @@ def norm_lang(lang: str | None) -> str:
|
|||||||
return "hu" if lang == "hu" else "en"
|
return "hu" if lang == "hu" else "en"
|
||||||
|
|
||||||
|
|
||||||
# Every card speaks the reader's language (E30): `lang="hu"` serves the HU overlay twin's title and
|
# Every card speaks the reader's language: `lang="hu"` serves the HU overlay twin's title and
|
||||||
# summary, falling back to EN where a row has none — the same rule as browse.unified_library's
|
# summary, falling back to EN where a row has none — the same rule as browse.unified_library's
|
||||||
# `title_col`/`summary_col`. These rails once served English to a Hungarian reader.
|
# `title_col`/`summary_col`. These rails once served English to a Hungarian reader.
|
||||||
def display_title(row: PlexItem | PlexShow, lang: str) -> str:
|
def display_title(row: PlexItem | PlexShow, lang: str) -> str:
|
||||||
@@ -54,7 +69,7 @@ def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
def show_card(sh: PlexShow, state: ShowState = SHOW_NEW, *, lang: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
"type": "show",
|
"type": "show",
|
||||||
@@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
|||||||
"art": _art_url(sh.rating_key, sh.art_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": state.status,
|
||||||
|
"resumable": state.resumable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-12
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Server-side ONLY: uses the admin ``X-Plex-Token`` from sysconfig. Playback never goes through
|
Server-side ONLY: uses the admin ``X-Plex-Token`` from sysconfig. Playback never goes through
|
||||||
this client — Siftlode plays the LOCAL physical file (see app.plex.paths). Plex is used purely for
|
this client — Siftlode plays the LOCAL physical file (see app.plex.paths). Plex is used purely for
|
||||||
metadata, image proxying, and (P5) optional watch-state write-back. Requests JSON via the
|
metadata, image proxying, and optional watch-state write-back. Requests JSON via the
|
||||||
``Accept: application/json`` header (Plex serves XML by default).
|
``Accept: application/json`` header (Plex serves XML by default).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class PlexClient:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise PlexError("Plex returned a non-JSON response") from e
|
raise PlexError("Plex returned a non-JSON response") from e
|
||||||
|
|
||||||
# --- Connectivity + sections (P0) ---
|
# --- Connectivity + sections ---
|
||||||
def server_info(self) -> dict:
|
def server_info(self) -> dict:
|
||||||
"""Root identity (friendlyName / version) — used by the admin 'Test connection'."""
|
"""Root identity (friendlyName / version) — used by the admin 'Test connection'."""
|
||||||
return self._get("/")
|
return self._get("/")
|
||||||
@@ -75,7 +75,7 @@ class PlexClient:
|
|||||||
mc = self._get("/library/sections")
|
mc = self._get("/library/sections")
|
||||||
return mc.get("Directory", []) or []
|
return mc.get("Directory", []) or []
|
||||||
|
|
||||||
# --- Catalog mirror (P1) ---
|
# --- Catalog mirror ---
|
||||||
def section_items(
|
def section_items(
|
||||||
self, key: str, item_type: int, start: int = 0, size: int = 200
|
self, key: str, item_type: int, start: int = 0, size: int = 200
|
||||||
) -> tuple[list[dict], int | None]:
|
) -> tuple[list[dict], int | None]:
|
||||||
@@ -88,14 +88,14 @@ class PlexClient:
|
|||||||
"type": item_type,
|
"type": item_type,
|
||||||
# includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap
|
# includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap
|
||||||
# listing, so the sync can capture the canonical guid without a per-item rich fetch —
|
# listing, so the sync can capture the canonical guid without a per-item rich fetch —
|
||||||
# the pairing key for the HU overlay (E5 S5).
|
# the pairing key for the HU overlay.
|
||||||
"includeGuids": 1,
|
"includeGuids": 1,
|
||||||
"X-Plex-Container-Start": start,
|
"X-Plex-Container-Start": start,
|
||||||
"X-Plex-Container-Size": size,
|
"X-Plex-Container-Size": size,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# `totalSize` only: `size` is THIS page's count, and read as the total it made a single
|
# `totalSize` only: `size` is THIS page's count, and read as the total it made a single
|
||||||
# page look like the whole section — "complete" to a prune or a reconcile (E32 review).
|
# page look like the whole section — "complete" to a prune or a reconcile.
|
||||||
total = mc.get("totalSize")
|
total = mc.get("totalSize")
|
||||||
return mc.get("Metadata", []) or [], None if total is None else int(total)
|
return mc.get("Metadata", []) or [], None if total is None else int(total)
|
||||||
|
|
||||||
@@ -137,12 +137,12 @@ class PlexClient:
|
|||||||
mc = self._get(f"/library/collections/{rating_key}/children")
|
mc = self._get(f"/library/collections/{rating_key}/children")
|
||||||
return mc.get("Metadata", []) or []
|
return mc.get("Metadata", []) or []
|
||||||
|
|
||||||
# --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) ---
|
# --- Collection editing (write-back to Plex; all shared/global, admin-gated in the routes) ---
|
||||||
@property
|
@property
|
||||||
def machine_id(self) -> str:
|
def machine_id(self) -> str:
|
||||||
"""Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached.
|
"""Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached.
|
||||||
Without one the uri would read `server:///…`, a malformed write Plex answers in its own way;
|
Without one the uri would read `server:///…`, a malformed write Plex answers in its own way;
|
||||||
it is refused here, by name (E32 S2)."""
|
it is refused here, by name."""
|
||||||
if not getattr(self, "_machine_id", None):
|
if not getattr(self, "_machine_id", None):
|
||||||
mid = self._get("/identity").get("machineIdentifier")
|
mid = self._get("/identity").get("machineIdentifier")
|
||||||
if not mid:
|
if not mid:
|
||||||
@@ -167,12 +167,16 @@ class PlexClient:
|
|||||||
keys = ",".join(str(k) for k in rating_keys)
|
keys = ",".join(str(k) for k in rating_keys)
|
||||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
||||||
|
|
||||||
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
|
def create_collection(
|
||||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
self, section_key: str, title: str, item_rating_key: str, item_type: int = 1
|
||||||
|
) -> dict:
|
||||||
|
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).
|
||||||
|
`item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection
|
||||||
|
made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after."""
|
||||||
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||||
mc = self._write(
|
mc = self._write(
|
||||||
"POST",
|
"POST",
|
||||||
f"/library/collections?type=1&smart=0§ionId={section_key}"
|
f"/library/collections?type={item_type}&smart=0§ionId={section_key}"
|
||||||
f"&title={quote(title, safe='')}&uri={uri}",
|
f"&title={quote(title, safe='')}&uri={uri}",
|
||||||
)
|
)
|
||||||
items = mc.get("Metadata", []) or []
|
items = mc.get("Metadata", []) or []
|
||||||
@@ -206,7 +210,7 @@ class PlexClient:
|
|||||||
items = mc.get("Metadata", []) or []
|
items = mc.get("Metadata", []) or []
|
||||||
return items[0] if items else None
|
return items[0] if items else None
|
||||||
|
|
||||||
# --- Watch-state write-back (P5 Phase B — Siftlode→Plex push; acts on the token's account) ---
|
# --- Watch-state write-back (Siftlode→Plex push; acts on the token's account) ---
|
||||||
_LIBRARY_IDENTIFIER = "com.plexapp.plugins.library"
|
_LIBRARY_IDENTIFIER = "com.plexapp.plugins.library"
|
||||||
|
|
||||||
def scrobble(self, rating_key: str) -> None:
|
def scrobble(self, rating_key: str) -> None:
|
||||||
@@ -243,7 +247,7 @@ class PlexClient:
|
|||||||
f"&state={state}&time={int(time_ms)}&duration={int(duration_ms)}",
|
f"&state={state}&time={int(time_ms)}&duration={int(duration_ms)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Watch-state read for incremental sync (P5 Phase C) ---
|
# --- Watch-state read for incremental sync ---
|
||||||
def accounts(self) -> list[dict]:
|
def accounts(self) -> list[dict]:
|
||||||
"""Server accounts (the owner + any managed/home users): id, name. Used to resolve the
|
"""Server accounts (the owner + any managed/home users): id, name. Used to resolve the
|
||||||
owner's accountID so the shared watch-history feed can be filtered to just their views."""
|
owner's accountID so the shared watch-history feed can be filtered to just their views."""
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""What a collection is to the app: where it comes from, whether it may be edited, its card, its
|
||||||
|
effective content rating, and how its members order — shared by the collection pages, the info
|
||||||
|
page's collection strips and the admin editor."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
from app.plex.models import PlexCollection
|
||||||
|
|
||||||
|
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||||
|
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||||
|
_CERT_RANK = {
|
||||||
|
"G": 1,
|
||||||
|
"TV-Y": 1,
|
||||||
|
"TV-Y7": 1,
|
||||||
|
"TV-G": 1,
|
||||||
|
"PG": 2,
|
||||||
|
"TV-PG": 2,
|
||||||
|
"PG-13": 3,
|
||||||
|
"TV-14": 3,
|
||||||
|
"R": 4,
|
||||||
|
"TV-MA": 4,
|
||||||
|
"NC-17": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collection_cert(certs: list[str | None]) -> str | None:
|
||||||
|
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
|
||||||
|
return max(ranked, key=lambda x: x[1])[0] if ranked else None
|
||||||
|
|
||||||
|
|
||||||
|
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
|
||||||
|
|
||||||
|
|
||||||
|
def collection_source(col: PlexCollection) -> str:
|
||||||
|
first = (col.title or "").strip().split(" ", 1)[0].lower()
|
||||||
|
if first in _STRIP_PROVIDERS:
|
||||||
|
return first
|
||||||
|
if col.smart:
|
||||||
|
return "smart"
|
||||||
|
return "collection"
|
||||||
|
|
||||||
|
|
||||||
|
def collection_editable(c: PlexCollection) -> bool:
|
||||||
|
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
||||||
|
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
||||||
|
return bool(c.editable) and not c.smart and collection_source(c) == "collection"
|
||||||
|
|
||||||
|
|
||||||
|
def collection_card(c: PlexCollection) -> dict:
|
||||||
|
return {
|
||||||
|
"id": c.rating_key,
|
||||||
|
"title": c.title,
|
||||||
|
"summary": c.summary,
|
||||||
|
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
|
||||||
|
"child_count": c.child_count,
|
||||||
|
"smart": c.smart,
|
||||||
|
"source": collection_source(c),
|
||||||
|
"editable": c.editable,
|
||||||
|
"can_edit": collection_editable(
|
||||||
|
c
|
||||||
|
), # whether the edit controls should be offered (admin UI)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Member(NamedTuple):
|
||||||
|
"""One collection member flattened to everything the Sort control can order by. The page's two
|
||||||
|
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
||||||
|
ordering is a pure list sort over these, which is also what makes it unit-testable."""
|
||||||
|
|
||||||
|
kind: str # "movie" | "show"
|
||||||
|
obj: Any # PlexItem | PlexShow, carried through to the card serializer
|
||||||
|
title: str # lower-cased; never NULL (the column is not nullable)
|
||||||
|
year: int | None
|
||||||
|
rating: float | None
|
||||||
|
added: datetime | None
|
||||||
|
release: date | None
|
||||||
|
duration: int | None # movies only — a show has no single runtime
|
||||||
|
last_watched: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
|
||||||
|
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
|
||||||
|
# that silently does nothing here.
|
||||||
|
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
|
||||||
|
"added": lambda m: m.added,
|
||||||
|
"lastwatched": lambda m: m.last_watched,
|
||||||
|
"release": lambda m: m.release,
|
||||||
|
"year": lambda m: m.year,
|
||||||
|
"rating": lambda m: m.rating,
|
||||||
|
"duration": lambda m: m.duration,
|
||||||
|
"title": lambda m: m.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
# What the page ordered by before it had a control, and still its default: a film series wants to be
|
||||||
|
# read in release order, not by whenever Plex happened to ingest it.
|
||||||
|
DEFAULT_MEMBER_SORT = "year"
|
||||||
|
|
||||||
|
|
||||||
|
def sort_members(
|
||||||
|
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
|
||||||
|
) -> list[Member]:
|
||||||
|
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
|
||||||
|
|
||||||
|
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
|
||||||
|
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
|
||||||
|
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
|
||||||
|
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
|
||||||
|
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
|
||||||
|
|
||||||
|
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
|
||||||
|
compare as Python strings, which puts every accented initial after "z"."""
|
||||||
|
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
|
||||||
|
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
|
||||||
|
if key is MEMBER_SORT_KEYS["title"]:
|
||||||
|
key = by_title
|
||||||
|
ordered = sorted(members, key=by_title) # stable secondary, applied first
|
||||||
|
have = [m for m in ordered if key(m) is not None]
|
||||||
|
missing = [m for m in ordered if key(m) is None]
|
||||||
|
have.sort(key=key, reverse=sort_dir != "asc")
|
||||||
|
return have + missing
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
|
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
|
||||||
the detail endpoints serve from the DB row (E5 S5b).
|
the detail endpoints serve from the DB row.
|
||||||
|
|
||||||
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
|
Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background
|
||||||
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
|
rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s
|
||||||
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
|
cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY
|
||||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
fields onto a movie/show row (genres/directors/studio/content_rating already land from the cheap
|
||||||
|
listing at `_apply_facet_fields`)."""
|
||||||
|
|
||||||
from app.plex import people
|
from app.plex import people
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
|
|
||||||
def rich_detail(meta: dict) -> dict:
|
def rich_detail(meta: dict) -> dict:
|
||||||
@@ -42,7 +44,7 @@ def rich_detail(meta: dict) -> dict:
|
|||||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||||
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
||||||
"studio": meta.get("studio"),
|
"studio": meta.get("studio"),
|
||||||
"tagline": meta.get("tagline"),
|
"tagline": plain_text(meta.get("tagline")),
|
||||||
"cast": people.cast_from_meta(meta),
|
"cast": people.cast_from_meta(meta),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
"""Plex Discover's reads: the rail-surface read-model.
|
||||||
|
|
||||||
|
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
|
||||||
|
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
|
||||||
|
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
|
||||||
|
recs the TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
|
||||||
|
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
|
||||||
|
their `art` + rating/summary for free). The rails carry EN metadata (like the other non-grid
|
||||||
|
card paths); the HU overlay coalesces only in the main grid.
|
||||||
|
|
||||||
|
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
|
||||||
|
Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
|
||||||
|
grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
|
||||||
|
the client hides). Genre-language matches the grid; duration is movie-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.collation import sorted_words
|
||||||
|
from app.discover.queries import RAIL_LIMIT
|
||||||
|
from app.models import User
|
||||||
|
from app.plex import rollup
|
||||||
|
from app.plex.cards import SHOW_NEW, display_title, leaf_card, movie_card, show_card
|
||||||
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||||
|
from app.plex.query import (
|
||||||
|
LibraryFilters,
|
||||||
|
apply_meta_filters,
|
||||||
|
apply_show_state_filter,
|
||||||
|
content_libs,
|
||||||
|
genres_col,
|
||||||
|
meta_filter_conds,
|
||||||
|
top_art_keys,
|
||||||
|
)
|
||||||
|
from app.plex.tmdb import resolve_recs
|
||||||
|
from app.userscope import owned_by
|
||||||
|
|
||||||
|
GENRE_TILES = 12
|
||||||
|
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||||
|
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
|
||||||
|
movie_ids, show_ids = content_libs(db)
|
||||||
|
return (
|
||||||
|
movie_ids if scope in ("movie", "both") else [],
|
||||||
|
show_ids if scope in ("show", "both") else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
|
||||||
|
"""Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
|
||||||
|
q = apply_meta_filters(q, PlexItem, p, lang)
|
||||||
|
if f.duration_min is not None:
|
||||||
|
q = q.where(PlexItem.duration_s >= f.duration_min)
|
||||||
|
if f.duration_max is not None:
|
||||||
|
q = q.where(PlexItem.duration_s <= f.duration_max)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> list[dict]:
|
||||||
|
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||||||
|
return [movie_card(it, sts.get(it.id), lang=lang) for it in items]
|
||||||
|
|
||||||
|
|
||||||
|
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||||||
|
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||||
|
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||||
|
|
||||||
|
|
||||||
|
def _recently_added(
|
||||||
|
db: Session,
|
||||||
|
uid: int,
|
||||||
|
movie_ids: list[int],
|
||||||
|
show_ids: list[int],
|
||||||
|
p: dict,
|
||||||
|
lang: str,
|
||||||
|
f: LibraryFilters,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||||||
|
movies = (
|
||||||
|
db.execute(
|
||||||
|
_movie_meta(
|
||||||
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||||
|
),
|
||||||
|
p,
|
||||||
|
lang,
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
.order_by(PlexItem.added_at.desc().nullslast())
|
||||||
|
.limit(RAIL_LIMIT)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if movie_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
shows = (
|
||||||
|
db.execute(
|
||||||
|
apply_meta_filters(
|
||||||
|
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||||
|
)
|
||||||
|
.order_by(PlexShow.added_at.desc().nullslast())
|
||||||
|
.limit(RAIL_LIMIT)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if show_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
|
||||||
|
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — a
|
||||||
|
# review found this one still on the older `(is not None, value)` form, i.e. one rule in two
|
||||||
|
# spellings, with the two comments explaining it differently.
|
||||||
|
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
|
||||||
|
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
|
||||||
|
top = tagged[:RAIL_LIMIT]
|
||||||
|
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
|
||||||
|
# share one), so the merge doesn't depend on object identity staying alive.
|
||||||
|
top_movies = [o for _, kind, o in top if kind == "movie"]
|
||||||
|
top_shows = [o for _, kind, o in top if kind == "show"]
|
||||||
|
mcards = {
|
||||||
|
m.rating_key: c
|
||||||
|
for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang), strict=True)
|
||||||
|
}
|
||||||
|
scards = {
|
||||||
|
s.rating_key: c
|
||||||
|
for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True)
|
||||||
|
}
|
||||||
|
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
|
||||||
|
|
||||||
|
|
||||||
|
def _recently_watched(
|
||||||
|
db: Session,
|
||||||
|
uid: int,
|
||||||
|
movie_ids: list[int],
|
||||||
|
show_ids: list[int],
|
||||||
|
p: dict,
|
||||||
|
lang: str,
|
||||||
|
f: LibraryFilters,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
|
||||||
|
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
|
||||||
|
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
|
||||||
|
rows: list[tuple[datetime | None, dict]] = [] # (watched_at, card)
|
||||||
|
if movie_ids:
|
||||||
|
mq = (
|
||||||
|
select(PlexItem, PlexState)
|
||||||
|
.join(
|
||||||
|
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_ids),
|
||||||
|
PlexState.status == "watched",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for it, st in db.execute(
|
||||||
|
_movie_meta(mq, p, lang, f)
|
||||||
|
.order_by(PlexState.watched_at.desc().nullslast())
|
||||||
|
.limit(RAIL_LIMIT)
|
||||||
|
).all():
|
||||||
|
rows.append((st.watched_at, leaf_card(it, st, lang=lang)))
|
||||||
|
if show_ids:
|
||||||
|
eq = (
|
||||||
|
select(PlexItem, PlexState, PlexShow)
|
||||||
|
.join(
|
||||||
|
PlexState, and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid))
|
||||||
|
)
|
||||||
|
.join(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||||
|
.where(
|
||||||
|
PlexItem.kind == "episode",
|
||||||
|
PlexItem.library_id.in_(show_ids),
|
||||||
|
PlexState.status == "watched",
|
||||||
|
)
|
||||||
|
.order_by(PlexState.watched_at.desc().nullslast())
|
||||||
|
.limit(RAIL_LIMIT)
|
||||||
|
)
|
||||||
|
for ep, st, sh in db.execute(eq).all():
|
||||||
|
card = leaf_card(
|
||||||
|
ep, st, show_title=display_title(sh, lang), show_rk=sh.rating_key, lang=lang
|
||||||
|
)
|
||||||
|
rows.append((st.watched_at, card))
|
||||||
|
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||||
|
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||||
|
|
||||||
|
|
||||||
|
def _continue_watching(
|
||||||
|
db: Session,
|
||||||
|
uid: int,
|
||||||
|
movie_ids: list[int],
|
||||||
|
show_ids: list[int],
|
||||||
|
p: dict,
|
||||||
|
lang: str,
|
||||||
|
f: LibraryFilters,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
||||||
|
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
|
||||||
|
rows: list[tuple[datetime | None, dict]] = [] # (resumed_at, card)
|
||||||
|
|
||||||
|
if movie_ids:
|
||||||
|
mq = (
|
||||||
|
select(PlexItem, PlexState)
|
||||||
|
.join(
|
||||||
|
PlexState,
|
||||||
|
and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid)),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_ids),
|
||||||
|
PlexState.position_seconds > 0,
|
||||||
|
# A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) —
|
||||||
|
# `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE.
|
||||||
|
or_(
|
||||||
|
PlexState.status.is_(None),
|
||||||
|
PlexState.status.notin_(["watched", "hidden"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mrows = db.execute(
|
||||||
|
_movie_meta(mq, p, lang, f)
|
||||||
|
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||||
|
.limit(RAIL_LIMIT)
|
||||||
|
).all()
|
||||||
|
for it, st in mrows:
|
||||||
|
rows.append((st.progress_updated_at, movie_card(it, st, lang=lang)))
|
||||||
|
|
||||||
|
if show_ids:
|
||||||
|
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
|
||||||
|
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
||||||
|
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||||
|
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||||
|
sq = apply_show_state_filter(
|
||||||
|
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||||
|
prog, prog.c.show_id == PlexShow.id
|
||||||
|
),
|
||||||
|
prog.c.total,
|
||||||
|
prog.c.watched,
|
||||||
|
prog.c.inprog,
|
||||||
|
"in_progress",
|
||||||
|
).where(PlexShow.library_id.in_(show_ids))
|
||||||
|
srows = db.execute(
|
||||||
|
apply_meta_filters(sq, PlexShow, p, lang)
|
||||||
|
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||||
|
.limit(RAIL_LIMIT)
|
||||||
|
).all()
|
||||||
|
for sh, resumed_at, total, watched, inprog in srows:
|
||||||
|
state = rollup.show_state(total, watched, inprog)
|
||||||
|
rows.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||||
|
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_tiles(
|
||||||
|
db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
||||||
|
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
|
||||||
|
The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
gcol = genres_col(model, lang)
|
||||||
|
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
||||||
|
model.library_id.in_(ids),
|
||||||
|
func.jsonb_typeof(gcol) == "array",
|
||||||
|
*meta_filter_conds(model, p, lang),
|
||||||
|
)
|
||||||
|
if model is PlexItem:
|
||||||
|
base = base.where(model.kind == "movie")
|
||||||
|
expanded = base.subquery()
|
||||||
|
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||||||
|
counts[g] = counts.get(g, 0) + c
|
||||||
|
alpha = {w: i for i, w in enumerate(sorted_words(db, list(counts), lang))}
|
||||||
|
top = sorted(counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))[:GENRE_TILES]
|
||||||
|
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
|
||||||
|
art = top_art_keys(
|
||||||
|
db,
|
||||||
|
PlexItem,
|
||||||
|
movie_ids,
|
||||||
|
genres_col(PlexItem, lang),
|
||||||
|
[g for g, _ in top],
|
||||||
|
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
|
||||||
|
)
|
||||||
|
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
|
||||||
|
|
||||||
|
|
||||||
|
def _because_you_watched(
|
||||||
|
db: Session,
|
||||||
|
uid: int,
|
||||||
|
movie_ids: list[int],
|
||||||
|
show_ids: list[int],
|
||||||
|
p: dict,
|
||||||
|
lang: str,
|
||||||
|
f: LibraryFilters,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
||||||
|
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
|
||||||
|
None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
|
||||||
|
The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
|
||||||
|
if not movie_ids:
|
||||||
|
return None
|
||||||
|
anchor = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem)
|
||||||
|
.join(
|
||||||
|
PlexState,
|
||||||
|
and_(PlexState.item_id == PlexItem.id, owned_by(PlexState.user_id, uid)),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_ids),
|
||||||
|
PlexState.status == "watched",
|
||||||
|
PlexItem.recommendations.isnot(None),
|
||||||
|
)
|
||||||
|
.order_by(PlexState.watched_at.desc().nullslast())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if anchor is None:
|
||||||
|
return None
|
||||||
|
resolved = resolve_recs(db, anchor.recommendations, lang=lang)
|
||||||
|
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
|
||||||
|
rks = [
|
||||||
|
r["rating_key"]
|
||||||
|
for r in resolved
|
||||||
|
if r.get("in_library") and r.get("rating_key") != anchor.rating_key
|
||||||
|
]
|
||||||
|
if not rks:
|
||||||
|
return None
|
||||||
|
movies = {
|
||||||
|
m.rating_key: m
|
||||||
|
for m in db.execute(
|
||||||
|
_movie_meta(
|
||||||
|
select(PlexItem).where(PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)),
|
||||||
|
p,
|
||||||
|
lang,
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
shows = {
|
||||||
|
s.rating_key: s
|
||||||
|
for s in db.execute(
|
||||||
|
apply_meta_filters(
|
||||||
|
select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||||||
|
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
||||||
|
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
||||||
|
mcards = {
|
||||||
|
m.rating_key: c
|
||||||
|
for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang), strict=True)
|
||||||
|
}
|
||||||
|
scards = {
|
||||||
|
s.rating_key: c
|
||||||
|
for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True)
|
||||||
|
}
|
||||||
|
items: list[dict] = []
|
||||||
|
for rk in rks:
|
||||||
|
if rk in mcards:
|
||||||
|
items.append(mcards[rk])
|
||||||
|
elif rk in scards:
|
||||||
|
items.append(scards[rk])
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"anchor": {"id": anchor.rating_key, "title": display_title(anchor, lang)},
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_page(db: Session, user: User, scope: str, lang: str, f: LibraryFilters) -> dict:
|
||||||
|
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||||||
|
sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
|
||||||
|
is null when there's no anchor. `lang` is already normalised."""
|
||||||
|
p = f.as_dict()
|
||||||
|
movie_ids, show_ids = _scope_libs(db, scope)
|
||||||
|
return {
|
||||||
|
"scope": scope,
|
||||||
|
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||||
|
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||||
|
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||||
|
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||||||
|
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||||||
|
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
|
||||||
|
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
|
||||||
|
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
|
||||||
|
fresh = (
|
||||||
|
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if exclude
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if len(fresh) == per:
|
||||||
|
return list(fresh)
|
||||||
|
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
|
||||||
|
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
|
||||||
|
return [*fresh, *rest.all()]
|
||||||
|
|
||||||
|
|
||||||
|
def _kept(
|
||||||
|
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
|
||||||
|
stays in it, marked watched, and one gone from the scope's libraries drops out."""
|
||||||
|
movies = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_ids),
|
||||||
|
PlexItem.rating_key.in_(keep),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if movie_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
shows = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if show_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
cards = {
|
||||||
|
c["id"]: c
|
||||||
|
for c in [
|
||||||
|
*_movie_cards(db, uid, list(movies), lang),
|
||||||
|
*_show_cards(db, uid, list(shows), lang),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [cards[k] for k in keep if k in cards]
|
||||||
|
|
||||||
|
|
||||||
|
def surprise(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
scope: str,
|
||||||
|
lang: str,
|
||||||
|
limit: int,
|
||||||
|
exclude: list[str],
|
||||||
|
keep: list[str],
|
||||||
|
f: LibraryFilters,
|
||||||
|
) -> dict:
|
||||||
|
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
|
||||||
|
the active filters. Only titles the user has NOT seen — a watched movie / a fully-watched show is
|
||||||
|
excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
|
||||||
|
SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
|
||||||
|
one type."""
|
||||||
|
p = f.as_dict()
|
||||||
|
movie_ids, show_ids = _scope_libs(db, scope)
|
||||||
|
if keep:
|
||||||
|
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
|
||||||
|
per = min(limit, SURPRISE_PER_KIND)
|
||||||
|
movies = []
|
||||||
|
if movie_ids:
|
||||||
|
# Exclude movies this user has watched.
|
||||||
|
watched_movie = (
|
||||||
|
select(PlexState.id)
|
||||||
|
.where(
|
||||||
|
PlexState.item_id == PlexItem.id,
|
||||||
|
owned_by(PlexState.user_id, user),
|
||||||
|
PlexState.status == "watched",
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
movies = _draw(
|
||||||
|
db,
|
||||||
|
_movie_meta(
|
||||||
|
select(PlexItem).where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_ids),
|
||||||
|
~watched_movie,
|
||||||
|
),
|
||||||
|
p,
|
||||||
|
lang,
|
||||||
|
f,
|
||||||
|
),
|
||||||
|
PlexItem.rating_key,
|
||||||
|
per,
|
||||||
|
exclude,
|
||||||
|
)
|
||||||
|
shows = []
|
||||||
|
if show_ids:
|
||||||
|
# Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
|
||||||
|
# as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
|
||||||
|
prog = rollup.agg_subquery(db, user.id).subquery()
|
||||||
|
not_fully_watched = or_(
|
||||||
|
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||||
|
)
|
||||||
|
shows = _draw(
|
||||||
|
db,
|
||||||
|
apply_meta_filters(
|
||||||
|
select(PlexShow)
|
||||||
|
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||||
|
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||||
|
PlexShow,
|
||||||
|
p,
|
||||||
|
lang,
|
||||||
|
),
|
||||||
|
PlexShow.rating_key,
|
||||||
|
per,
|
||||||
|
exclude,
|
||||||
|
)
|
||||||
|
mcards = _movie_cards(db, user.id, movies, lang)
|
||||||
|
scards = _show_cards(db, user.id, shows, lang)
|
||||||
|
# Interleave movie/show cards, then trim to `limit`.
|
||||||
|
items: list[dict] = []
|
||||||
|
for i in range(max(len(mcards), len(scards))):
|
||||||
|
if i < len(mcards):
|
||||||
|
items.append(mcards[i])
|
||||||
|
if i < len(scards):
|
||||||
|
items.append(scards[i])
|
||||||
|
return {"scope": scope, "items": items[:limit]}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""One Hungarian word per Plex genre (E30).
|
"""One Hungarian word per Plex genre.
|
||||||
|
|
||||||
Plex's Hungarian agent returns a MIXED vocabulary: some genres come back translated ("Dráma",
|
Plex's Hungarian agent returns a MIXED vocabulary: some genres come back translated ("Dráma",
|
||||||
"Vígjáték"), others stay English ("Action", "Adventure", "Biography"), and a title with no HU twin has
|
"Vígjáték"), others stay English ("Action", "Adventure", "Biography"), and a title with no HU twin has
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi import Response
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
from app.plex.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||||
from app.plex.cache import CACHE_DIR
|
from app.plex.cache import CACHE_DIR
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ _CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
|||||||
# Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail
|
# Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail
|
||||||
# page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid
|
# page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid
|
||||||
# poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR
|
# poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR
|
||||||
# (E31 S4e: cards fetched the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser
|
# (cards used to fetch the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser
|
||||||
# picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose
|
# picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose
|
||||||
# widths plexImage.contract.test holds to these).
|
# widths plexImage.contract.test holds to these).
|
||||||
SIZES = {
|
SIZES = {
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
"""The Plex browse grid's reads: the unified movies + shows feed, its search, and its facets.
|
||||||
|
|
||||||
|
A user's watch state is joined through `userscope`; the feed and its facets share every filter
|
||||||
|
(`plex.query`), so a facet count is the size of what the grid would show.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, and_, cast, func, literal, null, or_, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.collation import collated, sorted_words
|
||||||
|
from app.models import User
|
||||||
|
from app.plex import rollup
|
||||||
|
from app.plex.cards import norm_lang
|
||||||
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
||||||
|
from app.plex.query import (
|
||||||
|
FIELD_COLUMNS,
|
||||||
|
PLAYBACK_CLASSES,
|
||||||
|
TS_CONFIG,
|
||||||
|
LibraryFilters,
|
||||||
|
apply_meta_filters,
|
||||||
|
apply_movie_state_filter,
|
||||||
|
apply_show_state_filter,
|
||||||
|
content_libs,
|
||||||
|
field_recheck,
|
||||||
|
genres_col,
|
||||||
|
meta_filter_conds,
|
||||||
|
movie_status_case,
|
||||||
|
playable_conds,
|
||||||
|
playback_class_expr,
|
||||||
|
show_status_case,
|
||||||
|
split_csv,
|
||||||
|
summary_col,
|
||||||
|
title_col,
|
||||||
|
vector_col,
|
||||||
|
vector_match,
|
||||||
|
)
|
||||||
|
from app.userscope import owned_by
|
||||||
|
from app.utils import to_tsquery_str
|
||||||
|
|
||||||
|
|
||||||
|
def sort_column(u, sort: str, lang: str = "en"):
|
||||||
|
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
|
||||||
|
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
|
||||||
|
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
|
||||||
|
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
|
||||||
|
in the reader's alphabet (`collation`)."""
|
||||||
|
return {
|
||||||
|
"added": u.c.added_at,
|
||||||
|
"title": collated(u.c.title, lang),
|
||||||
|
"year": u.c.year,
|
||||||
|
"rating": u.c.rating,
|
||||||
|
"duration": u.c.duration_s,
|
||||||
|
"release": u.c.rel,
|
||||||
|
"lastwatched": u.c.last_watched,
|
||||||
|
}.get(sort, u.c.added_at)
|
||||||
|
|
||||||
|
|
||||||
|
def library_page(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
q: str | None,
|
||||||
|
field: str,
|
||||||
|
sort: str,
|
||||||
|
sort_dir: str,
|
||||||
|
show: str,
|
||||||
|
lang: str,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
f: LibraryFilters,
|
||||||
|
) -> tuple[int, list, list]:
|
||||||
|
"""One page of the unified cross-library browse: (total, rows, episodes). `rows` mixes movies
|
||||||
|
and shows (`kind` says which), scoped to movie|show|both, with the shared filters/search/watch
|
||||||
|
state (a show's state is the aggregate of its episodes). On a search that also matches episodes
|
||||||
|
(and shows are in scope), `episodes` holds the matching ones as (item, state, show) — the
|
||||||
|
grouped 'Episodes' section. `lang` is already normalised."""
|
||||||
|
p = f.as_dict()
|
||||||
|
movie_lib_ids, show_lib_ids = content_libs(db)
|
||||||
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||||
|
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||||
|
|
||||||
|
ts = to_tsquery_str(q) if q else None
|
||||||
|
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None
|
||||||
|
if field not in FIELD_COLUMNS and field != "all":
|
||||||
|
field = "all"
|
||||||
|
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
|
||||||
|
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
|
||||||
|
grid_field = field != "episode"
|
||||||
|
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None
|
||||||
|
|
||||||
|
selects = []
|
||||||
|
if want_movies and grid_field:
|
||||||
|
st = aliased(PlexState)
|
||||||
|
m_status = movie_status_case(st)
|
||||||
|
mq = (
|
||||||
|
select(
|
||||||
|
PlexItem.rating_key.label("rk"),
|
||||||
|
literal("movie").label("kind"),
|
||||||
|
title_col(PlexItem, lang).label("title"),
|
||||||
|
PlexItem.year.label("year"),
|
||||||
|
PlexItem.rating.label("rating"),
|
||||||
|
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
|
||||||
|
func.left(summary_col(PlexItem, lang), 300).label("summary"),
|
||||||
|
PlexItem.added_at.label("added_at"),
|
||||||
|
PlexItem.originally_available_at.label("rel"),
|
||||||
|
PlexItem.duration_s.label("duration_s"),
|
||||||
|
PlexItem.playable.label("playable"),
|
||||||
|
PlexItem.transcode_hw.label("transcode_hw"),
|
||||||
|
cast(null(), Integer).label("season_count"),
|
||||||
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
|
m_status.label("status"),
|
||||||
|
literal(False).label("resumable"),
|
||||||
|
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||||
|
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||||
|
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||||
|
(
|
||||||
|
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
||||||
|
if tsq is not None
|
||||||
|
else literal(0.0)
|
||||||
|
).label("rank"),
|
||||||
|
)
|
||||||
|
.outerjoin(st, and_(st.item_id == PlexItem.id, owned_by(st.user_id, user)))
|
||||||
|
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
|
||||||
|
)
|
||||||
|
mq = apply_movie_state_filter(mq, st, show)
|
||||||
|
mq = apply_meta_filters(mq, PlexItem, p, lang)
|
||||||
|
if f.duration_min is not None:
|
||||||
|
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||||
|
if f.duration_max is not None:
|
||||||
|
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||||
|
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
||||||
|
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
||||||
|
if tsq is not None:
|
||||||
|
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
||||||
|
if recheck is not None and (narrow := recheck(PlexItem, field, tsq, lang)) is not None:
|
||||||
|
mq = mq.where(narrow)
|
||||||
|
selects.append(mq)
|
||||||
|
if want_shows and grid_field:
|
||||||
|
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
|
||||||
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
|
s_status = show_status_case(tot, wat, inp)
|
||||||
|
sq = (
|
||||||
|
select(
|
||||||
|
PlexShow.rating_key.label("rk"),
|
||||||
|
literal("show").label("kind"),
|
||||||
|
title_col(PlexShow, lang).label("title"),
|
||||||
|
PlexShow.year.label("year"),
|
||||||
|
PlexShow.rating.label("rating"),
|
||||||
|
func.left(summary_col(PlexShow, lang), 300).label("summary"),
|
||||||
|
PlexShow.added_at.label("added_at"),
|
||||||
|
PlexShow.originally_available_at.label("rel"),
|
||||||
|
cast(null(), Integer).label("duration_s"),
|
||||||
|
cast(null(), String).label("playable"),
|
||||||
|
cast(null(), Boolean).label("transcode_hw"),
|
||||||
|
PlexShow.child_count.label("season_count"),
|
||||||
|
literal(0).label("position_seconds"),
|
||||||
|
s_status.label("status"),
|
||||||
|
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
||||||
|
(inp > 0).label("resumable"),
|
||||||
|
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||||
|
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||||
|
agg.c.resumed_at.label("last_watched"),
|
||||||
|
(
|
||||||
|
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
||||||
|
if tsq is not None
|
||||||
|
else literal(0.0)
|
||||||
|
).label("rank"),
|
||||||
|
)
|
||||||
|
.outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||||
|
.where(PlexShow.library_id.in_(show_lib_ids))
|
||||||
|
)
|
||||||
|
sq = apply_show_state_filter(sq, tot, wat, inp, show)
|
||||||
|
sq = apply_meta_filters(sq, PlexShow, p, lang)
|
||||||
|
if tsq is not None:
|
||||||
|
sq = sq.where(vector_match(PlexShow, lang, tsq))
|
||||||
|
if recheck is not None and (narrow := recheck(PlexShow, field, tsq, lang)) is not None:
|
||||||
|
sq = sq.where(narrow)
|
||||||
|
selects.append(sq)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
rows: list = []
|
||||||
|
if selects:
|
||||||
|
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
||||||
|
u = union.subquery()
|
||||||
|
total = db.scalar(select(func.count()).select_from(u)) or 0
|
||||||
|
|
||||||
|
if tsq is not None:
|
||||||
|
order = [u.c.rank.desc()]
|
||||||
|
else:
|
||||||
|
col = sort_column(u, sort, lang)
|
||||||
|
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||||
|
order.append(u.c.rk.desc())
|
||||||
|
rows = list(db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all())
|
||||||
|
|
||||||
|
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
|
||||||
|
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
|
||||||
|
# target the movie/show grid, so they suppress episodes.
|
||||||
|
eps: list = []
|
||||||
|
if tsq is not None and want_shows and field in ("all", "episode"):
|
||||||
|
ep_st = aliased(PlexState)
|
||||||
|
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
|
||||||
|
# still by its English one, like `vector_match` does for the grid.
|
||||||
|
if field == "episode":
|
||||||
|
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
|
||||||
|
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
|
||||||
|
else:
|
||||||
|
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
|
||||||
|
hit = ep_vec.op("@@")(tsq)
|
||||||
|
if lang == "hu":
|
||||||
|
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
|
||||||
|
ep_vec = ep_vec_hu
|
||||||
|
eps = list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem, ep_st, PlexShow)
|
||||||
|
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, owned_by(ep_st.user_id, user)))
|
||||||
|
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||||
|
.where(
|
||||||
|
PlexItem.kind == "episode",
|
||||||
|
PlexItem.library_id.in_(show_lib_ids),
|
||||||
|
hit,
|
||||||
|
)
|
||||||
|
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
||||||
|
.limit(24)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return total, rows, eps
|
||||||
|
|
||||||
|
|
||||||
|
def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: LibraryFilters) -> dict:
|
||||||
|
"""Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
|
||||||
|
filters (faceted search): each group's values/bounds are computed with all the OTHER active
|
||||||
|
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
|
||||||
|
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
|
||||||
|
movie-only. (Search text is not factored in — facets track the sidebar filters.) `lang` is
|
||||||
|
normalised here, after the empty answer."""
|
||||||
|
empty = {
|
||||||
|
"genres": [],
|
||||||
|
"content_ratings": [],
|
||||||
|
"studios": [],
|
||||||
|
"playable": [],
|
||||||
|
"year_min": None,
|
||||||
|
"year_max": None,
|
||||||
|
"rating_max": None,
|
||||||
|
"duration_min": None,
|
||||||
|
"duration_max": None,
|
||||||
|
}
|
||||||
|
all_movie_ids, all_show_ids = content_libs(db)
|
||||||
|
movie_ids = all_movie_ids if scope in ("movie", "both") else []
|
||||||
|
show_ids = all_show_ids if scope in ("show", "both") else []
|
||||||
|
if not movie_ids and not show_ids:
|
||||||
|
return empty
|
||||||
|
lang = norm_lang(lang)
|
||||||
|
p = f.as_dict()
|
||||||
|
|
||||||
|
uid = user.id
|
||||||
|
|
||||||
|
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
|
||||||
|
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
|
||||||
|
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
|
||||||
|
# The same filter on the same rows, so the counts do not change
|
||||||
|
# (test_plex_facets_grid_parity).
|
||||||
|
shows_in_scope: list[int] | None = None
|
||||||
|
if show_ids and show in ("watched", "in_progress", "unwatched"):
|
||||||
|
agg = rollup.agg_subquery(db, uid).subquery()
|
||||||
|
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||||
|
in_scope = apply_show_state_filter(
|
||||||
|
in_scope,
|
||||||
|
func.coalesce(agg.c.total, 0),
|
||||||
|
func.coalesce(agg.c.watched, 0),
|
||||||
|
func.coalesce(agg.c.inprog, 0),
|
||||||
|
show,
|
||||||
|
)
|
||||||
|
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
|
||||||
|
|
||||||
|
def wsq(query, model, ids, pp, kind_movie, with_dur):
|
||||||
|
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
|
||||||
|
facet aggregation query, so the facets reflect exactly the visible result set."""
|
||||||
|
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
|
||||||
|
if kind_movie:
|
||||||
|
query = query.where(model.kind == "movie")
|
||||||
|
if with_dur and pp.get("duration_min") is not None:
|
||||||
|
query = query.where(model.duration_s >= pp["duration_min"])
|
||||||
|
if with_dur and pp.get("duration_max") is not None:
|
||||||
|
query = query.where(model.duration_s <= pp["duration_max"])
|
||||||
|
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
||||||
|
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
||||||
|
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
||||||
|
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
||||||
|
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||||
|
st = aliased(PlexState)
|
||||||
|
query = query.outerjoin(st, and_(st.item_id == model.id, owned_by(st.user_id, uid)))
|
||||||
|
query = apply_movie_state_filter(query, st, show)
|
||||||
|
elif shows_in_scope is not None:
|
||||||
|
query = query.where(model.id.in_(shows_in_scope))
|
||||||
|
return query
|
||||||
|
|
||||||
|
genre_counts: dict[str, int] = {}
|
||||||
|
cr_counts: dict[str, int] = {}
|
||||||
|
studio_counts: dict[str, int] = {}
|
||||||
|
playable_counts: dict[str, int] = {}
|
||||||
|
years: list[int] = []
|
||||||
|
ratings: list[float] = []
|
||||||
|
durs: list[int] = []
|
||||||
|
|
||||||
|
def per_table(model, ids, kind_movie: bool) -> None:
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
|
||||||
|
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
|
||||||
|
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
|
||||||
|
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
|
||||||
|
# every matching title carries them, so they stay togglable.)
|
||||||
|
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
|
||||||
|
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
|
||||||
|
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
|
||||||
|
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
|
||||||
|
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
|
||||||
|
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
|
||||||
|
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
|
||||||
|
gcol = genres_col(model, lang)
|
||||||
|
expanded = (
|
||||||
|
wsq(
|
||||||
|
select(func.jsonb_array_elements_text(gcol).label("g")),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
genre_pp,
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
.where(func.jsonb_typeof(gcol) == "array")
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all():
|
||||||
|
genre_counts[g] = genre_counts.get(g, 0) + c
|
||||||
|
# Content ratings — exclude its own.
|
||||||
|
crq = (
|
||||||
|
wsq(
|
||||||
|
select(model.content_rating, func.count()),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "content_ratings": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
.where(model.content_rating.isnot(None))
|
||||||
|
.group_by(model.content_rating)
|
||||||
|
)
|
||||||
|
for cr, c in db.execute(crq).all():
|
||||||
|
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
||||||
|
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
|
||||||
|
stq = (
|
||||||
|
wsq(
|
||||||
|
select(model.studio, func.count()),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "studios": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
.where(model.studio.isnot(None))
|
||||||
|
.group_by(model.studio)
|
||||||
|
)
|
||||||
|
for s, c in db.execute(stq).all():
|
||||||
|
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||||
|
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
||||||
|
if kind_movie:
|
||||||
|
pcls = playback_class_expr()
|
||||||
|
pcq = (
|
||||||
|
wsq(
|
||||||
|
select(pcls.label("cls"), func.count()),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "playable": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
.where(pcls.isnot(None))
|
||||||
|
.group_by(pcls)
|
||||||
|
)
|
||||||
|
for cls, c in db.execute(pcq).all():
|
||||||
|
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||||
|
# Year bounds — exclude the year filter.
|
||||||
|
by = db.execute(
|
||||||
|
wsq(
|
||||||
|
select(func.min(model.year), func.max(model.year)),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "year_min": None, "year_max": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
if by[0] is not None:
|
||||||
|
years.append(by[0])
|
||||||
|
if by[1] is not None:
|
||||||
|
years.append(by[1])
|
||||||
|
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
|
||||||
|
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
||||||
|
br = db.scalar(
|
||||||
|
wsq(
|
||||||
|
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "rating_min": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if br is not None:
|
||||||
|
ratings.append(float(br))
|
||||||
|
# Duration bounds (movies) — exclude the duration filter.
|
||||||
|
if kind_movie:
|
||||||
|
bd = db.execute(
|
||||||
|
wsq(
|
||||||
|
select(func.min(model.duration_s), func.max(model.duration_s)),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
p,
|
||||||
|
kind_movie,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
if bd[0] is not None:
|
||||||
|
durs.append(bd[0])
|
||||||
|
if bd[1] is not None:
|
||||||
|
durs.append(bd[1])
|
||||||
|
|
||||||
|
per_table(PlexItem, movie_ids, True)
|
||||||
|
per_table(PlexShow, show_ids, False)
|
||||||
|
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
|
||||||
|
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
|
||||||
|
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
|
||||||
|
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
|
||||||
|
for g in split_csv(f.genres):
|
||||||
|
genre_counts.setdefault(g, 0)
|
||||||
|
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||||
|
for s in split_csv(f.studios):
|
||||||
|
studio_counts.setdefault(s, 0)
|
||||||
|
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||||
|
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||||
|
for c in split_csv(f.playable):
|
||||||
|
if c in PLAYBACK_CLASSES:
|
||||||
|
playable_counts.setdefault(c, 0)
|
||||||
|
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
|
||||||
|
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
|
||||||
|
return {
|
||||||
|
"genres": [
|
||||||
|
{"value": g, "count": c}
|
||||||
|
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
|
||||||
|
],
|
||||||
|
"content_ratings": [
|
||||||
|
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
|
||||||
|
],
|
||||||
|
"studios": [
|
||||||
|
{"value": s, "count": c}
|
||||||
|
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
|
||||||
|
],
|
||||||
|
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||||
|
"playable": [
|
||||||
|
{"value": c, "count": playable_counts[c]}
|
||||||
|
for c in PLAYBACK_CLASSES
|
||||||
|
if c in playable_counts
|
||||||
|
],
|
||||||
|
"year_min": min(years) if years else None,
|
||||||
|
"year_max": max(years) if years else None,
|
||||||
|
"rating_max": max(ratings) if ratings else None,
|
||||||
|
"duration_min": min(durs) if durs else None,
|
||||||
|
"duration_max": max(durs) if durs else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
"""Plex's tables: a self-contained catalog mirrored from a local Plex server — libraries, shows,
|
||||||
|
seasons, items, people, collections, the server's playlists — and each user's private part of it:
|
||||||
|
watch state, the account link, their own playlists. Only this unit writes them; another unit reads
|
||||||
|
them along a declared edge, or asks this unit's service."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Computed,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
from app.models import TimestampMixin, UpdatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plex integration (optional) -------------------------------------------------------------
|
||||||
|
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
|
||||||
|
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
|
||||||
|
# so it works for users without a Plex account. Mirrors the app's "shared catalog, private
|
||||||
|
# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as
|
||||||
|
# `videos` (migrations 0031/0032).
|
||||||
|
|
||||||
|
|
||||||
|
class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A mirrored Plex library section. Only `enabled` sections are exposed in the feed.
|
||||||
|
`plex_key` is the section id on the Plex server."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_libraries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
|
||||||
|
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
|
||||||
|
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
|
||||||
|
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
|
||||||
|
overlay_of: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
|
||||||
|
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
|
||||||
|
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_shows"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||||
|
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
library_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
|
||||||
|
# twin during sync. Indexed for the guid->row overlay match.
|
||||||
|
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
year: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
thumb_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(512)
|
||||||
|
) # Plex thumb path (proxied, not stored)
|
||||||
|
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
||||||
|
collection_keys: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # Plex collections this show is in
|
||||||
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||||
|
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
||||||
|
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||||
|
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
|
||||||
|
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
|
||||||
|
# none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python
|
||||||
|
# None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract
|
||||||
|
# elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items.
|
||||||
|
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
people_text: Mapped[str | None] = mapped_column(
|
||||||
|
Text
|
||||||
|
) # cast+directors, folded into search_vector
|
||||||
|
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||||
|
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
|
||||||
|
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||||
|
imdb_rating: Mapped[float | None] = mapped_column(
|
||||||
|
Float
|
||||||
|
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||||
|
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||||
|
tagline: Mapped[str | None] = mapped_column(Text)
|
||||||
|
full_cast: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # ordered [{name, role, thumb}]
|
||||||
|
# --- TMDB enrichment: 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. NULL = no HU twin matched,
|
||||||
|
# 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))
|
||||||
|
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
|
||||||
|
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||||
|
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A season under a show — used to order episodes for prev/next stepping."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_seasons"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
show_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
season_number: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||||
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
|
||||||
|
|
||||||
|
class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A playable LEAF — a movie or a single episode. This is what the feed lists and the player
|
||||||
|
plays. Movies carry `library_id` and no parents; episodes carry show/season refs +
|
||||||
|
season/episode numbers. `file_path` is the Plex-side absolute path of the physical media,
|
||||||
|
mapped through `plex_path_map` to a local mount at playback time. `playable` is the
|
||||||
|
ffprobe-derived browser-compatibility class (direct | remux | transcode)."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_items"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
|
||||||
|
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
library_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
|
||||||
|
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
|
||||||
|
# sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
|
||||||
|
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
|
show_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
season_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
season_number: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
episode_number: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
year: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
cast: Mapped[list | None] = mapped_column(JSON) # [role names]
|
||||||
|
# Physical media (from the Plex Part).
|
||||||
|
file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path
|
||||||
|
part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch)
|
||||||
|
container: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
codec_video: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
codec_audio: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
||||||
|
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
||||||
|
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
|
||||||
|
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
|
||||||
|
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
|
||||||
|
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
|
||||||
|
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
|
||||||
|
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
|
||||||
|
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
|
||||||
|
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
|
||||||
|
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
||||||
|
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
|
||||||
|
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
|
||||||
|
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
|
||||||
|
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||||
|
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||||
|
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
|
||||||
|
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
||||||
|
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
|
||||||
|
rating: Mapped[float | None] = mapped_column(Float, index=True)
|
||||||
|
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||||
|
studio: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||||
|
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
|
||||||
|
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
|
||||||
|
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
|
||||||
|
# finds titles by a person's name (and ranks them above summary-only mentions).
|
||||||
|
people_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||||
|
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
|
||||||
|
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||||
|
imdb_rating: Mapped[float | None] = mapped_column(
|
||||||
|
Float
|
||||||
|
) # IMDb-specific score (vs `rating` = audienceRating)
|
||||||
|
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||||
|
tagline: Mapped[str | None] = mapped_column(Text)
|
||||||
|
full_cast: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB(none_as_null=True)
|
||||||
|
) # ordered [{name, role, thumb}]
|
||||||
|
# --- TMDB enrichment: 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)
|
||||||
|
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched
|
||||||
|
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
||||||
|
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
|
||||||
|
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||||
|
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
|
||||||
|
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
|
||||||
|
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
|
||||||
|
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
|
||||||
|
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
|
||||||
|
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
|
||||||
|
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
|
||||||
|
row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
|
||||||
|
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_people"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=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
|
||||||
|
# TMDB person id, linked by name from a title's TMDB credits (the /person
|
||||||
|
# biography fetch builds on it). NULL until a TMDB-enriched title names this person.
|
||||||
|
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
|
||||||
|
# or by the first person-page open the job hasn't reached, 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)
|
||||||
|
# TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
|
||||||
|
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
|
||||||
|
# keywords have no Hungarian on TMDB, so they stay in the instance language.
|
||||||
|
biography_hu: 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):
|
||||||
|
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
||||||
|
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
|
||||||
|
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
|
||||||
|
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
|
||||||
|
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_collections"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
library_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
child_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class PlexState(Base, UpdatedAtMixin):
|
||||||
|
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
|
||||||
|
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
|
||||||
|
`synced_to_plex` marks whether this state was pushed back to a linked Plex account."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_states"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
item_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||||
|
position_seconds: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=0, server_default="0", nullable=False
|
||||||
|
)
|
||||||
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
|
||||||
|
|
||||||
|
class PlexLink(Base, UpdatedAtMixin):
|
||||||
|
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
|
||||||
|
|
||||||
|
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
|
||||||
|
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
|
||||||
|
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
|
||||||
|
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
|
||||||
|
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
|
||||||
|
sync loop then iterates link rows uniformly.
|
||||||
|
|
||||||
|
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
|
||||||
|
high-water mark for the incremental Plex→Siftlode pass."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_link"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
|
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
|
||||||
|
plex_account_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
plex_username: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
initial_import_done: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
|
||||||
|
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
|
||||||
|
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
|
||||||
|
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_playlists"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
|
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
|
||||||
|
|
||||||
|
class PlexPlaylistItem(Base):
|
||||||
|
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_playlist_items"
|
||||||
|
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE"))
|
||||||
|
item_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""The HU overlay: a Hungarian Plex section written onto its English twin's rows (E30), split out of
|
"""The HU overlay: a Hungarian Plex section written onto its English twin's rows, split out of
|
||||||
`plex/sync.py` (E32 S2), which runs it once per paired section during the full sync.
|
`plex/sync.py`, which runs it once per paired section during the full sync.
|
||||||
|
|
||||||
The pairing is admin-configured (`plex_overlay_map`, ``huKey:enKey,...``). An overlay section creates
|
The pairing is admin-configured (`plex_overlay_map`, ``huKey:enKey,...``). An overlay section creates
|
||||||
no browsable rows of its own; it only fills the `*_hu` columns of the EN rows it matches by guid.
|
no browsable rows of its own; it only fills the `*_hu` columns of the EN rows it matches by guid.
|
||||||
@@ -13,10 +13,11 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.models import PlexItem, PlexLibrary, PlexShow
|
from app.plex.models import PlexItem, PlexLibrary, PlexShow
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex.client import PlexClient
|
from app.plex.client import PlexClient
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ def sync_overlay(
|
|||||||
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
|
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
|
||||||
NO browsable rows — an overlay library is never listed on its own. Only title/summary/tagline/genres
|
NO browsable rows — an overlay library is never listed on its own. Only title/summary/tagline/genres
|
||||||
differ by language; the language-neutral people_text feeds both search vectors. A show section's
|
differ by language; the language-neutral people_text feeds both search vectors. A show section's
|
||||||
episodes are overlaid too (E30): the HU section lists every episode with its Hungarian title and
|
episodes are overlaid too: the HU section lists every episode with its Hungarian title and
|
||||||
summary, and the episode rows live in the EN show library.
|
summary, and the episode rows live in the EN show library.
|
||||||
|
|
||||||
This is a full RECONCILE: after a COMPLETE HU listing every guid-bearing EN row's HU fields are
|
This is a full RECONCILE: after a COMPLETE HU listing every guid-bearing EN row's HU fields are
|
||||||
@@ -109,8 +110,8 @@ def _overlay_pass(
|
|||||||
for g, meta in hu_by_guid.items():
|
for g, meta in hu_by_guid.items():
|
||||||
targets = by_guid[g]
|
targets = by_guid[g]
|
||||||
title_hu = (meta.get("title") or "").strip() or None
|
title_hu = (meta.get("title") or "").strip() or None
|
||||||
summary_hu = meta.get("summary")
|
summary_hu = plain_text(meta.get("summary"))
|
||||||
tagline_hu = (meta.get("tagline") or "").strip() or None
|
tagline_hu = plain_text(meta.get("tagline"))
|
||||||
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
||||||
for row in targets:
|
for row in targets:
|
||||||
row.title_hu = title_hu
|
row.title_hu = title_hu
|
||||||
@@ -141,7 +142,7 @@ def _clear_hu(by_guid: dict, episodes: bool) -> None:
|
|||||||
cheap. The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking
|
cheap. The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking
|
||||||
one, from its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma".
|
one, from its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma".
|
||||||
Only after a COMPLETE listing: one a Plex hiccup cut short would clear the HU text of every title
|
Only after a COMPLETE listing: one a Plex hiccup cut short would clear the HU text of every title
|
||||||
it failed to reach, until the next full sync (E32 S2, the watch reconcile's twin)."""
|
it failed to reach, until the next full sync (the watch reconcile's twin)."""
|
||||||
for group in by_guid.values():
|
for group in by_guid.values():
|
||||||
for row in group:
|
for row in group:
|
||||||
row.title_hu = row.summary_hu = None
|
row.title_hu = row.summary_hu = None
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.models import PlexPerson
|
from app.plex.models import PlexPerson
|
||||||
from app.plex import imgcache
|
from app.plex import imgcache
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ def people_by_credits(
|
|||||||
return [r[0] for r in rows]
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# --- Portraits (E30 S6) ---------------------------------------------------------------------------
|
# --- Portraits ---------------------------------------------------------------------------
|
||||||
# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder
|
# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder
|
||||||
# fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's
|
# fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's
|
||||||
# 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's:
|
# 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's:
|
||||||
@@ -226,7 +226,7 @@ _DEAD = frozenset({404, 410})
|
|||||||
|
|
||||||
|
|
||||||
def warm_portraits(db: Session, limit: int) -> dict:
|
def warm_portraits(db: Session, limit: int) -> dict:
|
||||||
"""Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a
|
"""Cache up to `limit` not-yet-cached headshots, the most-credited people first, so a
|
||||||
person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them
|
person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them
|
||||||
nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex
|
nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex
|
||||||
answers "not found" for is marked and left for PORTRAIT_RETRY; Plex failing in a row ends the run."""
|
answers "not found" for is marked and left for PORTRAIT_RETRY; Plex failing in a row ends the run."""
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Plex text fields as plain text.
|
||||||
|
|
||||||
|
Plex hands a summary over as the agent wrote it, and some agents (a Hungarian TV listing, for one)
|
||||||
|
write HTML: `<p>…</p>` paragraphs and `á`-style entities. The app renders summaries as text,
|
||||||
|
so the tags showed literally on the info page and the hover card, and a card's 300-character cut
|
||||||
|
could land inside one. Every summary and tagline is stored through `plain_text`, and the migration
|
||||||
|
that cleaned the existing rows calls it too, so the two cannot disagree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from html.entities import html5
|
||||||
|
|
||||||
|
_BREAK = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||||
|
# A paragraph or block boundary: a blank line between the two. Only a tag name ends the match, so a
|
||||||
|
# bare "<" in prose ("a < b", "<3") is left alone.
|
||||||
|
_BLOCK = re.compile(r"</?(?:p|div|li|ul|ol|h[1-6]|blockquote)\b[^>]*>", re.IGNORECASE)
|
||||||
|
_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
|
||||||
|
_BLANKS = re.compile(r"[ \t]*\n[ \t]*\n\s*")
|
||||||
|
|
||||||
|
# A well-formed character reference only. `html.unescape` alone also decodes the legacy forms that
|
||||||
|
# have no semicolon, so prose like "AT¬ice" or "R©" came out as "AT¬ice" and "R©". A named
|
||||||
|
# one counts only when its whole name is an entity: for "¬ice;" `html.unescape` still falls back
|
||||||
|
# to the legacy "¬" inside it.
|
||||||
|
_REF = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(m: re.Match[str]) -> str:
|
||||||
|
ref = m.group()
|
||||||
|
if ref[1] != "#":
|
||||||
|
return html.unescape(ref) if ref[1:] in html5 else ref
|
||||||
|
if ref[2] in "xX":
|
||||||
|
return html.unescape(ref)
|
||||||
|
# `int()` refuses a decimal run past 4300 digits, and any number past seven significant digits is
|
||||||
|
# beyond U+10FFFF, which the parser turns into U+FFFD anyway.
|
||||||
|
digits = ref[2:-1].lstrip("0") or "0"
|
||||||
|
return "�" if len(digits) > 7 else html.unescape(f"&#{digits};")
|
||||||
|
|
||||||
|
|
||||||
|
def plain_text(value: str | None) -> str | None:
|
||||||
|
"""`value` with its markup gone: line breaks kept as newlines, paragraphs as a blank line,
|
||||||
|
every other tag dropped and entities decoded. None or an all-blank result stays None."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if "<" in value or "&" in value:
|
||||||
|
value = _BREAK.sub("\n", value)
|
||||||
|
value = _BLOCK.sub("\n\n", value)
|
||||||
|
value = _TAG.sub("", value)
|
||||||
|
value = _REF.sub(_decode, value)
|
||||||
|
value = _BLANKS.sub("\n\n", value)
|
||||||
|
return value.strip() or None
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""Plex's reads for the HTTP layer: the mirrored rows a page shows, and a user's watch state on them.
|
||||||
|
|
||||||
|
A user's own rows (`PlexState`) are read through `userscope`, so "someone else's row" is the same
|
||||||
|
answer as a missing one. A rating key with a NUL in it names no row (`lookups.by_rating_key`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.collation import collated
|
||||||
|
from app.models import User
|
||||||
|
from app.plex import rollup
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.plex.models import (
|
||||||
|
PlexCollection,
|
||||||
|
PlexItem,
|
||||||
|
PlexLibrary,
|
||||||
|
PlexLink,
|
||||||
|
PlexPerson,
|
||||||
|
PlexPlaylist,
|
||||||
|
PlexPlaylistItem,
|
||||||
|
PlexSeason,
|
||||||
|
PlexShow,
|
||||||
|
PlexState,
|
||||||
|
)
|
||||||
|
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||||
|
from app.userscope import owned_by, owned_row
|
||||||
|
from app.utils import like_escape
|
||||||
|
|
||||||
|
# --- one row ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def item(db: Session, rating_key: str) -> PlexItem | None:
|
||||||
|
return by_rating_key(db, PlexItem, rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def movie(db: Session, rating_key: str) -> PlexItem | None:
|
||||||
|
"""The movie with this key — an episode's key is not one."""
|
||||||
|
it = item(db, rating_key)
|
||||||
|
return it if it is not None and it.kind == "movie" else None
|
||||||
|
|
||||||
|
|
||||||
|
def show(db: Session, rating_key: str) -> PlexShow | None:
|
||||||
|
return by_rating_key(db, PlexShow, rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def collection(db: Session, rating_key: str) -> PlexCollection | None:
|
||||||
|
return by_rating_key(db, PlexCollection, rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def season(db: Session, rating_key: str) -> PlexSeason | None:
|
||||||
|
return by_rating_key(db, PlexSeason, rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def season_episodes(db: Session, se: PlexSeason) -> list[PlexItem]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode")
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_by_id(db: Session, show_id: int) -> PlexShow | None:
|
||||||
|
return db.get(PlexShow, show_id)
|
||||||
|
|
||||||
|
|
||||||
|
def season_by_id(db: Session, season_id: int) -> PlexSeason | None:
|
||||||
|
return db.get(PlexSeason, season_id)
|
||||||
|
|
||||||
|
|
||||||
|
def library(db: Session, library_id: int) -> PlexLibrary | None:
|
||||||
|
return db.get(PlexLibrary, library_id)
|
||||||
|
|
||||||
|
|
||||||
|
def library_by_key(db: Session, plex_key: str) -> PlexLibrary | None:
|
||||||
|
return (
|
||||||
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(plex_key)))
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def library_key(db: Session, library_id: int | None) -> str | None:
|
||||||
|
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||||
|
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||||
|
if library_id is None:
|
||||||
|
return None
|
||||||
|
lib = db.get(PlexLibrary, library_id)
|
||||||
|
return lib.plex_key if lib else None
|
||||||
|
|
||||||
|
|
||||||
|
def person(db: Session, name: str) -> PlexPerson | None:
|
||||||
|
return db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
# --- a user's watch state --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def state(db: Session, user: User | int, item_id: int) -> PlexState | None:
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(owned_by(PlexState.user_id, user), PlexState.item_id == item_id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- a show ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def seasons(db: Session, sh: PlexShow) -> list[PlexSeason]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexSeason)
|
||||||
|
.where(PlexSeason.show_id == sh.id)
|
||||||
|
.order_by(PlexSeason.season_number.asc().nullsfirst())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def episodes(db: Session, sh: PlexShow) -> list[PlexItem]:
|
||||||
|
"""A show's episodes in season/episode order."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem)
|
||||||
|
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
|
||||||
|
.order_by(
|
||||||
|
PlexItem.season_number.asc().nullsfirst(),
|
||||||
|
PlexItem.episode_number.asc().nullsfirst(),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def episode_keys(db: Session, show_id: int) -> list[str]:
|
||||||
|
"""The rating keys of a show's episodes, in the order `episodes` gives."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem.rating_key)
|
||||||
|
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
|
||||||
|
.order_by(
|
||||||
|
PlexItem.season_number.asc().nullsfirst(),
|
||||||
|
PlexItem.episode_number.asc().nullsfirst(),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shows_by_keys(db: Session, rating_keys: list[str], exclude: int) -> dict[str, PlexShow]:
|
||||||
|
"""The mirrored shows among these keys, but `exclude`, by key."""
|
||||||
|
return {
|
||||||
|
s.rating_key: s
|
||||||
|
for s in db.execute(
|
||||||
|
select(PlexShow).where(PlexShow.rating_key.in_(rating_keys), PlexShow.id != exclude)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- libraries -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def browsable_libraries(db: Session, lang: str) -> list[tuple[PlexLibrary, int]]:
|
||||||
|
"""The mirrored libraries a user can browse, each with its title count (movies, or shows). An
|
||||||
|
HU overlay library is left out: it is never browsed, it only feeds its EN twin's Hungarian
|
||||||
|
metadata."""
|
||||||
|
libs = db.execute(
|
||||||
|
select(PlexLibrary)
|
||||||
|
.where(PlexLibrary.overlay_of.is_(None))
|
||||||
|
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang))
|
||||||
|
).scalars()
|
||||||
|
out = []
|
||||||
|
for lib in libs:
|
||||||
|
if lib.kind == "movie":
|
||||||
|
count = db.scalar(
|
||||||
|
select(func.count(PlexItem.id)).where(
|
||||||
|
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
|
||||||
|
out.append((lib, count or 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- people ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def person_photos(db: Session, names: list[str]) -> dict[str, str | None]:
|
||||||
|
return {
|
||||||
|
p.name: p.photo_thumb
|
||||||
|
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cast_count(db: Session, name: str, movie_lib_ids: list[int], show_lib_ids: list[int]) -> int:
|
||||||
|
"""How many movies (in `movie_lib_ids`) and shows (in `show_lib_ids`) name this person in their
|
||||||
|
cast. An empty id list leaves its side out."""
|
||||||
|
count = 0
|
||||||
|
if movie_lib_ids:
|
||||||
|
count += (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(PlexItem.id)).where(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_lib_ids),
|
||||||
|
PlexItem.cast_names.contains([name]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if show_lib_ids:
|
||||||
|
count += (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(PlexShow.id)).where(
|
||||||
|
PlexShow.library_id.in_(show_lib_ids),
|
||||||
|
PlexShow.cast_names.contains([name]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def filmography(
|
||||||
|
db: Session,
|
||||||
|
kind: Literal["movie", "show"],
|
||||||
|
name: str,
|
||||||
|
lib_ids: list[int],
|
||||||
|
lang: str,
|
||||||
|
limit: int,
|
||||||
|
) -> list:
|
||||||
|
"""The movies or shows in these libraries whose cast or directors name this person, newest
|
||||||
|
first."""
|
||||||
|
model: type[PlexItem] | type[PlexShow] = PlexItem if kind == "movie" else PlexShow
|
||||||
|
query = select(model).where(
|
||||||
|
model.library_id.in_(lib_ids),
|
||||||
|
or_(model.cast_names.contains([name]), model.directors.contains([name])),
|
||||||
|
)
|
||||||
|
if model is PlexItem:
|
||||||
|
query = query.where(PlexItem.kind == "movie")
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
query.order_by(
|
||||||
|
model.year.desc().nullslast(), collated(title_col(model, lang), lang)
|
||||||
|
).limit(limit)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- collections -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def collections_page(
|
||||||
|
db: Session, library: str | None, q: str | None, keys: list[str] | None, lang: str
|
||||||
|
) -> list[PlexCollection]:
|
||||||
|
"""Collections, most-populated first: one library's (by its plex key), else every enabled
|
||||||
|
library's; name-filtered by `q`; narrowed to `keys` when given (an empty list asks for none)."""
|
||||||
|
if library is not None:
|
||||||
|
lib = library_by_key(db, library)
|
||||||
|
if lib is None:
|
||||||
|
return []
|
||||||
|
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
|
||||||
|
else:
|
||||||
|
enabled_ids = list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexLibrary.id).where(
|
||||||
|
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
if not enabled_ids:
|
||||||
|
return []
|
||||||
|
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
|
||||||
|
term = (q or "").strip()
|
||||||
|
if term:
|
||||||
|
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
|
||||||
|
if keys is not None:
|
||||||
|
# An explicit but empty `keys=` asks for nothing, which is not the same as no `keys` (every
|
||||||
|
# collection): answered here without a query. The empty IN () would also match nothing.
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
query = query.where(PlexCollection.rating_key.in_(keys))
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
query.order_by(
|
||||||
|
PlexCollection.child_count.desc().nullslast(), collated(PlexCollection.title, lang)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def library_collections(db: Session, library_id: int, lang: str) -> list[PlexCollection]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexCollection)
|
||||||
|
.where(PlexCollection.library_id == library_id)
|
||||||
|
.order_by(collated(PlexCollection.title, lang))
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collections_by_keys(db: Session, keys: list[str]) -> list[PlexCollection]:
|
||||||
|
return list(
|
||||||
|
db.execute(select(PlexCollection).where(PlexCollection.rating_key.in_(keys))).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collection_named(db: Session, library_id: int, title: str) -> PlexCollection | None:
|
||||||
|
"""A collection of this library with this name, case aside."""
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(PlexCollection).where(
|
||||||
|
PlexCollection.library_id == library_id,
|
||||||
|
func.lower(PlexCollection.title) == title.lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collection_members(
|
||||||
|
db: Session, rating_key: str, lang: str
|
||||||
|
) -> tuple[list[PlexItem], list[PlexShow]]:
|
||||||
|
"""A collection's movies and shows, each by year then title."""
|
||||||
|
movies = db.execute(
|
||||||
|
select(PlexItem)
|
||||||
|
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
|
||||||
|
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
|
||||||
|
).scalars()
|
||||||
|
shows = db.execute(
|
||||||
|
select(PlexShow)
|
||||||
|
.where(PlexShow.collection_keys.contains([rating_key]))
|
||||||
|
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
|
||||||
|
).scalars()
|
||||||
|
return list(movies), list(shows)
|
||||||
|
|
||||||
|
|
||||||
|
def library_movies_in(
|
||||||
|
db: Session, library_id: int, rating_key: str, lang: str, limit: int
|
||||||
|
) -> list[PlexItem]:
|
||||||
|
"""The movies of one library in this collection, by year then title."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem)
|
||||||
|
.where(
|
||||||
|
PlexItem.library_id == library_id,
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.collection_keys.contains([rating_key]),
|
||||||
|
)
|
||||||
|
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
|
||||||
|
.limit(limit)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_rollups(db: Session, user_id: int, show_ids: list[int]) -> list:
|
||||||
|
"""The user's episode rollup (total, watched, in progress, last resumed) of each of these shows
|
||||||
|
that has episodes."""
|
||||||
|
if not show_ids:
|
||||||
|
return []
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
rollup.agg_subquery(db, user_id, include_resumed=True).where(
|
||||||
|
PlexItem.show_id.in_(show_ids)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collection_tiles(
|
||||||
|
db: Session, keys: list[str]
|
||||||
|
) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, int]]:
|
||||||
|
"""What each collection's tile shows: the top two movie members' keys, the top two show members'
|
||||||
|
keys (a collection lives in one library, so exactly one side has any), and its movies' total
|
||||||
|
runtime (a show has no single duration)."""
|
||||||
|
movie_ids, show_ids = content_libs(db)
|
||||||
|
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
|
||||||
|
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
|
||||||
|
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
|
||||||
|
return art_m, art_s, runtime
|
||||||
|
|
||||||
|
|
||||||
|
# --- the admin page --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def sample_file_path(db: Session) -> str | None:
|
||||||
|
"""Any mirrored item's Plex-side file path — a sample to test the mount and path map against."""
|
||||||
|
return db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None)))
|
||||||
|
|
||||||
|
|
||||||
|
def watch_link(db: Session, user: User | int) -> PlexLink | None:
|
||||||
|
"""The user's Plex watch-sync link, if they have one."""
|
||||||
|
return db.execute(select(PlexLink).where(owned_by(PlexLink.user_id, user))).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
# --- a user's playlists ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def own_playlist(
|
||||||
|
db: Session, user: User, playlist_id: int, *, lock: bool = False
|
||||||
|
) -> PlexPlaylist | None:
|
||||||
|
"""The user's playlist with this id; someone else's is the same answer as a missing one. `lock`
|
||||||
|
takes it FOR UPDATE: every add, remove and reorder does, so two of them never read the same max
|
||||||
|
position, and the list row is always locked before its items."""
|
||||||
|
return owned_row(db, PlexPlaylist, playlist_id, user, lock=lock)
|
||||||
|
|
||||||
|
|
||||||
|
def playlists_of(db: Session, user: User) -> list[PlexPlaylist]:
|
||||||
|
"""The user's playlists, newest-touched first."""
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlexPlaylist)
|
||||||
|
.where(owned_by(PlexPlaylist.user_id, user))
|
||||||
|
.order_by(PlexPlaylist.updated_at.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_count(db: Session, playlist_id: int) -> int:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(PlexPlaylistItem.id)).where(
|
||||||
|
PlexPlaylistItem.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist_rows(playlist_id: int):
|
||||||
|
return (
|
||||||
|
select(PlexPlaylistItem, PlexItem)
|
||||||
|
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
|
||||||
|
.where(PlexPlaylistItem.playlist_id == playlist_id)
|
||||||
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_cover(db: Session, playlist_id: int) -> str | None:
|
||||||
|
"""The rating key of a playlist's first item — its cover."""
|
||||||
|
first = db.execute(_playlist_rows(playlist_id)).first()
|
||||||
|
return first[1].rating_key if first else None
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_items(db: Session, playlist_id: int) -> list[PlexItem]:
|
||||||
|
"""A playlist's items in play order."""
|
||||||
|
return [it for _row, it in db.execute(_playlist_rows(playlist_id)).all()]
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_holds(db: Session, playlist_id: int, item_ids: set[int]) -> int:
|
||||||
|
"""How many of these items the playlist holds."""
|
||||||
|
if not item_ids:
|
||||||
|
return 0
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(PlexPlaylistItem.id)).where(
|
||||||
|
PlexPlaylistItem.playlist_id == playlist_id,
|
||||||
|
PlexPlaylistItem.item_id.in_(item_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def item_id(db: Session, rating_key: str) -> int | None:
|
||||||
|
"""The id of the mirrored item with this key. Unlike `item`, a NUL in the key reaches the query,
|
||||||
|
which the app answers as a 422 (`main._nul_in_text`), as for every other compared string."""
|
||||||
|
return db.scalar(select(PlexItem.id).where(PlexItem.rating_key == str(rating_key)))
|
||||||
|
|
||||||
|
|
||||||
|
def item_ids(db: Session, rating_keys: list[str]) -> set[int]:
|
||||||
|
"""The ids of the mirrored items with these keys."""
|
||||||
|
if not rating_keys:
|
||||||
|
return set()
|
||||||
|
return set(
|
||||||
|
db.execute(select(PlexItem.id).where(PlexItem.rating_key.in_(rating_keys))).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def items_by_keys(db: Session, rating_keys: list) -> list[PlexItem]:
|
||||||
|
"""The mirrored items with these keys, in the given order; an unknown key and a repeat drop out."""
|
||||||
|
order = [str(k) for k in rating_keys if str(k)]
|
||||||
|
if not order:
|
||||||
|
return []
|
||||||
|
by_rk = {
|
||||||
|
it.rating_key: it
|
||||||
|
for it in db.execute(select(PlexItem).where(PlexItem.rating_key.in_(order))).scalars()
|
||||||
|
}
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[PlexItem] = []
|
||||||
|
for rk in order:
|
||||||
|
it = by_rk.get(rk)
|
||||||
|
if it is not None and rk not in seen:
|
||||||
|
seen.add(rk)
|
||||||
|
out.append(it)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def shows_by_ids(db: Session, show_ids: set[int]) -> dict[int, PlexShow]:
|
||||||
|
if not show_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
sh.id: sh for sh in db.execute(select(PlexShow).where(PlexShow.id.in_(show_ids))).scalars()
|
||||||
|
}
|
||||||
+12
-12
@@ -10,7 +10,7 @@ from sqlalchemy import and_, case, func, or_, select, type_coerce
|
|||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import PlexItem, PlexLibrary
|
from app.plex.models import PlexItem, PlexLibrary
|
||||||
from app.plex.genres import hu_genre
|
from app.plex.genres import hu_genre
|
||||||
|
|
||||||
TS_CONFIG = "public.unaccent_simple"
|
TS_CONFIG = "public.unaccent_simple"
|
||||||
@@ -31,9 +31,9 @@ PLEX_SORT_KEYS: tuple[str, ...] = (
|
|||||||
"title",
|
"title",
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
# --- Language-aware column pickers ----------------------------------
|
||||||
#
|
#
|
||||||
# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
|
# The HU-overlay twin stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
|
||||||
# the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin.
|
# the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin.
|
||||||
# Two flavours of picker, on purpose:
|
# Two flavours of picker, on purpose:
|
||||||
# * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with
|
# * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with
|
||||||
@@ -41,8 +41,8 @@ PLEX_SORT_KEYS: tuple[str, ...] = (
|
|||||||
# HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.)
|
# HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.)
|
||||||
# * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the
|
# * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the
|
||||||
# generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN
|
# generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN
|
||||||
# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the S2
|
# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the
|
||||||
# invariant). `people_text` is language-neutral and shared by both vectors.
|
# field-search invariant). `people_text` is language-neutral and shared by both vectors.
|
||||||
|
|
||||||
|
|
||||||
def title_col(model, lang: str):
|
def title_col(model, lang: str):
|
||||||
@@ -216,7 +216,7 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
|
|||||||
stds = split_csv(p.get("studios"))
|
stds = split_csv(p.get("studios"))
|
||||||
if stds: # OR
|
if stds: # OR
|
||||||
conds.append(model.studio.in_(stds))
|
conds.append(model.studio.in_(stds))
|
||||||
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
|
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page
|
||||||
conds.append(model.keywords.contains([kw]))
|
conds.append(model.keywords.contains([kw]))
|
||||||
csel = split_csv(p.get("collections"))
|
csel = split_csv(p.get("collections"))
|
||||||
if (
|
if (
|
||||||
@@ -232,11 +232,11 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
|||||||
return q.where(*conds) if conds else q
|
return q.where(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
# --- Playback-class filter — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
||||||
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||||
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||||
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||||
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
|
# Public — it is the shared source of truth imported by plex.library (facet order + zero-fill).
|
||||||
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ class LibraryFilters:
|
|||||||
actors: str | None = None
|
actors: str | None = None
|
||||||
actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND)
|
actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND)
|
||||||
studios: str | None = None
|
studios: str | None = None
|
||||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
keywords: str | None = None # TMDB keyword chips, CSV; language-neutral
|
||||||
collections: str | None = None # CSV of collection rating_keys
|
collections: str | None = None # CSV of collection rating_keys
|
||||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||||
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
||||||
@@ -311,7 +311,7 @@ class LibraryFilters:
|
|||||||
# watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and
|
# watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and
|
||||||
# `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical:
|
# `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical:
|
||||||
# a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one
|
# a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one
|
||||||
# home for that predicate so the two call sites can't drift (E3 S1; pinned by test_plex_facets_grid_
|
# home for that predicate so the two call sites can't drift (pinned by test_plex_facets_grid_
|
||||||
# parity). Callers pass the already-joined state expressions, so this module stays model-agnostic:
|
# parity). Callers pass the already-joined state expressions, so this module stays model-agnostic:
|
||||||
# * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id).
|
# * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id).
|
||||||
# * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`.
|
# * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`.
|
||||||
@@ -367,9 +367,9 @@ def apply_show_state_filter(query, tot, wat, inp, show: str):
|
|||||||
def content_libs(db: Session) -> tuple[list[int], list[int]]:
|
def content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||||||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||||||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||||||
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).
|
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows.
|
||||||
|
|
||||||
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's
|
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: the
|
||||||
Home surface needs the same scope, and a domain package importing from `routes/` would invert the
|
Home surface needs the same scope, and a domain package importing from `routes/` would invert the
|
||||||
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
|
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
|
||||||
move."""
|
move."""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user