Compare commits
151
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
49a9319f84 | ||
|
|
70d56ab939 |
+10
-8
@@ -68,18 +68,20 @@ SMTP_FROM=
|
||||
|
||||
# --- Scheduler ---
|
||||
# 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
|
||||
# more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one
|
||||
# of them and false on the rest, to avoid double quota use and write races.
|
||||
# DATABASE_URL is set for you by docker-compose to the bundled `db` service. Run ONE app instance
|
||||
# per database: a starting instance closes every run still open as interrupted (it assumes the
|
||||
# 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
|
||||
|
||||
# --- Download center ---
|
||||
# The Download Center adds a `worker` container (runs the yt-dlp/ffmpeg job loop) and a small
|
||||
# --- Files library & downloads ---
|
||||
# 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.
|
||||
# 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.
|
||||
# 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) ---
|
||||
# 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
|
||||
# /data=/plex-media). Leave unset if you don't use Plex.
|
||||
# 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.
|
||||
# PLEX_HLS_DIR=/var/tmp/plex-hls
|
||||
|
||||
+2
-2
@@ -29,9 +29,9 @@ frontend/e2e/.auth/
|
||||
*.sql.gz
|
||||
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.
|
||||
/downloads/
|
||||
/files/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
+103
@@ -7,6 +7,109 @@ 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).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,14 +5,14 @@ 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
|
||||
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
|
||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
||||
router-free navigation provider.
|
||||
|
||||
## 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 + 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`
|
||||
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
|
||||
@@ -55,14 +55,15 @@ how it gets built. Concretely:
|
||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||
arguments, push back or propose an alternative — don't comply blindly.
|
||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||
bugs; do a real visual check of any UI change, 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
|
||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||
|
||||
## Architecture shape (don't fight it)
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`, `jobruns`,
|
||||
`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.**
|
||||
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
|
||||
@@ -91,8 +92,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` and `jobruns` own their session) |
|
||||
| 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 |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
@@ -101,7 +102,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| 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 loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
| 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 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 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 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 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 |
|
||||
@@ -114,7 +116,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
|
||||
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
|
||||
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
|
||||
(`SegmentedToggle` / `FilterSelect`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
||||
(`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)
|
||||
|
||||
|
||||
+10
-4
@@ -39,13 +39,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
# 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
|
||||
# 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).
|
||||
RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /downloads \
|
||||
&& chown appuser /app /downloads
|
||||
&& mkdir -p /files \
|
||||
&& chown appuser /app /files
|
||||
|
||||
# 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
|
||||
@@ -55,8 +55,14 @@ RUN adduser --disabled-password --gecos "" appuser \
|
||||
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
# `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.
|
||||
# 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
|
||||
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 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 \
|
||||
|
||||
@@ -22,10 +22,11 @@ blocker and SponsorBlock keep working.
|
||||
tags to slice the feed by.
|
||||
- **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.
|
||||
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
||||
- **Files** — one library for everything you keep: paste a link anywhere in the app to download it
|
||||
with yt-dlp (format presets, per-user storage quota, the running downloads in a tray), upload your
|
||||
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.
|
||||
link that plays on any phone.
|
||||
- **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.
|
||||
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||
|
||||
@@ -437,14 +437,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 12,
|
||||
"endColumn": 13,
|
||||
"lineCount": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportAttributeAccessIssue",
|
||||
"range": {
|
||||
@@ -638,41 +630,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/admin_logs.py": [
|
||||
{
|
||||
"code": "reportReturnType",
|
||||
"range": {
|
||||
"startColumn": 15,
|
||||
"endColumn": 47,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./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",
|
||||
"range": {
|
||||
@@ -734,88 +692,6 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/downloads.py": [
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 29,
|
||||
"endColumn": 33,
|
||||
"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/playlists.py": [
|
||||
{
|
||||
"code": "reportOptionalOperand",
|
||||
@@ -1134,22 +1010,6 @@
|
||||
"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": [
|
||||
@@ -1220,16 +1080,6 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/public.py": [
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 57,
|
||||
"endColumn": 71,
|
||||
"lineCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"./app/routes/scheduler.py": [
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
@@ -1455,14 +1305,6 @@
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
"startColumn": 38,
|
||||
"endColumn": 43,
|
||||
"lineCount": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reportArgumentType",
|
||||
"range": {
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import engine_from_config, pool
|
||||
from app.config import settings
|
||||
from app.db import Base
|
||||
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.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
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -20,7 +20,9 @@ Three layers, by *who* runs the code — not by feature:
|
||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the 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
|
||||
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
|
||||
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
|
||||
@@ -35,7 +37,7 @@ pre-abstract for a single caller; don't leave shared logic duplicated once there
|
||||
## Transaction ownership
|
||||
|
||||
This follows CLAUDE.md's canonical rule verbatim — **"the handler/job-wrapper commits; helpers flush
|
||||
(only `quota` owns its session)"** — so read it that way, not as an exception to it. The commit belongs
|
||||
(only `quota` and `jobruns` own their session)"** — so read it that way, not as an exception to it. The commit belongs
|
||||
to the **operation 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.
|
||||
`audit.record()` is the reference: it adds the audit row and the caller commits it together with the
|
||||
@@ -44,7 +46,7 @@ mutation it records, so an audit entry can never exist without its change (or vi
|
||||
- The **operation owner** is usually the route handler. A `service.*` operation the handler delegates a
|
||||
whole transition to (e.g. `service.cancel`) *is* that owner and commits its one transition — it isn't
|
||||
a mid-operation helper. A helper that owns its OWN short-lived session (the worker's `jobdb` writes,
|
||||
the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
|
||||
`jobruns`' run rows — the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
|
||||
- `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
|
||||
attribute — see `service.decr_ref_count`.
|
||||
@@ -66,8 +68,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
|
||||
startup, not user-tunable at runtime.
|
||||
- **`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
|
||||
slated to fold into `sysconfig` — do not add more like it.)
|
||||
goes in `SPECS`; don't invent a fourth store. Each spec states when a change takes effect
|
||||
(`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
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""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 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(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)
|
||||
+28
-7
@@ -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
|
||||
touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are
|
||||
only logged when they actually changed something (or errored); all admin actions and all manual
|
||||
runs are always logged. Secrets are never stored (config changes log the key name only).
|
||||
Design (locked): log at ACTION granularity, NEVER per-row — an action that touches thousands of
|
||||
rows is ONE audit row summarising the outcome. Background runs are not actions: they have their
|
||||
own history (app/jobruns.py). 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
|
||||
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.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app.models import AuditLog
|
||||
|
||||
|
||||
@@ -48,8 +49,6 @@ class AuditAction:
|
||||
MAINTENANCE_TUNE = "maintenance_tune"
|
||||
SYNC_PAUSE = "sync_pause"
|
||||
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)
|
||||
DOWNLOAD_CREDENTIAL_SET = "download_credential_set"
|
||||
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
|
||||
@@ -58,6 +57,27 @@ class AuditAction:
|
||||
# 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).
|
||||
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(
|
||||
@@ -83,6 +103,7 @@ def record(
|
||||
summary=(summary or None) and summary[:255],
|
||||
before=before,
|
||||
after=after,
|
||||
request_id=logs.current_request_id(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
|
||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
||||
# sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports.
|
||||
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
|
||||
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
||||
}
|
||||
|
||||
|
||||
+22
-13
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||
@@ -171,9 +171,13 @@ class Settings(BaseSettings):
|
||||
feed_default_hidden_live: str = "live,upcoming"
|
||||
|
||||
# --- 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.
|
||||
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
|
||||
# container, off for the API (which keeps the scheduler). Mirrors scheduler_enabled.
|
||||
worker_enabled: bool = False
|
||||
@@ -181,15 +185,18 @@ class Settings(BaseSettings):
|
||||
# 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").
|
||||
download_pot_base_url: str = "http://bgutil-pot:4416"
|
||||
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
|
||||
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
|
||||
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
|
||||
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
|
||||
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
|
||||
# yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one
|
||||
# client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil
|
||||
# sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died
|
||||
# with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback
|
||||
# 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
|
||||
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
||||
# 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"
|
||||
|
||||
@staticmethod
|
||||
@@ -261,10 +268,12 @@ class Settings(BaseSettings):
|
||||
# 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".
|
||||
download_layout: DownloadLayout = "plex"
|
||||
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
||||
sponsorblock_default: bool = False
|
||||
|
||||
# --- Diagnostics / logging ---
|
||||
# BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at
|
||||
@@ -342,8 +351,8 @@ class Settings(BaseSettings):
|
||||
plex_transcode_crf: int = 20
|
||||
plex_transcode_max_height: int = 1080
|
||||
# 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
|
||||
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
||||
# 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 files_root is fast
|
||||
# local disk, so the default is fine there.
|
||||
plex_hls_dir: str = ""
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app.discover import cards, queries
|
||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
||||
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
|
||||
|
||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||
@@ -203,8 +203,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s
|
||||
sh = db.get(PlexShow, pin.plex_show_id)
|
||||
if sh is None:
|
||||
return None
|
||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
||||
card = cards.from_plex(show_card(sh, status, lang=lang))
|
||||
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
|
||||
card = cards.from_plex(show_card(sh, state, lang=lang))
|
||||
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
||||
nxt = cards.from_plex(ep) if ep else None
|
||||
|
||||
|
||||
@@ -256,16 +256,20 @@ def plex_in_progress(
|
||||
if show_libs:
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
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.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).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)
|
||||
).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
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter):
|
||||
# `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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.db import SessionLocal
|
||||
from app.downloads import adapters
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, service
|
||||
from app.models import MediaAsset
|
||||
from app.downloads.models import MediaAsset
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
@@ -89,7 +89,7 @@ def run() -> dict:
|
||||
if a.poster_path is None:
|
||||
# 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.
|
||||
rel = editmod.ensure_poster(a, settings.download_root)
|
||||
rel = editmod.ensure_poster(a, settings.files_root)
|
||||
if rel:
|
||||
a.poster_path = rel
|
||||
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 app import logs
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import formats
|
||||
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.userscope import owned_by
|
||||
|
||||
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:
|
||||
return db.execute(
|
||||
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()
|
||||
|
||||
@@ -213,7 +215,7 @@ def status_list(db: Session, user_id: int) -> list[dict]:
|
||||
have = {
|
||||
c.host_key: c
|
||||
for c in db.execute(
|
||||
select(DownloadCredential).where(DownloadCredential.user_id == user_id)
|
||||
select(DownloadCredential).where(owned_by(DownloadCredential.user_id, user_id))
|
||||
).scalars()
|
||||
}
|
||||
out = []
|
||||
@@ -259,6 +261,28 @@ def usable_cookie(
|
||||
return None
|
||||
if not is_authed_asset(asset, spec, user_id, url):
|
||||
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)
|
||||
if not raw:
|
||||
log.warning(
|
||||
@@ -284,6 +308,14 @@ def resolve_cookiefile(
|
||||
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:
|
||||
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||
|
||||
|
||||
@@ -732,7 +732,7 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
|
||||
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
|
||||
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
|
||||
@@ -740,7 +740,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
||||
short clip) dodges black intro frames."""
|
||||
if not asset.rel_path:
|
||||
return None
|
||||
root = Path(download_root)
|
||||
root = Path(files_root)
|
||||
src = root / asset.rel_path
|
||||
if not src.exists():
|
||||
return None
|
||||
@@ -756,7 +756,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | 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.
|
||||
|
||||
Best-effort + time-bounded: for a very long/high-res source the one-pass decode can be slow,
|
||||
@@ -768,7 +768,7 @@ def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict |
|
||||
if not asset.rel_path:
|
||||
return None
|
||||
geom = storyboard_geometry(asset.duration_s)
|
||||
root = Path(download_root)
|
||||
root = Path(files_root)
|
||||
rel = f".storyboards/asset-{asset.id}.jpg"
|
||||
dest = root / rel
|
||||
|
||||
|
||||
@@ -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`.
|
||||
#
|
||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
|
||||
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
|
||||
# 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
|
||||
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
||||
@@ -53,6 +54,19 @@ from app.downloads.credentials import host_key_for_url
|
||||
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||
# that gives the site's sentence.
|
||||
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."
|
||||
|
||||
@@ -62,6 +76,7 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
# 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.
|
||||
("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.
|
||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||
@@ -129,6 +144,13 @@ _RULES: tuple[tuple[str, str], ...] = (
|
||||
("postprocessing", "postprocess"),
|
||||
("produced 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.
|
||||
("unable to download", "network"),
|
||||
("http error", "network"),
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""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 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 folder.kind != FolderKind.OWN:
|
||||
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
|
||||
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
|
||||
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:
|
||||
"""yt-dlp's logger protocol, pointed at ours.
|
||||
|
||||
@@ -124,6 +146,8 @@ class _YdlLog:
|
||||
is the one line whose level should say so."""
|
||||
|
||||
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
|
||||
# `[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
|
||||
@@ -281,8 +305,11 @@ def build_ydl_opts(
|
||||
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.
|
||||
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
|
||||
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)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
|
||||
+193
-88
@@ -1,33 +1,37 @@
|
||||
"""Download retention garbage collector (a scheduler job).
|
||||
|
||||
Five passes each run:
|
||||
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
||||
once (gc_notified flag) so a download isn't silently deleted.
|
||||
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
||||
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
|
||||
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed
|
||||
ready assets (never an upload) until back under it.
|
||||
Six passes each run:
|
||||
1. Pre-expiry warning — a done file whose `expires_at` (its folder's retention, `folders`) falls
|
||||
within the grace window, and that its user did not keep, warns its user once (`expiry_warned`).
|
||||
2. Expiry — such a file past its `expires_at` leaves the library: the job is deleted and its hold
|
||||
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 ready
|
||||
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
|
||||
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
|
||||
re-download renames an asset, e.g. old accented channel dirs, or an upload's stranded file).
|
||||
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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app import apitokens, logs, sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import quota, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
@@ -37,42 +41,118 @@ _UPLOAD_TREE = ".uploads"
|
||||
_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]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(DownloadJob.user_id).where(DownloadJob.asset_id == asset_id).distinct()
|
||||
).scalars()
|
||||
def _notify(db: Session, job: DownloadJob, asset: MediaAsset | None, ntype: str) -> None:
|
||||
title = job.display_name or (asset.title if asset else None) or "download"
|
||||
create_notification(
|
||||
db,
|
||||
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:
|
||||
title = asset.title or "download"
|
||||
for uid in _holders(db, asset.id):
|
||||
create_notification(
|
||||
db,
|
||||
user_id=uid,
|
||||
type=ntype,
|
||||
title=title, # English fallback; the client renders a translated message from data
|
||||
data={"kind": ntype, "title": asset.title, "video_id": asset.source_ref},
|
||||
commit=False,
|
||||
def _relock_expired(
|
||||
db: Session, job_id: int, asset_id: int | None, now: datetime
|
||||
) -> DownloadJob | None:
|
||||
"""Re-read an expired job under the lock order the user routes take (`own_job_for_update`: the
|
||||
asset, then the job) and say whether it is still to go. Between the pass's SELECT and here a
|
||||
user may have deleted or re-downloaded it (dropping it again would release its asset a second
|
||||
time, freeing a file another job still holds) or kept or moved it (it is no longer expired)."""
|
||||
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()
|
||||
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:
|
||||
if asset.rel_path:
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
_notify_holders(db, asset, ntype)
|
||||
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
||||
def _on_disk(db: Session) -> int:
|
||||
"""The bytes the cache holds now (every asset whose file is on disk)."""
|
||||
return (
|
||||
db.execute(
|
||||
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:
|
||||
"""Delete media/sidecar files (and now-empty dirs) under DOWNLOAD_ROOT that no `ready` asset
|
||||
def _relock_evictable(db: Session, asset_id: int) -> tuple[MediaAsset | None, list[DownloadJob]]:
|
||||
"""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
|
||||
channel dir, or a poster from a superseded naming scheme) — or, under `.uploads`, that no upload
|
||||
row names at all. Skips the other dot-prefixed system trees and only removes files older than
|
||||
`_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download is never deleted, and only files
|
||||
NOT owned by a surviving asset."""
|
||||
root = Path(download_root)
|
||||
root = Path(files_root)
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
keep: set[Path] = set()
|
||||
@@ -151,74 +231,95 @@ def run_download_gc(db: Session) -> dict:
|
||||
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
||||
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)
|
||||
soon = (
|
||||
db.execute(
|
||||
select(MediaAsset).where(
|
||||
MediaAsset.status == "ready",
|
||||
MediaAsset.gc_notified.is_(False),
|
||||
MediaAsset.expires_at.is_not(None),
|
||||
MediaAsset.expires_at > now,
|
||||
MediaAsset.expires_at <= warn_before,
|
||||
)
|
||||
soon = db.execute(
|
||||
select(DownloadJob.id, DownloadJob.expires_at).where(
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
DownloadJob.kept.is_(False),
|
||||
DownloadJob.expiry_warned.is_(False),
|
||||
DownloadJob.expires_at.is_not(None),
|
||||
DownloadJob.expires_at > now,
|
||||
DownloadJob.expires_at <= warn_before,
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in soon:
|
||||
_notify_holders(db, asset, "download_expiring")
|
||||
asset.gc_notified = True
|
||||
warned += 1
|
||||
if soon:
|
||||
).all()
|
||||
db.rollback() # the pass-1 snapshot holds no locks; each mark takes its own below
|
||||
for job_id, expires_at in soon:
|
||||
job = _mark_warned(db, job_id, expires_at)
|
||||
if job is not None:
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
_notify(db, job, asset, "download_expiring")
|
||||
warned += 1
|
||||
db.commit()
|
||||
|
||||
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
||||
dead = (
|
||||
db.execute(
|
||||
select(MediaAsset).where(
|
||||
MediaAsset.status.in_(("ready", "error")),
|
||||
MediaAsset.expires_at.is_not(None),
|
||||
MediaAsset.expires_at <= now,
|
||||
)
|
||||
# 2. Expiry — a file past its folder's retention leaves the library (warned in pass 1).
|
||||
# Each file is re-checked under its locks and committed on its own: the release unlinks the
|
||||
# file at once, so it must never act on what the SELECT saw a while ago.
|
||||
dead = db.execute(
|
||||
select(DownloadJob.id, DownloadJob.asset_id).where(
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
DownloadJob.kept.is_(False),
|
||||
DownloadJob.expires_at.is_not(None),
|
||||
DownloadJob.expires_at <= now,
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in dead:
|
||||
_delete_asset(db, asset, "download_expired")
|
||||
expired += 1
|
||||
if dead:
|
||||
).all()
|
||||
db.rollback() # the pass-2 snapshot holds no locks; each file takes its own below
|
||||
for job_id, asset_id in dead:
|
||||
job = _relock_expired(db, job_id, asset_id, now)
|
||||
if job is not None:
|
||||
_drop_job(db, job, "download_expired")
|
||||
expired += 1
|
||||
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")
|
||||
if cap > 0:
|
||||
total = (
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
if total > cap:
|
||||
# An upload counts toward the total (it is disk like any other) but is never evicted:
|
||||
# it has no source to fetch again, and it lives until its owner deletes it.
|
||||
candidates = (
|
||||
if _on_disk(db) > cap:
|
||||
kept_assets = select(DownloadJob.asset_id).where(
|
||||
DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None)
|
||||
)
|
||||
candidate_ids = (
|
||||
db.execute(
|
||||
select(MediaAsset)
|
||||
.where(MediaAsset.status == "ready", MediaAsset.source_kind != "upload")
|
||||
select(MediaAsset.id)
|
||||
.where(
|
||||
MediaAsset.status == "ready",
|
||||
MediaAsset.source_kind != "upload",
|
||||
MediaAsset.id.not_in(kept_assets),
|
||||
)
|
||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for asset in candidates:
|
||||
if total <= cap:
|
||||
db.rollback()
|
||||
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
|
||||
total -= asset.size_bytes or 0
|
||||
_delete_asset(db, asset, "download_evicted")
|
||||
# Locked and re-read like an expiry: since the SELECT a holder may have kept it,
|
||||
# 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
|
||||
db.commit()
|
||||
db.commit()
|
||||
|
||||
# 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
|
||||
@@ -257,7 +358,7 @@ def run_download_gc(db: Session) -> dict:
|
||||
)
|
||||
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.download_root, asset.rel_path)
|
||||
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||
db.delete(asset)
|
||||
reaped += 1
|
||||
if unheld:
|
||||
@@ -265,7 +366,10 @@ def run_download_gc(db: Session) -> dict:
|
||||
|
||||
# 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.
|
||||
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 = {
|
||||
"warned": warned,
|
||||
@@ -273,6 +377,7 @@ def run_download_gc(db: Session) -> dict:
|
||||
"evicted": evicted,
|
||||
"reaped": reaped,
|
||||
"swept": swept,
|
||||
"tokens_warned": tokens_warned,
|
||||
}
|
||||
log.info("download_gc %s", result)
|
||||
return result
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
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) -------
|
||||
@@ -68,8 +68,8 @@ def _reset_asset_pending(asset_id: int) -> None:
|
||||
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
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
|
||||
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).
|
||||
A DownloadLink is a capability URL: the unguessable token grants anyone read access to one
|
||||
download's file, or to the files in one of its owner's own folders (`folder_files`), on the
|
||||
login-free `/watch/{token}` page. So the token is never stored as written:
|
||||
the link is found by its SHA-256, and the owner's copy is Fernet-encrypted — a database copy alone
|
||||
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 hmac
|
||||
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.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
|
||||
# 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:
|
||||
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:
|
||||
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
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
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 -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
||||
"""What the file owner sees when managing their links."""
|
||||
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
|
||||
"""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 {
|
||||
"id": link.id,
|
||||
"token": link.token,
|
||||
"url": f"{base_url}/watch/{link.token}",
|
||||
"token": token,
|
||||
"url": f"{base_url}/watch/{token}" if token else None,
|
||||
"allow_download": link.allow_download,
|
||||
"has_password": link.password_hash is not None,
|
||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||
@@ -106,3 +174,117 @@ def public_meta(
|
||||
"file_url": file_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
|
||||
@@ -11,13 +11,12 @@ import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import links as linksmod
|
||||
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)
|
||||
|
||||
@@ -74,22 +73,25 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
|
||||
|
||||
def _watch_tags(token: str, db: Session) -> str | None:
|
||||
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
|
||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||
link = linksmod.find(db, token)
|
||||
if link is None or linksmod.is_expired(link):
|
||||
return None
|
||||
url = f"{settings.app_base}/watch/{token}"
|
||||
folder = link.folder_id is not None
|
||||
if link.password_hash:
|
||||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||||
return "\n ".join(
|
||||
[
|
||||
"<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:type", "video.other"),
|
||||
_tag("og:type", "website" if folder else "video.other"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
]
|
||||
)
|
||||
if folder:
|
||||
return _folder_tags(db, link, url)
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
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":
|
||||
@@ -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:
|
||||
# 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.
|
||||
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
|
||||
if image_dims:
|
||||
tags.append(_tag("og:image:width", str(image_dims[0])))
|
||||
@@ -147,6 +149,30 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
return "\n ".join(tags)
|
||||
|
||||
|
||||
def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
|
||||
"""A folder link's card: the folder's name and how many files it holds — never their names,
|
||||
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
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
from yt_dlp.utils import download_range_func
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||
@@ -26,7 +28,10 @@ from app.downloads.jobdb import (
|
||||
)
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||
from app.models import DISPLAY_TEXT_MAX, 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.titles import normalize_title
|
||||
|
||||
@@ -37,6 +42,15 @@ class _Aborted(Exception):
|
||||
"""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):
|
||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||
|
||||
@@ -82,16 +96,24 @@ def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *,
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
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
|
||||
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:
|
||||
return
|
||||
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(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type="download_done",
|
||||
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:
|
||||
db.rollback()
|
||||
@@ -213,7 +235,7 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
|
||||
|
||||
def cancelled() -> bool:
|
||||
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 cancelled
|
||||
@@ -333,7 +355,7 @@ def _media_info(info: dict) -> dict:
|
||||
|
||||
|
||||
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
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
@@ -347,9 +369,16 @@ def _extract_with_fallback(
|
||||
download as itself.
|
||||
|
||||
`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)
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
||||
@@ -375,6 +404,9 @@ def _extract_with_fallback(
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
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 /
|
||||
# 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
|
||||
@@ -411,14 +443,12 @@ def _extract_with_fallback(
|
||||
raise last_exc # unreachable (loop either returns or raises)
|
||||
|
||||
|
||||
def _download_settings() -> tuple[str, int]:
|
||||
"""Layout + retention days from the DB config (admin-overridable on the Configuration page),
|
||||
falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect."""
|
||||
def _download_layout() -> str:
|
||||
"""The on-disk layout from the DB config (admin-overridable on the Configuration page), falling
|
||||
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:
|
||||
return (
|
||||
sysconfig.get_str(db, "download_layout"),
|
||||
sysconfig.get_int(db, "download_retention_days"),
|
||||
)
|
||||
return sysconfig.get_str(db, "download_layout")
|
||||
|
||||
|
||||
def _ensure_phone_safe(
|
||||
@@ -502,8 +532,8 @@ def _download(
|
||||
# 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
|
||||
# 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
|
||||
# front instead.
|
||||
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
|
||||
# checks the announced size) instead.
|
||||
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
|
||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||
@@ -512,8 +542,8 @@ def _download(
|
||||
# free space, which is what the `quota_exceeded` category tells them.
|
||||
if remaining_bytes == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
info = _media_info(
|
||||
_extract_with_fallback(
|
||||
with formats.watching_size_cap() as over_cap:
|
||||
fetched = _extract_with_fallback(
|
||||
job_id,
|
||||
asset_id,
|
||||
url,
|
||||
@@ -523,7 +553,11 @@ def _download(
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
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
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
@@ -536,6 +570,13 @@ def _download(
|
||||
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()
|
||||
# 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
|
||||
@@ -554,19 +595,18 @@ def _download(
|
||||
thumbnail_url=info.get("thumbnail"),
|
||||
)
|
||||
|
||||
layout, retention_days = _download_settings()
|
||||
layout = _download_layout()
|
||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||
thumb = _find_thumbnail(staging, produced)
|
||||
# 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
|
||||
# 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")
|
||||
storage.place_file(produced, settings.download_root, rel)
|
||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
||||
storage.place_file(produced, settings.files_root, rel)
|
||||
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
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
@@ -592,12 +632,11 @@ def _download(
|
||||
# (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
|
||||
# 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.nfo_written = nfo_ok
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||
owner_id = job.user_id if job else None
|
||||
@@ -736,11 +775,18 @@ 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, *, what: str = "edit"
|
||||
job_id: int,
|
||||
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, 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.
|
||||
"""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`).
|
||||
Logged after the write, as on the download error path."""
|
||||
@@ -755,9 +801,12 @@ def _fail_edit(
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify_conversion(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,
|
||||
)
|
||||
db.commit()
|
||||
@@ -796,7 +845,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
if not src_ready or not src_rel:
|
||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||
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():
|
||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||
return
|
||||
@@ -834,15 +883,20 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
# 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)
|
||||
storage.place_file(dest_staging, settings.download_root, rel)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
storage.place_file(dest_staging, settings.files_root, rel)
|
||||
final = storage.abs_path(settings.files_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
|
||||
_, _, out_w, out_h, _ = probe.probe_media(final)
|
||||
_, retention_days = _download_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
@@ -861,7 +915,6 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||
owner_id = job.user_id if job else None
|
||||
@@ -896,6 +949,231 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
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,
|
||||
@@ -904,7 +1182,7 @@ def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) ->
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
|
||||
raise StatedFailure(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
raise StatedFailure(dlerrors.CONVERTED_TOO_BIG_MESSAGE)
|
||||
|
||||
|
||||
def _unplace(placed: Path | None) -> None:
|
||||
@@ -952,7 +1230,7 @@ def _free_unheld_upload(asset_id: int) -> bool:
|
||||
if asset is None or asset.ref_count != 0:
|
||||
return False
|
||||
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.commit()
|
||||
return True
|
||||
@@ -975,7 +1253,7 @@ def _convert_upload(job_id: int, asset_id: int) -> None:
|
||||
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.download_root, old_rel) if old_rel 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
|
||||
@@ -989,10 +1267,10 @@ def _convert_upload(job_id: int, asset_id: int) -> None:
|
||||
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.download_root, new_rel)
|
||||
storage.place_file(safe, settings.files_root, new_rel)
|
||||
if new_rel != old_rel:
|
||||
placed = storage.abs_path(settings.download_root, new_rel)
|
||||
final = storage.abs_path(settings.download_root, new_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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,406 @@
|
||||
"""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 = (
|
||||
db.get(DownloadJob, link.job_id)
|
||||
if link.job_id is not None
|
||||
else db.get(FileFolder, link.folder_id)
|
||||
)
|
||||
if target is None or target.user_id != user.id:
|
||||
return None
|
||||
return link
|
||||
|
||||
|
||||
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)
|
||||
@@ -15,7 +15,8 @@ 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
|
||||
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
|
||||
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
|
||||
@@ -24,8 +25,9 @@ from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.downloads.status import JobStatus
|
||||
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
|
||||
# (error, canceled) don't count.
|
||||
@@ -66,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
|
||||
# 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
|
||||
# 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(
|
||||
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
||||
@@ -83,10 +86,10 @@ def footprint(db: Session, user_id: int) -> int:
|
||||
held_assets = (
|
||||
select(DownloadJob.asset_id)
|
||||
.where(
|
||||
DownloadJob.user_id == user_id,
|
||||
owned_by(DownloadJob.user_id, user_id),
|
||||
or_(
|
||||
DownloadJob.status.in_(_HOLDING),
|
||||
and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR),
|
||||
and_(DownloadJob.job_kind == JobKind.UPLOAD, DownloadJob.status == JobStatus.ERROR),
|
||||
),
|
||||
DownloadJob.asset_id.is_not(None),
|
||||
)
|
||||
@@ -107,7 +110,7 @@ def _count(db: Session, user_id: int, states) -> int:
|
||||
db.execute(
|
||||
select(func.count())
|
||||
.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()
|
||||
or 0
|
||||
)
|
||||
@@ -199,3 +202,46 @@ def usage(db: Session, user_id: int) -> dict:
|
||||
"max_jobs": lim.max_jobs,
|
||||
"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
|
||||
|
||||
@@ -22,7 +22,8 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
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__)
|
||||
|
||||
@@ -95,14 +96,16 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
|
||||
(`cause=child_exit`)."""
|
||||
# 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.
|
||||
from app.downloads.pipeline import _convert_upload, _download, _process_edit
|
||||
from app.downloads.pipeline import _convert_upload, _download, _process_clip, _process_edit
|
||||
|
||||
with formats.hiding(source_ref):
|
||||
try:
|
||||
if job_kind == "edit":
|
||||
if job_kind == JobKind.EDIT:
|
||||
_process_edit(job_id, asset_id)
|
||||
elif job_kind == "upload":
|
||||
elif job_kind == JobKind.UPLOAD:
|
||||
_convert_upload(job_id, asset_id)
|
||||
elif job_kind == JobKind.CLIP:
|
||||
_process_clip(job_id, asset_id)
|
||||
else:
|
||||
_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
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
"""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
|
||||
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
|
||||
handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and
|
||||
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
||||
handles everything else. Every function here flushes and leaves the commit to its caller (the
|
||||
handler); the folder, link and quota writes are `folders`, `links` and `quota`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete as delete_rows
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import adapters
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import credentials
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
||||
from app.downloads import folders, formats, mediakind, quota, storage
|
||||
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, Channel, PlexItem, PlexShow, User, 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):
|
||||
@@ -38,12 +52,15 @@ def source_url(source_kind: str, source_ref: str) -> str:
|
||||
|
||||
|
||||
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
|
||||
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
|
||||
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
||||
if job is None or job.job_kind == "edit":
|
||||
own earlier download, not a web page) and for a Plex clip (the library is not a public page); a
|
||||
YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else
|
||||
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
|
||||
if job.job_kind == JobKind.CLIP:
|
||||
return clipmod.reference_url(job.edit_spec or {})
|
||||
if asset is not None and asset.source_webpage_url:
|
||||
return asset.source_webpage_url
|
||||
if job.source_kind in ("youtube", "url"):
|
||||
@@ -51,6 +68,23 @@ def reference_url(job, asset) -> str | 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(
|
||||
db: Session, source_kind: str, source_ref: str, format_sig: str
|
||||
) -> MediaAsset:
|
||||
@@ -63,7 +97,12 @@ def get_or_create_asset(
|
||||
MediaAsset.source_ref == source_ref,
|
||||
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:
|
||||
# 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
|
||||
@@ -180,24 +219,25 @@ def enqueue(
|
||||
profile_snapshot=spec,
|
||||
display_name=display_name
|
||||
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),
|
||||
)
|
||||
db.add(job)
|
||||
folders.place(db, job)
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
# Cache hit: the file already exists — no worker round-trip needed.
|
||||
if asset.status == "ready":
|
||||
job.status = "done"
|
||||
job.status = JobStatus.DONE
|
||||
job.progress = 100
|
||||
folders.stamp_done(db, job)
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
db.flush()
|
||||
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
|
||||
# `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.
|
||||
@@ -239,7 +279,7 @@ def enqueue_edit(
|
||||
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||
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.thumbnail_url = src_asset.thumbnail_url
|
||||
asset.upload_date = src_asset.upload_date
|
||||
@@ -251,7 +291,7 @@ def enqueue_edit(
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="edit",
|
||||
job_kind=JobKind.EDIT,
|
||||
source_kind="edit",
|
||||
source_ref=ref,
|
||||
source_job_id=source_job.id,
|
||||
@@ -259,19 +299,108 @@ def enqueue_edit(
|
||||
edit_spec=spec,
|
||||
profile_snapshot={},
|
||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||
status="queued",
|
||||
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": # identical clip already produced for this user → instant
|
||||
job.status = "done"
|
||||
job.status = JobStatus.DONE
|
||||
job.progress = 100
|
||||
folders.stamp_done(db, job)
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
db.flush()
|
||||
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
|
||||
|
||||
|
||||
@@ -291,6 +420,8 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
||||
if not fields:
|
||||
return False
|
||||
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
|
||||
# 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
|
||||
@@ -304,6 +435,41 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
||||
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:
|
||||
"""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,
|
||||
@@ -335,15 +501,15 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
||||
)
|
||||
if new_count == 0 and freeable:
|
||||
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)
|
||||
|
||||
|
||||
# --- Job state transitions ---------------------------------------------------------------------
|
||||
# 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
|
||||
# the worker/GC could share the same definitions (B-sub-8). Each commits its own transition; the
|
||||
# route keeps only auth + HTTP mapping + serialization.
|
||||
# the worker/GC could share the same definitions (B-sub-8). Each flushes its transition and the caller
|
||||
# commits it; the route keeps only auth + HTTP mapping + serialization.
|
||||
|
||||
|
||||
def pause(db: Session, job: DownloadJob) -> None:
|
||||
@@ -351,7 +517,7 @@ def pause(db: Session, job: DownloadJob) -> None:
|
||||
in any other state."""
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING):
|
||||
job.status = JobStatus.PAUSED
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def resume(db: Session, job: DownloadJob) -> None:
|
||||
@@ -375,7 +541,7 @@ def resume(db: Session, job: DownloadJob) -> None:
|
||||
asset.status = "pending"
|
||||
asset.error = None
|
||||
job.status = JobStatus.QUEUED
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def _follow_the_cookie(db: Session, job: DownloadJob, asset: MediaAsset) -> MediaAsset:
|
||||
@@ -410,14 +576,14 @@ def cancel(db: Session, job: DownloadJob) -> None:
|
||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||
release_asset(db, job) # release while the job still counts as holding
|
||||
job.status = JobStatus.CANCELED
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
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)."""
|
||||
release_asset(db, job)
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def redownload(db: Session, job: DownloadJob) -> None:
|
||||
@@ -464,4 +630,183 @@ def redownload(db: Session, job: DownloadJob) -> None:
|
||||
# name would survive the rebuild).
|
||||
job.display_name = None
|
||||
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}
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
# 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})
|
||||
|
||||
|
||||
# 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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
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:
|
||||
"""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
|
||||
@@ -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:
|
||||
"""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,
|
||||
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:
|
||||
"""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
|
||||
the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster
|
||||
@@ -161,7 +213,7 @@ def _clean_upload_ext(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
|
||||
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||
@@ -242,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:
|
||||
"""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
|
||||
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(".")
|
||||
base = display_filename(display_name)
|
||||
if not ext:
|
||||
return base
|
||||
if base.lower().endswith(f".{ext.lower()}"):
|
||||
base = base[: -(len(ext) + 1)]
|
||||
return f"{base}.{ext}"
|
||||
|
||||
|
||||
def abs_path(download_root: str, rel: str) -> Path:
|
||||
return Path(download_root) / rel
|
||||
def abs_path(files_root: str, rel: str) -> Path:
|
||||
return Path(files_root) / rel
|
||||
|
||||
|
||||
def safe_abs_path(download_root: str, rel: str) -> Path | None:
|
||||
"""The resolved absolute path for `rel`, but ONLY if it stays inside DOWNLOAD_ROOT and exists on
|
||||
disk — otherwise None. Centralizes the path-traversal + existence guard every file-serving endpoint
|
||||
shares, so the containment check lives in exactly one place."""
|
||||
root = Path(download_root).resolve()
|
||||
path = abs_path(download_root, rel).resolve()
|
||||
def safe_abs_path(files_root: str, rel: str | None) -> Path | None:
|
||||
"""The resolved absolute path for `rel`, but ONLY if it stays inside FILES_ROOT and exists on
|
||||
disk — otherwise None (no `rel` at all included: an asset without a file has nothing to serve).
|
||||
Centralizes the path-traversal + existence guard every file-serving endpoint shares, so the
|
||||
containment check lives in exactly one place."""
|
||||
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():
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def place_file(staging_file: Path, download_root: str, rel: str) -> None:
|
||||
"""Move a completed staging file into its final location under DOWNLOAD_ROOT."""
|
||||
dest = abs_path(download_root, rel)
|
||||
def place_file(staging_file: Path, files_root: str, rel: str) -> None:
|
||||
"""Move a completed staging file into its final location under FILES_ROOT."""
|
||||
dest = abs_path(files_root, rel)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(staging_file), str(dest))
|
||||
|
||||
@@ -301,9 +360,9 @@ def _sidecar(media: Path, ext: str) -> Path:
|
||||
return media.with_suffix(ext)
|
||||
|
||||
|
||||
def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None) -> bool:
|
||||
def write_sidecars(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."""
|
||||
media = abs_path(download_root, rel)
|
||||
media = abs_path(files_root, rel)
|
||||
try:
|
||||
_sidecar(media, ".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
|
||||
if thumb_src and thumb_src.exists():
|
||||
@@ -319,9 +378,9 @@ def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Pat
|
||||
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)."""
|
||||
media = abs_path(download_root, rel)
|
||||
media = abs_path(files_root, rel)
|
||||
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
||||
try:
|
||||
p.unlink()
|
||||
@@ -335,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__),
|
||||
)
|
||||
# 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
|
||||
while parent != root and root in parent.parents:
|
||||
try:
|
||||
|
||||
@@ -31,11 +31,14 @@ from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.audit import AuditAction
|
||||
from app.config import settings
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import mediakind, playable, probe, quota, service, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, MediaAsset, display_text
|
||||
from app.downloads import folders, mediakind, playable, probe, quota, service, storage
|
||||
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:
|
||||
@@ -53,14 +56,26 @@ def ingest_upload(
|
||||
src_path: Path,
|
||||
size: int,
|
||||
display_name: str | None = None,
|
||||
sha256: str | None = None,
|
||||
folder_id: int | None = None,
|
||||
via: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
||||
`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).
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
data = probe.probe_json(src_path)
|
||||
vcodec, acodec, width, height, duration = probe.facts(data)
|
||||
@@ -81,18 +96,21 @@ def ingest_upload(
|
||||
db.flush() # need asset.id for the on-disk path
|
||||
|
||||
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
|
||||
# asset/job rows roll back and nothing names the placed file: the per-user delete path keys off
|
||||
# asset.rel_path (which is gone), and the GC's `.uploads` sweep reaches it only an hour later. So
|
||||
# roll back the on-disk side too — unless the commit itself raised after it landed (see below).
|
||||
asset_id = asset.id
|
||||
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
|
||||
|
||||
asset.status = "pending" if convert else "ready"
|
||||
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.container = ext or None
|
||||
asset.vcodec = vcodec
|
||||
@@ -108,12 +126,12 @@ def ingest_upload(
|
||||
# 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
|
||||
# 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(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="upload",
|
||||
job_kind=JobKind.UPLOAD,
|
||||
source_kind="upload",
|
||||
source_ref=ref,
|
||||
profile_snapshot={},
|
||||
@@ -123,7 +141,21 @@ def ingest_upload(
|
||||
queue_pos=service.next_queue_pos(db),
|
||||
)
|
||||
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)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
user_id,
|
||||
AuditAction.FILE_UPLOAD,
|
||||
target_type="file",
|
||||
target_id=job.id,
|
||||
summary=via,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -139,7 +171,7 @@ def ingest_upload(
|
||||
# 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).
|
||||
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
|
||||
db.refresh(job)
|
||||
return job
|
||||
|
||||
@@ -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
|
||||
+18
-3
@@ -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:
|
||||
ts · level · sev · source · logger · event · msg · attrs
|
||||
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
|
||||
`job_id`) and the app version. That shape is what the admin log console filters on and what a Gitea
|
||||
issue attachment will carry, so it is deliberately flat and stable.
|
||||
`job_id`, `run_id`) and the app version. That shape is what the admin log console filters on and
|
||||
what a Gitea issue attachment will carry, so it is deliberately flat and stable.
|
||||
|
||||
Call sites stay ordinary stdlib logging:
|
||||
|
||||
@@ -81,15 +81,28 @@ _debug_session: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
"log_debug_session", 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 = {
|
||||
"request_id": _request_id,
|
||||
"user_id": _user_id,
|
||||
"debug_session_id": _debug_session,
|
||||
"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:
|
||||
"""What the auth dependency learns about a request, for every line logged after it.
|
||||
|
||||
@@ -690,7 +703,9 @@ _POLLED_ROUTES = frozenset(
|
||||
"/api/me/notifications/unread_count",
|
||||
"/api/downloads",
|
||||
"/api/downloads/index",
|
||||
"/api/downloads/usage",
|
||||
"/api/files",
|
||||
"/api/files/folders",
|
||||
"/api/files/usage",
|
||||
"/api/sync/my-status",
|
||||
"/api/admin/scheduler",
|
||||
"/api/admin/downloads",
|
||||
|
||||
@@ -362,6 +362,7 @@ def serialize(row: LogEvent) -> dict:
|
||||
"request_id": row.request_id,
|
||||
"user_id": row.user_id,
|
||||
"job_id": row.job_id,
|
||||
"run_id": row.run_id,
|
||||
"debug_session_id": row.debug_session_id,
|
||||
"version": row.version,
|
||||
}
|
||||
|
||||
+26
-1
@@ -28,6 +28,8 @@ log = logs.get_logger(__name__)
|
||||
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.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
||||
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 subtitles as plex_subtitles # noqa: E402 — logging is configured first
|
||||
from app.routes import ( # noqa: E402 — logging is configured first
|
||||
@@ -42,6 +44,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
||||
discover as discover_routes,
|
||||
downloads,
|
||||
feed,
|
||||
files as files_routes,
|
||||
health,
|
||||
me,
|
||||
notifications,
|
||||
@@ -49,12 +52,14 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
||||
plex as plex_routes,
|
||||
public as public_routes,
|
||||
quota,
|
||||
runs as runs_routes,
|
||||
saved_views,
|
||||
scheduler as scheduler_routes,
|
||||
search as search_routes,
|
||||
setup as setup_routes,
|
||||
sync,
|
||||
tags,
|
||||
tokens as tokens_routes,
|
||||
version,
|
||||
)
|
||||
from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first
|
||||
@@ -63,6 +68,10 @@ from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 —
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
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.
|
||||
logsink.start()
|
||||
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
|
||||
@@ -78,6 +87,18 @@ async def lifespan(app: FastAPI):
|
||||
# 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.
|
||||
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):
|
||||
token = state.rotate_setup_token(db)
|
||||
url = f"{settings.app_base}/setup?token={token}"
|
||||
@@ -267,7 +288,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:
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
@@ -316,12 +338,15 @@ app.include_router(saved_views.router)
|
||||
app.include_router(plex_routes.router)
|
||||
app.include_router(downloads.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(admin.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(scheduler_routes.router)
|
||||
app.include_router(audit_routes.router)
|
||||
app.include_router(admin_logs_routes.router)
|
||||
app.include_router(runs_routes.router)
|
||||
app.include_router(quota.router)
|
||||
app.include_router(version.router)
|
||||
app.include_router(setup_routes.router)
|
||||
|
||||
+88
-267
@@ -23,6 +23,7 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
from app.runstatus import RunStatus, RunTrigger, sql_in
|
||||
from app.utils import canonical_email
|
||||
|
||||
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
|
||||
@@ -38,7 +39,7 @@ def display_text(raw: str | None) -> str | None:
|
||||
blank -> None.
|
||||
|
||||
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
|
||||
`POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and
|
||||
`POST /api/downloads/edit`, `POST /api/files/upload`, the library PATCH's two fields, and
|
||||
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
|
||||
than refusing: the name is a label on work the user has already done — a file already
|
||||
transferred, an account already authenticated — and losing its tail is not worth failing the
|
||||
@@ -123,7 +124,8 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
||||
# account can't sign in by any method and its live sessions are rejected; the user is told
|
||||
# why and pointed at the operator's contact.
|
||||
# why and pointed at the operator's contact. The worker leaves its queued downloads unclaimed
|
||||
# until the block is lifted (`worker._claim_job`).
|
||||
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
||||
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
||||
@@ -462,27 +464,6 @@ class VideoState(Base, UpdatedAtMixin):
|
||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
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 SearchFind(Base, TimestampMixin):
|
||||
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
||||
|
||||
@@ -561,14 +542,20 @@ class QuotaEvent(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
# The background run that spent it (app/jobruns.py); NULL = a request's own spend. SET NULL: a
|
||||
# reaped run leaves the spend in the budget's history.
|
||||
job_run_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("job_runs.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Append-only admin/scheduler audit trail: who did what, when, and (where it applies)
|
||||
the before->after values. `actor_id` is the acting admin; NULL means the scheduler /
|
||||
background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run
|
||||
granularity — never per-row — so a scheduler run that touches thousands of videos is ONE
|
||||
row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded)."""
|
||||
"""Append-only admin audit trail: who did what, when, and (where it applies) the before->after
|
||||
values. `actor_id` is the acting admin; NULL means the background did it (SET NULL on user
|
||||
delete keeps the trail). Logged per ACTION, never per row; a background run is not an action
|
||||
and has its own history (`JobRun`). `before`/`after` are small JSON snapshots (secrets
|
||||
excluded). `request_id` is the request that made the change, so its log lines are one click
|
||||
away (NULL for rows written outside a request, and for rows older than the column)."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
@@ -585,6 +572,7 @@ class AuditLog(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
|
||||
class LogEvent(Base):
|
||||
@@ -607,6 +595,8 @@ class LogEvent(Base):
|
||||
"debug_session_id",
|
||||
postgresql_where="debug_session_id IS NOT NULL",
|
||||
),
|
||||
# A run's lines are `WHERE run_id = …`; request lines carry none.
|
||||
Index("ix_log_events_run", "run_id", postgresql_where="run_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
@@ -622,7 +612,11 @@ class LogEvent(Base):
|
||||
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer)
|
||||
debug_session_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# A DOWNLOAD job's id (download_jobs) — not a background run, which is `run_id`.
|
||||
job_id: Mapped[int | None] = mapped_column(Integer)
|
||||
# The background run (job_runs) that logged it. No foreign key, as `user_id`: the writer runs
|
||||
# behind the event, and a run reaped meanwhile must not fail the batch its lines travel in.
|
||||
run_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
version: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
@@ -687,15 +681,44 @@ class AppState(Base):
|
||||
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class SchedulerSetting(Base):
|
||||
"""Admin override for a scheduler job's run interval (minutes). One row per job id;
|
||||
absent = use the env/config default. DB-backed so it's editable from the admin UI at
|
||||
runtime without a redeploy (env stays a fallback)."""
|
||||
class JobRun(Base):
|
||||
"""One run of a background job — scheduled, an admin's "run now", or a user's ad-hoc work on one
|
||||
target — written when it starts and again when it ends (app/jobruns.py), each in its own
|
||||
committed session, so the history survives the job's own rollback and a restart. A row left
|
||||
`running` by a process that died is set `interrupted` at the next startup.
|
||||
|
||||
__tablename__ = "scheduler_settings"
|
||||
`quota_units` is what the run spent of the YouTube budget: the `quota_events` rows carrying its
|
||||
id, summed when it ends. `summary` is the English one-line fallback; `result` / `failure` are the
|
||||
same outcome as data, which the client names in the reader's language."""
|
||||
|
||||
job_id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
interval_minutes: Mapped[int] = mapped_column(Integer)
|
||||
__tablename__ = "job_runs"
|
||||
__table_args__ = (
|
||||
Index("ix_job_runs_job_started", "job", "started_at"),
|
||||
CheckConstraint(f"status IN ({sql_in(RunStatus)})", name="ck_job_runs_status"),
|
||||
CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"),
|
||||
# Only a run still going has no end.
|
||||
CheckConstraint(
|
||||
f"(status = '{RunStatus.RUNNING}') = (finished_at IS NULL)", name="ck_job_runs_finished"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
job: Mapped[str] = mapped_column(String(40))
|
||||
trigger: Mapped[str] = mapped_column(String(16))
|
||||
# Who started it: the admin of a manual run, the user of an ad-hoc one; NULL = the schedule.
|
||||
actor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
# What an ad-hoc run worked on (a channel id); NULL for the recurring jobs.
|
||||
target: Mapped[str | None] = mapped_column(String(128))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
result: Mapped[dict | None] = mapped_column(JSONB)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
failure: Mapped[dict | None] = mapped_column(JSONB)
|
||||
quota_units: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
@@ -816,247 +839,45 @@ class Notification(Base):
|
||||
)
|
||||
|
||||
|
||||
class MediaAsset(Base, TimestampMixin):
|
||||
"""A physical downloaded media file — the SHARED cache layer of the download center.
|
||||
class ApiToken(Base, TimestampMixin):
|
||||
"""A user's token for a machine to reach their Files library with (`app.apitokens`): sent as
|
||||
`Authorization: Bearer sfl_…`, honoured on `/api/files` only, and only in the folders its
|
||||
grants name. Only the SHA-256 of the token is kept — it is shown once, when it is made — plus
|
||||
its first characters (`prefix`), so the user can tell their tokens apart."""
|
||||
|
||||
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.
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
`rel_path` is relative to `settings.download_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)
|
||||
plus `last_access_at`/`expires_at` drive retention + LRU eviction in the GC job."""
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
prefix: Mapped[str] = mapped_column(String(16))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# The one notice its user gets before it runs out (`apitokens.warn_expiring`).
|
||||
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
__tablename__ = "media_assets"
|
||||
|
||||
class ApiTokenGrant(Base):
|
||||
"""What one token may do in one folder. A folder with no grant is out of the token's reach."""
|
||||
|
||||
__tablename__ = "api_token_grants"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
||||
UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"),
|
||||
CheckConstraint(
|
||||
"can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some"
|
||||
),
|
||||
)
|
||||
|
||||
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 download_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 download_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")
|
||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# 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 on the Downloads page. 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))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
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 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"),)
|
||||
|
||||
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
|
||||
token_id: Mapped[int] = mapped_column(ForeignKey("api_tokens.id", ondelete="CASCADE"))
|
||||
folder_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("file_folders.id", ondelete="CASCADE"), 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) or "upload" (a
|
||||
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`).
|
||||
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}}.
|
||||
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")
|
||||
|
||||
|
||||
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 — 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). 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"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
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
|
||||
can_list: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
|
||||
# --- Plex integration (optional) -------------------------------------------------------------
|
||||
|
||||
@@ -10,4 +10,4 @@ from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
|
||||
CACHE_DIR = Path(settings.files_root) / ".plex-img-cache"
|
||||
|
||||
@@ -3,9 +3,24 @@ 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
|
||||
router. Pure functions — no DB, no request state."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.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
|
||||
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
||||
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
||||
@@ -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 {
|
||||
"id": sh.rating_key,
|
||||
"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),
|
||||
"season_count": sh.child_count,
|
||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||
"status": status,
|
||||
"status": state.status,
|
||||
"resumable": state.resumable,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -167,12 +167,16 @@ class PlexClient:
|
||||
keys = ",".join(str(k) for k in rating_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:
|
||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
||||
def create_collection(
|
||||
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="")
|
||||
mc = self._write(
|
||||
"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}",
|
||||
)
|
||||
items = mc.get("Metadata", []) or []
|
||||
|
||||
@@ -7,6 +7,7 @@ the two never diverge — the same one-parser rule that keeps `people.py`'s cast
|
||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||
|
||||
from app.plex import people
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
|
||||
def rich_detail(meta: dict) -> dict:
|
||||
@@ -42,7 +43,7 @@ def rich_detail(meta: dict) -> dict:
|
||||
"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],
|
||||
"studio": meta.get("studio"),
|
||||
"tagline": meta.get("tagline"),
|
||||
"tagline": plain_text(meta.get("tagline")),
|
||||
"cast": people.cast_from_meta(meta),
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient
|
||||
from app.plex.genres import hu_genres
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
|
||||
@@ -109,8 +110,8 @@ def _overlay_pass(
|
||||
for g, meta in hu_by_guid.items():
|
||||
targets = by_guid[g]
|
||||
title_hu = (meta.get("title") or "").strip() or None
|
||||
summary_hu = meta.get("summary")
|
||||
tagline_hu = (meta.get("tagline") or "").strip() or None
|
||||
summary_hu = plain_text(meta.get("summary"))
|
||||
tagline_hu = plain_text(meta.get("tagline"))
|
||||
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
||||
for row in targets:
|
||||
row.title_hu = title_hu
|
||||
|
||||
@@ -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
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import PlexItem, PlexState
|
||||
from app.plex.cards import ShowState
|
||||
|
||||
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||
# same at every rail that merges two type-homogeneous lists in Python.
|
||||
@@ -32,6 +33,11 @@ def show_status(total: int, watched: int, inprog: int) -> str:
|
||||
return "new"
|
||||
|
||||
|
||||
def show_state(total: int, watched: int, inprog: int) -> ShowState:
|
||||
"""`show_status` plus whether an episode holds a resume point (the card's `ShowState`)."""
|
||||
return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0)
|
||||
|
||||
|
||||
def rollup(cards: list[dict]) -> dict:
|
||||
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
||||
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
||||
@@ -92,16 +98,16 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
)
|
||||
|
||||
|
||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowState]:
|
||||
if not show_ids:
|
||||
return {}
|
||||
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
|
||||
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||
return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows}
|
||||
|
||||
|
||||
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
|
||||
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
|
||||
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
|
||||
leaf analogue of `state_map` (which rolls a show's episodes up to a `ShowState`). One batched
|
||||
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
||||
if not item_ids:
|
||||
return {}
|
||||
|
||||
@@ -35,13 +35,13 @@ from app.config import settings
|
||||
from app.downloads.edit import encode_thread_cap
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths
|
||||
from app.plex.sync import HW_DECODABLE_CODECS
|
||||
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
_HLS_ROOT = (
|
||||
Path(settings.plex_hls_dir)
|
||||
if settings.plex_hls_dir
|
||||
else Path(settings.download_root) / ".plex-hls"
|
||||
else Path(settings.files_root) / ".plex-hls"
|
||||
)
|
||||
_SEG_SECONDS = 6
|
||||
# Cold-start warm-up. A freshly-spawned ffmpeg has produced only the first segment (or two) by the
|
||||
@@ -203,7 +203,8 @@ _HW_DECODABLE = HW_DECODABLE_CODECS
|
||||
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
|
||||
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
|
||||
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
|
||||
# THE single source is plex.sync (a clip's tonemap reads it too).
|
||||
_HDR_TRANSFERS = HDR_TRANSFERS
|
||||
|
||||
|
||||
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.plex import detail, overlay, people
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.genres import hu_genres
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.plaintext import plain_text
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
||||
@@ -63,9 +64,11 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
|
||||
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||
# own encoder).
|
||||
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
||||
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
|
||||
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
|
||||
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
|
||||
# one is tonemapped by all. A 10-bit/HDR source breaks the VAAPI scale=nv12 path on Gen 9.5 (see
|
||||
# plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||
HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||
|
||||
|
||||
def transcode_hw_capable(
|
||||
@@ -106,7 +109,7 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
||||
# whole library would drown the sync's own lines.
|
||||
except (TypeError, ValueError):
|
||||
depth = None
|
||||
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
||||
hdr = str(s.get("colorTrc") or "").lower() in HDR_TRANSFERS
|
||||
return depth, hdr
|
||||
return None, False
|
||||
|
||||
@@ -468,7 +471,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
|
||||
row.guid = _canonical_guid(meta) or row.guid
|
||||
row.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
row.summary = meta.get("summary")
|
||||
row.summary = plain_text(meta.get("summary"))
|
||||
row.year = meta.get("year")
|
||||
row.duration_s = _dur_s(meta)
|
||||
row.thumb_key = meta.get("thumb")
|
||||
@@ -576,7 +579,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
_canonical_guid(meta) or sh.guid
|
||||
) # EN<->HU pairing key; keep prior if listing has none
|
||||
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
sh.summary = meta.get("summary")
|
||||
sh.summary = plain_text(meta.get("summary"))
|
||||
sh.year = meta.get("year")
|
||||
sh.thumb_key = meta.get("thumb")
|
||||
sh.art_key = meta.get("art")
|
||||
@@ -659,7 +662,7 @@ def _upsert_collection(
|
||||
db.add(col)
|
||||
col.library_id = lib_id
|
||||
col.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
col.summary = meta.get("summary")
|
||||
col.summary = plain_text(meta.get("summary"))
|
||||
col.thumb_key = meta.get("thumb")
|
||||
col.child_count = meta.get("childCount")
|
||||
col.smart = str(meta.get("smart")) == "1"
|
||||
|
||||
+11
-3
@@ -16,7 +16,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app import logs, sysconfig
|
||||
from app.db import SessionLocal
|
||||
from app.models import ApiQuotaUsage, QuotaEvent
|
||||
|
||||
@@ -204,6 +204,14 @@ def record_usage(units: int) -> None:
|
||||
)
|
||||
with SessionLocal() as qs:
|
||||
qs.execute(stmt)
|
||||
# Audit detail: who/what spent it (per-user attribution; NULL actor = system).
|
||||
qs.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units))
|
||||
# Audit detail: who/what spent it (per-user attribution; NULL actor = system), and the
|
||||
# background run it belongs to, so the run's row can say what it cost.
|
||||
qs.add(
|
||||
QuotaEvent(
|
||||
user_id=_actor_id.get(),
|
||||
action=_action.get(),
|
||||
units=units,
|
||||
job_run_id=logs.current_run_id(),
|
||||
)
|
||||
)
|
||||
qs.commit()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""The account filter the activity page's lists share (runs, admin actions, server log, quota): one
|
||||
filter bar drives them all, so the value it sends means the same on every tab.
|
||||
|
||||
The value is an account id, or `none` — the rows no account made (the background) — a value to filter
|
||||
on, not the absence of a filter.
|
||||
"""
|
||||
|
||||
from sqlalchemy import ColumnElement
|
||||
|
||||
NONE = "none"
|
||||
# The `user` query parameter's shape on every route that takes it.
|
||||
PATTERN = r"^(none|[0-9]{1,10})$"
|
||||
|
||||
|
||||
def condition(column, value: str) -> ColumnElement[bool]:
|
||||
"""`column` (an account id column) narrowed to `value`."""
|
||||
return column.is_(None) if value == NONE else column == int(value)
|
||||
@@ -6,7 +6,7 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
|
||||
one existence-leak policy.
|
||||
"""
|
||||
|
||||
from typing import IO, Any, TypeVar
|
||||
from typing import Any, Protocol, TypeVar
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -16,6 +16,7 @@ from app.db import Base
|
||||
from app.models import POSITION_MAX_S, User
|
||||
from app.issues import gitea
|
||||
from app.refusal import refused
|
||||
from app.userscope import owned_row
|
||||
from app.youtube.client import YouTubeError, refusal_reason
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
@@ -90,8 +91,8 @@ def owned_or_404(
|
||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
||||
obj = get_or_404(db, model, id_, what=what, lock=lock)
|
||||
if getattr(obj, owner_attr) != user.id:
|
||||
obj = owned_row(db, model, id_, user, owner_attr=owner_attr, lock=lock)
|
||||
if obj is None:
|
||||
raise not_found(what)
|
||||
return obj
|
||||
|
||||
@@ -122,8 +123,14 @@ async def capped_body(request: Request, limit: int) -> bytes:
|
||||
return bytes(body)
|
||||
|
||||
|
||||
class ByteSink(Protocol):
|
||||
"""Where `capped_stream_to` writes: a file, or anything that takes the bytes on their way to one."""
|
||||
|
||||
def write(self, data: bytes, /) -> object: ...
|
||||
|
||||
|
||||
async def capped_stream_to(
|
||||
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
|
||||
request: Request, out: ByteSink, limit: int, refusal: HTTPException
|
||||
) -> int:
|
||||
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
|
||||
straight to `out` instead of accumulated, returning the size.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Keyset paging over `(timestamp desc, id desc)`, shared by the admin history lists (server log,
|
||||
audit log, background runs).
|
||||
|
||||
A timestamp alone is not unique (a browser log row's `ts` is clamped into its consent window; a run
|
||||
and its siblings can start in the same microsecond), and an offset would skip or repeat rows while
|
||||
writers add new ones at the top — so a page continues from the last row's `(timestamp, id)`.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import User
|
||||
|
||||
PAGE_MAX = 200
|
||||
|
||||
|
||||
def utc(value: datetime | None) -> datetime | None:
|
||||
"""A bound without a zone is UTC — the columns are `timestamptz`, and a naive value would be read
|
||||
in the database session's zone instead."""
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def encode_cursor(ts: datetime, row_id: int) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps([ts.isoformat(), row_id]).encode()).decode()
|
||||
|
||||
|
||||
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||
try:
|
||||
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||
at = datetime.fromisoformat(ts)
|
||||
return (at if at.tzinfo is not None else at.replace(tzinfo=timezone.utc)), int(row_id)
|
||||
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
||||
|
||||
|
||||
def older_than(ts_col, id_col, cursor: str):
|
||||
"""The rows after `cursor` in `(ts desc, id desc)` order. The `ts <=` conjunct is implied by the
|
||||
OR; it is there so the planner can bound the timestamp index scan instead of filtering every
|
||||
newer row first."""
|
||||
ts, row_id = decode_cursor(cursor)
|
||||
return and_(ts_col <= ts, or_(ts_col < ts, and_(ts_col == ts, id_col < row_id)))
|
||||
|
||||
|
||||
def page(rows: list, limit: int, ts_of) -> tuple[list, str | None]:
|
||||
"""`rows` was read with `limit + 1` (to know whether there is a next page without a count query):
|
||||
the page itself, and the cursor that continues after it (None on the last page)."""
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return rows, encode_cursor(ts_of(rows[-1]), rows[-1].id) if more else None
|
||||
|
||||
|
||||
def accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
||||
"""The accounts a page names, by id → address. An id with no account left (erased after the row
|
||||
was written) is simply absent, and the client shows the bare id."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
|
||||
return {str(uid): email for uid, email in rows}
|
||||
@@ -484,7 +484,8 @@ def set_user_suspended(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
|
||||
live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
|
||||
live session is dropped on its next request; their queued downloads wait (the worker skips them)
|
||||
until they are un-suspended. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
|
||||
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||
sign-in)."""
|
||||
suspended = body.suspended
|
||||
|
||||
@@ -8,33 +8,29 @@ What a row holds was redacted when it was written (`logs.record_fields`, and the
|
||||
browser's rows), so this serves the columns as they are; a value the redactor took out reads as
|
||||
`[redacted]` / `[email]`, which is the point — the console shows that something was there.
|
||||
|
||||
Paging is a keyset over `(ts desc, id desc)`: a browser row's `ts` is clamped into its consent
|
||||
window, so `ts` alone is not unique, and an offset would skip or repeat rows while the writer adds
|
||||
new ones at the top. The live tail re-reads the first page rather than asking for "ids after N":
|
||||
Paging is a keyset over `(ts desc, id desc)` (`_keyset`). The live tail re-reads the first page rather than asking for "ids after N":
|
||||
several processes write this table, and a batch's ids are taken when it inserts, not when it
|
||||
commits, so a slower batch can land below an id the tail already passed — and would never show.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Text, cast, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logsink
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import LogEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
||||
from app.utils import like_escape
|
||||
|
||||
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
|
||||
|
||||
PAGE_MAX = 200
|
||||
# A facet list is a chip row, not a report: past this many distinct events the rest are reachable
|
||||
# through the free-text search, and the response stays small whatever a self-host logs.
|
||||
FACET_EVENTS_MAX = 100
|
||||
@@ -42,38 +38,9 @@ FACET_EVENTS_MAX = 100
|
||||
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
|
||||
Facet = Literal["levels", "sources", "user", "event"]
|
||||
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
|
||||
# request) — a real value to filter on, not the absence of a filter.
|
||||
NONE = "none"
|
||||
|
||||
|
||||
def _utc(value: datetime | None) -> datetime | None:
|
||||
"""A bound without a zone is UTC — the column is `timestamptz`, and a naive value would be read
|
||||
in the database session's zone instead."""
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _encode_cursor(row: LogEvent) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps([row.ts.isoformat(), row.id]).encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||
try:
|
||||
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||
return _utc(datetime.fromisoformat(ts)), int(row_id)
|
||||
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
||||
|
||||
|
||||
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
||||
"""The accounts a page names, by id → address. An id with no account left (erased after the
|
||||
line was written) is simply absent, and the client shows the bare id."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
|
||||
return {str(uid): email for uid, email in rows}
|
||||
# `none` selects the rows that have no event code, as the account filter's `none` (app/routes/_account)
|
||||
# selects the rows of no account — a real value to filter on, not the absence of a filter.
|
||||
NONE = account.NONE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -86,6 +53,7 @@ class LogFilters:
|
||||
event: str | None
|
||||
request_id: str | None
|
||||
job_id: int | None
|
||||
run_id: int | None
|
||||
q: str | None
|
||||
since: datetime | None
|
||||
until: datetime | None
|
||||
@@ -100,11 +68,7 @@ class LogFilters:
|
||||
if self.sources and skip != "sources":
|
||||
out.append(LogEvent.source.in_(self.sources))
|
||||
if self.user is not None and skip != "user":
|
||||
out.append(
|
||||
LogEvent.user_id.is_(None)
|
||||
if self.user == NONE
|
||||
else LogEvent.user_id == int(self.user)
|
||||
)
|
||||
out.append(account.condition(LogEvent.user_id, self.user))
|
||||
if self.event is not None and skip != "event":
|
||||
out.append(
|
||||
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
|
||||
@@ -113,10 +77,12 @@ class LogFilters:
|
||||
out.append(LogEvent.request_id == self.request_id)
|
||||
if self.job_id is not None:
|
||||
out.append(LogEvent.job_id == self.job_id)
|
||||
if self.run_id is not None:
|
||||
out.append(LogEvent.run_id == self.run_id)
|
||||
if self.since is not None:
|
||||
out.append(LogEvent.ts >= _utc(self.since))
|
||||
out.append(LogEvent.ts >= utc(self.since))
|
||||
if self.until is not None:
|
||||
out.append(LogEvent.ts < _utc(self.until))
|
||||
out.append(LogEvent.ts < utc(self.until))
|
||||
if self.q and self.q.strip():
|
||||
like = f"%{like_escape(self.q.strip())}%"
|
||||
out.append(
|
||||
@@ -134,17 +100,20 @@ class LogFilters:
|
||||
def log_filters(
|
||||
levels: list[Level] = Query(default=[]),
|
||||
sources: list[str] = Query(default=[], max_length=8),
|
||||
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
|
||||
user: str | None = Query(default=None, pattern=account.PATTERN),
|
||||
event: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
|
||||
),
|
||||
request_id: str | None = Query(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$"),
|
||||
job_id: int | None = Query(default=None, ge=1),
|
||||
run_id: int | None = Query(default=None, ge=1),
|
||||
q: str | None = Query(default=None, max_length=200),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> LogFilters:
|
||||
return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
|
||||
return LogFilters(
|
||||
list(levels), list(sources), user, event, request_id, job_id, run_id, q, since, until
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -159,28 +128,20 @@ def list_logs(
|
||||
continues from a previous page's `next_cursor`."""
|
||||
query = select(LogEvent).where(*filters.conditions())
|
||||
if before is not None:
|
||||
ts, row_id = _decode_cursor(before)
|
||||
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
|
||||
# `ts` index scan instead of filtering every newer row first.
|
||||
query = query.where(
|
||||
and_(
|
||||
LogEvent.ts <= ts,
|
||||
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
|
||||
)
|
||||
)
|
||||
|
||||
# One more than asked, to know whether there is a next page without a second count query.
|
||||
rows = (
|
||||
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
|
||||
.scalars()
|
||||
.all()
|
||||
query = query.where(older_than(LogEvent.ts, LogEvent.id, before))
|
||||
rows, cursor = page(
|
||||
list(
|
||||
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
|
||||
.scalars()
|
||||
.all()
|
||||
),
|
||||
limit,
|
||||
lambda r: r.ts,
|
||||
)
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return {
|
||||
"items": [logsink.serialize(r) for r in rows],
|
||||
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
||||
"next_cursor": _encode_cursor(rows[-1]) if more else None,
|
||||
"accounts": accounts(db, {r.user_id for r in rows if r.user_id is not None}),
|
||||
"next_cursor": cursor,
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +172,6 @@ def log_facets(
|
||||
"sources": counts(LogEvent.source, "sources"),
|
||||
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
|
||||
"users": [{**u, "value": str(u["value"])} for u in users],
|
||||
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
|
||||
"accounts": accounts(db, {u["value"] for u in users if u["value"] != NONE}),
|
||||
"total": db.scalar(total) or 0,
|
||||
}
|
||||
|
||||
+106
-29
@@ -1,67 +1,144 @@
|
||||
"""Admin audit log API: read the append-only who/what/when trail, and clear it.
|
||||
|
||||
Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so
|
||||
this returns the most recent window of rows (capped) plus the true total, rather than paging
|
||||
server-side — the log stays small in practice (state-changing runs + admin actions only)."""
|
||||
Admin-only. The activity page reads it a page at a time, newest first, filtered on the server like
|
||||
the server log (`_keyset` paging), so the whole trail is reachable however long it grows."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import delete, func, select
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Text, cast, delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.audit import AuditAction
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import AuditLog, User
|
||||
from app.utils import iso
|
||||
from app.models import AuditLog, LogEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
||||
from app.utils import iso, like_escape
|
||||
|
||||
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
|
||||
|
||||
# Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd
|
||||
# switch the DataTable to server-side paging.
|
||||
MAX_ROWS = 2000
|
||||
|
||||
|
||||
def _serialize(row: AuditLog, actors: dict[int, str]) -> dict:
|
||||
def _serialize(row: AuditLog, lines: dict[str, int]) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"created_at": iso(row.created_at),
|
||||
# actor_id NULL = the scheduler/background did it (client shows "System"); a set id that
|
||||
# can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present.
|
||||
"actor": actors.get(row.actor_id) if row.actor_id is not None else None,
|
||||
# NULL = the background did it (the client shows "System"); the address is in `accounts`.
|
||||
"actor_id": row.actor_id,
|
||||
"action": row.action,
|
||||
"target_type": row.target_type,
|
||||
"target_id": row.target_id,
|
||||
"summary": row.summary,
|
||||
"before": row.before,
|
||||
"after": row.after,
|
||||
"request_id": row.request_id,
|
||||
# How many of that request's log lines are still kept: they go after days, the row after
|
||||
# months, and a link to none would open an empty list.
|
||||
"log_lines": lines.get(row.request_id, 0) if row.request_id else 0,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditFilters:
|
||||
action: str | None
|
||||
actor: str | None
|
||||
q: str | None
|
||||
since: datetime | None
|
||||
until: datetime | None
|
||||
|
||||
def conditions(self, skip: Literal["action"] | None = None) -> list:
|
||||
"""The WHERE conditions, ANDed. `skip="action"` leaves the action filter out, so the action
|
||||
menu counts every action under the other filters (the way back from a pick)."""
|
||||
out = []
|
||||
if self.action is not None and skip != "action":
|
||||
out.append(AuditLog.action == self.action)
|
||||
if self.actor is not None:
|
||||
out.append(account.condition(AuditLog.actor_id, self.actor))
|
||||
if self.since is not None:
|
||||
out.append(AuditLog.created_at >= utc(self.since))
|
||||
if self.until is not None:
|
||||
out.append(AuditLog.created_at < utc(self.until))
|
||||
if self.q and self.q.strip():
|
||||
like = f"%{like_escape(self.q.strip())}%"
|
||||
out.append(
|
||||
or_(
|
||||
AuditLog.summary.ilike(like),
|
||||
AuditLog.target_id.ilike(like),
|
||||
AuditLog.action.ilike(like),
|
||||
AuditLog.request_id.ilike(like),
|
||||
cast(AuditLog.before, Text).ilike(like),
|
||||
cast(AuditLog.after, Text).ilike(like),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def audit_filters(
|
||||
action: str | None = Query(default=None, max_length=48, pattern=r"^[a-z][a-z0-9_]*$"),
|
||||
user: str | None = Query(default=None, pattern=account.PATTERN),
|
||||
q: str | None = Query(default=None, max_length=200),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> AuditFilters:
|
||||
return AuditFilters(action, user, q, since, until)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_audit(
|
||||
limit: int = 500,
|
||||
filters: AuditFilters = Depends(audit_filters),
|
||||
before: str | None = Query(default=None, max_length=200),
|
||||
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
limit = max(1, min(limit, MAX_ROWS))
|
||||
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
|
||||
rows = (
|
||||
db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit))
|
||||
.scalars()
|
||||
.all()
|
||||
"""One page of rows, newest first, matching every filter given; `before` continues from a
|
||||
previous page's `next_cursor`."""
|
||||
query = select(AuditLog).where(*filters.conditions())
|
||||
if before is not None:
|
||||
query = query.where(older_than(AuditLog.created_at, AuditLog.id, before))
|
||||
order = (AuditLog.created_at.desc(), AuditLog.id.desc())
|
||||
rows, cursor = page(
|
||||
list(db.execute(query.order_by(*order).limit(limit + 1)).scalars().all()),
|
||||
limit,
|
||||
lambda r: r.created_at,
|
||||
)
|
||||
actor_ids = {r.actor_id for r in rows if r.actor_id is not None}
|
||||
actors: dict[int, str] = {}
|
||||
if actor_ids:
|
||||
actors = dict(db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all())
|
||||
requests = {r.request_id for r in rows if r.request_id}
|
||||
lines: dict[str, int] = {}
|
||||
if requests:
|
||||
counted = db.execute(
|
||||
select(LogEvent.request_id, func.count())
|
||||
.where(LogEvent.request_id.in_(requests))
|
||||
.group_by(LogEvent.request_id)
|
||||
)
|
||||
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
|
||||
return {
|
||||
"items": [_serialize(r, actors) for r in rows],
|
||||
"total": total,
|
||||
"returned": len(rows),
|
||||
"items": [_serialize(r, lines) for r in rows],
|
||||
"accounts": accounts(db, {r.actor_id for r in rows if r.actor_id is not None}),
|
||||
"next_cursor": cursor,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/facets")
|
||||
def audit_facets(
|
||||
filters: AuditFilters = Depends(audit_filters),
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The actions present under the other filters, with counts — what the action menu offers."""
|
||||
n = func.count().label("n")
|
||||
stmt = (
|
||||
select(AuditLog.action, n)
|
||||
.where(*filters.conditions("action"))
|
||||
.group_by(AuditLog.action)
|
||||
.order_by(n.desc(), AuditLog.action)
|
||||
)
|
||||
return {"actions": [{"value": a, "count": c} for a, c in db.execute(stmt).all()]}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
|
||||
|
||||
+28
-14
@@ -16,6 +16,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import apitokens
|
||||
from app import email as email_mod
|
||||
from app import logs
|
||||
from app import mailtext
|
||||
@@ -446,7 +447,7 @@ async def callback(
|
||||
# SEC-10: drop any password nobody proved the mailbox for, now that Google attests
|
||||
# ownership (bumps the epoch to revoke any session it minted). Runs before the
|
||||
# activation below, which is what it reads.
|
||||
revoke_untrusted_preset_password(user)
|
||||
revoke_untrusted_preset_password(db, user)
|
||||
# Google verified the email and is_allowed granted access, so finish activating a
|
||||
# previously pending password registration (otherwise current_user would bounce it).
|
||||
user.email_verified = True
|
||||
@@ -486,7 +487,7 @@ async def callback(
|
||||
# real owner, signed in through Google right now, sets a new password in Settings.
|
||||
if canonical_email(email) in env_admin_canonicals():
|
||||
if user.role != "admin" and user.password_hash is not None:
|
||||
_revoke_password(user)
|
||||
_revoke_password(db, user)
|
||||
user.role = "admin"
|
||||
elif not user.role:
|
||||
user.role = "user"
|
||||
@@ -1118,9 +1119,8 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
# about. The person still confirms their address the normal way.
|
||||
if kind == "reset":
|
||||
user.email_verified = True
|
||||
bump_session_epoch(
|
||||
user
|
||||
) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
# SA4: a reset (often a compromise response) kills all existing sessions — and API tokens.
|
||||
bump_session_epoch(db, user)
|
||||
# Burn any other outstanding reset tokens for this user.
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
@@ -1143,13 +1143,27 @@ def _remember_epoch(request: Request, user: User) -> None:
|
||||
request.session["epochs"] = epochs
|
||||
|
||||
|
||||
def bump_session_epoch(user: User) -> None:
|
||||
def bump_session_epoch(db: Session, user: User) -> None:
|
||||
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
||||
minted before the bump carry a now-stale epoch and are rejected by `current_user`."""
|
||||
user.session_epoch = (user.session_epoch or 0) + 1
|
||||
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
|
||||
API tokens go with them (`apitokens.revoke_all`): a token minted from a stolen session must not
|
||||
outlive the answer to that theft. Not committed — the caller commits it with its change.
|
||||
|
||||
The user's row is locked before the tokens go, the lock `create_token` takes too: a token made at
|
||||
the same moment either commits first and is deleted here, or waits and sees the new epoch. FOR NO
|
||||
KEY UPDATE (`key_share`): the two still exclude each other, but a row that only refers to the user
|
||||
(a notice, an audit line) is not held up — a token's expiry notice would deadlock with it."""
|
||||
if user.id is None:
|
||||
user.session_epoch = (user.session_epoch or 0) + 1
|
||||
return
|
||||
epoch = db.execute(
|
||||
select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True)
|
||||
).scalar_one()
|
||||
user.session_epoch = (epoch or 0) + 1
|
||||
apitokens.revoke_all(db, user.id)
|
||||
|
||||
|
||||
def revoke_untrusted_preset_password(user: User) -> None:
|
||||
def revoke_untrusted_preset_password(db: Session, user: User) -> None:
|
||||
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
||||
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
||||
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
|
||||
@@ -1183,15 +1197,15 @@ def revoke_untrusted_preset_password(user: User) -> None:
|
||||
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
|
||||
was locked out for good (it could not sign in either). The stamp separates the two cases."""
|
||||
if not user.email_verified or (not user.is_active and user.password_hash is not None):
|
||||
_revoke_password(user)
|
||||
_revoke_password(db, user)
|
||||
|
||||
|
||||
def _revoke_password(user: User) -> None:
|
||||
def _revoke_password(db: Session, user: User) -> None:
|
||||
"""Drop the account's password, stamp why (so password-reset still accepts it) and end every
|
||||
session it signed in."""
|
||||
user.password_hash = None
|
||||
user.password_revoked_at = datetime.now(timezone.utc)
|
||||
bump_session_epoch(user)
|
||||
bump_session_epoch(db, user)
|
||||
|
||||
|
||||
@router.post("/logout-others")
|
||||
@@ -1203,7 +1217,7 @@ def logout_others(
|
||||
in somewhere" / a suspected session compromise."""
|
||||
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
|
||||
# session at a newer epoch than the DB (see set_password).
|
||||
bump_session_epoch(user)
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
||||
@@ -1255,7 +1269,7 @@ def set_password(
|
||||
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
|
||||
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||
bump_session_epoch(user)
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
log.info("Password set/changed: uid=%s", user.id)
|
||||
|
||||
@@ -3,15 +3,16 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w
|
||||
(subscribe / unsubscribe) that require the optional write scope."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Select, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.collation import collated, sorted_words
|
||||
from app.mailtext import lang_for_user
|
||||
from app import logs, quota
|
||||
from app import logs, quota, scheduler
|
||||
from app.auth import admin_user, current_user, require_write_scope
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
@@ -481,7 +482,6 @@ class ChannelUpdateIn(BaseModel):
|
||||
def update_channel(
|
||||
channel_id: str,
|
||||
body: ChannelUpdateIn,
|
||||
background: BackgroundTasks,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
@@ -508,12 +508,19 @@ def update_channel(
|
||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||
run_recent_backfill(db, [channel], max_channels=1)
|
||||
|
||||
# …and kick the FULL deep backfill for this one channel immediately in the background, so the
|
||||
# whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks. The
|
||||
# request returns right away; the channel page's live poll shows the count climb. The scheduler
|
||||
# still finishes a very large channel's tail on its normal runs (deep_requested stays set).
|
||||
if deep_turned_on:
|
||||
background.add_task(backfill_channel_now, channel_id, user.id)
|
||||
# …and kick the FULL deep backfill for this one channel immediately, as a recorded run of its own,
|
||||
# so the whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks.
|
||||
# The request returns right away; the channel page's live poll shows the count climb. The
|
||||
# scheduler still finishes a very large channel's tail on its normal runs (deep_requested stays set).
|
||||
# A channel whose history is all in gets no run: it would only say "nothing to do", one kept row
|
||||
# per flip of the flag.
|
||||
if deep_turned_on and not (channel is not None and channel.backfill_done):
|
||||
scheduler.run_ad_hoc(
|
||||
scheduler.CHANNEL_BACKFILL,
|
||||
partial(backfill_channel_now, channel_id, user.id),
|
||||
actor_id=user.id,
|
||||
target=channel_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
|
||||
@@ -61,7 +61,7 @@ def set_config(
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
s = sysconfig.spec(key)
|
||||
s = sysconfig.config_page_spec(key)
|
||||
if s is None:
|
||||
raise not_found("config_key")
|
||||
if "value" not in body.model_fields_set:
|
||||
@@ -89,7 +89,9 @@ def set_config(
|
||||
before=None if s.secret else {"value": _redact(old)},
|
||||
after=None if s.secret else {"value": _redact(new)},
|
||||
)
|
||||
db.commit()
|
||||
# The override is committed even when the trail skips it: re-saving the value in force still
|
||||
# pins it as an override, and an uncommitted write would be dropped with the session.
|
||||
db.commit()
|
||||
_apply_runtime(db, key)
|
||||
return sysconfig.describe(db)
|
||||
|
||||
@@ -100,7 +102,7 @@ def reset_config(
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
s = sysconfig.spec(key)
|
||||
s = sysconfig.config_page_spec(key)
|
||||
if s is None:
|
||||
raise not_found("config_key")
|
||||
old = None if s.secret else sysconfig.get(db, key)
|
||||
|
||||
+253
-720
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,792 @@
|
||||
"""The Files library HTTP API — a user's files, the folders they sit in, and the file itself.
|
||||
|
||||
A file in the library is a user's DONE job (`DownloadJob`) over its shared `MediaAsset`: the job is
|
||||
the per-user layer (name, folder, kept, expiry, shares and links all hang off it), the asset the
|
||||
bytes. Folders are rows (`downloads.folders`), so a rename or a move never touches the disk.
|
||||
|
||||
In-flight jobs are not here: they are the download tray's (`routes.downloads`). The helpers above
|
||||
the routes are shared with that module — serialization, the ownership checks, the upload spool —
|
||||
because a download and a library file are one row at two moments. What is read comes from
|
||||
`downloads.queries`, what is written goes through Capture's service (`service`, `folders`); this
|
||||
module validates, answers in HTTP and commits.
|
||||
|
||||
A machine with an API token (`app.apitokens`) may use the routes that take `files_caller`: list the
|
||||
folders and files, fetch a file and its hash, upload, delete — each only in a folder its token was
|
||||
granted that right in, and never the files shared with the user. A folder or file out of its reach
|
||||
answers like a missing one. Everything else here is the signed-in user's alone (`current_user`).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, BinaryIO, Literal
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, sysconfig
|
||||
from app.apitokens import FilesCaller, Right, files_caller
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
from app.config import settings
|
||||
from app.db import get_db, release_read_connection
|
||||
from app.downloads import folders, preview, queries, quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.status import JobStatus
|
||||
from app.mailtext import lang_for_user
|
||||
from app.models import DISPLAY_TEXT_MAX, User, display_text
|
||||
from app.refusal import refused
|
||||
from app.routes._common import capped_stream_to, not_found
|
||||
from app.utils import iso
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.downloads.models import DownloadJob, FileFolder, MediaAsset
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
# The longest a folder may keep a file, in days — ten years is "forever" in all but name.
|
||||
_MAX_RETENTION_DAYS = 3650
|
||||
_HASH_CHUNK = 1 << 20
|
||||
# How many files the hash route reads at once: each holds a threadpool thread for the whole read.
|
||||
_HASHERS = 2
|
||||
_HASHING = threading.BoundedSemaphore(_HASHERS)
|
||||
|
||||
|
||||
# --- shared with routes.downloads -------------------------------------------------------
|
||||
|
||||
|
||||
def serialize(job: "DownloadJob", asset: "MediaAsset | None") -> dict:
|
||||
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
||||
return {
|
||||
"id": job.id,
|
||||
"status": job.status,
|
||||
"progress": job.progress,
|
||||
"speed_bps": job.speed_bps,
|
||||
"eta_s": job.eta_s,
|
||||
"phase": job.phase,
|
||||
"error": job.error,
|
||||
"error_code": job.error_code,
|
||||
"error_detail": job.error_detail,
|
||||
"queue_pos": job.queue_pos,
|
||||
"created_at": iso(job.created_at),
|
||||
"ready_at": iso(job.ready_at),
|
||||
"source_kind": job.source_kind,
|
||||
"source_ref": job.source_ref,
|
||||
"source_url": service.reference_url(job, asset),
|
||||
# A locally-generated poster to fall back to when the source had no thumbnail.
|
||||
"poster_url": f"/api/downloads/{job.id}/poster.jpg"
|
||||
if (asset is not None and asset.poster_path)
|
||||
else None,
|
||||
"profile_id": job.profile_id,
|
||||
"spec": job.profile_snapshot,
|
||||
"display_name": job.display_name,
|
||||
"display_uploader": job.display_uploader,
|
||||
"display_uploader_url": job.display_uploader_url,
|
||||
"extra_links": job.extra_links or [],
|
||||
"job_kind": job.job_kind,
|
||||
"source_job_id": job.source_job_id,
|
||||
"edit_spec": job.edit_spec,
|
||||
"can_download": ready and job.status == JobStatus.DONE,
|
||||
# The Files library: its folder, whether the user keeps it, and when it goes (NULL = never).
|
||||
"folder_id": job.folder_id,
|
||||
"kept": job.kept,
|
||||
"expires_at": iso(job.expires_at),
|
||||
"asset": None
|
||||
if asset is None
|
||||
else {
|
||||
"id": asset.id,
|
||||
"status": asset.status,
|
||||
"title": asset.title,
|
||||
"uploader": asset.uploader,
|
||||
"upload_date": iso(asset.upload_date),
|
||||
"duration_s": asset.duration_s,
|
||||
"size_bytes": asset.size_bytes,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"container": asset.container,
|
||||
# "video" | "audio" | "file" — the library gates play/edit on this (media only); a
|
||||
# generic uploaded file is download-only. Null while still in flight (set at finalize).
|
||||
"media_kind": asset.media_kind,
|
||||
"uploader_url": asset.uploader_url,
|
||||
"thumbnail_url": asset.thumbnail_url,
|
||||
# "image" | "pdf" | "text" | null — what `/preview` shows (`downloads/preview.py`).
|
||||
"preview": preview.kind(asset.media_kind, asset.container),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def serialize_job(db: Session, job: "DownloadJob") -> dict:
|
||||
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
|
||||
every single-job handler shares."""
|
||||
return serialize(job, queries.asset_of(db, job))
|
||||
|
||||
|
||||
def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
"""One of the user's jobs under its row lock (`service.lock_own_job`), or a 404."""
|
||||
job = service.lock_own_job(db, user, job_id)
|
||||
if job is None:
|
||||
raise not_found("download")
|
||||
return job
|
||||
|
||||
|
||||
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
||||
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
|
||||
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
||||
|
||||
|
||||
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
|
||||
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
|
||||
# FastAPI *before the first dependency runs*, so the auth dependency could not gate it and an anonymous
|
||||
# caller could make the process write the whole per-file allowance to temp, per request (SEC-20
|
||||
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
|
||||
# declared AFTER the auth one, is the only shape that closes it — the same one
|
||||
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
|
||||
# every route with a raised ceiling rather than leaving it to a comment like this one.
|
||||
#
|
||||
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
|
||||
# Hungarian filename would otherwise be unsendable.
|
||||
UPLOAD_FILENAME_HEADER = "x-upload-filename"
|
||||
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
|
||||
# The folder the file goes in (its id); without it, the user's Uploads folder.
|
||||
UPLOAD_FOLDER_HEADER = "x-upload-folder"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpooledUpload:
|
||||
"""What the body dependency hands the handler: the bytes already on disk and their hash, plus
|
||||
the names and the folder that came alongside them, and who sent them."""
|
||||
|
||||
path: Path
|
||||
size: int
|
||||
sha256: str
|
||||
user_id: int
|
||||
filename: str
|
||||
display_name: str | None
|
||||
folder_id: int | None
|
||||
via: str | None = None
|
||||
|
||||
|
||||
def _header_text(request: Request, name: str) -> str:
|
||||
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
|
||||
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
|
||||
own fallback names the file."""
|
||||
raw = request.headers.get(name, "")
|
||||
try:
|
||||
return unquote(raw, errors="strict").strip()
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
|
||||
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
|
||||
# a label; logging it would hand any client a free log line per request.
|
||||
return ""
|
||||
|
||||
|
||||
def _upload_folder(db: Session, caller: FilesCaller, raw: str) -> int | None:
|
||||
"""The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for
|
||||
none — which a token may not: it writes only where it was granted to."""
|
||||
if not raw.strip():
|
||||
if caller.grants is not None:
|
||||
raise refused(400, "api_token_folder_required")
|
||||
return None
|
||||
try:
|
||||
folder_id = int(raw)
|
||||
except ValueError as e:
|
||||
raise refused(409, "file_folder_gone") from e
|
||||
if not caller.may(folder_id, Right.WRITE):
|
||||
raise not_found("folder")
|
||||
try:
|
||||
return folders.upload_target(db, caller.user.id, folder_id, hold=False).id
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
|
||||
|
||||
class _HashingSink:
|
||||
"""The upload's file, hashing the bytes on their way in: the hash costs no second read."""
|
||||
|
||||
def __init__(self, out: BinaryIO) -> None:
|
||||
self.out = out
|
||||
self.hash = hashlib.sha256()
|
||||
|
||||
def write(self, data: bytes, /) -> int:
|
||||
self.hash.update(data)
|
||||
return self.out.write(data)
|
||||
|
||||
|
||||
async def spool_upload(
|
||||
request: Request,
|
||||
caller: FilesCaller = Depends(files_caller),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AsyncIterator[SpooledUpload]:
|
||||
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
||||
|
||||
`caller` comes first on purpose. Dependencies resolve in declaration order, so naming
|
||||
`files_caller` here makes the auth check a precondition of reading the body at all — a stranger,
|
||||
or a token without the right to write in that folder, is refused before a byte moves. The
|
||||
`yield` shape owns the temp file's life: ingest MOVES it into place on success (the unlink is
|
||||
then a no-op), and on any other exit — cap hit, empty, quota, probe or move error, a client that
|
||||
vanished mid-upload — it is removed."""
|
||||
cap = sysconfig.get_int(db, "download_upload_max_bytes")
|
||||
user_id = caller.user.id
|
||||
via = caller.via
|
||||
# A folder the file may not go in is refused before the transfer, not after it.
|
||||
folder_id = _upload_folder(db, caller, request.headers.get(UPLOAD_FOLDER_HEADER, ""))
|
||||
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
|
||||
# for why, and for the precondition it enforces. Everything this dependency still needs is
|
||||
# already an int, a string or a header in hand: touching an ORM attribute below (`caller.user`)
|
||||
# would reload it and check a connection straight back out.
|
||||
release_read_connection(db)
|
||||
staging = Path(settings.files_root) / ".staging" / "uploads"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(staging))
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
sink = _HashingSink(out)
|
||||
size = await capped_stream_to(
|
||||
request,
|
||||
sink,
|
||||
cap,
|
||||
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
|
||||
)
|
||||
yield SpooledUpload(
|
||||
path=tmp_path,
|
||||
size=size,
|
||||
sha256=sink.hash.hexdigest(),
|
||||
user_id=user_id,
|
||||
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
||||
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
||||
folder_id=folder_id,
|
||||
via=via,
|
||||
)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def accessible_job(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
"""A file the caller may read (`queries.accessible_job`: their own, or shared with them)."""
|
||||
job = queries.accessible_job(db, user, job_id)
|
||||
if job is None:
|
||||
raise not_found("download")
|
||||
return job
|
||||
|
||||
|
||||
def own_job(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
job = queries.own_job(db, user, job_id)
|
||||
if job is None:
|
||||
raise not_found("download")
|
||||
return job
|
||||
|
||||
|
||||
def own_folder(db: Session, user: User, folder_id: int) -> "FileFolder":
|
||||
folder = queries.own_folder(db, user, folder_id)
|
||||
if folder is None:
|
||||
raise not_found("folder")
|
||||
return folder
|
||||
|
||||
|
||||
def readable_job(db: Session, caller: FilesCaller, job_id: int) -> "DownloadJob":
|
||||
"""A file `caller` may read (`queries.readable_job`), or a 404."""
|
||||
job = queries.readable_job(db, caller, job_id)
|
||||
if job is None:
|
||||
raise not_found("download")
|
||||
return job
|
||||
|
||||
|
||||
# --- folders -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_folder(folder: "FileFolder", count: int, size: int, linked: bool = False) -> dict:
|
||||
return {
|
||||
"id": folder.id,
|
||||
"kind": folder.kind,
|
||||
# A source folder's name is the client's to render from its key, in the reader's language.
|
||||
"source_key": folder.source_key,
|
||||
"name": folder.name,
|
||||
"retention_days": folder.retention_days,
|
||||
"count": count,
|
||||
"size_bytes": size,
|
||||
"created_at": iso(folder.created_at),
|
||||
# A live public link opens it: whatever is moved in is shown to whoever holds the link.
|
||||
"has_link": linked,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
def list_folders(
|
||||
caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""The caller's folders with how many files each holds and their size (a token: the ones it may
|
||||
list). A source folder its files have all left stays (count 0): the client decides whether to
|
||||
show it."""
|
||||
rows = queries.folders_with_totals(db, caller.user, caller.folders_with(Right.LIST))
|
||||
linked = queries.linked_folders(db, [f.id for f, _, _ in rows])
|
||||
return [_serialize_folder(f, count, size, f.id in linked) for f, count, size in rows]
|
||||
|
||||
|
||||
class FolderIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=DISPLAY_TEXT_MAX)
|
||||
|
||||
|
||||
def _folder_name(raw: str) -> str:
|
||||
name = display_text(raw)
|
||||
if not name:
|
||||
raise refused(400, "file_folder_name_required")
|
||||
return name
|
||||
|
||||
|
||||
@router.post("/folders")
|
||||
def create_folder(
|
||||
body: FolderIn, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
try:
|
||||
folder = folders.create_own(db, user.id, _folder_name(body.name))
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
audit.record(
|
||||
db, user.id, AuditAction.FILE_FOLDER_CREATE, target_type="file_folder", target_id=folder.id
|
||||
)
|
||||
db.commit()
|
||||
return _serialize_folder(folder, 0, 0)
|
||||
|
||||
|
||||
class UpdateFolderIn(BaseModel):
|
||||
# Presence (model_fields_set) decides what is touched; retention_days null = keep forever.
|
||||
name: str | None = Field(default=None, min_length=1, max_length=DISPLAY_TEXT_MAX)
|
||||
retention_days: int | None = Field(default=None, ge=1, le=_MAX_RETENTION_DAYS)
|
||||
|
||||
|
||||
@router.patch("/folders/{folder_id}")
|
||||
def update_folder(
|
||||
folder_id: int,
|
||||
body: UpdateFolderIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Rename one of the caller's own folders (a source folder's name is its origin's), and/or set
|
||||
how long any of their folders keeps a file (`folders.set_retention`)."""
|
||||
folder = own_folder(db, user, folder_id)
|
||||
sent = body.model_fields_set
|
||||
if "name" in sent:
|
||||
try:
|
||||
folders.rename(db, folder, _folder_name(body.name or ""))
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.FILE_FOLDER_RENAME,
|
||||
target_type="file_folder",
|
||||
target_id=folder.id,
|
||||
)
|
||||
if "retention_days" in sent:
|
||||
# The value replaced is the one read under the folder's lock, not the one read above it.
|
||||
try:
|
||||
before = folders.set_retention(db, folder, body.retention_days)
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
if before != body.retention_days:
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.FILE_FOLDER_RETENTION,
|
||||
target_type="file_folder",
|
||||
target_id=folder.id,
|
||||
before={"retention_days": before},
|
||||
after={"retention_days": body.retention_days},
|
||||
)
|
||||
db.commit()
|
||||
count, size = queries.folder_totals(db, folder.id)
|
||||
linked = folder.id in queries.linked_folders(db, [folder.id])
|
||||
return _serialize_folder(folder, count, size, linked)
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}")
|
||||
def delete_folder(
|
||||
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Delete one of the caller's own folders; its files go back to their source folders."""
|
||||
folder = own_folder(db, user, folder_id)
|
||||
try:
|
||||
moved = folders.delete_own(db, folder)
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.FILE_FOLDER_DELETE,
|
||||
target_type="file_folder",
|
||||
target_id=folder_id,
|
||||
after={"files_moved": moved},
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": folder_id, "files_moved": moved}
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""The caller's storage quota, plus the per-file upload cap.
|
||||
|
||||
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
|
||||
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
|
||||
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
|
||||
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
|
||||
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
|
||||
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
|
||||
boundary: the caps stay where they are and still refuse a client that ignores this.
|
||||
"""
|
||||
return {
|
||||
**quota.usage(db, user.id),
|
||||
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/shared/{job_id}")
|
||||
def remove_shared_with_me(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Remove a file that was shared WITH this user from their 'Shared with me' list. Deletes
|
||||
only the recipient's share grant — the owner's job and the physical file are untouched (this is
|
||||
a per-user dismissal, not a delete)."""
|
||||
if service.unshare(db, job_id, user):
|
||||
db.commit()
|
||||
return {"ok": True, "removed": job_id}
|
||||
|
||||
|
||||
# --- files ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_files(
|
||||
folder_id: int | None = None,
|
||||
q: str = Query("", max_length=200),
|
||||
sort: Literal["added", "name", "size"] = "added",
|
||||
order: Literal["asc", "desc"] = "desc",
|
||||
scope: Literal["mine", "shared"] = "mine",
|
||||
caller: FilesCaller = Depends(files_caller),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""The caller's done files (`scope=mine`, optionally one folder's), or the ones shared with
|
||||
them (`scope=shared`); searched by name and channel, sorted by when they were added, by name
|
||||
(in the reader's alphabet) or by size. A token lists only in the folders it may list, and never
|
||||
the shared ones."""
|
||||
user = caller.user
|
||||
listable = caller.folders_with(Right.LIST)
|
||||
if listable is not None:
|
||||
if scope == "shared":
|
||||
raise refused(403, "api_token_scope")
|
||||
if folder_id is not None and folder_id not in listable:
|
||||
raise not_found("folder")
|
||||
shared = scope == "shared"
|
||||
jobs = queries.library(
|
||||
db,
|
||||
user,
|
||||
folder_id=folder_id,
|
||||
listable=listable,
|
||||
shared=shared,
|
||||
term=q.strip(),
|
||||
sort=sort,
|
||||
order=order,
|
||||
lang=lang_for_user(user),
|
||||
)
|
||||
assets = queries.assets_for(db, jobs)
|
||||
positions = queries.positions_for(db, user, jobs)
|
||||
shared_at = queries.shared_at(db, user, jobs) if shared else {}
|
||||
sources = queries.source_keys(db, jobs)
|
||||
return [
|
||||
{
|
||||
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||
"position_seconds": positions.get(j.id, 0),
|
||||
"shared": shared,
|
||||
"shared_at": iso(shared_at.get(j.id)),
|
||||
"source_key": sources[j.id],
|
||||
}
|
||||
for j in jobs
|
||||
]
|
||||
|
||||
|
||||
_MAX_EXTRA_LINKS = 8
|
||||
|
||||
|
||||
def _clean_url(raw) -> str | None:
|
||||
"""Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a
|
||||
public watch page); anything else is dropped rather than trusted."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
url = raw.strip()
|
||||
if not url:
|
||||
return None
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
# `urlparse` raises on a malformed authority; this function already has a "not a plausible URL"
|
||||
# answer, so the typo belongs in it.
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
# silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL"
|
||||
# answer, the caller already has one, and any line here would carry the URL itself.
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.netloc:
|
||||
return None
|
||||
return url[:2048]
|
||||
|
||||
|
||||
class UpdateFileIn(BaseModel):
|
||||
# Presence (model_fields_set) decides what is touched; a null/"" text falls back to the system
|
||||
# value (the title, the channel). All display-only: the file on disk keeps its own name.
|
||||
name: str | None = None
|
||||
uploader: str | None = None
|
||||
uploader_url: str | None = None
|
||||
extra_links: list | None = None
|
||||
folder_id: int | None = None
|
||||
kept: bool | None = None
|
||||
|
||||
|
||||
@router.patch("/{job_id}")
|
||||
def update_file(
|
||||
job_id: int,
|
||||
body: UpdateFileIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Rename a file and its channel, set its reference links (all display-only: the file on disk
|
||||
keeps its own name), move it to another folder (`folders.move`), or keep it until deleted /
|
||||
hand it back to its folder's retention."""
|
||||
job = own_job(db, user, job_id)
|
||||
sent = body.model_fields_set
|
||||
display: dict[str, object] = {}
|
||||
if "name" in sent:
|
||||
display["display_name"] = display_text(body.name)
|
||||
if "uploader" in sent:
|
||||
display["display_uploader"] = display_text(body.uploader)
|
||||
if "uploader_url" in sent:
|
||||
display["display_uploader_url"] = _clean_url(body.uploader_url)
|
||||
if "extra_links" in sent:
|
||||
raw = body.extra_links or []
|
||||
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
|
||||
display["extra_links"] = cleaned or None
|
||||
service.set_display(job, **display)
|
||||
if "folder_id" in sent:
|
||||
if body.folder_id is None:
|
||||
raise refused(400, "file_folder_gone")
|
||||
before = job.folder_id
|
||||
try:
|
||||
folders.move(db, job, folders.own_folder(db, user.id, body.folder_id))
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
if job.folder_id != before:
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.FILE_MOVE,
|
||||
target_type="file",
|
||||
target_id=job.id,
|
||||
before={"folder_id": before},
|
||||
after={"folder_id": job.folder_id},
|
||||
)
|
||||
if "kept" in sent and body.kept is not None and body.kept != job.kept:
|
||||
folders.set_kept(db, job, body.kept)
|
||||
action = AuditAction.FILE_KEEP if body.kept else AuditAction.FILE_UNKEEP
|
||||
audit.record(db, user.id, action, target_type="file", target_id=job.id)
|
||||
db.commit()
|
||||
return serialize_job(db, job)
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
def delete_file(
|
||||
job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Delete a file from the caller's library; the bytes go with their last holder. A token
|
||||
deletes only in a folder it may delete in — judged on the row as locked, so a file moved out of
|
||||
that folder meanwhile is out of its reach."""
|
||||
job = own_job_for_update(db, caller.user, job_id)
|
||||
if not caller.may(job.folder_id, Right.DELETE):
|
||||
db.rollback() # lets go of the locks; nothing was written
|
||||
raise not_found("download")
|
||||
audit.record(
|
||||
db,
|
||||
caller.user.id,
|
||||
AuditAction.FILE_DELETE,
|
||||
target_type="file",
|
||||
target_id=job_id,
|
||||
summary=caller.via,
|
||||
)
|
||||
service.delete(db, job)
|
||||
db.commit() # the audit row with it
|
||||
return {"ok": True, "deleted": job_id}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
def upload_file(
|
||||
upload: SpooledUpload = Depends(spool_upload),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Put a local file in the caller's library — in the folder `UPLOAD_FOLDER_HEADER` names, else
|
||||
their Uploads folder. Any file type; media gets the player, a generic file is download-only.
|
||||
The body IS the file (see the header note above `UPLOAD_FILENAME_HEADER`); `spool_upload` has
|
||||
authenticated the caller, checked the folder and streamed it to disk already.
|
||||
|
||||
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
|
||||
plus sync DB writes — so it must NOT run on the event loop."""
|
||||
return serialize_job(db, ingest_spooled(db, upload))
|
||||
|
||||
|
||||
def ingest_spooled(db: Session, upload: SpooledUpload) -> "DownloadJob":
|
||||
"""The spooled upload into the library (`upload.ingest_upload`, which commits its own rows),
|
||||
its refusals answered as HTTP."""
|
||||
if upload.size == 0:
|
||||
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
|
||||
try:
|
||||
job = uploadmod.ingest_upload(
|
||||
db,
|
||||
upload.user_id,
|
||||
filename=upload.filename,
|
||||
src_path=upload.path,
|
||||
size=upload.size,
|
||||
display_name=upload.display_name,
|
||||
sha256=upload.sha256,
|
||||
folder_id=upload.folder_id,
|
||||
via=upload.via,
|
||||
)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||
except folders.FolderError as e: # the folder went while the file was on its way
|
||||
raise refused(409, e.reason) from e
|
||||
return job
|
||||
|
||||
|
||||
def _ready_file(db: Session, job: "DownloadJob") -> tuple["MediaAsset", Path]:
|
||||
asset = queries.asset_of(db, job)
|
||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||
raise refused(409, "download_not_ready")
|
||||
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise not_found("file")
|
||||
return asset, path
|
||||
|
||||
|
||||
def serve_file(db: Session, job: "DownloadJob") -> FileResponse:
|
||||
"""The file of `job` (the caller may read it), range-aware, named by the user's display name.
|
||||
|
||||
The served Content-Type comes from the ffprobe-verified media_kind + a container allow-list
|
||||
(`storage.served_media_type`), NEVER guessed from the file's own extension. A generic 'file' (and
|
||||
any media whose container we don't recognise — e.g. a media/HTML polyglot named "evil.html")
|
||||
returns None → a neutral force-download attachment, so uploaded HTML/SVG can't execute inline on
|
||||
this origin. Recognised media is served with its real type so the in-app player streams it. Both
|
||||
are range-aware (FileResponse → 206). A file whose hash is known says so (`Repr-Digest`, RFC
|
||||
9530: the digest of the whole file, whichever range is asked for)."""
|
||||
asset, path = _ready_file(db, job)
|
||||
filename = storage.download_filename(
|
||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
||||
)
|
||||
headers = {}
|
||||
if asset.sha256:
|
||||
digest = base64.b64encode(bytes.fromhex(asset.sha256)).decode()
|
||||
headers["Repr-Digest"] = f"sha-256=:{digest}:"
|
||||
service.touch(asset) # the handler commits it
|
||||
media_type = storage.served_media_type(asset.media_kind, asset.container)
|
||||
if media_type is None:
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
headers=headers,
|
||||
)
|
||||
return FileResponse(path, filename=filename, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
@router.get("/{job_id}/file")
|
||||
def get_file(
|
||||
job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
):
|
||||
response = serve_file(db, readable_job(db, caller, job_id))
|
||||
db.commit()
|
||||
return response
|
||||
|
||||
|
||||
# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the
|
||||
# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other
|
||||
# answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old engines.
|
||||
PREVIEW_HEADERS = {
|
||||
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
}
|
||||
# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start.
|
||||
TRUNCATED_HEADER = "X-Preview-Truncated"
|
||||
|
||||
|
||||
@router.get("/{job_id}/preview")
|
||||
def preview_file(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> Response:
|
||||
"""The file shown in the library's viewer: an image or a PDF inline, a text format as plain text
|
||||
(its first `TEXT_PREVIEW_BYTES`). The type comes from `downloads/preview.py`'s allow-list, so
|
||||
an HTML or SVG upload arrives as text, never as a page. Anything else: 415 `file_no_preview`."""
|
||||
job = accessible_job(db, user, job_id)
|
||||
asset, path = _ready_file(db, job)
|
||||
kind = preview.kind(asset.media_kind, asset.container)
|
||||
if kind is None:
|
||||
raise refused(415, "file_no_preview")
|
||||
headers = dict(PREVIEW_HEADERS)
|
||||
if kind == "text":
|
||||
with path.open("rb") as f:
|
||||
head = f.read(preview.TEXT_PREVIEW_BYTES + 1)
|
||||
if len(head) > preview.TEXT_PREVIEW_BYTES:
|
||||
head = head[: preview.TEXT_PREVIEW_BYTES]
|
||||
headers[TRUNCATED_HEADER] = "1"
|
||||
text = preview.text_utf8(head)
|
||||
if text is None:
|
||||
raise refused(415, "file_no_preview")
|
||||
service.touch(asset)
|
||||
db.commit()
|
||||
return Response(text, media_type=preview.media_type(kind, asset.container), headers=headers)
|
||||
filename = storage.download_filename(
|
||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
||||
)
|
||||
service.touch(asset)
|
||||
db.commit()
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=filename,
|
||||
media_type=preview.media_type(kind, asset.container),
|
||||
content_disposition_type="inline",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}/sha256")
|
||||
def get_sha256(
|
||||
job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""The file's SHA-256 (hex), hashed now if it never was and kept on the asset for every holder.
|
||||
A sync handler: hashing a large file is blocking disk work, so it runs in the threadpool — and
|
||||
every other sync route shares that pool, so only `_HASHERS` files are hashed at a time (a caller
|
||||
past that is told to come back), and none of them holds a database connection while it reads."""
|
||||
asset, path = _ready_file(db, readable_job(db, caller, job_id))
|
||||
if asset.sha256:
|
||||
return {"sha256": asset.sha256}
|
||||
asset_id = asset.id
|
||||
if not _HASHING.acquire(blocking=False):
|
||||
raise refused(429, "file_hash_busy")
|
||||
try:
|
||||
release_read_connection(db)
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while chunk := f.read(_HASH_CHUNK):
|
||||
h.update(chunk)
|
||||
except OSError as e:
|
||||
raise not_found("file") from e
|
||||
finally:
|
||||
_HASHING.release()
|
||||
digest = h.hexdigest()
|
||||
service.store_sha256(db, asset_id, digest)
|
||||
db.commit()
|
||||
return {"sha256": digest}
|
||||
@@ -164,6 +164,7 @@ def unified_library(
|
||||
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"),
|
||||
@@ -211,6 +212,8 @@ def unified_library(
|
||||
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"),
|
||||
@@ -276,6 +279,7 @@ def unified_library(
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"season_count": r.season_count,
|
||||
"status": r.status,
|
||||
"resumable": r.resumable,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.models import (
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.cards import display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||
@@ -176,6 +176,40 @@ def collections(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/collections/for/{item_rating_key}")
|
||||
def collections_for_item(
|
||||
item_rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
|
||||
library (a collection lives in one library, and only a manual one can take a title — the same
|
||||
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
|
||||
movie = by_rating_key(db, PlexItem, item_rating_key)
|
||||
title: PlexItem | PlexShow | None = (
|
||||
movie if movie is not None and movie.kind == "movie" else None
|
||||
) or by_rating_key(db, PlexShow, item_rating_key)
|
||||
if title is None:
|
||||
raise not_found("item")
|
||||
member_of = set(title.collection_keys or [])
|
||||
rows = (
|
||||
db.execute(
|
||||
select(PlexCollection)
|
||||
.where(PlexCollection.library_id == title.library_id)
|
||||
.order_by(collated(PlexCollection.title, lang_for_user(user)))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"collections": [
|
||||
{**_collection_card(c), "contains": c.rating_key in member_of}
|
||||
for c in rows
|
||||
if _collection_editable(c)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -284,7 +318,7 @@ def collection_detail(
|
||||
if shows
|
||||
else []
|
||||
)
|
||||
sstate = {r.show_id: rollup.show_status(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||
|
||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||
@@ -325,7 +359,7 @@ def collection_detail(
|
||||
items = [
|
||||
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
||||
if m.kind == "movie"
|
||||
else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang)
|
||||
else show_card(m.obj, sstate.get(m.obj.id, SHOW_NEW), lang=lang)
|
||||
for m in members
|
||||
]
|
||||
years = [m.year for m in members if m.year is not None]
|
||||
@@ -407,27 +441,47 @@ def create_collection(
|
||||
seed = body.item_rating_key
|
||||
if not title or not seed:
|
||||
raise refused(400, "collection_fields_required")
|
||||
seed_show = by_rating_key(db, PlexShow, seed)
|
||||
if library:
|
||||
lib = (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||
)
|
||||
else:
|
||||
# The "New collection" flow only knows the seed title → derive its library.
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
|
||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||
if lib is None:
|
||||
raise not_found("library")
|
||||
_reject_duplicate_name(db, lib.id, title)
|
||||
wrote = False
|
||||
new_rk = ""
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
new = plex.create_collection(lib.plex_key, title, seed)
|
||||
# A show collection is made as one (Plex type 2): as a movie one it stayed empty.
|
||||
new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1)
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
||||
new_rk = str(new.get("ratingKey") or "")
|
||||
col = plex_sync.resync_collection(db, plex, lib, new_rk)
|
||||
col.editable = True
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
if wrote and new_rk:
|
||||
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
|
||||
# full sync preserves `editable` but creates a missing row read-only, so the collection
|
||||
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
|
||||
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
|
||||
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
|
||||
# the collection it was just added to, and the collection empty, until that sync.
|
||||
db.add(
|
||||
PlexCollection(
|
||||
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
|
||||
)
|
||||
)
|
||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
if row := by_rating_key(db, Model, seed):
|
||||
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
|
||||
db.commit()
|
||||
raise _write_or_pending_error(wrote, e) from e
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
|
||||
from app import logs
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.discover import queries
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
@@ -28,7 +29,7 @@ from app.plex import people as plex_people
|
||||
from app.plex import rollup, subtitles
|
||||
from app.plex.query import title_col
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import episode_card, movie_card, show_card
|
||||
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||
@@ -221,6 +222,24 @@ def show_detail(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/show/{rating_key}/next")
|
||||
def show_next(
|
||||
rating_key: str,
|
||||
lang: str = "en",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The episode a show's card plays: the one in progress, else the first unwatched, else the first
|
||||
(`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow).
|
||||
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
|
||||
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
|
||||
`next` is null for a show with no episodes."""
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
if sh is None:
|
||||
raise not_found("show")
|
||||
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
|
||||
|
||||
|
||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||
season subpage, read from the same cached show-detail payload)."""
|
||||
@@ -260,7 +279,7 @@ def _relatedshow_cards(
|
||||
if sh is None or rk in seen:
|
||||
continue
|
||||
seen.add(rk)
|
||||
out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang))
|
||||
out.append(show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang))
|
||||
if len(out) >= 20:
|
||||
break
|
||||
return out
|
||||
@@ -504,6 +523,7 @@ def item_detail(
|
||||
|
||||
prev_id, next_id = _episode_nav(db, it)
|
||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"kind": it.kind,
|
||||
@@ -537,6 +557,9 @@ def item_detail(
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
|
||||
# The episode page links up to its show and its season (their rating keys = the view keys).
|
||||
"show_id": show.rating_key if show else None,
|
||||
"season_id": season.rating_key if season else None,
|
||||
"season_number": it.season_number,
|
||||
"episode_number": it.episode_number,
|
||||
"prev_id": prev_id,
|
||||
|
||||
@@ -23,7 +23,13 @@ from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexShow, PlexState, User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.cards import (
|
||||
SHOW_NEW,
|
||||
display_title,
|
||||
leaf_card,
|
||||
movie_card,
|
||||
show_card,
|
||||
)
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
@@ -69,7 +75,7 @@ def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> lis
|
||||
|
||||
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, "new"), lang=lang) for s in shows]
|
||||
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||
|
||||
|
||||
def _recently_added(
|
||||
@@ -232,7 +238,9 @@ def _continue_watching(
|
||||
# `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).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.watched,
|
||||
prog.c.inprog,
|
||||
@@ -243,8 +251,9 @@ def _continue_watching(
|
||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for sh, resumed_at in srows:
|
||||
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
||||
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]]
|
||||
@@ -402,11 +411,71 @@ def discover(
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
@router.get("/surprise")
|
||||
def surprise(
|
||||
scope: str = "both",
|
||||
lang: str = "en",
|
||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||||
# The previous draw's rating keys: a reshuffle deals other titles first.
|
||||
exclude: list[str] = Query(default=[], max_length=40),
|
||||
# The draw on screen, read again rather than redrawn: its titles' watch state moves (a play, a
|
||||
# toggle) while the draw itself stands until the ↻.
|
||||
keep: list[str] = Query(default=[], max_length=40),
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -419,6 +488,8 @@ def surprise(
|
||||
lang = _norm_lang(lang)
|
||||
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:
|
||||
@@ -432,23 +503,21 @@ def surprise(
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
movies = (
|
||||
db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p,
|
||||
lang,
|
||||
f,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(per)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
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:
|
||||
@@ -458,21 +527,19 @@ def surprise(
|
||||
not_fully_watched = or_(
|
||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
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,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(per)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
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)
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models import (
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import movie_card, norm_lang, show_card
|
||||
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
|
||||
from app.plex.query import content_libs, split_csv, title_col
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
@@ -57,7 +57,7 @@ def _resolve_known_for(
|
||||
# A credit resolves against its OWN media_type's bucket only.
|
||||
if k.get("media_type") == "tv":
|
||||
if g in shows:
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, SHOW_NEW), lang=lang))
|
||||
elif g in movies:
|
||||
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
||||
return out[:_KNOWN_FOR_SHOWN]
|
||||
@@ -245,7 +245,7 @@ def person_detail(
|
||||
.all()
|
||||
)
|
||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
|
||||
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
|
||||
|
||||
known_for = _resolve_known_for(
|
||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
||||
|
||||
+162
-60
@@ -6,23 +6,26 @@ the app or any account data. Password-protected links exchange the password once
|
||||
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.downloads import queries, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
# Slow password guessing on a link (the token itself is 192-bit, so it needs no throttle). Counted per
|
||||
@@ -37,21 +40,30 @@ _unlock_limit = RateLimiter(max_events=10, window_seconds=300)
|
||||
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
||||
|
||||
|
||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||
def _link_or_404(db: Session, token: str) -> "DownloadLink":
|
||||
link = linksmod.find(db, token)
|
||||
if link is None or linksmod.is_expired(link):
|
||||
raise refused(404, "link_gone")
|
||||
return link
|
||||
|
||||
|
||||
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
def _being_made(db: Session, job: "DownloadJob") -> bool:
|
||||
"""The job is running, or queued for a worker that will take it: the worker leaves a suspended
|
||||
owner's queued jobs where they are (`worker._claim_job`), as long as the suspension lasts."""
|
||||
if job.status == JobStatus.RUNNING:
|
||||
return True
|
||||
if job.status != JobStatus.QUEUED:
|
||||
return False
|
||||
return not queries.owner_suspended(db, job)
|
||||
|
||||
|
||||
def _ready_job(db: Session, job: "DownloadJob | None") -> tuple["DownloadJob", "MediaAsset"]:
|
||||
asset = queries.asset_of(db, job)
|
||||
if (
|
||||
job is not None
|
||||
and asset is not None
|
||||
and asset.status in ("pending", "downloading")
|
||||
and job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
|
||||
and _being_made(db, job)
|
||||
):
|
||||
# Being (re)made — a re-download, or a conversion — so the link works again once it is ready:
|
||||
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
|
||||
@@ -62,42 +74,113 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
||||
raise refused(404, "video_gone")
|
||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
||||
# loads a player for a missing file — meta and file agree.
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise refused(404, "video_gone")
|
||||
return job, asset
|
||||
|
||||
|
||||
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
|
||||
return _ready_target(db, link)[1]
|
||||
def _ready_target(db: Session, link: "DownloadLink") -> tuple["DownloadJob", "MediaAsset"]:
|
||||
"""A file link's file. A folder link names no one file: its files are asked for by id."""
|
||||
if link.job_id is None:
|
||||
raise refused(404, "video_gone")
|
||||
return _ready_job(db, queries.job(db, link.job_id))
|
||||
|
||||
|
||||
def _file_url(token: str, grant: str | None = None) -> str:
|
||||
base = f"/api/public/watch/{token}/file"
|
||||
return f"{base}?g={grant}" if grant else base
|
||||
def _link_folder(db: Session, link: "DownloadLink") -> "FileFolder":
|
||||
folder = queries.link_folder(db, link)
|
||||
if folder is None:
|
||||
raise refused(404, "link_gone")
|
||||
return folder
|
||||
|
||||
|
||||
def _poster_url(token: str, asset, grant: str | None = None) -> str | None:
|
||||
def _folder_job(
|
||||
db: Session, link: "DownloadLink", job_id: int
|
||||
) -> tuple["DownloadJob", "MediaAsset"]:
|
||||
job = queries.folder_job(db, _link_folder(db, link), job_id)
|
||||
if job is None:
|
||||
raise refused(404, "video_gone")
|
||||
return _ready_job(db, job)
|
||||
|
||||
|
||||
def _base(token: str, job_id: int | None) -> str:
|
||||
"""A file link's file lives under its token; a folder link's files, each under its id."""
|
||||
return f"/api/public/watch/{token}" + (f"/files/{job_id}" if job_id is not None else "")
|
||||
|
||||
|
||||
def _file_url(token: str, grant: str | None = None, job_id: int | None = None) -> str:
|
||||
url = f"{_base(token, job_id)}/file"
|
||||
return f"{url}?g={grant}" if grant else url
|
||||
|
||||
|
||||
def _poster_url(
|
||||
token: str, asset: "MediaAsset", grant: str | None = None, job_id: int | None = None
|
||||
) -> str | None:
|
||||
if not asset.poster_path:
|
||||
return None
|
||||
base = f"/api/public/watch/{token}/poster.jpg"
|
||||
return f"{base}?g={grant}" if grant else base
|
||||
url = f"{_base(token, job_id)}/poster.jpg"
|
||||
return f"{url}?g={grant}" if grant else url
|
||||
|
||||
|
||||
def _folder_meta(db: Session, link: "DownloadLink", token: str, grant: str | None) -> dict:
|
||||
"""A folder link's page: the folder's name and each file's own watch metadata
|
||||
(`linksmod.folder_files`: ready, on disk, newest first)."""
|
||||
folder = _link_folder(db, link)
|
||||
files = [
|
||||
{
|
||||
"id": job.id,
|
||||
**linksmod.public_meta(
|
||||
link,
|
||||
job,
|
||||
asset,
|
||||
_file_url(token, grant, job.id),
|
||||
_poster_url(token, asset, grant, job.id),
|
||||
),
|
||||
}
|
||||
for job, asset in linksmod.folder_files(db, folder)
|
||||
]
|
||||
return {
|
||||
"needs_password": False,
|
||||
"kind": "folder",
|
||||
"name": folder.name,
|
||||
"allow_download": link.allow_download,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _file_meta(db: Session, link: "DownloadLink", token: str, grant: str | None) -> dict:
|
||||
job, asset = _ready_target(db, link)
|
||||
return {
|
||||
"needs_password": False,
|
||||
"kind": "file",
|
||||
**linksmod.public_meta(
|
||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _opened(db: Session, link: "DownloadLink", token: str, grant: str | None = None) -> dict:
|
||||
"""The page an open link shows, counted as one view (the handler commits the count)."""
|
||||
meta = (
|
||||
_folder_meta(db, link, token, grant)
|
||||
if link.folder_id is not None
|
||||
else _file_meta(db, link, token, grant)
|
||||
)
|
||||
linksmod.count_view(db, link)
|
||||
return meta
|
||||
|
||||
|
||||
@router.get("/watch/{token}")
|
||||
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
||||
"""Public metadata for the watch page. For a password link, returns only `needs_password`
|
||||
until the viewer unlocks it (no title/thumbnail leak)."""
|
||||
"""Public metadata for the watch page: one file's, or a folder's list. For a password link,
|
||||
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash:
|
||||
return {"needs_password": True}
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
# Which kind it opens, for the password prompt's title — never what is in it.
|
||||
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
|
||||
meta = _opened(db, link, token)
|
||||
db.commit()
|
||||
return {
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
class WatchUnlockIn(BaseModel):
|
||||
@@ -108,7 +191,7 @@ class WatchUnlockIn(BaseModel):
|
||||
def watch_unlock(
|
||||
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Exchange the link password for a signed grant carried by the media URL."""
|
||||
"""Exchange the link password for a signed grant carried by the media URLs."""
|
||||
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
|
||||
# would grow the limiter without bound.
|
||||
link = _link_or_404(db, token)
|
||||
@@ -117,37 +200,28 @@ def watch_unlock(
|
||||
raise refused(429, "too_many_attempts")
|
||||
if not link.password_hash:
|
||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||
job, asset = _ready_target(db, link)
|
||||
return {
|
||||
"ok": True,
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
meta = _opened(db, link, token)
|
||||
db.commit()
|
||||
return {"ok": True, **meta}
|
||||
if not verify_password(body.password, link.password_hash):
|
||||
_unlock_failures.allow(token)
|
||||
raise refused(403, "wrong_share_password")
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
grant = linksmod.make_grant(token, link.password_version)
|
||||
return {
|
||||
"ok": True,
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(
|
||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||
),
|
||||
}
|
||||
meta = _opened(db, link, token, grant)
|
||||
db.commit()
|
||||
return {"ok": True, **meta}
|
||||
|
||||
|
||||
@router.get("/watch/{token}/file")
|
||||
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
def _granted(link: "DownloadLink", token: str, g: str | None) -> None:
|
||||
"""A password link's files want the grant its unlock handed out."""
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise refused(403, "share_password_required")
|
||||
asset = _ready_asset(db, link)
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
|
||||
|
||||
def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
|
||||
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment."""
|
||||
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise refused(404, "video_gone")
|
||||
|
||||
@@ -173,17 +247,45 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
def _poster(asset: "MediaAsset") -> FileResponse:
|
||||
if not asset.poster_path:
|
||||
raise not_found("poster")
|
||||
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise not_found("poster")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("/watch/{token}/file")
|
||||
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""A file link's file. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _serve(link, _ready_target(db, link)[1])
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
|
||||
and the link-preview image). Password links require a valid grant, like the file."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise refused(403, "share_password_required")
|
||||
job, asset = _ready_target(db, link)
|
||||
if not asset.poster_path:
|
||||
raise not_found("poster")
|
||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise not_found("poster")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
_granted(link, token, g)
|
||||
return _poster(_ready_target(db, link)[1])
|
||||
|
||||
|
||||
@router.get("/watch/{token}/files/{job_id}/file")
|
||||
def watch_folder_file(token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""One file of a folder link's folder, while it is in it. Password links require a grant."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _serve(link, _folder_job(db, link, job_id)[1])
|
||||
|
||||
|
||||
@router.get("/watch/{token}/files/{job_id}/poster.jpg")
|
||||
def watch_folder_poster(
|
||||
token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)
|
||||
):
|
||||
"""The poster frame of one file of a folder link's folder, under the same terms as the file."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _poster(_folder_job(db, link, job_id)[1])
|
||||
|
||||
@@ -6,7 +6,7 @@ The aggregate daily budget lives in ApiQuotaUsage; here we read the per-spend au
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -14,6 +14,8 @@ from app import quota
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import QuotaEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import utc
|
||||
|
||||
router = APIRouter(prefix="/api/quota", tags=["quota"])
|
||||
|
||||
@@ -45,23 +47,32 @@ def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db))
|
||||
|
||||
@router.get("/admin")
|
||||
def admin_usage(
|
||||
days: int = 30,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
user: str | None = Query(default=None, pattern=account.PATTERN),
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
days = max(1, min(days, 365))
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
"""Spend per account (and per action) and per day, between `since` (default: 30 days back) and
|
||||
`until` (default: now), optionally for one account (`none` = the background)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
lo = utc(since) or now - timedelta(days=30)
|
||||
window = [QuotaEvent.created_at >= lo]
|
||||
if until is not None:
|
||||
window.append(QuotaEvent.created_at < utc(until))
|
||||
if user is not None:
|
||||
window.append(account.condition(QuotaEvent.user_id, user))
|
||||
|
||||
totals = db.execute(
|
||||
select(QuotaEvent.user_id, func.sum(QuotaEvent.units))
|
||||
.where(QuotaEvent.created_at >= since)
|
||||
.where(*window)
|
||||
.group_by(QuotaEvent.user_id)
|
||||
).all()
|
||||
|
||||
per_action: dict[int | None, dict[str, int]] = {}
|
||||
for uid, action, units in db.execute(
|
||||
select(QuotaEvent.user_id, QuotaEvent.action, func.sum(QuotaEvent.units))
|
||||
.where(QuotaEvent.created_at >= since)
|
||||
.where(*window)
|
||||
.group_by(QuotaEvent.user_id, QuotaEvent.action)
|
||||
).all():
|
||||
per_action.setdefault(uid, {})[action] = int(units)
|
||||
@@ -84,16 +95,16 @@ def admin_usage(
|
||||
]
|
||||
rows.sort(key=lambda r: r["total"], reverse=True)
|
||||
|
||||
# Instance-wide daily series in Pacific days (matches the budget reset).
|
||||
# The daily series in Pacific days (matches the budget reset), of the one account when filtered.
|
||||
day_expr = func.date(func.timezone("America/Los_Angeles", QuotaEvent.created_at))
|
||||
daily = [
|
||||
{"day": str(day), "total": int(units)}
|
||||
for day, units in db.execute(
|
||||
select(day_expr, func.sum(QuotaEvent.units))
|
||||
.where(QuotaEvent.created_at >= since)
|
||||
.where(*window)
|
||||
.group_by(day_expr)
|
||||
.order_by(day_expr)
|
||||
).all()
|
||||
]
|
||||
|
||||
return {"range_days": days, "rows": rows, "daily": daily}
|
||||
return {"rows": rows, "daily": daily}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Admin background-run history: read `job_runs`, a page at a time.
|
||||
|
||||
Admin-only and read-only — rows leave the table through the retention job
|
||||
(`job_run_retention_days`). Filtered on the server and paged by `_keyset`, like the server log and
|
||||
the audit log beside it on the activity page. A row names what a reader needs without a second
|
||||
request: the channel a user's ad-hoc run worked on, and how many log lines the run left (only
|
||||
WARNING and up are stored, so most runs have none — the count says so before the click).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import scheduler
|
||||
from app.auth import admin_user
|
||||
from app.db import get_db
|
||||
from app.models import Channel, JobRun, LogEvent, User
|
||||
from app.routes import _account as account
|
||||
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
||||
from app.runstatus import RunStatus
|
||||
from app.utils import iso, like_escape
|
||||
|
||||
router = APIRouter(prefix="/api/admin/runs", tags=["admin"])
|
||||
|
||||
Facet = Literal["job", "status"]
|
||||
|
||||
|
||||
def _serialize(run: JobRun, titles: dict[str, str], lines: dict[int, int]) -> dict:
|
||||
return {
|
||||
"id": run.id,
|
||||
"job": run.job,
|
||||
"trigger": run.trigger,
|
||||
"actor_id": run.actor_id,
|
||||
"target": run.target,
|
||||
"target_title": titles.get(run.target) if run.target is not None else None,
|
||||
"status": run.status,
|
||||
"started_at": iso(run.started_at),
|
||||
"finished_at": iso(run.finished_at),
|
||||
"summary": run.summary,
|
||||
"result": run.result,
|
||||
"error": run.error,
|
||||
"failure": run.failure,
|
||||
"quota_units": run.quota_units,
|
||||
"log_lines": lines.get(run.id, 0),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunFilters:
|
||||
job: str | None
|
||||
status: RunStatus | None
|
||||
actor: str | None
|
||||
q: str | None
|
||||
since: datetime | None
|
||||
until: datetime | None
|
||||
|
||||
def conditions(self, skip: Facet | None = None) -> list:
|
||||
"""The WHERE conditions, ANDed. `skip` leaves one menu's own filter out, so that menu counts
|
||||
its values under every other filter (the way back from a pick)."""
|
||||
out = []
|
||||
if self.job is not None and skip != "job":
|
||||
out.append(JobRun.job == self.job)
|
||||
if self.status is not None and skip != "status":
|
||||
out.append(JobRun.status == self.status)
|
||||
if self.actor is not None:
|
||||
out.append(account.condition(JobRun.actor_id, self.actor))
|
||||
if self.since is not None:
|
||||
out.append(JobRun.started_at >= utc(self.since))
|
||||
if self.until is not None:
|
||||
out.append(JobRun.started_at < utc(self.until))
|
||||
if self.q and self.q.strip():
|
||||
like = f"%{like_escape(self.q.strip())}%"
|
||||
out.append(
|
||||
or_(
|
||||
JobRun.job.ilike(like),
|
||||
JobRun.target.ilike(like),
|
||||
JobRun.summary.ilike(like),
|
||||
JobRun.error.ilike(like),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def run_filters(
|
||||
job: str | None = Query(default=None, max_length=40, pattern=r"^[a-z][a-z0-9_]*$"),
|
||||
status: RunStatus | None = None,
|
||||
user: str | None = Query(default=None, pattern=account.PATTERN),
|
||||
q: str | None = Query(default=None, max_length=200),
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> RunFilters:
|
||||
return RunFilters(job, status, user, q, since, until)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_runs(
|
||||
filters: RunFilters = Depends(run_filters),
|
||||
before: str | None = Query(default=None, max_length=200),
|
||||
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One page of runs, newest first, matching every filter given; `before` continues from a
|
||||
previous page's `next_cursor`."""
|
||||
query = select(JobRun).where(*filters.conditions())
|
||||
if before is not None:
|
||||
query = query.where(older_than(JobRun.started_at, JobRun.id, before))
|
||||
order = (JobRun.started_at.desc(), JobRun.id.desc())
|
||||
runs, cursor = page(
|
||||
list(db.execute(query.order_by(*order).limit(limit + 1)).scalars().all()),
|
||||
limit,
|
||||
lambda r: r.started_at,
|
||||
)
|
||||
channels = {r.target for r in runs if r.job in scheduler.AD_HOC_JOBS and r.target}
|
||||
titles: dict[str, str] = {}
|
||||
if channels:
|
||||
rows = db.execute(select(Channel.id, Channel.title).where(Channel.id.in_(channels)))
|
||||
titles = {cid: title for cid, title in rows.tuples() if title is not None}
|
||||
lines: dict[int, int] = {}
|
||||
if runs:
|
||||
counted = db.execute(
|
||||
select(LogEvent.run_id, func.count())
|
||||
.where(LogEvent.run_id.in_([r.id for r in runs]))
|
||||
.group_by(LogEvent.run_id)
|
||||
)
|
||||
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
|
||||
return {
|
||||
"items": [_serialize(r, titles, lines) for r in runs],
|
||||
"accounts": accounts(db, {r.actor_id for r in runs if r.actor_id is not None}),
|
||||
"next_cursor": cursor,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/facets")
|
||||
def run_facets(
|
||||
filters: RunFilters = Depends(run_filters),
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""What the job menu and the status chips offer, each counted under every filter but its own."""
|
||||
|
||||
def counts(col, skip: Facet) -> list[dict]:
|
||||
n = func.count().label("n")
|
||||
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
|
||||
return [{"value": v, "count": c} for v, c in db.execute(stmt).all()]
|
||||
|
||||
total = select(func.count()).select_from(JobRun).where(*filters.conditions("status"))
|
||||
return {
|
||||
"jobs": counts(JobRun.job, "job"),
|
||||
"statuses": counts(JobRun.status, "status"),
|
||||
"total": db.scalar(total) or 0,
|
||||
}
|
||||
@@ -17,8 +17,9 @@ from app.scheduler import (
|
||||
JOB_INTERVALS,
|
||||
MAX_INTERVAL,
|
||||
MIN_INTERVAL,
|
||||
apply_interval,
|
||||
reschedule,
|
||||
scheduler_snapshot,
|
||||
stage_interval,
|
||||
trigger_all,
|
||||
trigger_job,
|
||||
)
|
||||
@@ -42,14 +43,14 @@ def set_job_interval(
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
|
||||
to the live scheduler immediately."""
|
||||
"""Change how often a scheduler job runs (minutes). Stored as an override together with its
|
||||
audit row, then applied to the live scheduler."""
|
||||
if job_id not in JOB_INTERVALS:
|
||||
raise not_found("job")
|
||||
minutes = body.interval_minutes
|
||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
|
||||
applied = apply_interval(db, job_id, minutes)
|
||||
applied = stage_interval(db, job_id, minutes)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
@@ -60,6 +61,7 @@ def set_job_interval(
|
||||
after={"interval_minutes": applied},
|
||||
)
|
||||
db.commit()
|
||||
reschedule(job_id, applied)
|
||||
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
||||
|
||||
|
||||
@@ -117,7 +119,7 @@ def set_maintenance_settings(
|
||||
|
||||
@router.get("")
|
||||
def get_scheduler(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
snap = scheduler_snapshot()
|
||||
snap = scheduler_snapshot(db)
|
||||
|
||||
# Work still queued (shared catalog, so these are instance-wide, not per-user).
|
||||
deep_requested = (
|
||||
|
||||
@@ -91,12 +91,12 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
||||
|
||||
@router.post("/config")
|
||||
def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict:
|
||||
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
|
||||
sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic
|
||||
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be the
|
||||
Configuration page's; empty values are skipped (left at the default). Genuinely dynamic
|
||||
key→value map, so it stays a typed dict rather than a fixed model."""
|
||||
saved: list[str] = []
|
||||
for key, value in settings_map.items():
|
||||
if sysconfig.spec(key) is None:
|
||||
if sysconfig.config_page_spec(key) is None:
|
||||
raise refused(400, "unknown_setting", key=key)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""A user's API tokens for their Files library (`app.apitokens`): list, make, revoke.
|
||||
|
||||
Signed-in only — `current_user`, never a token: a token cannot make itself a wider one. A new
|
||||
token's value is in the answer that made it and nowhere after; only its hash is kept.
|
||||
|
||||
The Hub preset is the one machine a user swaps files with: it makes (or reuses) their Hub folder
|
||||
and gives the token every right in it, so the hub both drops files there and picks up the ones put
|
||||
there for it.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import apitokens, audit
|
||||
from app.apitokens import GRANT_COLUMNS, Right
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.downloads import folders, queries
|
||||
from app.models import ApiToken, ApiTokenGrant, User
|
||||
from app.refusal import refused
|
||||
from app.routes._common import owned_or_404
|
||||
from app.security import hash_token
|
||||
from app.utils import iso
|
||||
|
||||
router = APIRouter(prefix="/api/tokens", tags=["tokens"])
|
||||
|
||||
# Far more than one person's machines; a ceiling, so a script gone wrong cannot mint without end.
|
||||
MAX_TOKENS = 20
|
||||
_MAX_GRANTS = 50
|
||||
_MAX_EXPIRY_DAYS = 3650
|
||||
|
||||
|
||||
class GrantIn(BaseModel):
|
||||
folder_id: int
|
||||
rights: list[Right] = Field(min_length=1, max_length=len(Right))
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
# None = never expires.
|
||||
expires_days: int | None = Field(default=None, ge=1, le=_MAX_EXPIRY_DAYS)
|
||||
preset: Literal["hub"] | None = None
|
||||
grants: list[GrantIn] = Field(default_factory=list, max_length=_MAX_GRANTS)
|
||||
|
||||
|
||||
def _serialize(db: Session, token: ApiToken) -> dict:
|
||||
grants = list(
|
||||
db.execute(
|
||||
select(ApiTokenGrant)
|
||||
.where(ApiTokenGrant.token_id == token.id)
|
||||
.order_by(ApiTokenGrant.folder_id)
|
||||
).scalars()
|
||||
)
|
||||
# The folders are Capture's: read through its queries, not its table.
|
||||
by_id = queries.folders_by_id(db, {g.folder_id for g in grants})
|
||||
rows = [(g, by_id[g.folder_id]) for g in grants if g.folder_id in by_id]
|
||||
now = datetime.now(timezone.utc)
|
||||
return {
|
||||
"id": token.id,
|
||||
"name": token.name,
|
||||
"prefix": token.prefix,
|
||||
"created_at": iso(token.created_at),
|
||||
"expires_at": iso(token.expires_at),
|
||||
"expired": token.expires_at is not None and token.expires_at <= now,
|
||||
"expiring": apitokens.expiring(token, now),
|
||||
"last_used_at": iso(token.last_used_at),
|
||||
"grants": [
|
||||
{
|
||||
"folder_id": folder.id,
|
||||
"kind": folder.kind,
|
||||
"source_key": folder.source_key,
|
||||
"name": folder.name,
|
||||
"rights": sorted(apitokens.grant_rights(grant)),
|
||||
}
|
||||
for grant, folder in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tokens(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
tokens = (
|
||||
db.execute(select(ApiToken).where(ApiToken.user_id == user.id).order_by(ApiToken.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_serialize(db, t) for t in tokens]
|
||||
|
||||
|
||||
def _wanted_grants(db: Session, user: User, body: TokenIn) -> dict[int, set[Right]]:
|
||||
"""Folder id → the rights the new token gets there; every folder checked to be the user's, and
|
||||
writing only where an upload may land (`folders.upload_target`)."""
|
||||
if body.preset == "hub":
|
||||
if body.grants:
|
||||
raise refused(400, "api_token_preset_grants")
|
||||
return {folders.hub_folder(db, user.id).id: set(Right)}
|
||||
if not body.grants:
|
||||
raise refused(400, "api_token_no_grants")
|
||||
wanted: dict[int, set[Right]] = {}
|
||||
for grant in body.grants:
|
||||
wanted.setdefault(grant.folder_id, set()).update(grant.rights)
|
||||
for folder_id, rights in wanted.items():
|
||||
try:
|
||||
if Right.WRITE in rights:
|
||||
folders.upload_target(db, user.id, folder_id)
|
||||
else:
|
||||
folders.own_folder(db, user.id, folder_id)
|
||||
except folders.FolderError as e:
|
||||
raise refused(409, e.reason) from e
|
||||
return wanted
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_token(
|
||||
body: TokenIn, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Make a token; its value is in this answer only."""
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise refused(400, "api_token_name_required")
|
||||
# The user's row, locked: two requests at once cannot both slip under the ceiling, and a
|
||||
# session ended since `current_user` read the epoch (unlocked) cannot mint a token that outlives
|
||||
# it — `bump_session_epoch` takes this lock before it revokes them all. No key update: a row that
|
||||
# only refers to the user (a folder delete's audit line) must not wait on it, or the two deadlock.
|
||||
epoch = db.execute(
|
||||
select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True)
|
||||
).scalar_one()
|
||||
if (epoch or 0) != (user.session_epoch or 0):
|
||||
raise refused(401, "not_authenticated")
|
||||
count = db.execute(
|
||||
select(func.count(ApiToken.id)).where(ApiToken.user_id == user.id)
|
||||
).scalar_one()
|
||||
if count >= MAX_TOKENS:
|
||||
raise refused(409, "api_token_limit", limit=MAX_TOKENS)
|
||||
wanted = _wanted_grants(db, user, body)
|
||||
raw = apitokens.mint()
|
||||
token = ApiToken(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
prefix=raw[: apitokens.SHOWN_LEN],
|
||||
token_hash=hash_token(raw),
|
||||
expires_at=None
|
||||
if body.expires_days is None
|
||||
else datetime.now(timezone.utc) + timedelta(days=body.expires_days),
|
||||
)
|
||||
db.add(token)
|
||||
db.flush()
|
||||
for folder_id, rights in wanted.items():
|
||||
db.add(
|
||||
ApiTokenGrant(
|
||||
token_id=token.id,
|
||||
folder_id=folder_id,
|
||||
**{col: right in rights for right, col in GRANT_COLUMNS.items()},
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
user.id,
|
||||
AuditAction.API_TOKEN_CREATE,
|
||||
target_type="api_token",
|
||||
target_id=token.id,
|
||||
after={
|
||||
"grants": {str(f): sorted(r) for f, r in wanted.items()},
|
||||
"expires_at": iso(token.expires_at),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, **_serialize(db, token), "token": raw}
|
||||
|
||||
|
||||
@router.delete("/{token_id}")
|
||||
def revoke_token(
|
||||
token_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Revoke a token: it stops working at its next request (its grants go with it)."""
|
||||
token = owned_or_404(db, ApiToken, token_id, user, what="api_token")
|
||||
db.delete(token)
|
||||
audit.record(
|
||||
db, user.id, AuditAction.API_TOKEN_REVOKE, target_type="api_token", target_id=token_id
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": token_id}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""The words a background run's row is written with — the `job_runs` table's status and trigger.
|
||||
|
||||
One source for the literals: the scheduler writes them, the startup sweep rewrites `running`, the
|
||||
model's CHECK constraints are built from these sets, and the dashboard reads them back (the frontend's
|
||||
copies are held to them by `backendIds.contract.test.ts`). The migrated database's CHECKs are frozen
|
||||
in its migration: a test holds them equal to these sets, so a new word fails until a migration adds
|
||||
it (`test_runstatus.py`). The download pipeline's `cancelled`/`canceled` drift (`downloads/status.py`)
|
||||
is why a status is never typed as a bare string.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RunStatus(StrEnum):
|
||||
RUNNING = "running"
|
||||
OK = "ok"
|
||||
ERROR = "error"
|
||||
# Sync was paused: the run was due and deliberately did nothing.
|
||||
SKIPPED = "skipped"
|
||||
# The process stopped while the run was going (a deploy, a crash): set at the next startup, so a
|
||||
# row never claims to be running in a process that no longer exists.
|
||||
INTERRUPTED = "interrupted"
|
||||
|
||||
|
||||
class RunTrigger(StrEnum):
|
||||
# The run's interval came round.
|
||||
SCHEDULED = "scheduled"
|
||||
# An admin pressed "run now" (or "run all").
|
||||
MANUAL = "manual"
|
||||
# A user's own action started work on one target (a channel's full history), outside the
|
||||
# recurring jobs.
|
||||
AD_HOC = "ad_hoc"
|
||||
|
||||
|
||||
def sql_in(values: type[StrEnum]) -> str:
|
||||
"""`'a', 'b'` — the IN-list a CHECK constraint names these values with."""
|
||||
return ", ".join(f"'{v.value}'" for v in values)
|
||||
+348
-191
@@ -3,28 +3,31 @@ recent-first / deep backfill. Runs inside the API process (single worker)."""
|
||||
|
||||
import contextvars
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from datetime import timezone
|
||||
from typing import Callable
|
||||
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.jobstores.base import ConflictingIdError
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import audit, debugsession, logs, logsink, progress, quota
|
||||
from app.audit import AuditAction
|
||||
from app import audit, debugsession, jobruns, logs, logsink, progress, quota, sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads.gc import run_download_gc
|
||||
from app.models import SchedulerSetting
|
||||
from app.models import Channel, JobRun
|
||||
from app.plex.sync import sync as run_plex_sync
|
||||
from app.plex.tmdb import run_enrich as run_tmdb_enrich
|
||||
from app.plex.tmdb import run_people_enrich
|
||||
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
|
||||
from app.notifications import create_notification
|
||||
from app.runstatus import RunStatus, RunTrigger
|
||||
from app.state import is_sync_paused
|
||||
from app.sync.autotag import run_autotag_all
|
||||
from app.sync.explore import purge_ephemeral
|
||||
from app.sync.maintenance import run_maintenance
|
||||
from app.sync.playlists import sync_all_playlists
|
||||
from app.utils import iso
|
||||
from app.sync.runner import (
|
||||
run_deep_backfill,
|
||||
run_enrich,
|
||||
@@ -37,12 +40,31 @@ from app.sync.runner import (
|
||||
log = logs.get_logger(__name__)
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the
|
||||
# scheduler lives in the same single-worker process as the API, so a request can read it
|
||||
# directly). Ephemeral by design — it reflects "what the scheduler is doing right now and
|
||||
# how the last runs went" since this process started; durable history isn't the goal here.
|
||||
# What is running RIGHT NOW, per job: the single-run claim and the live progress. In memory on
|
||||
# purpose — the scheduler lives in the API's one process, and progress changes every second. What a
|
||||
# run did is durable instead: its `job_runs` row (app/jobruns.py), which outlives a restart.
|
||||
_activity: dict[str, dict] = {}
|
||||
_activity_lock = threading.Lock()
|
||||
# The runs this process has open (run id -> job), under the same lock. A row can stay `running` in
|
||||
# the table after its outcome failed to write (_close), so the table cannot say what is going; this
|
||||
# can, as the one process per database (app/jobruns.py) is the one running them.
|
||||
_open_runs: dict[int, str] = {}
|
||||
|
||||
|
||||
def _opened(run_id: int, job: str) -> None:
|
||||
with _activity_lock:
|
||||
_open_runs[run_id] = job
|
||||
|
||||
|
||||
def _closed(run_id: int) -> None:
|
||||
with _activity_lock:
|
||||
_open_runs.pop(run_id, None)
|
||||
|
||||
|
||||
def _open_run_ids() -> dict[int, str]:
|
||||
with _activity_lock:
|
||||
return dict(_open_runs)
|
||||
|
||||
|
||||
# Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that
|
||||
# run — and only that run, not the recurring scheduled ones — posts a completion notification
|
||||
@@ -51,59 +73,36 @@ _manual_actor: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
||||
"manual_actor", default=None
|
||||
)
|
||||
|
||||
# Each interval job's env/config default period (minutes). The admin can override any of
|
||||
# these at runtime (stored in scheduler_settings, applied live); see load_intervals.
|
||||
# Each interval job's env/config default period (minutes). The admin can override any of these at
|
||||
# runtime from the Scheduler page — a sysconfig key per job (`sysconfig.interval_key`), applied
|
||||
# live; see load_intervals.
|
||||
JOB_INTERVALS: dict[str, int] = {
|
||||
"rss_poll": settings.rss_poll_minutes,
|
||||
"enrich": settings.enrich_interval_minutes,
|
||||
"backfill": settings.backfill_interval_minutes,
|
||||
"autotag": settings.autotag_interval_minutes,
|
||||
"shorts": settings.shorts_probe_interval_minutes,
|
||||
"subscriptions": settings.subscriptions_resync_minutes,
|
||||
"playlist_sync": settings.playlist_sync_minutes,
|
||||
"maintenance": settings.maintenance_interval_minutes,
|
||||
"explore_cleanup": settings.explore_cleanup_minutes,
|
||||
"download_gc": settings.download_gc_minutes,
|
||||
"plex_sync": settings.plex_sync_interval_min,
|
||||
"plex_watch_sync": settings.plex_watch_sync_interval_min,
|
||||
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
|
||||
"tmdb_enrich": settings.tmdb_enrich_interval_min,
|
||||
"people_enrich": settings.people_enrich_interval_min,
|
||||
"audit_gc": settings.audit_gc_minutes,
|
||||
"log_gc": settings.log_gc_minutes,
|
||||
job: getattr(settings, attr) for job, attr in sysconfig.JOB_INTERVAL_DEFAULTS.items()
|
||||
}
|
||||
|
||||
# Sane bounds for an admin-set interval (minutes).
|
||||
MIN_INTERVAL = 1
|
||||
MAX_INTERVAL = 1440 # one day
|
||||
MIN_INTERVAL = sysconfig.MIN_INTERVAL
|
||||
MAX_INTERVAL = sysconfig.MAX_INTERVAL
|
||||
|
||||
|
||||
def load_intervals(db) -> dict[str, int]:
|
||||
"""Effective per-job intervals: the env/config defaults overlaid with any admin overrides
|
||||
saved in scheduler_settings."""
|
||||
iv = dict(JOB_INTERVALS)
|
||||
for row in db.execute(select(SchedulerSetting)).scalars():
|
||||
if row.job_id in iv and row.interval_minutes:
|
||||
iv[row.job_id] = row.interval_minutes
|
||||
return iv
|
||||
"""Effective per-job intervals: each job's sysconfig override, else its env/config default."""
|
||||
return {job: sysconfig.get_int(db, sysconfig.interval_key(job)) for job in JOB_INTERVALS}
|
||||
|
||||
|
||||
def apply_interval(db, job_id: str, minutes: int) -> int:
|
||||
"""Persist a job's interval override and reschedule the live job immediately (if the
|
||||
scheduler runs in this process). Returns the applied value."""
|
||||
def stage_interval(db, job_id: str, minutes: int) -> int:
|
||||
"""Stage a job's interval override (clamped) in `db`; returns the value. The caller commits it
|
||||
with its audit row, then calls `reschedule` — the live job follows only what was stored."""
|
||||
if job_id not in JOB_INTERVALS:
|
||||
raise KeyError(job_id)
|
||||
minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes)))
|
||||
row = db.get(SchedulerSetting, job_id)
|
||||
if row is None:
|
||||
db.add(SchedulerSetting(job_id=job_id, interval_minutes=minutes))
|
||||
else:
|
||||
row.interval_minutes = minutes
|
||||
db.commit()
|
||||
sysconfig.set_value(db, sysconfig.interval_key(job_id), minutes)
|
||||
return minutes
|
||||
|
||||
|
||||
def reschedule(job_id: str, minutes: int) -> None:
|
||||
"""Move the live interval job to `minutes` (if this process registered it)."""
|
||||
if _scheduler is not None and _scheduler.get_job(job_id) is not None:
|
||||
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
|
||||
log.info("rescheduled job %s to every %s min", job_id, minutes)
|
||||
return minutes
|
||||
|
||||
|
||||
def _record(name: str, **fields) -> None:
|
||||
@@ -112,7 +111,12 @@ def _record(name: str, **fields) -> None:
|
||||
|
||||
|
||||
def _notify_done(
|
||||
db, actor_id: int, job_id: str, status: str, summary: str | None, result: dict | None = None
|
||||
db,
|
||||
actor_id: int,
|
||||
job_id: str,
|
||||
status: RunStatus,
|
||||
summary: str | None,
|
||||
result: dict | None = None,
|
||||
) -> None:
|
||||
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best
|
||||
effort — a notification failure must never mask the job's own outcome."""
|
||||
@@ -130,49 +134,6 @@ def _notify_done(
|
||||
log.exception("failed to write completion notification for job %s", job_id)
|
||||
|
||||
|
||||
def _run_changed(result) -> bool:
|
||||
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.)
|
||||
A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex
|
||||
off — the default) are no-ops, not changes, so they don't flood the trail every interval."""
|
||||
if result is None:
|
||||
return False
|
||||
if isinstance(result, dict):
|
||||
# A phased job (backfill's recent/deep, people_enrich's bios/portraits) nests a result per
|
||||
# phase: a count inside a phase is a change too.
|
||||
return any(
|
||||
_run_changed(v) if isinstance(v, dict) else isinstance(v, (int, float)) and bool(v)
|
||||
for v in result.values()
|
||||
)
|
||||
if isinstance(result, (int, float)):
|
||||
return result != 0
|
||||
return bool(result)
|
||||
|
||||
|
||||
def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
|
||||
"""Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin
|
||||
clicked "run now" → actor_id set) and errors are always logged; automatic runs only when
|
||||
they changed something, so routine no-op polls don't spam the trail. Best-effort."""
|
||||
if actor_id is None and status == "ok" and not _run_changed(result):
|
||||
return
|
||||
# The result as data too: the audit log names it in the reader's language, as the inbox
|
||||
# does; `summary` stays the English line of the row's own summary column.
|
||||
data = _result_data(result)
|
||||
try:
|
||||
audit.record(
|
||||
db,
|
||||
actor_id,
|
||||
AuditAction.JOB_RUN,
|
||||
target_type="job",
|
||||
target_id=job_id,
|
||||
summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""),
|
||||
after={"status": status, "summary": summary, **({"result": data} if data else {})},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("failed to write audit row for job %s", job_id)
|
||||
|
||||
|
||||
def _job(name: str, fn) -> None:
|
||||
# The one place both run paths converge (manual trigger AND scheduled fire), so claim the run
|
||||
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
|
||||
@@ -180,71 +141,88 @@ def _job(name: str, fn) -> None:
|
||||
if not _claim_running(name):
|
||||
log.info("job %s already running — skipping this trigger", name)
|
||||
return
|
||||
db = SessionLocal()
|
||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
||||
_record(
|
||||
name, last_started=datetime.now(timezone.utc).isoformat(), last_error=None, progress=None
|
||||
) # running already claimed
|
||||
trigger = RunTrigger.SCHEDULED if actor_id is None else RunTrigger.MANUAL
|
||||
_record(name, progress=None) # running already claimed
|
||||
db = SessionLocal()
|
||||
run_id = None
|
||||
try:
|
||||
try:
|
||||
run_id = jobruns.start(name, trigger, actor_id)
|
||||
except Exception:
|
||||
# No row to tell it in: the database is what failed. The log is the record.
|
||||
log.exception("job %s not started: its run could not be recorded", name)
|
||||
return
|
||||
_opened(run_id, name)
|
||||
with logs.bind(run_id=run_id):
|
||||
_run(db, name, fn, run_id, actor_id)
|
||||
finally:
|
||||
# ALWAYS release the claim, even if the body raised before its own status update — so a
|
||||
# failure can never wedge the job as permanently "running".
|
||||
if run_id is not None:
|
||||
_closed(run_id)
|
||||
_record(name, running=False, progress=None)
|
||||
db.close()
|
||||
|
||||
|
||||
def _run(db, name: str, fn, run_id: int, actor_id: int | None) -> None:
|
||||
"""The run itself, inside its `job_runs` row: the pause-skip, the work, and its outcome — on
|
||||
the row, and in the triggering admin's inbox for a manual run."""
|
||||
|
||||
def sink(current, total, phase):
|
||||
_record(name, progress={"current": current, "total": total, "phase": phase})
|
||||
|
||||
# Only the work is inside the try: writing its outcome is not the job, and a write that fails
|
||||
# must not turn a run that succeeded into a failed one (see _close).
|
||||
result = None
|
||||
try:
|
||||
if is_sync_paused(db):
|
||||
log.info("job %s skipped (sync paused)", name)
|
||||
_record(
|
||||
name,
|
||||
status="skipped",
|
||||
last_finished=datetime.now(timezone.utc).isoformat(),
|
||||
last_result="paused",
|
||||
result=_PAUSED,
|
||||
progress=None,
|
||||
)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED)
|
||||
_audit_run(db, actor_id, name, "skipped", "sync paused", _PAUSED)
|
||||
return
|
||||
with progress.bind(sink):
|
||||
result = fn(db)
|
||||
summary = _summarize(result)
|
||||
data = _result_data(result)
|
||||
log.info("job %s -> %s", name, result)
|
||||
_record(
|
||||
name,
|
||||
status="ok",
|
||||
last_finished=datetime.now(timezone.utc).isoformat(),
|
||||
last_result=summary,
|
||||
result=data,
|
||||
progress=None,
|
||||
)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "ok", summary, data)
|
||||
_audit_run(db, actor_id, name, "ok", summary, result)
|
||||
paused = is_sync_paused(db)
|
||||
if not paused:
|
||||
with progress.bind(sink):
|
||||
result = fn(db)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
|
||||
err = str(exc) or exc.__class__.__name__
|
||||
# A failure the job named (progress.JobFailed) rides along as data, as a result does, so the
|
||||
# client says it in the reader's language; `err` stays the English line.
|
||||
failure = (
|
||||
{"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
|
||||
)
|
||||
_record(
|
||||
name,
|
||||
status="error",
|
||||
last_finished=datetime.now(timezone.utc).isoformat(),
|
||||
last_error=err,
|
||||
result=failure,
|
||||
progress=None,
|
||||
)
|
||||
err, failure = _failure(exc)
|
||||
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "error", err, failure)
|
||||
_audit_run(db, actor_id, name, "error", err, failure)
|
||||
finally:
|
||||
# ALWAYS release the claim, even if the body raised before its own status update — so a
|
||||
# failure can never wedge the job as permanently "running".
|
||||
_record(name, running=False)
|
||||
db.close()
|
||||
_notify_done(db, actor_id, name, RunStatus.ERROR, err, failure)
|
||||
return
|
||||
if paused:
|
||||
log.info("job %s skipped (sync paused)", name)
|
||||
_close(run_id, RunStatus.SKIPPED, summary="sync paused", result=_PAUSED)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, RunStatus.SKIPPED, "sync paused", _PAUSED)
|
||||
return
|
||||
summary, data = _finish_ok(name, run_id, result)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, RunStatus.OK, summary, data)
|
||||
|
||||
|
||||
def _close(run_id: int, status: RunStatus, **outcome) -> None:
|
||||
"""Write a run's outcome. Its own failure (the database) is the log's to tell, never the job's:
|
||||
the row then stays `running` until the next start's sweep closes it as interrupted."""
|
||||
try:
|
||||
jobruns.finish(run_id, status, **outcome)
|
||||
except Exception:
|
||||
log.exception("run %s: its outcome (%s) could not be recorded", run_id, status)
|
||||
|
||||
|
||||
def _finish_ok(name: str, run_id: int, result) -> tuple[str | None, dict | None]:
|
||||
"""Close a run that returned: its result on the row, as data and as the English one-liner."""
|
||||
summary = _summarize(result)
|
||||
data = _result_data(result)
|
||||
log.info("job %s -> %s", name, result)
|
||||
_close(run_id, RunStatus.OK, summary=summary, result=data)
|
||||
return summary, data
|
||||
|
||||
|
||||
def _failure(exc: Exception) -> tuple[str, dict | None]:
|
||||
"""A raised run's English error line, and the failure the job named (progress.JobFailed) as
|
||||
data, as a result is, so the client says it in the reader's language."""
|
||||
err = str(exc) or exc.__class__.__name__
|
||||
failure = {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
|
||||
return err, failure
|
||||
|
||||
|
||||
# A skipped run's result, in the same shape as a job's own `{"skipped": …}`.
|
||||
@@ -271,11 +249,13 @@ def _summarize(result) -> str | None:
|
||||
return str(result)
|
||||
|
||||
|
||||
def scheduler_snapshot() -> dict:
|
||||
"""What the scheduler is doing, for the admin dashboard. `running_here` is False in
|
||||
environments where the scheduler is disabled (e.g. local dev shares the prod-ish DB but
|
||||
runs no scheduler), so the UI can say so while still showing DB-derived queue/quota."""
|
||||
running_here = _scheduler is not None
|
||||
def scheduler_snapshot(db) -> dict:
|
||||
"""What the scheduler is doing, for the admin dashboard: each job's live state from this process
|
||||
and its last runs from `job_runs`, so a restart no longer blanks "when did it last run, and how
|
||||
did it go"; and the same for the ad-hoc runs users start on one target. `running_here` is False where the scheduler runs no interval jobs
|
||||
(SCHEDULER_ENABLED=false), so the UI can say so while still showing the DB-derived history,
|
||||
queue and quota."""
|
||||
running_here = _scheduler is not None and settings.scheduler_enabled
|
||||
next_runs: dict[str, str | None] = {}
|
||||
live_intervals: dict[str, int] = {}
|
||||
if _scheduler is not None:
|
||||
@@ -287,6 +267,12 @@ def scheduler_snapshot() -> dict:
|
||||
live_intervals[job.id] = round(iv.total_seconds() / 60)
|
||||
with _activity_lock:
|
||||
acts = {k: dict(v) for k, v in _activity.items()}
|
||||
# Open before the read or after it: a run that started or ended while the rows were read is
|
||||
# still counted as the open run it was, never as a stray one.
|
||||
open_runs = _open_run_ids()
|
||||
recent = jobruns.recent_runs(db, [*JOB_INTERVALS, *AD_HOC_JOBS], RECENT_RUNS)
|
||||
open_runs |= _open_run_ids()
|
||||
going_id = max((r for r, j in open_runs.items() if j == CHANNEL_BACKFILL), default=None)
|
||||
jobs = []
|
||||
for job_id, default_interval in JOB_INTERVALS.items():
|
||||
a = acts.get(job_id, {})
|
||||
@@ -296,16 +282,94 @@ def scheduler_snapshot() -> dict:
|
||||
"interval_minutes": live_intervals.get(job_id, default_interval),
|
||||
"next_run": next_runs.get(job_id),
|
||||
"running": a.get("running", False),
|
||||
"status": a.get("status"),
|
||||
"last_started": a.get("last_started"),
|
||||
"last_finished": a.get("last_finished"),
|
||||
"last_result": a.get("last_result"),
|
||||
"result": a.get("result"),
|
||||
"last_error": a.get("last_error"),
|
||||
"progress": a.get("progress"),
|
||||
**_last_runs(recent.get(job_id, []), open_runs),
|
||||
}
|
||||
)
|
||||
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
|
||||
# Work a user starts on one target: no interval, no "run now" (it needs its target), but its
|
||||
# runs are shown beside the jobs' — it spends the same quota. Its one in-memory guard is the
|
||||
# target's own (sync/runner), so "running" is the runs this process has open: two channels can
|
||||
# backfill at once, so a run still going counts even when a newer one already ended. That run then
|
||||
# leads the row, so its channel, its "no outcome yet" and its dot are one run's, never the
|
||||
# newer run's outcome under the older one's channel.
|
||||
backfills = recent.get(CHANNEL_BACKFILL, [])
|
||||
going = db.get(JobRun, going_id) if going_id is not None else None
|
||||
if going is not None and going.status != RunStatus.RUNNING:
|
||||
going = None # its outcome is written; only the bookkeeping has not caught up
|
||||
if going is not None:
|
||||
backfills = [going, *(r for r in backfills if r.id != going.id)][:RECENT_RUNS]
|
||||
shown = backfills[0] if backfills else None
|
||||
ad_hoc = [
|
||||
{
|
||||
"id": CHANNEL_BACKFILL,
|
||||
"running": going is not None,
|
||||
"target": shown.target if shown else None,
|
||||
"target_title": (
|
||||
db.scalar(select(Channel.title).where(Channel.id == shown.target))
|
||||
if shown is not None and shown.target
|
||||
else None
|
||||
),
|
||||
**_last_runs(backfills, open_runs),
|
||||
}
|
||||
]
|
||||
return {
|
||||
"running_here": running_here,
|
||||
"enabled": settings.scheduler_enabled,
|
||||
"jobs": jobs,
|
||||
"ad_hoc": ad_hoc,
|
||||
}
|
||||
|
||||
|
||||
# How many past runs a Scheduler row shows as dots; the full history is the run list.
|
||||
RECENT_RUNS = 3
|
||||
|
||||
|
||||
def _seen(run, open_runs: dict[int, str]) -> RunStatus:
|
||||
"""A run's status as the page tells it. A row `running` that no run here has open is one whose
|
||||
outcome failed to write: the next start's sweep closes it as interrupted, so it reads so now —
|
||||
not as a run still going beside a row that says nothing runs."""
|
||||
if run.status == RunStatus.RUNNING and run.id not in open_runs:
|
||||
return RunStatus.INTERRUPTED
|
||||
return RunStatus(run.status)
|
||||
|
||||
|
||||
def _last_runs(runs: list, open_runs: dict[int, str]) -> dict:
|
||||
"""A row's run fields from its newest runs (newest first): how the last one went, and the few
|
||||
before it for the history dots."""
|
||||
run = runs[0] if runs else None
|
||||
status = _seen(run, open_runs) if run is not None else None
|
||||
# A run still going has no outcome yet: only an ended run says how it went.
|
||||
ended = run if status is not None and status != RunStatus.RUNNING else None
|
||||
return {
|
||||
"status": status if ended else None,
|
||||
"last_started": iso(run.started_at) if run else None,
|
||||
# A stray row has no end of its own: it ended, as far as anyone can tell, where it began.
|
||||
"last_finished": iso(ended.finished_at or ended.started_at) if ended else None,
|
||||
"last_result": ended.summary if ended else None,
|
||||
# The outcome as data: the result, or on a failed run the failure the job named.
|
||||
"result": _outcome_data(ended),
|
||||
"last_error": ended.error if ended else None,
|
||||
"last_trigger": run.trigger if run else None,
|
||||
"last_run_id": run.id if run else None,
|
||||
"quota_units": ended.quota_units if ended else None,
|
||||
"recent": [_run_brief(r, open_runs) for r in runs],
|
||||
}
|
||||
|
||||
|
||||
def _run_brief(run, open_runs: dict[int, str]) -> dict:
|
||||
return {
|
||||
"id": run.id,
|
||||
"status": _seen(run, open_runs),
|
||||
"trigger": run.trigger,
|
||||
"started_at": iso(run.started_at),
|
||||
"finished_at": iso(run.finished_at),
|
||||
}
|
||||
|
||||
|
||||
def _outcome_data(run) -> dict | None:
|
||||
if run is None:
|
||||
return None
|
||||
return run.failure if run.status == RunStatus.ERROR else run.result
|
||||
|
||||
|
||||
def _rss_job() -> None:
|
||||
@@ -370,7 +434,8 @@ def _explore_cleanup_job() -> None:
|
||||
|
||||
|
||||
def _download_gc_job() -> None:
|
||||
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction.
|
||||
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction — and
|
||||
# the week-ahead warning before an API token runs out (`apitokens.warn_expiring`).
|
||||
# Pure disk/DB work (no YouTube quota).
|
||||
_job("download_gc", run_download_gc)
|
||||
|
||||
@@ -407,15 +472,16 @@ def _people_enrich_job() -> None:
|
||||
|
||||
|
||||
def _audit_gc_job() -> None:
|
||||
# The history cleanup: audit-log rows past audit_retention_days and YouTube quota history past
|
||||
# quota_retention_days (0 = keep forever, each). Pure DB, no quota.
|
||||
# The history cleanup: audit-log rows past audit_retention_days, YouTube quota history past
|
||||
# quota_retention_days and finished runs past job_run_retention_days (0 = keep forever, each).
|
||||
# Pure DB, no quota.
|
||||
def work(db):
|
||||
from app import sysconfig
|
||||
|
||||
reaped = audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))
|
||||
quota_reaped = quota.gc(db, sysconfig.get_int(db, "quota_retention_days"))
|
||||
runs_reaped = jobruns.gc(db, sysconfig.get_int(db, "job_run_retention_days"))
|
||||
db.commit()
|
||||
return {"reaped": reaped, "quota_reaped": quota_reaped}
|
||||
return {"reaped": reaped, "quota_reaped": quota_reaped, "runs_reaped": runs_reaped}
|
||||
|
||||
_job("audit_gc", work)
|
||||
|
||||
@@ -424,7 +490,6 @@ def _log_gc_job() -> None:
|
||||
# Prune log_events rows older than log_retention_days — the limit the privacy policy states, so
|
||||
# this runs hourly rather than daily. Pure DB, no quota.
|
||||
def work(db):
|
||||
from app import sysconfig
|
||||
|
||||
days = sysconfig.get_int(db, "log_retention_days")
|
||||
reaped = logsink.gc(db, days)
|
||||
@@ -484,15 +549,29 @@ def running_job_ids() -> set[str]:
|
||||
return {jid for jid, a in _activity.items() if a.get("running")}
|
||||
|
||||
|
||||
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
|
||||
"""Run a job immediately in a background thread, independent of its interval schedule.
|
||||
The wrapper handles its own DB session, activity tracking and pause-skip, so the live
|
||||
dashboard reflects the run. Returns "started", "already_running", or raises KeyError for
|
||||
an unknown job. Refusing a concurrent run keeps a manual trigger from overlapping a
|
||||
scheduled run (APScheduler enforces max_instances=1 for the scheduled side).
|
||||
def _as_actor(fn: Callable[[], None], actor_id: int | None) -> Callable[[], None]:
|
||||
"""`fn` run as `actor_id`'s manual run. The actor is RESET afterwards: the executor's pool
|
||||
threads are reused and keep their context from task to task, so an actor left set would make
|
||||
the next scheduled run that thread picks up look like this admin's manual run."""
|
||||
|
||||
`actor_id` (the admin who clicked "run now") is stashed in a contextvar inside the new
|
||||
thread so the run posts a completion notification to that admin's inbox."""
|
||||
def run() -> None:
|
||||
token = _manual_actor.set(actor_id)
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
_manual_actor.reset(token)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
|
||||
"""Run a job now, independent of its interval schedule: a one-shot job on the same scheduler,
|
||||
so a manual run shares the scheduled runs' executor and shutdown — no thread of its own. The
|
||||
wrapper handles its own DB session, run row and pause-skip. Returns "started",
|
||||
"already_running", or raises KeyError for an unknown job.
|
||||
|
||||
`actor_id` (the admin who clicked "run now") is set in a contextvar inside the run's thread, so
|
||||
the run is recorded as theirs and posts a completion notification to their inbox."""
|
||||
fn = JOB_FUNCS.get(job_id)
|
||||
if fn is None:
|
||||
raise KeyError(job_id)
|
||||
@@ -501,13 +580,18 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
|
||||
# _job claims, sees it's taken, and skips — no double run.
|
||||
if _is_running(job_id):
|
||||
return "already_running"
|
||||
sched = _scheduler
|
||||
if sched is None:
|
||||
# Only between shutdown and exit: start_scheduler runs at every API startup.
|
||||
raise RuntimeError("the scheduler is not running")
|
||||
|
||||
def run() -> None:
|
||||
if actor_id is not None:
|
||||
_manual_actor.set(actor_id)
|
||||
fn()
|
||||
|
||||
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
|
||||
try:
|
||||
# One id per job: a second click before the first one-shot has even started is the same
|
||||
# run, not a queued second one. misfire_grace_time=None: a busy pool delays it, never drops it.
|
||||
sched.add_job(_as_actor(fn, actor_id), "date", id=f"{job_id}@now", misfire_grace_time=None)
|
||||
# silent-ok: not a failure — the answer is the 409 the admin sees ("already running").
|
||||
except ConflictingIdError:
|
||||
return "already_running"
|
||||
return "started"
|
||||
|
||||
|
||||
@@ -520,22 +604,95 @@ def trigger_all(actor_id: int | None = None) -> list[str]:
|
||||
return started
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global _scheduler
|
||||
if not settings.scheduler_enabled or _scheduler is not None:
|
||||
return
|
||||
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
|
||||
db = SessionLocal()
|
||||
# Work a user starts on one target outside the job list — today the "get full history" backfill of
|
||||
# one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and
|
||||
# spent is in `job_runs` like any job's, not a background task only the log ever heard of.
|
||||
CHANNEL_BACKFILL = "channel_backfill"
|
||||
# Every ad-hoc job id: the page names each one (a frontend contract test reads this tuple).
|
||||
AD_HOC_JOBS = (CHANNEL_BACKFILL,)
|
||||
|
||||
|
||||
def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None:
|
||||
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
|
||||
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
|
||||
a second request before the first has even started is that same run."""
|
||||
if job not in AD_HOC_JOBS:
|
||||
raise KeyError(job) # an unnamed job would show on the page as its bare id
|
||||
sched = _scheduler
|
||||
if sched is None:
|
||||
# Only between shutdown and exit: start_scheduler runs at every API startup.
|
||||
raise RuntimeError("the scheduler is not running")
|
||||
try:
|
||||
iv = load_intervals(db)
|
||||
sched.add_job(
|
||||
_ad_hoc,
|
||||
"date",
|
||||
args=(job, fn, actor_id, target),
|
||||
id=f"{job}@{target}",
|
||||
misfire_grace_time=None,
|
||||
)
|
||||
# silent-ok: not a failure — the run for this target is already queued, and it does this work.
|
||||
except ConflictingIdError:
|
||||
log.info("ad-hoc %s for %s already queued", job, target)
|
||||
|
||||
|
||||
def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> None:
|
||||
try:
|
||||
run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target)
|
||||
except Exception:
|
||||
# No row to tell it in: the database is what failed. The log is the record.
|
||||
log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target)
|
||||
return
|
||||
_opened(run_id, job)
|
||||
try:
|
||||
_ad_hoc_work(job, fn, run_id)
|
||||
finally:
|
||||
db.close()
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
for job_id, fn in JOB_FUNCS.items():
|
||||
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
|
||||
_closed(run_id)
|
||||
|
||||
|
||||
def _ad_hoc_work(job: str, fn: Callable[[], dict], run_id: int) -> None:
|
||||
with logs.bind(run_id=run_id):
|
||||
# As in _run: only the work is inside the try.
|
||||
try:
|
||||
result = fn()
|
||||
except Exception as exc:
|
||||
log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job))
|
||||
err, failure = _failure(exc)
|
||||
_close(run_id, RunStatus.ERROR, error=err, failure=failure)
|
||||
return
|
||||
_finish_ok(job, run_id, result)
|
||||
|
||||
|
||||
# Room for every job at once (a "run all") beside a few ad-hoc runs: APScheduler's default pool of
|
||||
# 10 would queue a scheduled fire behind long manual runs.
|
||||
_POOL_SIZE = len(JOB_FUNCS) + 8
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""Start this process's scheduler. It always starts, so "run now" works on every install; only
|
||||
with SCHEDULER_ENABLED does it also register the recurring interval jobs. Before anything can
|
||||
run, every run a previous process left open is closed as interrupted — one API process serves a
|
||||
database (app/jobruns.py), so an open run here is a dead process's."""
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
return
|
||||
left = jobruns.interrupt_leftovers()
|
||||
if left:
|
||||
log.warning("%s run(s) left running by the previous process marked interrupted", left)
|
||||
scheduler = BackgroundScheduler(
|
||||
timezone="UTC", executors={"default": ThreadPoolExecutor(_POOL_SIZE)}
|
||||
)
|
||||
if settings.scheduler_enabled:
|
||||
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
iv = load_intervals(db)
|
||||
finally:
|
||||
db.close()
|
||||
for job_id, fn in JOB_FUNCS.items():
|
||||
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
|
||||
scheduler.start()
|
||||
_scheduler = scheduler
|
||||
log.info("scheduler started")
|
||||
log.info("scheduler started (interval jobs %s)", "on" if settings.scheduler_enabled else "off")
|
||||
|
||||
|
||||
def shutdown_scheduler() -> None:
|
||||
|
||||
@@ -46,6 +46,12 @@ _fernet: Fernet | None = (
|
||||
)
|
||||
|
||||
|
||||
def can_encrypt() -> bool:
|
||||
"""Whether a value can be stored encrypted here: TOKEN_ENCRYPTION_KEY is set. Production
|
||||
refuses to start without it (`config`); a local box may run without one."""
|
||||
return _fernet is not None
|
||||
|
||||
|
||||
def _require_fernet() -> Fernet:
|
||||
if _fernet is None:
|
||||
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
||||
|
||||
+22
-21
@@ -437,7 +437,7 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
||||
|
||||
|
||||
# Channels being deep-backfilled RIGHT NOW by EITHER path — the interactive backfill_channel_now (a
|
||||
# FastAPI BackgroundTasks threadpool thread) OR the scheduler's run_deep_backfill (its own thread).
|
||||
# one-shot run on the scheduler's pool, see scheduler.run_ad_hoc) OR the scheduler's run_deep_backfill.
|
||||
# BOTH register the channel they're paging (under the lock) so the other skips it: no two runs page
|
||||
# the same channel's `backfill_cursor` concurrently, whichever started first. `_immediate_running`
|
||||
# counts only the interactive path, capped so one user's burst can't drain the shared budget (the
|
||||
@@ -448,9 +448,9 @@ _backfill_lock = threading.Lock()
|
||||
_MAX_CONCURRENT_IMMEDIATE = 2
|
||||
|
||||
|
||||
def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) -> None:
|
||||
"""User-initiated "get full history": deep-backfill ONE channel to completion right now, in a
|
||||
background task with its OWN session, so the full catalogue lands in seconds instead of over the
|
||||
def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) -> dict:
|
||||
"""User-initiated "get full history": deep-backfill ONE channel to completion right now, as its
|
||||
own recorded run with its OWN session, so the full catalogue lands in seconds instead of over the
|
||||
paced scheduler's 10-minute ticks (max_pages=5/channel/tick). max_pages is high enough to finish
|
||||
any realistic channel in a single run (400 pages = 20 000 videos; e.g. BBC Earth's ~3400 = 69
|
||||
pages — the old 60-page cap stopped one page short of large channels, so their tail dribbled in
|
||||
@@ -459,34 +459,35 @@ def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) ->
|
||||
Quota-guarded, capped to _MAX_CONCURRENT_IMMEDIATE interactive runs so one user can't drain the
|
||||
shared backfill budget by opting many channels in at once (the overflow, and any channel too large
|
||||
to finish under the reserve, is finished by the scheduler since deep_requested stays set), and
|
||||
mutually excluded per-channel with the scheduler via _backfill_in_progress. Best-effort — logs and
|
||||
swallows failures."""
|
||||
mutually excluded per-channel with the scheduler via _backfill_in_progress.
|
||||
|
||||
Returns what the run did, as a job result: the counts, or `{"skipped": <why>}` when it left the
|
||||
channel to the scheduler. A failure raises — the run that wraps this records it."""
|
||||
global _immediate_running
|
||||
with _backfill_lock:
|
||||
if channel_id in _backfill_in_progress or _immediate_running >= _MAX_CONCURRENT_IMMEDIATE:
|
||||
return # already backfilling this channel, or too many interactive runs — leave it to the scheduler
|
||||
if channel_id in _backfill_in_progress:
|
||||
return {"skipped": "channel busy"}
|
||||
if _immediate_running >= _MAX_CONCURRENT_IMMEDIATE:
|
||||
return {"skipped": "at capacity"}
|
||||
_backfill_in_progress.add(channel_id)
|
||||
_immediate_running += 1
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
user = get_service_user(db)
|
||||
if user is None:
|
||||
return
|
||||
return {"skipped": "no service user"}
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None or channel.backfill_done or not channel.uploads_playlist_id:
|
||||
return
|
||||
return {"skipped": "nothing to do"}
|
||||
if quota.backfill_reserve_reached():
|
||||
return
|
||||
try:
|
||||
# Attribute the spend to the requesting user under the full-backfill action, so it
|
||||
# shows up correctly in the usage breakdown (not the default OTHER/system bucket).
|
||||
with quota.attribute(user_id, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
backfill_channel_deep(db, yt, channel, max_pages)
|
||||
reconcile_full_history(db)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("Immediate backfill failed for channel %s", channel_id)
|
||||
return {"skipped": "low quota"}
|
||||
# Attribute the spend to the requesting user under the full-backfill action, so it
|
||||
# shows up correctly in the usage breakdown (not the default OTHER/system bucket).
|
||||
with quota.attribute(user_id, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
added = backfill_channel_deep(db, yt, channel, max_pages)
|
||||
reconciled = reconcile_full_history(db)
|
||||
return {"videos_added": added, "reconciled_full": reconciled}
|
||||
finally:
|
||||
with _backfill_lock:
|
||||
_backfill_in_progress.discard(channel_id)
|
||||
|
||||
+251
-61
@@ -8,7 +8,8 @@ stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secr
|
||||
stored in the DB and stay env-only).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -27,6 +28,16 @@ _decrypt_warned: set[str] = set()
|
||||
_coerce_warned: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
# When a changed value takes effect. "live": the next read obeys it — every consumer reads the key
|
||||
# where it uses it, never once at import (test-held: a read outside a function fails). A value read
|
||||
# once at startup is live only when its writer also applies it (the job intervals reschedule, the log
|
||||
# level is set as it is saved). "restart": the process has to start
|
||||
# again. No key needs a restart today, and the Configuration page has no way to say so yet, so a
|
||||
# "restart" key is refused until the page can (test-held).
|
||||
Applies = Literal["live", "restart"]
|
||||
LIVE: Applies = "live"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigSpec:
|
||||
key: str
|
||||
@@ -37,6 +48,8 @@ class ConfigSpec:
|
||||
min: int | None = None
|
||||
max: int | None = None
|
||||
allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset"
|
||||
# Required, with no default: each key states it, so a new one cannot be live by omission.
|
||||
applies: Applies = field(kw_only=True)
|
||||
|
||||
@property
|
||||
def choices(self) -> tuple[str, ...]:
|
||||
@@ -45,30 +58,78 @@ class ConfigSpec:
|
||||
return choices_of(Settings.model_fields[self.default_attr].annotation)
|
||||
|
||||
|
||||
# Each interval job of the scheduler and the `settings` attribute holding its default period
|
||||
# (minutes). Its override is the `job_interval_<job>` key, set from the Scheduler page.
|
||||
JOB_INTERVAL_DEFAULTS: dict[str, str] = {
|
||||
"rss_poll": "rss_poll_minutes",
|
||||
"enrich": "enrich_interval_minutes",
|
||||
"backfill": "backfill_interval_minutes",
|
||||
"autotag": "autotag_interval_minutes",
|
||||
"shorts": "shorts_probe_interval_minutes",
|
||||
"subscriptions": "subscriptions_resync_minutes",
|
||||
"playlist_sync": "playlist_sync_minutes",
|
||||
"maintenance": "maintenance_interval_minutes",
|
||||
"explore_cleanup": "explore_cleanup_minutes",
|
||||
"download_gc": "download_gc_minutes",
|
||||
"plex_sync": "plex_sync_interval_min",
|
||||
"plex_watch_sync": "plex_watch_sync_interval_min",
|
||||
"plex_watch_reconcile": "plex_watch_reconcile_interval_min",
|
||||
"tmdb_enrich": "tmdb_enrich_interval_min",
|
||||
"people_enrich": "people_enrich_interval_min",
|
||||
"audit_gc": "audit_gc_minutes",
|
||||
"log_gc": "log_gc_minutes",
|
||||
}
|
||||
# Sane bounds for an admin-set interval (minutes).
|
||||
MIN_INTERVAL = 1
|
||||
MAX_INTERVAL = 1440 # one day
|
||||
|
||||
|
||||
def interval_key(job: str) -> str:
|
||||
return f"job_interval_{job}"
|
||||
|
||||
|
||||
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
|
||||
# off env into the admin UI; the admin page renders it automatically from this list. A group's keys
|
||||
# stay together, in the order its section lists them; a NEW group also needs its place in SECTIONS.
|
||||
SPECS: tuple[ConfigSpec, ...] = (
|
||||
# --- Access / registration ---
|
||||
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
||||
ConfigSpec("allow_registration", "bool", "access", "allow_registration", applies=LIVE),
|
||||
# --- Email / SMTP (one logical group; the password is the only secret) ---
|
||||
ConfigSpec("smtp_host", "str", "email", "smtp_host"),
|
||||
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
|
||||
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True),
|
||||
ConfigSpec("smtp_from", "str", "email", "smtp_from"),
|
||||
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
|
||||
ConfigSpec("smtp_host", "str", "email", "smtp_host", applies=LIVE),
|
||||
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535, applies=LIVE),
|
||||
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True, applies=LIVE),
|
||||
ConfigSpec("smtp_from", "str", "email", "smtp_from", applies=LIVE),
|
||||
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True, applies=LIVE),
|
||||
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True, applies=LIVE),
|
||||
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
|
||||
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
|
||||
ConfigSpec(
|
||||
"youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True, applies=LIVE
|
||||
),
|
||||
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
|
||||
# install wizard / Configuration page so no restart is needed when the creds change. ---
|
||||
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
|
||||
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
|
||||
# --- Quota (shared YouTube Data API daily budget) ---
|
||||
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
|
||||
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True, applies=LIVE),
|
||||
ConfigSpec(
|
||||
"backfill_quota_reserve", "int", "quota", "backfill_quota_reserve", min=0, max=1_000_000
|
||||
"google_client_secret", "str", "google", "google_client_secret", secret=True, applies=LIVE
|
||||
),
|
||||
# --- Quota (shared YouTube Data API daily budget) ---
|
||||
ConfigSpec(
|
||||
"quota_daily_budget",
|
||||
"int",
|
||||
"quota",
|
||||
"quota_daily_budget",
|
||||
min=1,
|
||||
max=1_000_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"backfill_quota_reserve",
|
||||
"int",
|
||||
"quota",
|
||||
"backfill_quota_reserve",
|
||||
min=0,
|
||||
max=1_000_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"search_daily_limit_per_user",
|
||||
@@ -77,10 +138,19 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"search_daily_limit_per_user",
|
||||
min=0,
|
||||
max=100_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
|
||||
ConfigSpec("search_source", "choice", "quota", "search_source"),
|
||||
ConfigSpec("quota_retention_days", "int", "quota", "quota_retention_days", min=0, max=3650),
|
||||
ConfigSpec("search_source", "choice", "quota", "search_source", applies=LIVE),
|
||||
ConfigSpec(
|
||||
"quota_retention_days",
|
||||
"int",
|
||||
"quota",
|
||||
"quota_retention_days",
|
||||
min=0,
|
||||
max=3650,
|
||||
applies=LIVE,
|
||||
),
|
||||
# --- Backfill (recent-first first pass per channel) ---
|
||||
ConfigSpec(
|
||||
"backfill_recent_max_videos",
|
||||
@@ -89,19 +159,46 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"backfill_recent_max_videos",
|
||||
min=1,
|
||||
max=100_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500
|
||||
"backfill_recent_max_days",
|
||||
"int",
|
||||
"backfill",
|
||||
"backfill_recent_max_days",
|
||||
min=1,
|
||||
max=36_500,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168, applies=LIVE
|
||||
),
|
||||
ConfigSpec("rss_fallback_hours", "int", "backfill", "rss_fallback_hours", min=0, max=168),
|
||||
# --- Shorts probe ---
|
||||
ConfigSpec(
|
||||
"shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600
|
||||
"shorts_probe_max_seconds",
|
||||
"int",
|
||||
"shorts",
|
||||
"shorts_probe_max_seconds",
|
||||
min=1,
|
||||
max=3_600,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"shorts_probe_batch", "int", "shorts", "shorts_probe_batch", min=1, max=10_000, applies=LIVE
|
||||
),
|
||||
ConfigSpec("shorts_probe_batch", "int", "shorts", "shorts_probe_batch", min=1, max=10_000),
|
||||
# --- Batch sizes ---
|
||||
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
||||
ConfigSpec(
|
||||
"enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50, applies=LIVE
|
||||
),
|
||||
ConfigSpec(
|
||||
"autotag_title_sample",
|
||||
"int",
|
||||
"batch",
|
||||
"autotag_title_sample",
|
||||
min=1,
|
||||
max=1_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
# --- Maintenance (rolling re-validation of already-known videos) ---
|
||||
# Videos re-checked per maintenance run. Folded here from app_state (B-core-11) so every admin
|
||||
# knob lives in one store; read by sync/maintenance.py, tuned from the Scheduler dashboard.
|
||||
@@ -112,16 +209,33 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"maintenance_revalidate_batch",
|
||||
min=100,
|
||||
max=100_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
# --- Discovery cleanup (search + explore ephemeral content) ---
|
||||
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
|
||||
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
|
||||
# Days an un-kept live-search result video is kept before the cleanup job removes it.
|
||||
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
|
||||
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
|
||||
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
|
||||
ConfigSpec(
|
||||
"download_default_max_bytes", "int", "downloads", "download_default_max_bytes", min=0
|
||||
"explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650, applies=LIVE
|
||||
),
|
||||
# Days an un-kept live-search result video is kept before the cleanup job removes it.
|
||||
ConfigSpec(
|
||||
"search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650, applies=LIVE
|
||||
),
|
||||
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
|
||||
ConfigSpec(
|
||||
"download_total_max_bytes",
|
||||
"int",
|
||||
"downloads",
|
||||
"download_total_max_bytes",
|
||||
min=0,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"download_default_max_bytes",
|
||||
"int",
|
||||
"downloads",
|
||||
"download_default_max_bytes",
|
||||
min=0,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"download_default_max_concurrent",
|
||||
@@ -130,6 +244,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"download_default_max_concurrent",
|
||||
min=1,
|
||||
max=20,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"download_default_max_jobs",
|
||||
@@ -138,6 +253,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"download_default_max_jobs",
|
||||
min=1,
|
||||
max=100_000,
|
||||
applies=LIVE,
|
||||
),
|
||||
# min=1, NOT 0: a stored 0 used to read as "no per-file cap" (the cap was enforced as
|
||||
# `if cap and size > cap`, so a falsy value disabled it). Now the route passes this number
|
||||
@@ -161,14 +277,27 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"download_upload_max_bytes",
|
||||
min=1,
|
||||
max=settings.download_upload_max_bytes,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
|
||||
"download_retention_days",
|
||||
"int",
|
||||
"downloads",
|
||||
"download_retention_days",
|
||||
min=0,
|
||||
max=3_650,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec(
|
||||
"download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650
|
||||
"download_gc_grace_days",
|
||||
"int",
|
||||
"downloads",
|
||||
"download_gc_grace_days",
|
||||
min=0,
|
||||
max=3_650,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec("download_layout", "choice", "downloads", "download_layout"),
|
||||
ConfigSpec("download_layout", "choice", "downloads", "download_layout", applies=LIVE),
|
||||
ConfigSpec(
|
||||
"download_worker_concurrency",
|
||||
"int",
|
||||
@@ -176,60 +305,112 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
"download_worker_concurrency",
|
||||
min=1,
|
||||
max=16,
|
||||
applies=LIVE,
|
||||
),
|
||||
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
||||
# --- Audit log ---
|
||||
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
|
||||
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
|
||||
ConfigSpec(
|
||||
"audit_retention_days",
|
||||
"int",
|
||||
"audit",
|
||||
"audit_retention_days",
|
||||
min=0,
|
||||
max=3650,
|
||||
applies=LIVE,
|
||||
),
|
||||
# Days the job_runs table keeps a finished background run before the audit_gc job prunes it.
|
||||
ConfigSpec(
|
||||
"job_run_retention_days",
|
||||
"int",
|
||||
"audit",
|
||||
"job_run_retention_days",
|
||||
min=0,
|
||||
max=3650,
|
||||
applies=LIVE,
|
||||
),
|
||||
# --- Diagnostics ---
|
||||
# Runtime log level for the API process. Applied without a restart — see routes/config.py and
|
||||
# main.py's lifespan.
|
||||
ConfigSpec("log_level", "choice", "diagnostics", "log_level"),
|
||||
ConfigSpec("log_level", "choice", "diagnostics", "log_level", applies=LIVE),
|
||||
# Days the log_events table keeps a row (the log_gc job prunes). No "0 = forever": stored logs
|
||||
# carry account-linked request history, and the privacy policy names a limit.
|
||||
ConfigSpec("log_retention_days", "int", "diagnostics", "log_retention_days", min=1, max=90),
|
||||
ConfigSpec(
|
||||
"log_retention_days",
|
||||
"int",
|
||||
"diagnostics",
|
||||
"log_retention_days",
|
||||
min=1,
|
||||
max=90,
|
||||
applies=LIVE,
|
||||
),
|
||||
# --- Plex integration (optional; local-file playback + Plex metadata) ---
|
||||
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
|
||||
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
|
||||
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True),
|
||||
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"),
|
||||
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64),
|
||||
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled", applies=LIVE),
|
||||
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url", applies=LIVE),
|
||||
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True, applies=LIVE),
|
||||
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map", applies=LIVE),
|
||||
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries", applies=LIVE),
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map", applies=LIVE),
|
||||
ConfigSpec(
|
||||
"plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16, applies=LIVE
|
||||
),
|
||||
ConfigSpec(
|
||||
"plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64, applies=LIVE
|
||||
),
|
||||
# Transcode re-encode tuning. The libx264 knobs apply only to the software fallback.
|
||||
# crf 0–51 (x264 range); max_height 0 = source.
|
||||
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
|
||||
ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"),
|
||||
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
|
||||
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw", applies=LIVE),
|
||||
ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset", applies=LIVE),
|
||||
ConfigSpec(
|
||||
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
|
||||
"plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51, applies=LIVE
|
||||
),
|
||||
ConfigSpec(
|
||||
"plex_transcode_max_height",
|
||||
"int",
|
||||
"plex",
|
||||
"plex_transcode_max_height",
|
||||
min=0,
|
||||
max=4320,
|
||||
applies=LIVE,
|
||||
),
|
||||
# Audio loudness normalisation on the HLS path (remux + transcode alike, not direct play).
|
||||
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"),
|
||||
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
|
||||
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm", applies=LIVE),
|
||||
# --- TMDB enrichment (optional) — the API key is the only secret ---
|
||||
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
|
||||
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
|
||||
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True, applies=LIVE),
|
||||
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language", applies=LIVE),
|
||||
# --- Bug reports: the Gitea issue tracker a debug session's issue goes to ---
|
||||
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"),
|
||||
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"),
|
||||
ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True),
|
||||
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
|
||||
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
|
||||
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url", applies=LIVE),
|
||||
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo", applies=LIVE),
|
||||
ConfigSpec(
|
||||
"issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True, applies=LIVE
|
||||
),
|
||||
# --- Scheduler: how often each interval job runs (minutes), one key per job ---
|
||||
*(
|
||||
ConfigSpec(
|
||||
interval_key(job),
|
||||
"int",
|
||||
"scheduler",
|
||||
attr,
|
||||
min=MIN_INTERVAL,
|
||||
max=MAX_INTERVAL,
|
||||
applies=LIVE,
|
||||
)
|
||||
for job, attr in JOB_INTERVAL_DEFAULTS.items()
|
||||
),
|
||||
)
|
||||
|
||||
# The admin page's sections (the owner's pick): sixteen groups as tabs wrapped to three rows at 133%
|
||||
# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in
|
||||
# exactly one section (test-held), so a new group cannot fall off the page.
|
||||
# exactly one section, or is edited on its own page (test-held), so a new group cannot fall off.
|
||||
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("access", ("access", "google", "email")),
|
||||
("youtube", ("youtube", "quota", "backfill", "shorts", "batch", "explore")),
|
||||
("media", ("downloads", "plex", "tmdb")),
|
||||
("system", ("maintenance", "audit", "diagnostics", "issues")),
|
||||
)
|
||||
# Groups another admin page edits, so the Configuration page neither lists nor writes them: an
|
||||
# interval is changed on the Scheduler page, which also reschedules the running job — a write that
|
||||
# skipped it would leave the job on its old period while the page showed the new one.
|
||||
OWN_PAGE_GROUPS: tuple[str, ...] = ("scheduler",)
|
||||
|
||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||
|
||||
@@ -238,6 +419,12 @@ def spec(key: str) -> ConfigSpec | None:
|
||||
return _BY_KEY.get(key)
|
||||
|
||||
|
||||
def config_page_spec(key: str) -> ConfigSpec | None:
|
||||
"""`spec`, for the Configuration page: None for a key edited on its own page as well."""
|
||||
s = _BY_KEY.get(key)
|
||||
return None if s is None or s.group in OWN_PAGE_GROUPS else s
|
||||
|
||||
|
||||
def secrets_manageable() -> bool:
|
||||
"""Secret values can only be stored when Fernet is configured (TOKEN_ENCRYPTION_KEY)."""
|
||||
return bool(settings.token_encryption_key)
|
||||
@@ -426,13 +613,16 @@ def reset(db: Session, key: str) -> None:
|
||||
|
||||
|
||||
def describe(db: Session) -> dict:
|
||||
"""Admin-UI metadata + current values, grouped. Secret values are never echoed —
|
||||
only whether an override is set and whether an env default exists."""
|
||||
"""Admin-UI metadata + current values, grouped — the keys of the Configuration page, not those
|
||||
edited on their own page. Secret values are never echoed — only whether an override is set and
|
||||
whether an env default exists."""
|
||||
groups: dict[str, list[dict]] = {}
|
||||
# Every override in one query: a db.get per key was one round trip each (two for a key with no
|
||||
# row, which the session cannot cache), ~40 ms of the page's ~55 on prod.
|
||||
rows = {r.key: r for r in db.scalars(select(SystemConfig))}
|
||||
for s in SPECS:
|
||||
if s.group in OWN_PAGE_GROUPS:
|
||||
continue
|
||||
row = rows.get(s.key)
|
||||
is_set = row is not None
|
||||
item: dict = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Every table, registered on `Base.metadata`.
|
||||
|
||||
Each unit declares its tables in its own `models` module, so importing `app.models` alone no longer
|
||||
names the whole schema. Whatever needs all of it — the migrations, the test database, a check over
|
||||
every mapper — imports this module.
|
||||
"""
|
||||
|
||||
import app.downloads.models # Capture's tables
|
||||
import app.models # noqa: F401 — the core tables
|
||||
@@ -1,13 +1,54 @@
|
||||
"""Shared "who is an active, non-suspended account" predicate.
|
||||
"""Whose a row is, and who an account is to the others — one place for both.
|
||||
|
||||
Used wherever a user must be filtered out of a recipient list or rejected as a target: the download
|
||||
share-by-email and recipient-picker endpoints. Kept in one place so the two forms (SQL clauses for a
|
||||
query, Python check for a loaded row) can't drift apart, and so a route module doesn't have to import
|
||||
another route module just to reuse the rule.
|
||||
**Ownership.** A user-owned table says whose a row is in one column (`user_id`, or `to_user_id` for
|
||||
something handed to them). A unit's reads and writes name that column through `owned_by` and fetch a
|
||||
row by id through `owned_row`, never with an inline `Model.user_id == …`: the rule "a row owned by
|
||||
someone else is answered like a missing one" then lives here, and a contract test
|
||||
(`test_unit_fences.py`) holds a switched-on unit to it.
|
||||
|
||||
**Recipients.** "An active, non-suspended account" — the download share-by-email and the recipient
|
||||
picker filter on it. Kept here so the two forms (SQL clauses for a query, a Python check for a loaded
|
||||
row) can't drift apart.
|
||||
"""
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import ColumnElement, func, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Session
|
||||
|
||||
from app.collation import collated
|
||||
from app.db import Base
|
||||
from app.models import User
|
||||
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
|
||||
def _owner_id(owner: User | int) -> int:
|
||||
return owner if isinstance(owner, int) else owner.id
|
||||
|
||||
|
||||
def owned_by(column: InstrumentedAttribute, owner: User | int) -> ColumnElement[bool]:
|
||||
"""`column` — a table's owner column — names `owner`."""
|
||||
return column == _owner_id(owner)
|
||||
|
||||
|
||||
def owned_row(
|
||||
db: Session,
|
||||
model: type[M],
|
||||
id_: Any,
|
||||
owner: User | int,
|
||||
*,
|
||||
owner_attr: str = "user_id",
|
||||
lock: bool = False,
|
||||
) -> M | None:
|
||||
"""The row of `model` with this primary key when `owner` owns it, else None — a row of someone
|
||||
else's is the same answer as a missing one, so a caller can never tell the two apart. `lock`
|
||||
takes it FOR UPDATE (past the identity map)."""
|
||||
row = db.get(model, id_, with_for_update=True if lock else None)
|
||||
if row is None or getattr(row, owner_attr) != _owner_id(owner):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def messageable_clauses() -> list:
|
||||
"""SQL clauses for "an active, non-suspended account" — splat into a recipient query."""
|
||||
@@ -20,3 +61,30 @@ def messageable_clauses() -> list:
|
||||
def is_messageable_user(u: User | None) -> bool:
|
||||
"""Python mirror of `messageable_clauses()` for an already-loaded row (a share recipient)."""
|
||||
return bool(u and u.is_active and not u.is_suspended)
|
||||
|
||||
|
||||
def user_by_email(db: Session, email: str) -> User | None:
|
||||
"""The account with this address, matched without regard to case."""
|
||||
return db.execute(
|
||||
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def messageable_others(db: Session, user: User, lang: str) -> list[User]:
|
||||
"""Every account `user` may hand something to: active, not suspended, not themselves; in the
|
||||
reader's alphabet by address."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(User)
|
||||
.where(User.id != user.id, *messageable_clauses())
|
||||
.order_by(collated(User.email, lang))
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def emails_of(db: Session, ids: set[int] | None = None) -> dict[int, str]:
|
||||
"""Each account's address by id, in address order: these ids, or every account."""
|
||||
stmt = select(User.id, User.email).order_by(User.email)
|
||||
if ids is not None:
|
||||
stmt = stmt.where(User.id.in_(ids))
|
||||
return dict(db.execute(stmt).tuples().all())
|
||||
|
||||
+49
-14
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
from app import logsink
|
||||
@@ -36,7 +37,7 @@ from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import credentials, formats, quota, service
|
||||
from app.downloads import credentials, formats, quota, service, storage
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
@@ -44,8 +45,9 @@ from app.downloads.jobdb import (
|
||||
_reset_asset_pending,
|
||||
_staging_dir,
|
||||
)
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobKind, JobStatus
|
||||
from app.models import DISPLAY_TEXT_MAX
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
_stop = threading.Event()
|
||||
@@ -162,7 +164,12 @@ def _claim_job() -> int | None:
|
||||
seen.) The per-user limit is the quota row's `max_concurrent` (else the sysconfig default);
|
||||
`unlimited` users are never capped. A final Python `at_concurrency_limit` re-check still guards
|
||||
the tiny race between the exclusion subquery and the claim (it can only ever reject, never
|
||||
over-claim), so correctness never depends on the two observations agreeing."""
|
||||
over-claim), so correctness never depends on the two observations agreeing.
|
||||
|
||||
A suspended user's queued jobs are left where they are: suspension blocks their downloads (not
|
||||
only their sign-in), and lifting it lets them run in their old place. A job already running
|
||||
when the user is suspended finishes on that worker; one a worker restart puts back in the queue
|
||||
(`_requeue_if_running`, `_recover_orphans`) waits with the rest."""
|
||||
with SessionLocal() as db:
|
||||
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
|
||||
rows = db.execute(
|
||||
@@ -170,6 +177,10 @@ def _claim_job() -> int | None:
|
||||
"""
|
||||
SELECT id, user_id FROM download_jobs
|
||||
WHERE status='queued'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = download_jobs.user_id AND u.is_suspended
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_assets a
|
||||
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
||||
@@ -437,7 +448,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
if status is None or status in STOPPED_BY_ROUTE:
|
||||
reason = "cancelled" # a route stopped it (or the row vanished)
|
||||
break
|
||||
if status == "running":
|
||||
if status == JobStatus.RUNNING:
|
||||
if stale is not None and stale > stall_s:
|
||||
reason = "stalled"
|
||||
break
|
||||
@@ -448,7 +459,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
# teardown. A still-running job over the cap is failed ("timeout"); a terminal one (already
|
||||
# done/error) just gets its lingering child killed, its DB status left intact ("abandon").
|
||||
if time.monotonic() - started > wall_s:
|
||||
reason = "timeout" if status == "running" else "abandon"
|
||||
reason = "timeout" if status == JobStatus.RUNNING else "abandon"
|
||||
break
|
||||
# else: a terminal status under the cap (done/error, or a re-queued contended asset) means
|
||||
# the child is exiting on its own — keep polling; NEVER kill a finishing child (that could
|
||||
@@ -479,7 +490,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
_reset_asset_pending(asset_id)
|
||||
if proc.returncode != _CHILD_OK:
|
||||
status, _ = _job_liveness(job_id)
|
||||
if status == "running":
|
||||
if status == JobStatus.RUNNING:
|
||||
_cleanup_staging(asset_id)
|
||||
_fail_running(
|
||||
job_id,
|
||||
@@ -560,15 +571,12 @@ def _process_job(job_id: int) -> None:
|
||||
asset_id = asset.id if asset else None
|
||||
asset_status = asset.status if asset else None
|
||||
asset_error = asset.error if asset else None
|
||||
if asset_status == "error":
|
||||
if asset is not None and asset_status == "error":
|
||||
# A job that meets an already-failed asset gets that failure's explanation — not a bare
|
||||
# "Download failed". Only the text is on the asset, so it is diagnosed again, for this
|
||||
# link, with whether a cookie goes with it asked of the same gate the download child
|
||||
# used (`credentials.usable_cookie`) — as it stands now, so the advice fits this job's Retry.
|
||||
url = service.source_url(job.source_kind, job.source_ref)
|
||||
cookie = credentials.usable_cookie(
|
||||
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
|
||||
)
|
||||
url, cookie = _failed_link_and_cookie(db, job, asset)
|
||||
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
||||
del cookie # only whether there is one matters here
|
||||
|
||||
@@ -605,13 +613,34 @@ def _process_job(job_id: int) -> None:
|
||||
_run_supervised(job_id, asset_id)
|
||||
|
||||
|
||||
def _failed_link_and_cookie(
|
||||
db: Session, job: DownloadJob, asset: MediaAsset
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""The link a job's failed asset was fetched from, and the cookie that fetch would go with now.
|
||||
A clip's link is its source's (a `clip` ref names the user's range, no page), asked of the
|
||||
clip's own cookie gate, as its fetch was (`_clip_source`); a Plex clip fetches nothing."""
|
||||
if job.job_kind == JobKind.CLIP:
|
||||
spec = dict(job.edit_spec or {})
|
||||
if spec.get("source") != "youtube":
|
||||
return None, None
|
||||
url = service.source_url("youtube", spec["ref"])
|
||||
return url, credentials.usable_clip_cookie(db, job.user_id, url, asset, spec)
|
||||
url = service.source_url(job.source_kind, job.source_ref)
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
return url, credentials.usable_cookie(db, job.user_id, url, asset, spec)
|
||||
|
||||
|
||||
def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if job and asset:
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||
if not job.display_name and asset.title:
|
||||
# A clip queued under the placeholder takes the title its sibling's fetch found.
|
||||
unnamed = not job.display_name or (
|
||||
job.job_kind == JobKind.CLIP and job.display_name == service.clip_title(None, None)
|
||||
)
|
||||
if unnamed and asset.title:
|
||||
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
||||
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
@@ -682,12 +711,18 @@ def main() -> None:
|
||||
# `log_events`, so on the deploy that first adds the table the worker may start before the API
|
||||
# has migrated — those few seconds of batches fail and say so on stderr; nothing else breaks.
|
||||
logsink.start()
|
||||
problem = storage.files_root_problem(settings.files_root)
|
||||
if problem:
|
||||
# Refuse, loudly: downloading into a root that is not the volume loses every file with the
|
||||
# container. The queue waits until the mount is fixed (see docs/self-hosting.md).
|
||||
log.critical("The files library cannot be written: %s. The worker will not start.", problem)
|
||||
raise SystemExit(1)
|
||||
_recover_orphans()
|
||||
global _target
|
||||
_target = max(1, settings.download_worker_concurrency) # used only if the DB can't answer
|
||||
threads: dict[int, threading.Thread] = {}
|
||||
level = _sync_knobs(threads, None)
|
||||
log.info("Download worker started (concurrency=%d, root=%s).", _target, settings.download_root)
|
||||
log.info("Download worker started (concurrency=%d, root=%s).", _target, settings.files_root)
|
||||
while not _stop.wait(_KNOB_SECONDS):
|
||||
level = _sync_knobs(threads, level)
|
||||
log.info("Download worker stopping…")
|
||||
|
||||
@@ -16,6 +16,9 @@ services:
|
||||
POSTGRES_DB: siftlode_test
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run
|
||||
# One database per pytest-xdist worker, each through the app's own pool (5 + 10 overflow): the default 100
|
||||
# connections would run out at 8 workers.
|
||||
command: ["postgres", "-c", "max_connections=300"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U siftlode -d siftlode_test"]
|
||||
interval: 1s
|
||||
@@ -37,4 +40,4 @@ services:
|
||||
- ./:/app
|
||||
working_dir: /app
|
||||
# Coverage is on demand, not in the gate: see docs/development.md.
|
||||
command: ["python", "-m", "pytest"]
|
||||
command: ["python", "-m", "pytest", "-n", "auto"]
|
||||
|
||||
+6
-1
@@ -7,4 +7,9 @@
|
||||
# not an unknown marker that quietly does nothing.
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
addopts = -q -rs --strict-markers
|
||||
# `-p tests.db_per_worker`: under pytest-xdist (`-n`) every worker gets its own database — DATABASE_URL + `_gw<N>`,
|
||||
# created on first use — because the `db` fixture's TRUNCATE-per-test isolation assumes one process per database.
|
||||
# It is a plugin so it runs before conftest imports the app (whose settings read DATABASE_URL once). Serial: no-op.
|
||||
# The file is a verbatim copy of a shared tool (see its docstring); change the source, not the copy.
|
||||
addopts = -q -rs --strict-markers -p tests.db_per_worker
|
||||
db_per_worker_env = DATABASE_URL
|
||||
|
||||
@@ -16,3 +16,6 @@ pytest-cov==7.1.0
|
||||
ruff==0.15.21
|
||||
httpx2==2.13.0
|
||||
pip-audit==2.10.1
|
||||
# pytest-xdist: the DB lane runs on every core (`-n auto` in docker-compose.test.yml); tests/db_per_worker.py gives
|
||||
# each worker its own database. Same tests, same strictness — only side by side.
|
||||
pytest-xdist==3.8.0
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
|
||||
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
|
||||
# The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh)
|
||||
# scans it, blocking. A security fix may be ONE pin bumped in place instead of a full
|
||||
# regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must
|
||||
# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177).
|
||||
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
||||
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403).
|
||||
APScheduler==3.11.3
|
||||
Authlib==1.7.2
|
||||
Mako==1.4.1
|
||||
@@ -62,4 +63,4 @@ uvloop==0.22.1
|
||||
watchfiles==1.2.0
|
||||
websockets==17.0.1
|
||||
yt-dlp-ejs==0.8.0
|
||||
yt-dlp==2026.7.4
|
||||
yt-dlp==2026.8.19
|
||||
|
||||
@@ -16,12 +16,14 @@ argon2-cffi>=23.1,<26
|
||||
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
|
||||
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
|
||||
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
|
||||
# keep the floor loose and bump it when extraction breaks.
|
||||
yt-dlp[default]>=2025.5.22
|
||||
# keep the floor loose and bump it when extraction breaks. 2026.8.19 marks the clients that need a
|
||||
# stream PO token, which the shipped player clients (config.download_player_clients) rely on.
|
||||
yt-dlp[default]>=2026.8.19
|
||||
# Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some
|
||||
# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome
|
||||
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in
|
||||
# 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping
|
||||
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imported curl_cffi only in
|
||||
# 0.10.x–0.15.x and silently reported every target "unavailable" outside it (2026.08.19 takes up to
|
||||
# 0.16.x; the cap stays on the measured 0.15 until a bump re-measures it). Move it when bumping
|
||||
# yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome`
|
||||
# fails the gate if the locked pair can no longer impersonate.
|
||||
curl_cffi>=0.10,<0.16
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user