Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+5
-5
@@ -73,13 +73,13 @@ SMTP_FROM=
|
|||||||
# of them and false on the rest, to avoid double quota use and write races.
|
# of them and false on the rest, to avoid double quota use and write races.
|
||||||
SCHEDULER_ENABLED=true
|
SCHEDULER_ENABLED=true
|
||||||
|
|
||||||
# --- Download center ---
|
# --- Files library & downloads ---
|
||||||
# The Download Center adds a `worker` container (runs the yt-dlp/ffmpeg job loop) and a small
|
# Downloading adds a `worker` container (runs the yt-dlp/ffmpeg job loop) and a small
|
||||||
# `bgutil-pot` sidecar (mints YouTube tokens) — both come up automatically with docker compose.
|
# `bgutil-pot` sidecar (mints YouTube tokens) — both come up automatically with docker compose.
|
||||||
# Downloaded media defaults to a Docker-managed named volume. Set DOWNLOAD_HOST_PATH to a host
|
# The Files library defaults to a Docker-managed named volume. Set FILES_HOST_PATH to a host
|
||||||
# directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there.
|
# directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there.
|
||||||
# The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 <dir>.
|
# The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 <dir>.
|
||||||
# DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
# FILES_HOST_PATH=/mnt/media/youtube
|
||||||
|
|
||||||
# --- Plex integration (optional) ---
|
# --- Plex integration (optional) ---
|
||||||
# The optional Plex module browses/plays your Plex library, playing the LOCAL physical file
|
# The optional Plex module browses/plays your Plex library, playing the LOCAL physical file
|
||||||
@@ -89,6 +89,6 @@ SCHEDULER_ENABLED=true
|
|||||||
# to /plex-media inside the container (the local side of the admin "path map", whose default is
|
# to /plex-media inside the container (the local side of the admin "path map", whose default is
|
||||||
# /data=/plex-media). Leave unset if you don't use Plex.
|
# /data=/plex-media). Leave unset if you don't use Plex.
|
||||||
# PLEX_MEDIA_HOST_PATH=/mnt/media
|
# PLEX_MEDIA_HOST_PATH=/mnt/media
|
||||||
# Optional: scratch dir for on-the-fly HLS remux segments. Defaults to DOWNLOAD_ROOT; point it at
|
# Optional: scratch dir for on-the-fly HLS remux segments. Defaults to FILES_ROOT; point it at
|
||||||
# fast local storage if your download volume is slow.
|
# fast local storage if your download volume is slow.
|
||||||
# PLEX_HLS_DIR=/var/tmp/plex-hls
|
# PLEX_HLS_DIR=/var/tmp/plex-hls
|
||||||
|
|||||||
+2
-2
@@ -29,9 +29,9 @@ frontend/e2e/.auth/
|
|||||||
*.sql.gz
|
*.sql.gz
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
# Download center: locally downloaded media (the DOWNLOAD_ROOT bind mount at the repo root).
|
# Download center: locally downloaded media (the FILES_ROOT bind mount at the repo root).
|
||||||
# Anchored with a leading slash so it does NOT also ignore the backend/app/downloads package.
|
# Anchored with a leading slash so it does NOT also ignore the backend/app/downloads package.
|
||||||
/downloads/
|
/files/
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -7,6 +7,39 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.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
|
## 0.103.0 — 2026-10-08
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ be developed that way. This file is the rules of the road: read it before you to
|
|||||||
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
||||||
worse than no rule — if you change how the code works, change this file in the same commit.
|
worse than no rule — if you change how the code works, change this file in the same commit.
|
||||||
|
|
||||||
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a files library with downloads). It is a
|
||||||
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
||||||
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
||||||
router-free navigation provider.
|
router-free navigation provider.
|
||||||
|
|||||||
+3
-3
@@ -39,13 +39,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
|
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
|
||||||
# `chown -R /app` would rewrite every file into a duplicate layer). /downloads must be appuser-owned
|
# `chown -R /app` would rewrite every file into a duplicate layer). /files must be appuser-owned
|
||||||
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
|
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
|
||||||
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
|
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
|
||||||
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
||||||
RUN adduser --disabled-password --gecos "" appuser \
|
RUN adduser --disabled-password --gecos "" appuser \
|
||||||
&& mkdir -p /downloads \
|
&& mkdir -p /files \
|
||||||
&& chown appuser /app /downloads
|
&& chown appuser /app /files
|
||||||
|
|
||||||
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode.
|
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode.
|
||||||
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ blocker and SponsorBlock keep working.
|
|||||||
tags to slice the feed by.
|
tags to slice the feed by.
|
||||||
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
||||||
- **In-app player** with resume, plus keyboard/scroll controls.
|
- **In-app player** with resume, plus keyboard/scroll controls.
|
||||||
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
|
- **Files** — one library for everything you keep: paste a link anywhere in the app to download it
|
||||||
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
|
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
|
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
|
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
|
||||||
movies and shows alongside your feed, with watch state kept in sync.
|
movies and shows alongside your feed, with watch state kept in sync.
|
||||||
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
|
||||||
|
|||||||
@@ -437,14 +437,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 12,
|
|
||||||
"endColumn": 13,
|
|
||||||
"lineCount": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportAttributeAccessIssue",
|
"code": "reportAttributeAccessIssue",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -735,30 +727,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/downloads.py": [
|
"./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",
|
"code": "reportOptionalMemberAccess",
|
||||||
"range": {
|
"range": {
|
||||||
@@ -798,22 +766,6 @@
|
|||||||
"endColumn": 54,
|
"endColumn": 54,
|
||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 29,
|
|
||||||
"endColumn": 33,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 40,
|
|
||||||
"endColumn": 50,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/playlists.py": [
|
"./app/routes/playlists.py": [
|
||||||
@@ -1134,22 +1086,6 @@
|
|||||||
"endColumn": 48,
|
"endColumn": 48,
|
||||||
"lineCount": 1
|
"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": [
|
"./app/routes/plex/playback.py": [
|
||||||
@@ -1220,16 +1156,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./app/routes/public.py": [
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 57,
|
|
||||||
"endColumn": 71,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/routes/scheduler.py": [
|
"./app/routes/scheduler.py": [
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
@@ -1455,14 +1381,6 @@
|
|||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 38,
|
|
||||||
"endColumn": 43,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "reportArgumentType",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"range": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Create Date: 2026-07-07
|
|||||||
|
|
||||||
Some sources (a direct media URL like a reddit v.redd.it HLS manifest) carry no thumbnail, so the
|
Some sources (a direct media URL like a reddit v.redd.it HLS manifest) carry no thumbnail, so the
|
||||||
download shows a blank image box. The worker now extracts a representative frame with ffmpeg and
|
download shows a blank image box. The worker now extracts a representative frame with ffmpeg and
|
||||||
saves it as a poster sidecar; `poster_path` (relative to download_root) records it so the card /
|
saves it as a poster sidecar; `poster_path` (relative to files_root) records it so the card /
|
||||||
watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
|
watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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")
|
||||||
@@ -58,6 +58,17 @@ class AuditAction:
|
|||||||
# A user sent a debug session's issue to the tracker — the one act that takes their log
|
# A user sent a debug session's issue to the tracker — the one act that takes their log
|
||||||
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
|
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
|
||||||
ISSUE_CREATE = "issue_create"
|
ISSUE_CREATE = "issue_create"
|
||||||
|
# The Files library (`routes.files`): what a user put in, took out, kept or reorganised. Ids
|
||||||
|
# only — a file's or folder's name is the user's own text, not the admin's to read here.
|
||||||
|
FILE_UPLOAD = "file_upload"
|
||||||
|
FILE_DELETE = "file_delete"
|
||||||
|
FILE_KEEP = "file_keep"
|
||||||
|
FILE_UNKEEP = "file_unkeep"
|
||||||
|
FILE_MOVE = "file_move"
|
||||||
|
FILE_FOLDER_CREATE = "file_folder_create"
|
||||||
|
FILE_FOLDER_RENAME = "file_folder_rename"
|
||||||
|
FILE_FOLDER_RETENTION = "file_folder_retention"
|
||||||
|
FILE_FOLDER_DELETE = "file_folder_delete"
|
||||||
|
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
|||||||
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
|
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
|
||||||
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
|
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
|
||||||
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
|
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
|
||||||
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
|
# sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports.
|
||||||
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
|
("POST", "/api/files/upload"): settings.download_upload_max_bytes,
|
||||||
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-11
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, get_args, get_origin
|
from typing import Literal, get_args, get_origin
|
||||||
|
|
||||||
from pydantic import Field, ValidationInfo, field_validator, model_validator
|
from pydantic import AliasChoices, Field, ValidationInfo, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Shipped placeholder; production must override it. Used by the startup guard below.
|
# Shipped placeholder; production must override it. Used by the startup guard below.
|
||||||
@@ -171,9 +171,13 @@ class Settings(BaseSettings):
|
|||||||
feed_default_hidden_live: str = "live,upcoming"
|
feed_default_hidden_live: str = "live,upcoming"
|
||||||
|
|
||||||
# --- Download center (yt-dlp) ---
|
# --- Download center (yt-dlp) ---
|
||||||
# Root directory (inside the container) for downloaded media — a Docker volume/bind mount.
|
# Root directory (inside the container) for the files library — a Docker volume/bind mount.
|
||||||
# Env-only (a mount path can't live in the DB). The worker lays a Plex-style tree under it.
|
# Env-only (a mount path can't live in the DB). The worker lays a Plex-style tree under it.
|
||||||
download_root: str = "/downloads"
|
# DOWNLOAD_ROOT is its old name: an install still on the old compose file (mounted at
|
||||||
|
# /downloads, DOWNLOAD_ROOT=/downloads) keeps finding its files after it pulls a new image.
|
||||||
|
files_root: str = Field(
|
||||||
|
default="/files", validation_alias=AliasChoices("FILES_ROOT", "DOWNLOAD_ROOT", "files_root")
|
||||||
|
)
|
||||||
# Whether THIS process runs the download worker loop. On for the dedicated worker
|
# Whether THIS process runs the download worker loop. On for the dedicated worker
|
||||||
# container, off for the API (which keeps the scheduler). Mirrors scheduler_enabled.
|
# container, off for the API (which keeps the scheduler). Mirrors scheduler_enabled.
|
||||||
worker_enabled: bool = False
|
worker_enabled: bool = False
|
||||||
@@ -181,15 +185,18 @@ class Settings(BaseSettings):
|
|||||||
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
||||||
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
|
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
|
||||||
download_pot_base_url: str = "http://bgutil-pot:4416"
|
download_pot_base_url: str = "http://bgutil-pot:4416"
|
||||||
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
|
# yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one
|
||||||
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
|
# client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil
|
||||||
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
|
# sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died
|
||||||
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
|
# with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback
|
||||||
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
|
# below served 360p only, so every download came out at 360p without failing. On a
|
||||||
|
# bot/DRM/format failure the worker retries with the FALLBACK set — the POT-capable web clients
|
||||||
|
# (web needs the token + Deno) plus android, whose 360p is the last resort. Both admin-tunable
|
||||||
|
# (env) when YouTube shifts what works.
|
||||||
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
|
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
|
||||||
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
|
||||||
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
|
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
|
||||||
download_player_clients: str = "android_vr"
|
download_player_clients: str = "tv_simply"
|
||||||
download_player_clients_fallback: str = "web_safari,web,android"
|
download_player_clients_fallback: str = "web_safari,web,android"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -342,8 +349,8 @@ class Settings(BaseSettings):
|
|||||||
plex_transcode_crf: int = 20
|
plex_transcode_crf: int = 20
|
||||||
plex_transcode_max_height: int = 1080
|
plex_transcode_max_height: int = 1080
|
||||||
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
||||||
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
|
# dir under files_root. On localdev the files_root is a slow Windows bind-mount, so point
|
||||||
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's files_root is fast
|
||||||
# local disk, so the default is fine there.
|
# local disk, so the default is fine there.
|
||||||
plex_hls_dir: str = ""
|
plex_hls_dir: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.discover import cards, queries
|
from app.discover import cards, queries
|
||||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import leaf_card, show_card
|
from app.plex.cards import SHOW_NEW, leaf_card, show_card
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||||
@@ -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)
|
sh = db.get(PlexShow, pin.plex_show_id)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
return None
|
return None
|
||||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
|
||||||
card = cards.from_plex(show_card(sh, status, lang=lang))
|
card = cards.from_plex(show_card(sh, state, lang=lang))
|
||||||
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
||||||
nxt = cards.from_plex(ep) if ep else None
|
nxt = cards.from_plex(ep) if ep else None
|
||||||
|
|
||||||
|
|||||||
@@ -256,16 +256,20 @@ def plex_in_progress(
|
|||||||
if show_libs:
|
if show_libs:
|
||||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||||
sq = apply_show_state_filter(
|
sq = apply_show_state_filter(
|
||||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||||
|
prog, prog.c.show_id == PlexShow.id
|
||||||
|
),
|
||||||
prog.c.total,
|
prog.c.total,
|
||||||
prog.c.watched,
|
prog.c.watched,
|
||||||
prog.c.inprog,
|
prog.c.inprog,
|
||||||
"in_progress",
|
"in_progress",
|
||||||
).where(PlexShow.library_id.in_(show_libs))
|
).where(PlexShow.library_id.in_(show_libs))
|
||||||
for sh, resumed_at in db.execute(
|
for sh, resumed_at, total, watched, inprog in db.execute(
|
||||||
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
||||||
).all():
|
).all():
|
||||||
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
out.append(
|
||||||
|
(resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang))
|
||||||
|
)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter):
|
|||||||
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
|
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
|
||||||
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
|
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
|
||||||
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
|
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
|
||||||
# rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every
|
# rather than a hard failure. Our primary set is tv_simply ALONE, which cannot carry cookies either
|
||||||
|
# (measured on yt-dlp 2026.08.19: `SUPPORTS_COOKIES` False), so without this filter every
|
||||||
# signed-in download would burn its first attempt that way before reaching the POT-backed web
|
# signed-in download would burn its first attempt that way before reaching the POT-backed web
|
||||||
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
|
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
|
||||||
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
|
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def run() -> dict:
|
|||||||
if a.poster_path is None:
|
if a.poster_path is None:
|
||||||
# Record a self-hosted poster for every download (the reliable og:image source):
|
# Record a self-hosted poster for every download (the reliable og:image source):
|
||||||
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
||||||
rel = editmod.ensure_poster(a, settings.download_root)
|
rel = editmod.ensure_poster(a, settings.files_root)
|
||||||
if rel:
|
if rel:
|
||||||
a.poster_path = rel
|
a.poster_path = rel
|
||||||
filled_poster += 1
|
filled_poster += 1
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Ad-hoc clips: a marked range of a YouTube video or a Plex item, cut into a new per-user asset.
|
||||||
|
|
||||||
|
A clip never needs the whole source first. A YouTube clip fetches only its range (yt-dlp's
|
||||||
|
`download_ranges`; measured: 3-7 s whether the video runs four minutes or an hour, and the fetched
|
||||||
|
range starts on the marked frame), and a Plex clip reads the library file in place. Either way the
|
||||||
|
range then goes through ONE re-encode built here, so every clip has the same shape whatever its
|
||||||
|
source: frame-exact at both ends, H.264 at most 1080p (an HDR source tonemapped to SDR — kept as
|
||||||
|
10-bit PQ it played washed out), stereo AAC, in an .mp4 that plays on any phone.
|
||||||
|
|
||||||
|
A clip may also crop the picture to a frame the user drew over the playing video. The frame is
|
||||||
|
given in fractions of the box it was drawn in, with that box's aspect: the YouTube iframe never
|
||||||
|
tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16),
|
||||||
|
and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped.
|
||||||
|
|
||||||
|
The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its
|
||||||
|
signature is the asset's cache key, so marking the same range again is an instant cache hit, and a
|
||||||
|
different range or audio track is a different clip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||||
|
from app.plex.sync import HDR_TRANSFERS
|
||||||
|
|
||||||
|
SOURCES = ("youtube", "plex")
|
||||||
|
# Owner's cap: a clip is a moment, not a way to export a whole film to a public link.
|
||||||
|
MAX_CLIP_S = 600
|
||||||
|
MIN_CLIP_S = 1
|
||||||
|
MAX_HEIGHT = 1080
|
||||||
|
# What yt-dlp fetches the range in (formats.normalize fills the rest): the clip's own ceiling, so the
|
||||||
|
# re-encode never upscales and never throws away a better source than it keeps.
|
||||||
|
YOUTUBE_FETCH_SPEC = {"mode": "av", "max_height": MAX_HEIGHT, "container": "mp4"}
|
||||||
|
# Bump when the spec → output-bytes mapping changes, so a cached clip re-derives.
|
||||||
|
_SIG_VERSION = 1
|
||||||
|
|
||||||
|
_YOUTUBE_ID = re.compile(r"[A-Za-z0-9_-]{11}")
|
||||||
|
_RATING_KEY = re.compile(r"[0-9]{1,32}")
|
||||||
|
_MAX_AUDIO_TRACK = 63
|
||||||
|
# HDR → SDR as measured on the owner's 4K HDR files (2026-10-10): linearise, tonemap (hable),
|
||||||
|
# convert the primaries and transfer to BT.709 and back to 8-bit 4:2:0.
|
||||||
|
_TONEMAP = (
|
||||||
|
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,"
|
||||||
|
"zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||||
|
)
|
||||||
|
_SCALE = f"scale=-2:'min({MAX_HEIGHT},ih)'"
|
||||||
|
# A crop frame is at least this share of its box on each side (the player's frame holds the same
|
||||||
|
# floor), and what is left of it on the real picture at least _MIN_VISIBLE of it.
|
||||||
|
MIN_CROP = 0.1
|
||||||
|
_MIN_VISIBLE = 0.05
|
||||||
|
# The box aspects a frame may be drawn in: a 9:16 short to a 21:9 film, with room either side.
|
||||||
|
_CROP_ASPECTS = (0.25, 4.0)
|
||||||
|
# Float slack on the frame's far edges (x + w, y + h), which the client rounds to 4 decimals.
|
||||||
|
_EDGE_SLACK = 1e-3
|
||||||
|
|
||||||
|
|
||||||
|
class ClipError(Exception):
|
||||||
|
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
||||||
|
message names (the cap itself, so the client never says a stale one)."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str, **params: float):
|
||||||
|
self.reason = reason
|
||||||
|
self.params = params
|
||||||
|
super().__init__(reason)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(
|
||||||
|
source: str,
|
||||||
|
ref: str,
|
||||||
|
start_s: float,
|
||||||
|
end_s: float,
|
||||||
|
audio_track: int | None = None,
|
||||||
|
crop: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
|
||||||
|
decimals, so the same marks always give the same cache key. Raises ClipError."""
|
||||||
|
if source not in SOURCES:
|
||||||
|
raise ClipError("bad_source")
|
||||||
|
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
|
||||||
|
if not isinstance(ref, str) or not pattern.fullmatch(ref):
|
||||||
|
raise ClipError("bad_source")
|
||||||
|
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
|
||||||
|
raise ClipError("bad_range")
|
||||||
|
start, end = round(start_s, 3), round(end_s, 3)
|
||||||
|
if end - start < MIN_CLIP_S:
|
||||||
|
raise ClipError("too_short")
|
||||||
|
if end - start > MAX_CLIP_S:
|
||||||
|
raise ClipError("too_long", limit_s=MAX_CLIP_S)
|
||||||
|
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
|
||||||
|
if source == "plex" and audio_track is not None:
|
||||||
|
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
|
||||||
|
raise ClipError("bad_audio")
|
||||||
|
spec["audio_track"] = audio_track
|
||||||
|
frame = _crop(crop)
|
||||||
|
if frame is not None:
|
||||||
|
spec["crop"] = frame
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _crop(raw: dict | None) -> dict | None:
|
||||||
|
"""A crop frame, canonical — or None for no crop, which a frame over the whole box also is (the
|
||||||
|
same clip as one cut without a frame, so the same cache key)."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
x, y, w, h, aspect = (float(raw[k]) for k in ("x", "y", "w", "h", "aspect"))
|
||||||
|
except (KeyError, TypeError, ValueError) as e:
|
||||||
|
raise ClipError("bad_crop") from e
|
||||||
|
if not all(math.isfinite(v) for v in (x, y, w, h, aspect)):
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
lo, hi = _CROP_ASPECTS
|
||||||
|
if not lo <= aspect <= hi or x < 0 or y < 0:
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
if w < MIN_CROP - _EDGE_SLACK or h < MIN_CROP - _EDGE_SLACK:
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
if x + w > 1 + _EDGE_SLACK or y + h > 1 + _EDGE_SLACK:
|
||||||
|
raise ClipError("bad_crop")
|
||||||
|
frame = {"x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y), "aspect": aspect}
|
||||||
|
frame = {k: round(v, 4) for k, v in frame.items()}
|
||||||
|
if (frame["x"], frame["y"], frame["w"], frame["h"]) == (0, 0, 1, 1):
|
||||||
|
return None
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def duration(spec: dict) -> float:
|
||||||
|
return spec["end_s"] - spec["start_s"]
|
||||||
|
|
||||||
|
|
||||||
|
def sig(spec: dict) -> str:
|
||||||
|
"""The clip's cache signature: the spec itself, versioned."""
|
||||||
|
body = json.dumps({**spec, "_v": _SIG_VERSION}, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(body.encode()).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def asset_ref(user_id: int, spec: dict) -> str:
|
||||||
|
"""The asset's `source_ref`. The user id makes a clip private to whoever cut it, like an edit:
|
||||||
|
never dedup-shared, never cache-served to someone else."""
|
||||||
|
return f"{user_id}:{spec['source']}:{spec['ref']}"
|
||||||
|
|
||||||
|
|
||||||
|
def reference_url(spec: dict) -> str | None:
|
||||||
|
"""Where a YouTube clip came from: its video at the clip's start. A Plex clip has no public page."""
|
||||||
|
if spec.get("source") != "youtube" or not spec.get("ref"):
|
||||||
|
return None
|
||||||
|
return f"https://www.youtube.com/watch?v={spec['ref']}&t={int(spec.get('start_s') or 0)}s"
|
||||||
|
|
||||||
|
|
||||||
|
def is_hdr(probe: dict) -> bool:
|
||||||
|
"""Whether the first video stream of an ffprobe `-show_streams` JSON carries an HDR transfer."""
|
||||||
|
for s in probe.get("streams") or []:
|
||||||
|
if s.get("codec_type") == "video":
|
||||||
|
# PQ / HLG: an 8-bit SDR encode must tonemap them (the one set the Plex stream uses).
|
||||||
|
return s.get("color_transfer") in HDR_TRANSFERS
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def display_aspect(probe: dict) -> float | None:
|
||||||
|
"""The picture's shape as a player shows it, from an ffprobe `-show_streams` JSON: the first video
|
||||||
|
stream's width × its sample aspect ratio over its height, turned a quarter when the stream says
|
||||||
|
it is rotated (ffmpeg turns such a stream upright before any filter of ours, so a crop is cut
|
||||||
|
from the upright picture). None when the stream has no size."""
|
||||||
|
for s in probe.get("streams") or []:
|
||||||
|
if s.get("codec_type") != "video":
|
||||||
|
continue
|
||||||
|
w, h = s.get("width"), s.get("height")
|
||||||
|
if not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0:
|
||||||
|
return None
|
||||||
|
dar = w * (_ratio(s.get("sample_aspect_ratio")) or 1.0) / h
|
||||||
|
return 1 / dar if _quarter_turned(s) else dar
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio(v: object) -> float | None:
|
||||||
|
"""'32:27' → 1.185…; '0:1', 'N/A', a missing or malformed value → None."""
|
||||||
|
if not isinstance(v, str) or ":" not in v:
|
||||||
|
return None
|
||||||
|
num, _, den = v.partition(":")
|
||||||
|
try:
|
||||||
|
n, d = float(num), float(den)
|
||||||
|
except ValueError: # silent-ok: a malformed ratio reads as none given; the caller takes 1:1
|
||||||
|
return None
|
||||||
|
return n / d if n > 0 and d > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _quarter_turned(stream: dict) -> bool:
|
||||||
|
rotation: object = (stream.get("tags") or {}).get("rotate")
|
||||||
|
for sd in stream.get("side_data_list") or []:
|
||||||
|
if "rotation" in sd:
|
||||||
|
rotation = sd["rotation"]
|
||||||
|
try:
|
||||||
|
return round(float(str(rotation))) % 180 == 90
|
||||||
|
except ValueError: # silent-ok: no (None) or a malformed rotation: the stream is upright
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def crop_filter(crop: dict, dar: float) -> str | None:
|
||||||
|
"""The ffmpeg `crop` for a frame drawn in a box of `crop["aspect"]`, over a picture of display
|
||||||
|
aspect `dar` fitted into that box as a player fits it (whole, centred, bars on the short sides).
|
||||||
|
The frame's part over a bar is dropped: "" when what is left is the whole picture, None when too
|
||||||
|
little of the frame lies on the picture at all.
|
||||||
|
|
||||||
|
In fractions of the input's own width and height, so a sample aspect ratio other than 1:1 (an
|
||||||
|
anamorphic DVD) needs nothing of its own, and floored to even pixels: an even 4:2:0 frame, and a
|
||||||
|
window that never runs past the picture's far edge."""
|
||||||
|
box = crop["aspect"]
|
||||||
|
pic_w, pic_h = (1.0, box / dar) if dar > box else (dar / box, 1.0)
|
||||||
|
|
||||||
|
def span(start: float, size: float, extent: float) -> tuple[float, float]:
|
||||||
|
origin = (1 - extent) / 2
|
||||||
|
a = min(1.0, max(0.0, (start - origin) / extent))
|
||||||
|
b = min(1.0, max(0.0, (start + size - origin) / extent))
|
||||||
|
return a, b - a
|
||||||
|
|
||||||
|
x, w = span(crop["x"], crop["w"], pic_w)
|
||||||
|
y, h = span(crop["y"], crop["h"], pic_h)
|
||||||
|
if w < _MIN_VISIBLE or h < _MIN_VISIBLE:
|
||||||
|
return None
|
||||||
|
if w > 1 - 1e-4 and h > 1 - 1e-4:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def px(axis: str, f: float) -> str:
|
||||||
|
return f"trunc({axis}*{f:.6f}/2)*2"
|
||||||
|
|
||||||
|
return f"crop={px('iw', w)}:{px('ih', h)}:{px('iw', x)}:{px('ih', y)}"
|
||||||
|
|
||||||
|
|
||||||
|
def audio_track_count(probe: dict) -> int:
|
||||||
|
return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio")
|
||||||
|
|
||||||
|
|
||||||
|
def build_cmd(
|
||||||
|
src: Path,
|
||||||
|
dest: Path,
|
||||||
|
start_s: float,
|
||||||
|
dur_s: float,
|
||||||
|
*,
|
||||||
|
audio_track: int | None,
|
||||||
|
hdr: bool,
|
||||||
|
threads: int,
|
||||||
|
crop: str = "",
|
||||||
|
) -> list[str]:
|
||||||
|
"""ffmpeg for one clip: an input seek (decodes from the keyframe before `start_s` and drops the
|
||||||
|
frames up to it, so the clip starts on the marked frame), the first video stream, the chosen audio
|
||||||
|
track (the first when none was chosen; none at all is fine), no subtitles, data or chapters.
|
||||||
|
A `crop` (crop_filter) comes first: the tonemap and the scale then work on the frame it keeps."""
|
||||||
|
vf = ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME])
|
||||||
|
audio = f"0:a:{audio_track}" if audio_track is not None else "0:a:0?"
|
||||||
|
return [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-progress",
|
||||||
|
"pipe:1",
|
||||||
|
"-nostats",
|
||||||
|
"-ss",
|
||||||
|
f"{start_s:.3f}",
|
||||||
|
*INPUT_GUARD,
|
||||||
|
"-i",
|
||||||
|
str(src),
|
||||||
|
"-t",
|
||||||
|
f"{dur_s:.3f}",
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
audio,
|
||||||
|
"-sn",
|
||||||
|
"-dn",
|
||||||
|
# A source's chapters would ride into the .mp4 as a data track of their own (an mkv's
|
||||||
|
# chapter list came out as `bin_data`); a clip of a moment has no chapters to keep.
|
||||||
|
"-map_chapters",
|
||||||
|
"-1",
|
||||||
|
"-vf",
|
||||||
|
vf,
|
||||||
|
*X264_ARGS,
|
||||||
|
"-threads",
|
||||||
|
str(threads),
|
||||||
|
*AAC_ARGS,
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
str(dest),
|
||||||
|
]
|
||||||
@@ -30,6 +30,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
from app.downloads.adapters import _host_is
|
from app.downloads.adapters import _host_is
|
||||||
from app.models import DownloadCredential, MediaAsset
|
from app.models import DownloadCredential, MediaAsset
|
||||||
@@ -259,6 +260,28 @@ def usable_cookie(
|
|||||||
return None
|
return None
|
||||||
if not is_authed_asset(asset, spec, user_id, url):
|
if not is_authed_asset(asset, spec, user_id, url):
|
||||||
return None # shared asset — never attach a cookie to a non-private identity
|
return None # shared asset — never attach a cookie to a non-private identity
|
||||||
|
return _readable(cred, user_id, host_key)
|
||||||
|
|
||||||
|
|
||||||
|
def usable_clip_cookie(
|
||||||
|
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict
|
||||||
|
) -> str | None:
|
||||||
|
"""`usable_cookie` for an ad-hoc clip's range fetch. The second gate is the clip's own identity:
|
||||||
|
a clip asset is private by construction (`clip.asset_ref` names its user, see downloads/clip),
|
||||||
|
so a cookie goes with it exactly when that asset is THIS user's clip of `clip_spec`. Without it a
|
||||||
|
video YouTube gates signed out could never be clipped, while its download goes through."""
|
||||||
|
host_key = host_key_for_url(url)
|
||||||
|
if host_key is None:
|
||||||
|
return None
|
||||||
|
cred = get_credential(db, user_id, host_key)
|
||||||
|
if cred is None:
|
||||||
|
return None
|
||||||
|
if asset.source_kind != "clip" or asset.source_ref != clipmod.asset_ref(user_id, clip_spec):
|
||||||
|
return None
|
||||||
|
return _readable(cred, user_id, host_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _readable(cred: DownloadCredential, user_id: int, host_key: str) -> str | None:
|
||||||
raw = decrypt_optional(cred.cookies_enc)
|
raw = decrypt_optional(cred.cookies_enc)
|
||||||
if not raw:
|
if not raw:
|
||||||
log.warning(
|
log.warning(
|
||||||
@@ -284,6 +307,14 @@ def resolve_cookiefile(
|
|||||||
return _write_cookiefile(raw, staging) if raw else None
|
return _write_cookiefile(raw, staging) if raw else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_clip_cookiefile(
|
||||||
|
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict, staging: Path
|
||||||
|
) -> Path | None:
|
||||||
|
"""`resolve_cookiefile` for an ad-hoc clip (`usable_clip_cookie`)."""
|
||||||
|
raw = usable_clip_cookie(db, user_id, url, asset, clip_spec)
|
||||||
|
return _write_cookiefile(raw, staging) if raw else None
|
||||||
|
|
||||||
|
|
||||||
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
def _write_cookiefile(raw: str, staging: Path) -> Path:
|
||||||
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
return dest if dest.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
def ensure_poster(asset, files_root: str, timeout_s: int = 30) -> str | None:
|
||||||
"""Generate a poster frame for a ready asset that has no thumbnail (e.g. a direct media URL
|
"""Generate a poster frame for a ready asset that has no thumbnail (e.g. a direct media URL
|
||||||
like a reddit HLS manifest, which yt-dlp can't get a thumbnail for). Written as the media's
|
like a reddit HLS manifest, which yt-dlp can't get a thumbnail for). Written as the media's
|
||||||
`<base>.jpg` sidecar — so Plex uses it too and `delete_asset_files` cleans it up — and returned
|
`<base>.jpg` sidecar — so Plex uses it too and `delete_asset_files` cleans it up — and returned
|
||||||
@@ -740,7 +740,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
|||||||
short clip) dodges black intro frames."""
|
short clip) dodges black intro frames."""
|
||||||
if not asset.rel_path:
|
if not asset.rel_path:
|
||||||
return None
|
return None
|
||||||
root = Path(download_root)
|
root = Path(files_root)
|
||||||
src = root / asset.rel_path
|
src = root / asset.rel_path
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
return None
|
return None
|
||||||
@@ -756,7 +756,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
|
|||||||
return rel if dest.exists() else None
|
return rel if dest.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict | None:
|
def ensure_storyboard(asset, files_root: str, timeout_s: int = 90) -> dict | None:
|
||||||
"""Return the filmstrip meta for a ready source asset, generating the sprite on first use.
|
"""Return the filmstrip meta for a ready source asset, generating the sprite on first use.
|
||||||
|
|
||||||
Best-effort + time-bounded: for a very long/high-res source the one-pass decode can be slow,
|
Best-effort + time-bounded: for a very long/high-res source the one-pass decode can be slow,
|
||||||
@@ -768,7 +768,7 @@ def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict |
|
|||||||
if not asset.rel_path:
|
if not asset.rel_path:
|
||||||
return None
|
return None
|
||||||
geom = storyboard_geometry(asset.duration_s)
|
geom = storyboard_geometry(asset.duration_s)
|
||||||
root = Path(download_root)
|
root = Path(files_root)
|
||||||
rel = f".storyboards/asset-{asset.id}.jpg"
|
rel = f".storyboards/asset-{asset.id}.jpg"
|
||||||
dest = root / rel
|
dest = root / rel
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ from app.downloads.credentials import host_key_for_url
|
|||||||
|
|
||||||
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
|
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
|
||||||
#
|
#
|
||||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
|
||||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
|
||||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
# each one, because a message and its rule drifting apart is exactly how a
|
||||||
|
# category silently becomes unreachable.
|
||||||
#
|
#
|
||||||
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
|
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
|
||||||
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
|
||||||
@@ -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
|
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
|
||||||
# that gives the site's sentence.
|
# that gives the site's sentence.
|
||||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||||
|
# A download, a cut clip, or an upload's converted file, held to the space left, which is not zero:
|
||||||
|
# "full" would be untrue. All three classify by the one phrase they share.
|
||||||
|
DOWNLOAD_TOO_BIG_MESSAGE = (
|
||||||
|
"The video is larger than the download storage you have left — free up space, then try again."
|
||||||
|
)
|
||||||
|
CLIP_TOO_BIG_MESSAGE = (
|
||||||
|
"The clip is larger than the download storage you have left — "
|
||||||
|
"free up space or cut a shorter clip, then try again."
|
||||||
|
)
|
||||||
|
CONVERTED_TOO_BIG_MESSAGE = (
|
||||||
|
"The converted file is larger than the download storage you have left — "
|
||||||
|
"free up space, then try again."
|
||||||
|
)
|
||||||
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
|
||||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||||
|
|
||||||
@@ -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
|
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||||
("download storage is full", "quota_exceeded"),
|
("download storage is full", "quota_exceeded"),
|
||||||
|
("larger than the download storage you have left", "quota_exceeded"),
|
||||||
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
|
||||||
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
|
||||||
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
|
||||||
@@ -129,6 +144,13 @@ _RULES: tuple[tuple[str, str], ...] = (
|
|||||||
("postprocessing", "postprocess"),
|
("postprocessing", "postprocess"),
|
||||||
("produced no output file", "postprocess"),
|
("produced no output file", "postprocess"),
|
||||||
("no output file", "postprocess"),
|
("no output file", "postprocess"),
|
||||||
|
# yt-dlp's own verdict that its extractor did not understand the site's answer: it appends this
|
||||||
|
# request for a bug report to an UNEXPECTED extraction failure, never to an expected one (a private,
|
||||||
|
# removed or geo-blocked video says so in words matched above). Measured 2026-10-12: a TikTok link
|
||||||
|
# failed with it on every attempt under yt-dlp 2026.07.04 and downloads under 2026.08.19 — the
|
||||||
|
# site changed and a newer yt-dlp is the cure. After the media-format and post-processing rules,
|
||||||
|
# whose texts can carry the same request; before the network ones, which are about the transport.
|
||||||
|
("please report this issue", "site_changed"),
|
||||||
# Network / transport.
|
# Network / transport.
|
||||||
("unable to download", "network"),
|
("unable to download", "network"),
|
||||||
("http error", "network"),
|
("http error", "network"),
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""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. Two 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.
|
||||||
|
|
||||||
|
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 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 and own folders 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.models import DownloadJob, FileFolder, MediaAsset
|
||||||
|
|
||||||
|
|
||||||
|
class FolderKind(StrEnum):
|
||||||
|
SOURCE = "source"
|
||||||
|
OWN = "own"
|
||||||
|
|
||||||
|
|
||||||
|
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(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 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, 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, 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)
|
||||||
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
|
|||||||
return _PATH_RE.sub("…", text)
|
return _PATH_RE.sub("…", text)
|
||||||
|
|
||||||
|
|
||||||
|
# yt-dlp's HTTP downloader, refusing a file announced larger than `max_filesize`, logs this line and
|
||||||
|
# RETURNS — no exception and no file (measured; test_formats holds it against the pinned yt-dlp).
|
||||||
|
_OVER_CAP_LINE = "File is larger than max-filesize"
|
||||||
|
_OVER_CAP: contextvars.ContextVar[list[bool] | None] = contextvars.ContextVar(
|
||||||
|
"formats_over_cap", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def watching_size_cap() -> Iterator[list[bool]]:
|
||||||
|
"""While yt-dlp works inside it: a one-slot flag, set when it refused a file over the size cap
|
||||||
|
(`build_ydl_opts(max_bytes=…)`). It then returns as if it had downloaded, and all the caller
|
||||||
|
would find is no file — a processing fault, to anyone reading it, for what is the owner's
|
||||||
|
storage. A context variable, like `hiding`: yt-dlp's logger calls run on the extraction's thread."""
|
||||||
|
flag = [False]
|
||||||
|
token = _OVER_CAP.set(flag)
|
||||||
|
try:
|
||||||
|
yield flag
|
||||||
|
finally:
|
||||||
|
_OVER_CAP.reset(token)
|
||||||
|
|
||||||
|
|
||||||
class _YdlLog:
|
class _YdlLog:
|
||||||
"""yt-dlp's logger protocol, pointed at ours.
|
"""yt-dlp's logger protocol, pointed at ours.
|
||||||
|
|
||||||
@@ -124,6 +146,8 @@ class _YdlLog:
|
|||||||
is the one line whose level should say so."""
|
is the one line whose level should say so."""
|
||||||
|
|
||||||
def _emit(self, level: int, msg: str) -> None:
|
def _emit(self, level: int, msg: str) -> None:
|
||||||
|
if _OVER_CAP_LINE in msg and (over := _OVER_CAP.get()) is not None:
|
||||||
|
over[0] = True
|
||||||
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
|
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
|
||||||
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
|
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
|
||||||
# a record that has args, this way the question never arises — and it is the shape ruff's
|
# a record that has args, this way the question never arises — and it is the shape ruff's
|
||||||
@@ -281,8 +305,11 @@ def build_ydl_opts(
|
|||||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||||
merge so a source adapter can never clobber it.
|
merge so a source adapter can never clobber it.
|
||||||
|
|
||||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
|
||||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
a file announced larger than it rather than filling the disk and only then being caught by the
|
||||||
|
quota accounting. Its ffmpeg downloader (a `download_ranges` fetch) does not read it, nor does
|
||||||
|
a fragmented download (YouTube's formats: measured, 718 MB fetched with 1 MB left) — so the
|
||||||
|
pipeline also holds every produced file to what is left by its real size."""
|
||||||
s = normalize(spec)
|
s = normalize(spec)
|
||||||
h = s["max_height"]
|
h = s["max_height"]
|
||||||
opts: dict = {
|
opts: dict = {
|
||||||
|
|||||||
+184
-85
@@ -1,12 +1,13 @@
|
|||||||
"""Download retention garbage collector (a scheduler job).
|
"""Download retention garbage collector (a scheduler job).
|
||||||
|
|
||||||
Five passes each run:
|
Five passes each run:
|
||||||
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
1. Pre-expiry warning — a done file whose `expires_at` (its folder's retention, `folders`) falls
|
||||||
once (gc_notified flag) so a download isn't silently deleted.
|
within the grace window, and that its user did not keep, warns its user once (`expiry_warned`).
|
||||||
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
2. Expiry — such a file past its `expires_at` leaves the library: the job is deleted and its hold
|
||||||
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
|
released, so the shared file goes with its last holder.
|
||||||
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed
|
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed ready
|
||||||
ready assets (never an upload) until back under it.
|
assets (never an upload, never one a user kept) until back under it; each holder's file leaves
|
||||||
|
their library, with a notice.
|
||||||
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
4. Orphan reap — delete errored, fileless, unreferenced asset rows (they carry no TTL, so
|
||||||
passes 1-3 skip them; benign row-count hygiene).
|
passes 1-3 skip them; benign row-count hygiene).
|
||||||
5. Orphan FILE sweep — delete on-disk files/dirs no surviving asset owns (cruft left when a
|
5. Orphan FILE sweep — delete on-disk files/dirs no surviving asset owns (cruft left when a
|
||||||
@@ -14,19 +15,20 @@ Five passes each run:
|
|||||||
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
|
It skips the dot-prefixed system trees (all but `.uploads`) and anything newer than an hour,
|
||||||
so an in-flight download is never touched.
|
so an in-flight download is never touched.
|
||||||
|
|
||||||
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
Runs in the API process (which mounts FILES_ROOT). Pure disk/DB work — no YouTube quota.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import quota, storage
|
from app.downloads import quota, service, storage
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
|
|
||||||
@@ -37,42 +39,118 @@ _UPLOAD_TREE = ".uploads"
|
|||||||
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
_ORPHAN_MIN_AGE_S = 3600 # only sweep files older than this, so an in-flight download is never hit
|
||||||
|
|
||||||
|
|
||||||
def _holders(db: Session, asset_id: int) -> list[int]:
|
def _notify(db: Session, job: DownloadJob, asset: MediaAsset | None, ntype: str) -> None:
|
||||||
return list(
|
title = job.display_name or (asset.title if asset else None) or "download"
|
||||||
db.execute(
|
create_notification(
|
||||||
select(DownloadJob.user_id).where(DownloadJob.asset_id == asset_id).distinct()
|
db,
|
||||||
).scalars()
|
user_id=job.user_id,
|
||||||
|
type=ntype,
|
||||||
|
title=title, # English fallback; the client renders a translated message from data
|
||||||
|
data={
|
||||||
|
"kind": ntype,
|
||||||
|
"title": title,
|
||||||
|
"video_id": asset.source_ref if asset else None,
|
||||||
|
"job_id": job.id,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_holders(db: Session, asset: MediaAsset, ntype: str) -> None:
|
def _relock_expired(
|
||||||
title = asset.title or "download"
|
db: Session, job_id: int, asset_id: int | None, now: datetime
|
||||||
for uid in _holders(db, asset.id):
|
) -> DownloadJob | None:
|
||||||
create_notification(
|
"""Re-read an expired job under the lock order the user routes take (`own_job_for_update`: the
|
||||||
db,
|
asset, then the job) and say whether it is still to go. Between the pass's SELECT and here a
|
||||||
user_id=uid,
|
user may have deleted or re-downloaded it (dropping it again would release its asset a second
|
||||||
type=ntype,
|
time, freeing a file another job still holds) or kept or moved it (it is no longer expired)."""
|
||||||
title=title, # English fallback; the client renders a translated message from data
|
if asset_id is not None:
|
||||||
data={"kind": ntype, "title": asset.title, "video_id": asset.source_ref},
|
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
commit=False,
|
job = db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
still = (
|
||||||
|
job is not None
|
||||||
|
and job.asset_id == asset_id
|
||||||
|
and job.status == JobStatus.DONE
|
||||||
|
and not job.kept
|
||||||
|
and job.expires_at is not None
|
||||||
|
and job.expires_at <= now
|
||||||
|
)
|
||||||
|
return job if still else None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_warned(db: Session, job_id: int, expires_at: datetime) -> DownloadJob | None:
|
||||||
|
"""Mark one file warned if it still goes when the pass saw it go — not deleted, kept, moved or
|
||||||
|
re-timed since (each of those sets a fresh expiry, and its own warning to come) — and return it.
|
||||||
|
The UPDATE's row lock holds it so until the commit."""
|
||||||
|
hit = db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(
|
||||||
|
DownloadJob.id == job_id,
|
||||||
|
DownloadJob.status == JobStatus.DONE,
|
||||||
|
DownloadJob.kept.is_(False),
|
||||||
|
DownloadJob.expiry_warned.is_(False),
|
||||||
|
DownloadJob.expires_at == expires_at,
|
||||||
)
|
)
|
||||||
|
.values(expiry_warned=True)
|
||||||
|
.returning(DownloadJob.id)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return db.get(DownloadJob, job_id) if hit is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
|
def _on_disk(db: Session) -> int:
|
||||||
if asset.rel_path:
|
"""The bytes the cache holds now (every asset whose file is on disk)."""
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
return (
|
||||||
_notify_holders(db, asset, ntype)
|
db.execute(
|
||||||
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sweep_orphan_files(db: Session, download_root: str) -> int:
|
def _relock_evictable(db: Session, asset_id: int) -> tuple[MediaAsset | None, list[DownloadJob]]:
|
||||||
"""Delete media/sidecar files (and now-empty dirs) under DOWNLOAD_ROOT that no `ready` asset
|
"""Re-read an eviction candidate and its holders under their locks (the asset, then its jobs —
|
||||||
|
the order the user routes take): between the pass's SELECT and here a holder may have kept it,
|
||||||
|
or every holder deleted it."""
|
||||||
|
asset = db.execute(
|
||||||
|
select(MediaAsset)
|
||||||
|
.where(MediaAsset.id == asset_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
holders = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.asset_id == asset_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return asset, list(holders)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_job(db: Session, job: DownloadJob, ntype: str) -> None:
|
||||||
|
"""Take a file out of its user's library: tell them, release the job's hold on the asset (the
|
||||||
|
file goes with the last holder), delete the row (its shares and links cascade)."""
|
||||||
|
_notify(db, job, db.get(MediaAsset, job.asset_id) if job.asset_id else None, ntype)
|
||||||
|
service.release_asset(db, job)
|
||||||
|
db.delete(job)
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep_orphan_files(db: Session, files_root: str) -> int:
|
||||||
|
"""Delete media/sidecar files (and now-empty dirs) under FILES_ROOT that no `ready` asset
|
||||||
owns — cruft left when a re-download changes an asset's on-disk name (e.g. an old accented
|
owns — cruft left when a re-download changes an asset's on-disk name (e.g. an old accented
|
||||||
channel dir, or a poster from a superseded naming scheme) — or, under `.uploads`, that no upload
|
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
|
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
|
`_ORPHAN_MIN_AGE_S`, so a just-completed or in-flight download is never deleted, and only files
|
||||||
NOT owned by a surviving asset."""
|
NOT owned by a surviving asset."""
|
||||||
root = Path(download_root)
|
root = Path(files_root)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return 0
|
return 0
|
||||||
keep: set[Path] = set()
|
keep: set[Path] = set()
|
||||||
@@ -151,74 +229,95 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
||||||
warned = expired = evicted = reaped = 0
|
warned = expired = evicted = reaped = 0
|
||||||
|
|
||||||
# 1. Pre-expiry warnings.
|
# 1. Pre-expiry warnings — one per file, the first run inside the grace window. Each mark is
|
||||||
|
# conditional on the expiry the SELECT saw and committed on its own: a file deleted since is
|
||||||
|
# passed over (a write to its gone row would fail the whole run), and one kept, moved or re-timed
|
||||||
|
# since keeps its fresh expiry unwarned.
|
||||||
warn_before = now + timedelta(days=grace_days)
|
warn_before = now + timedelta(days=grace_days)
|
||||||
soon = (
|
soon = db.execute(
|
||||||
db.execute(
|
select(DownloadJob.id, DownloadJob.expires_at).where(
|
||||||
select(MediaAsset).where(
|
DownloadJob.status == JobStatus.DONE,
|
||||||
MediaAsset.status == "ready",
|
DownloadJob.kept.is_(False),
|
||||||
MediaAsset.gc_notified.is_(False),
|
DownloadJob.expiry_warned.is_(False),
|
||||||
MediaAsset.expires_at.is_not(None),
|
DownloadJob.expires_at.is_not(None),
|
||||||
MediaAsset.expires_at > now,
|
DownloadJob.expires_at > now,
|
||||||
MediaAsset.expires_at <= warn_before,
|
DownloadJob.expires_at <= warn_before,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.scalars()
|
).all()
|
||||||
.all()
|
db.rollback() # the pass-1 snapshot holds no locks; each mark takes its own below
|
||||||
)
|
for job_id, expires_at in soon:
|
||||||
for asset in soon:
|
job = _mark_warned(db, job_id, expires_at)
|
||||||
_notify_holders(db, asset, "download_expiring")
|
if job is not None:
|
||||||
asset.gc_notified = True
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
warned += 1
|
_notify(db, job, asset, "download_expiring")
|
||||||
if soon:
|
warned += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
# 2. Expiry — a file past its folder's retention leaves the library (warned in pass 1).
|
||||||
dead = (
|
# Each file is re-checked under its locks and committed on its own: the release unlinks the
|
||||||
db.execute(
|
# file at once, so it must never act on what the SELECT saw a while ago.
|
||||||
select(MediaAsset).where(
|
dead = db.execute(
|
||||||
MediaAsset.status.in_(("ready", "error")),
|
select(DownloadJob.id, DownloadJob.asset_id).where(
|
||||||
MediaAsset.expires_at.is_not(None),
|
DownloadJob.status == JobStatus.DONE,
|
||||||
MediaAsset.expires_at <= now,
|
DownloadJob.kept.is_(False),
|
||||||
)
|
DownloadJob.expires_at.is_not(None),
|
||||||
|
DownloadJob.expires_at <= now,
|
||||||
)
|
)
|
||||||
.scalars()
|
).all()
|
||||||
.all()
|
db.rollback() # the pass-2 snapshot holds no locks; each file takes its own below
|
||||||
)
|
for job_id, asset_id in dead:
|
||||||
for asset in dead:
|
job = _relock_expired(db, job_id, asset_id, now)
|
||||||
_delete_asset(db, asset, "download_expired")
|
if job is not None:
|
||||||
expired += 1
|
_drop_job(db, job, "download_expired")
|
||||||
if dead:
|
expired += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 3. LRU eviction over the total cache cap.
|
# 3. LRU eviction over the total cache cap: the disk's safety net. Never an upload (no source to
|
||||||
|
# fetch again) and never a file anyone kept; an evicted file leaves every holder's library.
|
||||||
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
||||||
if cap > 0:
|
if cap > 0:
|
||||||
total = (
|
if _on_disk(db) > cap:
|
||||||
db.execute(
|
kept_assets = select(DownloadJob.asset_id).where(
|
||||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None)
|
||||||
).scalar()
|
)
|
||||||
or 0
|
candidate_ids = (
|
||||||
)
|
|
||||||
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 = (
|
|
||||||
db.execute(
|
db.execute(
|
||||||
select(MediaAsset)
|
select(MediaAsset.id)
|
||||||
.where(MediaAsset.status == "ready", MediaAsset.source_kind != "upload")
|
.where(
|
||||||
|
MediaAsset.status == "ready",
|
||||||
|
MediaAsset.source_kind != "upload",
|
||||||
|
MediaAsset.id.not_in(kept_assets),
|
||||||
|
)
|
||||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for asset in candidates:
|
db.rollback()
|
||||||
if total <= cap:
|
for asset_id in candidate_ids:
|
||||||
|
# Counted afresh before each: a file deleted since (this one, an upload, a candidate
|
||||||
|
# further down) has freed its space, and no other user's file goes for that space.
|
||||||
|
if _on_disk(db) <= cap:
|
||||||
break
|
break
|
||||||
total -= asset.size_bytes or 0
|
# Locked and re-read like an expiry: since the SELECT a holder may have kept it,
|
||||||
_delete_asset(db, asset, "download_evicted")
|
# every holder deleted it, or someone asked for it again (a fresh cache hit).
|
||||||
|
asset, holders = _relock_evictable(db, asset_id)
|
||||||
|
if (
|
||||||
|
asset is None
|
||||||
|
or asset.status != "ready"
|
||||||
|
or any(j.kept for j in holders)
|
||||||
|
or (asset.last_access_at is not None and asset.last_access_at > now)
|
||||||
|
):
|
||||||
|
db.commit()
|
||||||
|
continue
|
||||||
|
for job in holders:
|
||||||
|
_notify(db, job, asset, "download_evicted")
|
||||||
|
db.delete(job)
|
||||||
|
if asset.rel_path:
|
||||||
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
|
db.delete(asset)
|
||||||
evicted += 1
|
evicted += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still-
|
# 4. Reap orphaned errored rows (no file, no holder). get_or_create_asset resets a still-
|
||||||
# referenced errored row to pending on re-enqueue, so these only accumulate once every
|
# referenced errored row to pending on re-enqueue, so these only accumulate once every
|
||||||
@@ -257,7 +356,7 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
)
|
)
|
||||||
for asset in unheld:
|
for asset in unheld:
|
||||||
if asset.rel_path: # no holder is left to tell, so no notice — just the file and the row
|
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)
|
db.delete(asset)
|
||||||
reaped += 1
|
reaped += 1
|
||||||
if unheld:
|
if unheld:
|
||||||
@@ -265,7 +364,7 @@ def run_download_gc(db: Session) -> dict:
|
|||||||
|
|
||||||
# 5. Orphan FILE sweep — remove on-disk cruft no surviving asset owns (old accented dirs, stale
|
# 5. Orphan FILE sweep — remove on-disk cruft no surviving asset owns (old accented dirs, stale
|
||||||
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
# posters). Runs last so the keep-set reflects the assets that survived passes 2-4.
|
||||||
swept = _sweep_orphan_files(db, settings.download_root)
|
swept = _sweep_orphan_files(db, settings.files_root)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"warned": warned,
|
"warned": warned,
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ def _reset_asset_pending(asset_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _staging_dir(asset_id: int) -> Path:
|
def _staging_dir(asset_id: int) -> Path:
|
||||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
return Path(settings.files_root) / ".staging" / f"asset-{asset_id}"
|
||||||
|
|
||||||
|
|
||||||
def _edit_staging_dir(asset_id: int) -> Path:
|
def _edit_staging_dir(asset_id: int) -> Path:
|
||||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
return Path(settings.files_root) / ".staging" / f"edit-{asset_id}"
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
|||||||
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
|
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
|
||||||
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
|
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
|
||||||
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
|
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
|
||||||
poster_abs = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
poster_abs = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||||||
image_dims = _jpeg_size(poster_abs) if poster_abs else None
|
image_dims = _jpeg_size(poster_abs) if poster_abs else None
|
||||||
if image_dims:
|
if image_dims:
|
||||||
tags.append(_tag("og:image:width", str(image_dims[0])))
|
tags.append(_tag("og:image:width", str(image_dims[0])))
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
from yt_dlp.utils import download_range_func
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||||
@@ -26,7 +28,9 @@ from app.downloads.jobdb import (
|
|||||||
)
|
)
|
||||||
from app.downloads.stated import StatedFailure
|
from app.downloads.stated import StatedFailure
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset, PlexItem
|
||||||
|
from app.plex import lookups as plex_lookups
|
||||||
|
from app.plex import paths as plex_paths
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.titles import normalize_title
|
from app.titles import normalize_title
|
||||||
|
|
||||||
@@ -37,6 +41,15 @@ class _Aborted(Exception):
|
|||||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||||
|
|
||||||
|
|
||||||
|
class _ClipFetchFailed(Exception):
|
||||||
|
"""A clip's YouTube range fetch failed: its message and the download catalog's diagnosis, so a
|
||||||
|
clip on YouTube's wall says what a download of the same video would."""
|
||||||
|
|
||||||
|
def __init__(self, msg: str, diagnosis: dlerrors.Diagnosis):
|
||||||
|
super().__init__(msg)
|
||||||
|
self.diagnosis = diagnosis
|
||||||
|
|
||||||
|
|
||||||
class _OwnerRefused(Exception):
|
class _OwnerRefused(Exception):
|
||||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||||
|
|
||||||
@@ -82,16 +95,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
|
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
|
scheduler._notify_done). `kind` ("download"/"edit"/"upload") lets the inbox pick accurate copy; the
|
||||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
caller BEFORE its commit; only the folder is read here, once, for the notice's "Open"."""
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
# The notice's "Open" lands on the file in its folder: both ride along (a folder the file
|
||||||
|
# has left since is the page's to notice — it then looks among every file).
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
create_notification(
|
create_notification(
|
||||||
db,
|
db,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
type="download_done",
|
type="download_done",
|
||||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||||
data={"title": title, "kind": kind},
|
data={
|
||||||
|
"title": title,
|
||||||
|
"kind": kind,
|
||||||
|
"job_id": job_id,
|
||||||
|
"folder_id": job.folder_id if job else None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -213,7 +234,7 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
|
|||||||
|
|
||||||
def cancelled() -> bool:
|
def cancelled() -> bool:
|
||||||
if not state["cancelled"] and allow():
|
if not state["cancelled"] and allow():
|
||||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
state["cancelled"] = _job_status(job_id) not in (JobStatus.RUNNING, None)
|
||||||
return state["cancelled"]
|
return state["cancelled"]
|
||||||
|
|
||||||
return cancelled
|
return cancelled
|
||||||
@@ -333,7 +354,7 @@ def _media_info(info: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_with_fallback(
|
def _extract_with_fallback(
|
||||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None, section=None
|
||||||
):
|
):
|
||||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||||
@@ -347,9 +368,16 @@ def _extract_with_fallback(
|
|||||||
download as itself.
|
download as itself.
|
||||||
|
|
||||||
`cookiefile` is the acting user's per-host cookies.txt, already gated by
|
`cookiefile` is the acting user's per-host cookies.txt, already gated by
|
||||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
`credentials.resolve_cookiefile` (a clip: `resolve_clip_cookiefile`); passed to every attempt so
|
||||||
|
the fallback stays authenticated.
|
||||||
|
|
||||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. A
|
||||||
|
`section` fetch goes through yt-dlp's ffmpeg downloader, which does not read it: a clip holds
|
||||||
|
its own output to the cap (`_process_clip`).
|
||||||
|
|
||||||
|
`section` = (start_s, end_s) fetches only that range (an ad-hoc clip, see downloads/clip): the
|
||||||
|
range is stream-copied, and its first frame is the marked one (measured), so the clip's own
|
||||||
|
re-encode starts at 0 instead of re-fetching keyframes at the cut."""
|
||||||
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
client_sets = adapter.client_sets(authed=cookiefile is not None)
|
||||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
|
||||||
@@ -375,6 +403,9 @@ def _extract_with_fallback(
|
|||||||
max_bytes=max_bytes,
|
max_bytes=max_bytes,
|
||||||
)
|
)
|
||||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||||
|
if section is not None:
|
||||||
|
opts["download_ranges"] = download_range_func([], [section])
|
||||||
|
opts["force_keyframes_at_cuts"] = False
|
||||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||||
@@ -411,14 +442,12 @@ def _extract_with_fallback(
|
|||||||
raise last_exc # unreachable (loop either returns or raises)
|
raise last_exc # unreachable (loop either returns or raises)
|
||||||
|
|
||||||
|
|
||||||
def _download_settings() -> tuple[str, int]:
|
def _download_layout() -> str:
|
||||||
"""Layout + retention days from the DB config (admin-overridable on the Configuration page),
|
"""The on-disk layout from the DB config (admin-overridable on the Configuration page), falling
|
||||||
falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect."""
|
back to the env default. Read here, not from `settings`, so an admin edit takes effect. (How long
|
||||||
|
a file stays is its folder's, stamped on the job when it is done — `folders.done_expiry`.)"""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
return (
|
return sysconfig.get_str(db, "download_layout")
|
||||||
sysconfig.get_str(db, "download_layout"),
|
|
||||||
sysconfig.get_int(db, "download_retention_days"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_phone_safe(
|
def _ensure_phone_safe(
|
||||||
@@ -502,8 +531,8 @@ def _download(
|
|||||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
|
||||||
# front instead.
|
# checks the announced size) instead.
|
||||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||||
@@ -512,8 +541,8 @@ def _download(
|
|||||||
# free space, which is what the `quota_exceeded` category tells them.
|
# free space, which is what the `quota_exceeded` category tells them.
|
||||||
if remaining_bytes == 0:
|
if remaining_bytes == 0:
|
||||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||||
info = _media_info(
|
with formats.watching_size_cap() as over_cap:
|
||||||
_extract_with_fallback(
|
fetched = _extract_with_fallback(
|
||||||
job_id,
|
job_id,
|
||||||
asset_id,
|
asset_id,
|
||||||
url,
|
url,
|
||||||
@@ -523,7 +552,11 @@ def _download(
|
|||||||
cookiefile=str(cookiefile) if cookiefile else None,
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
max_bytes=remaining_bytes,
|
max_bytes=remaining_bytes,
|
||||||
)
|
)
|
||||||
)
|
info = _media_info(dict(fetched))
|
||||||
|
# yt-dlp refused a file larger than what is left: it returns with no file, which would
|
||||||
|
# otherwise read as a processing fault. The owner's storage, so the owner's refusal.
|
||||||
|
if over_cap[0]:
|
||||||
|
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
|
||||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||||
@@ -536,6 +569,13 @@ def _download(
|
|||||||
CONVERTING,
|
CONVERTING,
|
||||||
int(info["duration"]) if info.get("duration") else None,
|
int(info["duration"]) if info.get("duration") else None,
|
||||||
)
|
)
|
||||||
|
# Held to the cap by its real size, as a clip is: yt-dlp's `max_filesize` only stops a plain
|
||||||
|
# HTTP file that announces its size, and YouTube's fragmented formats never meet it
|
||||||
|
# (measured: 718 MB fetched with 1 MB left). Read again — other jobs may have landed.
|
||||||
|
with SessionLocal() as qdb:
|
||||||
|
room = quota.remaining_bytes(qdb, user_id)
|
||||||
|
if room is not None and produced.stat().st_size > room:
|
||||||
|
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
|
||||||
ext = produced.suffix.lstrip(".").lower()
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
|
||||||
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
|
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
|
||||||
@@ -554,19 +594,18 @@ def _download(
|
|||||||
thumbnail_url=info.get("thumbnail"),
|
thumbnail_url=info.get("thumbnail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout, retention_days = _download_settings()
|
layout = _download_layout()
|
||||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||||
thumb = _find_thumbnail(staging, produced)
|
thumb = _find_thumbnail(staging, produced)
|
||||||
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
||||||
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
||||||
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
||||||
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
||||||
storage.place_file(produced, settings.download_root, rel)
|
storage.place_file(produced, settings.files_root, rel)
|
||||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
nfo_ok = storage.write_sidecars(settings.files_root, rel, meta, poster_jpg)
|
||||||
|
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
size = final.stat().st_size if final.exists() else None
|
size = final.stat().st_size if final.exists() else None
|
||||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
@@ -592,12 +631,11 @@ def _download(
|
|||||||
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
||||||
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
||||||
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
||||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||||
asset.source_webpage_url = info.get("webpage_url")
|
asset.source_webpage_url = info.get("webpage_url")
|
||||||
asset.nfo_written = nfo_ok
|
asset.nfo_written = nfo_ok
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
@@ -736,11 +774,18 @@ def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseExcep
|
|||||||
|
|
||||||
|
|
||||||
def _fail_edit(
|
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:
|
) -> None:
|
||||||
"""Fail a local ffmpeg job — an edit, or an upload's conversion (`what="upload"`) — and say so
|
"""Fail a local ffmpeg job — an edit, a clip, or an upload's conversion (`what="upload"`) — and
|
||||||
ONCE: every way one fails comes through here, so this is where its line and its event code
|
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.
|
(`<what>.job.failed`) live. `exc` is the exception when there was one; `diagnosis` replaces the
|
||||||
|
plain category when the failure was a fetch the download catalog can name (a clip's).
|
||||||
|
|
||||||
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
|
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
|
||||||
Logged after the write, as on the download error path."""
|
Logged after the write, as on the download error path."""
|
||||||
@@ -755,9 +800,12 @@ def _fail_edit(
|
|||||||
job_id,
|
job_id,
|
||||||
status=JobStatus.ERROR,
|
status=JobStatus.ERROR,
|
||||||
error=msg,
|
error=msg,
|
||||||
error_code=dlerrors.classify_conversion(msg)
|
error_code=diagnosis.code
|
||||||
|
if diagnosis
|
||||||
|
else dlerrors.classify_conversion(msg)
|
||||||
if what == "upload"
|
if what == "upload"
|
||||||
else dlerrors.classify(msg),
|
else dlerrors.classify(msg),
|
||||||
|
error_detail=diagnosis.detail if diagnosis else None,
|
||||||
phase=None,
|
phase=None,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -796,7 +844,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
if not src_ready or not src_rel:
|
if not src_ready or not src_rel:
|
||||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||||
return
|
return
|
||||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
src_path = storage.abs_path(settings.files_root, src_rel)
|
||||||
if not src_path.exists():
|
if not src_path.exists():
|
||||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||||
return
|
return
|
||||||
@@ -834,15 +882,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.
|
# 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)
|
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
|
||||||
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
|
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
|
||||||
|
# New storage on the owner's footprint, held to what is left by its real size, as a
|
||||||
|
# download and a player clip are.
|
||||||
|
if user_id is not None:
|
||||||
|
with SessionLocal() as qdb:
|
||||||
|
room = quota.remaining_bytes(qdb, user_id)
|
||||||
|
if room is not None and dest_staging.stat().st_size > room:
|
||||||
|
raise StatedFailure(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||||
|
|
||||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||||
storage.place_file(dest_staging, settings.download_root, rel)
|
storage.place_file(dest_staging, settings.files_root, rel)
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
size = final.stat().st_size if final.exists() else None
|
size = final.stat().st_size if final.exists() else None
|
||||||
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
|
# 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)
|
_, _, 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:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
@@ -861,7 +914,6 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
asset.width, asset.height = src_w, src_h
|
asset.width, asset.height = src_w, src_h
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
@@ -896,6 +948,231 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_room(user_id: int) -> int | None:
|
||||||
|
"""What the clip's owner may still store (`quota.remaining_bytes`), refusing at zero — see
|
||||||
|
`_download`: zero is a refusal, not "no limit"."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
remaining = quota.remaining_bytes(db, user_id)
|
||||||
|
if remaining == 0:
|
||||||
|
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_source(
|
||||||
|
job_id: int, asset_id: int, spec: dict, staging: Path, remaining: int | None
|
||||||
|
) -> tuple[Path, float, str | None]:
|
||||||
|
"""The file a clip is cut from, where in it the clip starts, and the source's title when only
|
||||||
|
the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at
|
||||||
|
0 there); a Plex clip reads the library file in place."""
|
||||||
|
if spec["source"] == "youtube":
|
||||||
|
url = service.source_url("youtube", spec["ref"])
|
||||||
|
cookiefile: Path | None = None
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
# Signed in when the user has a YouTube cookie, as their downloads are: YouTube gates
|
||||||
|
# single videos signed out ("not a bot"), and the clip is theirs alone.
|
||||||
|
if job and asset:
|
||||||
|
cookiefile = credentials.resolve_clip_cookiefile(
|
||||||
|
db, job.user_id, url, asset, spec, staging
|
||||||
|
)
|
||||||
|
_set_job(job_id, phase="downloading", progress=0)
|
||||||
|
try:
|
||||||
|
with formats.watching_size_cap() as over_cap:
|
||||||
|
info = _extract_with_fallback(
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
url,
|
||||||
|
formats.normalize(clipmod.YOUTUBE_FETCH_SPEC),
|
||||||
|
staging,
|
||||||
|
adapters.resolve_adapter("youtube", spec["ref"]),
|
||||||
|
cookiefile=str(cookiefile) if cookiefile else None,
|
||||||
|
max_bytes=remaining,
|
||||||
|
section=(spec["start_s"], spec["end_s"]),
|
||||||
|
)
|
||||||
|
# Should yt-dlp's own cap ever refuse the range (see `_download`): the owner's storage.
|
||||||
|
if over_cap[0]:
|
||||||
|
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||||
|
except (_Aborted, _OwnerRefused):
|
||||||
|
raise
|
||||||
|
except Exception as exc: # re-raised below with the download catalog's diagnosis
|
||||||
|
msg = str(exc)[:500]
|
||||||
|
raise _ClipFetchFailed(
|
||||||
|
msg, dlerrors.diagnose(msg, url=url, cookie_attached=cookiefile is not None)
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
# Never leave a plaintext session cookie on disk past the fetch (see `_download`).
|
||||||
|
if cookiefile is not None:
|
||||||
|
cookiefile.unlink(missing_ok=True)
|
||||||
|
info = _media_info(dict(info))
|
||||||
|
title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info))
|
||||||
|
return _produced_file(info, staging), 0.0, title
|
||||||
|
with SessionLocal() as db:
|
||||||
|
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
|
||||||
|
src = plex_paths.local_media_path(db, item.file_path) if item else None
|
||||||
|
if src is None:
|
||||||
|
raise StatedFailure("The library file is not reachable from here.")
|
||||||
|
return src, spec["start_s"], None
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_crop(spec: dict, data: dict) -> str:
|
||||||
|
"""The clip's crop filter, from its frame and the picture the file really holds ("" for none)."""
|
||||||
|
frame = spec.get("crop")
|
||||||
|
if not frame:
|
||||||
|
return ""
|
||||||
|
dar = clipmod.display_aspect(data)
|
||||||
|
if dar is None:
|
||||||
|
raise StatedFailure("The picture's size could not be read, so the crop cannot be placed.")
|
||||||
|
crop = clipmod.crop_filter(frame, dar)
|
||||||
|
if crop is None:
|
||||||
|
raise StatedFailure("The crop frame lies outside the picture.")
|
||||||
|
return crop
|
||||||
|
|
||||||
|
|
||||||
|
def _process_clip(job_id: int, asset_id: int) -> None:
|
||||||
|
"""Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip
|
||||||
|
shape, then into the user's library beside their edits."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
spec = dict(job.edit_spec or {})
|
||||||
|
user_id = job.user_id
|
||||||
|
staging = _edit_staging_dir(asset_id)
|
||||||
|
try:
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Refused before the fetch and the encode when the owner's storage is already full.
|
||||||
|
remaining = _clip_room(user_id)
|
||||||
|
src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging, remaining)
|
||||||
|
data = probe.probe_json(src)
|
||||||
|
if not data:
|
||||||
|
raise StatedFailure("The source file could not be read.")
|
||||||
|
audio_track = spec.get("audio_track")
|
||||||
|
tracks = clipmod.audio_track_count(data)
|
||||||
|
if tracks == 0:
|
||||||
|
# A silent source: the player still names its track 0, and the clip is silent too.
|
||||||
|
audio_track = None
|
||||||
|
elif audio_track is not None and audio_track >= tracks:
|
||||||
|
raise StatedFailure("The chosen audio track is not in the file.")
|
||||||
|
crop = _clip_crop(spec, data)
|
||||||
|
total_s = clipmod.duration(spec)
|
||||||
|
out = staging / "clip.mp4"
|
||||||
|
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||||
|
editmod.run_ffmpeg(
|
||||||
|
clipmod.build_cmd(
|
||||||
|
src,
|
||||||
|
out,
|
||||||
|
start,
|
||||||
|
total_s,
|
||||||
|
audio_track=audio_track,
|
||||||
|
hdr=clipmod.is_hdr(data),
|
||||||
|
threads=editmod.encode_thread_cap(),
|
||||||
|
crop=crop,
|
||||||
|
),
|
||||||
|
total_s,
|
||||||
|
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||||
|
should_cancel=_cancel_poll(job_id),
|
||||||
|
)
|
||||||
|
if not out.exists():
|
||||||
|
raise StatedFailure("ffmpeg produced no output file")
|
||||||
|
# The phone-safe shape by construction; still held to the rule every ready file meets.
|
||||||
|
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
|
||||||
|
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
|
||||||
|
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
|
||||||
|
# again, since other jobs of this owner may have landed during the encode.
|
||||||
|
remaining = _clip_room(user_id)
|
||||||
|
if remaining is not None and out.stat().st_size > remaining:
|
||||||
|
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
|
||||||
|
ext = out.suffix.lstrip(".").lower() or "mp4"
|
||||||
|
rel = storage.edit_rel_path(user_id, asset_id, ext)
|
||||||
|
storage.place_file(out, settings.files_root, rel)
|
||||||
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
|
vcodec, acodec, width, height, _ = probe.probe_media(final)
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if asset:
|
||||||
|
asset.status = "ready"
|
||||||
|
asset.rel_path = rel
|
||||||
|
asset.size_bytes = final.stat().st_size if final.exists() else None
|
||||||
|
asset.container = ext
|
||||||
|
asset.vcodec, asset.acodec, asset.width, asset.height = (
|
||||||
|
vcodec,
|
||||||
|
acodec,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
asset.duration_s = round(total_s)
|
||||||
|
asset.media_kind = mediakind.derive(vcodec, acodec)
|
||||||
|
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
|
||||||
|
# item has no public thumbnail at all.
|
||||||
|
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||||
|
asset.error = None
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
|
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
|
||||||
|
placeholder = service.clip_title(None, None)
|
||||||
|
if asset and fetched_title and asset.title == placeholder:
|
||||||
|
named = service.clip_title(fetched_title, None)
|
||||||
|
asset.title = named
|
||||||
|
if job_row := db.get(DownloadJob, job_id):
|
||||||
|
if job_row.display_name == placeholder:
|
||||||
|
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
|
||||||
|
title = asset.title if asset else None
|
||||||
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
|
db.commit()
|
||||||
|
if finalized:
|
||||||
|
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
||||||
|
log.info(
|
||||||
|
"clip job %s done: asset %s",
|
||||||
|
job_id,
|
||||||
|
asset_id,
|
||||||
|
extra=logs.event(
|
||||||
|
"clip.job.done", job_id=job_id, asset_id=asset_id, source=spec["source"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (_Aborted, editmod.EditAborted):
|
||||||
|
# Paused/canceled mid-fetch or mid-encode: free the asset for a later retry.
|
||||||
|
_reset_asset_pending(asset_id)
|
||||||
|
log.info(
|
||||||
|
"clip job %s aborted (pause/cancel)",
|
||||||
|
job_id,
|
||||||
|
extra=logs.event("clip.job.aborted", job_id=job_id),
|
||||||
|
)
|
||||||
|
except _OwnerRefused as exc:
|
||||||
|
# Quota full: the job fails, the asset goes back to pending for a Retry once there is room
|
||||||
|
# (the download's handling; see `_download`).
|
||||||
|
msg = str(exc)[:500]
|
||||||
|
with SessionLocal() as db:
|
||||||
|
service.finalize_if_running(
|
||||||
|
db,
|
||||||
|
job_id,
|
||||||
|
status=JobStatus.ERROR,
|
||||||
|
error=msg,
|
||||||
|
error_code=dlerrors.classify(msg),
|
||||||
|
error_detail=None,
|
||||||
|
phase=None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
_reset_asset_pending(asset_id)
|
||||||
|
log.info(
|
||||||
|
"clip job %s refused for its owner: %s",
|
||||||
|
job_id,
|
||||||
|
formats.log_safe(msg),
|
||||||
|
extra=logs.event("clip.job.refused", job_id=job_id),
|
||||||
|
)
|
||||||
|
except _ClipFetchFailed as exc:
|
||||||
|
# silent-ok: not silent — `_fail_edit` is the report, with the fetch's diagnosis.
|
||||||
|
_fail_edit(job_id, asset_id, str(exc), exc, what="clip", diagnosis=exc.diagnosis)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||||
|
# silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
|
||||||
|
_fail_edit(job_id, asset_id, str(exc)[:500], exc, what="clip")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) -> None:
|
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
|
"""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,
|
charged at its original size, and a re-encode can come out larger. The original stays, counted,
|
||||||
@@ -904,7 +1181,7 @@ def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) ->
|
|||||||
return
|
return
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
|
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:
|
def _unplace(placed: Path | None) -> None:
|
||||||
@@ -952,7 +1229,7 @@ def _free_unheld_upload(asset_id: int) -> bool:
|
|||||||
if asset is None or asset.ref_count != 0:
|
if asset is None or asset.ref_count != 0:
|
||||||
return False
|
return False
|
||||||
if asset.rel_path:
|
if asset.rel_path:
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
db.delete(asset)
|
db.delete(asset)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
@@ -975,7 +1252,7 @@ def _convert_upload(job_id: int, asset_id: int) -> None:
|
|||||||
duration = asset.duration_s if asset else None
|
duration = asset.duration_s if asset else None
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
owner_id = job.user_id if job else None
|
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():
|
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")
|
_fail_edit(job_id, asset_id, dlerrors.UPLOAD_MISSING_MESSAGE, what="upload")
|
||||||
return
|
return
|
||||||
@@ -989,10 +1266,10 @@ def _convert_upload(job_id: int, asset_id: int) -> None:
|
|||||||
new_rel = storage.with_ext(old_rel, old_ext, ext)
|
new_rel = storage.with_ext(old_rel, old_ext, ext)
|
||||||
if safe != src:
|
if safe != src:
|
||||||
_check_converted_fits(owner_id, old_size, safe.stat().st_size)
|
_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:
|
if new_rel != old_rel:
|
||||||
placed = storage.abs_path(settings.download_root, new_rel)
|
placed = storage.abs_path(settings.files_root, new_rel)
|
||||||
final = storage.abs_path(settings.download_root, new_rel)
|
final = storage.abs_path(settings.files_root, new_rel)
|
||||||
vcodec, acodec, width, height, dur = probe.probe_media(final)
|
vcodec, acodec, width, height, dur = probe.probe_media(final)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
asset = db.get(MediaAsset, asset_id)
|
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
|
||||||
@@ -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
|
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
|
||||||
|
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -24,7 +25,7 @@ from sqlalchemy import and_, func, or_, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobKind, JobStatus
|
||||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||||
@@ -66,7 +67,8 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
|||||||
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
|
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
|
||||||
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
|
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
|
||||||
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
|
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
|
||||||
# downloads, suspend them or set max_jobs=0, not max_concurrent=0.)
|
# downloads, suspend them — the worker leaves a suspended user's queued jobs unclaimed — or
|
||||||
|
# set max_jobs=0, not max_concurrent=0.)
|
||||||
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
|
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
|
||||||
return QuotaLimits(
|
return QuotaLimits(
|
||||||
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
||||||
@@ -86,7 +88,7 @@ def footprint(db: Session, user_id: int) -> int:
|
|||||||
DownloadJob.user_id == user_id,
|
DownloadJob.user_id == user_id,
|
||||||
or_(
|
or_(
|
||||||
DownloadJob.status.in_(_HOLDING),
|
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),
|
DownloadJob.asset_id.is_not(None),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.config import settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
|
from app.downloads.status import JobKind
|
||||||
from app.models import DownloadJob
|
from app.models import DownloadJob
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
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`)."""
|
(`cause=child_exit`)."""
|
||||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
||||||
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||||
from app.downloads.pipeline import _convert_upload, _download, _process_edit
|
from app.downloads.pipeline import _convert_upload, _download, _process_clip, _process_edit
|
||||||
|
|
||||||
with formats.hiding(source_ref):
|
with formats.hiding(source_ref):
|
||||||
try:
|
try:
|
||||||
if job_kind == "edit":
|
if job_kind == JobKind.EDIT:
|
||||||
_process_edit(job_id, asset_id)
|
_process_edit(job_id, asset_id)
|
||||||
elif job_kind == "upload":
|
elif job_kind == JobKind.UPLOAD:
|
||||||
_convert_upload(job_id, asset_id)
|
_convert_upload(job_id, asset_id)
|
||||||
|
elif job_kind == JobKind.CLIP:
|
||||||
|
_process_clip(job_id, asset_id)
|
||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||||
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import credentials
|
from app.downloads import credentials
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, mediakind, quota, storage
|
from app.downloads import folders, formats, mediakind, quota, storage
|
||||||
from app.downloads.status import HOLDING, JobStatus
|
from app.downloads.status import HOLDING, JobKind, JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
|
from app.models import (
|
||||||
|
DISPLAY_TEXT_MAX,
|
||||||
|
Channel,
|
||||||
|
DownloadJob,
|
||||||
|
MediaAsset,
|
||||||
|
PlexItem,
|
||||||
|
PlexShow,
|
||||||
|
Video,
|
||||||
|
)
|
||||||
|
from app.plex import lookups as plex_lookups
|
||||||
|
from app.plex import paths as plex_paths
|
||||||
|
|
||||||
|
|
||||||
class EditError(Exception):
|
class EditError(Exception):
|
||||||
@@ -38,12 +49,15 @@ def source_url(source_kind: str, source_ref: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def reference_url(job, asset) -> str | None:
|
def reference_url(job, asset) -> str | None:
|
||||||
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
|
"""The clean "downloaded from" URL for a job — shared by the Files library and the public
|
||||||
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
|
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
|
||||||
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
|
own earlier download, not a web page) and for a Plex clip (the library is not a public page); a
|
||||||
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else
|
||||||
if job is None or job.job_kind == "edit":
|
one derived from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
|
||||||
|
if job is None or job.job_kind == JobKind.EDIT:
|
||||||
return None
|
return None
|
||||||
|
if job.job_kind == JobKind.CLIP:
|
||||||
|
return clipmod.reference_url(job.edit_spec or {})
|
||||||
if asset is not None and asset.source_webpage_url:
|
if asset is not None and asset.source_webpage_url:
|
||||||
return asset.source_webpage_url
|
return asset.source_webpage_url
|
||||||
if job.source_kind in ("youtube", "url"):
|
if job.source_kind in ("youtube", "url"):
|
||||||
@@ -51,6 +65,23 @@ def reference_url(job, asset) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def library_left_behind(db: Session, files_root: str) -> bool:
|
||||||
|
"""True when the library names files and none of the newest of them is under `files_root` — a
|
||||||
|
fresh, empty volume mounted in the library's place (a compose file changed under an install, its
|
||||||
|
old volume left behind). The mount check cannot see that: the new volume is a real mount."""
|
||||||
|
rels = (
|
||||||
|
db.execute(
|
||||||
|
select(MediaAsset.rel_path)
|
||||||
|
.where(MediaAsset.status == "ready", MediaAsset.rel_path.is_not(None))
|
||||||
|
.order_by(MediaAsset.id.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return bool(rels) and not any(storage.abs_path(files_root, r).exists() for r in rels if r)
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_asset(
|
def get_or_create_asset(
|
||||||
db: Session, source_kind: str, source_ref: str, format_sig: str
|
db: Session, source_kind: str, source_ref: str, format_sig: str
|
||||||
) -> MediaAsset:
|
) -> MediaAsset:
|
||||||
@@ -63,7 +94,12 @@ def get_or_create_asset(
|
|||||||
MediaAsset.source_ref == source_ref,
|
MediaAsset.source_ref == source_ref,
|
||||||
MediaAsset.format_sig == format_sig,
|
MediaAsset.format_sig == format_sig,
|
||||||
)
|
)
|
||||||
asset = db.execute(stmt).scalar_one_or_none()
|
# Locked to the caller's commit: a release or the GC freeing this asset meanwhile (they lock it
|
||||||
|
# too) either waits for the new hold, or has deleted the row by the time this read returns — and
|
||||||
|
# the caller then gets a fresh one, never a job pointing at a row that is gone.
|
||||||
|
asset = db.execute(
|
||||||
|
stmt.with_for_update().execution_options(populate_existing=True)
|
||||||
|
).scalar_one_or_none()
|
||||||
if asset is not None:
|
if asset is not None:
|
||||||
# A previously-FAILED shared asset must be re-attempted for the new job about to hold it —
|
# A previously-FAILED shared asset must be re-attempted for the new job about to hold it —
|
||||||
# reset it to pending (+clear the error) so the worker actually re-downloads instead of
|
# reset it to pending (+clear the error) so the worker actually re-downloads instead of
|
||||||
@@ -180,16 +216,18 @@ def enqueue(
|
|||||||
profile_snapshot=spec,
|
profile_snapshot=spec,
|
||||||
display_name=display_name
|
display_name=display_name
|
||||||
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
|
||||||
status="queued",
|
status=JobStatus.QUEUED,
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
incr_ref_count(db, asset.id)
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
# Cache hit: the file already exists — no worker round-trip needed.
|
# Cache hit: the file already exists — no worker round-trip needed.
|
||||||
if asset.status == "ready":
|
if asset.status == "ready":
|
||||||
job.status = "done"
|
job.status = JobStatus.DONE
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
|
folders.stamp_done(db, job)
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -197,7 +235,7 @@ def enqueue(
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
def clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||||
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
|
||||||
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
|
||||||
# rather than run through `display_text` — blank here means "no title", not None.
|
# rather than run through `display_text` — blank here means "no title", not None.
|
||||||
@@ -239,7 +277,7 @@ def enqueue_edit(
|
|||||||
ref = f"{user_id}:{src_asset.id}"[:512]
|
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||||
if not asset.title:
|
if not asset.title:
|
||||||
asset.title = _clip_title(src_asset.title, display_name)
|
asset.title = clip_title(src_asset.title, display_name)
|
||||||
asset.uploader = src_asset.uploader
|
asset.uploader = src_asset.uploader
|
||||||
asset.thumbnail_url = src_asset.thumbnail_url
|
asset.thumbnail_url = src_asset.thumbnail_url
|
||||||
asset.upload_date = src_asset.upload_date
|
asset.upload_date = src_asset.upload_date
|
||||||
@@ -251,7 +289,7 @@ def enqueue_edit(
|
|||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
job_kind="edit",
|
job_kind=JobKind.EDIT,
|
||||||
source_kind="edit",
|
source_kind="edit",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
source_job_id=source_job.id,
|
source_job_id=source_job.id,
|
||||||
@@ -259,15 +297,106 @@ def enqueue_edit(
|
|||||||
edit_spec=spec,
|
edit_spec=spec,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
status="queued",
|
status=JobStatus.QUEUED,
|
||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
incr_ref_count(db, asset.id)
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||||
job.status = "done"
|
job.status = JobStatus.DONE
|
||||||
job.progress = 100
|
job.progress = 100
|
||||||
|
folders.stamp_done(db, job)
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_source_facts(db: Session, spec: dict) -> dict:
|
||||||
|
"""Title, uploader, thumbnail and length of what a clip is cut from, read from our own catalog.
|
||||||
|
Raises ClipError when a Plex item is unknown or its file cannot be read here — said at enqueue,
|
||||||
|
rather than as a job that fails in the queue a minute later."""
|
||||||
|
if spec["source"] == "youtube":
|
||||||
|
video = db.get(Video, spec["ref"])
|
||||||
|
if video is None: # not in the catalog: titled "Clip" until yt-dlp names it (worker)
|
||||||
|
return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None}
|
||||||
|
channel = db.get(Channel, video.channel_id)
|
||||||
|
return {
|
||||||
|
"title": video.title,
|
||||||
|
"uploader": channel.title if channel else None,
|
||||||
|
"thumbnail_url": video.thumbnail_url,
|
||||||
|
"length_s": video.duration_seconds,
|
||||||
|
}
|
||||||
|
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
|
||||||
|
if item is None:
|
||||||
|
raise clipmod.ClipError("not_found")
|
||||||
|
if plex_paths.local_media_path(db, item.file_path) is None:
|
||||||
|
raise clipmod.ClipError("media_missing")
|
||||||
|
title = item.title
|
||||||
|
show = db.get(PlexShow, item.show_id) if item.show_id else None
|
||||||
|
if show is not None:
|
||||||
|
episode = (
|
||||||
|
f"S{item.season_number}·E{item.episode_number}"
|
||||||
|
if item.season_number is not None and item.episode_number is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
title = " — ".join(p for p in (show.title, episode, item.title) if p)
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"uploader": None,
|
||||||
|
# Plex art is served through our own authenticated routes, never a public URL; the worker
|
||||||
|
# cuts the clip's poster from the clip itself.
|
||||||
|
"thumbnail_url": None,
|
||||||
|
"length_s": item.duration_s,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_clip(
|
||||||
|
db: Session, user_id: int, spec: dict, display_name: str | None = None
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Queue an ad-hoc clip (`clipmod.normalize`d spec) for `user_id`: a NEW per-user asset cut
|
||||||
|
straight from the source — a YouTube range or a Plex library file (see downloads/clip).
|
||||||
|
|
||||||
|
Raises ClipError (unknown or unreachable source, a range past its end) or quota.QuotaExceeded."""
|
||||||
|
facts = _clip_source_facts(db, spec)
|
||||||
|
# One second of grace: a mark set on the last frame may round past the catalog's whole seconds.
|
||||||
|
if facts["length_s"] and spec["end_s"] > facts["length_s"] + 1:
|
||||||
|
raise clipmod.ClipError("past_end")
|
||||||
|
|
||||||
|
quota.check_enqueue(db, user_id)
|
||||||
|
|
||||||
|
ref = clipmod.asset_ref(user_id, spec)[:512]
|
||||||
|
asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec))
|
||||||
|
if not asset.title:
|
||||||
|
asset.title = clip_title(facts["title"], display_name)
|
||||||
|
asset.uploader = facts["uploader"]
|
||||||
|
asset.thumbnail_url = facts["thumbnail_url"]
|
||||||
|
asset.duration_s = round(clipmod.duration(spec))
|
||||||
|
asset.media_kind = mediakind.VIDEO
|
||||||
|
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
job_kind=JobKind.CLIP,
|
||||||
|
source_kind="clip",
|
||||||
|
source_ref=ref,
|
||||||
|
edit_spec=spec,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
queue_pos=next_queue_pos(db),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
|
incr_ref_count(db, asset.id)
|
||||||
|
|
||||||
|
if asset.status == "ready": # the same range was already cut for this user → instant
|
||||||
|
job.status = JobStatus.DONE
|
||||||
|
job.progress = 100
|
||||||
|
folders.stamp_done(db, job)
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -291,6 +420,8 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
|||||||
if not fields:
|
if not fields:
|
||||||
return False
|
return False
|
||||||
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
|
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
|
||||||
|
if fields.get("status") == JobStatus.DONE:
|
||||||
|
return _finalize_done(db, job_id, {**fields, "ready_at": datetime.now(timezone.utc)})
|
||||||
# A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the
|
# A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the
|
||||||
# rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this
|
# rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this
|
||||||
# is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound
|
# is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound
|
||||||
@@ -304,6 +435,41 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
|||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_done(db: Session, job_id: int, fields: dict) -> bool:
|
||||||
|
"""`finalize_if_running` for a job that finished: done is when a file's retention starts, its
|
||||||
|
folder's unless the user kept it. The expiry is written only while the job is still where it was
|
||||||
|
read from — a move, a keep, or its folder's delete (which sends its files home) landing while the
|
||||||
|
folder's retention was read sends the read round again, never stamping a folder's retention on a
|
||||||
|
file now in another one."""
|
||||||
|
while True:
|
||||||
|
held = db.execute(
|
||||||
|
select(DownloadJob.folder_id, DownloadJob.kept, DownloadJob.status).where(
|
||||||
|
DownloadJob.id == job_id
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
if held is None or held.status != JobStatus.RUNNING:
|
||||||
|
return False
|
||||||
|
stamped = {
|
||||||
|
**fields,
|
||||||
|
"expires_at": folders.done_expiry(db, held.folder_id, held.kept),
|
||||||
|
"expiry_warned": False,
|
||||||
|
}
|
||||||
|
hit = db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(
|
||||||
|
DownloadJob.id == job_id,
|
||||||
|
DownloadJob.status == JobStatus.RUNNING.value,
|
||||||
|
DownloadJob.folder_id.is_not_distinct_from(held.folder_id),
|
||||||
|
DownloadJob.kept == held.kept,
|
||||||
|
)
|
||||||
|
.values(**stamped)
|
||||||
|
.returning(DownloadJob.id)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if hit is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def release_asset(db: Session, job: DownloadJob) -> None:
|
def release_asset(db: Session, job: DownloadJob) -> None:
|
||||||
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
||||||
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
||||||
@@ -335,7 +501,7 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
|||||||
)
|
)
|
||||||
if new_count == 0 and freeable:
|
if new_count == 0 and freeable:
|
||||||
if asset.rel_path:
|
if asset.rel_path:
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
storage.delete_asset_files(settings.files_root, asset.rel_path)
|
||||||
db.delete(asset)
|
db.delete(asset)
|
||||||
|
|
||||||
|
|
||||||
@@ -464,4 +630,6 @@ def redownload(db: Session, job: DownloadJob) -> None:
|
|||||||
# name would survive the rebuild).
|
# name would survive the rebuild).
|
||||||
job.display_name = None
|
job.display_name = None
|
||||||
job.queue_pos = next_queue_pos(db) # tail of the queue, not its stale original slot
|
job.queue_pos = next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||||
|
job.expires_at = None # stamped again when the rebuild is done
|
||||||
|
job.expiry_warned = False
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -33,6 +33,27 @@ HOLDING = frozenset(
|
|||||||
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobKind(StrEnum):
|
||||||
|
"""What a job produces, which decides the worker path that runs it and what the library may do
|
||||||
|
with the result (only a DOWNLOAD has a web source to fetch again)."""
|
||||||
|
|
||||||
|
DOWNLOAD = "download" # yt-dlp fetches a source into a shared, deduplicated asset
|
||||||
|
EDIT = "edit" # a per-user trim/crop of a file already in the library
|
||||||
|
UPLOAD = "upload" # a file the user sent; the worker only converts it
|
||||||
|
CLIP = (
|
||||||
|
"clip" # a per-user range of a YouTube video or a Plex item, cut straight from the source
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# A job kind with no web source to fetch again: Re-download refuses these.
|
||||||
|
NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP})
|
||||||
|
|
||||||
|
|
||||||
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
||||||
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
||||||
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
||||||
|
|
||||||
|
|
||||||
|
# A job that will not move again on its own: the queue lists it only briefly after it got here.
|
||||||
|
FINISHED = frozenset({JobStatus.DONE, JobStatus.CANCELED})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ a Plex library using a metadata agent picks up rich info. Channel = "show", vide
|
|||||||
date-based — the community-standard mapping for YouTube-in-Plex.
|
date-based — the community-standard mapping for YouTube-in-Plex.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import unicodedata
|
import unicodedata
|
||||||
@@ -67,6 +68,57 @@ _FOLD_MAP = str.maketrans(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mount_points(mountinfo: str) -> list[str]:
|
||||||
|
"""The mount points a /proc/<pid>/mountinfo table lists (its fifth field, octal escapes undone)."""
|
||||||
|
points = []
|
||||||
|
for line in mountinfo.splitlines():
|
||||||
|
fields = line.split(" ")
|
||||||
|
if len(fields) > 4:
|
||||||
|
points.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), fields[4]))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _in_container() -> bool:
|
||||||
|
return Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mountinfo() -> str | None:
|
||||||
|
try:
|
||||||
|
return Path("/proc/self/mountinfo").read_text()
|
||||||
|
# silent-ok: no mount table to read (not Linux) — the caller then cannot tell, and refuses nothing.
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def files_root_problem(
|
||||||
|
root: str, mountinfo: str | None = None, in_container: bool | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Why `root` cannot be the files library's volume, or None when it can. In a container, a root
|
||||||
|
that is no mount of its own (nor inside one) is the image's empty directory: the library would
|
||||||
|
answer 404 for every file, and what a download writes would go when the container does — the
|
||||||
|
state an install is in when its compose file still mounts the library somewhere else.
|
||||||
|
|
||||||
|
Asked of the kernel's mount table, not by comparing devices: a storage driver that keeps the
|
||||||
|
container's root on the host's own disk (vfs, the fallback under Docker-in-LXC on older ZFS) gives
|
||||||
|
a mounted volume the same device as `/`. Outside a container a plain directory is a fine root."""
|
||||||
|
path = Path(root)
|
||||||
|
if not path.is_dir():
|
||||||
|
return f"{root} does not exist"
|
||||||
|
if in_container if in_container is not None else _in_container():
|
||||||
|
table = mountinfo if mountinfo is not None else _read_mountinfo()
|
||||||
|
if table is not None:
|
||||||
|
real = os.path.realpath(root)
|
||||||
|
mounted = any(
|
||||||
|
m != "/" and (real == m or real.startswith(m.rstrip("/") + "/"))
|
||||||
|
for m in _mount_points(table)
|
||||||
|
)
|
||||||
|
if not mounted:
|
||||||
|
return f"{root} is not a mounted volume (it is the container's own empty directory)"
|
||||||
|
if not os.access(path, os.W_OK):
|
||||||
|
return f"{root} is not writable"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _ascii_fold(text: str) -> str:
|
def _ascii_fold(text: str) -> str:
|
||||||
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
|
"""Transliterate to pure ASCII: apply the explicit map, NFKD-split accented letters and drop
|
||||||
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
|
the combining marks (á→a, ő→o), then strip any leftover non-ASCII (non-Latin scripts that have
|
||||||
@@ -122,7 +174,7 @@ def display_filename(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | None = None) -> str:
|
||||||
"""Path of the media file relative to DOWNLOAD_ROOT (forward slashes).
|
"""Path of the media file relative to FILES_ROOT (forward slashes).
|
||||||
|
|
||||||
`asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref,
|
`asset_id` disambiguates the file. A MediaAsset is unique on (source_kind, source_ref,
|
||||||
format_sig), so the SAME video downloaded in two formats is two distinct assets — but without
|
format_sig), so the SAME video downloaded in two formats is two distinct assets — but without
|
||||||
@@ -144,7 +196,7 @@ def rel_path(meta: MediaMeta, ext: str, layout: str = "plex", asset_id: int | No
|
|||||||
|
|
||||||
|
|
||||||
def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
||||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a phase-2 editor derivative.
|
"""On-disk path (relative to FILES_ROOT) for a phase-2 editor derivative.
|
||||||
|
|
||||||
Kept in a per-user `.edits/` tree — device-downloadable via the file endpoint, but NOT part of
|
Kept in a per-user `.edits/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||||
the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster
|
the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster
|
||||||
@@ -161,7 +213,7 @@ def _clean_upload_ext(filename: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
def upload_rel_path(user_id: int, asset_id: int, filename: str) -> str:
|
||||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a locally-uploaded file.
|
"""On-disk path (relative to FILES_ROOT) for a locally-uploaded file.
|
||||||
|
|
||||||
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
Kept in a per-user `.uploads/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||||
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
the Plex library (an upload is arbitrary user content, not a canonical episode; no .nfo/poster
|
||||||
@@ -250,24 +302,27 @@ def download_filename(display_name: str, container: str | None, path: Path) -> s
|
|||||||
return f"{base}.{ext}"
|
return f"{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
def abs_path(download_root: str, rel: str) -> Path:
|
def abs_path(files_root: str, rel: str) -> Path:
|
||||||
return Path(download_root) / rel
|
return Path(files_root) / rel
|
||||||
|
|
||||||
|
|
||||||
def safe_abs_path(download_root: str, rel: str) -> Path | None:
|
def safe_abs_path(files_root: str, rel: str | None) -> Path | None:
|
||||||
"""The resolved absolute path for `rel`, but ONLY if it stays inside DOWNLOAD_ROOT and exists on
|
"""The resolved absolute path for `rel`, but ONLY if it stays inside FILES_ROOT and exists on
|
||||||
disk — otherwise None. Centralizes the path-traversal + existence guard every file-serving endpoint
|
disk — otherwise None (no `rel` at all included: an asset without a file has nothing to serve).
|
||||||
shares, so the containment check lives in exactly one place."""
|
Centralizes the path-traversal + existence guard every file-serving endpoint shares, so the
|
||||||
root = Path(download_root).resolve()
|
containment check lives in exactly one place."""
|
||||||
path = abs_path(download_root, rel).resolve()
|
if not rel:
|
||||||
|
return None
|
||||||
|
root = Path(files_root).resolve()
|
||||||
|
path = abs_path(files_root, rel).resolve()
|
||||||
if root not in path.parents or not path.exists():
|
if root not in path.parents or not path.exists():
|
||||||
return None
|
return None
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def place_file(staging_file: Path, download_root: str, rel: str) -> None:
|
def place_file(staging_file: Path, files_root: str, rel: str) -> None:
|
||||||
"""Move a completed staging file into its final location under DOWNLOAD_ROOT."""
|
"""Move a completed staging file into its final location under FILES_ROOT."""
|
||||||
dest = abs_path(download_root, rel)
|
dest = abs_path(files_root, rel)
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.move(str(staging_file), str(dest))
|
shutil.move(str(staging_file), str(dest))
|
||||||
|
|
||||||
@@ -301,9 +356,9 @@ def _sidecar(media: Path, ext: str) -> Path:
|
|||||||
return media.with_suffix(ext)
|
return media.with_suffix(ext)
|
||||||
|
|
||||||
|
|
||||||
def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None) -> bool:
|
def write_sidecars(files_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None) -> bool:
|
||||||
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
|
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
|
||||||
media = abs_path(download_root, rel)
|
media = abs_path(files_root, rel)
|
||||||
try:
|
try:
|
||||||
_sidecar(media, ".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
|
_sidecar(media, ".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
|
||||||
if thumb_src and thumb_src.exists():
|
if thumb_src and thumb_src.exists():
|
||||||
@@ -319,9 +374,9 @@ def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Pat
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def delete_asset_files(download_root: str, rel: str) -> None:
|
def delete_asset_files(files_root: str, rel: str) -> None:
|
||||||
"""Remove the media file and its sidecars; prune now-empty parent dirs (best effort)."""
|
"""Remove the media file and its sidecars; prune now-empty parent dirs (best effort)."""
|
||||||
media = abs_path(download_root, rel)
|
media = abs_path(files_root, rel)
|
||||||
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
|
||||||
try:
|
try:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
@@ -335,7 +390,7 @@ def delete_asset_files(download_root: str, rel: str) -> None:
|
|||||||
extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
|
extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
|
||||||
)
|
)
|
||||||
# Prune empty Season/Channel dirs up to (but not including) the root.
|
# Prune empty Season/Channel dirs up to (but not including) the root.
|
||||||
root = Path(download_root).resolve()
|
root = Path(files_root).resolve()
|
||||||
parent = media.parent
|
parent = media.parent
|
||||||
while parent != root and root in parent.parents:
|
while parent != root and root in parent.parents:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit
|
||||||
|
from app.audit import AuditAction
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import mediakind, playable, probe, quota, service, storage
|
from app.downloads import folders, mediakind, playable, probe, quota, service, storage
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobKind, JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset, display_text
|
from app.models import DownloadJob, MediaAsset, display_text
|
||||||
|
|
||||||
|
|
||||||
@@ -53,14 +55,24 @@ def ingest_upload(
|
|||||||
src_path: Path,
|
src_path: Path,
|
||||||
size: int,
|
size: int,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
sha256: str | None = None,
|
||||||
|
folder_id: int | None = None,
|
||||||
) -> DownloadJob:
|
) -> DownloadJob:
|
||||||
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
"""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`
|
`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.
|
||||||
|
|
||||||
|
Raises folders.FolderError when that folder is not one an upload may land in (or is gone).
|
||||||
|
|
||||||
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
||||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||||
|
if folder_id is not None:
|
||||||
|
# Refused before any work; held only once the job is about to name it (below), not through
|
||||||
|
# the probe and the poster, which a rename or delete of the folder would wait out.
|
||||||
|
folders.upload_target(db, user_id, folder_id, hold=False)
|
||||||
|
|
||||||
data = probe.probe_json(src_path)
|
data = probe.probe_json(src_path)
|
||||||
vcodec, acodec, width, height, duration = probe.facts(data)
|
vcodec, acodec, width, height, duration = probe.facts(data)
|
||||||
@@ -81,18 +93,21 @@ def ingest_upload(
|
|||||||
db.flush() # need asset.id for the on-disk path
|
db.flush() # need asset.id for the on-disk path
|
||||||
|
|
||||||
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
||||||
storage.place_file(src_path, settings.download_root, rel)
|
storage.place_file(src_path, settings.files_root, rel)
|
||||||
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
||||||
# asset/job rows roll back and nothing names the placed file: the per-user delete path keys off
|
# 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
|
# 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).
|
# roll back the on-disk side too — unless the commit itself raised after it landed (see below).
|
||||||
asset_id = asset.id
|
asset_id = asset.id
|
||||||
try:
|
try:
|
||||||
final = storage.abs_path(settings.download_root, rel)
|
final = storage.abs_path(settings.files_root, rel)
|
||||||
actual_size = final.stat().st_size if final.exists() else size
|
actual_size = final.stat().st_size if final.exists() else size
|
||||||
|
|
||||||
asset.status = "pending" if convert else "ready"
|
asset.status = "pending" if convert else "ready"
|
||||||
asset.rel_path = rel
|
asset.rel_path = rel
|
||||||
|
# The hash names the bytes that arrived: a conversion replaces them, so it is kept only
|
||||||
|
# when the file stays as it came.
|
||||||
|
asset.sha256 = None if convert else sha256
|
||||||
asset.size_bytes = actual_size
|
asset.size_bytes = actual_size
|
||||||
asset.container = ext or None
|
asset.container = ext or None
|
||||||
asset.vcodec = vcodec
|
asset.vcodec = vcodec
|
||||||
@@ -108,12 +123,12 @@ def ingest_upload(
|
|||||||
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
||||||
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
||||||
# server side even for a codec the browser can't play).
|
# server side even for a codec the browser can't play).
|
||||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
asset.poster_path = editmod.ensure_poster(asset, settings.files_root)
|
||||||
|
|
||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
job_kind="upload",
|
job_kind=JobKind.UPLOAD,
|
||||||
source_kind="upload",
|
source_kind="upload",
|
||||||
source_ref=ref,
|
source_ref=ref,
|
||||||
profile_snapshot={},
|
profile_snapshot={},
|
||||||
@@ -123,7 +138,14 @@ def ingest_upload(
|
|||||||
queue_pos=service.next_queue_pos(db),
|
queue_pos=service.next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
|
folders.place(db, job)
|
||||||
|
if folder_id is not None:
|
||||||
|
job.folder_id = folders.upload_target(db, user_id, folder_id).id
|
||||||
|
if not convert:
|
||||||
|
folders.stamp_done(db, job)
|
||||||
service.incr_ref_count(db, asset.id)
|
service.incr_ref_count(db, asset.id)
|
||||||
|
db.flush()
|
||||||
|
audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -139,7 +161,7 @@ def ingest_upload(
|
|||||||
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
||||||
# when none was generated (audio/file, or a video whose poster never started).
|
# when none was generated (audio/file, or a video whose poster never started).
|
||||||
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
||||||
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
storage.abs_path(settings.files_root, leaked).unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
db.refresh(job)
|
db.refresh(job)
|
||||||
return job
|
return job
|
||||||
|
|||||||
+3
-1
@@ -690,7 +690,9 @@ _POLLED_ROUTES = frozenset(
|
|||||||
"/api/me/notifications/unread_count",
|
"/api/me/notifications/unread_count",
|
||||||
"/api/downloads",
|
"/api/downloads",
|
||||||
"/api/downloads/index",
|
"/api/downloads/index",
|
||||||
"/api/downloads/usage",
|
"/api/files",
|
||||||
|
"/api/files/folders",
|
||||||
|
"/api/files/usage",
|
||||||
"/api/sync/my-status",
|
"/api/sync/my-status",
|
||||||
"/api/admin/scheduler",
|
"/api/admin/scheduler",
|
||||||
"/api/admin/downloads",
|
"/api/admin/downloads",
|
||||||
|
|||||||
+22
-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 import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
|
||||||
from app.config import settings # noqa: E402 — logging is configured first
|
from app.config import settings # noqa: E402 — logging is configured first
|
||||||
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
||||||
|
from app.downloads import service as download_service # noqa: E402 — logging is configured first
|
||||||
|
from app.downloads import storage # noqa: E402 — logging is configured first
|
||||||
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
|
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
|
||||||
from app.plex import subtitles as plex_subtitles # noqa: E402 — logging is configured first
|
from app.plex import subtitles as plex_subtitles # noqa: E402 — logging is configured first
|
||||||
from app.routes import ( # noqa: E402 — logging is configured first
|
from app.routes import ( # noqa: E402 — logging is configured first
|
||||||
@@ -42,6 +44,7 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
|||||||
discover as discover_routes,
|
discover as discover_routes,
|
||||||
downloads,
|
downloads,
|
||||||
feed,
|
feed,
|
||||||
|
files as files_routes,
|
||||||
health,
|
health,
|
||||||
me,
|
me,
|
||||||
notifications,
|
notifications,
|
||||||
@@ -63,6 +66,10 @@ from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 —
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
log.info("Siftlode starting up")
|
log.info("Siftlode starting up")
|
||||||
|
problem = storage.files_root_problem(settings.files_root)
|
||||||
|
if problem:
|
||||||
|
# The rest of the app still serves; the files library cannot (the worker refuses to start).
|
||||||
|
log.critical("The files library is not reachable: %s. See docs/self-hosting.md.", problem)
|
||||||
# The stored copy of the log (log_events). The entrypoint has already migrated the schema.
|
# The stored copy of the log (log_events). The entrypoint has already migrated the schema.
|
||||||
logsink.start()
|
logsink.start()
|
||||||
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
|
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
|
||||||
@@ -78,6 +85,18 @@ async def lifespan(app: FastAPI):
|
|||||||
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
||||||
# and `%s` of the exception does not say which of them raised.
|
# and `%s` of the exception does not say which of them raised.
|
||||||
log.exception("could not read the stored log level; staying at %s", current_level())
|
log.exception("could not read the stored log level; staying at %s", current_level())
|
||||||
|
if not problem:
|
||||||
|
try:
|
||||||
|
left = download_service.library_left_behind(db, settings.files_root)
|
||||||
|
except Exception: # a database not reachable yet: the check is a hint, never a gate
|
||||||
|
log.exception("could not check the files library against its database")
|
||||||
|
else:
|
||||||
|
if left:
|
||||||
|
log.critical(
|
||||||
|
"The files library is empty but the database names files: the volume "
|
||||||
|
"mounted at %s is not the one that holds them. See docs/self-hosting.md.",
|
||||||
|
settings.files_root,
|
||||||
|
)
|
||||||
if not state.is_configured(db):
|
if not state.is_configured(db):
|
||||||
token = state.rotate_setup_token(db)
|
token = state.rotate_setup_token(db)
|
||||||
url = f"{settings.app_base}/setup?token={token}"
|
url = f"{settings.app_base}/setup?token={token}"
|
||||||
@@ -267,7 +286,8 @@ async def static_cache_headers(request, call_next):
|
|||||||
# Baseline security response headers (SEC-1). A minimal, safe set that needs no per-route tuning:
|
# Baseline security response headers (SEC-1). A minimal, safe set that needs no per-route tuning:
|
||||||
# nosniff stops MIME-confusion, the Referrer-Policy keeps the (private) app URL from leaking to
|
# nosniff stops MIME-confusion, the Referrer-Policy keeps the (private) app URL from leaking to
|
||||||
# external sites the user follows, `frame-ancestors 'none'` blocks click-jacking by refusing to be
|
# external sites the user follows, `frame-ancestors 'none'` blocks click-jacking by refusing to be
|
||||||
# iframed anywhere (we never embed our own pages; we embed OTHERS, which this does not affect), and
|
# iframed anywhere (we embed OTHERS, which this does not affect; the one own answer the app frames, a
|
||||||
|
# library file's preview, sets its own `frame-ancestors 'self'` — these are defaults, `setdefault`), and
|
||||||
# Permissions-Policy turns off powerful features the app never uses. `X-Frame-Options: DENY` duplicates
|
# Permissions-Policy turns off powerful features the app never uses. `X-Frame-Options: DENY` duplicates
|
||||||
# the frame-ancestors intent for legacy engines that never implemented CSP frame-ancestors (belt and
|
# the frame-ancestors intent for legacy engines that never implemented CSP frame-ancestors (belt and
|
||||||
# suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy
|
# suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy
|
||||||
@@ -316,6 +336,7 @@ app.include_router(saved_views.router)
|
|||||||
app.include_router(plex_routes.router)
|
app.include_router(plex_routes.router)
|
||||||
app.include_router(downloads.router)
|
app.include_router(downloads.router)
|
||||||
app.include_router(downloads.admin_router)
|
app.include_router(downloads.admin_router)
|
||||||
|
app.include_router(files_routes.router)
|
||||||
app.include_router(public_routes.router)
|
app.include_router(public_routes.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
|
|||||||
+96
-16
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
@@ -18,6 +18,7 @@ from sqlalchemy import (
|
|||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
@@ -38,7 +39,7 @@ def display_text(raw: str | None) -> str | None:
|
|||||||
blank -> None.
|
blank -> None.
|
||||||
|
|
||||||
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
|
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
|
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
|
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
|
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")
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
||||||
# account can't sign in by any method and its live sessions are rejected; the user is told
|
# account can't sign in by any method and its live sessions are rejected; the user is told
|
||||||
# why and pointed at the operator's contact.
|
# 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")
|
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
|
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
||||||
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
||||||
@@ -825,12 +827,13 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
links to this ready asset instantly — no re-download, no cost. Only the SOURCE download is
|
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.
|
shared; per-user edited derivatives (phase 2) are never cached here.
|
||||||
|
|
||||||
`rel_path` is relative to `settings.download_root` (the mount), so the physical filename is
|
`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
|
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
|
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
|
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)
|
uniformly for both catalog videos and ad-hoc URLs. `ref_count` (active jobs pointing here) frees
|
||||||
plus `last_access_at`/`expires_at` drive retention + LRU eviction in the GC job."""
|
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"
|
__tablename__ = "media_assets"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -846,10 +849,10 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(16), default="pending", server_default="pending", index=True
|
String(16), default="pending", server_default="pending", index=True
|
||||||
) # "pending" | "ready" | "error"
|
) # "pending" | "ready" | "error"
|
||||||
rel_path: Mapped[str | None] = mapped_column(Text) # relative to download_root
|
rel_path: Mapped[str | None] = mapped_column(Text) # relative to files_root
|
||||||
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
|
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
|
# 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.
|
# card / watch page / link preview can still show an image. Relative to files_root.
|
||||||
poster_path: Mapped[str | None] = mapped_column(Text)
|
poster_path: Mapped[str | None] = mapped_column(Text)
|
||||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
duration_s: Mapped[int | None] = mapped_column(Integer)
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
@@ -865,8 +868,6 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
# 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))
|
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||||||
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
|
||||||
gc_notified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
||||||
title: Mapped[str | None] = mapped_column(Text)
|
title: Mapped[str | None] = mapped_column(Text)
|
||||||
uploader: Mapped[str | None] = mapped_column(String(255))
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||||
@@ -876,13 +877,21 @@ class MediaAsset(Base, TimestampMixin):
|
|||||||
upload_date: Mapped[date | None] = mapped_column(Date)
|
upload_date: Mapped[date | None] = mapped_column(Date)
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
||||||
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
|
# 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
|
# 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).
|
# fall back to a URL derived from source_kind+source_ref).
|
||||||
source_webpage_url: Mapped[str | None] = mapped_column(Text)
|
source_webpage_url: Mapped[str | None] = mapped_column(Text)
|
||||||
error: 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")
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
expires_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):
|
class DownloadProfile(Base, TimestampMixin):
|
||||||
@@ -906,6 +915,47 @@ class DownloadProfile(Base, TimestampMixin):
|
|||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
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. 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"),
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
||||||
|
|
||||||
@@ -924,7 +974,13 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
||||||
|
|
||||||
__tablename__ = "download_jobs"
|
__tablename__ = "download_jobs"
|
||||||
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
|
__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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
@@ -933,8 +989,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
)
|
)
|
||||||
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
source_ref: Mapped[str] = mapped_column(String(512))
|
source_ref: Mapped[str] = mapped_column(String(512))
|
||||||
# "download" (default), "edit" (a per-user trim/crop derivative of another job) or "upload" (a
|
# "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`).
|
# 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")
|
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).
|
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
||||||
source_job_id: Mapped[int | None] = mapped_column(
|
source_job_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -943,7 +1000,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
source_asset_id: Mapped[int | None] = mapped_column(
|
source_asset_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("media_assets.id", ondelete="SET NULL")
|
ForeignKey("media_assets.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}}.
|
# 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)
|
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||||
profile_id: Mapped[int | None] = mapped_column(
|
profile_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||||
@@ -970,6 +1029,27 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
# The host-specific failure-catalog entry on top of the category (`facebook.login_wall`), or NULL.
|
# 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
|
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")
|
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):
|
class DownloadShare(Base, TimestampMixin):
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
|
CACHE_DIR = Path(settings.files_root) / ".plex-img-cache"
|
||||||
|
|||||||
@@ -3,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
|
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||||
router. Pure functions — no DB, no request state."""
|
router. Pure functions — no DB, no request state."""
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from app.models import PlexItem, PlexShow, PlexState
|
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
|
# 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
|
# 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
|
# `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 {
|
return {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
"type": "show",
|
"type": "show",
|
||||||
@@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
|||||||
"art": _art_url(sh.rating_key, sh.art_key),
|
"art": _art_url(sh.rating_key, sh.art_key),
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
"status": status,
|
"status": state.status,
|
||||||
|
"resumable": state.resumable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`)."""
|
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||||
|
|
||||||
from app.plex import people
|
from app.plex import people
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
|
|
||||||
def rich_detail(meta: dict) -> dict:
|
def rich_detail(meta: dict) -> dict:
|
||||||
@@ -42,7 +43,7 @@ def rich_detail(meta: dict) -> dict:
|
|||||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||||
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
||||||
"studio": meta.get("studio"),
|
"studio": meta.get("studio"),
|
||||||
"tagline": meta.get("tagline"),
|
"tagline": plain_text(meta.get("tagline")),
|
||||||
"cast": people.cast_from_meta(meta),
|
"cast": people.cast_from_meta(meta),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
|
|||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex.client import PlexClient
|
from app.plex.client import PlexClient
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -109,8 +110,8 @@ def _overlay_pass(
|
|||||||
for g, meta in hu_by_guid.items():
|
for g, meta in hu_by_guid.items():
|
||||||
targets = by_guid[g]
|
targets = by_guid[g]
|
||||||
title_hu = (meta.get("title") or "").strip() or None
|
title_hu = (meta.get("title") or "").strip() or None
|
||||||
summary_hu = meta.get("summary")
|
summary_hu = plain_text(meta.get("summary"))
|
||||||
tagline_hu = (meta.get("tagline") or "").strip() or None
|
tagline_hu = plain_text(meta.get("tagline"))
|
||||||
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
|
||||||
for row in targets:
|
for row in targets:
|
||||||
row.title_hu = title_hu
|
row.title_hu = title_hu
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.models import PlexItem, PlexState
|
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
|
# 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.
|
# 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"
|
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:
|
def rollup(cards: list[dict]) -> dict:
|
||||||
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
"""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
|
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:
|
if not show_ids:
|
||||||
return {}
|
return {}
|
||||||
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
|
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]:
|
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
|
"""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 ()`)."""
|
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
||||||
if not item_ids:
|
if not item_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ from app.config import settings
|
|||||||
from app.downloads.edit import encode_thread_cap
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths
|
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__)
|
log = logs.get_logger(__name__)
|
||||||
_HLS_ROOT = (
|
_HLS_ROOT = (
|
||||||
Path(settings.plex_hls_dir)
|
Path(settings.plex_hls_dir)
|
||||||
if 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
|
_SEG_SECONDS = 6
|
||||||
# Cold-start warm-up. A freshly-spawned ffmpeg has produced only the first segment (or two) by the
|
# 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
|
# 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
|
# (_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.
|
# 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).
|
# 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.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.genres import hu_genres
|
from app.plex.genres import hu_genres
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
# Codecs/containers a browser can play in a native <video> without any transcoding.
|
# 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
|
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||||
# own encoder).
|
# own encoder).
|
||||||
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
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
|
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
|
||||||
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
|
||||||
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
# 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(
|
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.
|
# whole library would drown the sync's own lines.
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
depth = None
|
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 depth, hdr
|
||||||
return None, False
|
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).
|
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
|
||||||
row.guid = _canonical_guid(meta) or row.guid
|
row.guid = _canonical_guid(meta) or row.guid
|
||||||
row.title = (meta.get("title") or "").strip() or "Untitled"
|
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.year = meta.get("year")
|
||||||
row.duration_s = _dur_s(meta)
|
row.duration_s = _dur_s(meta)
|
||||||
row.thumb_key = meta.get("thumb")
|
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
|
_canonical_guid(meta) or sh.guid
|
||||||
) # EN<->HU pairing key; keep prior if listing has none
|
) # EN<->HU pairing key; keep prior if listing has none
|
||||||
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
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.year = meta.get("year")
|
||||||
sh.thumb_key = meta.get("thumb")
|
sh.thumb_key = meta.get("thumb")
|
||||||
sh.art_key = meta.get("art")
|
sh.art_key = meta.get("art")
|
||||||
@@ -659,7 +662,7 @@ def _upsert_collection(
|
|||||||
db.add(col)
|
db.add(col)
|
||||||
col.library_id = lib_id
|
col.library_id = lib_id
|
||||||
col.title = (meta.get("title") or "").strip() or "Untitled"
|
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.thumb_key = meta.get("thumb")
|
||||||
col.child_count = meta.get("childCount")
|
col.child_count = meta.get("childCount")
|
||||||
col.smart = str(meta.get("smart")) == "1"
|
col.smart = str(meta.get("smart")) == "1"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
|
|||||||
one existence-leak policy.
|
one existence-leak policy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import IO, Any, TypeVar
|
from typing import Any, Protocol, TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -122,8 +122,14 @@ async def capped_body(request: Request, limit: int) -> bytes:
|
|||||||
return bytes(body)
|
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(
|
async def capped_stream_to(
|
||||||
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
|
request: Request, out: ByteSink, limit: int, refusal: HTTPException
|
||||||
) -> int:
|
) -> int:
|
||||||
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
|
"""`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.
|
straight to `out` instead of accumulated, returning the size.
|
||||||
|
|||||||
@@ -484,7 +484,8 @@ def set_user_suspended(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
|
"""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
|
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||||
sign-in)."""
|
sign-in)."""
|
||||||
suspended = body.suspended
|
suspended = body.suspended
|
||||||
|
|||||||
+136
-475
@@ -2,21 +2,18 @@
|
|||||||
|
|
||||||
All routes are behind `current_user`; admin routes add `admin_user`. Live job state is polled by the
|
All routes are behind `current_user`; admin routes add `admin_user`. Live job state is polled by the
|
||||||
frontend (the worker is a separate process); this module never downloads — it enqueues, manages job
|
frontend (the worker is a separate process); this module never downloads — it enqueues, manages job
|
||||||
state, serves finished files (range-aware, with the user's custom display name), and shares them.
|
state, and shares finished files. The library itself — listing, renaming, moving, deleting and
|
||||||
|
serving a done file, and uploading one — is `routes.files`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import tempfile
|
from typing import Literal
|
||||||
from collections.abc import AsyncIterator, Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from urllib.parse import parse_qs, urlparse
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -26,14 +23,14 @@ from app.collation import collated
|
|||||||
from app import audit, sysconfig
|
from app import audit, sysconfig
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db, release_read_connection
|
from app.db import get_db
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
from app.downloads import credentials as dlcreds
|
from app.downloads import credentials as dlcreds
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import quota, service, storage
|
from app.downloads import quota, service, storage
|
||||||
from app.downloads import upload as uploadmod
|
|
||||||
from app.downloads import urlguard
|
from app.downloads import urlguard
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
@@ -47,15 +44,24 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.routes._common import (
|
from app.routes._common import (
|
||||||
ProgressIn,
|
ProgressIn,
|
||||||
capped_stream_to,
|
|
||||||
get_or_404,
|
get_or_404,
|
||||||
kept_position,
|
kept_position,
|
||||||
not_found,
|
not_found,
|
||||||
owned_or_404,
|
owned_or_404,
|
||||||
)
|
)
|
||||||
|
from app.routes.files import (
|
||||||
|
MAX_LIST,
|
||||||
|
accessible_job,
|
||||||
|
assets_for,
|
||||||
|
own_job_for_update,
|
||||||
|
positions_for,
|
||||||
|
quota_detail,
|
||||||
|
serialize,
|
||||||
|
serialize_job,
|
||||||
|
source_keys,
|
||||||
|
)
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
@@ -63,11 +69,6 @@ admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"]
|
|||||||
|
|
||||||
_VIDEO_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
_VIDEO_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
||||||
|
|
||||||
# Cap the per-user job listings so a heavy library can't return an unbounded result set. The
|
|
||||||
# Download Center renders newest-first; 500 is far beyond any real manual-download history (and
|
|
||||||
# matches the admin listing's own cap).
|
|
||||||
_MAX_LIST = 500
|
|
||||||
|
|
||||||
|
|
||||||
# --- source + serialization ----------------------------------------------------------------
|
# --- source + serialization ----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -111,115 +112,6 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
|||||||
raise refused(400, "invalid_link")
|
raise refused(400, "invalid_link")
|
||||||
|
|
||||||
|
|
||||||
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),
|
|
||||||
"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,
|
|
||||||
"expired": job.asset_id is None and job.status == JobStatus.DONE,
|
|
||||||
"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,
|
|
||||||
"expires_at": iso(asset.expires_at),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _assets_for(db: Session, jobs: list[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(
|
|
||||||
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
return {job_id: pos for job_id, pos in rows}
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
||||||
return _serialize(job, asset)
|
|
||||||
|
|
||||||
|
|
||||||
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
||||||
"""The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
|
||||||
or writes the status those decide on (pause). The job is re-read under a row lock, so 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.
|
|
||||||
|
|
||||||
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
|
||||||
(`service.finalize_if_running`), so the two can never deadlock."""
|
|
||||||
while True:
|
|
||||||
asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").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:
|
|
||||||
raise not_found("download")
|
|
||||||
if job.asset_id == asset_id:
|
|
||||||
return job
|
|
||||||
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
|
||||||
|
|
||||||
|
|
||||||
# --- profiles ------------------------------------------------------------------------------
|
# --- profiles ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -429,122 +321,8 @@ def enqueue_download(
|
|||||||
try:
|
try:
|
||||||
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||||
return _serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SpooledUpload:
|
|
||||||
"""What the body dependency hands the handler: the bytes already on disk, plus the two names
|
|
||||||
that came alongside them."""
|
|
||||||
|
|
||||||
path: Path
|
|
||||||
size: int
|
|
||||||
user_id: int
|
|
||||||
filename: str
|
|
||||||
display_name: str | 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 ""
|
|
||||||
|
|
||||||
|
|
||||||
async def spool_upload(
|
|
||||||
request: Request,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> AsyncIterator[SpooledUpload]:
|
|
||||||
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
|
||||||
|
|
||||||
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
|
|
||||||
naming `current_user` here makes the auth check a precondition of reading the body at all — a
|
|
||||||
stranger 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 = user.id
|
|
||||||
# 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 or a header in hand: touching an ORM attribute below would reload `user` and
|
|
||||||
# check a connection straight back out.
|
|
||||||
release_read_connection(db)
|
|
||||||
staging = Path(settings.download_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:
|
|
||||||
size = await capped_stream_to(
|
|
||||||
request,
|
|
||||||
out,
|
|
||||||
cap,
|
|
||||||
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
|
|
||||||
)
|
|
||||||
yield SpooledUpload(
|
|
||||||
path=tmp_path,
|
|
||||||
size=size,
|
|
||||||
user_id=user_id,
|
|
||||||
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
|
|
||||||
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
|
||||||
def upload_download(
|
|
||||||
upload: SpooledUpload = Depends(spool_upload),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Ingest a local file into the caller's library. Any file type is accepted; media
|
|
||||||
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
|
|
||||||
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
|
|
||||||
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
|
|
||||||
probes + places it and returns its job — `done`, or `queued` for the worker's phone-safe
|
|
||||||
conversion (`downloads.upload`).
|
|
||||||
|
|
||||||
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. The streaming half is async because
|
|
||||||
reading the body is; keeping them in separate callables is what lets each run where it belongs."""
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
except quota.QuotaExceeded as e:
|
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
|
||||||
return _serialize_job(db, job)
|
|
||||||
|
|
||||||
|
|
||||||
class EnqueueEditIn(BaseModel):
|
class EnqueueEditIn(BaseModel):
|
||||||
@@ -562,7 +340,7 @@ def enqueue_edit(
|
|||||||
The source may be the user's own download OR one shared with them — either way the resulting
|
The source may be the user's own download OR one shared with them — either way the resulting
|
||||||
clip is a NEW per-user derivative in the editor's own library (counts against their quota); the
|
clip is a NEW per-user derivative in the editor's own library (counts against their quota); the
|
||||||
source file is only read, never modified, so editing a shared video is safe."""
|
source file is only read, never modified, so editing a shared video is safe."""
|
||||||
source_job = _accessible_job(db, user, body.source_job_id)
|
source_job = accessible_job(db, user, body.source_job_id)
|
||||||
if source_job.status != JobStatus.DONE or source_job.asset_id is None:
|
if source_job.status != JobStatus.DONE or source_job.asset_id is None:
|
||||||
raise refused(409, "edit_unfinished")
|
raise refused(409, "edit_unfinished")
|
||||||
spec = body.edit_spec
|
spec = body.edit_spec
|
||||||
@@ -570,59 +348,94 @@ def enqueue_edit(
|
|||||||
try:
|
try:
|
||||||
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||||
except service.EditError as e:
|
except service.EditError as e:
|
||||||
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
|
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
|
||||||
return _serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
def _quota_detail(e: quota.QuotaExceeded) -> dict:
|
class ClipCropIn(BaseModel):
|
||||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
x: float
|
||||||
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
|
y: float
|
||||||
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
w: float
|
||||||
|
h: float
|
||||||
|
aspect: float
|
||||||
|
|
||||||
|
|
||||||
|
class EnqueueClipIn(BaseModel):
|
||||||
|
source: Literal["youtube", "plex"]
|
||||||
|
# The YouTube video id or the Plex rating key.
|
||||||
|
ref: str = Field(max_length=32)
|
||||||
|
start_s: float
|
||||||
|
end_s: float
|
||||||
|
# Plex only: which of the file's audio tracks (0-based, in file order) — the one being heard.
|
||||||
|
audio_track: int | None = None
|
||||||
|
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
|
||||||
|
crop: ClipCropIn | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clip")
|
||||||
|
def enqueue_clip(
|
||||||
|
body: EnqueueClipIn, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""Queue an ad-hoc clip: the marked range of a YouTube video or a Plex item, cut straight from
|
||||||
|
the source into a new asset in this user's library (downloads/clip)."""
|
||||||
|
try:
|
||||||
|
crop = body.crop.model_dump() if body.crop else None
|
||||||
|
spec = clipmod.normalize(
|
||||||
|
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
|
||||||
|
)
|
||||||
|
display_name = display_text(body.display_name)
|
||||||
|
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
||||||
|
except clipmod.ClipError as e:
|
||||||
|
status = 404 if e.reason == "not_found" else 422
|
||||||
|
detail = {"code": "clip", "reason": e.reason, **e.params}
|
||||||
|
raise HTTPException(status_code=status, detail=detail) from e
|
||||||
|
except quota.QuotaExceeded as e:
|
||||||
|
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||||
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
|
# How long a finished job stays in the queue: the tray polls it from every page and must see a job
|
||||||
|
# turn done (its "Done · Open" row and the done notice) — the finished file itself is the library's.
|
||||||
|
# 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.
|
||||||
|
QUEUE_RECENT = timedelta(minutes=10)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
"""The queue: what is waiting, running, paused or failed, and what finished a moment ago."""
|
||||||
|
recent = datetime.now(timezone.utc) - QUEUE_RECENT
|
||||||
jobs = (
|
jobs = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(DownloadJob)
|
select(DownloadJob)
|
||||||
.where(DownloadJob.user_id == user.id)
|
.where(
|
||||||
|
DownloadJob.user_id == user.id,
|
||||||
|
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())
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
||||||
.limit(_MAX_LIST)
|
.limit(MAX_LIST)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
assets = assets_for(db, jobs)
|
||||||
positions = _positions_for(db, user, jobs)
|
positions = positions_for(db, user, jobs)
|
||||||
|
sources = source_keys(db, jobs)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
**serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
|
||||||
"position_seconds": positions.get(j.id, 0),
|
"position_seconds": positions.get(j.id, 0),
|
||||||
|
"source_key": sources[j.id],
|
||||||
}
|
}
|
||||||
for j in jobs
|
for j in jobs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage")
|
|
||||||
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
||||||
"""The caller's download 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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||||
_INDEX_RANK: dict[str, int] = {
|
_INDEX_RANK: dict[str, int] = {
|
||||||
JobStatus.DONE: 4,
|
JobStatus.DONE: 4,
|
||||||
@@ -649,106 +462,22 @@ def my_download_index(user: User = Depends(current_user), db: Session = Depends(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shared")
|
|
||||||
def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
|
||||||
jobs = (
|
|
||||||
db.execute(
|
|
||||||
select(DownloadJob)
|
|
||||||
.join(DownloadShare, DownloadShare.job_id == DownloadJob.id)
|
|
||||||
.where(DownloadShare.to_user_id == user.id)
|
|
||||||
.order_by(DownloadShare.created_at.desc(), DownloadJob.id.desc())
|
|
||||||
.limit(_MAX_LIST)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assets = _assets_for(db, jobs)
|
|
||||||
positions = _positions_for(db, user, jobs)
|
|
||||||
out = []
|
|
||||||
for j in jobs:
|
|
||||||
data = _serialize(j, assets.get(j.asset_id))
|
|
||||||
data["shared"] = True
|
|
||||||
data["position_seconds"] = positions.get(j.id, 0)
|
|
||||||
out.append(data)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
_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
|
|
||||||
# The third site in this module handing user input to `urlparse`, which raises on a malformed
|
|
||||||
# authority. This one 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 UpdateDownloadMetaIn(BaseModel):
|
|
||||||
# PATCH display-only overrides; presence (model_fields_set) decides what's touched. A field
|
|
||||||
# set to null/"" clears the override (falls back to the on-disk system value).
|
|
||||||
display_name: str | None = None
|
|
||||||
display_uploader: str | None = None
|
|
||||||
display_uploader_url: str | None = None
|
|
||||||
extra_links: list | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{job_id}")
|
|
||||||
def update_download_meta(
|
|
||||||
job_id: int,
|
|
||||||
body: UpdateDownloadMetaIn,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
|
|
||||||
All are display-only overrides — the on-disk file keeps its system-decided, id-bound name."""
|
|
||||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
|
||||||
sent = body.model_fields_set
|
|
||||||
if "display_name" in sent:
|
|
||||||
job.display_name = display_text(body.display_name)
|
|
||||||
if "display_uploader" in sent:
|
|
||||||
job.display_uploader = display_text(body.display_uploader)
|
|
||||||
if "display_uploader_url" in sent:
|
|
||||||
job.display_uploader_url = _clean_url(body.display_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]
|
|
||||||
job.extra_links = cleaned or None
|
|
||||||
db.commit()
|
|
||||||
return _serialize_job(db, job)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/pause")
|
@router.post("/{job_id}/pause")
|
||||||
def pause_download(
|
def pause_download(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job_for_update(db, user, job_id)
|
job = own_job_for_update(db, user, job_id)
|
||||||
service.pause(db, job)
|
service.pause(db, job)
|
||||||
return _serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/resume")
|
@router.post("/{job_id}/resume")
|
||||||
def resume_download(
|
def resume_download(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job_for_update(db, user, job_id)
|
job = own_job_for_update(db, user, job_id)
|
||||||
service.resume(db, job)
|
service.resume(db, job)
|
||||||
return _serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/redownload")
|
@router.post("/{job_id}/redownload")
|
||||||
@@ -767,90 +496,24 @@ def redownload_download(
|
|||||||
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
under the current format/naming rules — never silently reuses a shared cached file — and (b)
|
||||||
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
never deletes a file another job/user still shares: the old asset is only removed if THIS job
|
||||||
was its last holder."""
|
was its last holder."""
|
||||||
job = _own_job_for_update(db, user, job_id)
|
job = own_job_for_update(db, user, job_id)
|
||||||
if job.job_kind in ("edit", "upload"):
|
if job.job_kind in NOT_REFETCHABLE:
|
||||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
# None has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
||||||
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
|
# source, an ad-hoc clip is a range of a source the clip itself does not re-fetch.
|
||||||
raise refused(409, "not_redownloadable")
|
raise refused(409, "not_redownloadable")
|
||||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
|
||||||
raise refused(409, "still_downloading")
|
raise refused(409, "still_downloading")
|
||||||
service.redownload(db, job)
|
service.redownload(db, job)
|
||||||
return _serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/cancel")
|
@router.post("/{job_id}/cancel")
|
||||||
def cancel_download(
|
def cancel_download(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job_for_update(db, user, job_id)
|
job = own_job_for_update(db, user, job_id)
|
||||||
service.cancel(db, job)
|
service.cancel(db, job)
|
||||||
return _serialize_job(db, job)
|
return serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}")
|
|
||||||
def delete_download(
|
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
||||||
) -> dict:
|
|
||||||
job = _own_job_for_update(db, user, job_id)
|
|
||||||
service.delete(db, job)
|
|
||||||
return {"deleted": job_id}
|
|
||||||
|
|
||||||
|
|
||||||
# --- file download (range-aware, custom display name) --------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
|
||||||
job = db.get(DownloadJob, job_id)
|
|
||||||
if job is None:
|
|
||||||
raise not_found("download")
|
|
||||||
if job.user_id == user.id:
|
|
||||||
return job
|
|
||||||
shared = db.execute(
|
|
||||||
select(DownloadShare.id).where(
|
|
||||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if shared is None:
|
|
||||||
raise not_found("download")
|
|
||||||
return job
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{job_id}/file")
|
|
||||||
def download_file(
|
|
||||||
job_id: int,
|
|
||||||
request: Request,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
job = _accessible_job(db, user, job_id)
|
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
|
||||||
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.download_root, asset.rel_path)
|
|
||||||
if path is None:
|
|
||||||
raise not_found("file")
|
|
||||||
|
|
||||||
filename = storage.download_filename(
|
|
||||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
|
||||||
)
|
|
||||||
|
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
|
||||||
db.commit()
|
|
||||||
# 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).
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
return FileResponse(path, filename=filename, media_type=media_type)
|
|
||||||
|
|
||||||
|
|
||||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||||
@@ -865,7 +528,7 @@ def set_library_progress(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
|
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
|
||||||
one shared with them). A position too early or too close to the end clears it instead."""
|
one shared with them). A position too early or too close to the end clears it instead."""
|
||||||
_accessible_job(db, user, job_id)
|
accessible_job(db, user, job_id)
|
||||||
position = kept_position(body)
|
position = kept_position(body)
|
||||||
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
|
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
|
||||||
if not position:
|
if not position:
|
||||||
@@ -897,11 +560,11 @@ def storyboard_meta(
|
|||||||
|
|
||||||
Best-effort: for a very long/high-res source the sprite may not generate in time — then
|
Best-effort: for a very long/high-res source the sprite may not generate in time — then
|
||||||
`available` is False and the editor shows a plain timeline."""
|
`available` is False and the editor shows a plain timeline."""
|
||||||
job = _accessible_job(db, user, job_id)
|
job = accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||||
raise refused(409, "download_not_ready")
|
raise refused(409, "download_not_ready")
|
||||||
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
meta = editmod.ensure_storyboard(asset, settings.files_root)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
return {"available": False}
|
return {"available": False}
|
||||||
db.commit() # persist storyboard_path if it was just generated
|
db.commit() # persist storyboard_path if it was just generated
|
||||||
@@ -918,11 +581,11 @@ def storyboard_meta(
|
|||||||
@router.get("/{job_id}/poster.jpg")
|
@router.get("/{job_id}/poster.jpg")
|
||||||
def poster_image(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
def poster_image(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
"""The locally-generated poster frame for a download whose source had no thumbnail."""
|
"""The locally-generated poster frame for a download whose source had no thumbnail."""
|
||||||
job = _accessible_job(db, user, job_id)
|
job = accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.poster_path:
|
if asset is None or not asset.poster_path:
|
||||||
raise not_found("poster")
|
raise not_found("poster")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise not_found("poster")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
@@ -932,11 +595,11 @@ def poster_image(job_id: int, user: User = Depends(current_user), db: Session =
|
|||||||
def storyboard_image(
|
def storyboard_image(
|
||||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
job = _accessible_job(db, user, job_id)
|
job = accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.storyboard_path:
|
if asset is None or not asset.storyboard_path:
|
||||||
raise not_found("filmstrip")
|
raise not_found("filmstrip")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
path = storage.safe_abs_path(settings.files_root, asset.storyboard_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise not_found("filmstrip")
|
raise not_found("filmstrip")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
@@ -1025,24 +688,6 @@ def unshare_download(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@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 download 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)."""
|
|
||||||
row = db.execute(
|
|
||||||
select(DownloadShare).where(
|
|
||||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if row is not None:
|
|
||||||
db.delete(row)
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True, "removed": job_id}
|
|
||||||
|
|
||||||
|
|
||||||
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -1188,19 +833,20 @@ def admin_list_downloads(
|
|||||||
db.execute(
|
db.execute(
|
||||||
select(DownloadJob)
|
select(DownloadJob)
|
||||||
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
||||||
.limit(_MAX_LIST)
|
.limit(MAX_LIST)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
assets = _assets_for(db, jobs)
|
assets = assets_for(db, jobs)
|
||||||
emails = {
|
emails = {
|
||||||
u.id: u.email
|
u.id: u.email
|
||||||
for u in db.execute(select(User).where(User.id.in_({j.user_id for j in jobs}))).scalars()
|
for u in db.execute(select(User).where(User.id.in_({j.user_id for j in jobs}))).scalars()
|
||||||
}
|
}
|
||||||
out = []
|
out = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
data = _serialize(j, assets.get(j.asset_id))
|
data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None)
|
||||||
|
data["user_id"] = j.user_id
|
||||||
data["user_email"] = emails.get(j.user_id)
|
data["user_email"] = emails.get(j.user_id)
|
||||||
out.append(data)
|
out.append(data)
|
||||||
return out
|
return out
|
||||||
@@ -1214,24 +860,39 @@ def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_d
|
|||||||
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
||||||
).one()
|
).one()
|
||||||
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
||||||
per_user = db.execute(
|
# EVERY user, not only those holding a file: a quota is set before anyone downloads.
|
||||||
select(DownloadJob.user_id, func.count(func.distinct(DownloadJob.asset_id)))
|
emails: dict[int, str] = {
|
||||||
.where(DownloadJob.asset_id.is_not(None))
|
uid: email
|
||||||
.group_by(DownloadJob.user_id)
|
for uid, email in db.execute(select(User.id, User.email).order_by(User.email)).tuples()
|
||||||
).all()
|
|
||||||
emails = {
|
|
||||||
u.id: u.email
|
|
||||||
for u in db.execute(select(User).where(User.id.in_([uid for uid, _ in per_user]))).scalars()
|
|
||||||
}
|
}
|
||||||
|
uids = list(emails)
|
||||||
|
# What each one's Files lists: their finished files.
|
||||||
|
files: dict[int, int] = {
|
||||||
|
uid: n
|
||||||
|
for uid, n in db.execute(
|
||||||
|
select(DownloadJob.user_id, func.count())
|
||||||
|
.where(DownloadJob.status == JobStatus.DONE)
|
||||||
|
.group_by(DownloadJob.user_id)
|
||||||
|
).tuples()
|
||||||
|
}
|
||||||
|
|
||||||
|
def row(uid: int) -> dict:
|
||||||
|
lim = quota.resolve(db, uid)
|
||||||
|
return {
|
||||||
|
"user_id": uid,
|
||||||
|
"email": emails[uid],
|
||||||
|
"footprint_bytes": quota.footprint(db, uid),
|
||||||
|
"files": files.get(uid, 0),
|
||||||
|
"max_bytes": lim.max_bytes,
|
||||||
|
"unlimited": lim.unlimited,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"files_on_disk": on_disk[0],
|
"files_on_disk": on_disk[0],
|
||||||
"total_bytes": int(on_disk[1]),
|
"total_bytes": int(on_disk[1]),
|
||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
"total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
|
||||||
"per_user": [
|
"per_user": [row(uid) for uid in uids],
|
||||||
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
|
|
||||||
for uid, _ in per_user
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,872 @@
|
|||||||
|
"""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 locks, the upload spool —
|
||||||
|
because a download and a library file are one row at two moments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from collections.abc import AsyncIterator, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import 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 import func, or_, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit, sysconfig
|
||||||
|
from app.audit import AuditAction
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.collation import collated
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_db, release_read_connection
|
||||||
|
from app.downloads import folders, preview, 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,
|
||||||
|
DownloadJob,
|
||||||
|
DownloadShare,
|
||||||
|
FileFolder,
|
||||||
|
LibraryState,
|
||||||
|
MediaAsset,
|
||||||
|
User,
|
||||||
|
display_text,
|
||||||
|
)
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import capped_stream_to, not_found, owned_or_404
|
||||||
|
from app.utils import iso, like_escape
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
|
# Cap the 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
|
||||||
|
# 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 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(
|
||||||
|
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {job_id: pos for job_id, pos in rows}
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
return serialize(job, asset)
|
||||||
|
|
||||||
|
|
||||||
|
def own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
|
"""The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
|
||||||
|
or writes the status those decide on (pause). The job is re-read under a row lock, so 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.
|
||||||
|
|
||||||
|
The asset is locked BEFORE the job: the order a finishing worker child takes them in
|
||||||
|
(`service.finalize_if_running`), so the two can never deadlock."""
|
||||||
|
while True:
|
||||||
|
asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").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:
|
||||||
|
raise not_found("download")
|
||||||
|
if job.asset_id == asset_id:
|
||||||
|
return job
|
||||||
|
db.rollback() # another request moved the job to another asset meanwhile: lock that one
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
size: int
|
||||||
|
sha256: str
|
||||||
|
user_id: int
|
||||||
|
filename: str
|
||||||
|
display_name: str | None
|
||||||
|
folder_id: int | 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, user_id: int, raw: str) -> int | None:
|
||||||
|
"""The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for
|
||||||
|
none."""
|
||||||
|
if not raw.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return folders.upload_target(db, user_id, int(raw), hold=False).id
|
||||||
|
except ValueError as e:
|
||||||
|
raise refused(409, "file_folder_gone") from e
|
||||||
|
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,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AsyncIterator[SpooledUpload]:
|
||||||
|
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
|
||||||
|
|
||||||
|
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
|
||||||
|
naming `current_user` here makes the auth check a precondition of reading the body at all — a
|
||||||
|
stranger 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 = user.id
|
||||||
|
# A folder the file may not go in is refused before the transfer, not after it.
|
||||||
|
folder_id = _upload_folder(db, user_id, 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 or a header in hand: touching an ORM attribute below would reload `user` 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,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
|
"""A file the caller may read: their own, or one shared with them."""
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise not_found("download")
|
||||||
|
if job.user_id == user.id:
|
||||||
|
return job
|
||||||
|
shared = db.execute(
|
||||||
|
select(DownloadShare.id).where(
|
||||||
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if shared is None:
|
||||||
|
raise not_found("download")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
# --- folders -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_folder(folder: FileFolder, count: int, size: int) -> 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/folders")
|
||||||
|
def list_folders(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
"""The caller's folders with how many files each holds and their size. A source folder its
|
||||||
|
files have all left stays (count 0): the client decides whether to show it."""
|
||||||
|
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
FileFolder,
|
||||||
|
func.count(DownloadJob.id),
|
||||||
|
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
||||||
|
)
|
||||||
|
.outerjoin(DownloadJob, done)
|
||||||
|
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
|
.where(FileFolder.user_id == user.id)
|
||||||
|
.group_by(FileFolder.id)
|
||||||
|
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
|
||||||
|
).all()
|
||||||
|
return [_serialize_folder(f, count, int(size)) 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:
|
||||||
|
folder = FileFolder(user_id=user.id, kind=folders.FolderKind.OWN, name=_folder_name(body.name))
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.add(folder)
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError as e: # the unique (user, lower(name)): one folder per name
|
||||||
|
raise refused(409, "file_folder_name_taken") 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 = owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
||||||
|
sent = body.model_fields_set
|
||||||
|
if "name" in sent:
|
||||||
|
if folder.kind != folders.FolderKind.OWN:
|
||||||
|
raise refused(409, "file_folder_not_own")
|
||||||
|
folder.name = _folder_name(body.name or "")
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError as e:
|
||||||
|
raise refused(409, "file_folder_name_taken") 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 = _folder_totals(db, folder.id)
|
||||||
|
return _serialize_folder(folder, count, size)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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 = owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
||||||
|
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)."""
|
||||||
|
row = db.execute(
|
||||||
|
select(DownloadShare).where(
|
||||||
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
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",
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
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."""
|
||||||
|
stmt = (
|
||||||
|
select(DownloadJob)
|
||||||
|
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||||
|
.where(DownloadJob.status == JobStatus.DONE)
|
||||||
|
)
|
||||||
|
shared = scope == "shared"
|
||||||
|
if shared:
|
||||||
|
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
|
||||||
|
DownloadShare.to_user_id == user.id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(DownloadJob.user_id == user.id)
|
||||||
|
if folder_id is not None:
|
||||||
|
stmt = stmt.where(DownloadJob.folder_id == folder_id)
|
||||||
|
name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "")
|
||||||
|
term = q.strip()
|
||||||
|
if term:
|
||||||
|
like = f"%{like_escape(term)}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
name.ilike(like),
|
||||||
|
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 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.
|
||||||
|
key = {
|
||||||
|
"added": DownloadShare.created_at if shared else DownloadJob.ready_at,
|
||||||
|
"name": collated(name, lang_for_user(user)),
|
||||||
|
"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()
|
||||||
|
jobs = db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars().all()
|
||||||
|
assets = assets_for(db, jobs)
|
||||||
|
positions = positions_for(db, user, jobs)
|
||||||
|
shared_at = _shared_at(db, user, jobs) if shared else {}
|
||||||
|
sources = 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
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 _shared_at(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, datetime]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(DownloadShare.job_id, DownloadShare.created_at).where(
|
||||||
|
DownloadShare.to_user_id == user.id,
|
||||||
|
DownloadShare.job_id.in_([j.id for j in jobs]),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {job_id: at for job_id, at in rows}
|
||||||
|
|
||||||
|
|
||||||
|
_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 = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
|
sent = body.model_fields_set
|
||||||
|
if "name" in sent:
|
||||||
|
job.display_name = display_text(body.name)
|
||||||
|
if "uploader" in sent:
|
||||||
|
job.display_uploader = display_text(body.uploader)
|
||||||
|
if "uploader_url" in sent:
|
||||||
|
job.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]
|
||||||
|
job.extra_links = cleaned or None
|
||||||
|
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, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a file from the caller's library; the bytes go with their last holder."""
|
||||||
|
job = own_job_for_update(db, user, job_id)
|
||||||
|
audit.record(db, user.id, AuditAction.FILE_DELETE, target_type="file", target_id=job_id)
|
||||||
|
service.delete(db, job) # commits, 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,
|
||||||
|
)
|
||||||
|
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 = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
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}:"
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc) # 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, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
|
response = serve_file(db, accessible_job(db, user, 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")
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
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, user: User = Depends(current_user), 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, accessible_job(db, user, 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()
|
||||||
|
db.execute(
|
||||||
|
update(MediaAsset)
|
||||||
|
.where(MediaAsset.id == asset_id, MediaAsset.sha256.is_(None))
|
||||||
|
.values(sha256=digest)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"sha256": digest}
|
||||||
@@ -164,6 +164,7 @@ def unified_library(
|
|||||||
cast(null(), Integer).label("season_count"),
|
cast(null(), Integer).label("season_count"),
|
||||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
m_status.label("status"),
|
m_status.label("status"),
|
||||||
|
literal(False).label("resumable"),
|
||||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
# "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).
|
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||||
@@ -211,6 +212,8 @@ def unified_library(
|
|||||||
PlexShow.child_count.label("season_count"),
|
PlexShow.child_count.label("season_count"),
|
||||||
literal(0).label("position_seconds"),
|
literal(0).label("position_seconds"),
|
||||||
s_status.label("status"),
|
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
|
# 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.
|
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||||
agg.c.resumed_at.label("last_watched"),
|
agg.c.resumed_at.label("last_watched"),
|
||||||
@@ -276,6 +279,7 @@ def unified_library(
|
|||||||
"thumb": f"/api/plex/image/{r.rk}",
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
"season_count": r.season_count,
|
"season_count": r.season_count,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
|
"resumable": r.resumable,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex import sync as plex_sync
|
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.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||||
@@ -318,7 +318,7 @@ def collection_detail(
|
|||||||
if shows
|
if shows
|
||||||
else []
|
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}
|
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||||
|
|
||||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||||
@@ -359,7 +359,7 @@ def collection_detail(
|
|||||||
items = [
|
items = [
|
||||||
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
||||||
if m.kind == "movie"
|
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
|
for m in members
|
||||||
]
|
]
|
||||||
years = [m.year for m in members if m.year is not None]
|
years = [m.year for m in members if m.year is not None]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
|
|||||||
from app import logs
|
from app import logs
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.discover import queries
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexCollection,
|
PlexCollection,
|
||||||
PlexItem,
|
PlexItem,
|
||||||
@@ -28,7 +29,7 @@ from app.plex import people as plex_people
|
|||||||
from app.plex import rollup, subtitles
|
from app.plex import rollup, subtitles
|
||||||
from app.plex.query import title_col
|
from app.plex.query import title_col
|
||||||
from app.plex import tmdb as plex_tmdb
|
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.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
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:
|
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
|
"""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)."""
|
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:
|
if sh is None or rk in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(rk)
|
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:
|
if len(out) >= 20:
|
||||||
break
|
break
|
||||||
return out
|
return out
|
||||||
@@ -504,6 +523,7 @@ def item_detail(
|
|||||||
|
|
||||||
prev_id, next_id = _episode_nav(db, it)
|
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
|
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 {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
"kind": it.kind,
|
"kind": it.kind,
|
||||||
@@ -537,6 +557,9 @@ def item_detail(
|
|||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
|
"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,
|
"season_number": it.season_number,
|
||||||
"episode_number": it.episode_number,
|
"episode_number": it.episode_number,
|
||||||
"prev_id": prev_id,
|
"prev_id": prev_id,
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ from app.auth import current_user
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import PlexItem, PlexShow, PlexState, User
|
from app.models import PlexItem, PlexShow, PlexState, User
|
||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
from app.plex.cards import (
|
||||||
|
SHOW_NEW,
|
||||||
|
display_title,
|
||||||
|
leaf_card,
|
||||||
|
movie_card,
|
||||||
|
show_card,
|
||||||
|
)
|
||||||
from app.plex.query import (
|
from app.plex.query import (
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
apply_meta_filters,
|
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]:
|
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])
|
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(
|
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.
|
# `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()
|
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||||
sq = apply_show_state_filter(
|
sq = apply_show_state_filter(
|
||||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
|
||||||
|
prog, prog.c.show_id == PlexShow.id
|
||||||
|
),
|
||||||
prog.c.total,
|
prog.c.total,
|
||||||
prog.c.watched,
|
prog.c.watched,
|
||||||
prog.c.inprog,
|
prog.c.inprog,
|
||||||
@@ -243,8 +251,9 @@ def _continue_watching(
|
|||||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||||
.limit(RAIL_LIMIT)
|
.limit(RAIL_LIMIT)
|
||||||
).all()
|
).all()
|
||||||
for sh, resumed_at in srows:
|
for sh, resumed_at, total, watched, inprog in srows:
|
||||||
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
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)
|
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
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")
|
@router.get("/surprise")
|
||||||
def surprise(
|
def surprise(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
lang: str = "en",
|
lang: str = "en",
|
||||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
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(),
|
f: LibraryFilters = Depends(),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -419,6 +488,8 @@ def surprise(
|
|||||||
lang = _norm_lang(lang)
|
lang = _norm_lang(lang)
|
||||||
p = f.as_dict()
|
p = f.as_dict()
|
||||||
movie_ids, show_ids = _scope_libs(db, scope)
|
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)
|
per = min(limit, SURPRISE_PER_KIND)
|
||||||
movies = []
|
movies = []
|
||||||
if movie_ids:
|
if movie_ids:
|
||||||
@@ -432,23 +503,21 @@ def surprise(
|
|||||||
)
|
)
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
movies = (
|
movies = _draw(
|
||||||
db.execute(
|
db,
|
||||||
_movie_meta(
|
_movie_meta(
|
||||||
select(PlexItem).where(
|
select(PlexItem).where(
|
||||||
PlexItem.kind == "movie",
|
PlexItem.kind == "movie",
|
||||||
PlexItem.library_id.in_(movie_ids),
|
PlexItem.library_id.in_(movie_ids),
|
||||||
~watched_movie,
|
~watched_movie,
|
||||||
),
|
),
|
||||||
p,
|
p,
|
||||||
lang,
|
lang,
|
||||||
f,
|
f,
|
||||||
)
|
),
|
||||||
.order_by(func.random())
|
PlexItem.rating_key,
|
||||||
.limit(per)
|
per,
|
||||||
)
|
exclude,
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
shows = []
|
shows = []
|
||||||
if show_ids:
|
if show_ids:
|
||||||
@@ -458,21 +527,19 @@ def surprise(
|
|||||||
not_fully_watched = or_(
|
not_fully_watched = or_(
|
||||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||||
)
|
)
|
||||||
shows = (
|
shows = _draw(
|
||||||
db.execute(
|
db,
|
||||||
apply_meta_filters(
|
apply_meta_filters(
|
||||||
select(PlexShow)
|
select(PlexShow)
|
||||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||||
PlexShow,
|
PlexShow,
|
||||||
p,
|
p,
|
||||||
lang,
|
lang,
|
||||||
)
|
),
|
||||||
.order_by(func.random())
|
PlexShow.rating_key,
|
||||||
.limit(per)
|
per,
|
||||||
)
|
exclude,
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
mcards = _movie_cards(db, user.id, movies, lang)
|
mcards = _movie_cards(db, user.id, movies, lang)
|
||||||
scards = _show_cards(db, user.id, shows, 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 rollup
|
||||||
from app.plex import tmdb as plex_tmdb
|
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
|
from app.plex.query import content_libs, split_csv, title_col
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
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.
|
# A credit resolves against its OWN media_type's bucket only.
|
||||||
if k.get("media_type") == "tv":
|
if k.get("media_type") == "tv":
|
||||||
if g in shows:
|
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:
|
elif g in movies:
|
||||||
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
||||||
return out[:_KNOWN_FOR_SHOWN]
|
return out[:_KNOWN_FOR_SHOWN]
|
||||||
@@ -245,7 +245,7 @@ def person_detail(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
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(
|
known_for = _resolve_known_for(
|
||||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.db import get_db
|
|||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import storage
|
from app.downloads import storage
|
||||||
from app.downloads.status import JobStatus
|
from app.downloads.status import JobStatus
|
||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.models import DownloadJob, DownloadLink, MediaAsset, User
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
from app.security import verify_password
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
@@ -44,6 +44,17 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
|
|||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
owner = db.get(User, job.user_id)
|
||||||
|
return owner is not None and not owner.is_suspended
|
||||||
|
|
||||||
|
|
||||||
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||||
@@ -51,7 +62,7 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
|||||||
job is not None
|
job is not None
|
||||||
and asset is not None
|
and asset is not None
|
||||||
and asset.status in ("pending", "downloading")
|
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:
|
# 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".
|
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
|
||||||
@@ -62,7 +73,7 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
|||||||
raise refused(404, "video_gone")
|
raise refused(404, "video_gone")
|
||||||
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
|
# 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.
|
# 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:
|
if path is None:
|
||||||
raise refused(404, "video_gone")
|
raise refused(404, "video_gone")
|
||||||
return job, asset
|
return job, asset
|
||||||
@@ -147,7 +158,7 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
|||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||||
raise refused(403, "share_password_required")
|
raise refused(403, "share_password_required")
|
||||||
asset = _ready_asset(db, link)
|
asset = _ready_asset(db, link)
|
||||||
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:
|
if path is None:
|
||||||
raise refused(404, "video_gone")
|
raise refused(404, "video_gone")
|
||||||
|
|
||||||
@@ -183,7 +194,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
|
|||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
if not asset.poster_path:
|
if not asset.poster_path:
|
||||||
raise not_found("poster")
|
raise not_found("poster")
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise not_found("poster")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|||||||
+47
-13
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app import logsink
|
from app import logsink
|
||||||
@@ -36,7 +37,7 @@ from app import sysconfig
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
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 import errors as dlerrors
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
@@ -44,7 +45,7 @@ from app.downloads.jobdb import (
|
|||||||
_reset_asset_pending,
|
_reset_asset_pending,
|
||||||
_staging_dir,
|
_staging_dir,
|
||||||
)
|
)
|
||||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
from app.downloads.status import STOPPED_BY_ROUTE, JobKind, JobStatus
|
||||||
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
@@ -162,7 +163,12 @@ def _claim_job() -> int | None:
|
|||||||
seen.) The per-user limit is the quota row's `max_concurrent` (else the sysconfig default);
|
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
|
`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
|
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:
|
with SessionLocal() as db:
|
||||||
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
|
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
@@ -170,6 +176,10 @@ def _claim_job() -> int | None:
|
|||||||
"""
|
"""
|
||||||
SELECT id, user_id FROM download_jobs
|
SELECT id, user_id FROM download_jobs
|
||||||
WHERE status='queued'
|
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 (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM media_assets a
|
SELECT 1 FROM media_assets a
|
||||||
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
||||||
@@ -437,7 +447,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
|||||||
if status is None or status in STOPPED_BY_ROUTE:
|
if status is None or status in STOPPED_BY_ROUTE:
|
||||||
reason = "cancelled" # a route stopped it (or the row vanished)
|
reason = "cancelled" # a route stopped it (or the row vanished)
|
||||||
break
|
break
|
||||||
if status == "running":
|
if status == JobStatus.RUNNING:
|
||||||
if stale is not None and stale > stall_s:
|
if stale is not None and stale > stall_s:
|
||||||
reason = "stalled"
|
reason = "stalled"
|
||||||
break
|
break
|
||||||
@@ -448,7 +458,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
|
# 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").
|
# done/error) just gets its lingering child killed, its DB status left intact ("abandon").
|
||||||
if time.monotonic() - started > wall_s:
|
if time.monotonic() - started > wall_s:
|
||||||
reason = "timeout" if status == "running" else "abandon"
|
reason = "timeout" if status == JobStatus.RUNNING else "abandon"
|
||||||
break
|
break
|
||||||
# else: a terminal status under the cap (done/error, or a re-queued contended asset) means
|
# 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
|
# the child is exiting on its own — keep polling; NEVER kill a finishing child (that could
|
||||||
@@ -479,7 +489,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
|||||||
_reset_asset_pending(asset_id)
|
_reset_asset_pending(asset_id)
|
||||||
if proc.returncode != _CHILD_OK:
|
if proc.returncode != _CHILD_OK:
|
||||||
status, _ = _job_liveness(job_id)
|
status, _ = _job_liveness(job_id)
|
||||||
if status == "running":
|
if status == JobStatus.RUNNING:
|
||||||
_cleanup_staging(asset_id)
|
_cleanup_staging(asset_id)
|
||||||
_fail_running(
|
_fail_running(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -560,15 +570,12 @@ def _process_job(job_id: int) -> None:
|
|||||||
asset_id = asset.id if asset else None
|
asset_id = asset.id if asset else None
|
||||||
asset_status = asset.status if asset else None
|
asset_status = asset.status if asset else None
|
||||||
asset_error = asset.error 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
|
# 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
|
# "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
|
# 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.
|
# 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)
|
url, cookie = _failed_link_and_cookie(db, job, asset)
|
||||||
cookie = credentials.usable_cookie(
|
|
||||||
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
|
|
||||||
)
|
|
||||||
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
|
||||||
del cookie # only whether there is one matters here
|
del cookie # only whether there is one matters here
|
||||||
|
|
||||||
@@ -605,13 +612,34 @@ def _process_job(job_id: int) -> None:
|
|||||||
_run_supervised(job_id, asset_id)
|
_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:
|
def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
if job and asset:
|
if job and asset:
|
||||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
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).
|
# 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]
|
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
@@ -682,12 +710,18 @@ def main() -> None:
|
|||||||
# `log_events`, so on the deploy that first adds the table the worker may start before the API
|
# `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.
|
# has migrated — those few seconds of batches fail and say so on stderr; nothing else breaks.
|
||||||
logsink.start()
|
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()
|
_recover_orphans()
|
||||||
global _target
|
global _target
|
||||||
_target = max(1, settings.download_worker_concurrency) # used only if the DB can't answer
|
_target = max(1, settings.download_worker_concurrency) # used only if the DB can't answer
|
||||||
threads: dict[int, threading.Thread] = {}
|
threads: dict[int, threading.Thread] = {}
|
||||||
level = _sync_knobs(threads, None)
|
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):
|
while not _stop.wait(_KNOB_SECONDS):
|
||||||
level = _sync_knobs(threads, level)
|
level = _sync_knobs(threads, level)
|
||||||
log.info("Download worker stopping…")
|
log.info("Download worker stopping…")
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
|
# 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
|
# 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)
|
# 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
|
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
|
||||||
# regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must
|
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||||
# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177).
|
# `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
|
APScheduler==3.11.3
|
||||||
Authlib==1.7.2
|
Authlib==1.7.2
|
||||||
Mako==1.4.1
|
Mako==1.4.1
|
||||||
@@ -62,4 +63,4 @@ uvloop==0.22.1
|
|||||||
watchfiles==1.2.0
|
watchfiles==1.2.0
|
||||||
websockets==17.0.1
|
websockets==17.0.1
|
||||||
yt-dlp-ejs==0.8.0
|
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)
|
# 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
|
# 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
|
# 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.
|
# keep the floor loose and bump it when extraction breaks. 2026.8.19 marks the clients that need a
|
||||||
yt-dlp[default]>=2025.5.22
|
# 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
|
# 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
|
# 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
|
# (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 reports every target "unavailable" outside it. Move it when bumping
|
# 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`
|
# 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.
|
# fails the gate if the locked pair can no longer impersonate.
|
||||||
curl_cffi>=0.10,<0.16
|
curl_cffi>=0.10,<0.16
|
||||||
|
|||||||
@@ -254,20 +254,24 @@ def as_user(db, override_dep, _configured):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def upload_headers():
|
def upload_headers():
|
||||||
"""Build the headers `POST /api/downloads/upload` takes (the file is the raw body).
|
"""Build the headers the upload routes take (the file is the raw body).
|
||||||
|
|
||||||
Shared, because two test modules drive that route and a second copy of this is exactly the drift
|
Shared, because two test modules drive that route and a second copy of this is exactly the drift
|
||||||
that lets the wire shape change in one place only. The body IS the file now; the names ride as
|
that lets the wire shape change in one place only. The body IS the file now; the names ride as
|
||||||
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
|
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
|
||||||
latin-1 and a Hungarian filename would not survive raw)."""
|
latin-1 and a Hungarian filename would not survive raw)."""
|
||||||
|
|
||||||
def _headers(filename: str, display_name: str | None = None) -> dict[str, str]:
|
def _headers(
|
||||||
|
filename: str, display_name: str | None = None, folder: int | str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
"content-type": "application/octet-stream",
|
"content-type": "application/octet-stream",
|
||||||
"x-upload-filename": quote(filename),
|
"x-upload-filename": quote(filename),
|
||||||
}
|
}
|
||||||
if display_name is not None:
|
if display_name is not None:
|
||||||
headers["x-upload-display-name"] = quote(display_name)
|
headers["x-upload-display-name"] = quote(display_name)
|
||||||
|
if folder is not None:
|
||||||
|
headers["x-upload-folder"] = str(folder)
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
return _headers
|
return _headers
|
||||||
|
|||||||
@@ -175,6 +175,21 @@ def test_the_cookie_capable_clients_match_the_installed_ytdlp():
|
|||||||
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
|
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_player_clients_exist_in_the_installed_ytdlp():
|
||||||
|
# yt-dlp skips a client it does not know with a warning and falls back to its own defaults, so
|
||||||
|
# a bump that renames or drops one (tv_simply, the one measured to deliver HD) would change
|
||||||
|
# what every download is fetched with, silently. Read from the Settings defaults, not from
|
||||||
|
# `settings`, which an environment may override.
|
||||||
|
from yt_dlp.extractor.youtube._base import INNERTUBE_CLIENTS
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
fields = Settings.model_fields
|
||||||
|
for knob in ("download_player_clients", "download_player_clients_fallback"):
|
||||||
|
names = Settings._clients(fields[knob].default)
|
||||||
|
assert names and set(names) <= set(INNERTUBE_CLIENTS), (knob, names)
|
||||||
|
|
||||||
|
|
||||||
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
|
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
|
||||||
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
|
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
|
||||||
assert ea["youtube"]["player_client"] == ["android_vr"]
|
assert ea["youtube"]["player_client"] == ["android_vr"]
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
|
|||||||
CAPPED_BODY_SITES = {
|
CAPPED_BODY_SITES = {
|
||||||
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
|
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
|
||||||
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
|
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
|
||||||
("downloads.py", "spool_upload", "capped_stream_to", "cap"),
|
("files.py", "spool_upload", "capped_stream_to", "cap"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -367,7 +367,7 @@ def test_every_body_reading_site_is_known_and_fits_under_its_ceiling():
|
|||||||
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
|
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||||
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
|
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
|
||||||
# than being a constant to compare against — that pairing is its own test below.
|
# than being a constant to compare against — that pairing is its own test below.
|
||||||
assert ("POST", "/api/downloads/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
|
assert ("POST", "/api/files/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
|
||||||
|
|
||||||
|
|
||||||
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
|
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
|
||||||
@@ -467,7 +467,7 @@ def test_every_body_reading_site_hands_its_pooled_connection_back_first():
|
|||||||
assert not missing, (
|
assert not missing, (
|
||||||
"a route reads its own request body while still inside its read transaction, pinning a "
|
"a route reads its own request body while still inside its read transaction, pinning a "
|
||||||
f"pooled connection for the whole transfer: {missing}. Call "
|
f"pooled connection for the whole transfer: {missing}. Call "
|
||||||
"db.release_read_connection(db) before the await — see routes/downloads.py:spool_upload."
|
"db.release_read_connection(db) before the await — see routes/files.py:spool_upload."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
|
|||||||
`errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first.
|
`errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first.
|
||||||
"""
|
"""
|
||||||
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
|
||||||
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
|
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/files/upload")]
|
||||||
assert spec.max == edge
|
assert spec.max == edge
|
||||||
|
|
||||||
|
|
||||||
@@ -540,7 +540,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa
|
|||||||
"its dependencies, so FastAPI reads it before the first dependency runs and the raised "
|
"its dependencies, so FastAPI reads it before the first dependency runs and the raised "
|
||||||
"ceiling is all that stands between an anonymous caller and the whole allowance. Read the "
|
"ceiling is all that stands between an anonymous caller and the whole allowance. Read the "
|
||||||
"body yourself, from a dependency declared AFTER the auth one — see "
|
"body yourself, from a dependency declared AFTER the auth one — see "
|
||||||
"routes/downloads.py:spool_upload."
|
"routes/files.py:spool_upload."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,810 @@
|
|||||||
|
"""Ad-hoc clips (downloads/clip): the spec, the one ffmpeg command every clip goes through, the route,
|
||||||
|
and the worker path for both sources.
|
||||||
|
|
||||||
|
The ffmpeg command is judged at argv level: where the seek sits, which streams are mapped, what the
|
||||||
|
video filter does to an HDR source. The worker runs with ffmpeg / yt-dlp replaced by stand-ins that
|
||||||
|
write a file where they were told to, so what is checked is what the pipeline asks of them and what
|
||||||
|
it stores afterwards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import worker as workermod
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import clip as clipmod
|
||||||
|
from app.downloads import errors as dlerrors
|
||||||
|
from app.downloads import formats, pipeline, quota, runner, service
|
||||||
|
from app.downloads import probe as probemod
|
||||||
|
from app.downloads.edit import INPUT_GUARD
|
||||||
|
from app.models import Channel, DownloadJob, MediaAsset, PlexItem, PlexLibrary, PlexShow, Video
|
||||||
|
from app.plex import paths as plex_paths
|
||||||
|
|
||||||
|
# --- the spec -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_spec_is_canonical_so_the_same_marks_hit_the_same_clip():
|
||||||
|
a = clipmod.normalize("youtube", "abcdefghijk", 10.00049, 40.0)
|
||||||
|
b = clipmod.normalize("youtube", "abcdefghijk", 10.0, 40.0004)
|
||||||
|
assert a == b == {"source": "youtube", "ref": "abcdefghijk", "start_s": 10.0, "end_s": 40.0}
|
||||||
|
assert clipmod.sig(a) == clipmod.sig(b)
|
||||||
|
assert clipmod.sig(a) != clipmod.sig({**a, "end_s": 41.0})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("args", "reason"),
|
||||||
|
[
|
||||||
|
(("vimeo", "abcdefghijk", 0, 10), "bad_source"),
|
||||||
|
(("youtube", "short", 0, 10), "bad_source"),
|
||||||
|
(("youtube", "abcdefghij/", 0, 10), "bad_source"),
|
||||||
|
(("plex", "12a", 0, 10), "bad_source"),
|
||||||
|
(("youtube", "abcdefghijk", -1, 10), "bad_range"),
|
||||||
|
(("youtube", "abcdefghijk", 0, float("nan")), "bad_range"),
|
||||||
|
(("youtube", "abcdefghijk", 5, 5.5), "too_short"),
|
||||||
|
(("youtube", "abcdefghijk", 30, 10), "too_short"),
|
||||||
|
(("youtube", "abcdefghijk", 0, 600.001), "too_long"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_range_it_cannot_cut_is_refused_with_its_reason(args, reason):
|
||||||
|
with pytest.raises(clipmod.ClipError) as e:
|
||||||
|
clipmod.normalize(*args)
|
||||||
|
assert e.value.reason == reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ten_minute_cap_itself_is_allowed():
|
||||||
|
assert clipmod.duration(clipmod.normalize("plex", "42", 0, 600)) == 600
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_audio_track_is_kept_for_plex_only_and_must_be_sane():
|
||||||
|
assert clipmod.normalize("plex", "42", 0, 10, 2)["audio_track"] == 2
|
||||||
|
assert "audio_track" not in clipmod.normalize("youtube", "abcdefghijk", 0, 10, 2)
|
||||||
|
with pytest.raises(clipmod.ClipError, match="bad_audio"):
|
||||||
|
clipmod.normalize("plex", "42", 0, 10, -1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_links_its_video_at_the_clip_start_and_a_plex_clip_links_nothing():
|
||||||
|
yt = clipmod.normalize("youtube", "abcdefghijk", 75.9, 90)
|
||||||
|
assert clipmod.reference_url(yt) == "https://www.youtube.com/watch?v=abcdefghijk&t=75s"
|
||||||
|
assert clipmod.reference_url(clipmod.normalize("plex", "42", 0, 10)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- the ffmpeg command ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd(**kw):
|
||||||
|
args = {"audio_track": None, "hdr": False, "threads": 3, **kw}
|
||||||
|
return clipmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.mp4"), 61.5, 30.0, **args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_seek_is_on_the_input_so_the_clip_starts_on_the_marked_frame():
|
||||||
|
cmd = _cmd()
|
||||||
|
i = cmd.index("-i")
|
||||||
|
assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i
|
||||||
|
assert cmd[i + 1] == "/m/in.mkv"
|
||||||
|
assert cmd[cmd.index("-t") + 1] == "30.000" and cmd.index("-t") > i
|
||||||
|
# The single-file demuxer allowlist goes with every library read.
|
||||||
|
g = cmd.index(INPUT_GUARD[0])
|
||||||
|
assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i
|
||||||
|
assert cmd[-1] == "/s/out.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_maps_the_first_video_and_the_heard_audio_track_and_nothing_else():
|
||||||
|
cmd = _cmd(audio_track=2)
|
||||||
|
maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"]
|
||||||
|
assert maps == ["0:v:0", "0:a:2"]
|
||||||
|
assert "-sn" in cmd and "-dn" in cmd
|
||||||
|
# An mkv's chapters would ride into the .mp4 as a data track of their own (measured on a Blu-ray
|
||||||
|
# rip: `bin_data`), which -dn does not drop.
|
||||||
|
assert cmd[cmd.index("-map_chapters") + 1] == "-1"
|
||||||
|
# No track chosen: the first one, and a source with no audio at all still cuts.
|
||||||
|
maps = [c for i, c in enumerate(_cmd()) if i and _cmd()[i - 1] == "-map"]
|
||||||
|
assert maps == ["0:v:0", "0:a:0?"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_output_is_phone_safe_stereo_h264_at_most_1080p():
|
||||||
|
cmd = _cmd()
|
||||||
|
assert cmd[cmd.index("-c:v") + 1] == "libx264"
|
||||||
|
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||||
|
assert cmd[cmd.index("-c:a") + 1] == "aac" and cmd[cmd.index("-ac") + 1] == "2"
|
||||||
|
assert cmd[cmd.index("-threads") + 1] == "3"
|
||||||
|
vf = cmd[cmd.index("-vf") + 1]
|
||||||
|
assert "min(1080,ih)" in vf and "tonemap" not in vf
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_hdr_source_is_tonemapped_to_sdr_before_it_is_scaled():
|
||||||
|
vf = _cmd(hdr=True)[_cmd(hdr=True).index("-vf") + 1]
|
||||||
|
assert vf.index("tonemap") < vf.index("zscale=t=bt709") < vf.index("min(1080,ih)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("transfer", "hdr"),
|
||||||
|
[("smpte2084", True), ("arib-std-b67", True), ("bt709", False), (None, False)],
|
||||||
|
)
|
||||||
|
def test_hdr_is_read_from_the_first_video_stream(transfer, hdr):
|
||||||
|
data = {
|
||||||
|
"streams": [{"codec_type": "audio"}, {"codec_type": "video", "color_transfer": transfer}]
|
||||||
|
}
|
||||||
|
assert clipmod.is_hdr(data) is hdr
|
||||||
|
|
||||||
|
|
||||||
|
# --- the crop ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FRAME = {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crop_frame_is_canonical_and_a_frame_over_the_whole_box_is_no_crop():
|
||||||
|
a = clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "x": 0.50004})
|
||||||
|
b = clipmod.normalize("plex", "42", 0, 10, crop=_FRAME)
|
||||||
|
assert a["crop"] == b["crop"] == {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 1.7778}
|
||||||
|
assert clipmod.sig(a) == clipmod.sig(b)
|
||||||
|
whole = clipmod.normalize(
|
||||||
|
"plex", "42", 0, 10, crop={"x": 0, "y": 0, "w": 1, "h": 1, "aspect": 1}
|
||||||
|
)
|
||||||
|
assert whole == clipmod.normalize("plex", "42", 0, 10)
|
||||||
|
# A far edge a hair past the box (the client's rounding) is the box's edge.
|
||||||
|
assert clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "w": 0.5004})["crop"]["w"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"frame",
|
||||||
|
[
|
||||||
|
{**_FRAME, "w": 0.05},
|
||||||
|
{**_FRAME, "h": 0.09},
|
||||||
|
{**_FRAME, "x": -0.1},
|
||||||
|
{**_FRAME, "x": 0.8},
|
||||||
|
{**_FRAME, "y": 0.6},
|
||||||
|
{**_FRAME, "aspect": 0},
|
||||||
|
{**_FRAME, "aspect": 9},
|
||||||
|
{**_FRAME, "x": float("nan")},
|
||||||
|
{"x": 0.1, "y": 0.1, "w": 0.5},
|
||||||
|
{**_FRAME, "h": "half"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_crop_frame_it_cannot_cut_is_refused(frame):
|
||||||
|
with pytest.raises(clipmod.ClipError) as e:
|
||||||
|
clipmod.normalize("plex", "42", 0, 10, crop=frame)
|
||||||
|
assert e.value.reason == "bad_crop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("stream", "dar"),
|
||||||
|
[
|
||||||
|
({"width": 1920, "height": 1080}, 16 / 9),
|
||||||
|
# An anamorphic DVD: 720×480 stored, shown 16:9.
|
||||||
|
({"width": 720, "height": 480, "sample_aspect_ratio": "32:27"}, 16 / 9),
|
||||||
|
({"width": 640, "height": 480, "sample_aspect_ratio": "0:1"}, 4 / 3),
|
||||||
|
# A phone video stored landscape, shown upright.
|
||||||
|
({"width": 1280, "height": 720, "side_data_list": [{"rotation": -90}]}, 9 / 16),
|
||||||
|
({"width": 1280, "height": 720, "tags": {"rotate": "270"}}, 9 / 16),
|
||||||
|
({"width": 1280, "height": 720, "side_data_list": [{"rotation": 180}]}, 16 / 9),
|
||||||
|
({"width": None, "height": 720}, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_the_display_aspect_is_the_picture_as_a_player_shows_it(stream, dar):
|
||||||
|
got = clipmod.display_aspect(
|
||||||
|
{"streams": [{"codec_type": "audio"}, {"codec_type": "video", **stream}]}
|
||||||
|
)
|
||||||
|
assert got == pytest.approx(dar) if dar else got is None
|
||||||
|
|
||||||
|
|
||||||
|
def _crop_args(f: str) -> list[float]:
|
||||||
|
# crop=trunc(iw*W/2)*2:trunc(ih*H/2)*2:trunc(iw*X/2)*2:trunc(ih*Y/2)*2 → [W, H, X, Y]
|
||||||
|
assert f.startswith("crop=")
|
||||||
|
return [float(p.split("*")[1].split("/")[0]) for p in f[5:].split(":")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_on_a_picture_that_fills_its_box_is_cut_as_drawn():
|
||||||
|
assert _crop_args(clipmod.crop_filter(_FRAME, 16 / 9)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_drawn_in_a_16_9_box_over_a_4_3_picture_maps_onto_the_picture():
|
||||||
|
# The 4:3 picture fills 3/4 of the box's width from 1/8 in: a frame over its right half's middle.
|
||||||
|
frame = {"x": 0.5, "y": 0.25, "w": 0.1875, "h": 0.5, "aspect": 16 / 9}
|
||||||
|
assert _crop_args(clipmod.crop_filter(frame, 4 / 3)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_part_of_a_frame_over_a_bar_is_dropped_and_a_frame_on_a_bar_alone_is_no_cut():
|
||||||
|
over = {"x": 0.0, "y": 0.0, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
|
||||||
|
w, h, x, y = _crop_args(clipmod.crop_filter(over, 4 / 3))
|
||||||
|
assert (w, h, x, y) == pytest.approx([1 / 6, 0.5, 0, 0], abs=1e-6)
|
||||||
|
assert clipmod.crop_filter({**over, "w": 0.13}, 4 / 3) is None
|
||||||
|
# A letterboxed film: the frame over the whole box is the whole picture.
|
||||||
|
whole = {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "aspect": 16 / 9}
|
||||||
|
assert clipmod.crop_filter(whole, 2.39) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_crop_comes_first_so_the_tonemap_and_the_scale_work_on_what_it_keeps():
|
||||||
|
f = clipmod.crop_filter(_FRAME, 16 / 9)
|
||||||
|
vf = _cmd(hdr=True, crop=f)[_cmd(hdr=True, crop=f).index("-vf") + 1]
|
||||||
|
assert vf.startswith(f + ",") and vf.index("tonemap") < vf.index("min(1080,ih)")
|
||||||
|
assert "crop" not in _cmd()[_cmd().index("-vf") + 1]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the route ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def yt_video(db):
|
||||||
|
db.add(Channel(id="UC_clip", title="The Channel"))
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Video(
|
||||||
|
id="abcdefghijk",
|
||||||
|
channel_id="UC_clip",
|
||||||
|
title="A Funny Video",
|
||||||
|
duration_seconds=300,
|
||||||
|
thumbnail_url="https://i.ytimg.com/vi/abcdefghijk/hq.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return "abcdefghijk"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plex_episode(db, tmp_path, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
show = PlexShow(rating_key="s9", library_id=lib.id, title="The Show")
|
||||||
|
db.add(show)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
PlexItem(
|
||||||
|
rating_key="777",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title="Pilot",
|
||||||
|
show_id=show.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=2,
|
||||||
|
duration_s=1800,
|
||||||
|
file_path="/data/shows/pilot.mkv",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
media = tmp_path / "pilot.mkv"
|
||||||
|
media.write_bytes(b"m" * 8)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
plex_paths,
|
||||||
|
"local_media_path",
|
||||||
|
lambda _db, p: media if p == "/data/shows/pilot.mkv" else None,
|
||||||
|
)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(client, **body):
|
||||||
|
return client.post("/api/downloads/clip", json={"start_s": 10, "end_s": 40, **body})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_is_queued_as_its_own_job_titled_after_its_video(
|
||||||
|
db, make_user, as_user, yt_video
|
||||||
|
):
|
||||||
|
r = _clip(as_user(make_user()), source="youtube", ref=yt_video)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
job = r.json()
|
||||||
|
assert (job["job_kind"], job["status"], job["display_name"]) == (
|
||||||
|
"clip",
|
||||||
|
"queued",
|
||||||
|
"A Funny Video (clip)",
|
||||||
|
)
|
||||||
|
assert job["edit_spec"] == {"source": "youtube", "ref": yt_video, "start_s": 10, "end_s": 40}
|
||||||
|
assert job["source_url"] == f"https://www.youtube.com/watch?v={yt_video}&t=10s"
|
||||||
|
asset = db.get(MediaAsset, job["asset"]["id"])
|
||||||
|
assert (asset.duration_s, asset.uploader, asset.media_kind) == (30, "The Channel", "video")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_range_again_is_the_same_clip_but_never_another_users(
|
||||||
|
db, make_user, as_user, yt_video
|
||||||
|
):
|
||||||
|
a, b = make_user(), make_user()
|
||||||
|
first = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"]
|
||||||
|
again = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"]
|
||||||
|
other = _clip(as_user(b), source="youtube", ref=yt_video).json()["asset"]["id"]
|
||||||
|
longer = _clip(as_user(a), source="youtube", ref=yt_video, end_s=41).json()["asset"]["id"]
|
||||||
|
assert first == again
|
||||||
|
assert len({first, other, longer}) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ready_clip_asked_for_again_is_done_at_once(db, make_user, as_user, yt_video):
|
||||||
|
u = make_user()
|
||||||
|
first = _clip(as_user(u), source="youtube", ref=yt_video).json()
|
||||||
|
asset = db.get(MediaAsset, first["asset"]["id"])
|
||||||
|
asset.status, asset.rel_path = "ready", ".edits/1/clip_1.mp4"
|
||||||
|
db.commit()
|
||||||
|
again = _clip(as_user(u), source="youtube", ref=yt_video).json()
|
||||||
|
assert (again["status"], again["progress"]) == ("done", 100)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("body", "status", "reason", "params"),
|
||||||
|
[
|
||||||
|
# The cap travels with its refusal, so the client never states a stale one.
|
||||||
|
({"end_s": 700}, 422, "too_long", {"limit_s": clipmod.MAX_CLIP_S}),
|
||||||
|
({"start_s": 299.5, "end_s": 330}, 422, "past_end", {}),
|
||||||
|
({"ref": "nope"}, 422, "bad_source", {}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_youtube_clip_it_cannot_cut_says_why(
|
||||||
|
db, make_user, as_user, yt_video, body, status, reason, params
|
||||||
|
):
|
||||||
|
r = _clip(as_user(make_user()), **{"source": "youtube", "ref": yt_video, **body})
|
||||||
|
assert r.status_code == status
|
||||||
|
assert r.json()["detail"] == {"code": "clip", "reason": reason, **params}
|
||||||
|
assert db.query(DownloadJob).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_clip_is_titled_show_episode_title_and_keeps_its_audio_track(
|
||||||
|
db, make_user, as_user, plex_episode
|
||||||
|
):
|
||||||
|
r = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
job = r.json()
|
||||||
|
assert job["display_name"] == "The Show — S1·E2 — Pilot (clip)"
|
||||||
|
assert job["edit_spec"]["audio_track"] == 1
|
||||||
|
assert job["source_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_clip_of_an_unknown_item_is_a_404_and_of_an_unreachable_file_a_422(
|
||||||
|
db, make_user, as_user, plex_episode
|
||||||
|
):
|
||||||
|
c = as_user(make_user())
|
||||||
|
assert _clip(c, source="plex", ref="999").json()["detail"]["reason"] == "not_found"
|
||||||
|
assert _clip(c, source="plex", ref="999").status_code == 404
|
||||||
|
item = db.query(PlexItem).one()
|
||||||
|
item.file_path = "/data/elsewhere.mkv"
|
||||||
|
db.commit()
|
||||||
|
r = _clip(c, source="plex", ref="777")
|
||||||
|
assert (r.status_code, r.json()["detail"]["reason"]) == (422, "media_missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clip_over_quota_is_refused_like_a_download(
|
||||||
|
db, make_user, as_user, yt_video, monkeypatch
|
||||||
|
):
|
||||||
|
def full(_db, _uid):
|
||||||
|
raise quota.QuotaExceeded("max_jobs", limit=5, current=5)
|
||||||
|
|
||||||
|
monkeypatch.setattr(quota, "check_enqueue", full)
|
||||||
|
r = _clip(as_user(make_user()), source="youtube", ref=yt_video)
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert db.query(DownloadJob).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clip_is_not_redownloadable(db, make_user, as_user, yt_video):
|
||||||
|
c = as_user(make_user())
|
||||||
|
job = _clip(c, source="youtube", ref=yt_video).json()
|
||||||
|
row = db.get(DownloadJob, job["id"])
|
||||||
|
row.status, row.ready_at = "done", row.created_at
|
||||||
|
db.commit()
|
||||||
|
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||||
|
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "not_redownloadable")
|
||||||
|
|
||||||
|
|
||||||
|
# --- the worker ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clip_job_reaches_the_clip_entry(monkeypatch):
|
||||||
|
called = []
|
||||||
|
for name in ("_convert_upload", "_process_edit", "_download", "_process_clip"):
|
||||||
|
monkeypatch.setattr(pipeline, name, lambda *a, _n=name: called.append(_n))
|
||||||
|
runner._run(5, 7, "clip", "clip", "1:youtube:abcdefghijk", {}, 1)
|
||||||
|
assert called == ["_process_clip"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker(db, tmp_path, monkeypatch):
|
||||||
|
"""The worker path with its external programs replaced: ffmpeg writes the file it was given, the
|
||||||
|
probe answers what the test says, the phone-safe step and the poster pass through."""
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path / "root"))
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def ffmpeg(cmd, *_a, **_k):
|
||||||
|
seen["cmd"] = cmd
|
||||||
|
Path(cmd[-1]).write_bytes(b"clip")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg)
|
||||||
|
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, p, *_a, **_k: p)
|
||||||
|
monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 1920, 1080, 30))
|
||||||
|
seen["probe"] = {"streams": [{"codec_type": "video"}, {"codec_type": "audio"}]}
|
||||||
|
monkeypatch.setattr(probemod, "probe_json", lambda _p: seen["probe"])
|
||||||
|
|
||||||
|
def run(job_json):
|
||||||
|
job = db.get(DownloadJob, job_json["id"])
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
job.status, asset.status = "running", "downloading"
|
||||||
|
db.commit()
|
||||||
|
pipeline._process_clip(job.id, asset.id)
|
||||||
|
db.expire_all()
|
||||||
|
return db.get(DownloadJob, job.id), db.get(MediaAsset, asset.id)
|
||||||
|
|
||||||
|
seen["run"] = run
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_clip_is_cut_from_the_library_file_with_its_audio_track(
|
||||||
|
db, make_user, as_user, plex_episode, worker
|
||||||
|
):
|
||||||
|
worker["probe"] = {
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "color_transfer": "smpte2084"},
|
||||||
|
{"codec_type": "audio"},
|
||||||
|
{"codec_type": "audio"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status, asset.container, asset.height) == (
|
||||||
|
"done",
|
||||||
|
"ready",
|
||||||
|
"mp4",
|
||||||
|
1080,
|
||||||
|
)
|
||||||
|
cmd = worker["cmd"]
|
||||||
|
assert cmd[cmd.index("-i") + 1] == str(plex_episode)
|
||||||
|
assert cmd[cmd.index("-ss") + 1] == "10.000"
|
||||||
|
assert "0:a:1" in cmd and "tonemap" in cmd[cmd.index("-vf") + 1]
|
||||||
|
assert asset.rel_path.startswith(".edits/") and asset.rel_path.endswith(".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cropped_plex_clip_cuts_the_frame_from_the_picture_and_is_its_own_clip(
|
||||||
|
db, make_user, as_user, plex_episode, worker
|
||||||
|
):
|
||||||
|
worker["probe"] = {
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "width": 720, "height": 480, "sample_aspect_ratio": "32:27"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
c = as_user(make_user())
|
||||||
|
plain = _clip(c, source="plex", ref="777").json()
|
||||||
|
job = _clip(c, source="plex", ref="777", crop=_FRAME).json()
|
||||||
|
assert job["asset"]["id"] != plain["asset"]["id"]
|
||||||
|
assert job["edit_spec"]["crop"]["w"] == 0.25
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status) == ("done", "ready")
|
||||||
|
vf = worker["cmd"][worker["cmd"].index("-vf") + 1]
|
||||||
|
# Stored to four decimals, the box's 16:9 is a hair off the file's: well under a pixel.
|
||||||
|
assert _crop_args(vf.split(",")[0]) == pytest.approx([0.25, 0.5, 0.5, 0.25], abs=1e-4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crop_frame_that_misses_the_picture_fails_and_says_so(
|
||||||
|
db, make_user, as_user, plex_episode, worker
|
||||||
|
):
|
||||||
|
# A 4:3 file, a frame drawn over its pillar bar in a 16:9 box.
|
||||||
|
worker["probe"] = {"streams": [{"codec_type": "video", "width": 640, "height": 480}]}
|
||||||
|
frame = {"x": 0.0, "y": 0.0, "w": 0.12, "h": 0.5, "aspect": 16 / 9}
|
||||||
|
job = _clip(as_user(make_user()), source="plex", ref="777", crop=frame).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status) == ("error", "error")
|
||||||
|
assert "crop" in job.error
|
||||||
|
assert "cmd" not in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crop_frame_the_route_cannot_cut_says_why(db, make_user, as_user, yt_video):
|
||||||
|
r = _clip(as_user(make_user()), source="youtube", ref=yt_video, crop={**_FRAME, "w": 0.01})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert r.json()["detail"] == {"code": "clip", "reason": "bad_crop"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_clip_of_a_track_the_file_does_not_have_fails_and_says_so(
|
||||||
|
db, make_user, as_user, plex_episode, worker
|
||||||
|
):
|
||||||
|
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=3).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status) == ("error", "error")
|
||||||
|
assert "audio track" in job.error
|
||||||
|
assert "cmd" not in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_fetches_only_its_range_and_cuts_it_from_zero(
|
||||||
|
db, make_user, as_user, yt_video, worker, monkeypatch
|
||||||
|
):
|
||||||
|
asked: dict = {}
|
||||||
|
|
||||||
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||||
|
asked.update(url=url, spec=spec, **kw)
|
||||||
|
f = Path(staging) / "abcdefghijk.mp4"
|
||||||
|
f.write_bytes(b"range")
|
||||||
|
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||||
|
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status) == ("done", "ready")
|
||||||
|
assert asked["section"] == (10, 40)
|
||||||
|
assert asked["spec"]["max_height"] == 1080
|
||||||
|
assert asked["url"].endswith(yt_video)
|
||||||
|
cmd = worker["cmd"]
|
||||||
|
assert cmd[cmd.index("-ss") + 1] == "0.000" and cmd[cmd.index("-t") + 1] == "30.000"
|
||||||
|
assert cmd[cmd.index("-i") + 1].endswith("abcdefghijk.mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_hands_the_range_to_yt_dlp(monkeypatch, tmp_path):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class FakeYDL:
|
||||||
|
def __init__(self, opts):
|
||||||
|
seen.update(opts)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_info(self, *_a, **_k):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline.yt_dlp, "YoutubeDL", FakeYDL)
|
||||||
|
monkeypatch.setattr(pipeline, "_touch_job", lambda *_a: None)
|
||||||
|
adapter = pipeline.adapters.resolve_adapter("youtube", "abcdefghijk")
|
||||||
|
pipeline._extract_with_fallback(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||||
|
pipeline.formats.normalize({}),
|
||||||
|
tmp_path,
|
||||||
|
adapter,
|
||||||
|
section=(10.0, 40.0),
|
||||||
|
)
|
||||||
|
ranges = list(seen["download_ranges"]({}, None))
|
||||||
|
assert [(r["start_time"], r["end_time"]) for r in ranges] == [(10.0, 40.0)]
|
||||||
|
assert seen["force_keyframes_at_cuts"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_reference_url_follows_the_clip_spec(db, make_user, as_user, yt_video):
|
||||||
|
job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json()
|
||||||
|
row = db.get(DownloadJob, job["id"])
|
||||||
|
assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_outside_the_catalog_takes_its_title_from_the_fetch(
|
||||||
|
db, make_user, as_user, worker, monkeypatch
|
||||||
|
):
|
||||||
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||||
|
f = Path(staging) / "zyxwvutsrqp.mp4"
|
||||||
|
f.write_bytes(b"range")
|
||||||
|
return {"requested_downloads": [{"filepath": str(f)}], "title": "Found On YouTube"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||||
|
job = _clip(as_user(make_user()), source="youtube", ref="zyxwvutsrqp").json()
|
||||||
|
assert job["display_name"] == "Clip (clip)"
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.title, job.display_name) == (
|
||||||
|
"done",
|
||||||
|
"Found On YouTube (clip)",
|
||||||
|
"Found On YouTube (clip)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- a YouTube clip goes with the user's own cookie, and its failure is diagnosed -----------------
|
||||||
|
|
||||||
|
_YT_COOKIES = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tabc\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fernet(monkeypatch):
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_extract(monkeypatch, asked: dict):
|
||||||
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||||
|
cookiefile = kw.get("cookiefile")
|
||||||
|
asked["cookiefile"] = cookiefile
|
||||||
|
asked["cookie_text"] = Path(cookiefile).read_text() if cookiefile else None
|
||||||
|
f = Path(staging) / "abcdefghijk.mp4"
|
||||||
|
f.write_bytes(b"range")
|
||||||
|
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_goes_signed_in_with_the_users_own_cookie_and_leaves_none_behind(
|
||||||
|
db, make_user, as_user, yt_video, worker, monkeypatch, fernet
|
||||||
|
):
|
||||||
|
from app.downloads import credentials
|
||||||
|
|
||||||
|
user = make_user()
|
||||||
|
credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES)
|
||||||
|
db.commit()
|
||||||
|
asked: dict = {}
|
||||||
|
_capture_extract(monkeypatch, asked)
|
||||||
|
job, _asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
|
||||||
|
assert job.status == "done"
|
||||||
|
assert asked["cookie_text"] == _YT_COOKIES
|
||||||
|
assert not Path(asked["cookiefile"]).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_never_goes_with_another_users_cookie(
|
||||||
|
db, make_user, as_user, yt_video, worker, monkeypatch, fernet
|
||||||
|
):
|
||||||
|
from app.downloads import credentials
|
||||||
|
|
||||||
|
other = make_user()
|
||||||
|
credentials.upsert_credential(db, other.id, "youtube", _YT_COOKIES)
|
||||||
|
db.commit()
|
||||||
|
asked: dict = {}
|
||||||
|
_capture_extract(monkeypatch, asked)
|
||||||
|
job, _asset = worker["run"](_clip(as_user(make_user()), source="youtube", ref=yt_video).json())
|
||||||
|
assert job.status == "done" and asked["cookiefile"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("with_cookie", "detail"), [(False, "youtube.bot_wall"), (True, "youtube.cookie_rejected")]
|
||||||
|
)
|
||||||
|
def test_a_youtube_clip_on_the_bot_wall_is_diagnosed_like_a_download(
|
||||||
|
db, make_user, as_user, yt_video, worker, monkeypatch, fernet, with_cookie, detail
|
||||||
|
):
|
||||||
|
from app.downloads import credentials
|
||||||
|
|
||||||
|
user = make_user()
|
||||||
|
if with_cookie:
|
||||||
|
credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def walled(*_a, **_k):
|
||||||
|
raise RuntimeError(f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot.")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", walled)
|
||||||
|
job, asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
|
||||||
|
assert (job.status, asset.status) == ("error", "error")
|
||||||
|
assert (job.error_code, job.error_detail) == ("login_required", detail)
|
||||||
|
|
||||||
|
|
||||||
|
# --- held to the owner's storage cap, and a clip that cannot or need not go on ---------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_range(monkeypatch):
|
||||||
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||||
|
f = Path(staging) / "abcdefghijk.mp4"
|
||||||
|
f.write_bytes(b"range")
|
||||||
|
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["youtube", "plex"])
|
||||||
|
def test_a_clip_for_an_owner_whose_storage_is_full_is_refused_before_it_is_cut(
|
||||||
|
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
|
||||||
|
):
|
||||||
|
_fetch_range(monkeypatch)
|
||||||
|
ref = yt_video if source == "youtube" else "777"
|
||||||
|
job = _clip(as_user(make_user()), source=source, ref=ref).json()
|
||||||
|
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 0)
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
# Refused like a download at its cap: the job says why, the asset waits for a Retry.
|
||||||
|
assert (job.status, job.error, asset.status) == (
|
||||||
|
"error",
|
||||||
|
dlerrors.QUOTA_FULL_MESSAGE,
|
||||||
|
"pending",
|
||||||
|
)
|
||||||
|
assert "cmd" not in worker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["youtube", "plex"])
|
||||||
|
def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
|
||||||
|
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
|
||||||
|
):
|
||||||
|
# The range fetch's own ceiling never reaches yt-dlp's ffmpeg downloader, and a Plex clip
|
||||||
|
# fetches nothing: the cut file itself is held to the cap.
|
||||||
|
_fetch_range(monkeypatch)
|
||||||
|
ref = yt_video if source == "youtube" else "777"
|
||||||
|
job = _clip(as_user(make_user()), source=source, ref=ref).json()
|
||||||
|
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 3) # the cut is 4 bytes
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
# Not "full": there is room left, just not this much.
|
||||||
|
assert (job.status, job.error, asset.status, asset.rel_path) == (
|
||||||
|
"error",
|
||||||
|
dlerrors.CLIP_TOO_BIG_MESSAGE,
|
||||||
|
"pending",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
|
||||||
|
db, make_user, as_user, yt_video, worker, monkeypatch
|
||||||
|
):
|
||||||
|
# Should yt-dlp's own `max_filesize` refuse the range, it returns with no file (test_formats):
|
||||||
|
# the owner's storage, not a processing fault, and nothing is cut.
|
||||||
|
def refused(*_a, max_bytes=None, **_k):
|
||||||
|
formats._YDL_LOG.debug(
|
||||||
|
f"[download] File is larger than max-filesize (9000 bytes > {max_bytes} bytes). Aborting."
|
||||||
|
)
|
||||||
|
return {"requested_downloads": [{}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", refused)
|
||||||
|
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||||
|
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 500)
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, job.error_code, job.error, asset.status) == (
|
||||||
|
"error",
|
||||||
|
"quota_exceeded",
|
||||||
|
dlerrors.CLIP_TOO_BIG_MESSAGE,
|
||||||
|
"pending",
|
||||||
|
)
|
||||||
|
assert "cmd" not in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_clip_of_a_silent_file_is_cut_silent(db, make_user, as_user, plex_episode, worker):
|
||||||
|
# The player names the track it plays 0 even when the file has none.
|
||||||
|
worker["probe"] = {"streams": [{"codec_type": "video"}]}
|
||||||
|
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status) == ("done", "ready")
|
||||||
|
assert "0:a:0?" in worker["cmd"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plex_clip_of_a_file_the_probe_cannot_read_says_so(
|
||||||
|
db, make_user, as_user, plex_episode, worker
|
||||||
|
):
|
||||||
|
worker["probe"] = None
|
||||||
|
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
assert (job.status, asset.status) == ("error", "error")
|
||||||
|
assert job.error == "The source file could not be read."
|
||||||
|
assert "cmd" not in worker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("where", ["fetch", "encode"])
|
||||||
|
def test_a_paused_clip_frees_its_asset_for_a_later_retry(
|
||||||
|
db, make_user, as_user, yt_video, worker, monkeypatch, where
|
||||||
|
):
|
||||||
|
def stopped(*_a, **_k):
|
||||||
|
raise pipeline._Aborted() if where == "fetch" else pipeline.editmod.EditAborted()
|
||||||
|
|
||||||
|
if where == "fetch":
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", stopped)
|
||||||
|
else:
|
||||||
|
_fetch_range(monkeypatch)
|
||||||
|
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", stopped)
|
||||||
|
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||||
|
job, asset = worker["run"](job)
|
||||||
|
# The route already wrote the job's own state (paused / canceled); the asset is free again.
|
||||||
|
assert (job.status, job.error, asset.status) == ("running", None, "pending")
|
||||||
|
|
||||||
|
|
||||||
|
# --- a second job on the same clip -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _second_job(db, client, **body):
|
||||||
|
"""Two jobs for one range: the second shares the first's asset."""
|
||||||
|
first = _clip(client, **body).json()
|
||||||
|
second = _clip(client, **body).json()
|
||||||
|
assert first["asset"]["id"] == second["asset"]["id"]
|
||||||
|
return db.get(DownloadJob, first["id"]), db.get(DownloadJob, second["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_clip_job_on_a_failed_clip_gets_the_same_youtube_diagnosis(
|
||||||
|
db, make_user, as_user, yt_video
|
||||||
|
):
|
||||||
|
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref=yt_video)
|
||||||
|
asset = db.get(MediaAsset, second.asset_id)
|
||||||
|
asset.status = "error"
|
||||||
|
asset.error = f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot."
|
||||||
|
second.status = "running"
|
||||||
|
db.commit()
|
||||||
|
workermod._process_job(second.id)
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, second.id)
|
||||||
|
assert (job.status, job.error_code, job.error_detail) == (
|
||||||
|
"error",
|
||||||
|
"login_required",
|
||||||
|
"youtube.bot_wall",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_clip_job_takes_the_title_its_sibling_found(db, make_user, as_user):
|
||||||
|
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref="zyxwvutsrqp")
|
||||||
|
assert second.display_name == "Clip (clip)"
|
||||||
|
asset = db.get(MediaAsset, second.asset_id)
|
||||||
|
asset.status, asset.title = "ready", "Found On YouTube (clip)"
|
||||||
|
second.status = "running"
|
||||||
|
db.commit()
|
||||||
|
workermod._finish_from_cache(second.id, asset.id)
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, second.id)
|
||||||
|
assert (job.status, job.display_name) == ("done", "Found On YouTube (clip)")
|
||||||
@@ -5,6 +5,14 @@ from app.downloads import errors as dlerrors
|
|||||||
from app.downloads.errors import classify
|
from app.downloads.errors import classify
|
||||||
|
|
||||||
|
|
||||||
|
# Verbatim from dev job 128 (2026-10-09, yt-dlp 2026.07.04); the same link downloads under 2026.08.19.
|
||||||
|
_TIKTOK_UNEXPECTED = (
|
||||||
|
"ERROR: [TikTok] 7418778475890232609: Unexpected response from webpage request; please report "
|
||||||
|
"this issue on https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue "
|
||||||
|
"template. Confirm you are on the latest version using yt-dlp -U"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_real_ytdlp_messages_map_to_categories():
|
def test_real_ytdlp_messages_map_to_categories():
|
||||||
# Verbatim-shaped yt-dlp / ffmpeg output → the category the UI explains.
|
# Verbatim-shaped yt-dlp / ffmpeg output → the category the UI explains.
|
||||||
cases = {
|
cases = {
|
||||||
@@ -32,6 +40,7 @@ def test_real_ytdlp_messages_map_to_categories():
|
|||||||
"The source file is missing.": "source_missing",
|
"The source file is missing.": "source_missing",
|
||||||
"ERROR: unable to download video data: HTTP Error 403: Forbidden": "network",
|
"ERROR: unable to download video data: HTTP Error 403: Forbidden": "network",
|
||||||
"ERROR: Unable to download webpage: <urlopen error timed out>": "network",
|
"ERROR: Unable to download webpage: <urlopen error timed out>": "network",
|
||||||
|
_TIKTOK_UNEXPECTED: "site_changed",
|
||||||
}
|
}
|
||||||
for msg, expected in cases.items():
|
for msg, expected in cases.items():
|
||||||
assert classify(msg) == expected, f"{msg!r} → {classify(msg)!r}, expected {expected!r}"
|
assert classify(msg) == expected, f"{msg!r} → {classify(msg)!r}, expected {expected!r}"
|
||||||
@@ -63,6 +72,9 @@ def test_our_own_worker_refusals_classify_as_themselves():
|
|||||||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||||||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||||
|
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||||
|
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||||
|
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
|
||||||
|
|
||||||
|
|
||||||
def test_the_redacted_network_text_classifies_back_to_network():
|
def test_the_redacted_network_text_classifies_back_to_network():
|
||||||
@@ -208,13 +220,25 @@ def test_our_post_without_a_video_verdict_names_the_group_link():
|
|||||||
|
|
||||||
|
|
||||||
def test_a_catalog_entry_never_leaves_its_host():
|
def test_a_catalog_entry_never_leaves_its_host():
|
||||||
# "Cannot parse data" is yt-dlp's wording for a broken extractor on every site.
|
# "Cannot parse data" is yt-dlp's wording for a broken extractor on every site: elsewhere it is
|
||||||
|
# only the category — the site changed under the extractor.
|
||||||
other = _FB_PARSE.replace("[facebook]", "[vimeo]")
|
other = _FB_PARSE.replace("[facebook]", "[vimeo]")
|
||||||
assert dlerrors.diagnose(other, url="https://vimeo.com/1") == ("unknown", None)
|
assert dlerrors.diagnose(other, url="https://vimeo.com/1") == ("site_changed", None)
|
||||||
# The Facebook text for a link that is not Facebook's (a look-alike host) is not the Facebook wall.
|
# The Facebook text for a link that is not Facebook's (a look-alike host) is not the Facebook wall.
|
||||||
assert dlerrors.diagnose(_FB_PARSE, url="https://facebook.com.evil/reel/1") == ("unknown", None)
|
assert dlerrors.diagnose(_FB_PARSE, url="https://facebook.com.evil/reel/1") == (
|
||||||
|
"site_changed",
|
||||||
|
None,
|
||||||
|
)
|
||||||
# No link known: the category only.
|
# No link known: the category only.
|
||||||
assert dlerrors.diagnose(_FB_PARSE) == ("unknown", None)
|
assert dlerrors.diagnose(_FB_PARSE) == ("site_changed", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bug_report_request_keeps_the_more_specific_category():
|
||||||
|
# yt-dlp asks for the bug report on these too; what they say about the media wins.
|
||||||
|
assert classify(_FB_NO_FORMATS) == "format_unavailable"
|
||||||
|
assert classify("ERROR: [x] 1: Unable to extract title; please report this issue") == (
|
||||||
|
"format_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_diagnose_falls_back_to_the_category():
|
def test_diagnose_falls_back_to_the_category():
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _asset_and_job(db, user, *, sig, asset_status, job_status, error=None):
|
|||||||
|
|
||||||
|
|
||||||
def _fail_the_child(monkeypatch, tmp_path, *, cookie: bool):
|
def _fail_the_child(monkeypatch, tmp_path, *, cookie: bool):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
cookiefile = tmp_path / "cookies.txt"
|
cookiefile = tmp_path / "cookies.txt"
|
||||||
cookiefile.write_text("# Netscape HTTP Cookie File\n")
|
cookiefile.write_text("# Netscape HTTP Cookie File\n")
|
||||||
@@ -230,7 +230,7 @@ def test_a_download_yt_dlp_gave_no_duration_stores_the_files_duration(db, monkey
|
|||||||
# row lost its trim action. Through the real finalize, with a real 3-second file.
|
# row lost its trim action. Through the real finalize, with a real 3-second file.
|
||||||
u = _user(db, "fb-duration@example.com")
|
u = _user(db, "fb-duration@example.com")
|
||||||
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
|
||||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||||
@@ -350,7 +350,7 @@ def test_a_signed_in_facebook_download_is_titled_with_its_posts_text(db, monkeyp
|
|||||||
# file name and the share preview all said "Video". The post's text is in the description.
|
# file name and the share preview all said "Video". The post's text is in the description.
|
||||||
u = _user(db, "fb-title@example.com")
|
u = _user(db, "fb-title@example.com")
|
||||||
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
|
||||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||||
@@ -401,7 +401,7 @@ def test_an_mp3_download_is_stored_as_an_mp3(db, monkeypatch, tmp_path):
|
|||||||
# video/mp4, which iPad's WebKit will not play. It stays an .mp3 (served as audio/mpeg).
|
# video/mp4, which iPad's WebKit will not play. It stays an .mp3 (served as audio/mpeg).
|
||||||
u = _user(db, "fb-mp3@example.com")
|
u = _user(db, "fb-mp3@example.com")
|
||||||
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
a, j = _asset_and_job(db, u, sig="shared", asset_status="downloading", job_status="running")
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
|
||||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||||
@@ -430,7 +430,7 @@ def test_a_video_download_is_still_made_browser_playable(db, monkeypatch, tmp_pa
|
|||||||
a, j = _asset_and_job(
|
a, j = _asset_and_job(
|
||||||
db, u, sig=f"mkv-{mode}", asset_status="downloading", job_status="running"
|
db, u, sig=f"mkv-{mode}", asset_status="downloading", job_status="running"
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
|
||||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
def extract(job_id, asset_id, url, spec, staging, adapter, **kwargs):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ unlocked read: two overlapping requests for one job (a double-click, a second ta
|
|||||||
gateway timeout) both saw it holding its asset and both released the hold. The count then reaches 0
|
gateway timeout) both saw it holding its asset and both released the hold. The count then reaches 0
|
||||||
while another job still holds the asset, and GC or the ready-file delete removes it under that job.
|
while another job still holds the asset, and GC or the ready-file delete removes it under that job.
|
||||||
A pause racing a cancel did the same one step later: its unlocked write turned the released job back
|
A pause racing a cancel did the same one step later: its unlocked write turned the released job back
|
||||||
into a holder, which a later delete releases again. `_own_job_for_update` makes the second request
|
into a holder, which a later delete releases again. `own_job_for_update` makes the second request
|
||||||
wait and decide on the first one's committed result.
|
wait and decide on the first one's committed result.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -18,12 +18,8 @@ from sqlalchemy import select
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import service
|
from app.downloads import service
|
||||||
from app.models import DownloadJob, MediaAsset, User
|
from app.models import DownloadJob, MediaAsset, User
|
||||||
from app.routes.downloads import (
|
from app.routes.downloads import cancel_download, pause_download
|
||||||
_own_job_for_update,
|
from app.routes.files import delete_file, own_job_for_update
|
||||||
cancel_download,
|
|
||||||
delete_download,
|
|
||||||
pause_download,
|
|
||||||
)
|
|
||||||
|
|
||||||
_REF = "https://example.com/video"
|
_REF = "https://example.com/video"
|
||||||
|
|
||||||
@@ -58,7 +54,7 @@ def _asset_held_by_two_jobs(db, status="error"):
|
|||||||
("status", "transition", "route", "left"),
|
("status", "transition", "route", "left"),
|
||||||
[
|
[
|
||||||
("error", service.cancel, cancel_download, "canceled"),
|
("error", service.cancel, cancel_download, "canceled"),
|
||||||
("error", service.delete, delete_download, None),
|
("error", service.delete, delete_file, None),
|
||||||
("queued", service.cancel, pause_download, "canceled"),
|
("queued", service.cancel, pause_download, "canceled"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -80,7 +76,7 @@ def test_overlapping_requests_for_one_job_release_its_hold_once(
|
|||||||
finished.set()
|
finished.set()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
locked = _own_job_for_update(first, user, job_id)
|
locked = own_job_for_update(first, user, job_id)
|
||||||
other = threading.Thread(target=the_other_request)
|
other = threading.Thread(target=the_other_request)
|
||||||
other.start()
|
other.start()
|
||||||
# The second request waits for the first one's locks instead of deciding on the same row.
|
# The second request waits for the first one's locks instead of deciding on the same row.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ its OWN session, so every test COMMITS its setup before calling it.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import pipeline, quota
|
from app.downloads import errors as dlerrors
|
||||||
|
from app.downloads import formats, pipeline, quota
|
||||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||||
from app.worker import _claim_job
|
from app.worker import _claim_job
|
||||||
|
|
||||||
@@ -155,6 +156,25 @@ def test_unlimited_user_is_never_excluded(db):
|
|||||||
assert _claim_job() == q.id
|
assert _claim_job() == q.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_suspended_users_queued_jobs_wait(db):
|
||||||
|
# Suspending a user blocks their downloads (quota.resolve's advice): a job they queued before
|
||||||
|
# stays queued, unclaimed, and runs once the suspension is lifted.
|
||||||
|
s = User(email="suspended@example.com", is_suspended=True)
|
||||||
|
o = User(email="other@example.com")
|
||||||
|
db.add_all([s, o])
|
||||||
|
db.flush()
|
||||||
|
waits = _job(db, s.id, queue_pos=1)
|
||||||
|
served = _job(db, o.id, queue_pos=2)
|
||||||
|
db.commit()
|
||||||
|
assert _claim_job() == served.id
|
||||||
|
assert _claim_job() is None
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, waits.id).status == "queued"
|
||||||
|
db.get(User, s.id).is_suspended = False
|
||||||
|
db.commit()
|
||||||
|
assert _claim_job() == waits.id
|
||||||
|
|
||||||
|
|
||||||
# --- #3 · list cap --------------------------------------------------------------------------
|
# --- #3 · list cap --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -168,7 +188,7 @@ def test_list_downloads_is_capped(db, monkeypatch):
|
|||||||
_job(db, u.id, queue_pos=i, status="done")
|
_job(db, u.id, queue_pos=i, status="done")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(dl, "_MAX_LIST", 2)
|
monkeypatch.setattr(dl, "MAX_LIST", 2)
|
||||||
rows = dl.list_downloads(user=u, db=db)
|
rows = dl.list_downloads(user=u, db=db)
|
||||||
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
||||||
|
|
||||||
@@ -239,7 +259,7 @@ def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch
|
|||||||
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
|
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
|
||||||
`worker._process_job` would then hand verbatim to someone with an empty library.
|
`worker._process_job` would then hand verbatim to someone with an empty library.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
over, other = User(email="over@example.com"), User(email="other@example.com")
|
over, other = User(email="over@example.com"), User(email="other@example.com")
|
||||||
db.add_all([over, other])
|
db.add_all([over, other])
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -278,3 +298,97 @@ def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch
|
|||||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||||
assert db.get(MediaAsset, shared.id).error is None
|
assert db.get(MediaAsset, shared.id).error is None
|
||||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
assert db.get(DownloadJob, theirs.id).status == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_download_over_the_space_left_says_so_and_releases_the_shared_asset(
|
||||||
|
db, monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
# yt-dlp refuses a file over `max_filesize` by saying so and returning with no file (pinned in
|
||||||
|
# test_formats). That is the owner's storage, not a processing fault: their refusal, in words
|
||||||
|
# true of a cap with space still left, and the shared asset goes back for the next job.
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
u = User(email="nearcap@example.com")
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||||
|
)
|
||||||
|
held = _sized_asset(db, 400, "near-held")
|
||||||
|
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
|
||||||
|
shared = MediaAsset(
|
||||||
|
source_kind="youtube",
|
||||||
|
source_ref="y" * 11,
|
||||||
|
format_sig="s",
|
||||||
|
status="downloading",
|
||||||
|
ref_count=1,
|
||||||
|
)
|
||||||
|
db.add(shared)
|
||||||
|
db.flush()
|
||||||
|
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||||
|
db.commit()
|
||||||
|
caps = []
|
||||||
|
|
||||||
|
def refuses_over_the_cap(*args, max_bytes=None, **kwargs):
|
||||||
|
caps.append(max_bytes)
|
||||||
|
formats._YDL_LOG.debug(
|
||||||
|
f"[download] File is larger than max-filesize (2000 bytes > {max_bytes} bytes). Aborting."
|
||||||
|
)
|
||||||
|
return {"id": "y" * 11, "requested_downloads": [{}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", refuses_over_the_cap)
|
||||||
|
pipeline._download(mine.id, shared.id, "youtube", "y" * 11, {}, u.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, mine.id)
|
||||||
|
assert caps == [600]
|
||||||
|
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
||||||
|
assert job.error == dlerrors.DOWNLOAD_TOO_BIG_MESSAGE
|
||||||
|
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_download_that_outgrows_the_space_left_unannounced_is_refused_and_not_placed(
|
||||||
|
db, monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
# yt-dlp's `max_filesize` only stops a plain HTTP file that announces its size: YouTube's
|
||||||
|
# fragmented formats never meet it (measured on localdev: 718 MB fetched with 1 MB left). The
|
||||||
|
# file it produced is held to what is left by its real size, as a clip is.
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
u = User(email="unannounced@example.com")
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||||
|
)
|
||||||
|
held = _sized_asset(db, 400, "unannounced-held")
|
||||||
|
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
|
||||||
|
shared = MediaAsset(
|
||||||
|
source_kind="youtube",
|
||||||
|
source_ref="z" * 11,
|
||||||
|
format_sig="s",
|
||||||
|
status="downloading",
|
||||||
|
ref_count=1,
|
||||||
|
)
|
||||||
|
db.add(shared)
|
||||||
|
db.flush()
|
||||||
|
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def fetched_past_the_cap(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||||
|
f = staging / "zzzzzzzzzzz.mp4"
|
||||||
|
f.write_bytes(b"v" * 2000) # 600 left
|
||||||
|
return {"id": "z" * 11, "requested_downloads": [{"filepath": str(f)}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", fetched_past_the_cap)
|
||||||
|
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
|
||||||
|
pipeline._download(mine.id, shared.id, "youtube", "z" * 11, {}, u.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, mine.id)
|
||||||
|
assert (job.status, job.error_code, job.error) == (
|
||||||
|
"error",
|
||||||
|
"quota_exceeded",
|
||||||
|
dlerrors.DOWNLOAD_TOO_BIG_MESSAGE,
|
||||||
|
)
|
||||||
|
asset = db.get(MediaAsset, shared.id)
|
||||||
|
assert (asset.status, asset.rel_path) == ("pending", None)
|
||||||
|
assert not any(p.suffix == ".mp4" for p in tmp_path.rglob("*") if "staging" not in p.parts)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def test_resume_resets_a_failed_shared_asset_to_pending(db, as_user):
|
|||||||
|
|
||||||
def test_delete_releases_and_removes_the_job(db, as_user):
|
def test_delete_releases_and_removes_the_job(db, as_user):
|
||||||
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
||||||
r = as_user(u).request("DELETE", f"/api/downloads/{job.id}")
|
r = as_user(u).request("DELETE", f"/api/files/{job.id}")
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(DownloadJob, job.id) is None
|
assert db.get(DownloadJob, job.id) is None
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""An editor clip is held to its owner's storage cap by its real size, as a download and a player
|
||||||
|
clip are: its file is new storage on the owner's footprint, and nothing else checked it.
|
||||||
|
|
||||||
|
Real ffmpeg — the size the check reads is the encoder's.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import errors as dlerrors
|
||||||
|
from app.downloads import pipeline, quota, service
|
||||||
|
from app.downloads import upload as uploadmod
|
||||||
|
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_editor_clip_larger_than_what_is_left_is_refused_and_not_placed(
|
||||||
|
db, make_user, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
src = tmp_path / "in.mp4"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
||||||
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
) # fmt: skip
|
||||||
|
u = make_user()
|
||||||
|
source = uploadmod.ingest_upload(
|
||||||
|
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
||||||
|
)
|
||||||
|
job = service.enqueue_edit(db, u.id, source, {"trim": {"start_s": 0, "end_s": 1.5}})
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
job.status, asset.status = "running", "downloading" # the worker's claim
|
||||||
|
# Ten bytes left: no clip of a real video fits.
|
||||||
|
db.add(
|
||||||
|
DownloadQuota(
|
||||||
|
user_id=u.id,
|
||||||
|
max_bytes=quota.footprint(db, u.id) + 10,
|
||||||
|
max_concurrent=2,
|
||||||
|
max_jobs=100,
|
||||||
|
unlimited=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
pipeline._process_edit(job.id, asset.id)
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
||||||
|
assert (job.status, job.error_code, job.error) == (
|
||||||
|
"error",
|
||||||
|
"quota_exceeded",
|
||||||
|
dlerrors.CLIP_TOO_BIG_MESSAGE,
|
||||||
|
)
|
||||||
|
assert asset.rel_path is None
|
||||||
|
assert not list(tmp_path.glob(".edits/**/*.mp4"))
|
||||||
@@ -258,7 +258,7 @@ def test_an_edit_that_crashes_is_said_once(sink, finalized, monkeypatch, tmp_pat
|
|||||||
"""The line `_process_edit`'s handler used to log before `_fail_edit` logged it again."""
|
"""The line `_process_edit`'s handler used to log before `_fail_edit` logged it again."""
|
||||||
(tmp_path / "src.mp4").write_bytes(b"x")
|
(tmp_path / "src.mp4").write_bytes(b"x")
|
||||||
monkeypatch.setattr(pipeline, "SessionLocal", _EditDb)
|
monkeypatch.setattr(pipeline, "SessionLocal", _EditDb)
|
||||||
monkeypatch.setattr(pipeline.settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(pipeline.settings, "files_root", str(tmp_path))
|
||||||
monkeypatch.setattr(pipeline, "_edit_staging_dir", lambda asset_id: tmp_path / "staging")
|
monkeypatch.setattr(pipeline, "_edit_staging_dir", lambda asset_id: tmp_path / "staging")
|
||||||
|
|
||||||
def odd_spec(*a, **k):
|
def odd_spec(*a, **k):
|
||||||
@@ -300,7 +300,7 @@ def test_a_failed_download_line_does_not_put_back_what_the_adapter_took_out(sink
|
|||||||
RuntimeError("dictionary changed size during iteration"),
|
RuntimeError("dictionary changed size during iteration"),
|
||||||
RecursionError("maximum recursion depth exceeded"),
|
RecursionError("maximum recursion depth exceeded"),
|
||||||
NotImplementedError(),
|
NotImplementedError(),
|
||||||
FileNotFoundError(2, "No such file", "/downloads/Some_Channel/Season_2024/A_Private_Title"),
|
FileNotFoundError(2, "No such file", "/files/Some_Channel/Season_2024/A_Private_Title"),
|
||||||
],
|
],
|
||||||
ids=lambda e: type(e).__name__,
|
ids=lambda e: type(e).__name__,
|
||||||
)
|
)
|
||||||
@@ -499,11 +499,9 @@ def test_a_failed_poster_or_storyboard_names_no_file(sink, tmp_path):
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"err",
|
"err",
|
||||||
[
|
[
|
||||||
subprocess.TimeoutExpired(
|
subprocess.TimeoutExpired(["ffmpeg", "-i", "/files/Some_Channel/A_Private_Title.mp4"], 30),
|
||||||
["ffmpeg", "-i", "/downloads/Some_Channel/A_Private_Title.mp4"], 30
|
FileNotFoundError(2, "No such file", "/files/Some_Channel/A_Private_Title.jpg"),
|
||||||
),
|
subprocess.CalledProcessError(1, ["ffmpeg", "-i", "/files/Some_Channel/x.mp4"]),
|
||||||
FileNotFoundError(2, "No such file", "/downloads/Some_Channel/A_Private_Title.jpg"),
|
|
||||||
subprocess.CalledProcessError(1, ["ffmpeg", "-i", "/downloads/Some_Channel/x.mp4"]),
|
|
||||||
],
|
],
|
||||||
ids=lambda e: type(e).__name__,
|
ids=lambda e: type(e).__name__,
|
||||||
)
|
)
|
||||||
@@ -523,9 +521,7 @@ def test_a_thumbnail_that_cannot_be_copied_names_no_file(sink, monkeypatch, tmp_
|
|||||||
src.write_bytes(b"\xff\xd8\xff rest")
|
src.write_bytes(b"\xff\xd8\xff rest")
|
||||||
|
|
||||||
def refuse(a, b):
|
def refuse(a, b):
|
||||||
raise PermissionError(
|
raise PermissionError(13, "Permission denied", "/files/Some_Channel/A_Private_Title.jpg")
|
||||||
13, "Permission denied", "/downloads/Some_Channel/A_Private_Title.jpg"
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(editmod.shutil, "copyfile", refuse)
|
monkeypatch.setattr(editmod.shutil, "copyfile", refuse)
|
||||||
assert editmod.thumbnail_to_jpg(src, tmp_path / "out.jpg") is None
|
assert editmod.thumbnail_to_jpg(src, tmp_path / "out.jpg") is None
|
||||||
|
|||||||
@@ -0,0 +1,807 @@
|
|||||||
|
"""The Files library's folders and a file's retention: which folder a new file lands in, when a done
|
||||||
|
file goes, and the GC that takes it — warning once, never a file its user kept, never a file another
|
||||||
|
user still holds.
|
||||||
|
|
||||||
|
Pure-logic for the source-key rule; DB-backed for the rest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import folders, formats, service
|
||||||
|
from app.downloads import gc as gcmod
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.models import DownloadJob, FileFolder, MediaAsset, Notification
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("url", "key"),
|
||||||
|
[
|
||||||
|
("https://www.tiktok.com/@a/video/1", "site:tiktok.com"),
|
||||||
|
("https://m.facebook.com/groups/x/posts/2", "site:facebook.com"),
|
||||||
|
("https://vimeo.com/123", "site:vimeo.com"),
|
||||||
|
("https://youtu.be/abcdefghijk", "youtube"),
|
||||||
|
("https://music.youtube.com/watch?v=abcdefghijk", "youtube"),
|
||||||
|
("https://www.youtube.com/watch?v=abcdefghijk", "youtube"),
|
||||||
|
# A front door is only stripped when a real host remains behind it.
|
||||||
|
("https://m.co/x", "site:m.co"),
|
||||||
|
("not a url", "site:unknown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_url_lands_in_its_sites_folder(url, key):
|
||||||
|
assert folders.site_key(url) == key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _asset(db, kind="youtube", ref="abcdefghijk", status="ready", rel="a.mp4", size=10):
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind=kind,
|
||||||
|
source_ref=ref,
|
||||||
|
format_sig=hashlib.sha256(f"{kind}:{ref}".encode()).hexdigest()[:32],
|
||||||
|
status=status,
|
||||||
|
rel_path=rel,
|
||||||
|
size_bytes=size,
|
||||||
|
ref_count=0,
|
||||||
|
title="A title",
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def _job(db, user_id, asset, *, kind="youtube", ref=None, status=JobStatus.DONE, **kw):
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id if asset else None,
|
||||||
|
source_kind=kind,
|
||||||
|
source_ref=ref or (asset.source_ref if asset else "x"),
|
||||||
|
profile_snapshot={},
|
||||||
|
status=status,
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
folders.place(db, job)
|
||||||
|
if asset is not None:
|
||||||
|
service.incr_ref_count(db, asset.id)
|
||||||
|
db.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _folder(db, job) -> FileFolder:
|
||||||
|
return db.get(FileFolder, job.folder_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_kind_of_job_lands_in_its_source_folder(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
yt = _job(db, uid, _asset(db))
|
||||||
|
site = _job(db, uid, _asset(db, "url", "https://www.tiktok.com/@a/video/1"), kind="url")
|
||||||
|
up = _job(db, uid, _asset(db, "upload", "u1"), kind="upload")
|
||||||
|
plex = _job(
|
||||||
|
db, uid, _asset(db, "clip", "c1"), kind="clip", edit_spec={"source": "plex", "ref": "9"}
|
||||||
|
)
|
||||||
|
ytclip = _job(
|
||||||
|
db, uid, _asset(db, "clip", "c2"), kind="clip", edit_spec={"source": "youtube", "ref": "v"}
|
||||||
|
)
|
||||||
|
# An edit of a Plex clip: only its parent knows it came from Plex (the clip's asset says "clip").
|
||||||
|
edit = _job(
|
||||||
|
db, uid, _asset(db, "edit", "e1"), kind="edit", source_job_id=plex.id,
|
||||||
|
source_asset_id=plex.asset_id,
|
||||||
|
) # fmt: skip
|
||||||
|
keys = {j.id: _folder(db, j).source_key for j in (yt, site, up, plex, ytclip, edit)}
|
||||||
|
assert keys == {
|
||||||
|
yt.id: "youtube",
|
||||||
|
site.id: "site:tiktok.com",
|
||||||
|
up.id: "upload",
|
||||||
|
plex.id: "plex",
|
||||||
|
ytclip.id: "youtube",
|
||||||
|
edit.id: "plex", # an edit sits with the file it was cut from
|
||||||
|
}
|
||||||
|
# One folder per origin per user: the two YouTube files share theirs.
|
||||||
|
assert _folder(db, yt).id == _folder(db, ytclip).id
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_edit_whose_parent_is_gone_follows_the_parents_asset(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
src = _asset(db, "url", "https://vimeo.com/1")
|
||||||
|
edit = DownloadJob(
|
||||||
|
user_id=uid, source_kind="edit", source_ref="e", profile_snapshot={},
|
||||||
|
source_job_id=None, source_asset_id=src.id, status=JobStatus.DONE,
|
||||||
|
) # fmt: skip
|
||||||
|
assert folders.source_key_for(db, edit) == "site:vimeo.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_edit_keeps_its_origin_after_its_parent_and_its_file_are_gone(db, make_user):
|
||||||
|
# The parent download expires, its file goes with its last holder, and both of the edit's
|
||||||
|
# pointers to it are cleared: the edit must still go back to the YouTube folder it came from.
|
||||||
|
uid = make_user().id
|
||||||
|
parent = _job(db, uid, _asset(db))
|
||||||
|
edit = _job(
|
||||||
|
db, uid, _asset(db, "edit", "e1"), kind="edit", source_job_id=parent.id,
|
||||||
|
source_asset_id=parent.asset_id,
|
||||||
|
) # fmt: skip
|
||||||
|
home = _folder(db, edit)
|
||||||
|
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||||
|
db.add(own)
|
||||||
|
db.flush()
|
||||||
|
folders.move(db, edit, own)
|
||||||
|
edit.source_job_id = edit.source_asset_id = None # what ON DELETE SET NULL leaves
|
||||||
|
db.delete(parent)
|
||||||
|
db.commit()
|
||||||
|
folders.move(db, edit, home)
|
||||||
|
assert edit.folder_id == home.id
|
||||||
|
folders.move(db, edit, own)
|
||||||
|
folders.delete_own(db, own)
|
||||||
|
assert edit.folder_id == home.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_folder_deleted_while_a_file_goes_in_takes_that_file_home_too(db, make_user):
|
||||||
|
# Before, the delete missed the file still on its way in and failed on the foreign key (a 500),
|
||||||
|
# or the file's insert did after the folder went.
|
||||||
|
import threading
|
||||||
|
|
||||||
|
uid = make_user().id
|
||||||
|
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||||
|
db.add(own)
|
||||||
|
db.commit()
|
||||||
|
own_id = own.id
|
||||||
|
job = _job(db, uid, _asset(db))
|
||||||
|
home = job.folder_id
|
||||||
|
target = folders.own_folder(db, uid, own_id) # held from the check until this commits
|
||||||
|
outcome: list[object] = []
|
||||||
|
|
||||||
|
def delete_it():
|
||||||
|
other = SessionLocal()
|
||||||
|
try:
|
||||||
|
outcome.append(folders.delete_own(other, other.get(FileFolder, own_id)))
|
||||||
|
other.commit()
|
||||||
|
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||||
|
outcome.append(e)
|
||||||
|
finally:
|
||||||
|
other.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=delete_it)
|
||||||
|
t.start()
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the delete waits for the file, even before it is written
|
||||||
|
folders.move(db, job, target)
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [1]
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).folder_id == home
|
||||||
|
assert db.get(FileFolder, own_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_folders_are_per_user(db, make_user):
|
||||||
|
a, b = make_user().id, make_user().id
|
||||||
|
asset = _asset(db)
|
||||||
|
ja, jb = _job(db, a, asset), _job(db, b, asset)
|
||||||
|
assert ja.folder_id != jb.folder_id
|
||||||
|
assert (_folder(db, ja).user_id, _folder(db, jb).user_id) == (a, b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_source_folder_keeps_files_the_retention_days_and_uploads_forever(db, make_user):
|
||||||
|
sysconfig.set_value(db, "download_retention_days", 12)
|
||||||
|
db.commit()
|
||||||
|
uid = make_user().id
|
||||||
|
yt = _job(db, uid, _asset(db))
|
||||||
|
up = _job(db, uid, _asset(db, "upload", "u1"), kind="upload")
|
||||||
|
assert _folder(db, yt).retention_days == 12
|
||||||
|
assert _folder(db, up).retention_days is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_enqueue_places_the_job_and_a_cache_hit_is_stamped_at_once(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
spec = formats.normalize({})
|
||||||
|
first = service.enqueue(db, uid, "youtube", "abcdefghijk", spec)
|
||||||
|
assert _folder(db, first).source_key == "youtube"
|
||||||
|
assert first.expires_at is None # queued: its retention starts when it is done
|
||||||
|
asset = db.get(MediaAsset, first.asset_id)
|
||||||
|
asset.status, asset.rel_path = "ready", "a.mp4"
|
||||||
|
db.commit()
|
||||||
|
other = make_user().id
|
||||||
|
hit = service.enqueue(db, other, "youtube", "abcdefghijk", spec)
|
||||||
|
assert hit.status == JobStatus.DONE
|
||||||
|
days = _folder(db, hit).retention_days
|
||||||
|
assert hit.expires_at is not None
|
||||||
|
assert abs(hit.expires_at - (NOW + timedelta(days=days))) < timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finishing_worker_stamps_the_expiry_unless_kept(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
asset = _asset(db)
|
||||||
|
plain = _job(db, uid, asset, status=JobStatus.RUNNING)
|
||||||
|
kept = _job(db, uid, asset, status=JobStatus.RUNNING, kept=True)
|
||||||
|
for job in (plain, kept):
|
||||||
|
assert service.finalize_if_running(db, job.id, status=JobStatus.DONE, progress=100)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(plain)
|
||||||
|
db.refresh(kept)
|
||||||
|
assert plain.expires_at is not None
|
||||||
|
assert kept.expires_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def _gc(db):
|
||||||
|
return gcmod.run_download_gc(db)
|
||||||
|
|
||||||
|
|
||||||
|
def _notices(db, uid, kind):
|
||||||
|
return db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Notification)
|
||||||
|
.where(Notification.user_id == uid, Notification.type == kind)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gc_warns_once_then_takes_the_file_out_of_the_library(db, make_user, _root):
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
uid = make_user().id
|
||||||
|
job = _job(db, uid, _asset(db), expires_at=NOW + timedelta(hours=12))
|
||||||
|
_gc(db)
|
||||||
|
_gc(db)
|
||||||
|
assert _notices(db, uid, "download_expiring") == 1
|
||||||
|
job.expires_at = NOW - timedelta(minutes=1)
|
||||||
|
db.commit()
|
||||||
|
assert _gc(db)["expired"] == 1
|
||||||
|
assert db.get(DownloadJob, job.id) is None
|
||||||
|
assert _notices(db, uid, "download_expired") == 1
|
||||||
|
# Its last holder gone, the shared file and its row go too.
|
||||||
|
assert not (_root / "a.mp4").exists()
|
||||||
|
assert db.execute(select(func.count()).select_from(MediaAsset)).scalar() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("restart", ["unkeep", "move", "retention"])
|
||||||
|
def test_a_file_whose_clock_starts_again_is_warned_again(db, make_user, _root, restart):
|
||||||
|
# The GC warns once per expiry; a file whose expiry was moved must be warned before the new one.
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
uid = make_user().id
|
||||||
|
job = _job(db, uid, _asset(db), expires_at=NOW + timedelta(hours=12))
|
||||||
|
_gc(db)
|
||||||
|
assert job.expiry_warned
|
||||||
|
if restart == "unkeep":
|
||||||
|
folders.set_kept(db, job, True)
|
||||||
|
folders.set_kept(db, job, False)
|
||||||
|
elif restart == "move":
|
||||||
|
home = _folder(db, job)
|
||||||
|
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||||
|
db.add(own)
|
||||||
|
db.flush()
|
||||||
|
folders.move(db, job, own)
|
||||||
|
folders.move(db, job, home)
|
||||||
|
else:
|
||||||
|
folders.set_retention(db, _folder(db, job), 90)
|
||||||
|
db.commit()
|
||||||
|
assert not job.expiry_warned
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gc_never_takes_a_kept_file(db, make_user, _root):
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
uid = make_user().id
|
||||||
|
job = _job(db, uid, _asset(db), kept=True, expires_at=NOW - timedelta(days=1))
|
||||||
|
assert _gc(db)["expired"] == 0
|
||||||
|
assert db.get(DownloadJob, job.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_users_expiry_leaves_another_users_copy(db, make_user, _root):
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
a, b = make_user().id, make_user().id
|
||||||
|
asset = _asset(db)
|
||||||
|
gone = _job(db, a, asset, expires_at=NOW - timedelta(minutes=1))
|
||||||
|
stays = _job(db, b, asset, expires_at=NOW + timedelta(days=20))
|
||||||
|
_gc(db)
|
||||||
|
assert db.get(DownloadJob, gone.id) is None
|
||||||
|
assert db.get(DownloadJob, stays.id) is not None
|
||||||
|
assert (_root / "a.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _meanwhile(monkeypatch, act, step="_relock_expired"):
|
||||||
|
"""Run `act` in another session (another request) between a GC pass's SELECT and its handling of
|
||||||
|
each row it found — the window a user's click can land in. `step` is the pass's per-row re-read
|
||||||
|
(expiry, warning, eviction); `act` gets that row's id."""
|
||||||
|
real = getattr(gcmod, step)
|
||||||
|
|
||||||
|
def wrapped(db, key, *rest):
|
||||||
|
other = SessionLocal()
|
||||||
|
try:
|
||||||
|
act(other, key)
|
||||||
|
other.commit()
|
||||||
|
finally:
|
||||||
|
other.close()
|
||||||
|
return real(db, key, *rest)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gcmod, step, wrapped)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_kept_while_the_gc_runs_stays(db, make_user, _root, monkeypatch):
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
job = _job(db, make_user().id, _asset(db), expires_at=NOW - timedelta(minutes=1))
|
||||||
|
_meanwhile(monkeypatch, lambda other, jid: setattr(other.get(DownloadJob, jid), "kept", True))
|
||||||
|
assert _gc(db)["expired"] == 0
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id) is not None
|
||||||
|
assert (_root / "a.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_deleted_while_the_gc_runs_is_released_once(db, make_user, _root, monkeypatch):
|
||||||
|
# Released twice, the shared file would go from under the other user's copy.
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
a, b = make_user().id, make_user().id
|
||||||
|
asset = _asset(db)
|
||||||
|
_job(db, a, asset, expires_at=NOW - timedelta(minutes=1))
|
||||||
|
stays = _job(db, b, asset, expires_at=NOW + timedelta(days=20))
|
||||||
|
|
||||||
|
def user_deletes(other, jid):
|
||||||
|
service.delete(other, other.get(DownloadJob, jid))
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, user_deletes)
|
||||||
|
_gc(db)
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, stays.id).asset_id == asset.id
|
||||||
|
assert db.get(MediaAsset, asset.id).ref_count == 1
|
||||||
|
assert (_root / "a.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cache_cap_never_evicts_a_kept_file_and_an_evicted_one_leaves_every_library(
|
||||||
|
db, make_user, _root
|
||||||
|
):
|
||||||
|
sysconfig.set_value(db, "download_total_max_bytes", 15)
|
||||||
|
db.commit()
|
||||||
|
a, b = make_user().id, make_user().id
|
||||||
|
for name in ("old.mp4", "new.mp4"):
|
||||||
|
(_root / name).write_bytes(b"x")
|
||||||
|
old = _asset(db, ref="oldoldoldol", rel="old.mp4", size=10)
|
||||||
|
new = _asset(db, ref="newnewnewne", rel="new.mp4", size=10)
|
||||||
|
old.last_access_at = NOW - timedelta(days=9)
|
||||||
|
new.last_access_at = NOW - timedelta(days=1)
|
||||||
|
kept = _job(db, a, old, kept=True) # the older file, but kept: the newer one goes instead
|
||||||
|
ja, jb = _job(db, a, new), _job(db, b, new)
|
||||||
|
assert _gc(db)["evicted"] == 1
|
||||||
|
assert db.get(DownloadJob, kept.id) is not None
|
||||||
|
assert db.get(DownloadJob, ja.id) is None and db.get(DownloadJob, jb.id) is None
|
||||||
|
assert _notices(db, a, "download_evicted") == 1
|
||||||
|
assert _notices(db, b, "download_evicted") == 1
|
||||||
|
assert (_root / "old.mp4").exists() and not (_root / "new.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_deleted_while_the_gc_warns_does_not_stop_the_run(db, make_user, _root, monkeypatch):
|
||||||
|
# Written back from a stale read, the warning of a deleted file failed the whole run: nothing
|
||||||
|
# expired or was evicted that cycle, nor the next while the race lasted.
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
(_root / "b.mp4").write_bytes(b"x")
|
||||||
|
uid = make_user().id
|
||||||
|
_job(db, uid, _asset(db), expires_at=NOW + timedelta(hours=1))
|
||||||
|
gone = _job(
|
||||||
|
db, uid, _asset(db, ref="bbbbbbbbbbb", rel="b.mp4"), expires_at=NOW - timedelta(minutes=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
def user_deletes(other, jid):
|
||||||
|
service.delete(other, other.get(DownloadJob, jid))
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, user_deletes, step="_mark_warned")
|
||||||
|
result = _gc(db)
|
||||||
|
assert (result["warned"], result["expired"]) == (0, 1)
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, gone.id) is None
|
||||||
|
assert _notices(db, uid, "download_expiring") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_re_timed_while_the_gc_warns_keeps_its_new_expiry_unwarned(
|
||||||
|
db, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
# Moved, un-kept or its folder's retention changed: a fresh expiry, warned when it comes — not
|
||||||
|
# marked warned now for a date it no longer has.
|
||||||
|
uid = make_user().id
|
||||||
|
job = _job(db, uid, _asset(db), expires_at=NOW + timedelta(hours=1))
|
||||||
|
later = NOW + timedelta(days=30)
|
||||||
|
|
||||||
|
def re_timed(other, jid):
|
||||||
|
moved = other.get(DownloadJob, jid)
|
||||||
|
moved.expires_at = later
|
||||||
|
moved.expiry_warned = False
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, re_timed, step="_mark_warned")
|
||||||
|
assert _gc(db)["warned"] == 0
|
||||||
|
db.expire_all()
|
||||||
|
fresh = db.get(DownloadJob, job.id)
|
||||||
|
assert (fresh.expires_at, fresh.expiry_warned) == (later, False)
|
||||||
|
assert _notices(db, uid, "download_expiring") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _two_over_the_cap(db, make_user, _root):
|
||||||
|
sysconfig.set_value(db, "download_total_max_bytes", 15)
|
||||||
|
db.commit()
|
||||||
|
a, b = make_user().id, make_user().id
|
||||||
|
for name in ("old.mp4", "new.mp4"):
|
||||||
|
(_root / name).write_bytes(b"x")
|
||||||
|
old = _asset(db, ref="oldoldoldol", rel="old.mp4", size=10)
|
||||||
|
new = _asset(db, ref="newnewnewne", rel="new.mp4", size=10)
|
||||||
|
old.last_access_at = NOW - timedelta(days=9)
|
||||||
|
new.last_access_at = NOW - timedelta(days=1)
|
||||||
|
return _job(db, a, old), _job(db, b, new)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_deleted_while_the_cache_is_over_its_cap_frees_that_space_and_no_more(
|
||||||
|
db, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
# Its bytes still counted, the pass went on to evict another user's file for space already free.
|
||||||
|
jold, jnew = _two_over_the_cap(db, make_user, _root)
|
||||||
|
|
||||||
|
def holder_deletes(other, asset_id):
|
||||||
|
if asset_id == jold.asset_id:
|
||||||
|
service.delete(other, other.get(DownloadJob, jold.id))
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, holder_deletes, step="_relock_evictable")
|
||||||
|
assert _gc(db)["evicted"] == 0
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, jnew.id) is not None
|
||||||
|
assert (_root / "new.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_kept_while_the_cache_is_over_its_cap_stays(db, make_user, _root, monkeypatch):
|
||||||
|
jold, jnew = _two_over_the_cap(db, make_user, _root)
|
||||||
|
|
||||||
|
def holder_keeps(other, asset_id):
|
||||||
|
if asset_id == jold.asset_id:
|
||||||
|
other.get(DownloadJob, jold.id).kept = True
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, holder_keeps, step="_relock_evictable")
|
||||||
|
assert _gc(db)["evicted"] == 1 # the newer one goes in its place
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, jold.id) is not None
|
||||||
|
assert (_root / "old.mp4").exists()
|
||||||
|
assert db.get(DownloadJob, jnew.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _in_thread(work):
|
||||||
|
"""Run `work(other_session)` in another thread and session; returns (thread, outcome list)."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
outcome: list[object] = []
|
||||||
|
|
||||||
|
def run():
|
||||||
|
other = SessionLocal()
|
||||||
|
try:
|
||||||
|
outcome.append(work(other))
|
||||||
|
other.commit()
|
||||||
|
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||||
|
outcome.append(e)
|
||||||
|
finally:
|
||||||
|
other.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=run)
|
||||||
|
t.start()
|
||||||
|
return t, outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_retention_changed_while_a_file_goes_in_times_that_file_too(db, make_user):
|
||||||
|
# The change used to list the folder's files before the arriving one was in, so that one kept the
|
||||||
|
# old retention — deleted later from a folder now set to keep files forever.
|
||||||
|
uid = make_user().id
|
||||||
|
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine", retention_days=7)
|
||||||
|
db.add(own)
|
||||||
|
db.commit()
|
||||||
|
own_id = own.id
|
||||||
|
job = _job(db, uid, _asset(db))
|
||||||
|
target = folders.own_folder(db, uid, own_id) # held from the check until this commits
|
||||||
|
t, outcome = _in_thread(
|
||||||
|
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
|
||||||
|
)
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the change waits for the file being put in
|
||||||
|
folders.move(db, job, target) # stamped with the 7 days it sees
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [7] # the retention it replaced
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).expires_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_retention_changed_while_a_file_finishes_there_times_that_file_too(db, make_user):
|
||||||
|
# The worker's finish read the folder's old retention unlocked, while the change listed the done
|
||||||
|
# files before this one was among them.
|
||||||
|
uid = make_user().id
|
||||||
|
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine", retention_days=7)
|
||||||
|
db.add(own)
|
||||||
|
db.commit()
|
||||||
|
own_id = own.id
|
||||||
|
job = _job(db, uid, _asset(db), status=JobStatus.RUNNING)
|
||||||
|
folders.move(db, job, own)
|
||||||
|
db.commit()
|
||||||
|
job.status = JobStatus.DONE
|
||||||
|
folders.stamp_done(db, job) # the finish: stamped with the 7 days it reads
|
||||||
|
db.flush()
|
||||||
|
t, outcome = _in_thread(
|
||||||
|
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
|
||||||
|
)
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the change waits for the file being stamped
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [7] # the retention it replaced
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).expires_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def _deleted_uncommitted(db, job):
|
||||||
|
"""`service.delete` up to its commit: the file is gone in this session, its row still locked."""
|
||||||
|
service.release_asset(db, job)
|
||||||
|
db.delete(job)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_moved_out_while_its_folder_is_deleted_stays_where_it_went(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
f = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="F")
|
||||||
|
g = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="G")
|
||||||
|
db.add_all([f, g])
|
||||||
|
db.commit()
|
||||||
|
f_id, g_id = f.id, g.id
|
||||||
|
job = _job(db, uid, _asset(db))
|
||||||
|
folders.move(db, job, f)
|
||||||
|
db.commit()
|
||||||
|
folders.move(db, job, folders.own_folder(db, uid, g_id))
|
||||||
|
db.flush() # the move is written, not yet committed
|
||||||
|
t, outcome = _in_thread(lambda other: folders.delete_own(other, other.get(FileFolder, f_id)))
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the delete waits for the file it would take home
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [0]
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).folder_id == g_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_deleted_while_its_folder_is_deleted_does_not_fail_the_delete(db, make_user, _root):
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
uid = make_user().id
|
||||||
|
f = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="F")
|
||||||
|
db.add(f)
|
||||||
|
db.commit()
|
||||||
|
f_id = f.id
|
||||||
|
job = _job(db, uid, _asset(db))
|
||||||
|
folders.move(db, job, f)
|
||||||
|
db.commit()
|
||||||
|
_deleted_uncommitted(db, db.get(DownloadJob, job.id))
|
||||||
|
t, outcome = _in_thread(lambda other: folders.delete_own(other, other.get(FileFolder, f_id)))
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the delete waits for the file being deleted
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [0]
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(FileFolder, f_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _own_with_a_file(db, uid, retention=30):
|
||||||
|
"""A user's own folder keeping files `retention` days, holding one done file due in as many."""
|
||||||
|
own = FileFolder(
|
||||||
|
user_id=uid, kind=folders.FolderKind.OWN, name="Mine", retention_days=retention
|
||||||
|
)
|
||||||
|
db.add(own)
|
||||||
|
db.flush()
|
||||||
|
job = _job(db, uid, _asset(db))
|
||||||
|
job.folder_id = own.id
|
||||||
|
job.expires_at = NOW + timedelta(days=retention)
|
||||||
|
db.commit()
|
||||||
|
return own.id, job.id
|
||||||
|
|
||||||
|
|
||||||
|
def _elsewhere(act):
|
||||||
|
other = SessionLocal()
|
||||||
|
try:
|
||||||
|
act(other)
|
||||||
|
other.commit()
|
||||||
|
finally:
|
||||||
|
other.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_retention_change_works_from_what_a_change_before_it_left(db, make_user):
|
||||||
|
# The locked re-read was never fetched, so the change shifted the files from the value read before
|
||||||
|
# its wait: 30 -> 10 -> 60 left them due in 40 days under a folder saying 60.
|
||||||
|
own_id, job_id = _own_with_a_file(db, make_user().id)
|
||||||
|
stale = db.get(FileFolder, own_id) # read before the other change lands
|
||||||
|
_elsewhere(lambda other: folders.set_retention(other, other.get(FileFolder, own_id), 10))
|
||||||
|
assert folders.set_retention(db, stale, 60) == 10
|
||||||
|
db.commit()
|
||||||
|
db.expire_all()
|
||||||
|
due = db.get(DownloadJob, job_id).expires_at - NOW
|
||||||
|
assert timedelta(days=59) < due < timedelta(days=61)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_retention_change_to_a_folder_deleted_meanwhile_is_refused(db, make_user):
|
||||||
|
own_id, _ = _own_with_a_file(db, make_user().id)
|
||||||
|
stale = db.get(FileFolder, own_id)
|
||||||
|
_elsewhere(lambda other: folders.delete_own(other, other.get(FileFolder, own_id)))
|
||||||
|
with pytest.raises(folders.FolderError, match="file_folder_gone"):
|
||||||
|
folders.set_retention(db, stale, 60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_moved_out_while_its_folder_retention_changes_keeps_its_new_folders(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
own_id, job_id = _own_with_a_file(db, uid)
|
||||||
|
g = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="G", retention_days=7)
|
||||||
|
db.add(g)
|
||||||
|
db.commit()
|
||||||
|
g_id = g.id
|
||||||
|
folders.move(db, db.get(DownloadJob, job_id), folders.own_folder(db, uid, g_id))
|
||||||
|
db.flush() # the move is written, not yet committed
|
||||||
|
t, outcome = _in_thread(
|
||||||
|
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
|
||||||
|
)
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the change waits for the file it would time
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [30]
|
||||||
|
db.expire_all()
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
assert job.folder_id == g_id
|
||||||
|
assert job.expires_at is not None # G's 7 days, not the forever of the folder it left
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_deleted_while_its_folder_retention_changes_does_not_fail_the_change(
|
||||||
|
db, make_user, _root
|
||||||
|
):
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
own_id, job_id = _own_with_a_file(db, make_user().id)
|
||||||
|
_deleted_uncommitted(db, db.get(DownloadJob, job_id))
|
||||||
|
t, outcome = _in_thread(
|
||||||
|
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
|
||||||
|
)
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the change waits for the file it would time
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [30]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_finishing_while_its_folder_is_deleted_gets_its_new_folders_retention(db, make_user):
|
||||||
|
# The finish read the folder unlocked, then waited out the delete and found no folder: the file
|
||||||
|
# was done with no expiry at all, in a source folder that keeps files 30 days.
|
||||||
|
uid = make_user().id
|
||||||
|
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine", retention_days=7)
|
||||||
|
db.add(own)
|
||||||
|
db.commit()
|
||||||
|
own_id = own.id
|
||||||
|
job = _job(db, uid, _asset(db), status=JobStatus.RUNNING)
|
||||||
|
job_id = job.id
|
||||||
|
folders.move(db, job, own)
|
||||||
|
db.commit()
|
||||||
|
folders.delete_own(db, db.get(FileFolder, own_id))
|
||||||
|
db.flush() # the delete, not yet committed
|
||||||
|
t, outcome = _in_thread(
|
||||||
|
lambda other: service.finalize_if_running(other, job_id, status=JobStatus.DONE)
|
||||||
|
)
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # the finish waits for the delete
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
assert outcome == [True]
|
||||||
|
db.expire_all()
|
||||||
|
done = db.get(DownloadJob, job_id)
|
||||||
|
assert done.folder_id != own_id
|
||||||
|
assert done.expires_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _an_upload_over_the_cap_too(db, uid, _root):
|
||||||
|
(_root / "u.bin").write_bytes(b"x")
|
||||||
|
return _job(db, uid, _asset(db, "upload", "u1", rel="u.bin", size=10), kind="upload")
|
||||||
|
|
||||||
|
|
||||||
|
def test_space_freed_elsewhere_while_the_cache_is_over_its_cap_evicts_no_more(
|
||||||
|
db, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
# The pass counted the disk once: an upload deleted meanwhile still counted, so a second user's
|
||||||
|
# file went for space already free.
|
||||||
|
jold, jnew = _two_over_the_cap(db, make_user, _root)
|
||||||
|
up = _an_upload_over_the_cap_too(db, make_user().id, _root)
|
||||||
|
|
||||||
|
def upload_deleted(other, asset_id):
|
||||||
|
if asset_id == jold.asset_id:
|
||||||
|
service.delete(other, other.get(DownloadJob, up.id))
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, upload_deleted, step="_relock_evictable")
|
||||||
|
assert _gc(db)["evicted"] == 1
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, jnew.id) is not None
|
||||||
|
assert (_root / "new.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_asked_for_again_while_the_cache_is_over_its_cap_stays(
|
||||||
|
db, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
# A cache hit between the pass's SELECT and its lock: the file the user just asked for went.
|
||||||
|
jold, jnew = _two_over_the_cap(db, make_user, _root)
|
||||||
|
|
||||||
|
def asked_again(other, asset_id):
|
||||||
|
if asset_id == jold.asset_id:
|
||||||
|
other.get(MediaAsset, asset_id).last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
_meanwhile(monkeypatch, asked_again, step="_relock_evictable")
|
||||||
|
_gc(db)
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, jold.id) is not None
|
||||||
|
assert (_root / "old.mp4").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_asked_for_while_its_cached_copy_is_freed_gets_a_fresh_one(db, make_user):
|
||||||
|
# The enqueue read the cached copy unlocked: freed meanwhile, its job pointed at a row that was gone
|
||||||
|
# (a foreign-key failure, a 500).
|
||||||
|
asset = _asset(db)
|
||||||
|
asset_id, sig = asset.id, asset.format_sig
|
||||||
|
db.commit()
|
||||||
|
db.execute(select(MediaAsset).where(MediaAsset.id == asset_id).with_for_update())
|
||||||
|
db.delete(db.get(MediaAsset, asset_id))
|
||||||
|
db.flush() # the release, not yet committed
|
||||||
|
t, outcome = _in_thread(
|
||||||
|
lambda other: service.get_or_create_asset(other, "youtube", "abcdefghijk", sig).id
|
||||||
|
)
|
||||||
|
t.join(timeout=1)
|
||||||
|
assert t.is_alive() # it waits for the release
|
||||||
|
db.commit()
|
||||||
|
t.join(timeout=10)
|
||||||
|
[fresh] = outcome
|
||||||
|
assert isinstance(fresh, int) and fresh != asset_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_volume_in_the_librarys_place_is_noticed(db, make_user, _root):
|
||||||
|
# A fresh volume is a real, writable mount: only the files the library names tell it is the wrong one.
|
||||||
|
assert service.library_left_behind(db, str(_root)) is False # nothing named yet
|
||||||
|
_asset(db, rel="a.mp4")
|
||||||
|
db.commit()
|
||||||
|
assert service.library_left_behind(db, str(_root)) is True
|
||||||
|
(_root / "a.mp4").write_bytes(b"x")
|
||||||
|
assert service.library_left_behind(db, str(_root)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_a_user_takes_their_folders_and_files_with_them(db, make_user):
|
||||||
|
# Both tables cascade from users; the files' folder FK refuses deleting a folder that still holds
|
||||||
|
# files, which must not stop the account's own deletion.
|
||||||
|
user = make_user()
|
||||||
|
_job(db, user.id, _asset(db))
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
assert db.execute(select(func.count()).select_from(FileFolder)).scalar() == 0
|
||||||
|
assert db.execute(select(func.count()).select_from(DownloadJob)).scalar() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_longest_host_has_room_for_its_folder(db, make_user):
|
||||||
|
label = "a" * 63
|
||||||
|
host = ".".join([label, label, label, "b" * 58]) + ".hu" # 253 characters, the DNS maximum
|
||||||
|
assert len(host) == 253
|
||||||
|
uid = make_user().id
|
||||||
|
job = _job(db, uid, _asset(db, "url", f"https://{host}/v"), kind="url")
|
||||||
|
assert _folder(db, job).source_key == f"site:{host}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ready_notice_names_the_file_and_its_folder(db, make_user):
|
||||||
|
# The notice's "Open" goes to the file in its folder, so both ride along as data.
|
||||||
|
from app.downloads import pipeline
|
||||||
|
|
||||||
|
user = make_user()
|
||||||
|
job = _job(db, user.id, _asset(db))
|
||||||
|
pipeline._notify_download_done(db, user.id, "A title", job.id, kind="download")
|
||||||
|
db.commit()
|
||||||
|
note = db.scalars(select(Notification).where(Notification.user_id == user.id)).one()
|
||||||
|
assert note.type == "download_done"
|
||||||
|
assert note.data == {
|
||||||
|
"title": "A title",
|
||||||
|
"kind": "download",
|
||||||
|
"job_id": job.id,
|
||||||
|
"folder_id": job.folder_id,
|
||||||
|
}
|
||||||
|
assert job.folder_id is not None
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
"""The Files library API (`routes.files`), DB-lane: folders (list, own create/rename/delete, a
|
||||||
|
folder's retention), the file list (done only, by folder, searched, sorted, shared with me), a
|
||||||
|
file's rename / move / keep / delete, the upload into a folder, and the file's hash.
|
||||||
|
|
||||||
|
Every route is also held to the caller's own rows: another user's folder or file answers like a
|
||||||
|
missing one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import folders, service
|
||||||
|
from app.downloads import upload as uploadmod
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.models import AuditLog, DownloadJob, DownloadShare, FileFolder, MediaAsset
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _file(db, user_id, *, kind="youtube", ref=None, title="A title", size=10, rel=None, **kw):
|
||||||
|
"""A done library file: an asset with bytes on disk-path terms, and the user's job over it."""
|
||||||
|
n = db.execute(select(MediaAsset.id)).all()
|
||||||
|
ref = ref or f"vid{len(n):08d}"
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind=kind,
|
||||||
|
source_ref=ref,
|
||||||
|
format_sig=f"sig-{ref}",
|
||||||
|
status="ready",
|
||||||
|
rel_path=rel or f"{ref}.mp4",
|
||||||
|
size_bytes=size,
|
||||||
|
ref_count=0,
|
||||||
|
title=title,
|
||||||
|
media_kind="video",
|
||||||
|
container="mp4",
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind=kind,
|
||||||
|
source_ref=ref,
|
||||||
|
profile_snapshot={},
|
||||||
|
status=kw.pop("status", JobStatus.DONE),
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
folders.place(db, job)
|
||||||
|
folders.stamp_done(db, job)
|
||||||
|
service.incr_ref_count(db, asset.id)
|
||||||
|
db.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _own(db, user_id, name="Saját"):
|
||||||
|
folder = FileFolder(user_id=user_id, kind=folders.FolderKind.OWN, name=name)
|
||||||
|
db.add(folder)
|
||||||
|
db.commit()
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def _audits(db, action):
|
||||||
|
return db.execute(select(AuditLog).where(AuditLog.action == action)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
# --- folders -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_folder_list_counts_each_folders_done_files_and_is_the_callers_own(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
alice, bob = make_user(), make_user()
|
||||||
|
_file(db, alice.id, size=10)
|
||||||
|
_file(db, alice.id, size=5)
|
||||||
|
_file(db, alice.id, kind="url", ref="https://vimeo.com/1", size=7)
|
||||||
|
_file(db, alice.id, status=JobStatus.RUNNING) # not in the library yet
|
||||||
|
_file(db, bob.id)
|
||||||
|
got = {
|
||||||
|
(f["kind"], f["source_key"]): (f["count"], f["size_bytes"])
|
||||||
|
for f in as_user(alice).get("/api/files/folders").json()
|
||||||
|
}
|
||||||
|
assert got == {("source", "youtube"): (2, 15), ("source", "site:vimeo.com"): (1, 7)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_own_folder_is_made_once_per_name_whatever_its_case(db, make_user, as_user):
|
||||||
|
alice = make_user()
|
||||||
|
c = as_user(alice)
|
||||||
|
made = c.post("/api/files/folders", json={"name": "Nyaralás"})
|
||||||
|
assert made.status_code == 200, made.text
|
||||||
|
assert (made.json()["kind"], made.json()["name"]) == ("own", "Nyaralás")
|
||||||
|
again = c.post("/api/files/folders", json={"name": "NYARALÁS"})
|
||||||
|
assert again.status_code == 409
|
||||||
|
assert again.json()["detail"]["reason"] == "file_folder_name_taken"
|
||||||
|
blank = c.post("/api/files/folders", json={"name": " "})
|
||||||
|
assert blank.json()["detail"]["reason"] == "file_folder_name_required"
|
||||||
|
# Another user may have a folder of the same name.
|
||||||
|
assert as_user(make_user()).post("/api/files/folders", json={"name": "Nyaralás"}).is_success
|
||||||
|
assert len(_audits(db, "file_folder_create")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_an_own_folder_is_renamed_and_only_by_its_user(db, make_user, as_user):
|
||||||
|
alice = make_user()
|
||||||
|
c = as_user(alice)
|
||||||
|
src = db.get(FileFolder, _file(db, alice.id).folder_id)
|
||||||
|
own = _own(db, alice.id, "Régi")
|
||||||
|
taken = _own(db, alice.id, "Foglalt")
|
||||||
|
ok = c.patch(f"/api/files/folders/{own.id}", json={"name": "Új"})
|
||||||
|
assert ok.json()["name"] == "Új"
|
||||||
|
clash = c.patch(f"/api/files/folders/{own.id}", json={"name": taken.name.lower()})
|
||||||
|
assert clash.json()["detail"]["reason"] == "file_folder_name_taken"
|
||||||
|
no = c.patch(f"/api/files/folders/{src.id}", json={"name": "YouTube 2"})
|
||||||
|
assert no.status_code == 409 and no.json()["detail"]["reason"] == "file_folder_not_own"
|
||||||
|
stranger = as_user(make_user()).patch(f"/api/files/folders/{own.id}", json={"name": "X"})
|
||||||
|
assert stranger.status_code == 404
|
||||||
|
db.refresh(own)
|
||||||
|
assert own.name == "Új"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_shorter_retention_moves_each_files_expiry_but_never_inside_the_warning_window(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
sysconfig.set_value(db, "download_gc_grace_days", 3)
|
||||||
|
db.commit()
|
||||||
|
alice = make_user()
|
||||||
|
fresh = _file(db, alice.id) # expires in 30 days
|
||||||
|
old = _file(db, alice.id)
|
||||||
|
old.expires_at = NOW + timedelta(days=10) # 20 days old already
|
||||||
|
kept = _file(db, alice.id, kept=True)
|
||||||
|
db.commit()
|
||||||
|
folder_id = fresh.folder_id
|
||||||
|
r = as_user(alice).patch(f"/api/files/folders/{folder_id}", json={"retention_days": 20})
|
||||||
|
assert r.json()["retention_days"] == 20
|
||||||
|
for j in (fresh, old, kept):
|
||||||
|
db.refresh(j)
|
||||||
|
# 30 → 20 days: each loses 10 — the one with 10 left would be due now, so it gets the window.
|
||||||
|
assert abs(fresh.expires_at - (NOW + timedelta(days=20))) < timedelta(minutes=5)
|
||||||
|
assert abs(old.expires_at - (NOW + timedelta(days=3))) < timedelta(minutes=5)
|
||||||
|
assert kept.expires_at is None
|
||||||
|
forever = as_user(alice).patch(f"/api/files/folders/{folder_id}", json={"retention_days": None})
|
||||||
|
assert forever.json()["retention_days"] is None
|
||||||
|
db.refresh(fresh)
|
||||||
|
assert fresh.expires_at is None
|
||||||
|
rows = _audits(db, "file_folder_retention")
|
||||||
|
assert [(a.before, a.after) for a in rows] == [
|
||||||
|
({"retention_days": 30}, {"retention_days": 20}),
|
||||||
|
({"retention_days": 20}, {"retention_days": None}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_retention_set_on_a_folder_that_kept_forever_starts_its_files_clock_now(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
alice = make_user()
|
||||||
|
up = _file(db, alice.id, kind="upload", ref="u1")
|
||||||
|
assert up.expires_at is None
|
||||||
|
r = as_user(alice).patch(f"/api/files/folders/{up.folder_id}", json={"retention_days": 7})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
db.refresh(up)
|
||||||
|
assert abs(up.expires_at - (NOW + timedelta(days=7))) < timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_an_own_folder_sends_its_files_home_and_a_source_folder_cannot_be_deleted(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
alice = make_user()
|
||||||
|
c = as_user(alice)
|
||||||
|
yt = _file(db, alice.id)
|
||||||
|
site = _file(db, alice.id, kind="url", ref="https://vimeo.com/2")
|
||||||
|
home = {yt.id: yt.folder_id, site.id: site.folder_id}
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
for j in (yt, site):
|
||||||
|
assert c.patch(f"/api/files/{j.id}", json={"folder_id": own.id}).is_success
|
||||||
|
r = c.delete(f"/api/files/folders/{own.id}")
|
||||||
|
assert r.json() == {"ok": True, "deleted": own.id, "files_moved": 2}
|
||||||
|
assert db.get(FileFolder, own.id) is None
|
||||||
|
for j in (yt, site):
|
||||||
|
db.refresh(j)
|
||||||
|
assert j.folder_id == home[j.id]
|
||||||
|
# Back in a source folder, its clock runs again from now.
|
||||||
|
assert abs(j.expires_at - (NOW + timedelta(days=30))) < timedelta(minutes=5)
|
||||||
|
src = c.delete(f"/api/files/folders/{yt.folder_id}")
|
||||||
|
assert src.status_code == 409 and src.json()["detail"]["reason"] == "file_folder_not_own"
|
||||||
|
other = _own(db, alice.id, "Más")
|
||||||
|
assert as_user(make_user()).delete(f"/api/files/folders/{other.id}").status_code == 404
|
||||||
|
assert len(_audits(db, "file_folder_delete")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- the file list -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_holds_the_callers_done_files_by_folder(db, make_user, as_user):
|
||||||
|
alice, bob = make_user(), make_user()
|
||||||
|
yt = _file(db, alice.id)
|
||||||
|
site = _file(db, alice.id, kind="url", ref="https://vimeo.com/3")
|
||||||
|
_file(db, alice.id, status=JobStatus.QUEUED)
|
||||||
|
_file(db, bob.id)
|
||||||
|
c = as_user(alice)
|
||||||
|
assert {f["id"] for f in c.get("/api/files").json()} == {yt.id, site.id}
|
||||||
|
only = c.get("/api/files", params={"folder_id": site.folder_id}).json()
|
||||||
|
assert [f["id"] for f in only] == [site.id]
|
||||||
|
assert only[0]["folder_id"] == site.folder_id and only[0]["shared"] is False
|
||||||
|
# Bob's folder id names nothing of Alice's.
|
||||||
|
bobs = db.execute(select(FileFolder.id).where(FileFolder.user_id == bob.id)).scalar()
|
||||||
|
assert c.get("/api/files", params={"folder_id": bobs}).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_is_searched_by_name_and_channel_with_wildcards_taken_literally(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
alice = make_user()
|
||||||
|
hit = _file(db, alice.id, title="Balaton 100% nyár")
|
||||||
|
by_channel = _file(db, alice.id, title="Más", display_uploader="Balatoni csatorna")
|
||||||
|
_file(db, alice.id, title="Balaton 1000 nyár")
|
||||||
|
c = as_user(alice)
|
||||||
|
assert [f["id"] for f in c.get("/api/files", params={"q": "100%"}).json()] == [hit.id]
|
||||||
|
found = {f["id"] for f in c.get("/api/files", params={"q": "balatoni"}).json()}
|
||||||
|
assert found == {by_channel.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_sorts_by_name_size_or_when_added(db, make_user, as_user):
|
||||||
|
alice = make_user()
|
||||||
|
b = _file(db, alice.id, title="Béka", size=30)
|
||||||
|
a = _file(db, alice.id, title="alma", size=10)
|
||||||
|
z = _file(db, alice.id, title="Zebra", size=20)
|
||||||
|
c = as_user(alice)
|
||||||
|
|
||||||
|
def ids(**params):
|
||||||
|
return [f["id"] for f in c.get("/api/files", params=params).json()]
|
||||||
|
|
||||||
|
assert ids() == [z.id, a.id, b.id] # newest first
|
||||||
|
assert ids(sort="name", order="asc") == [a.id, b.id, z.id] # the reader's alphabet, any case
|
||||||
|
assert ids(sort="size", order="desc") == [b.id, z.id, a.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_is_added_when_it_is_done_not_when_it_was_asked_for(db, make_user, as_user):
|
||||||
|
"""A download that failed for days and then went through is new: it lists first, dated today."""
|
||||||
|
alice = make_user()
|
||||||
|
early = _file(db, alice.id, created_at=NOW - timedelta(days=1))
|
||||||
|
early.ready_at = NOW - timedelta(days=1)
|
||||||
|
late = _file(db, alice.id, status=JobStatus.RUNNING, created_at=NOW - timedelta(days=5))
|
||||||
|
late.ready_at = None
|
||||||
|
db.commit()
|
||||||
|
assert service.finalize_if_running(db, late.id, status=JobStatus.DONE)
|
||||||
|
db.commit()
|
||||||
|
db.expire_all() # the finalize is a Core UPDATE; the route reads this same session
|
||||||
|
got = as_user(alice).get("/api/files").json()
|
||||||
|
assert [f["id"] for f in got] == [late.id, early.id]
|
||||||
|
ready = datetime.fromisoformat(got[0]["ready_at"])
|
||||||
|
assert ready > NOW - timedelta(minutes=5)
|
||||||
|
assert datetime.fromisoformat(got[0]["created_at"]) < NOW - timedelta(days=4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_job_made_done_is_ready_as_it_is_made(db, make_user):
|
||||||
|
# An upload kept as it came is inserted done; the database refuses a done job without the moment.
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=make_user().id,
|
||||||
|
source_kind="upload",
|
||||||
|
source_ref="r",
|
||||||
|
profile_snapshot={},
|
||||||
|
status=JobStatus.DONE,
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
assert job.ready_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_with_me_lists_what_others_shared_and_nothing_else(db, make_user, as_user):
|
||||||
|
alice, bob, carol = make_user(), make_user(), make_user()
|
||||||
|
theirs = _file(db, bob.id)
|
||||||
|
_file(db, bob.id) # not shared
|
||||||
|
for_carol = _file(db, bob.id)
|
||||||
|
db.add(DownloadShare(job_id=theirs.id, from_user_id=bob.id, to_user_id=alice.id))
|
||||||
|
db.add(DownloadShare(job_id=for_carol.id, from_user_id=bob.id, to_user_id=carol.id))
|
||||||
|
db.commit()
|
||||||
|
got = as_user(alice).get("/api/files", params={"scope": "shared"}).json()
|
||||||
|
assert [(f["id"], f["shared"]) for f in got] == [(theirs.id, True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_with_me_is_ordered_by_when_it_was_shared_and_can_be_dismissed(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
"""A friend's old file shared today is new to me: "added" is the share's date there. Dismissing
|
||||||
|
one drops only my grant; the owner's file stays."""
|
||||||
|
alice, bob = make_user(), make_user()
|
||||||
|
old = _file(db, bob.id, created_at=NOW - timedelta(days=30))
|
||||||
|
new = _file(db, bob.id, created_at=NOW - timedelta(days=1))
|
||||||
|
db.add(
|
||||||
|
DownloadShare(
|
||||||
|
job_id=new.id,
|
||||||
|
from_user_id=bob.id,
|
||||||
|
to_user_id=alice.id,
|
||||||
|
created_at=NOW - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
DownloadShare(
|
||||||
|
job_id=old.id,
|
||||||
|
from_user_id=bob.id,
|
||||||
|
to_user_id=alice.id,
|
||||||
|
created_at=NOW - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
c = as_user(alice)
|
||||||
|
got = c.get("/api/files", params={"scope": "shared"}).json()
|
||||||
|
assert [f["id"] for f in got] == [old.id, new.id]
|
||||||
|
assert got[0]["shared_at"] is not None and got[0]["shared_at"] > got[1]["shared_at"]
|
||||||
|
r = c.delete(f"/api/files/shared/{old.id}")
|
||||||
|
assert r.json() == {"ok": True, "removed": old.id}
|
||||||
|
assert [f["id"] for f in c.get("/api/files", params={"scope": "shared"}).json()] == [new.id]
|
||||||
|
assert db.get(DownloadJob, old.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_listed_file_says_where_it_came_from_wherever_it_sits(db, make_user, as_user):
|
||||||
|
"""The Source column: a file's own source folder, also after it moved into an own folder."""
|
||||||
|
alice = make_user()
|
||||||
|
yt = _file(db, alice.id)
|
||||||
|
site = _file(db, alice.id, kind="url", ref="https://www.tiktok.com/@x/video/1")
|
||||||
|
moved = _file(db, alice.id)
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
c = as_user(alice)
|
||||||
|
assert c.patch(f"/api/files/{moved.id}", json={"folder_id": own.id}).status_code == 200
|
||||||
|
got = {f["id"]: f["source_key"] for f in c.get("/api/files").json()}
|
||||||
|
assert got == {yt.id: "youtube", site.id: "site:tiktok.com", moved.id: "youtube"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- one file ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_is_renamed_kept_and_given_back_to_its_folders_retention(db, make_user, as_user):
|
||||||
|
alice = make_user()
|
||||||
|
job = _file(db, alice.id)
|
||||||
|
c = as_user(alice)
|
||||||
|
assert c.patch(f"/api/files/{job.id}", json={"name": "Új név"}).json()["display_name"] == (
|
||||||
|
"Új név"
|
||||||
|
)
|
||||||
|
kept = c.patch(f"/api/files/{job.id}", json={"kept": True}).json()
|
||||||
|
assert (kept["kept"], kept["expires_at"]) == (True, None)
|
||||||
|
back = c.patch(f"/api/files/{job.id}", json={"kept": False}).json()
|
||||||
|
assert back["kept"] is False and back["expires_at"] is not None
|
||||||
|
# Setting what it already is records nothing.
|
||||||
|
assert c.patch(f"/api/files/{job.id}", json={"kept": False}).status_code == 200
|
||||||
|
assert len(_audits(db, "file_keep")) == 1 and len(_audits(db, "file_unkeep")) == 1
|
||||||
|
assert as_user(make_user()).patch(f"/api/files/{job.id}", json={"kept": True}).status_code == (
|
||||||
|
404
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_files_channel_and_links_are_set_and_only_plausible_links_are_kept(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
alice = make_user()
|
||||||
|
job = _file(db, alice.id)
|
||||||
|
c = as_user(alice)
|
||||||
|
got = c.patch(
|
||||||
|
f"/api/files/{job.id}",
|
||||||
|
json={
|
||||||
|
"uploader": " Saját csatorna ",
|
||||||
|
"uploader_url": "mailto:someone@example.com",
|
||||||
|
"extra_links": ["https://a.example/x", "ftp://b.example", "", 7, "http://[::1/x"]
|
||||||
|
+ [f"https://c.example/{i}" for i in range(10)],
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert got["display_uploader"] == "Saját csatorna"
|
||||||
|
assert got["display_uploader_url"] is None
|
||||||
|
assert got["extra_links"][0] == "https://a.example/x" and len(got["extra_links"]) == 8
|
||||||
|
cleared = c.patch(f"/api/files/{job.id}", json={"uploader": "", "extra_links": []}).json()
|
||||||
|
assert (cleared["display_uploader"], cleared["extra_links"]) == (None, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_usage_carries_the_quota_and_the_upload_cap(db, make_user, as_user):
|
||||||
|
got = as_user(make_user()).get("/api/files/usage").json()
|
||||||
|
assert got["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
|
||||||
|
assert "footprint_bytes" in got and "max_bytes" in got
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_moves_to_an_own_folder_or_home_and_nowhere_else(db, make_user, as_user):
|
||||||
|
alice, bob = make_user(), make_user()
|
||||||
|
job = _file(db, alice.id)
|
||||||
|
home = job.folder_id
|
||||||
|
vimeo = db.get(FileFolder, _file(db, alice.id, kind="url", ref="https://vimeo.com/4").folder_id)
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
bobs = _own(db, bob.id, "Bobé")
|
||||||
|
c = as_user(alice)
|
||||||
|
moved = c.patch(f"/api/files/{job.id}", json={"folder_id": own.id}).json()
|
||||||
|
assert (moved["folder_id"], moved["expires_at"]) == (own.id, None) # an own folder keeps it
|
||||||
|
wrong = c.patch(f"/api/files/{job.id}", json={"folder_id": vimeo.id})
|
||||||
|
assert wrong.json()["detail"]["reason"] == "file_folder_not_its_source"
|
||||||
|
foreign = c.patch(f"/api/files/{job.id}", json={"folder_id": bobs.id})
|
||||||
|
assert foreign.json()["detail"]["reason"] == "file_folder_gone"
|
||||||
|
home_again = c.patch(f"/api/files/{job.id}", json={"folder_id": home}).json()
|
||||||
|
assert home_again["folder_id"] == home and home_again["expires_at"] is not None
|
||||||
|
assert [(a.before, a.after) for a in _audits(db, "file_move")] == [
|
||||||
|
({"folder_id": home}, {"folder_id": own.id}),
|
||||||
|
({"folder_id": own.id}, {"folder_id": home}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_deleted_file_leaves_the_library_and_the_log_says_so(db, make_user, as_user, _root):
|
||||||
|
alice = make_user()
|
||||||
|
job = _file(db, alice.id)
|
||||||
|
assert as_user(make_user()).delete(f"/api/files/{job.id}").status_code == 404
|
||||||
|
r = as_user(alice).delete(f"/api/files/{job.id}")
|
||||||
|
assert r.json() == {"ok": True, "deleted": job.id}
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id) is None
|
||||||
|
[row] = _audits(db, "file_delete")
|
||||||
|
assert (row.actor_id, row.target_id) == (alice.id, str(job.id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_shared_with_someone_is_theirs_to_watch_not_to_change(db, make_user, as_user, _root):
|
||||||
|
# The read routes let a share's recipient in; the write routes must not.
|
||||||
|
alice, friend = make_user(), make_user()
|
||||||
|
job = _file(db, alice.id, title="Mine")
|
||||||
|
db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id))
|
||||||
|
db.commit()
|
||||||
|
c = as_user(friend)
|
||||||
|
assert c.patch(f"/api/files/{job.id}", json={"name": "Theirs"}).status_code == 404
|
||||||
|
assert c.patch(f"/api/files/{job.id}", json={"kept": True}).status_code == 404
|
||||||
|
assert c.delete(f"/api/files/{job.id}").status_code == 404
|
||||||
|
db.expire_all()
|
||||||
|
still = db.get(DownloadJob, job.id)
|
||||||
|
assert still is not None and still.display_name is None and not still.kept
|
||||||
|
|
||||||
|
|
||||||
|
# --- upload, the file, its hash ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _no_probe(monkeypatch):
|
||||||
|
# A file ffprobe cannot read: a generic file, done at once (no conversion).
|
||||||
|
monkeypatch.setattr(uploadmod.probe, "probe_json", lambda _p: None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_upload_lands_in_the_folder_it_asked_for_with_its_hash(
|
||||||
|
db, make_user, as_user, _root, _no_probe, upload_headers
|
||||||
|
):
|
||||||
|
alice = make_user()
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
body = b"hello files"
|
||||||
|
c = as_user(alice)
|
||||||
|
r = c.post("/api/files/upload", content=body, headers=upload_headers("a.txt", folder=own.id))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
job = db.get(DownloadJob, r.json()["id"])
|
||||||
|
assert job.folder_id == own.id and job.expires_at is None
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
assert asset.sha256 == hashlib.sha256(body).hexdigest()
|
||||||
|
plain = c.post("/api/files/upload", content=b"x", headers=upload_headers("b.txt")).json()
|
||||||
|
assert db.get(FileFolder, plain["folder_id"]).source_key == "upload"
|
||||||
|
assert len(_audits(db, "file_upload")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("which", "reason"),
|
||||||
|
[
|
||||||
|
("site", "file_folder_not_its_source"), # YouTube's folder: no upload came from there
|
||||||
|
("foreign", "file_folder_gone"),
|
||||||
|
("garbage", "file_folder_gone"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_an_upload_to_a_folder_it_may_not_use_is_refused_before_its_body_is_read(
|
||||||
|
db, make_user, as_user, _root, _no_probe, upload_headers, monkeypatch, which, reason
|
||||||
|
):
|
||||||
|
from app.routes import files as files_routes
|
||||||
|
|
||||||
|
alice, bob = make_user(), make_user()
|
||||||
|
folder = {
|
||||||
|
"site": lambda: _file(db, alice.id).folder_id,
|
||||||
|
"foreign": lambda: _own(db, bob.id).id,
|
||||||
|
"garbage": lambda: "12abc",
|
||||||
|
}[which]()
|
||||||
|
streamed = []
|
||||||
|
|
||||||
|
async def spy(*args, **kwargs):
|
||||||
|
streamed.append(True)
|
||||||
|
raise AssertionError("the body was read")
|
||||||
|
|
||||||
|
monkeypatch.setattr(files_routes, "capped_stream_to", spy)
|
||||||
|
before = db.execute(select(DownloadJob.id)).all()
|
||||||
|
r = as_user(alice).post(
|
||||||
|
"/api/files/upload", content=b"data", headers=upload_headers("a.txt", folder=folder)
|
||||||
|
)
|
||||||
|
assert (r.status_code, r.json()["detail"]["reason"]) == (409, reason)
|
||||||
|
assert streamed == []
|
||||||
|
assert db.execute(select(DownloadJob.id)).all() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_upload_holds_its_folder_only_once_its_file_is_about_to_be_in_it(
|
||||||
|
db, make_user, as_user, _root, upload_headers, monkeypatch
|
||||||
|
):
|
||||||
|
# Held from the start, the folder waited out the probe and the poster (up to a minute) before a
|
||||||
|
# rename or a delete of it could go on; not held at all, a delete could take it from under the file.
|
||||||
|
alice = make_user()
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
steps: list[object] = []
|
||||||
|
real = folders.own_folder
|
||||||
|
|
||||||
|
def own_folder(db_, user_id, folder_id, *, hold=True):
|
||||||
|
steps.append(("folder", hold))
|
||||||
|
return real(db_, user_id, folder_id, hold=hold)
|
||||||
|
|
||||||
|
def probe(_p):
|
||||||
|
steps.append("probe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(folders, "own_folder", own_folder)
|
||||||
|
monkeypatch.setattr(uploadmod.probe, "probe_json", probe)
|
||||||
|
r = as_user(alice).post(
|
||||||
|
"/api/files/upload", content=b"x", headers=upload_headers("a.txt", folder=own.id)
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# The route's own look before the body, the ingest's look, the probe, then the hold.
|
||||||
|
assert steps[-3:] == [("folder", False), "probe", ("folder", True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_moved_holds_the_folder_it_goes_to(db, make_user, as_user, monkeypatch):
|
||||||
|
alice = make_user()
|
||||||
|
job = _file(db, alice.id)
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
holds: list[bool] = []
|
||||||
|
real = folders.own_folder
|
||||||
|
|
||||||
|
def own_folder(db_, user_id, folder_id, *, hold=True):
|
||||||
|
holds.append(hold)
|
||||||
|
return real(db_, user_id, folder_id, hold=hold)
|
||||||
|
|
||||||
|
monkeypatch.setattr(folders, "own_folder", own_folder)
|
||||||
|
r = as_user(alice).patch(f"/api/files/{job.id}", json={"folder_id": own.id})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert holds == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_folder_deleted_while_its_upload_streams_refuses_the_upload(
|
||||||
|
db, make_user, as_user, _root, _no_probe, upload_headers, monkeypatch
|
||||||
|
):
|
||||||
|
from app.routes import files as files_routes
|
||||||
|
|
||||||
|
alice = make_user()
|
||||||
|
own = _own(db, alice.id)
|
||||||
|
real = files_routes.capped_stream_to
|
||||||
|
|
||||||
|
async def deleting_meanwhile(*args, **kwargs):
|
||||||
|
size = await real(*args, **kwargs)
|
||||||
|
db.delete(db.get(FileFolder, own.id))
|
||||||
|
db.commit()
|
||||||
|
return size
|
||||||
|
|
||||||
|
monkeypatch.setattr(files_routes, "capped_stream_to", deleting_meanwhile)
|
||||||
|
r = as_user(alice).post(
|
||||||
|
"/api/files/upload", content=b"data", headers=upload_headers("a.txt", folder=own.id)
|
||||||
|
)
|
||||||
|
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "file_folder_gone")
|
||||||
|
assert db.execute(select(DownloadJob.id).where(DownloadJob.user_id == alice.id)).all() == []
|
||||||
|
assert not (_root / ".uploads").exists() # checked before the file was placed
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_file_carries_its_hash_once_known_and_the_hash_is_taken_on_first_ask(
|
||||||
|
db, make_user, as_user, _root
|
||||||
|
):
|
||||||
|
alice, friend = make_user(), make_user()
|
||||||
|
body = b"\x00\x01video bytes"
|
||||||
|
job = _file(db, alice.id, rel="v.mp4")
|
||||||
|
(_root / "v.mp4").write_bytes(body)
|
||||||
|
c = as_user(alice)
|
||||||
|
first = c.get(f"/api/files/{job.id}/file")
|
||||||
|
assert first.status_code == 200 and first.content == body
|
||||||
|
assert "repr-digest" not in first.headers # not hashed yet: nothing claimed
|
||||||
|
want = hashlib.sha256(body).hexdigest()
|
||||||
|
assert c.get(f"/api/files/{job.id}/sha256").json() == {"sha256": want}
|
||||||
|
assert db.get(MediaAsset, job.asset_id).sha256 == want
|
||||||
|
ranged = c.get(f"/api/files/{job.id}/file", headers={"Range": "bytes=0-3"})
|
||||||
|
assert ranged.status_code == 206
|
||||||
|
digest = base64.b64encode(bytes.fromhex(want)).decode()
|
||||||
|
assert ranged.headers["repr-digest"] == f"sha-256=:{digest}:"
|
||||||
|
# A friend it was shared with may read it too; a stranger is told it does not exist.
|
||||||
|
db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id))
|
||||||
|
db.commit()
|
||||||
|
assert as_user(friend).get(f"/api/files/{job.id}/sha256").json() == {"sha256": want}
|
||||||
|
assert as_user(make_user()).get(f"/api/files/{job.id}/sha256").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_a_few_files_are_hashed_at_once(db, make_user, as_user, _root):
|
||||||
|
# Each hash holds a thread of the pool every sync route shares for the whole read; a caller
|
||||||
|
# past the few that may run is told to come back, not queued behind them.
|
||||||
|
from app.routes import files as filesroute
|
||||||
|
|
||||||
|
alice = make_user()
|
||||||
|
job = _file(db, alice.id, rel="v.mp4")
|
||||||
|
(_root / "v.mp4").write_bytes(b"bytes")
|
||||||
|
held = [filesroute._HASHING.acquire(blocking=False) for _ in range(filesroute._HASHERS)]
|
||||||
|
try:
|
||||||
|
assert all(held)
|
||||||
|
busy = as_user(alice).get(f"/api/files/{job.id}/sha256")
|
||||||
|
finally:
|
||||||
|
for _ in held:
|
||||||
|
filesroute._HASHING.release()
|
||||||
|
assert busy.status_code == 429
|
||||||
|
assert busy.json()["detail"]["reason"] == "file_hash_busy"
|
||||||
|
assert as_user(alice).get(f"/api/files/{job.id}/sha256").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_file_at_an_assets_path_forgets_the_old_hash(db, make_user):
|
||||||
|
job = _file(db, make_user().id)
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
asset.sha256 = "ab" * 32
|
||||||
|
asset.rel_path = "converted.mp4"
|
||||||
|
assert asset.sha256 is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- the queue (the tray's poll) ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
# The tray polls this on every page: a finished file is the library's, so it is listed only
|
||||||
|
# while the tray may still be showing it done.
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
user = make_user()
|
||||||
|
old = _file(db, user.id, title="Old")
|
||||||
|
fresh = _file(db, user.id, title="Fresh")
|
||||||
|
waiting = _file(db, user.id, title="Waiting", status=JobStatus.QUEUED)
|
||||||
|
stuck = _file(db, user.id, title="Stuck", status=JobStatus.ERROR)
|
||||||
|
paused = _file(db, user.id, title="Paused", status=JobStatus.PAUSED)
|
||||||
|
stopped = _file(db, user.id, title="Stopped", status=JobStatus.ERROR)
|
||||||
|
dropped = _file(db, user.id, title="Dropped", status=JobStatus.CANCELED)
|
||||||
|
for job in (old, stopped, dropped):
|
||||||
|
db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job.id)
|
||||||
|
.values(updated_at=NOW - timedelta(hours=1), ready_at=NOW - timedelta(hours=1))
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
rows = as_user(user).get("/api/downloads").json()
|
||||||
|
# An old failure still waits for the user; an old finished or cancelled job does not.
|
||||||
|
assert {row["id"] for row in rows} == {fresh.id, waiting.id, stuck.id, paused.id, stopped.id}
|
||||||
|
# The tray names where each came from, as the library does.
|
||||||
|
assert {row["source_key"] for row in rows} == {"youtube"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_old_file_written_to_does_not_come_back_as_just_finished(db, make_user, as_user):
|
||||||
|
# Renaming, keeping or moving touches the row; the tray would announce the file as ready again.
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
user = make_user()
|
||||||
|
old = _file(db, user.id, title="Old")
|
||||||
|
db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.id == old.id)
|
||||||
|
.values(updated_at=NOW - timedelta(days=30), ready_at=NOW - timedelta(days=30))
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
c = as_user(user)
|
||||||
|
assert (
|
||||||
|
c.patch(f"/api/files/{old.id}", json={"name": "Renamed", "kept": True}).status_code == 200
|
||||||
|
)
|
||||||
|
assert [row["id"] for row in c.get("/api/downloads").json()] == []
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""A library file's preview (`GET /api/files/{id}/preview`, `downloads/preview.py`), DB-lane.
|
||||||
|
|
||||||
|
What it may show is an allow-list on the stored extension: an image or a PDF inline, a text format
|
||||||
|
(HTML and SVG among them) as plain text, its head only. Every answer is sandboxed and may be framed
|
||||||
|
by the app alone; anything else, a binary "text" included, has no preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import folders, preview, service
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.models import DownloadJob, DownloadShare, MediaAsset
|
||||||
|
from app.routes.files import TRUNCATED_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(db, root, user_id, ext, body, *, media_kind="file", title="notes"):
|
||||||
|
"""A done uploaded file named `title.ext` holding `body`."""
|
||||||
|
n = len(db.execute(select(MediaAsset.id)).all())
|
||||||
|
rel = f"up{n}.{ext}" if ext else f"up{n}"
|
||||||
|
(root / rel).write_bytes(body)
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="upload",
|
||||||
|
source_ref=f"up{n}",
|
||||||
|
format_sig=f"sig-up{n}",
|
||||||
|
status="ready",
|
||||||
|
rel_path=rel,
|
||||||
|
size_bytes=len(body),
|
||||||
|
ref_count=0,
|
||||||
|
title=title,
|
||||||
|
media_kind=media_kind,
|
||||||
|
container=ext,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="upload",
|
||||||
|
source_ref=f"up{n}",
|
||||||
|
profile_snapshot={},
|
||||||
|
status=JobStatus.DONE,
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
folders.place(db, job)
|
||||||
|
service.incr_ref_count(db, asset.id)
|
||||||
|
db.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_sandboxed(r):
|
||||||
|
assert r.headers["content-security-policy"] == "sandbox; frame-ancestors 'self'"
|
||||||
|
assert r.headers["x-frame-options"] == "SAMEORIGIN"
|
||||||
|
assert r.headers["x-content-type-options"] == "nosniff"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"ext,ctype",
|
||||||
|
[
|
||||||
|
("png", "image/png"),
|
||||||
|
("JPG", "image/jpeg"),
|
||||||
|
("webp", "image/webp"),
|
||||||
|
("pdf", "application/pdf"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_an_image_or_a_pdf_is_shown_inline_with_its_listed_type_in_a_sandbox(
|
||||||
|
db, make_user, as_user, _root, ext, ctype
|
||||||
|
):
|
||||||
|
alice = make_user()
|
||||||
|
job = _upload(db, _root, alice.id, ext, b"bytes of it")
|
||||||
|
r = as_user(alice).get(f"/api/files/{job.id}/preview")
|
||||||
|
assert r.status_code == 200 and r.content == b"bytes of it"
|
||||||
|
assert r.headers["content-type"] == ctype
|
||||||
|
assert r.headers["content-disposition"].startswith("inline;")
|
||||||
|
assert f'filename="notes.{ext}"' in r.headers["content-disposition"]
|
||||||
|
_assert_sandboxed(r)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ext", ["html", "svg", "txt", "py"])
|
||||||
|
def test_html_svg_and_code_arrive_as_plain_text_never_as_a_page(db, make_user, as_user, _root, ext):
|
||||||
|
alice = make_user()
|
||||||
|
body = b"<svg onload=x><script>y</script></svg>"
|
||||||
|
job = _upload(db, _root, alice.id, ext, body)
|
||||||
|
r = as_user(alice).get(f"/api/files/{job.id}/preview")
|
||||||
|
assert r.status_code == 200 and r.content == body
|
||||||
|
assert r.headers["content-type"] == "text/plain; charset=utf-8"
|
||||||
|
assert TRUNCATED_HEADER.lower() not in r.headers
|
||||||
|
_assert_sandboxed(r)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_text_is_cut_to_its_head_and_says_so(db, make_user, as_user, _root):
|
||||||
|
alice = make_user()
|
||||||
|
body = b"a" * preview.TEXT_PREVIEW_BYTES + b"tail"
|
||||||
|
job = _upload(db, _root, alice.id, "log", body)
|
||||||
|
r = as_user(alice).get(f"/api/files/{job.id}/preview")
|
||||||
|
assert r.status_code == 200 and len(r.content) == preview.TEXT_PREVIEW_BYTES
|
||||||
|
assert r.headers[TRUNCATED_HEADER] == "1"
|
||||||
|
exact = _upload(db, _root, alice.id, "log", b"a" * preview.TEXT_PREVIEW_BYTES)
|
||||||
|
whole = as_user(alice).get(f"/api/files/{exact.id}/preview")
|
||||||
|
assert len(whole.content) == preview.TEXT_PREVIEW_BYTES
|
||||||
|
assert TRUNCATED_HEADER.lower() not in whole.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"ext,body,media_kind",
|
||||||
|
[
|
||||||
|
("zip", b"PK\x03\x04", "file"), # not on the list
|
||||||
|
("docx", b"PK\x03\x04", "file"),
|
||||||
|
(None, b"plain", "file"), # no extension
|
||||||
|
("txt", b"text\x00with a nul", "file"), # a "text" that is binary
|
||||||
|
("mp4", b"video", "video"), # media plays in the player instead
|
||||||
|
("png", b"png", "video"), # an extension alone never makes media a preview
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_anything_else_has_no_preview(db, make_user, as_user, _root, ext, body, media_kind):
|
||||||
|
alice = make_user()
|
||||||
|
job = _upload(db, _root, alice.id, ext, body, media_kind=media_kind)
|
||||||
|
r = as_user(alice).get(f"/api/files/{job.id}/preview")
|
||||||
|
assert r.status_code == 415
|
||||||
|
assert r.json()["detail"] == {"code": "refused", "reason": "file_no_preview"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body",
|
||||||
|
[
|
||||||
|
"Árvíztűrő tükörfúrógép".encode("cp1250"), # an old Hungarian subtitle
|
||||||
|
"Árvíztűrő tükörfúrógép".encode("utf-8-sig"),
|
||||||
|
"Árvíztűrő tükörfúrógép".encode("utf-16"), # a BOM, then NULs: text all the same
|
||||||
|
"Árvíztűrő tükörfúrógép".encode("utf-8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_text_arrives_as_utf8_whatever_it_was_written_in(db, make_user, as_user, _root, body):
|
||||||
|
alice = make_user()
|
||||||
|
job = _upload(db, _root, alice.id, "srt", body)
|
||||||
|
r = as_user(alice).get(f"/api/files/{job.id}/preview")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.content.decode("utf-8") == "Árvíztűrő tükörfúrógép"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_character_cut_at_the_cap_keeps_the_text_utf8(db, make_user, as_user, _root):
|
||||||
|
alice = make_user()
|
||||||
|
body = b"a" * (preview.TEXT_PREVIEW_BYTES - 1) + "ő".encode() # its 2nd byte past the cap
|
||||||
|
job = _upload(db, _root, alice.id, "txt", body)
|
||||||
|
r = as_user(alice).get(f"/api/files/{job.id}/preview")
|
||||||
|
assert r.content == body[: preview.TEXT_PREVIEW_BYTES] # not read as cp1250
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_list_says_which_preview_each_file_gets(db, make_user, as_user, _root):
|
||||||
|
alice = make_user()
|
||||||
|
for ext in ["png", "pdf", "html", "zip"]:
|
||||||
|
_upload(db, _root, alice.id, ext, b"x", title=ext)
|
||||||
|
_upload(db, _root, alice.id, "mp4", b"x", media_kind="video", title="mp4")
|
||||||
|
got = {
|
||||||
|
f["asset"]["container"]: f["asset"]["preview"]
|
||||||
|
for f in as_user(alice).get("/api/files").json()
|
||||||
|
}
|
||||||
|
assert got == {"png": "image", "pdf": "pdf", "html": "text", "zip": None, "mp4": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_preview_is_the_holders_or_a_friends_it_was_shared_with(db, make_user, as_user, _root):
|
||||||
|
alice, friend = make_user(), make_user()
|
||||||
|
job = _upload(db, _root, alice.id, "png", b"img")
|
||||||
|
assert as_user(make_user()).get(f"/api/files/{job.id}/preview").status_code == 404
|
||||||
|
db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id))
|
||||||
|
db.commit()
|
||||||
|
assert as_user(friend).get(f"/api/files/{job.id}/preview").status_code == 200
|
||||||
@@ -3,6 +3,13 @@ playability guarantee) and must SELECT H.264 (not merely prefer it), so shared d
|
|||||||
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
|
||||||
See the iPad AV1 broken-player fix + the format narrowing."""
|
See the iPad AV1 broken-player fix + the format narrowing."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import functools
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats
|
from app.downloads import formats
|
||||||
|
|
||||||
@@ -162,3 +169,40 @@ def test_the_mp3_fix_moved_the_audio_cache_identity(monkeypatch):
|
|||||||
current = formats.format_sig(audio)
|
current = formats.format_sig(audio)
|
||||||
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
|
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
|
||||||
assert formats.format_sig(audio) != current
|
assert formats.format_sig(audio) != current
|
||||||
|
|
||||||
|
|
||||||
|
class _QuietFiles(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, *args): # the test's output is yt-dlp's, not the server's
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
# yt-dlp hangs up on a file over the cap as soon as it reads its size.
|
||||||
|
with contextlib.suppress(ConnectionError):
|
||||||
|
super().handle()
|
||||||
|
|
||||||
|
|
||||||
|
def test_yt_dlp_refuses_a_file_over_the_cap_without_raising_and_the_watch_sees_it(tmp_path):
|
||||||
|
# The boundary `watching_size_cap` reads, held against the yt-dlp this image pins: over
|
||||||
|
# `max_filesize` its HTTP downloader logs one line and RETURNS — no exception, no file — so
|
||||||
|
# without the watch the pipeline would only find "no output file" and call it a processing fault.
|
||||||
|
served = tmp_path / "served"
|
||||||
|
served.mkdir()
|
||||||
|
(served / "v.mp4").write_bytes(b"\0" * 200_000)
|
||||||
|
srv = http.server.ThreadingHTTPServer(
|
||||||
|
("127.0.0.1", 0), functools.partial(_QuietFiles, directory=str(served))
|
||||||
|
)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
url = f"http://127.0.0.1:{srv.server_port}/v.mp4"
|
||||||
|
try:
|
||||||
|
for cap, refused in ((1_000, True), (10_000_000, False)):
|
||||||
|
out = tmp_path / f"out-{cap}"
|
||||||
|
opts = formats.build_ydl_opts(
|
||||||
|
{}, str(out / "%(id)s.%(ext)s"), lambda d: None, max_bytes=cap
|
||||||
|
)
|
||||||
|
with formats.watching_size_cap() as over, yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
|
ydl.extract_info(url, download=True)
|
||||||
|
assert over[0] is refused, cap
|
||||||
|
files = [p for p in out.glob("*") if p.suffix == ".mp4"] if out.exists() else []
|
||||||
|
assert bool(files) is not refused, cap
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
|||||||
@@ -9,13 +9,26 @@ cannot reach, is ever read or written. DB-backed (real Postgres).
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.models import DownloadJob, DownloadShare, LibraryState
|
from app.models import DownloadJob, DownloadShare, LibraryState, MediaAsset
|
||||||
from app.routes._common import ProgressIn, kept_position
|
from app.routes._common import ProgressIn, kept_position
|
||||||
|
|
||||||
|
|
||||||
def _done_job(db, user) -> DownloadJob:
|
def _done_job(db, user) -> DownloadJob:
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="url",
|
||||||
|
source_ref="https://example.com/a.mp4",
|
||||||
|
format_sig=f"sig-{user.id}",
|
||||||
|
status="ready",
|
||||||
|
rel_path=f"a-{user.id}.mp4",
|
||||||
|
ref_count=1,
|
||||||
|
media_kind="video",
|
||||||
|
container="mp4",
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
job = DownloadJob(
|
job = DownloadJob(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
asset_id=asset.id,
|
||||||
source_kind="url",
|
source_kind="url",
|
||||||
source_ref="https://example.com/a.mp4",
|
source_ref="https://example.com/a.mp4",
|
||||||
status="done",
|
status="done",
|
||||||
@@ -59,7 +72,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
|
|||||||
"position_seconds": 754,
|
"position_seconds": 754,
|
||||||
}
|
}
|
||||||
|
|
||||||
[item] = c.get("/api/downloads").json()
|
[item] = c.get("/api/files").json()
|
||||||
assert item["position_seconds"] == 754
|
assert item["position_seconds"] == 754
|
||||||
# A second checkpoint moves it rather than adding a row.
|
# A second checkpoint moves it rather than adding a row.
|
||||||
assert _checkpoint(c, job.id, 900) == 900
|
assert _checkpoint(c, job.id, 900) == 900
|
||||||
@@ -69,7 +82,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
|
|||||||
def test_an_item_never_played_lists_zero(db, make_user, as_user):
|
def test_an_item_never_played_lists_zero(db, make_user, as_user):
|
||||||
me = make_user()
|
me = make_user()
|
||||||
_done_job(db, me)
|
_done_job(db, me)
|
||||||
[item] = as_user(me).get("/api/downloads").json()
|
[item] = as_user(me).get("/api/files").json()
|
||||||
assert item["position_seconds"] == 0
|
assert item["position_seconds"] == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -110,8 +123,11 @@ def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_us
|
|||||||
assert _checkpoint(as_user(owner), job.id, 100) == 100
|
assert _checkpoint(as_user(owner), job.id, 100) == 100
|
||||||
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
|
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
|
||||||
|
|
||||||
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
|
assert as_user(owner).get("/api/files").json()[0]["position_seconds"] == 100
|
||||||
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
|
assert (
|
||||||
|
as_user(friend).get("/api/files", params={"scope": "shared"}).json()[0]["position_seconds"]
|
||||||
|
== 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
|
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Migration 0102's data step, DB-lane: every existing job lands in its source folder, and a done
|
||||||
|
file's expiry moves from the shared asset to the user's job.
|
||||||
|
|
||||||
|
The schema half is checked by test_migrations.py; a row placed in the wrong folder is invisible there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_jobs_are_placed_and_keep_their_expiry(db, make_user):
|
||||||
|
alice, bob = make_user().id, make_user().id
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0101_plex_text_plain")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
|
||||||
|
def asset(kind, ref, expires=None):
|
||||||
|
return conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO media_assets (source_kind, source_ref, format_sig, status,"
|
||||||
|
" ref_count, expires_at) VALUES (:k, :r, :sig, 'ready', 1, :e)"
|
||||||
|
" RETURNING id"
|
||||||
|
),
|
||||||
|
{"k": kind, "r": ref, "sig": kind + ref, "e": expires},
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
def job(user, kind, ref, asset_id, status="done", spec=None, parent=None):
|
||||||
|
return conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO download_jobs (user_id, asset_id, source_kind, source_ref,"
|
||||||
|
" profile_snapshot, status, edit_spec, source_job_id)"
|
||||||
|
" VALUES (:u, :a, :k, :r, '{}', :s, CAST(:spec AS json), :p) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": user, "a": asset_id, "k": kind, "r": ref, "s": status,
|
||||||
|
"spec": spec, "p": parent},
|
||||||
|
).scalar_one() # fmt: skip
|
||||||
|
|
||||||
|
yt = asset("youtube", "abcdefghijk", "2030-01-02T00:00:00+00:00")
|
||||||
|
tt = asset("url", "https://www.tiktok.com/@a/video/1", "2030-01-03T00:00:00+00:00")
|
||||||
|
up = asset("upload", "u1")
|
||||||
|
cl = asset("clip", "c1", "2030-01-04T00:00:00+00:00")
|
||||||
|
j_yt = job(alice, "youtube", "abcdefghijk", yt)
|
||||||
|
j_yt_bob = job(bob, "youtube", "abcdefghijk", yt, status="queued")
|
||||||
|
j_tt = job(alice, "url", "https://www.tiktok.com/@a/video/1", tt)
|
||||||
|
j_up = job(alice, "upload", "u1", up)
|
||||||
|
j_cl = job(alice, "clip", "c1", cl, spec='{"source": "plex", "ref": "9"}')
|
||||||
|
j_ed = job(alice, "edit", "e1", cl, parent=j_tt)
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT j.id, f.user_id, f.source_key, f.retention_days, j.expires_at::date"
|
||||||
|
" FROM download_jobs j JOIN file_folders f ON f.id = j.folder_id"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
folders = conn.execute(text("SELECT count(*) FROM file_folders")).scalar()
|
||||||
|
got = {r[0]: (r[1], r[2], r[3], str(r[4]) if r[4] else None) for r in rows}
|
||||||
|
assert got == {
|
||||||
|
j_yt: (alice, "youtube", 30, "2030-01-02"),
|
||||||
|
j_yt_bob: (bob, "youtube", 30, None), # not done yet: no expiry until it is
|
||||||
|
j_tt: (alice, "site:tiktok.com", 30, "2030-01-03"),
|
||||||
|
j_up: (alice, "upload", None, None), # an upload never expires
|
||||||
|
j_cl: (alice, "plex", 30, "2030-01-04"),
|
||||||
|
j_ed: (alice, "site:tiktok.com", 30, "2030-01-04"),
|
||||||
|
}
|
||||||
|
# alice: youtube, tiktok, upload, plex; bob: youtube.
|
||||||
|
assert folders == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admins_retention_is_kept_and_the_downgrade_gives_the_asset_back_its_expiry(
|
||||||
|
db, make_user
|
||||||
|
):
|
||||||
|
alice, bob = make_user().id, make_user().id
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0101_plex_text_plain")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO system_config (key, value) VALUES ('download_retention_days', '7')"
|
||||||
|
" ON CONFLICT (key) DO UPDATE SET value = '7'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
yt = conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO media_assets (source_kind, source_ref, format_sig, status,"
|
||||||
|
" ref_count, expires_at) VALUES ('youtube', 'abcdefghijk', 'sig', 'ready', 2,"
|
||||||
|
" '2030-01-02T00:00:00+00:00') RETURNING id"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
for user in (alice, bob):
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO download_jobs (user_id, asset_id, source_kind, source_ref,"
|
||||||
|
" profile_snapshot, status) VALUES (:u, :a, 'youtube', 'abcdefghijk',"
|
||||||
|
" '{}', 'done')"
|
||||||
|
),
|
||||||
|
{"u": user, "a": yt},
|
||||||
|
)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
retention = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT DISTINCT retention_days FROM file_folders WHERE source_key = 'youtube'"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
def asset_expiry_after_downgrade(alice_at, bob_at):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for user, at in ((alice, alice_at), (bob, bob_at)):
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE download_jobs SET expires_at = CAST(:at AS timestamptz)"
|
||||||
|
" WHERE asset_id = :a AND user_id = :u"
|
||||||
|
),
|
||||||
|
{"at": at, "a": yt, "u": user},
|
||||||
|
)
|
||||||
|
command.downgrade(cfg, "0101_plex_text_plain")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
got = conn.execute(
|
||||||
|
text("SELECT expires_at::date FROM media_assets WHERE id = :a"), {"a": yt}
|
||||||
|
).scalar()
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
return str(got) if got else None
|
||||||
|
|
||||||
|
# The shared file goes when its last holder's copy would; one holder keeping it keeps it.
|
||||||
|
latest = asset_expiry_after_downgrade(
|
||||||
|
"2030-02-01T00:00:00+00:00", "2030-03-01T00:00:00+00:00"
|
||||||
|
)
|
||||||
|
kept_by_one = asset_expiry_after_downgrade("2030-02-01T00:00:00+00:00", None)
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert retention == [(7,)]
|
||||||
|
assert latest == "2030-03-01"
|
||||||
|
assert kept_by_one is None
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Migration 0103's data step, DB-lane: the fileless "expired" rows of the old retention go, every
|
||||||
|
other job stays.
|
||||||
|
|
||||||
|
An expired file now leaves the library (`downloads.gc`); before, it stayed as a done job with no
|
||||||
|
asset. Left in place, those rows would sit unseen in no listing forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fileless_expired_rows_go_and_every_other_job_stays(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0102_file_folders")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
folder = conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO file_folders (user_id, kind, source_key, retention_days)"
|
||||||
|
" VALUES (:u, 'source', 'youtube', 30) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": uid},
|
||||||
|
).scalar_one()
|
||||||
|
asset = conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO media_assets (source_kind, source_ref, format_sig, status, ref_count)"
|
||||||
|
" VALUES ('youtube', 'abcdefghijk', 'sig', 'ready', 1) RETURNING id"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
def job(status, asset_id):
|
||||||
|
return conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO download_jobs (user_id, asset_id, source_kind, source_ref,"
|
||||||
|
" profile_snapshot, status, folder_id) VALUES (:u, :a, 'youtube',"
|
||||||
|
" 'abcdefghijk', '{}', :s, :f) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": uid, "a": asset_id, "s": status, "f": folder},
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
expired = job("done", None)
|
||||||
|
alive = job("done", asset)
|
||||||
|
failed = job("error", None) # a failed job has no file either, and is not expired
|
||||||
|
queued = job("queued", None)
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
left = set(conn.execute(text("SELECT id FROM download_jobs")).scalars())
|
||||||
|
has_hash = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM information_schema.columns"
|
||||||
|
" WHERE table_name = 'media_assets' AND column_name = 'sha256'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert left == {alive, failed, queued}
|
||||||
|
assert expired not in left
|
||||||
|
assert has_hash == 1
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Migration 0104's data step, DB-lane: a done file from before is dated by when it was asked for
|
||||||
|
(where it already sat); a job not done has no date."""
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_done_file_takes_its_request_time(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0103_files_sha256")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
|
||||||
|
def job(status, created):
|
||||||
|
return conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO download_jobs (user_id, source_kind, source_ref,"
|
||||||
|
" profile_snapshot, status, created_at) VALUES (:u, 'url', 'x', '{}', :s,"
|
||||||
|
" CAST(:c AS timestamptz)) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": uid, "s": status, "c": created},
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
done = job("done", "2026-10-02T10:00:00+00:00")
|
||||||
|
failed = job("error", "2026-10-03T10:00:00+00:00")
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = dict(
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, to_char(ready_at AT TIME ZONE 'UTC', 'MM-DD HH24') FROM download_jobs"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert rows[done] == "10-02 10"
|
||||||
|
assert rows[failed] is None
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Migration 0105's data step, DB-lane: a file in a source folder records that folder's key as its
|
||||||
|
origin; a file in an own folder is left to be worked out, as before."""
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_in_a_source_folder_records_its_origin(db, make_user):
|
||||||
|
uid = make_user().id
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0104_job_ready_at")
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
|
||||||
|
def folder(kind, key, name):
|
||||||
|
return conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO file_folders (user_id, kind, source_key, name) VALUES"
|
||||||
|
" (:u, :k, :s, :n) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": uid, "k": kind, "s": key, "n": name},
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
def job(folder_id):
|
||||||
|
return conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO download_jobs (user_id, source_kind, source_ref,"
|
||||||
|
" profile_snapshot, status, folder_id) VALUES (:u, 'url', 'x', '{}',"
|
||||||
|
" 'queued', :f) RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": uid, "f": folder_id},
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
in_source = job(folder("source", "site:vimeo.com", None))
|
||||||
|
in_own = job(folder("own", None, "Mine"))
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = dict(conn.execute(text("SELECT id, source_key FROM download_jobs")).all())
|
||||||
|
assert rows[in_source] == "site:vimeo.com"
|
||||||
|
assert rows[in_own] is None
|
||||||
@@ -46,7 +46,7 @@ def test_an_odd_sized_4_4_4_video_converts_to_the_even_frame_below_it(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_an_odd_crop_makes_a_clip_and_stores_the_frame_it_has(db, make_user, tmp_path, monkeypatch):
|
def test_an_odd_crop_makes_a_clip_and_stores_the_frame_it_has(db, make_user, tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
src = tmp_path / "in.mp4"
|
src = tmp_path / "in.mp4"
|
||||||
_ffmpeg(
|
_ffmpeg(
|
||||||
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
||||||
|
|||||||
@@ -73,10 +73,17 @@ def test_every_path_to_ready_runs_the_rule():
|
|||||||
found.append(where)
|
found.append(where)
|
||||||
if not runs_rule:
|
if not runs_rule:
|
||||||
skipped.append(where)
|
skipped.append(where)
|
||||||
# The paths known today: a download, an editor clip, an upload's conversion, an upload kept as is.
|
# The paths known today: a download, an editor clip, an ad-hoc clip, an upload's conversion, an
|
||||||
|
# upload kept as is.
|
||||||
# Fewer means the scan went blind (a rename, a new status spelling) — not that a path is gone.
|
# Fewer means the scan went blind (a rename, a new status spelling) — not that a path is gone.
|
||||||
names = {w.rsplit(" ", 1)[1] for w in found}
|
names = {w.rsplit(" ", 1)[1] for w in found}
|
||||||
assert {"_download", "_process_edit", "_convert_upload", "ingest_upload"} <= names, found
|
assert {
|
||||||
|
"_download",
|
||||||
|
"_process_edit",
|
||||||
|
"_process_clip",
|
||||||
|
"_convert_upload",
|
||||||
|
"ingest_upload",
|
||||||
|
} <= names, found
|
||||||
assert skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}"
|
assert skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}"
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +127,7 @@ _FILES = {
|
|||||||
def media_root(tmp_path, monkeypatch):
|
def media_root(tmp_path, monkeypatch):
|
||||||
root = tmp_path / "media"
|
root = tmp_path / "media"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
monkeypatch.setattr(settings, "download_root", str(root))
|
monkeypatch.setattr(settings, "files_root", str(root))
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
@@ -134,9 +141,7 @@ def test_what_a_link_serves_passes_the_rule(
|
|||||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args, str(src)], check=True)
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args, str(src)], check=True)
|
||||||
|
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
r = owner.post(
|
r = owner.post("/api/files/upload", content=src.read_bytes(), headers=upload_headers(filename))
|
||||||
"/api/downloads/upload", content=src.read_bytes(), headers=upload_headers(filename)
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
job_id = r.json()["id"]
|
job_id = r.json()["id"]
|
||||||
assert r.json()["status"] == "queued" # every one of these fails the rule as it came
|
assert r.json()["status"] == "queued" # every one of these fails the rule as it came
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import app.state as state
|
|||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import detail
|
from app.plex import detail
|
||||||
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||||
|
|
||||||
@@ -276,8 +276,16 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
|
|||||||
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
||||||
db.add(show)
|
db.add(show)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
||||||
|
db.add(season)
|
||||||
|
db.flush()
|
||||||
ep = PlexItem(
|
ep = PlexItem(
|
||||||
rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id
|
rating_key="e1",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title="Pilot",
|
||||||
|
show_id=show.id,
|
||||||
|
season_id=season.id,
|
||||||
)
|
)
|
||||||
db.add(ep)
|
db.add(ep)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -289,6 +297,8 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
||||||
|
# The page's links up to the show and the season.
|
||||||
|
assert (body["show_id"], body["season_id"]) == ("s1", "se1")
|
||||||
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
|
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
|
||||||
db.refresh(ep)
|
db.refresh(ep)
|
||||||
assert ep.full_cast is None # episodes are not persisted/enriched
|
assert ep.full_cast is None # episodes are not persisted/enriched
|
||||||
@@ -436,3 +446,63 @@ def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep
|
|||||||
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
|
||||||
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _show_with_episodes(db, n=3):
|
||||||
|
lib = PlexLibrary(plex_key="3", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
show = PlexShow(rating_key="sn1", library_id=lib.id, title="Next Show")
|
||||||
|
db.add(show)
|
||||||
|
db.flush()
|
||||||
|
eps = [
|
||||||
|
PlexItem(
|
||||||
|
rating_key=f"sn1e{i}",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title=f"Episode {i}",
|
||||||
|
show_id=show.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=i,
|
||||||
|
)
|
||||||
|
for i in range(1, n + 1)
|
||||||
|
]
|
||||||
|
db.add_all(eps)
|
||||||
|
db.flush()
|
||||||
|
return eps
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_plays_the_first_episode_of_an_untouched_show(db, monkeypatch, override_dep):
|
||||||
|
"""A show card's picture plays: never started → its first episode."""
|
||||||
|
_show_with_episodes(db)
|
||||||
|
user = _user(db, "next1@example.com")
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
nxt = resp.json()["next"]
|
||||||
|
assert nxt["id"] == "sn1e1"
|
||||||
|
assert nxt["show_title"] == "Next Show"
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_continues_the_episode_in_progress(db, monkeypatch, override_dep):
|
||||||
|
"""Started → the episode in progress (the show page's Resume), not the first unwatched."""
|
||||||
|
eps = _show_with_episodes(db)
|
||||||
|
user = _user(db, "next2@example.com")
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=eps[0].id, status="watched"))
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=eps[2].id, status="new", position_seconds=120))
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
|
||||||
|
assert resp.json()["next"]["id"] == "sn1e3"
|
||||||
|
assert resp.json()["next"]["position_seconds"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_next_is_null_for_an_empty_show_and_404_for_an_unknown_one(
|
||||||
|
db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
_show_with_episodes(db, n=0)
|
||||||
|
user = _user(db, "next3@example.com")
|
||||||
|
db.commit()
|
||||||
|
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next").json() == {
|
||||||
|
"next": None
|
||||||
|
}
|
||||||
|
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/nope/next").status_code == 404
|
||||||
|
|||||||
@@ -165,6 +165,30 @@ def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
|
|||||||
assert "m1" not in ids
|
assert "m1" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_continue_watching_show_says_whether_it_has_an_episode_to_resume(
|
||||||
|
db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
# Both in-progress rails (Discover's and Home's) agree with the one rollup: a show part watched
|
||||||
|
# with nothing to resume offers Play alone, not Continue + Restart.
|
||||||
|
from app.discover.queries import plex_in_progress
|
||||||
|
|
||||||
|
user = _seed(db)
|
||||||
|
|
||||||
|
def rails():
|
||||||
|
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
|
||||||
|
home = {c["id"]: c for _, c in plex_in_progress(db, user.id, lang="en")}
|
||||||
|
return {c["id"]: c for c in d["continue_watching"]}["sh1"], home["sh1"]
|
||||||
|
|
||||||
|
assert [c["resumable"] for c in rails()] == [True, True]
|
||||||
|
ep1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e1")).scalar_one()
|
||||||
|
st = db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == ep1.id)
|
||||||
|
).scalar_one()
|
||||||
|
st.status, st.position_seconds = "watched", 0
|
||||||
|
db.commit()
|
||||||
|
assert [(c["status"], c["resumable"]) for c in rails()] == [("in_progress", False)] * 2
|
||||||
|
|
||||||
|
|
||||||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
|
||||||
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
||||||
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
||||||
@@ -316,6 +340,35 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_d
|
|||||||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reshuffle_deals_other_titles_first(db, monkeypatch, override_dep):
|
||||||
|
# The ↻ sends the previous draw: what is left of the pool comes first, and a pool smaller than a
|
||||||
|
# draw is topped up from the excluded titles rather than leaving the rail short.
|
||||||
|
user = _seed(db)
|
||||||
|
|
||||||
|
def movies(url: str) -> list[str]:
|
||||||
|
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||||
|
return [c["id"] for c in items if c["type"] == "movie"]
|
||||||
|
|
||||||
|
for _ in range(5): # random order: one lucky pass would prove nothing
|
||||||
|
got = movies("/api/plex/surprise?scope=movie&exclude=m2")
|
||||||
|
assert got[0] == "m3" and set(got) == {"m2", "m3"}
|
||||||
|
assert set(movies("/api/plex/surprise?scope=movie&exclude=m2&exclude=m3")) == {"m2", "m3"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_kept_draw_is_read_again_not_redrawn(db, monkeypatch, override_dep):
|
||||||
|
# The rail's draw stands until the ↻, but its cards' watch state moves: `keep` answers the titles on
|
||||||
|
# screen, in their order and as they stand now, a watched one included; the scope still bounds it.
|
||||||
|
user = _seed(db)
|
||||||
|
url = "/api/plex/surprise?scope=both&keep=m3&keep=sh1&keep=m1&keep=nope"
|
||||||
|
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||||
|
assert _ids(items) == ["m3", "sh1", "m1"]
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["m1"]["status"] == "watched"
|
||||||
|
assert by_id["m3"]["position_seconds"] > 0
|
||||||
|
movie_only = "/api/plex/surprise?scope=movie&keep=sh1&keep=m2"
|
||||||
|
assert _ids(_get(db, user, monkeypatch, override_dep, movie_only).json()["items"]) == ["m2"]
|
||||||
|
|
||||||
|
|
||||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||||||
user = _seed(db)
|
user = _seed(db)
|
||||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Plex summaries and taglines are stored as plain text.
|
||||||
|
|
||||||
|
A Hungarian TV agent hands summaries over as HTML (`<p>A műsorban … párosnak …</p>`), and
|
||||||
|
the info page and the hover card showed the tags. `plain_text` is the one cleaner: the sync's every
|
||||||
|
summary path and the HU overlay store through it, and migration 0101 cleaned the rows written before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
|
||||||
|
from app.plex.detail import rich_detail
|
||||||
|
from app.plex.overlay import sync_overlay
|
||||||
|
from app.plex.plaintext import plain_text
|
||||||
|
from app.plex import sync
|
||||||
|
from app.plex.sync import _apply_item, _upsert_collection
|
||||||
|
|
||||||
|
HU_SUMMARY = "<p>A műsorban minden párosnak stoppal kell eljutnia.</p>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw, clean",
|
||||||
|
[
|
||||||
|
(HU_SUMMARY, "A műsorban minden párosnak stoppal kell eljutnia."),
|
||||||
|
("<p>One.</p><p>Two.</p>", "One.\n\nTwo."),
|
||||||
|
("Line one<br>line two<BR/>three", "Line one\nline two\nthree"),
|
||||||
|
("<b>Bold</b> & <i>more</i>", "Bold & more"),
|
||||||
|
# Prose that only looks like markup stays as written.
|
||||||
|
("a < b and 1 <3 you", "a < b and 1 <3 you"),
|
||||||
|
# Only a reference with its semicolon is one: an ampersand in prose stays as written.
|
||||||
|
("AT¬ice, R© and H&M", "AT¬ice, R© and H&M"),
|
||||||
|
# ...and only a whole entity name: a legacy one at its start ("¬", "&") is not.
|
||||||
|
("AT¬ice; and &x;", "AT¬ice; and &x;"),
|
||||||
|
("été ©", "été ©"),
|
||||||
|
# A decimal reference too long for int() to parse is out of range, like �.
|
||||||
|
("a &#" + "9" * 5000 + "; b", "a � b"),
|
||||||
|
("&#" + "0" * 5000 + "65;", "A"),
|
||||||
|
("Plain summary.", "Plain summary."),
|
||||||
|
(" padded ", "padded"),
|
||||||
|
("<p></p>", None),
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_plain_text(raw, clean):
|
||||||
|
assert plain_text(raw) == clean
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_item_sync_stores_a_summary_as_plain_text():
|
||||||
|
row = PlexItem()
|
||||||
|
_apply_item(row, lib_id=1, kind="episode", meta={"title": "Ep", "summary": HU_SUMMARY})
|
||||||
|
assert row.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_show_sync_stores_a_summary_as_plain_text(db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.commit()
|
||||||
|
meta = {"ratingKey": "s1", "title": "Show", "summary": HU_SUMMARY}
|
||||||
|
|
||||||
|
def listing(_plex, _key, typ, progress=None):
|
||||||
|
if progress is not None:
|
||||||
|
progress["complete"] = True
|
||||||
|
return [meta] if typ == sync._TYPE_SHOW else []
|
||||||
|
|
||||||
|
monkeypatch.setattr(sync, "_paginate", listing)
|
||||||
|
sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0})
|
||||||
|
db.commit()
|
||||||
|
show = db.execute(select(PlexShow).where(PlexShow.rating_key == "s1")).scalar_one()
|
||||||
|
assert show.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_collection_stores_its_summary_as_plain_text(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
col = _upsert_collection(db, None, "c1", lib.id, {"title": "C", "summary": "<p>A & B</p>"})
|
||||||
|
assert col.summary == "A & B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fetched_tagline_is_plain_text():
|
||||||
|
assert rich_detail({"tagline": "<i>An offer</i> & more"})["tagline"] == "An offer & more"
|
||||||
|
|
||||||
|
|
||||||
|
class _StubPlex:
|
||||||
|
def __init__(self, items: list[dict]):
|
||||||
|
self._items = items
|
||||||
|
|
||||||
|
def section_items(self, key, item_type, start, size):
|
||||||
|
return self._items[start : start + size], len(self._items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hungarian_overlay_stores_plain_text(db):
|
||||||
|
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||||
|
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
|
||||||
|
db.add_all([en_lib, hu_lib])
|
||||||
|
db.flush()
|
||||||
|
hu_lib.overlay_of = en_lib.id
|
||||||
|
row = PlexItem(rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"ratingKey": "h1",
|
||||||
|
"title": "Szemtől szemben",
|
||||||
|
"summary": HU_SUMMARY,
|
||||||
|
"tagline": "<i>Egy élet</i>",
|
||||||
|
"Guid": [{"id": "tmdb://1"}],
|
||||||
|
}
|
||||||
|
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {"overlaid": 0})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
assert row.summary_hu == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
assert row.tagline_hu == "Egy élet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_0101_cleans_the_stored_rows(db):
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "0100_drop_plex_item_markers")
|
||||||
|
upgraded = False
|
||||||
|
try:
|
||||||
|
with Session(engine) as s:
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
|
||||||
|
s.add(lib)
|
||||||
|
s.flush()
|
||||||
|
ep = PlexItem(
|
||||||
|
rating_key="e1",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title="Ep",
|
||||||
|
summary=HU_SUMMARY,
|
||||||
|
tagline_hu="<i>x</i>",
|
||||||
|
)
|
||||||
|
plain = PlexItem(
|
||||||
|
rating_key="m1", library_id=lib.id, kind="movie", title="M", summary="a < b"
|
||||||
|
)
|
||||||
|
show = PlexShow(rating_key="s1", library_id=lib.id, title="S", summary_hu="<p>Hu</p>")
|
||||||
|
col = PlexCollection(rating_key="c1", library_id=lib.id, title="C", summary="A & B")
|
||||||
|
s.add_all([ep, plain, show, col])
|
||||||
|
s.commit()
|
||||||
|
ids = (ep.id, plain.id, show.id, col.id)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
upgraded = True
|
||||||
|
with Session(engine) as s:
|
||||||
|
ep2, plain2 = s.get(PlexItem, ids[0]), s.get(PlexItem, ids[1])
|
||||||
|
show2, col2 = s.get(PlexShow, ids[2]), s.get(PlexCollection, ids[3])
|
||||||
|
assert ep2 and plain2 and show2 and col2
|
||||||
|
assert ep2.summary == "A műsorban minden párosnak stoppal kell eljutnia."
|
||||||
|
assert ep2.tagline_hu == "x"
|
||||||
|
assert plain2.summary == "a < b"
|
||||||
|
assert show2.summary_hu == "Hu"
|
||||||
|
assert col2.summary == "A & B"
|
||||||
|
finally:
|
||||||
|
if not upgraded:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
@@ -18,6 +18,7 @@ from app.db import get_db
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import rollup, sync
|
from app.plex import rollup, sync
|
||||||
|
from app.plex.cards import ShowState
|
||||||
|
|
||||||
|
|
||||||
def _dt(day: int) -> datetime:
|
def _dt(day: int) -> datetime:
|
||||||
@@ -184,21 +185,53 @@ class TestRollup:
|
|||||||
def test_state_map_new_inprogress_watched(self, db):
|
def test_state_map_new_inprogress_watched(self, db):
|
||||||
s = _seed(db)
|
s = _seed(db)
|
||||||
show, user = s["show"], s["user"]
|
show, user = s["show"], s["user"]
|
||||||
# e1 in-progress, e2 new -> in_progress
|
# e1 in-progress, e2 new -> in_progress, and e1's resume point makes it resumable
|
||||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)}
|
||||||
# empty input short-circuits
|
# empty input short-circuits
|
||||||
assert rollup.state_map(db, user.id, []) == {}
|
assert rollup.state_map(db, user.id, []) == {}
|
||||||
# a fresh user with no state -> new
|
# a fresh user with no state -> new
|
||||||
other = User(email="rollup-other@example.com")
|
other = User(email="rollup-other@example.com")
|
||||||
db.add(other)
|
db.add(other)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
|
assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
|
||||||
# mark every episode watched for `user` -> watched
|
# e1 finished, e2 untouched: part watched, but no episode to resume — Continue and Restart
|
||||||
|
# would both play e2 from its start
|
||||||
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
||||||
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
|
e1, e2 = db.execute(
|
||||||
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
|
select(PlexItem).where(PlexItem.kind == "episode").order_by(PlexItem.episode_number)
|
||||||
|
).scalars()
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
|
assert rollup.state_map(db, user.id, [show.id]) == {
|
||||||
|
show.id: ShowState("in_progress", False)
|
||||||
|
}
|
||||||
|
# mark every episode watched for `user` -> watched
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e2.id, status="watched"))
|
||||||
|
db.commit()
|
||||||
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("watched", False)}
|
||||||
|
|
||||||
|
def test_the_grid_card_says_whether_a_show_has_an_episode_to_resume(
|
||||||
|
self, db, monkeypatch, override_dep
|
||||||
|
):
|
||||||
|
s = _seed(db)
|
||||||
|
user = s["user"]
|
||||||
|
cards = {
|
||||||
|
i["id"]: i
|
||||||
|
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||||
|
}
|
||||||
|
assert cards["s1"]["resumable"] is True
|
||||||
|
# e1 finished, e2 untouched: in progress, but nothing to resume
|
||||||
|
e1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "e1")).scalar_one()
|
||||||
|
db.execute(
|
||||||
|
delete(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == e1.id)
|
||||||
|
)
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||||
|
db.commit()
|
||||||
|
cards = {
|
||||||
|
i["id"]: i
|
||||||
|
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||||
|
}
|
||||||
|
assert (cards["s1"]["status"], cards["s1"]["resumable"]) == ("in_progress", False)
|
||||||
|
|
||||||
|
|
||||||
def _fake_paginate(by_type):
|
def _fake_paginate(by_type):
|
||||||
|
|||||||
@@ -19,15 +19,13 @@ _PW = "open sesame"
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
||||||
audio = {"codec_type": "audio", "codec_name": "aac"}
|
audio = {"codec_type": "audio", "codec_name": "aac"}
|
||||||
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
||||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
job = owner.post(
|
job = owner.post("/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4"))
|
||||||
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
|
|
||||||
)
|
|
||||||
assert job.status_code == 200, job.text
|
assert job.status_code == 200, job.text
|
||||||
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
|
||||||
assert link.status_code == 200, link.text
|
assert link.status_code == 200, link.text
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ SERIALIZERS = {
|
|||||||
"_collection_card",
|
"_collection_card",
|
||||||
"_playlist_card",
|
"_playlist_card",
|
||||||
"_serialize",
|
"_serialize",
|
||||||
"_serialize_job",
|
"_serialize_folder",
|
||||||
|
"serialize",
|
||||||
|
"serialize_job",
|
||||||
"_serialize_profile",
|
"_serialize_profile",
|
||||||
"_serialize_user",
|
"_serialize_user",
|
||||||
"_summary",
|
"_summary",
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ def pages(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path / "media"))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
|
||||||
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
|
||||||
audio = {"codec_type": "audio", "codec_name": "aac"}
|
audio = {"codec_type": "audio", "codec_name": "aac"}
|
||||||
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
|
||||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||||
owner = as_user(make_user())
|
owner = as_user(make_user())
|
||||||
job = owner.post(
|
job = owner.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"fake-video",
|
content=b"fake-video",
|
||||||
headers=upload_headers("clip.mp4", display_name="Holiday clip"),
|
headers=upload_headers("clip.mp4", display_name="Holiday clip"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import date
|
|||||||
from app.downloads.storage import (
|
from app.downloads.storage import (
|
||||||
MediaMeta,
|
MediaMeta,
|
||||||
download_filename,
|
download_filename,
|
||||||
|
files_root_problem,
|
||||||
rel_path,
|
rel_path,
|
||||||
safe_abs_path,
|
safe_abs_path,
|
||||||
sanitize,
|
sanitize,
|
||||||
@@ -100,7 +101,7 @@ class TestDownloadFilename:
|
|||||||
|
|
||||||
|
|
||||||
class TestSafeAbsPath:
|
class TestSafeAbsPath:
|
||||||
"""The path-traversal guard: only paths that resolve INSIDE download_root AND exist are returned."""
|
"""The path-traversal guard: only paths that resolve INSIDE files_root AND exist are returned."""
|
||||||
|
|
||||||
def test_returns_the_path_for_a_file_inside_the_root(self, tmp_path: Path):
|
def test_returns_the_path_for_a_file_inside_the_root(self, tmp_path: Path):
|
||||||
(tmp_path / "sub").mkdir()
|
(tmp_path / "sub").mkdir()
|
||||||
@@ -174,3 +175,38 @@ def test_sidecars_keep_the_full_basename_when_the_title_has_a_dot(tmp_path: Path
|
|||||||
assert f"{stem}.nfo" in names, names
|
assert f"{stem}.nfo" in names, names
|
||||||
assert f"{stem}.jpg" in names, names
|
assert f"{stem}.jpg" in names, names
|
||||||
assert "Chan_-_2026-08-12_-_Sonka_Hazilag.nfo" not in names # the old truncated orphan
|
assert "Chan_-_2026-08-12_-_Sonka_Hazilag.nfo" not in names # the old truncated orphan
|
||||||
|
|
||||||
|
|
||||||
|
def _mounts(*points):
|
||||||
|
"""A /proc/self/mountinfo table with these mount points (the fields around them are filler)."""
|
||||||
|
return "\n".join(f"{i} 1 0:{i} / {p} rw - ext4 /dev/x rw" for i, p in enumerate(points, 22))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_files_root_on_the_containers_own_disk_is_refused(tmp_path):
|
||||||
|
# The image's empty /files, left unmounted by an old compose file: every file would 404 and
|
||||||
|
# every download would go with the container.
|
||||||
|
root = str(tmp_path)
|
||||||
|
plain = _mounts("/", "/proc", "/etc/hosts")
|
||||||
|
assert "not a mounted volume" in files_root_problem(root, plain, in_container=True)
|
||||||
|
assert "does not exist" in files_root_problem(str(tmp_path / "gone"))
|
||||||
|
# Its own mount, or inside one: the volume — whatever device it reports. On a storage driver that
|
||||||
|
# keeps the container's root on the host's disk (vfs) a volume has the same device as /.
|
||||||
|
assert files_root_problem(root, _mounts("/", root), in_container=True) is None
|
||||||
|
assert files_root_problem(root, _mounts("/", str(tmp_path.parent)), in_container=True) is None
|
||||||
|
# A mount point with a space in it, as the kernel escapes it.
|
||||||
|
spaced = tmp_path / "my files"
|
||||||
|
spaced.mkdir()
|
||||||
|
escaped = str(spaced).replace(" ", "\\040")
|
||||||
|
assert files_root_problem(str(spaced), _mounts("/", escaped), in_container=True) is None
|
||||||
|
# Outside a container a plain directory is a fine root.
|
||||||
|
assert files_root_problem(root, plain, in_container=False) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_old_name_of_the_files_root_still_finds_the_files(monkeypatch):
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
monkeypatch.delenv("FILES_ROOT", raising=False)
|
||||||
|
monkeypatch.setenv("DOWNLOAD_ROOT", "/downloads")
|
||||||
|
assert Settings().files_root == "/downloads"
|
||||||
|
monkeypatch.setenv("FILES_ROOT", "/files")
|
||||||
|
assert Settings().files_root == "/files"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ generic file (force-download so uploaded HTML/SVG can't execute inline), quota a
|
|||||||
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
|
size known up front, the per-file cap enforced while streaming, filename sanitization, and the two
|
||||||
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
|
guards that keep an upload out of paths that assume a re-fetchable source (edit / redownload).
|
||||||
|
|
||||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
DB-backed (real Postgres) — skipped when no database is reachable. `settings.files_root` is
|
||||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||||
monkeypatch the shared `probe.probe_json` so they don't shell out to ffprobe; the classifier's OWN
|
monkeypatch the shared `probe.probe_json` so they don't shell out to ffprobe; the classifier's OWN
|
||||||
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
|
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
|
||||||
@@ -51,7 +51,7 @@ def test_derive_media_kind():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def _root(tmp_path, monkeypatch):
|
def _root(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
|
|||||||
multipart the old route would have spooled, not the raw body the new one expects."""
|
multipart the old route would have spooled, not the raw body the new one expects."""
|
||||||
body, pulled = _counting_multipart()
|
body, pulled = _counting_multipart()
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=body,
|
content=body,
|
||||||
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
|
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
|
||||||
)
|
)
|
||||||
@@ -227,7 +227,7 @@ def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _ro
|
|||||||
body, pulled = _counting_body()
|
body, pulled = _counting_body()
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=body,
|
content=body,
|
||||||
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
|
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
|
||||||
)
|
)
|
||||||
@@ -281,7 +281,7 @@ def test_upload_route_generic_forces_download(
|
|||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"<script>alert(1)</script>",
|
content=b"<script>alert(1)</script>",
|
||||||
headers=upload_headers("report.html"),
|
headers=upload_headers("report.html"),
|
||||||
)
|
)
|
||||||
@@ -290,7 +290,7 @@ def test_upload_route_generic_forces_download(
|
|||||||
assert job["asset"]["media_kind"] == "file"
|
assert job["asset"]["media_kind"] == "file"
|
||||||
assert job["can_download"] is True
|
assert job["can_download"] is True
|
||||||
# The served file must be a neutral force-download attachment — never inline text/html.
|
# The served file must be a neutral force-download attachment — never inline text/html.
|
||||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
fr = c.get(f"/api/files/{job['id']}/file")
|
||||||
assert fr.status_code == 200
|
assert fr.status_code == 200
|
||||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||||
@@ -307,7 +307,7 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
|||||||
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
_probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video…
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"<script>alert(1)</script>",
|
content=b"<script>alert(1)</script>",
|
||||||
headers=upload_headers("evil.html"),
|
headers=upload_headers("evil.html"),
|
||||||
)
|
)
|
||||||
@@ -320,11 +320,11 @@ def test_upload_route_media_html_extension_not_served_as_html(
|
|||||||
assert job["status"] == "queued"
|
assert job["status"] == "queued"
|
||||||
row = db.get(DownloadJob, job["id"])
|
row = db.get(DownloadJob, job["id"])
|
||||||
db.get(MediaAsset, row.asset_id).status = "ready"
|
db.get(MediaAsset, row.asset_id).status = "ready"
|
||||||
row.status = "done"
|
row.status, row.ready_at = "done", row.created_at
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Authenticated endpoint: never text/html.
|
# Authenticated endpoint: never text/html.
|
||||||
fr = c.get(f"/api/downloads/{job['id']}/file")
|
fr = c.get(f"/api/files/{job['id']}/file")
|
||||||
assert fr.status_code == 200
|
assert fr.status_code == 200
|
||||||
assert fr.headers["content-type"].startswith("application/octet-stream")
|
assert fr.headers["content-type"].startswith("application/octet-stream")
|
||||||
assert "attachment" in fr.headers["content-disposition"].lower()
|
assert "attachment" in fr.headers["content-disposition"].lower()
|
||||||
@@ -348,7 +348,7 @@ def test_public_watch_file_forces_download_for_generic_file(
|
|||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"<script>alert(1)</script>",
|
content=b"<script>alert(1)</script>",
|
||||||
headers=upload_headers("notes.html"),
|
headers=upload_headers("notes.html"),
|
||||||
).json()
|
).json()
|
||||||
@@ -369,7 +369,7 @@ def test_public_watch_file_serves_media_inline_with_real_type(
|
|||||||
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
_probe(monkeypatch, ("h264", "aac", 640, 360, 12))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"fake-mp4-bytes",
|
content=b"fake-mp4-bytes",
|
||||||
headers=upload_headers("clip.mp4"),
|
headers=upload_headers("clip.mp4"),
|
||||||
).json()
|
).json()
|
||||||
@@ -389,7 +389,7 @@ def test_upload_route_enforces_per_file_cap(
|
|||||||
db.commit()
|
db.commit()
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"way over the cap",
|
content=b"way over the cap",
|
||||||
headers=upload_headers("big.bin"),
|
headers=upload_headers("big.bin"),
|
||||||
)
|
)
|
||||||
@@ -412,14 +412,10 @@ def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more(
|
|||||||
db.commit()
|
db.commit()
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
|
|
||||||
at_the_cap = c.post(
|
at_the_cap = c.post("/api/files/upload", content=payload, headers=upload_headers("exact.bin"))
|
||||||
"/api/downloads/upload", content=payload, headers=upload_headers("exact.bin")
|
|
||||||
)
|
|
||||||
assert at_the_cap.status_code == 200, at_the_cap.text
|
assert at_the_cap.status_code == 200, at_the_cap.text
|
||||||
|
|
||||||
over = c.post(
|
over = c.post("/api/files/upload", content=payload + b"x", headers=upload_headers("over.bin"))
|
||||||
"/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin")
|
|
||||||
)
|
|
||||||
assert over.status_code == 422
|
assert over.status_code == 422
|
||||||
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
|
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
|
||||||
|
|
||||||
@@ -437,9 +433,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
|
|||||||
"""
|
"""
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post(
|
r = c.post("/api/files/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin"))
|
||||||
"/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
|
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
|
||||||
assert asset is not None
|
assert asset is not None
|
||||||
@@ -451,7 +445,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
|
|||||||
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
||||||
db, as_user, make_user, _root, monkeypatch, upload_headers
|
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
):
|
):
|
||||||
"""The sweep, held end to end rather than at one site.
|
"""The sweep, held end to end rather than at one site (the clip route joined it later).
|
||||||
|
|
||||||
`display_text` was introduced for the upload path and applied to three of the five writers of
|
`display_text` was introduced for the upload path and applied to three of the five writers of
|
||||||
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
|
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
|
||||||
@@ -467,9 +461,7 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
|||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
|
|
||||||
# 1. the upload route — the name arrives in a request header.
|
# 1. the upload route — the name arrives in a request header.
|
||||||
up = c.post(
|
up = c.post("/api/files/upload", content=b"video", headers=upload_headers("a.mp4", long_name))
|
||||||
"/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name)
|
|
||||||
)
|
|
||||||
assert up.status_code == 200, up.text
|
assert up.status_code == 200, up.text
|
||||||
assert up.json()["display_name"] == bound
|
assert up.json()["display_name"] == bound
|
||||||
|
|
||||||
@@ -494,24 +486,41 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
|
|||||||
assert edit.status_code == 200, edit.text
|
assert edit.status_code == 200, edit.text
|
||||||
assert edit.json()["display_name"] == bound
|
assert edit.json()["display_name"] == bound
|
||||||
|
|
||||||
# 4 + 5. the metadata PATCH's two fields.
|
# 4 + 5. the library PATCH's two fields.
|
||||||
patched = c.patch(
|
patched = c.patch(
|
||||||
f"/api/downloads/{up.json()['id']}",
|
f"/api/files/{up.json()['id']}",
|
||||||
json={"display_name": long_name, "display_uploader": long_name},
|
json={"name": long_name, "uploader": long_name},
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200, patched.text
|
assert patched.status_code == 200, patched.text
|
||||||
assert patched.json()["display_name"] == bound
|
assert patched.json()["display_name"] == bound
|
||||||
assert patched.json()["display_uploader"] == bound
|
assert patched.json()["display_uploader"] == bound
|
||||||
|
|
||||||
|
# 6. the clip route — the clip panel's name field.
|
||||||
|
clip = c.post(
|
||||||
|
"/api/downloads/clip",
|
||||||
|
json={
|
||||||
|
"source": "youtube",
|
||||||
|
"ref": "dQw4w9WgXcQ",
|
||||||
|
"start_s": 0,
|
||||||
|
"end_s": 5,
|
||||||
|
"display_name": long_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert clip.status_code == 200, clip.text
|
||||||
|
assert clip.json()["display_name"] == bound
|
||||||
|
|
||||||
|
|
||||||
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
||||||
"""Every place under `backend/app/` that puts a value into a display field, as
|
"""Every place under `backend/app/` that puts a value into a display field, as
|
||||||
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
|
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
|
||||||
|
|
||||||
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
|
THREE SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
|
||||||
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
|
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
|
||||||
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
|
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
|
||||||
the site the guard could not see.
|
the site the guard could not see. The third, `fields["display_name"] = …`, is a dict a finalize
|
||||||
|
call is spread from (the worker's), which the first two missed as well. A value passed
|
||||||
|
POSITIONALLY is still unseen: a writer binds it to a name or a keyword first, as every route here
|
||||||
|
does.
|
||||||
|
|
||||||
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
|
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
|
||||||
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
|
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
|
||||||
@@ -537,6 +546,13 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
|||||||
t.id if isinstance(t, ast.Name) else t.attr
|
t.id if isinstance(t, ast.Name) else t.attr
|
||||||
for t in node.targets
|
for t in node.targets
|
||||||
if isinstance(t, (ast.Name, ast.Attribute))
|
if isinstance(t, (ast.Name, ast.Attribute))
|
||||||
|
} | {
|
||||||
|
# `fields["display_name"] = …` — a dict the write is then spread from.
|
||||||
|
t.slice.value
|
||||||
|
for t in node.targets
|
||||||
|
if isinstance(t, ast.Subscript)
|
||||||
|
and isinstance(t.slice, ast.Constant)
|
||||||
|
and isinstance(t.slice.value, str)
|
||||||
}
|
}
|
||||||
value = node.value
|
value = node.value
|
||||||
elif isinstance(node, ast.keyword):
|
elif isinstance(node, ast.keyword):
|
||||||
@@ -558,9 +574,12 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
|||||||
# Bounded by `display_text` unless noted:
|
# Bounded by `display_text` unless noted:
|
||||||
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
|
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
|
||||||
# one value that needs no bound, and a clear is still a write.
|
# one value that needs no bound, and a clear is still a write.
|
||||||
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
|
# service.enqueue / enqueue_edit / `display_name` is already bounded by its caller; the fallback
|
||||||
# slices the YouTube title to the same width.
|
# enqueue_clip slices the source's title to the same width.
|
||||||
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
|
# files.ingest_spooled passes through `spool_upload`'s value, bounded there.
|
||||||
|
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
|
||||||
|
# pipeline._download / _process_clip / worker._finish_from_cache
|
||||||
|
# slice a fetched or stored title to the column's width.
|
||||||
#
|
#
|
||||||
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
||||||
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
|
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
|
||||||
@@ -573,17 +592,22 @@ DISPLAY_WRITE_SITES = {
|
|||||||
"if asset.status == 'ready' and asset.title else None)"
|
"if asset.status == 'ready' and asset.title else None)"
|
||||||
},
|
},
|
||||||
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||||
|
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||||
|
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
|
||||||
|
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
|
||||||
|
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
|
||||||
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
||||||
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
||||||
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
|
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
|
||||||
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
|
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
|
||||||
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
|
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
|
||||||
("routes/downloads.py", "update_download_meta"): {
|
("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"},
|
||||||
"display_text(body.display_name)",
|
("routes/files.py", "ingest_spooled"): {"upload.display_name"},
|
||||||
"display_text(body.display_uploader)",
|
("routes/files.py", "update_file"): {
|
||||||
|
"display_text(body.name)",
|
||||||
|
"display_text(body.uploader)",
|
||||||
},
|
},
|
||||||
("routes/downloads.py", "upload_download"): {"upload.display_name"},
|
("routes/files.py", "spool_upload"): {
|
||||||
("routes/downloads.py", "spool_upload"): {
|
|
||||||
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
|
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -633,7 +657,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded(
|
|||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
|
|
||||||
ok = c.post(
|
ok = c.post(
|
||||||
"/api/downloads/upload",
|
"/api/files/upload",
|
||||||
content=b"data",
|
content=b"data",
|
||||||
headers=upload_headers("clip.bin", "Nyári vakáció"),
|
headers=upload_headers("clip.bin", "Nyári vakáció"),
|
||||||
)
|
)
|
||||||
@@ -642,7 +666,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded(
|
|||||||
|
|
||||||
long_name = "n" * 300
|
long_name = "n" * 300
|
||||||
bounded = c.post(
|
bounded = c.post(
|
||||||
"/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
|
"/api/files/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
|
||||||
)
|
)
|
||||||
assert bounded.status_code == 200, bounded.text
|
assert bounded.status_code == 200, bounded.text
|
||||||
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
|
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
|
||||||
@@ -662,12 +686,12 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
|||||||
the checkout is its consequence (measured), and the pool counter is shared with whatever else
|
the checkout is its consequence (measured), and the pool counter is shared with whatever else
|
||||||
the test process is holding.
|
the test process is holding.
|
||||||
"""
|
"""
|
||||||
from app.routes import downloads as downloads_routes
|
from app.routes import files as files_routes
|
||||||
|
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
seen: dict[str, bool] = {}
|
seen: dict[str, bool] = {}
|
||||||
real_stream = downloads_routes.capped_stream_to
|
real_stream = files_routes.capped_stream_to
|
||||||
real_release = downloads_routes.release_read_connection
|
real_release = files_routes.release_read_connection
|
||||||
|
|
||||||
def spy_release(session):
|
def spy_release(session):
|
||||||
# The PRECONDITION, so this test cannot pass vacuously. Asserting only "no transaction
|
# The PRECONDITION, so this test cannot pass vacuously. Asserting only "no transaction
|
||||||
@@ -682,10 +706,10 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams(
|
|||||||
seen["while_streaming"] = bool(db.in_transaction())
|
seen["while_streaming"] = bool(db.in_transaction())
|
||||||
return await real_stream(request, out, limit, refusal)
|
return await real_stream(request, out, limit, refusal)
|
||||||
|
|
||||||
monkeypatch.setattr(downloads_routes, "release_read_connection", spy_release)
|
monkeypatch.setattr(files_routes, "release_read_connection", spy_release)
|
||||||
monkeypatch.setattr(downloads_routes, "capped_stream_to", spy_stream)
|
monkeypatch.setattr(files_routes, "capped_stream_to", spy_stream)
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin"))
|
r = c.post("/api/files/upload", content=b"data", headers=upload_headers("held.bin"))
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert seen["before"] is True, (
|
assert seen["before"] is True, (
|
||||||
"nothing had opened a transaction before the release, so this test proves nothing — the "
|
"nothing had opened a transaction before the release, so this test proves nothing — the "
|
||||||
@@ -707,14 +731,14 @@ def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db)
|
|||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.get("/api/downloads/usage")
|
r = c.get("/api/files/usage")
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
|
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
|
||||||
|
|
||||||
# And it is the KNOB, not a constant: a lowered override must show through.
|
# And it is the KNOB, not a constant: a lowered override must show through.
|
||||||
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
|
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert c.get("/api/downloads/usage").json()["upload_max_bytes"] == 4096
|
assert c.get("/api/files/usage").json()["upload_max_bytes"] == 4096
|
||||||
|
|
||||||
|
|
||||||
def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
|
def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
|
||||||
@@ -729,10 +753,10 @@ def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
|
|||||||
|
|
||||||
sysconfig.set_value(db, "download_upload_max_bytes", 8)
|
sysconfig.set_value(db, "download_upload_max_bytes", 8)
|
||||||
db.commit()
|
db.commit()
|
||||||
staging = Path(settings.download_root) / ".staging" / "uploads"
|
staging = Path(settings.files_root) / ".staging" / "uploads"
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
|
|
||||||
r = c.post("/api/downloads/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
|
r = c.post("/api/files/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
|
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
|
||||||
assert list(staging.iterdir()) == [], "a refused upload left its partial file behind"
|
assert list(staging.iterdir()) == [], "a refused upload left its partial file behind"
|
||||||
@@ -754,7 +778,7 @@ def test_upload_cap_cannot_be_disabled_with_zero(db):
|
|||||||
|
|
||||||
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
|
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
r = c.post("/api/downloads/upload", content=b"", headers=upload_headers("empty.bin"))
|
r = c.post("/api/files/upload", content=b"", headers=upload_headers("empty.bin"))
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json()["detail"]["code"] == "upload_empty"
|
assert r.json()["detail"]["code"] == "upload_empty"
|
||||||
|
|
||||||
@@ -762,7 +786,7 @@ def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch,
|
|||||||
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
|
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
||||||
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
r = c.post(f"/api/downloads/{job['id']}/redownload")
|
||||||
assert r.status_code == 409
|
assert r.status_code == 409
|
||||||
|
|
||||||
@@ -772,7 +796,7 @@ def test_edit_rejects_a_generic_upload_source(
|
|||||||
):
|
):
|
||||||
_probe(monkeypatch, (None, None, None, None, None))
|
_probe(monkeypatch, (None, None, None, None, None))
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json()
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/api/downloads/edit",
|
"/api/downloads/edit",
|
||||||
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import pytest
|
|||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import gc as gcmod
|
from app.downloads import gc as gcmod
|
||||||
from app.downloads import pipeline, quota, storage
|
from app.downloads import pipeline, quota, storage
|
||||||
from app.downloads import probe as probemod
|
from app.downloads import probe as probemod
|
||||||
@@ -28,7 +29,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def _root(tmp_path, monkeypatch):
|
def _root(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +125,8 @@ def test_a_conversion_that_outgrows_the_cap_is_refused_and_keeps_the_original(
|
|||||||
db.expire_all()
|
db.expire_all()
|
||||||
job = db.get(DownloadJob, job.id)
|
job = db.get(DownloadJob, job.id)
|
||||||
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
assert (job.status, job.error_code) == ("error", "quota_exceeded")
|
||||||
|
# 90 of the 100 are still free: the converted file is too big, the storage is not full.
|
||||||
|
assert job.error == dlerrors.CONVERTED_TOO_BIG_MESSAGE
|
||||||
assert original.exists()
|
assert original.exists()
|
||||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ def test_the_sweep_keeps_an_aged_upload_file_its_row_names_in_any_state(
|
|||||||
_probe(monkeypatch)
|
_probe(monkeypatch)
|
||||||
job, asset = _ingest(db, _root, make_user().id)
|
job, asset = _ingest(db, _root, make_user().id)
|
||||||
path = storage.abs_path(str(_root), asset.rel_path)
|
path = storage.abs_path(str(_root), asset.rel_path)
|
||||||
job.status, asset.status = job_status, asset_status
|
job.status, asset.status, job.ready_at = job_status, asset_status, job.created_at
|
||||||
db.commit()
|
db.commit()
|
||||||
_aged(path)
|
_aged(path)
|
||||||
|
|
||||||
@@ -424,7 +427,11 @@ def _route_moved(db, job, asset, job_status: str, ref_count: int) -> None:
|
|||||||
"""What the cancel/pause/delete route leaves behind, written as SQL: the session's copy of
|
"""What the cancel/pause/delete route leaves behind, written as SQL: the session's copy of
|
||||||
`ref_count` is stale (the routes count it in SQL), so an ORM assignment of the same value would
|
`ref_count` is stale (the routes count it in SQL), so an ORM assignment of the same value would
|
||||||
write nothing."""
|
write nothing."""
|
||||||
db.execute(update(DownloadJob).where(DownloadJob.id == job.id).values(status=job_status))
|
db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job.id)
|
||||||
|
.values(status=job_status, ready_at=DownloadJob.created_at)
|
||||||
|
)
|
||||||
db.execute(update(MediaAsset).where(MediaAsset.id == asset.id).values(ref_count=ref_count))
|
db.execute(update(MediaAsset).where(MediaAsset.id == asset.id).values(ref_count=ref_count))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -546,7 +553,7 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made(
|
|||||||
_probe(monkeypatch, vcodec="h264")
|
_probe(monkeypatch, vcodec="h264")
|
||||||
c = as_user(make_user())
|
c = as_user(make_user())
|
||||||
job = c.post(
|
job = c.post(
|
||||||
"/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
|
"/api/files/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
|
||||||
).json()
|
).json()
|
||||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||||
assert c.get(f"/api/public/watch/{link['token']}").status_code == 200
|
assert c.get(f"/api/public/watch/{link['token']}").status_code == 200
|
||||||
@@ -558,14 +565,77 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made(
|
|||||||
assert r.json()["detail"]["reason"] == reason
|
assert r.json()["detail"]["reason"] == reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_link_to_a_suspended_owners_waiting_rebuild_says_it_is_gone(
|
||||||
|
db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||||
|
):
|
||||||
|
# The worker leaves a suspended user's queued jobs where they are, maybe for good: like a paused
|
||||||
|
# rebuild, nothing is making the video, so "try again shortly" would never come true.
|
||||||
|
_probe(monkeypatch, vcodec="h264")
|
||||||
|
owner = make_user()
|
||||||
|
c = as_user(owner)
|
||||||
|
job = c.post(
|
||||||
|
"/api/files/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
|
||||||
|
).json()
|
||||||
|
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||||
|
db.get(MediaAsset, job["asset"]["id"]).status = "pending"
|
||||||
|
db.get(DownloadJob, job["id"]).status = "queued"
|
||||||
|
owner.is_suspended = True
|
||||||
|
db.commit()
|
||||||
|
r = c.get(f"/api/public/watch/{link['token']}")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["detail"]["reason"] == "video_gone"
|
||||||
|
# A rebuild already running when the owner was suspended still finishes.
|
||||||
|
db.get(DownloadJob, job["id"]).status = "running"
|
||||||
|
db.commit()
|
||||||
|
assert c.get(f"/api/public/watch/{link['token']}").json()["detail"]["reason"] == (
|
||||||
|
"video_preparing"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch):
|
def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch):
|
||||||
# The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`).
|
# The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`).
|
||||||
_probe(monkeypatch)
|
_probe(monkeypatch)
|
||||||
u = make_user()
|
u = make_user()
|
||||||
_ingest(db, _root, u.id, size=10)
|
_ingest(db, _root, u.id, size=10)
|
||||||
r = as_user(make_user(role="admin")).get("/api/admin/downloads/storage").json()
|
admin = make_user(role="admin")
|
||||||
|
r = as_user(admin).get("/api/admin/downloads/storage").json()
|
||||||
assert (r["files_on_disk"], r["total_bytes"]) == (1, 10)
|
assert (r["files_on_disk"], r["total_bytes"]) == (1, 10)
|
||||||
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10}
|
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10, admin.id: 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admin_storage_view_names_each_users_quota_and_files(
|
||||||
|
db, as_user, make_user, _root, monkeypatch
|
||||||
|
):
|
||||||
|
# A row says how full the user is against THEIR limit (a custom one, here), and how many files
|
||||||
|
# their Files lists — the finished ones, not one still waiting for its conversion.
|
||||||
|
_probe(monkeypatch)
|
||||||
|
u = make_user()
|
||||||
|
done, _ = _ingest(db, _root, u.id, size=10)
|
||||||
|
waiting, _ = _ingest(db, _root, u.id, name="b.mov", size=5)
|
||||||
|
done.status, waiting.status = "done", "queued"
|
||||||
|
done.ready_at = done.created_at
|
||||||
|
db.commit()
|
||||||
|
admin = as_user(make_user(role="admin"))
|
||||||
|
put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000})
|
||||||
|
assert put.status_code == 200
|
||||||
|
rows = admin.get("/api/admin/downloads/storage").json()["per_user"]
|
||||||
|
mine = [(p["files"], p["max_bytes"], p["unlimited"]) for p in rows if p["user_id"] == u.id]
|
||||||
|
assert mine == [(1, 1000, False)]
|
||||||
|
# Every download names its owner, so its row can open THEIR quota.
|
||||||
|
jobs = admin.get("/api/admin/downloads").json()
|
||||||
|
assert {(j["id"], j["user_id"]) for j in jobs} == {(done.id, u.id), (waiting.id, u.id)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admin_storage_view_lists_every_user_even_one_without_a_file(db, as_user, make_user):
|
||||||
|
# The admin sets a quota BEFORE anyone downloads: a user holding nothing is still a row.
|
||||||
|
empty = make_user()
|
||||||
|
admin_user = make_user(role="admin")
|
||||||
|
admin = as_user(admin_user)
|
||||||
|
rows = admin.get("/api/admin/downloads/storage").json()["per_user"]
|
||||||
|
by_id = {p["user_id"]: p for p in rows}
|
||||||
|
assert {empty.id, admin_user.id} <= by_id.keys()
|
||||||
|
assert (by_id[empty.id]["email"], by_id[empty.id]["files"]) == (empty.email, 0)
|
||||||
|
assert by_id[empty.id]["footprint_bytes"] == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def test_a_clip_the_phone_safe_step_changed_is_stored_under_its_new_extension(
|
|||||||
):
|
):
|
||||||
# A fast trim stream-copies its source's container; when the phone-safe step remuxes it (an
|
# A fast trim stream-copies its source's container; when the phone-safe step remuxes it (an
|
||||||
# audio clip out of an .mp4 becomes an .m4a), the stored path and container follow the new file.
|
# audio clip out of an .mp4 becomes an .m4a), the stored path and container follow the new file.
|
||||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
data = {
|
data = {
|
||||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
||||||
"format": {"format_name": "mov,mp4", "duration": "10"},
|
"format": {"format_name": "mov,mp4", "duration": "10"},
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ def test_worker_boots_with_the_stored_concurrency_and_log_level(monkeypatch, sto
|
|||||||
monkeypatch.setattr(worker, "_wait_for_schema", lambda: True)
|
monkeypatch.setattr(worker, "_wait_for_schema", lambda: True)
|
||||||
monkeypatch.setattr(worker.logsink, "start", lambda: None)
|
monkeypatch.setattr(worker.logsink, "start", lambda: None)
|
||||||
monkeypatch.setattr(worker, "_recover_orphans", lambda: None)
|
monkeypatch.setattr(worker, "_recover_orphans", lambda: None)
|
||||||
|
monkeypatch.setattr(worker.storage, "files_root_problem", lambda _root: None)
|
||||||
|
|
||||||
threading.Timer(0.5, worker._stop.set).start()
|
threading.Timer(0.5, worker._stop.set).start()
|
||||||
worker.main()
|
worker.main()
|
||||||
@@ -107,3 +108,17 @@ def test_an_unreadable_database_keeps_what_runs(monkeypatch, levels, loops):
|
|||||||
assert worker._target == 2
|
assert worker._target == 2
|
||||||
assert sorted(threads) == [0, 1] # the loops still run on the boot value
|
assert sorted(threads) == [0, 1] # the loops still run on the boot value
|
||||||
assert levels == []
|
assert levels == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_worker_will_not_start_on_a_files_root_that_is_not_the_volume(monkeypatch, loops):
|
||||||
|
# Files downloaded into the container's own empty /files go with the container: refuse, loudly.
|
||||||
|
monkeypatch.setattr(worker.settings, "worker_enabled", True)
|
||||||
|
monkeypatch.setattr(worker.logs, "configure_logging", lambda _source: None)
|
||||||
|
monkeypatch.setattr(worker.signal, "signal", lambda *_a: None)
|
||||||
|
monkeypatch.setattr(worker, "_wait_for_schema", lambda: True)
|
||||||
|
monkeypatch.setattr(worker.logsink, "start", lambda: None)
|
||||||
|
monkeypatch.setattr(worker.storage, "files_root_problem", lambda _root: "not a mounted volume")
|
||||||
|
with pytest.raises(SystemExit) as exit_:
|
||||||
|
worker.main()
|
||||||
|
assert exit_.value.code == 1
|
||||||
|
assert loops == []
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
|
# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
|
||||||
# OAUTH_REDIRECT_URL=https://siftlode.example.com/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS,
|
# OAUTH_REDIRECT_URL=https://siftlode.example.com/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS,
|
||||||
# YOUTUBE_API_KEY. APP_VERSION is exported by the deploy step (selects the image tag).
|
# YOUTUBE_API_KEY. APP_VERSION is exported by the deploy step (selects the image tag).
|
||||||
# Optional: DOWNLOAD_HOST_PATH (host dir for the download tree) and PLEX_MEDIA_HOST_PATH (host dir
|
# Optional: FILES_HOST_PATH (host dir for the download tree) and PLEX_MEDIA_HOST_PATH (host dir
|
||||||
# holding the Plex library, mounted read-only for the optional Plex module).
|
# holding the Plex library, mounted read-only for the optional Plex module).
|
||||||
|
|
||||||
name: siftlode
|
name: siftlode
|
||||||
@@ -72,8 +72,8 @@ services:
|
|||||||
# The public instance owns the background scheduler (single writer).
|
# The public instance owns the background scheduler (single writer).
|
||||||
SCHEDULER_ENABLED: "true"
|
SCHEDULER_ENABLED: "true"
|
||||||
# Download center: the API serves finished files + builds filmstrips (read-only root is fine —
|
# Download center: the API serves finished files + builds filmstrips (read-only root is fine —
|
||||||
# it only writes to the /downloads mount + tmpfs). The yt-dlp job loop runs in `worker` below.
|
# it only writes to the /files mount + tmpfs). The yt-dlp job loop runs in `worker` below.
|
||||||
DOWNLOAD_ROOT: /downloads
|
FILES_ROOT: /files
|
||||||
WORKER_ENABLED: "false"
|
WORKER_ENABLED: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -84,9 +84,9 @@ services:
|
|||||||
# (10.x.x.x:8080) and on the trusted home LAN. Never forwarded to the public internet.
|
# (10.x.x.x:8080) and on the trusted home LAN. Never forwarded to the public internet.
|
||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
# Downloaded media. Defaults to a Docker-managed named volume; set FILES_HOST_PATH in .env
|
||||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
- ${FILES_HOST_PATH:-${DOWNLOAD_HOST_PATH:-siftlode_files}}:/files
|
||||||
# Plex media, read-only. Only used when the optional Plex module is enabled: set
|
# Plex media, read-only. Only used when the optional Plex module is enabled: set
|
||||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the Plex library (the local side
|
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the Plex library (the local side
|
||||||
# of plex_path_map, default /plex-media). Left unset → an empty named volume (harmless; the
|
# of plex_path_map, default /plex-media). Left unset → an empty named volume (harmless; the
|
||||||
@@ -113,7 +113,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
||||||
# DB (job queue) and the downloads mount with the API. Not read-only (yt-dlp/deno/ffmpeg write to
|
# DB (job queue) and the /files mount with the API. Not read-only (yt-dlp/deno/ffmpeg write to
|
||||||
# $HOME caches + the staging dir); heavier limits since it does the video encoding.
|
# $HOME caches + the staging dir); heavier limits since it does the video encoding.
|
||||||
worker:
|
worker:
|
||||||
image: registry.example.com/you/siftlode:${APP_VERSION:-latest}
|
image: registry.example.com/you/siftlode:${APP_VERSION:-latest}
|
||||||
@@ -124,7 +124,7 @@ services:
|
|||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
||||||
# The API owns the scheduler; the worker must not also run it.
|
# The API owns the scheduler; the worker must not also run it.
|
||||||
SCHEDULER_ENABLED: "false"
|
SCHEDULER_ENABLED: "false"
|
||||||
DOWNLOAD_ROOT: /downloads
|
FILES_ROOT: /files
|
||||||
WORKER_ENABLED: "true"
|
WORKER_ENABLED: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -137,7 +137,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
volumes:
|
volumes:
|
||||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
- ${FILES_HOST_PATH:-${DOWNLOAD_HOST_PATH:-siftlode_files}}:/files
|
||||||
# Plex media (read-only) — same mount as the api; used by Plex clip jobs (a later phase).
|
# Plex media (read-only) — same mount as the api; used by Plex clip jobs (a later phase).
|
||||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||||
mem_limit: 2g
|
mem_limit: 2g
|
||||||
@@ -167,7 +167,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
siftlode_pgdata:
|
siftlode_pgdata:
|
||||||
siftlode_downloads:
|
siftlode_files:
|
||||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||||
siftlode_plex_media:
|
siftlode_plex_media:
|
||||||
|
|
||||||
|
|||||||
@@ -21,21 +21,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# This instance owns its scheduler (its own DB, so no double-write concern).
|
# This instance owns its scheduler (its own DB, so no double-write concern).
|
||||||
SCHEDULER_ENABLED: "true"
|
SCHEDULER_ENABLED: "true"
|
||||||
# Plex HLS remux scratch on fast container-local storage (the /downloads bind-mount is a slow
|
# Plex HLS remux scratch on fast container-local storage (the /files bind-mount is a slow
|
||||||
# Windows Docker Desktop mount in dev; prod uses fast local disk so it keeps the default).
|
# Windows Docker Desktop mount in dev; prod uses fast local disk so it keeps the default).
|
||||||
PLEX_HLS_DIR: /var/tmp/plex-hls
|
PLEX_HLS_DIR: /var/tmp/plex-hls
|
||||||
security_opt:
|
security_opt:
|
||||||
- apparmor:unconfined
|
- apparmor:unconfined
|
||||||
volumes:
|
volumes:
|
||||||
# Downloaded media lives on the host so you can inspect the generated Plex tree. Gitignored.
|
# Downloaded media lives on the host so you can inspect the generated Plex tree. Gitignored.
|
||||||
- ./downloads:/downloads
|
- ./files:/files
|
||||||
- plex_media:/plex-media:ro
|
- plex_media:/plex-media:ro
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
security_opt:
|
security_opt:
|
||||||
- apparmor:unconfined
|
- apparmor:unconfined
|
||||||
volumes:
|
volumes:
|
||||||
- ./downloads:/downloads
|
- ./files:/files
|
||||||
- plex_media:/plex-media:ro
|
- plex_media:/plex-media:ro
|
||||||
|
|
||||||
bgutil-pot:
|
bgutil-pot:
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ services:
|
|||||||
# This instance owns the background scheduler (single writer).
|
# This instance owns the background scheduler (single writer).
|
||||||
SCHEDULER_ENABLED: "true"
|
SCHEDULER_ENABLED: "true"
|
||||||
# Download center: the API serves finished files + builds filmstrips; the worker (below) runs
|
# Download center: the API serves finished files + builds filmstrips; the worker (below) runs
|
||||||
# the yt-dlp job loop. Read-only root is fine — it only writes to the /downloads mount + tmpfs.
|
# the yt-dlp job loop. Read-only root is fine — it only writes to the /files mount + tmpfs.
|
||||||
DOWNLOAD_ROOT: /downloads
|
FILES_ROOT: /files
|
||||||
WORKER_ENABLED: "false"
|
WORKER_ENABLED: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -60,9 +60,9 @@ services:
|
|||||||
# front for HTTPS / public exposure — and set OAUTH_REDIRECT_URL to the https URL.
|
# front for HTTPS / public exposure — and set OAUTH_REDIRECT_URL to the https URL.
|
||||||
- "${HTTP_PORT:-8080}:8000"
|
- "${HTTP_PORT:-8080}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
# Downloaded media. Defaults to a Docker-managed named volume; set FILES_HOST_PATH in .env
|
||||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
- ${FILES_HOST_PATH:-${DOWNLOAD_HOST_PATH:-siftlode_files}}:/files
|
||||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
||||||
# DB (job queue) and the downloads mount with the API. Not read-only (yt-dlp/deno/ffmpeg write to
|
# DB (job queue) and the /files mount with the API. Not read-only (yt-dlp/deno/ffmpeg write to
|
||||||
# $HOME caches + the staging dir).
|
# $HOME caches + the staging dir).
|
||||||
worker:
|
worker:
|
||||||
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
||||||
@@ -98,7 +98,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
||||||
SCHEDULER_ENABLED: "false"
|
SCHEDULER_ENABLED: "false"
|
||||||
DOWNLOAD_ROOT: /downloads
|
FILES_ROOT: /files
|
||||||
WORKER_ENABLED: "true"
|
WORKER_ENABLED: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -111,7 +111,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
volumes:
|
volumes:
|
||||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
- ${FILES_HOST_PATH:-${DOWNLOAD_HOST_PATH:-siftlode_files}}:/files
|
||||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -136,7 +136,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
siftlode_pgdata:
|
siftlode_pgdata:
|
||||||
siftlode_downloads:
|
siftlode_files:
|
||||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||||
siftlode_plex_media:
|
siftlode_plex_media:
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -37,7 +37,7 @@ services:
|
|||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||||
# Download center: the API serves finished files + builds filmstrips; the worker (below) runs
|
# Download center: the API serves finished files + builds filmstrips; the worker (below) runs
|
||||||
# the yt-dlp job loop.
|
# the yt-dlp job loop.
|
||||||
DOWNLOAD_ROOT: /downloads
|
FILES_ROOT: /files
|
||||||
WORKER_ENABLED: "false"
|
WORKER_ENABLED: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -45,9 +45,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8080}:8000"
|
- "${APP_PORT:-8080}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
# Downloaded media. Defaults to a Docker-managed named volume; set FILES_HOST_PATH in .env
|
||||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
- ${FILES_HOST_PATH:-${DOWNLOAD_HOST_PATH:-files}}:/files
|
||||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||||
@@ -63,7 +63,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
||||||
# DB (job queue) + the downloads mount with the API.
|
# DB (job queue) + the /files mount with the API.
|
||||||
worker:
|
worker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -80,7 +80,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||||
SCHEDULER_ENABLED: "false"
|
SCHEDULER_ENABLED: "false"
|
||||||
DOWNLOAD_ROOT: /downloads
|
FILES_ROOT: /files
|
||||||
WORKER_ENABLED: "true"
|
WORKER_ENABLED: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -91,7 +91,7 @@ services:
|
|||||||
bgutil-pot:
|
bgutil-pot:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
- ${FILES_HOST_PATH:-${DOWNLOAD_HOST_PATH:-files}}:/files
|
||||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||||
logging: *logging
|
logging: *logging
|
||||||
@@ -107,6 +107,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
downloads:
|
files:
|
||||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||||
plex_media:
|
plex_media:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user