diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51046584..fe84175e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
+## 0.89.0 — 2026-09-19
+
+A release for the log rather than for the screen: what the server writes down when something fails is now complete, said once, and free of what you downloaded or searched for.
+
+### Fixes
+
+- A download that failed because the server lost its database for a moment no longer shows the video's title and file path inside the database's own error message.
+
+### Internal
+
+- No log line carries what a person typed, watched or downloaded: search terms, titles, channel names, file paths, links and video ids are taken out — also from yt-dlp's and ffmpeg's own text, from error stacks, and from database errors. The one exception is Full diagnostics, and only under the user's own consent.
+- Every one of the backend's 114 swallowed exceptions was ruled on: 44 now leave a line with an event code, the other 70 say in the code why they need none — and a test keeps it that way. A failure is logged once, with its stack when it is our own bug, and a stalled or killed download no longer fails without a line.
+- yt-dlp's own explanations (“Requested format is not available”, a sign-in wall) reach the log instead of a stdout nobody read, and a burst of identical lines is summarised instead of flooding the stored log.
+- Three adversarial review rounds: 49 findings raised, 42 survived three independent attempts to refute them, all fixed, and every regression test was proven to fail with its own fix removed.
+
## 0.88.0 — 2026-09-18
When something goes wrong, you can now turn on detailed logging for yourself for 24 hours — and read your own log on the spot, before any of it is sent anywhere. Siftlode also has a privacy policy and terms at last, in both languages, saying exactly what is recorded and for how long.
diff --git a/VERSION b/VERSION
index fe6d01c1..5aee1345 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.88.0
+0.89.0
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 2dae04ba..b8044a9c 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -1,5 +1,3 @@
-import logging
-
import httpx
from fastapi import BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import delete, func, select
@@ -39,7 +37,7 @@ READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
_YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE})
-log = logging.getLogger("siftlode.auth")
+log = logs.get_logger(__name__)
def google_enabled(db: Session) -> bool:
@@ -222,7 +220,7 @@ def revoke_google_token(token: str | None) -> None:
timeout=10,
)
log.info("Google token revoke: status=%s", resp.status_code)
- except Exception:
+ except Exception: # noqa: BLE001 — a revoke we cannot make must not fail the sign-out
log.warning("Google token revoke failed", exc_info=True)
@@ -305,6 +303,13 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
try:
hid = int(hdr)
except ValueError:
+ # Under `strict` this becomes a 401 and the tab authenticates as nobody, which reads
+ # in the browser exactly like a session that expired. The value itself stays out —
+ # it came from a request, and `wallet` says which accounts were actually on offer.
+ log.debug(
+ "the active-account header could not be read as an id",
+ extra=logs.event("auth.account_header.invalid", accounts=len(wallet)),
+ )
hid = None
if hid is not None and hid in wallet:
return hid, hid == default_id
@@ -376,12 +381,20 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
user_id, is_default = resolved_user_id(request, strict=False)
try:
return _session_user(request, db, user_id, is_default)
+ # silent-ok: this is the anonymous case as much as the refused one — every landing page hit
+ # comes through here. The second attempt below is the handling, and it reports.
except HTTPException:
if is_default:
return None
try:
return _session_user(request, db, *resolved_user_id(request, strict=False))
except HTTPException:
+ # Both the pinned account AND the default one were refused, so the tab is signed out
+ # without being told why — and it got here from a session that named a real account.
+ log.info(
+ "a session was resolved to nobody: neither the named nor the default account held",
+ extra=logs.event("auth.session.probe_rejected", user_id=user_id),
+ )
return None
diff --git a/backend/app/config.py b/backend/app/config.py
index eaa9976e..2b2ad824 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -16,6 +16,8 @@ def _version_from_file() -> str:
v = p.read_text(encoding="utf-8").strip()
if v:
return v
+ # silent-ok: this runs at import, and `app.logs` imports THIS module — a log call here
+ # would be a circular import. The loop tries the other location, then falls back to "dev".
except OSError:
continue
return "dev"
diff --git a/backend/app/db.py b/backend/app/db.py
index 8ca8ecf2..51a06301 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -20,6 +20,10 @@ engine = create_engine(
max_overflow=30,
pool_timeout=30,
future=True,
+ # A database error renders its bound parameters into its message — a title, an uploader, a
+ # file path, a URL — and that message is logged and stored as a job's error text. The SQL
+ # stays; the values are data, and the ids an operator needs are on the line already.
+ hide_parameters=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py
index aa79ecd8..7a49ca47 100644
--- a/backend/app/discover/pins.py
+++ b/backend/app/discover/pins.py
@@ -62,6 +62,8 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
return (col, sh.id) if sh is not None else None
try:
pid = int(ref)
+ # silent-ok: validating a reference the client supplied. "Not an integer" is answered as "no
+ # such pin target", which is the same answer this function gives for one that does not exist.
except ValueError:
return None
pl = db.get(Playlist, pid)
diff --git a/backend/app/downloads/adapters.py b/backend/app/downloads/adapters.py
index 3f0c4ca5..4749adc5 100644
--- a/backend/app/downloads/adapters.py
+++ b/backend/app/downloads/adapters.py
@@ -18,6 +18,7 @@ import yt_dlp
from yt_dlp.networking.impersonate import ImpersonateTarget
from app.config import settings
+from app.downloads.stated import StatedFailure
# Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry
# below and the failure catalog (errors.py) decide on it, so the wording exists once.
@@ -178,11 +179,11 @@ class FacebookAdapter(SourceAdapter):
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
# form, no post id at all), which is not a post without a video — the user's cookie is the cause.
if _quoted(post_id) not in page:
- raise RuntimeError(POST_NOT_SHOWN_MESSAGE)
+ raise StatedFailure(POST_NOT_SHOWN_MESSAGE)
video_id = facebook_post_video_id(page, post_id)
if video_id is None:
# Never fall back to the post link: that is exactly where the wrong video comes from.
- raise RuntimeError(POST_WITHOUT_VIDEO_MESSAGE)
+ raise StatedFailure(POST_WITHOUT_VIDEO_MESSAGE)
return f"https://www.facebook.com/watch/?v={video_id}"
def client_sets(self, authed: bool = False) -> list:
diff --git a/backend/app/downloads/backfill.py b/backend/app/downloads/backfill.py
index 76d8258c..b793a770 100644
--- a/backend/app/downloads/backfill.py
+++ b/backend/app/downloads/backfill.py
@@ -11,11 +11,12 @@ Run once per environment:
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
"""
-import logging
+from urllib.parse import urlsplit
import yt_dlp
from sqlalchemy import select
+from app import logs
from app.config import settings
from app.db import SessionLocal
from app.downloads import adapters
@@ -23,12 +24,17 @@ from app.downloads import edit as editmod
from app.downloads import formats, service
from app.models import MediaAsset
-log = logging.getLogger("siftlode.backfill")
+log = logs.get_logger(__name__)
-def _extract_meta(url: str, adapter: adapters.SourceAdapter) -> dict | None:
+def _extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
needs the POT/player-client strategy even just to extract)."""
+ with formats.hiding(url):
+ return _extract_meta_scoped(url, adapter, asset_id)
+
+
+def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
for clients in adapter.client_sets():
opts = formats.build_ydl_opts(
{}, "%(id)s.%(ext)s", lambda d: None, extra=adapter.ydl_opts_extra(clients)
@@ -40,7 +46,19 @@ def _extract_meta(url: str, adapter: adapters.SourceAdapter) -> dict | None:
if info:
return info
except Exception as exc: # noqa: BLE001 — best-effort backfill, keep going
- log.info("meta extract failed for %s (%s): %s", url, clients, str(exc)[:100])
+ # The asset id, not the URL. A source URL is a row of this user's download
+ # history, and `redact_text` only takes credential params out of it — the rest
+ # of it goes in whole. The id names the same asset to anyone holding the
+ # database and nothing at all to anyone who is not; the host is kept because it
+ # is what decides which client set was tried. yt-dlp's message goes through
+ # `log_safe` for the same reason: it opens with the URL or the video id.
+ log.info(
+ "meta extract failed for asset %s on %s (%s): %s",
+ asset_id,
+ urlsplit(url).hostname or "?", # log-pii-ok: the host, never the path or query
+ clients,
+ formats.log_safe(exc)[:100],
+ )
return None
@@ -60,12 +78,14 @@ def run() -> dict:
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
if a.uploader_url is None and adapter.wants_metadata_backfill():
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
- info = _extract_meta(url, adapter)
+ info = _extract_meta(url, adapter, a.id)
uu = adapter.uploader_url(info) if info else None
if uu:
a.uploader_url = uu
filled_url += 1
- log.info("asset %s uploader_url <- %s", a.id, uu)
+ log.info(
+ "asset %s uploader_url filled", a.id
+ ) # the URL names what they downloaded
if a.poster_path is None:
# Record a self-hosted poster for every download (the reliable og:image source):
# ensure_poster returns the existing .jpg thumbnail sidecar, or cuts a frame.
@@ -73,7 +93,7 @@ def run() -> dict:
if rel:
a.poster_path = rel
filled_poster += 1
- log.info("asset %s poster <- %s", a.id, rel)
+ log.info("asset %s poster filled", a.id) # the path carries the title
db.commit()
result = {
"assets": len(assets),
diff --git a/backend/app/downloads/credentials.py b/backend/app/downloads/credentials.py
index c3e20ebb..bce4ca4c 100644
--- a/backend/app/downloads/credentials.py
+++ b/backend/app/downloads/credentials.py
@@ -23,19 +23,19 @@ Security controls enforced here:
logged, or written to a job's error/snapshot.
"""
-import logging
import os
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
+from app import logs
from app.downloads import formats
from app.downloads.adapters import _host_is
from app.models import DownloadCredential, MediaAsset
from app.security import decrypt_optional, encrypt
-log = logging.getLogger("siftlode.downloads.credentials")
+log = logs.get_logger(__name__)
class HostSpec:
diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py
index aabeba7b..d4967169 100644
--- a/backend/app/downloads/edit.py
+++ b/backend/app/downloads/edit.py
@@ -23,7 +23,6 @@ for a player hover-preview later.
import hashlib
import json
-import logging
import math
import os
import queue
@@ -33,9 +32,11 @@ import threading
import time
from pathlib import Path
+from app import logs
from app.config import settings
+from app.downloads.stated import StatedFailure
-log = logging.getLogger("siftlode.edit")
+log = logs.get_logger(__name__)
def encode_thread_cap() -> int:
@@ -71,7 +72,13 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if crop:
try:
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
- except (KeyError, TypeError, ValueError):
+ except (KeyError, TypeError, ValueError) as exc:
+ # The clip still renders — uncropped. The user asked for a crop, got a whole frame,
+ # and nothing anywhere called it a failure; this line is the only trace there is.
+ log.warning(
+ "the crop in an edit spec could not be read and was dropped",
+ extra=logs.event("edit.spec.crop_dropped", reason=type(exc).__name__),
+ )
c = None
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
out["crop"] = c
@@ -80,6 +87,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
segs = spec.get("segments")
if isinstance(segs, list) and segs:
norm: list[dict] = []
+ unreadable = 0
for s in segs:
try:
st = max(0.0, float((s or {}).get("start_s") or 0.0))
@@ -88,9 +96,18 @@ def normalize_edit_spec(spec: dict | None) -> dict:
continue
en = float(end_raw)
except (TypeError, ValueError):
+ # silent-ok: counted, not logged here — one cut-list can drop several, and the
+ # join that comes out is simply shorter than the one the user built. Reported
+ # once after the loop.
+ unreadable += 1
continue
if en > st:
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
+ if unreadable:
+ log.warning(
+ "some segments of a cut list could not be read and were dropped",
+ extra=logs.event("edit.spec.segments_dropped", dropped=unreadable, kept=len(norm)),
+ )
norm.sort(key=lambda x: x["start_s"])
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
@@ -114,7 +131,13 @@ def normalize_edit_spec(spec: dict | None) -> dict:
start = max(0.0, float(trim.get("start_s") or 0.0))
end_raw = trim.get("end_s")
end = float(end_raw) if end_raw is not None else None
- except (TypeError, ValueError):
+ except (TypeError, ValueError) as exc:
+ # Either a 400 the user cannot act on, or — beside a crop — a full-length clip where
+ # they asked for a cut. Both are worth one line saying the spec was the problem.
+ log.warning(
+ "the trim in an edit spec could not be read and was dropped",
+ extra=logs.event("edit.spec.trim_dropped", reason=type(exc).__name__),
+ )
start, end = 0.0, None
t: dict = {"start_s": round(start, 3)}
if end is not None and end > start:
@@ -306,11 +329,14 @@ def _parse_out_time(line: str) -> float | None:
if line.startswith("out_time_us="):
try:
return int(line.split("=", 1)[1]) / 1_000_000
+ # silent-ok: one line of ffmpeg's `-progress` stream, several per second. None means "this
+ # line carried no usable time", which the caller already treats as "no progress yet".
except ValueError:
return None
if line.startswith("out_time_ms="): # (some builds mislabel this as microseconds)
try:
return int(line.split("=", 1)[1]) / 1_000_000
+ # silent-ok: as above, the other spelling of the same line.
except ValueError:
return None
return None
@@ -329,7 +355,9 @@ def _pump(stream, sink: "queue.Queue[str | None]") -> None:
try:
for line in stream:
sink.put(line)
- except (ValueError, OSError): # pipe closed under us by the kill path
+ # silent-ok: the pipe was closed under us by our OWN kill path — the expected end of a
+ # cancelled or timed-out run, and the code that did the killing is the code that reports it.
+ except (ValueError, OSError):
pass
finally:
sink.put(None)
@@ -347,6 +375,8 @@ def _drain_tail(stream, sink: list[str]) -> None:
sink.append(line)
if len(sink) > 200:
del sink[:100]
+ # silent-ok: same closed pipe as `_pump` above, and the tail collected so far is still handed
+ # to whoever reports the failure.
except (ValueError, OSError):
pass
@@ -362,13 +392,17 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label
try:
proc.wait(timeout=grace_s)
return
+ # silent-ok: the escalation below IS the handling of this timeout, and surviving THAT is what
+ # the line under the second wait reports.
except subprocess.TimeoutExpired:
pass
kill()
try:
proc.wait(timeout=grace_s)
except subprocess.TimeoutExpired:
- log.error("%s pid %s survived SIGKILL", label, proc.pid)
+ # noqa TRY400: the exception IS the message here. A TimeoutExpired's traceback is this
+ # `wait` call and nothing else — it would add three frames and no fact the line lacks.
+ log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400
def _stop(proc: subprocess.Popen) -> None:
@@ -404,6 +438,8 @@ def run_ffmpeg(
while True:
try:
line = lines.get(timeout=1.0)
+ # silent-ok: not a failure — the one-second timeout is how this loop gets a turn to
+ # poll for cancellation while ffmpeg is quiet.
except queue.Empty:
line = ""
if line is None: # stdout hit EOF — ffmpeg is done (or dying)
@@ -416,11 +452,11 @@ def run_ffmpeg(
if should_cancel():
raise EditAborted
if time.monotonic() - last_output > stall_timeout_s:
- raise RuntimeError(f"ffmpeg stalled: no progress for {int(stall_timeout_s)}s")
+ raise StatedFailure(f"ffmpeg stalled: no progress for {int(stall_timeout_s)}s")
try:
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
except subprocess.TimeoutExpired: # pipes closed but the process lingers
- raise RuntimeError("ffmpeg did not exit after closing its output")
+ raise StatedFailure("ffmpeg did not exit after closing its output")
except BaseException:
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this
# function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
@@ -435,7 +471,7 @@ def run_ffmpeg(
if pipe:
pipe.close()
if ret != 0:
- raise RuntimeError(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
+ raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
# --- ffmpeg: universal browser playability --------------------------------------------------
@@ -471,7 +507,13 @@ def run_ffprobe_json(
try:
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
return json.loads(out)
- except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError) as exc:
+ # None makes every field it would have filled read as absent, and a file with no streams
+ # and no duration is indistinguishable here from one that is not media at all.
+ log.warning(
+ "ffprobe could not read a file",
+ extra=logs.event("media.ffprobe_failed", reason=type(exc).__name__),
+ )
return None
@@ -525,7 +567,13 @@ def probe_duration(path: Path) -> int:
check=True,
).stdout.strip()
return int(float(out)) if out else 0
- except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError) as exc:
+ # Zero is the denominator of the re-encode progress bar, so a failed probe leaves the user
+ # watching a bar that never moves rather than a job that says it went wrong.
+ log.info(
+ "the duration probe failed; progress for this job has no total",
+ extra=logs.event("media.duration_probe_failed", reason=type(exc).__name__),
+ )
return 0
@@ -670,15 +718,28 @@ def _run_ffmpeg_best_effort(cmd: list[str], timeout_s: int) -> Exception | None:
try:
subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=True)
return None
+ # silent-ok: the exception is the RETURN VALUE. This helper exists so the three callers share
+ # one run/timeout policy, and each logs the failure with the context only it has.
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc:
return exc
+def _step_failure(err: BaseException) -> str:
+ """What a best-effort image step's failure line says: the kind and, for ffmpeg, its exit
+ status. Never `str(err)`: a CalledProcessError or TimeoutExpired quotes ffmpeg's argv, and an
+ OSError its file name — the source path either way, which is built from the channel and the
+ title."""
+ code = getattr(err, "returncode", None)
+ return f"{type(err).__name__} {code}" if code is not None else type(err).__name__
+
+
def _is_jpeg(path: Path) -> bool:
"""True if the file starts with the JPEG SOI marker (FF D8 FF)."""
try:
with path.open("rb") as f:
return f.read(3) == b"\xff\xd8\xff"
+ # silent-ok: False only means "do not copy this straight through" — the caller then goes the
+ # ffmpeg route, which opens the same file and reports it properly if it really is unreadable.
except OSError:
return False
@@ -699,13 +760,15 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
shutil.copyfile(src, dest)
return dest
except OSError as exc:
- log.info("thumbnail copy failed (%s); poster falls back to a cut frame", str(exc)[:120])
+ log.info(
+ "thumbnail copy failed (%s); poster falls back to a cut frame", _step_failure(exc)
+ )
return None
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
if err is not None:
log.info(
"thumbnail->jpg conversion failed (%s); poster falls back to a cut frame",
- str(err)[:120],
+ _step_failure(err),
)
return None
return dest if dest.exists() else None
@@ -730,7 +793,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
at = min(3.0, (asset.duration_s or 10) * 0.1)
err = _run_ffmpeg_best_effort(build_poster_cmd(src, dest, at), timeout_s)
if err is not None:
- log.info("poster gen skipped for asset %s: %s", asset.id, str(err)[:120])
+ log.info("poster gen skipped for asset %s: %s", asset.id, _step_failure(err))
return None
return rel if dest.exists() else None
@@ -761,7 +824,7 @@ def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict |
cmd = build_storyboard_cmd(src, dest, asset.duration_s, geom)
err = _run_ffmpeg_best_effort(cmd, timeout_s)
if err is not None:
- log.info("storyboard gen skipped for asset %s: %s", asset.id, str(err)[:120])
+ log.info("storyboard gen skipped for asset %s: %s", asset.id, _step_failure(err))
return None
if not dest.exists():
return None
diff --git a/backend/app/downloads/formats.py b/backend/app/downloads/formats.py
index cc05becf..22e43f50 100644
--- a/backend/app/downloads/formats.py
+++ b/backend/app/downloads/formats.py
@@ -17,8 +17,134 @@ spec shape (all keys optional; defaults + browser-playability clamp applied by `
`container`/`vcodec` keys survive only so an old stored profile deserializes without error.
"""
+import contextlib
+import contextvars
import hashlib
import json
+import logging
+import os
+import re
+import urllib.parse
+from collections.abc import Iterator
+
+from app import logs
+
+log = logs.get_logger(__name__)
+
+# yt-dlp names what it is working on in these shapes, and each one is a row of someone's download
+# history: the source URL, the `[extractor] :` prefix on nearly every warning and error, the
+# file path it writes to (`storage.rel_path` builds it from the channel and the title), a
+# playlist's title, and an id quoted mid-sentence. The job id is already on every line
+# (`logs.set_job` in the download child), so none of them adds anything an operator needs. What
+# stays: the host (it says which extractor is failing) and the extractor tag. Every pattern below
+# is a shape measured in yt-dlp's own source (2026.07.04), pinned in test_ytdlp_logging.py.
+_URL_RE = re.compile(r"https?://([^/\s]+)\S*")
+# `Destination:` is yt-dlp's own word in `[download] Destination: `, not an id — the path
+# after it is what `_PATH_RE` takes out.
+_ID_PREFIX_RE = re.compile(r"(\[[\w:.-]+\] )(?!Destination:)[^\s:\[\]]+:")
+# The generic extractor's "id" is the URL's basename, percent-DECODED: `My%20Holiday.mp4` is
+# `My Holiday` — spaces, brackets, colons and all, which no pattern can bound (`Our Trip: Day 1`
+# ends at which `: `?). The job knows its own URL, so the exact name is taken out as a literal
+# (`hiding`). This pattern is the fallback for a line logged outside that scope: up to the first
+# `: `. Only this extractor: every other one names a site id, and letting the prefix run to `: `
+# everywhere would take the words out of lines like `[VideoConvertor] Converting …; Destination:`.
+_GENERIC_ID_RE = re.compile(r"(\[generic\] )(?!Destination:)[^\[\]\n]+?:(?= |$)", re.M)
+# `[youtube:tab] Playlist : Downloading 5 items` — the title is not an id, and has spaces.
+_PLAYLIST_TITLE_RE = re.compile(r"(\] Playlist ).+?(?=: Downloading )")
+# `[download] Finished downloading playlist: `, `[download] Downloading playlist: `,
+# `… Skipping already downloaded playlist: ` — the title runs to the end of the line.
+_TITLE_TAIL_RE = re.compile(r"(playlist: |\[download\] Downloading \w+: ).*", re.I)
+# An id quoted mid-sentence, not as a prefix: `… not available for `, and `--no-playlist`'s
+# `Downloading just the video ` (`_yes_playlist`) / `Downloading just video ` (multifeed).
+_BARE_ID_RE = re.compile(
+ r"((?:subtitles not available for|Downloading just (?:the )?\w+) )[^\s,;]+"
+)
+# Two segments or more, and not glued to a word: `www.youtube.com/…` is what the URL pattern
+# leaves behind, and it must survive this one.
+_PATH_RE = re.compile(r"(? str:
+ """The name yt-dlp's generic extractor gives a URL: its basename, extension off, percent-
+ decoded — `InfoExtractor._generic_id` (2026.07.04), pinned against it in the tests."""
+ return urllib.parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0])
+
+
+@contextlib.contextmanager
+def hiding(url: str | None) -> Iterator[None]:
+ """For the work on one URL: `log_safe` also takes out the name yt-dlp's generic extractor
+ gives it, exactly, where yt-dlp puts it (`[generic] :`). A context variable, so it
+ holds for yt-dlp's logger calls too — they run on the same thread as the extraction."""
+ name = generic_id(url) if url else ""
+ token = _HIDDEN.set((*_HIDDEN.get(), name) if name else _HIDDEN.get())
+ try:
+ yield
+ finally:
+ _HIDDEN.reset(token)
+
+
+def log_safe(text: object) -> str:
+ """yt-dlp's text (its own lines, or a DownloadError's message) with the parts that name a
+ person's download taken out. Every log line that carries yt-dlp's words goes through here,
+ not just the adapter's: yt-dlp raises the same text it has just logged, so a line that skipped
+ it would put back one line later exactly what the adapter took out."""
+ text = str(text)
+ for name in _HIDDEN.get():
+ text = text.replace(f"[generic] {name}:", "[generic] …:")
+ text = _URL_RE.sub(lambda m: f"{m.group(1)}/…", text)
+ text = _ID_PREFIX_RE.sub(r"\1…:", text)
+ text = _GENERIC_ID_RE.sub(r"\1…:", text)
+ text = _PLAYLIST_TITLE_RE.sub(r"\1…", text)
+ text = _TITLE_TAIL_RE.sub(r"\1…", text)
+ text = _BARE_ID_RE.sub(r"\1…", text)
+ return _PATH_RE.sub("…", text)
+
+
+class _YdlLog:
+ """yt-dlp's logger protocol, pointed at ours.
+
+ Without it yt-dlp explains itself to nobody: `quiet` sends its output to a stdout we do not
+ read, so "Requested format is not available", a signature-extraction failure or a POT refusal
+ reached us only as whatever text ended up in the raised exception. Measured against yt-dlp
+ 2026.07.04: with `logger` set it calls `debug` for extraction and progress lines and
+ `warning`/`error` for the rest, and `quiet`/`no_warnings` do NOT gate those calls.
+
+ `debug` goes to TRACE: it is several lines per second during a download, so it shows only
+ when the whole process runs at TRACE. (Not under a user's own Teljes consent — that lives in
+ the API process's request scope, and the download child that runs yt-dlp has none.)
+
+ `error` goes to WARNING, not ERROR. yt-dlp calls it from `trouble()` just before it raises,
+ on EVERY failed attempt — including the ones the pipeline then recovers from with its fallback
+ client set. Whether the job failed is the pipeline's verdict (`download.job.failed`), and that
+ is the one line whose level should say so."""
+
+ def _emit(self, level: int, msg: str) -> None:
+ # The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
+ # `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
+ # a record that has args, this way the question never arises — and it is the shape ruff's
+ # `G` rules ask for. (The sink's flood limiter groups a bare `%s` by the text itself, so
+ # distinct yt-dlp warnings do not share one bucket — `logsink._template`.)
+ log.log(level, "%s", log_safe(msg), extra=logs.event("download.ytdlp"))
+
+ def debug(self, msg):
+ self._emit(logs.TRACE, msg)
+
+ def info(self, msg):
+ self._emit(logging.DEBUG, msg)
+
+ def warning(self, msg):
+ self._emit(logging.WARNING, msg)
+
+ def error(self, msg):
+ self._emit(logging.WARNING, msg)
+
+
+_YDL_LOG = _YdlLog()
# Fields that affect the produced file — and therefore the cache identity.
_SIG_KEYS = (
@@ -78,7 +204,13 @@ def normalize(spec: dict) -> dict:
# rather than raising an unhandled 500 at enqueue.
try:
s["max_height"] = int(s["max_height"])
- except (TypeError, ValueError):
+ except (TypeError, ValueError) as exc:
+ # "best" is the opposite of what a height cap asks for: the user set a ceiling to save
+ # bandwidth or disk, and the download quietly takes the largest rendition there is.
+ log.warning(
+ "the height cap on a download could not be read and was dropped",
+ extra=logs.event("download.format.max_height_dropped", reason=type(exc).__name__),
+ )
s["max_height"] = None
for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"):
s[b] = bool(s[b])
@@ -181,6 +313,12 @@ def build_ydl_opts(
if extra:
opts.update(extra)
+ # With a logger attached, `quiet` and `no_warnings` stop deciding whether yt-dlp explains itself
+ # and only decide where — so both stay on above and the explanation comes here instead of to a
+ # stdout nothing reads. AFTER the adapter merge, like `cookiefile` and the size cap: an adapter
+ # must not be able to silence the one account of what went wrong that a failed job leaves.
+ opts["logger"] = _YDL_LOG
+
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
#
diff --git a/backend/app/downloads/gc.py b/backend/app/downloads/gc.py
index a640c22e..60267f98 100644
--- a/backend/app/downloads/gc.py
+++ b/backend/app/downloads/gc.py
@@ -16,7 +16,6 @@ Five passes each run:
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
"""
-import logging
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -24,14 +23,13 @@ from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
-from app import sysconfig
+from app import logs, sysconfig
from app.config import settings
from app.downloads import storage
from app.models import DownloadJob, MediaAsset
from app.notifications import create_notification
-log = logging.getLogger("siftlode.downloads.gc")
-
+log = logs.get_logger(__name__)
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
@@ -99,6 +97,7 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
if pause_s and touched % batch == 0:
time.sleep(pause_s)
+ unreclaimed = 0
for entry in root.iterdir():
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
continue
@@ -111,7 +110,9 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
f.unlink()
removed += 1
except OSError:
- pass
+ # silent-ok: counted, not logged per file — a broken mount would put one line
+ # under every orphan. The total is reported once at the end of the sweep.
+ unreclaimed += 1
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
for d in sorted(
(p for p in entry.rglob("*") if p.is_dir()),
@@ -120,12 +121,20 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
):
try:
d.rmdir()
+ # silent-ok: `rmdir` refusing means the directory still holds files, which is
+ # the ordinary answer here — this prune only takes the ones that emptied.
except OSError:
pass
try:
entry.rmdir()
+ # silent-ok: the same prune one level up, at the channel directory.
except OSError:
pass
+ if unreclaimed:
+ log.warning(
+ "some orphaned download files could not be reclaimed",
+ extra=logs.event("download.gc.unreclaimed", files=unreclaimed, removed=removed),
+ )
return removed
diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py
index 41b3cdd5..0f023774 100644
--- a/backend/app/downloads/links.py
+++ b/backend/app/downloads/links.py
@@ -49,6 +49,8 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
exp_s, sig = grant.split(".", 1)
try:
exp = int(exp_s)
+ # silent-ok: an untrusted grant that does not parse is simply not a valid grant, and the
+ # honest answer to it — a password prompt — is the same as for one that expired.
except ValueError:
return False
if exp < datetime.now(timezone.utc).timestamp():
diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py
index b9babc41..29d1e9aa 100644
--- a/backend/app/downloads/og.py
+++ b/backend/app/downloads/og.py
@@ -66,6 +66,8 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
width = (data[3] << 8) + data[4]
return width, height
f.seek(length - 2, 1) # skip this segment's payload
+ # silent-ok: None omits width/height from the unfurl card, which is what the card does for
+ # every poster whose dimensions were never asked for. Nothing is claimed that is not true.
except OSError:
return None
diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py
index 758d2f4f..d61e99b6 100644
--- a/backend/app/downloads/pipeline.py
+++ b/backend/app/downloads/pipeline.py
@@ -1,6 +1,5 @@
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
-import logging
import re
import shutil
import time
@@ -9,7 +8,7 @@ from pathlib import Path
import yt_dlp
-from app import sysconfig
+from app import logs, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
@@ -24,12 +23,13 @@ from app.downloads.jobdb import (
_staging_dir,
_touch_job,
)
+from app.downloads.stated import StatedFailure
from app.downloads.status import JobStatus
from app.models import DownloadJob, MediaAsset
from app.notifications import create_notification
from app.titles import normalize_title
-log = logging.getLogger("siftlode.worker")
+log = logs.get_logger(__name__)
class _Aborted(Exception):
@@ -98,6 +98,8 @@ def _parse_upload_date(raw) -> date | None:
return None
try:
return datetime.strptime(str(raw), "%Y%m%d").date()
+ # silent-ok: a date we cannot read becomes a date the card does not show, which is what the
+ # card shows for every source that never supplies one. Absent is the honest rendering.
except ValueError:
return None
@@ -138,7 +140,14 @@ def _make_progress_hook(job_id: int, asset_id: int):
try:
_fill_asset_meta_early(asset_id, d["info_dict"])
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
- pass
+ # Best-effort is about the DOWNLOAD, not about knowing. What this swallows is
+ # anything from a shape change in yt-dlp's info dict to the database being down,
+ # and all the user sees is a card with an id on it until the job finishes.
+ log.info(
+ "early metadata could not be filled in for this download",
+ extra=logs.event("download.meta.early_fill_failed", asset_id=asset_id),
+ exc_info=True,
+ )
if d.get("status") != "downloading":
return
if not allow_write():
@@ -299,7 +308,7 @@ def _produced_file(info: dict, staging: Path) -> Path:
and not _YTDLP_TEMP_RE.search(p.name)
]
if not cands:
- raise RuntimeError("yt-dlp produced no output file")
+ raise StatedFailure("yt-dlp produced no output file")
return max(cands, key=lambda p: p.stat().st_size)
@@ -375,13 +384,22 @@ def _extract_with_fallback(
"job %s: clients %s failed (%s); retrying with fallback",
job_id,
clients,
- str(exc)[:70],
+ formats.log_safe(exc)[:70],
)
for p in list(staging.iterdir()): # drop partials before the retry
try:
p.unlink()
- except OSError:
- pass
+ except OSError as exc:
+ # A partial left behind is not inert: the next client set resumes into it,
+ # so the retry this loop exists to make clean starts dirty instead.
+ log.info(
+ "a partial file could not be removed before the retry",
+ extra=logs.event(
+ "download.retry.partial_unlink_failed",
+ job_id=job_id,
+ reason=type(exc).__name__,
+ ),
+ )
_set_job(job_id, progress=0, phase="downloading")
continue
raise
@@ -464,7 +482,7 @@ def _download(
try:
urlguard.check_url(url)
except urlguard.BlockedUrl as e:
- raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
+ raise StatedFailure(f"Refusing to download from this address ({e.reason}).") from e
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
# this asset is that user's private, salted identity (the double gate lives in credentials).
remaining_bytes: int | None = None
@@ -586,13 +604,23 @@ def _download(
db.commit()
if finalized:
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
- log.info("job %s done: %s", job_id, rel)
+ # The asset id, not `rel`: the path is built from the channel and the title.
+ log.info(
+ "job %s done: asset %s",
+ job_id,
+ asset_id,
+ extra=logs.event("download.job.done", job_id=job_id, asset_id=asset_id),
+ )
except (_Aborted, editmod.EditAborted):
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
_reset_asset_pending(asset_id)
- log.info("job %s aborted (pause/cancel)", job_id)
+ log.info(
+ "job %s aborted (pause/cancel)",
+ job_id,
+ extra=logs.event("download.job.aborted", job_id=job_id),
+ )
except _OwnerRefused as exc:
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
# so another user's job for the same video is claimed and downloaded normally instead of
@@ -613,7 +641,12 @@ def _download(
# it, and this one must already be out of the way. (The staging tree is still torn down in
# `finally` below — the same narrow window the pause/cancel path above has always had.)
_reset_asset_pending(asset_id)
- log.info("job %s refused for its owner: %s", job_id, msg)
+ log.info(
+ "job %s refused for its owner: %s",
+ job_id,
+ formats.log_safe(msg),
+ extra=logs.event("download.job.refused", job_id=job_id),
+ )
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
msg = str(exc)[:500]
# The catalog needs the link and whether the user's cookie went with it: the same yt-dlp
@@ -626,8 +659,9 @@ def _download(
if asset:
asset.status = "error"
asset.error = msg
- # Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
- # with `error` (error also holds the asset → would re-open the double-release class).
+ # Atomic fail: only while still running, so a route-set cancel/pause is never
+ # overwritten with `error` (error also holds the asset → would re-open the
+ # double-release class).
service.finalize_if_running(
db,
job_id,
@@ -638,7 +672,10 @@ def _download(
phase=None,
)
db.commit()
- log.warning("job %s failed: %s", job_id, msg)
+ # After the write: a job the write could not fail is not a failed job. When it raises,
+ # the runner reports this failure with it (`runner._run`) and the worker fails the job.
+ # silent-ok: not silent — `_log_download_failed` is the report.
+ _log_download_failed(job_id, msg, diagnosis.code, exc)
finally:
# Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging
# tree too, but never leave a plaintext session cookie on disk a moment longer than needed).
@@ -647,7 +684,50 @@ def _download(
shutil.rmtree(staging, ignore_errors=True)
-def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
+def _failure_stack(exc: BaseException) -> dict[str, str]:
+ """The `extra=` fragment that puts a failure's traceback on its line — or nothing.
+
+ Nothing for yt-dlp's DownloadError and for our own `StatedFailure` (`ffmpeg failed (1): …`):
+ their message is the whole story, and their stack is only the `except` that caught them.
+ Everything else keeps it — a TypeError on an odd info dict, a plain RuntimeError, an OSError,
+ a database error: that is our code or our platform failing, and the stack is the lead.
+
+ Scrubbed, link by link (`logs.scrubbed_stack`): a stack's messages are data — an OSError names
+ its file, and a database error raised inside `except DownloadError` carries yt-dlp's
+ unscrubbed text as its context."""
+ if isinstance(exc, (yt_dlp.utils.DownloadError, StatedFailure)):
+ return {}
+ return logs.scrubbed_stack(exc, formats.log_safe)
+
+
+def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseException) -> None:
+ """The child's `download.job.failed` line. `msg` is the user's copy and keeps their URL; the
+ log's copy goes through `log_safe`, or it would put back one line later the URL and id the
+ yt-dlp adapter took out of the line before it. The stack only when the classifier could not
+ place the failure: a recognised one is fully described by its code."""
+ unplaced = code in (None, "", "unknown")
+ log.warning(
+ "job %s failed: %s",
+ job_id,
+ formats.log_safe(msg),
+ extra={
+ **logs.event(
+ "download.job.failed",
+ job_id=job_id,
+ error_code=code,
+ error_type=type(exc).__name__,
+ ),
+ **(_failure_stack(exc) if unplaced else {}),
+ },
+ )
+
+
+def _fail_edit(job_id: int, asset_id: int, msg: str, exc: BaseException | None = None) -> None:
+ """Fail an edit job, and say so ONCE — every way an edit fails comes through here, so this is
+ where its line and its event code live. `exc` is the exception when there was one.
+
+ 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."""
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
if asset:
@@ -663,7 +743,22 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
phase=None,
)
db.commit()
- log.warning("edit job %s failed: %s", job_id, msg)
+ # `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
+ # built from the channel and the title.
+ log.warning(
+ "edit job %s failed: %s",
+ job_id,
+ formats.log_safe(msg),
+ extra={
+ **logs.event(
+ "edit.job.failed",
+ job_id=job_id,
+ asset_id=asset_id,
+ **({"error_type": type(exc).__name__} if exc is not None else {}),
+ ),
+ **(_failure_stack(exc) if exc is not None else {}),
+ },
+ )
def _process_edit(job_id: int, asset_id: int) -> None:
@@ -715,7 +810,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
should_cancel=_cancel_poll(job_id),
)
if not dest_staging.exists():
- raise RuntimeError("ffmpeg produced no output file")
+ raise StatedFailure("ffmpeg produced no output file")
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
storage.place_file(dest_staging, settings.download_root, rel)
@@ -751,13 +846,24 @@ def _process_edit(job_id: int, asset_id: int) -> None:
db.commit()
if finalized:
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
- log.info("edit job %s done: %s", job_id, rel)
+ # The asset id, not `rel`: the path is built from the channel and the title.
+ log.info(
+ "edit job %s done: asset %s",
+ job_id,
+ asset_id,
+ extra=logs.event("edit.job.done", job_id=job_id, asset_id=asset_id),
+ )
except editmod.EditAborted:
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
_reset_asset_pending(asset_id)
- log.info("edit job %s aborted (pause/cancel)", job_id)
+ log.info(
+ "edit job %s aborted (pause/cancel)",
+ job_id,
+ extra=logs.event("edit.job.aborted", job_id=job_id),
+ )
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
- _fail_edit(job_id, asset_id, str(exc)[:500])
+ # 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)
finally:
shutil.rmtree(staging, ignore_errors=True)
diff --git a/backend/app/downloads/probe.py b/backend/app/downloads/probe.py
index 4187d00c..04dd70d3 100644
--- a/backend/app/downloads/probe.py
+++ b/backend/app/downloads/probe.py
@@ -64,6 +64,8 @@ def _facts_from_probe(
raw = fmt.get("duration")
if raw is not None:
duration = int(float(raw))
+ # silent-ok: an unusable duration is stored as no duration, which the UI renders as unknown
+ # rather than as zero — the honest state, and the one a re-probe can still fill in.
except (TypeError, ValueError):
duration = None
return vcodec, acodec, width, height, duration
diff --git a/backend/app/downloads/runner.py b/backend/app/downloads/runner.py
index 0e1c70cd..ac87b6c1 100644
--- a/backend/app/downloads/runner.py
+++ b/backend/app/downloads/runner.py
@@ -11,7 +11,6 @@ writes the terminal job/asset state itself (done / error, or a cooperative pause
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
"""
-import logging
import os
import shutil
import subprocess
@@ -22,9 +21,10 @@ from app import logsink
from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
+from app.downloads import formats
from app.models import DownloadJob
-log = logging.getLogger("siftlode.worker.runner")
+log = logs.get_logger(__name__)
def _deprioritize() -> None:
@@ -57,7 +57,12 @@ def main() -> None:
logs.configure_logging("runner")
_deprioritize()
if len(sys.argv) != 2:
- print("usage: python -m app.downloads.runner ", file=sys.stderr)
+ # noqa T201: a usage line is for the person who typed the command. Logging is already
+ # configured here, and in a deployment it writes JSON records — the one reader of this
+ # line wants the bare sentence, not a record with a request id they do not have.
+ print( # noqa: T201
+ "usage: python -m app.downloads.runner ", file=sys.stderr
+ )
raise SystemExit(2)
job_id = int(sys.argv[1])
# This whole process exists to run ONE job — tag every line it prints with it, so the child's
@@ -77,14 +82,40 @@ def main() -> None:
if asset_id is None:
return
+ _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id)
+
+
+def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) -> None:
+ """Run the job, and be the last stop before Python's own exception hook.
+
+ What gets past the pipeline is a failure it could not even record (the database refused the
+ write that fails the job). Python's hook would print it to stderr raw — and its context is the
+ download's own failure, whose text is the URL or ffmpeg's stderr naming the file. Here it is
+ one line with the whole chain scrubbed; the non-zero exit then has the worker fail the job
+ (`cause=child_exit`)."""
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
from app.downloads.pipeline import _download, _process_edit
- if job_kind == "edit":
- _process_edit(job_id, asset_id)
- else:
- _download(job_id, asset_id, source_kind, source_ref, spec, user_id)
+ with formats.hiding(source_ref):
+ try:
+ if job_kind == "edit":
+ _process_edit(job_id, asset_id)
+ else:
+ _download(job_id, asset_id, source_kind, source_ref, spec, user_id)
+ except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
+ # noqa TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
+ log.error( # noqa: TRY400
+ "the download child for job %s failed",
+ job_id,
+ extra={
+ **logs.event(
+ "download.child.failed", job_id=job_id, error_type=type(exc).__name__
+ ),
+ **logs.scrubbed_stack(exc, formats.log_safe),
+ },
+ )
+ raise SystemExit(1) from None
if __name__ == "__main__":
diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py
index 96918e0f..896d65ac 100644
--- a/backend/app/downloads/service.py
+++ b/backend/app/downloads/service.py
@@ -87,6 +87,8 @@ def get_or_create_asset(
with db.begin_nested():
db.add(asset)
db.flush()
+ # silent-ok: the expected outcome of the race this savepoint exists for — two jobs wanting the
+ # same asset. Losing it means the other insert won, and re-selecting is the whole handling.
except IntegrityError:
asset = db.execute(stmt).scalar_one()
return asset
diff --git a/backend/app/downloads/stated.py b/backend/app/downloads/stated.py
new file mode 100644
index 00000000..e4a2c991
--- /dev/null
+++ b/backend/app/downloads/stated.py
@@ -0,0 +1,13 @@
+"""The failures the download pipeline raises itself, with the whole story in the message.
+
+`ffmpeg failed (1): …`, `yt-dlp produced no output file`, a post that shows no video: the message
+says what happened, and the stack is only the `except` that caught it. A failure line leaves that
+stack off (`pipeline._failure_stack`) — but ONLY for these. A plain `RuntimeError` is also what
+Python raises for a bug of ours (a dict changed size during iteration, a generator's
+StopIteration, `RecursionError`, `NotImplementedError`), and that one's stack is the only lead.
+"""
+
+
+class StatedFailure(RuntimeError):
+ """A failure we raised on purpose, whose message is its full explanation. A `RuntimeError` so
+ every `except RuntimeError` that caught these before still does."""
diff --git a/backend/app/downloads/storage.py b/backend/app/downloads/storage.py
index 88ee8577..0084938e 100644
--- a/backend/app/downloads/storage.py
+++ b/backend/app/downloads/storage.py
@@ -19,8 +19,11 @@ from datetime import date
from pathlib import Path
from xml.sax.saxutils import escape
+from app import logs
from app.downloads import mediakind
+log = logs.get_logger(__name__)
+
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
_MAX_TITLE = 120
# User-facing (Content-Disposition) names can be longer than an on-disk component.
@@ -294,7 +297,13 @@ def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Pat
if thumb_src and thumb_src.exists():
shutil.copyfile(str(thumb_src), str(_sidecar(media, ".jpg")))
return True
- except OSError:
+ except OSError as exc:
+ # The download itself still completes, so the job reads as done while the library it was
+ # downloaded FOR has no .nfo to read and no poster to show.
+ log.warning(
+ "the sidecars beside a download could not be written",
+ extra=logs.event("download.sidecars.write_failed", reason=type(exc).__name__),
+ )
return False
@@ -304,14 +313,23 @@ def delete_asset_files(download_root: str, rel: str) -> None:
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
try:
p.unlink()
- except OSError:
- pass
+ except FileNotFoundError:
+ pass # silent-ok: already gone is the outcome this function wanted
+ except OSError as exc:
+ # The row goes and the bytes stay. Nothing else ever revisits this path, so the disk
+ # quietly keeps a file no account has any way to see, let alone delete.
+ log.info(
+ "a deleted download left a file behind",
+ extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
+ )
# Prune empty Season/Channel dirs up to (but not including) the root.
root = Path(download_root).resolve()
parent = media.parent
while parent != root and root in parent.parents:
try:
parent.rmdir() # only succeeds if empty
+ # silent-ok: this `rmdir` failing is the loop's stop condition — the parent still
+ # holds another download's files, so the prune has gone as far up as it may.
except OSError:
break
parent = parent.parent
diff --git a/backend/app/email.py b/backend/app/email.py
index 1c08e0e4..8b92813a 100644
--- a/backend/app/email.py
+++ b/backend/app/email.py
@@ -11,7 +11,6 @@ to be a person telling them nothing arrived.
"""
import html
-import logging
import re
import smtplib
import ssl
@@ -21,15 +20,14 @@ from email.utils import formatdate
from sqlalchemy import select
from sqlalchemy.orm import Session
-from app import mailtext, sysconfig
+from app import logs, mailtext, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.models import User
from app.notifications import create_notification
from app.ratelimit import RateLimiter
-log = logging.getLogger("siftlode.email")
-
+log = logs.get_logger(__name__)
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
# stay readable; the log keeps the full record either way.
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
@@ -155,7 +153,15 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
return False
c = _smtp()
if not (c["host"] and c["user"] and c["password"]):
- log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
+ # The count, not the addresses: `redact_text` rewrites every one of them to `[email]`
+ # before either sink sees the line, so the list only ever printed as noise. The subject
+ # stays — the templates are static, and the one that interpolates (`admin_new_request`)
+ # interpolates an address, which the same redactor covers.
+ log.info(
+ "SMTP not configured; skipping email %r to %d recipient(s)",
+ subject,
+ len(recipients), # log-pii-ok: how many, not who
+ )
_alert_admins("not_configured", subject, recipients)
return False
msg = EmailMessage()
@@ -176,10 +182,12 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
s.starttls(context=ssl.create_default_context())
s.login(c["user"], c["password"])
s.send_message(msg)
- log.info("Sent email %r to %s", subject, recipients)
+ # log-pii-ok: how many, not who
+ log.info("Sent email %r to %d recipient(s)", subject, len(recipients))
return True
except Exception:
- log.exception("SMTP send failed (%r to %s)", subject, recipients)
+ # log-pii-ok: how many, not who
+ log.exception("SMTP send failed (%r to %d recipient(s))", subject, len(recipients))
_alert_admins("send_failed", subject, recipients)
return False
diff --git a/backend/app/logs.py b/backend/app/logs.py
index 700c6aa6..013076ed 100644
--- a/backend/app/logs.py
+++ b/backend/app/logs.py
@@ -15,7 +15,7 @@ issue attachment will carry, so it is deliberately flat and stable.
Call sites stay ordinary stdlib logging:
- log = logging.getLogger("siftlode.downloads")
+ log = logs.get_logger(__name__) # app/downloads/pipeline.py -> siftlode.downloads.pipeline
log.info("job finished", extra=logs.event("download.job.done", job_id=7, bytes=n))
`event()` is optional — a bare `log.info("…")` still comes out well-formed — but the dotted code is
@@ -37,7 +37,9 @@ import re
import sys
import threading
import time
+import traceback
import uuid
+from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from urllib.parse import parse_qsl
@@ -152,11 +154,15 @@ def bind(**fields: Any):
_CONTEXT_VARS[key].reset(token)
-def event(code: str, **attrs: Any) -> dict[str, Any]:
+def event(code: str, /, **attrs: Any) -> dict[str, Any]:
"""`extra=` payload for a log call: a stable dotted event code plus structured attributes.
Returns a plain dict so it composes with stdlib logging and costs nothing when the level is off
- (the dict is built either way; keep the attrs cheap — no DB reads inside the call)."""
+ (the dict is built either way; keep the attrs cheap — no DB reads inside the call).
+
+ `code` is positional-only so that no attribute name is reserved: `event("x", code=...)` means
+ the attribute, not this parameter. Without the `/` it was a TypeError, and `code` is exactly
+ the word a caller reaches for when the thing that failed has one of its own."""
return {"event": code, "attrs": attrs}
@@ -259,11 +265,53 @@ def redact(value: Any, *, key: str = "") -> Any:
# --- formatters ----------------------------------------------------------------------------
+_SCRUBBED_STACK = "scrubbed_stack"
+
+
+def scrubbed_stack(exc: BaseException, scrub: Callable[[BaseException], str]) -> dict[str, str]:
+ """`extra=` fragment: `exc`'s traceback, rendered where an `exc_info` stack goes, with the
+ message of every exception in it put through `scrub`.
+
+ For a failure whose message can name someone's data (a download's URL, a file path, a video
+ id). The frames name code and are kept whole; the text at the foot of each link is the data.
+ A plain `exc_info` renders the chain too — the `__cause__`/`__context__` a DB error raised
+ inside `except DownloadError` carries — so scrubbing only the line's own message is not
+ enough. Merge it into the call's `extra`: `{**event(...), **scrubbed_stack(...)}`."""
+ links: list[BaseException] = []
+ link: BaseException | None = exc
+ while link is not None and all(link is not seen for seen in links):
+ links.append(link)
+ link = link.__cause__ or (None if link.__suppress_context__ else link.__context__)
+ parts: list[str] = []
+ for i in range(len(links) - 1, -1, -1): # the oldest first, as Python prints a chain
+ link = links[i]
+ kind = type(link)
+ name = kind.__qualname__
+ if kind.__module__ not in ("builtins", "__main__"):
+ name = f"{kind.__module__}.{name}"
+ try:
+ message = scrub(link)
+ # silent-ok: the message is what could not be rendered, and the placeholder says so — the
+ # caller is in the middle of reporting one failure and must not get a second from here.
+ except Exception: # noqa: BLE001 — a `__str__` that raises, of any shape
+ message = ""
+ frames = "".join(traceback.format_tb(link.__traceback__))
+ head = f"Traceback (most recent call last):\n{frames}" if frames else ""
+ parts.append(f"{head}{name}: {message}" if message else f"{head}{name}")
+ if i:
+ parts.append(
+ "\nThe above exception was the direct cause of the following exception:\n"
+ if links[i - 1].__cause__ is link
+ else "\nDuring handling of the above exception, another exception occurred:\n"
+ )
+ return {_SCRUBBED_STACK: "\n".join(parts)}
+
+
# Attributes every LogRecord carries; anything else a caller passed via `extra=` is a structured
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
_RECORD_STANDARD = frozenset(
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
- | {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated"}
+ | {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated", _SCRUBBED_STACK}
)
@@ -314,6 +362,8 @@ def _safe_message(record: logging.LogRecord) -> str:
must not take down the caller, so the failure is reported IN the line instead."""
try:
return record.getMessage()
+ # silent-ok: the failure is reported IN the returned line. Logging it would re-enter this same
+ # formatter with a record built out of the one that just failed.
except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
return f"{record.msg!r} (log formatting failed: {exc})"
@@ -342,6 +392,8 @@ def record_fields(record: logging.LogRecord) -> dict[str, Any]:
fields["attrs"] = redact(attrs)
if record.exc_info:
fields["exc"] = redact_text(logging.Formatter().formatException(record.exc_info))
+ elif getattr(record, _SCRUBBED_STACK, None):
+ fields["exc"] = redact_text(getattr(record, _SCRUBBED_STACK))
elif record.exc_text:
# A QueueHandler-style hand-off formats the traceback into exc_text and drops exc_info.
fields["exc"] = redact_text(record.exc_text)
@@ -375,6 +427,8 @@ class TextFormatter(logging.Formatter):
line += f" {json.dumps(redact(attrs), ensure_ascii=False, default=str)}"
if record.exc_info:
line += "\n" + redact_text(self.formatException(record.exc_info))
+ elif getattr(record, _SCRUBBED_STACK, None):
+ line += "\n" + redact_text(getattr(record, _SCRUBBED_STACK))
return line
@@ -401,6 +455,28 @@ _THIRD_PARTY_LEVELS = {
}
+def get_logger(name: str) -> logging.Logger:
+ """The logger for one module: `logs.get_logger(__name__)` in `app/plex/sync.py` is
+ `siftlode.plex.sync`.
+
+ The names were literals before, and 25 of them covered 34 modules — six different Plex modules
+ all called themselves `siftlode.plex`, so neither a level pinned per logger nor E22.6's console
+ filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one
+ name per module, it cannot drift from the file it sits in, and `test_logger_names.py` fails if a
+ module hand-rolls one again.
+
+ `__main__` is resolved to the module it really is. The worker runs as `python -m app.worker`
+ and spawns `python -m app.downloads.runner`, so in the two long-running processes `__name__`
+ is `__main__` — taken at its word, both would log as the bare root. `-m` records the real name
+ on the module's spec; a plain script has none and stays the root."""
+ if name == "__main__":
+ spec = getattr(sys.modules.get("__main__"), "__spec__", None)
+ name = getattr(spec, "name", None) or name
+ stem = name.removeprefix("app.") if name.startswith("app.") else ""
+ # logger-name-ok: this call IS the derivation
+ return logging.getLogger(f"siftlode.{stem}" if stem else "siftlode")
+
+
def resolve_level(name: str) -> int:
"""Level name → number. Both ways in (the `LOG_LEVEL` env and the `log_level` admin knob) are
already held to the `LogLevel` choices, so an unknown name here is a programming error."""
@@ -566,7 +642,10 @@ _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
-_request_log = logging.getLogger("siftlode.http")
+# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
+# what an operator pins a level on when they want request lines and nothing else. The module it
+# happens to live in is not what the name is about.
+_request_log = logging.getLogger("siftlode.http") # logger-name-ok: named for the traffic
async def log_requests(request, call_next):
@@ -646,6 +725,16 @@ async def log_requests(request, call_next):
return response
+def consented_trace() -> bool:
+ """Whether the current request's OWN user turned Teljes (TRACE) on — the gate for a line that
+ carries what they typed or what was quoted back to them. The level alone is not enough: an
+ admin running the whole process at TRACE would pass it for every user, and a scheduler thread,
+ which has no request, for none of them in particular. Guard such a line with this, then log it
+ at TRACE."""
+ scope = _scope.get()
+ return scope is not None and _traced(scope)
+
+
def _traced(scope: _RequestScope) -> bool:
"""Whether this request's query string is logged: only under its OWN user's Teljes consent. An
admin setting the whole process to TRACE gets more detail, not every user's search terms — the
diff --git a/backend/app/logsink.py b/backend/app/logsink.py
index 444fe5bd..924475ee 100644
--- a/backend/app/logsink.py
+++ b/backend/app/logsink.py
@@ -29,6 +29,7 @@ import atexit
import json
import logging
import queue
+import re
import sys
import threading
import time
@@ -66,7 +67,7 @@ _LIMITS = {
"exc": 32_000,
}
-_log = logging.getLogger("siftlode.logsink")
+_log = logs.get_logger(__name__)
def should_store(record: logging.LogRecord) -> bool:
@@ -137,6 +138,21 @@ def _notice(code: str, msg: str, **attrs: Any) -> dict[str, Any]:
)
+_DIGITS = re.compile(r"\d+")
+
+
+def _template(record: logging.LogRecord) -> str:
+ """The flood limiter's grouping key: the message template. Except when the template is a bare
+ `%s` — a pass-through for someone else's text (yt-dlp's lines, `formats._YdlLog`), where the
+ template says nothing and every distinct line would share one bucket. Then the text itself
+ groups, with its numbers folded, so `Retrying fragment 5 (1/10)` and `(2/10)` are still one
+ repeat and a different warning is not crowded out by them."""
+ msg = str(record.msg)
+ if msg != "%s" or not record.args:
+ return msg
+ return _DIGITS.sub("#", record.getMessage())[:200]
+
+
class _Repeats:
"""Per-template admission for one minute at a time. Used by the writer thread only (unlocked)."""
@@ -155,9 +171,11 @@ class _Repeats:
self.seen[key] = (count or 0) + 1
return self.seen[key] <= self.per_window
- def roll(self, now: float) -> list[dict[str, Any]]:
- """Close the window if it is over: one notice per template that went past the limit."""
- if now - self.started < self.window:
+ def roll(self, now: float, *, final: bool = False) -> list[dict[str, Any]]:
+ """Close the window if it is over — or, `final`, because the process is: one notice per
+ template that went past the limit. Without `final` a process that lives under a minute
+ (every download child) would drop its repeats and never say so."""
+ if not final and now - self.started < self.window:
return []
notices = [
_notice(
@@ -202,6 +220,8 @@ class LogSink(logging.Handler):
return
try:
row = to_row(logs.record_fields(record))
+ # silent-ok: `handleError` is the report — logging's own out-of-band channel to stderr.
+ # A log call here would be handed to this handler, which is the one that just failed.
except Exception: # noqa: BLE001 — a record we cannot turn into a row is reported, not raised
self.handleError(record)
return
@@ -209,21 +229,25 @@ class LogSink(logging.Handler):
key = (
None
if row["event"] == "http.request"
- else (record.name, str(record.msg), record.levelno)
+ else (record.name, _template(record), record.levelno)
)
try:
self._queue.put_nowait((key, row))
+ # silent-ok: counted, not logged — the count goes out as the `log.sink.dropped` notice row
+ # the writer emits, which is the one report that does not feed this queue again.
except queue.Full:
with self._dropped_lock:
self._dropped += 1
# --- writer side ---------------------------------------------------------------------------
- def write_pending(self, wait: float = 0.0) -> int:
+ def write_pending(self, wait: float = 0.0, *, final: bool = False) -> int:
"""Write one batch: whatever is queued (up to BATCH_MAX), waiting up to `wait` seconds for the
- first item. Returns the number of rows written, notices included."""
+ first item. Returns the number of rows written, notices included. `final` closes the
+ repeat window early — the last batch before the process exits."""
rows: list[dict[str, Any]] = []
try:
item = self._queue.get(timeout=wait) if wait > 0 else self._queue.get_nowait()
+ # silent-ok: an idle sink. Nothing failed; there was simply nothing waiting.
except queue.Empty:
item = None
while item is not None:
@@ -234,9 +258,10 @@ class LogSink(logging.Handler):
break
try:
item = self._queue.get_nowait()
+ # silent-ok: the batch ends here because the queue ran dry, which is the normal end.
except queue.Empty:
item = None
- rows.extend(self._repeats.roll(time.monotonic()))
+ rows.extend(self._repeats.roll(time.monotonic(), final=final))
with self._dropped_lock:
dropped, self._dropped = self._dropped, 0
if dropped:
@@ -254,8 +279,12 @@ class LogSink(logging.Handler):
with self._bind.connect() as conn, logs.erasure_guard(), conn.begin():
conn.execute(insert(LogEvent), without_erased(rows))
except Exception as exc: # noqa: BLE001 — the database is down or refused the batch
- print(
- f"log sink: could not store {len(rows)} log events: {logs.redact_text(str(exc))[:300]}",
+ # silent-ok / noqa T201: the sink's OWN failure. A log call here would be handed to
+ # this sink, which is the thing that just failed — straight into a loop. stderr is
+ # the one way out that does not re-enter.
+ print( # noqa: T201
+ f"log sink: could not store {len(rows)} log events: "
+ f"{logs.redact_text(str(exc))[:300]}",
file=sys.stderr,
)
return 0
@@ -266,6 +295,7 @@ class LogSink(logging.Handler):
self.write_pending(wait=FLUSH_SECONDS)
while self.write_pending(): # drain what arrived before the stop
pass
+ self.write_pending(final=True) # and the repeat notices of a window cut short
def start(self) -> None:
self._writer = threading.Thread(target=self._run, name="log-sink", daemon=True)
diff --git a/backend/app/main.py b/backend/app/main.py
index a4c1f44a..5896a885 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,5 +1,4 @@
import asyncio
-import logging
import math
from contextlib import asynccontextmanager, suppress
from pathlib import Path
@@ -12,11 +11,12 @@ from fastapi.exceptions import RequestValidationError
# import time is already covered. ONE format for the api, the worker, the download child and alembic
# alike (app/logs.py) — this replaces the old uvicorn --log-config + the hand-rolled fallback that
# used to live here, and uvicorn's access log is replaced by the `http.request` middleware below.
+from app import logs
from app.logs import configure_logging, current_level, log_requests, set_level
configure_logging("api")
-log = logging.getLogger("siftlode.app")
+log = logs.get_logger(__name__)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -73,17 +73,17 @@ async def lifespan(app: FastAPI):
# booting — an unreachable or not-yet-migrated database has to start into the env level.
try:
set_level(sysconfig.get_str(db, "log_level"))
- except Exception as exc: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
- log.warning(
- "could not read the stored log level (%s); staying at %s", exc, current_level()
- )
+ except Exception: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
+ # With the traceback: this catch spans a connection, a query and a coercion at boot,
+ # and `%s` of the exception does not say which of them raised.
+ log.exception("could not read the stored log level; staying at %s", current_level())
if not state.is_configured(db):
token = state.rotate_setup_token(db)
url = f"{settings.app_base}/setup?token={token}"
log.warning(
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
" Open the install wizard (internal access only):\n %s",
- url,
+ url, # log-pii-ok: this instance's OWN setup URL, and `?token=` is redacted
)
# No HLS session survives a restart (they live in this process), so anything still on disk is
# a crashed run's segments — sweep them or they accumulate forever. FIRE AND FORGET, not
diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py
index d3313323..8701b6ce 100644
--- a/backend/app/plex/client.py
+++ b/backend/app/plex/client.py
@@ -6,16 +6,15 @@ metadata, image proxying, and (P5) optional watch-state write-back. Requests JSO
``Accept: application/json`` header (Plex serves XML by default).
"""
-import logging
from urllib.parse import quote
import httpx
from sqlalchemy.orm import Session
-from app import sysconfig
+from app import logs, sysconfig
from app.config import settings
-log = logging.getLogger("siftlode.plex")
+log = logs.get_logger(__name__)
class PlexError(Exception):
@@ -106,7 +105,18 @@ class PlexClient:
Best-effort (needs the library's related data) — returns [] if Plex has none."""
try:
mc = self._get(f"/library/metadata/{rating_key}/related")
- except PlexError:
+ except PlexError as exc:
+ # An empty list is also the answer for an item that genuinely has no related titles,
+ # so the caller cannot tell the two apart — and it deliberately declines to CACHE an
+ # empty one for exactly that reason. This line is what says which it was.
+ log.debug(
+ "the related items for a Plex item could not be read",
+ extra=logs.event(
+ "plex.client.related_unavailable",
+ rating_key=rating_key,
+ reason=type(exc).__name__,
+ ),
+ )
return []
out: list[dict] = []
for hub in mc.get("Hub", []) or []:
@@ -139,6 +149,8 @@ class PlexClient:
raise PlexError(str(e)) from e
try:
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
+ # silent-ok: the HTTP call already succeeded — this is a write endpoint, and Plex answers
+ # those with an empty or non-JSON body as a matter of course.
except ValueError:
return {}
diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py
index 8f03e6fa..7752b7d4 100644
--- a/backend/app/plex/detail.py
+++ b/backend/app/plex/detail.py
@@ -22,6 +22,8 @@ def rich_detail(meta: dict) -> dict:
if str(r.get("image") or "").startswith("imdb://"):
try:
imdb_rating = round(float(r.get("value")), 1)
+ # silent-ok: no IMDb score, which is the common and legitimate case — the UI has a
+ # separate audience score to fall back on and shows nothing if neither exists.
except (TypeError, ValueError):
pass
break
diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py
index a71256e1..76f20b56 100644
--- a/backend/app/plex/imgcache.py
+++ b/backend/app/plex/imgcache.py
@@ -11,10 +11,13 @@ The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see
from fastapi import Response
from sqlalchemy.orm import Session
+from app import logs
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR
from app.plex.lookups import by_rating_key
+log = logs.get_logger(__name__)
+
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
@@ -46,6 +49,8 @@ def read(key: str) -> tuple[bytes, str] | None:
try:
if p.exists() and p.stat().st_size > 0:
return p.read_bytes(), ct
+ # silent-ok: a miss. The caller fetches from Plex and writes the file again, and the write
+ # path below is the one that reports a cache that cannot be used.
except OSError:
pass
return None
@@ -58,8 +63,14 @@ def write(key: str, data: bytes, ct: str) -> None:
tmp = CACHE_DIR / f"{key}.{ext}.tmp"
tmp.write_bytes(data)
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
- except OSError:
- pass # cache is best-effort
+ except OSError as exc:
+ # Best-effort, and silently so: with an unwritable cache every poster on every grid goes
+ # to Plex live, for as long as nobody notices. Debug, because a grid is ~100 images and
+ # the point is the condition, not the count.
+ log.debug(
+ "the Plex image cache could not be written",
+ extra=logs.event("plex.imgcache.write_failed", reason=type(exc).__name__),
+ )
def response(data: bytes, ct: str) -> Response:
diff --git a/backend/app/plex/paths.py b/backend/app/plex/paths.py
index 3c93feaa..9f9eaaf6 100644
--- a/backend/app/plex/paths.py
+++ b/backend/app/plex/paths.py
@@ -12,7 +12,9 @@ from pathlib import Path
from sqlalchemy.orm import Session
-from app import sysconfig
+from app import logs, sysconfig
+
+log = logs.get_logger(__name__)
def parse_map(raw: str) -> list[tuple[str, str]]:
@@ -47,6 +49,13 @@ def local_media_path(db: Session, plex_path: str | None) -> Path | None:
return None
try:
p = Path(map_path(db, plex_path)).resolve()
- except OSError:
+ except OSError as exc:
+ # None is answered as a 404 "no local file", which is also the answer for a title the
+ # server does not hold — so a dead mount or a wrong path map reads as missing media for
+ # the whole library at once. The path itself stays out: it carries the title.
+ log.warning(
+ "a Plex media path could not be resolved locally",
+ extra=logs.event("plex.paths.resolve_failed", reason=type(exc).__name__),
+ )
return None
return p if p.is_file() else None
diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py
index aec74c3e..9975e1be 100644
--- a/backend/app/plex/stream.py
+++ b/backend/app/plex/stream.py
@@ -18,7 +18,6 @@ segments. The remux is CPU-light (video copy + a tiny audio transcode), so it ru
CPU-only prod host; only P3's full transcode is CPU-heavy.
"""
-import logging
import os
import re
import secrets
@@ -31,15 +30,14 @@ from pathlib import Path
from sqlalchemy.orm import Session as DbSession
-from app import sysconfig
+from app import logs, sysconfig
from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS
-log = logging.getLogger("siftlode.plex")
-
+log = logs.get_logger(__name__)
_HLS_ROOT = (
Path(settings.plex_hls_dir)
if settings.plex_hls_dir
@@ -135,6 +133,8 @@ class HlsSession:
# single index. Derived, not passed, so it cannot disagree with the argv.
self.entry = _MASTER_PL if self.variants else _INDEX_PL
self.last_access = time.time()
+ # `artifact_may_arrive` reports a refusing session directory once, not per segment.
+ self.probe_failure_reported = False
def _seek_opts(start_s: float) -> list[str]:
@@ -232,7 +232,15 @@ def _probe_video_kind(src: Path) -> str:
text=True,
timeout=10,
)
- except (subprocess.SubprocessError, OSError):
+ except (subprocess.SubprocessError, OSError) as exc:
+ # The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind
+ # ffprobe sends every HDR file down it, so playback quietly degrades to a software
+ # transcode with nothing anywhere saying why. The path stays out of the line: it is the
+ # title of whatever is playing.
+ log.info(
+ "video-kind probe failed; assuming the HW-safe pipeline",
+ extra=logs.event("plex.stream.probe_kind_failed", reason=type(exc).__name__),
+ )
return _VK_HWSAFE
pix_fmt = bits = transfer = ""
for line in out.stdout.splitlines():
@@ -261,7 +269,14 @@ def _opencl_present() -> bool:
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
try:
return any(_OPENCL_ICD_DIR.glob("*.icd"))
- except OSError:
+ except OSError as exc:
+ # A MISSING directory does not come through here — `glob` simply yields nothing for one.
+ # This is a directory we cannot read, and the answer it forces (no OpenCL) costs every HDR
+ # source the hardware tonemap path.
+ log.info(
+ "the OpenCL ICD directory could not be read; treating OpenCL as absent",
+ extra=logs.event("plex.stream.opencl_probe_failed", reason=type(exc).__name__),
+ )
return False
@@ -447,6 +462,8 @@ def _sw_child_preexec() -> None:
playback."""
try:
os.nice(_SW_TRANSCODE_NICE)
+ # silent-ok: this runs in the forked child, between fork and exec. Logging there can deadlock
+ # on a lock some other thread held at fork time, and the child is about to become ffmpeg.
except OSError:
pass
@@ -473,7 +490,14 @@ def _probe_audio_langs(src: Path) -> list[str]:
timeout=10,
)
return [ln.strip() for ln in out.stdout.splitlines()]
- except (subprocess.SubprocessError, OSError):
+ except (subprocess.SubprocessError, OSError) as exc:
+ # An empty list IS the single-audio path, so a failed probe silently collapses a file with
+ # several audio tracks down to one — and the viewer gets a player with no language picker
+ # rather than an error.
+ log.warning(
+ "audio-track probe failed; falling back to a single-audio session",
+ extra=logs.event("plex.stream.probe_audio_failed", reason=type(exc).__name__),
+ )
return []
@@ -582,8 +606,14 @@ def _probe_first_pts(seg: Path) -> float | None:
tok = line.strip().strip(",")
if tok:
return float(tok)
- except (subprocess.SubprocessError, ValueError, OSError):
- pass
+ except (subprocess.SubprocessError, ValueError, OSError) as exc:
+ # None means "keep the offset the client asked for", and the whole point of this probe is
+ # that the two differ: without it the subtitle track is shifted by whatever the container
+ # started its timestamps at. Seconds of drift, and nothing on screen calls it a failure.
+ log.warning(
+ "first-PTS probe failed; the requested offset is used as-is",
+ extra=logs.event("plex.stream.first_pts_probe_failed", reason=type(exc).__name__),
+ )
return None
@@ -592,10 +622,18 @@ def _kill(s: HlsSession) -> None:
s.proc.terminate()
try:
s.proc.wait(timeout=3)
+ # silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
+ # seconds gets KILL, which is exactly what this branch does.
except subprocess.TimeoutExpired:
s.proc.kill()
- except Exception:
- pass
+ except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
+ # Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
+ # handle and a core, and the transcode cap that is supposed to bound them counts rows in
+ # `_sessions` rather than processes. One line is the only way that ever surfaces.
+ log.info(
+ "could not stop the transcode process",
+ extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
+ )
shutil.rmtree(s.dir, ignore_errors=True)
@@ -608,6 +646,13 @@ def _enforce_cap(cap: int) -> None:
if len(tx) <= cap:
return
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
+ # Somebody's playback stops here. The route turns the missing session into a retryable
+ # 404 and the player restarts at the playhead, so from the server nothing failed — and
+ # "my film keeps rebuffering" has no other explanation anywhere.
+ log.info(
+ "a transcode session was dropped to stay under the cap",
+ extra=logs.event("plex.stream.session_culled", key=key, cap=cap, sessions=len(tx)),
+ )
_kill(s)
_sessions.pop(key, None)
@@ -710,6 +755,21 @@ def start_session(
tonemap=tonemap,
)
_sessions[key] = s
+ # The one line that says which pipeline a session actually got. Everything downstream
+ # — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
+ # now it was only inferable from the ffmpeg command nobody keeps.
+ log.info(
+ "an HLS session started",
+ extra=logs.event(
+ "plex.stream.session_started",
+ key=key,
+ transcode=transcode,
+ hw=hw,
+ tonemap=tonemap,
+ variants=len(variants or ()),
+ start_s=round(start_s, 1),
+ ),
+ )
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
@@ -728,7 +788,8 @@ def start_session(
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
log.warning(
- "plex transcode: HW session produced no segments — falling back to software key=%s", key
+ "the hardware transcode produced no segments; falling back to software",
+ extra=logs.event("plex.stream.hw_fallback", key=key),
)
respawned = _spawn(False, False, replace_only=s)
if respawned is None:
@@ -867,7 +928,23 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
return True
try:
return (s.dir / segment_name(seq - 1, variant)).exists()
- except OSError:
+ except OSError as exc:
+ # `exists()` answers False for a missing file without raising, so this is the session
+ # directory itself refusing — a full or unmounted disk. Every segment then 404s, which the
+ # player shows as a stall. Once per session: this runs for every segment request and
+ # every hls.js retry of one, and the sink's limiter still stores 30 a minute of a
+ # template — stdout would get them all.
+ if not s.probe_failure_reported:
+ s.probe_failure_reported = True
+ log.warning(
+ "a session segment could not be checked",
+ extra=logs.event(
+ "plex.stream.segment_probe_failed",
+ key=s.key,
+ seq=seq,
+ reason=type(exc).__name__,
+ ),
+ )
return False
@@ -884,12 +961,16 @@ def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None =
try:
if path.exists() and path.stat().st_size > 0:
return True
+ # silent-ok: this is a 150 ms poll — a line here is a line every 150 ms for the whole
+ # timeout. The final check below decides, and the 404 the caller then returns is honest.
except OSError:
pass
if proc is not None and proc.poll() is not None:
# the producer exited — no more segments are coming; one last check then give up.
try:
return path.exists() and path.stat().st_size > 0
+ # silent-ok: one last look after the producer exited. False here becomes a 404 the
+ # player recovers from, which is the honest answer either way.
except OSError:
return False
time.sleep(0.15)
@@ -922,7 +1003,16 @@ def sweep_orphans(older_than: float | None = None) -> int:
dropped = 0
try:
entries = list(_HLS_ROOT.iterdir())
- except OSError: # the root doesn't exist yet — nothing to sweep
+ # silent-ok: nothing has been transcoded yet, so there is nothing to sweep.
+ except FileNotFoundError:
+ return 0
+ except OSError as exc:
+ # Any OTHER reason the root will not list is not "nothing to sweep": the sweep reports 0
+ # like a clean run while every crashed session's segments stay on disk forever.
+ log.warning(
+ "the HLS root could not be listed; no orphans were swept",
+ extra=logs.event("plex.stream.sweep_root_unreadable", reason=type(exc).__name__),
+ )
return 0
for p in entries:
if not _SESSION_DIR_RE.match(p.name):
@@ -932,7 +1022,9 @@ def sweep_orphans(older_than: float | None = None) -> int:
try:
if p.stat().st_mtime >= cutoff:
continue # this run made (or reused) it — never ours to delete
- except OSError: # vanished under us (a concurrent restart of that session)
+ # silent-ok: vanished under us — a concurrent restart of that session removed it, which
+ # is the outcome this sweep wanted anyway.
+ except OSError:
continue
with _lock:
if any(s.dir == p for s in _sessions.values()):
diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py
index 132f476e..c09e97ec 100644
--- a/backend/app/plex/subtitles.py
+++ b/backend/app/plex/subtitles.py
@@ -33,17 +33,18 @@ zero-based clock and shifted back to absolute time here with `shift_vtt`.
"""
import json
-import logging
import re
import subprocess
import tempfile
import threading
import time
+import logging
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from sqlalchemy.orm import Session
+from app import logs
from app.db import SessionLocal
from app.models import PlexItem
from app.plex import paths as plex_paths
@@ -51,8 +52,7 @@ from app.plex.cache import CACHE_DIR
from app.plex.client import PlexClient, PlexError
from app.plex.lookups import by_rating_key
-log = logging.getLogger("siftlode.plex")
-
+log = logs.get_logger(__name__)
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
BITMAP_SUBS = {
"pgs",
@@ -104,6 +104,7 @@ def shift_vtt(vtt: str, offset: float) -> str:
straddling 0 is clamped to start at 0."""
if not offset:
return vtt
+ unshifted = 0
out_blocks: list[str] = []
for block in re.split(r"\n[ \t]*\n", vtt):
if "-->" not in block:
@@ -121,7 +122,12 @@ def shift_vtt(vtt: str, offset: float) -> str:
st = _vtt_ts_to_s(left.strip()) - offset
en = _vtt_ts_to_s(rparts[0]) - offset
except (ValueError, IndexError):
- break # unparseable timing → leave the block as-is
+ # silent-ok: left as-is means left UNSHIFTED — the viewer sees that one cue at
+ # the wrong moment. Counted rather than logged here: this is per cue, and the file
+ # being parsed is one ffmpeg wrote for us a moment ago, so a broken timestamp is
+ # a fact about the extraction, reported once below.
+ unshifted += 1
+ break
if en <= 0:
keep = False # whole cue is before the session start → drop the block
else:
@@ -131,6 +137,11 @@ def shift_vtt(vtt: str, offset: float) -> str:
break
if keep:
out_blocks.append("\n".join(lines))
+ if unshifted:
+ log.info(
+ "some cues could not be shifted and stay at their original time",
+ extra=logs.event("plex.subtitles.cues_unshifted", cues=unshifted, offset_s=offset),
+ )
return "\n\n".join(out_blocks)
@@ -162,7 +173,15 @@ def cache_read(key: str) -> str | None:
try:
p = CACHE_DIR / f"{key}.vtt"
return p.read_text(encoding="utf-8") if p.exists() else None
- except OSError:
+ except OSError as exc:
+ # None is the same answer as "never cached", so a cache we cannot read looks exactly like
+ # a cold one — and every request re-extracts instead of saying the disk is the problem.
+ log.warning(
+ "the cached subtitle could not be read",
+ extra=logs.event(
+ "plex.subtitles.cache_read_failed", key=key, reason=type(exc).__name__
+ ),
+ )
return None
@@ -175,7 +194,16 @@ def cache_write(key: str, vtt: str) -> bool:
tmp.write_text(vtt, encoding="utf-8")
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
return True
- except OSError:
+ except OSError as exc:
+ # `publish` turns False into "the region was not claimed" and moves on, so the track just
+ # never appears. Which errno it was — full disk, read-only mount, permissions — is the
+ # whole diagnosis, and it exists only here.
+ log.warning(
+ "the subtitle cache could not be written",
+ extra=logs.event(
+ "plex.subtitles.cache_write_failed", key=key, reason=type(exc).__name__
+ ),
+ )
return False
@@ -206,7 +234,17 @@ def read_meta(key: str) -> tuple[list[list[float]], bool]:
try:
raw = json.loads(_meta_path(key).read_text(encoding="utf-8"))
return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete"))
- except (OSError, ValueError, TypeError):
+ # silent-ok: no sidecar at all is the documented older-build case the fallback exists for.
+ except FileNotFoundError:
+ return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
+ except (OSError, ValueError, TypeError) as exc:
+ # A sidecar that EXISTS and will not parse is different, and the fallback is the dangerous
+ # answer to it: beside a cached VTT it reports the track as complete, so a partial track is
+ # never extended again and the viewer loses every cue past the window that was cached.
+ log.warning(
+ "the subtitle sidecar could not be read; the cached track is taken as complete",
+ extra=logs.event("plex.subtitles.meta_unreadable", key=key, reason=type(exc).__name__),
+ )
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
@@ -216,6 +254,8 @@ def _exists(p: Path) -> bool:
escaped as a 500 from the status route."""
try:
return p.exists()
+ # silent-ok: this function's whole job is to answer False for a path the OS refuses. The
+ # refusal IS the return value; a caller that needs to report one reports it itself.
except (OSError, ValueError):
return False
@@ -226,8 +266,18 @@ def _write_meta(key: str, ranges: list[list[float]], complete: bool) -> None:
tmp = CACHE_DIR / f"{key}.json.tmp"
tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8")
tmp.replace(_meta_path(key))
- except OSError:
- pass
+ except OSError as exc:
+ # Without the sidecar beside it, a cached window reads back as a COMPLETE track (see
+ # `read_meta`) — so one failed write here permanently costs the rest of that subtitle.
+ log.warning(
+ "the subtitle sidecar could not be written",
+ extra=logs.event(
+ "plex.subtitles.meta_write_failed",
+ key=key,
+ complete=complete,
+ reason=type(exc).__name__,
+ ),
+ )
def generation(key: str) -> int:
@@ -235,7 +285,9 @@ def generation(key: str) -> int:
window is superseded by a wider one the browser refetches instead of keeping the short VTT."""
try:
return _vtt_path(key).stat().st_mtime_ns
- except (OSError, ValueError): # ValueError: a NUL in the key — `stat` refuses the path outright
+ # silent-ok: 0 for a track that is not cached yet, which is the ordinary case on every first
+ # request. (ValueError: a NUL in the key — `stat` refuses the path outright.)
+ except (OSError, ValueError):
return 0
@@ -267,16 +319,27 @@ def merge_vtt(*sources: str) -> str:
happen to share a time (two speakers, a sign over dialogue) are both kept."""
seen: set[str] = set()
cues: list[tuple[float, str]] = []
+ dropped = 0
for src in sources:
for block in re.split(r"\n[ \t]*\n", src or ""):
- canon = _canonical_cue(block.strip())
+ stripped = block.strip()
+ canon = _canonical_cue(stripped)
if canon is None:
+ # A block WITH a timing line that still would not parse is a cue we are throwing
+ # away, not a header — and the merged track would simply be missing those lines.
+ if "-->" in stripped:
+ dropped += 1
continue # header / NOTE / STYLE — the merged file gets its own single header
start, key, text = canon
if key in seen:
continue
seen.add(key)
cues.append((start, text))
+ if dropped:
+ log.info(
+ "some cues were dropped from the merged subtitle: their timing would not parse",
+ extra=logs.event("plex.subtitles.cues_dropped", cues=dropped, kept=len(cues)),
+ )
cues.sort(key=lambda c: c[0])
return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n"
@@ -295,6 +358,8 @@ def _canonical_cue(block: str) -> tuple[float, str, str] | None:
try:
st = _vtt_ts_to_s(left.strip())
en = _vtt_ts_to_s(rparts[0])
+ # silent-ok: None here means "not a cue", and the caller tells a header apart from a
+ # broken timing line by whether the block had a `-->` at all — see `merge_vtt`.
except (ValueError, IndexError):
return None
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}"
@@ -310,6 +375,8 @@ def _last_cue_end(vtt: str) -> float:
continue
try:
end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0]))
+ # silent-ok: this is a floor — "how far the pass provably got". Skipping a line it cannot
+ # read makes the answer smaller, never larger, so the error is always on the safe side.
except (ValueError, IndexError):
continue
return end
@@ -652,12 +719,35 @@ def _run(
return
_extract_and_publish(rating_key, sub_ord, window_start)
except SubtitleUnavailable as e:
+ # The player's own state was the only place this ever showed. `_pass_failed` backs the item
+ # off for an hour and `_fail` records a code, but neither leaves a line, so "my subtitles
+ # never appear" had nothing on the server to read.
+ # A final code is a fact about the file (no such track, a bitmap subtitle); anything else
+ # is this instance failing at something it could do.
+ log.log(
+ logging.INFO if e.code in FINAL_CODES else logging.WARNING,
+ "subtitle extraction gave up",
+ extra=logs.event(
+ "plex.subtitles.pass_failed",
+ rating_key=rating_key,
+ sub_ord=sub_ord,
+ code=e.code,
+ windowed=window_start is not None,
+ ),
+ )
if e.code == "extract_failed":
_pass_failed(rating_key, [key], window_start)
else:
_fail(key, e.code)
except PlexError as e:
- log.warning("subtitle extraction failed for %s/%s: %s", rating_key, sub_ord, e)
+ # No traceback on purpose: a PlexError carries httpx's own message, which names the method,
+ # the URL and the status — the stack under it is this `except` and the client call, and
+ # says nothing the message does not. (The URL holds no token; it travels as a header.)
+ log.warning(
+ "subtitle extraction failed talking to Plex: %s",
+ e,
+ extra=logs.event("plex.subtitles.plex_error", rating_key=rating_key, sub_ord=sub_ord),
+ )
_fail(key, "plex_error")
except (
Exception
@@ -829,6 +919,16 @@ def _ffmpeg_extract(
try:
_, err = proc.communicate(timeout=_TERM_GRACE_S)
except subprocess.TimeoutExpired:
+ # TERM is what makes a timed-out pass worth keeping: ffmpeg closes its outputs
+ # on it. An ffmpeg that ignores TERM leaves only whole blocks already flushed,
+ # so the partial track is short for a reason the later line cannot tell apart.
+ log.info(
+ "the subtitle extraction ignored TERM and was killed",
+ extra=logs.event(
+ "plex.subtitles.extract_killed_after_term",
+ windowed=start is not None,
+ ),
+ )
proc.kill()
_, err = proc.communicate()
finally:
diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py
index a581a4b0..271a2d9f 100644
--- a/backend/app/plex/sync.py
+++ b/backend/app/plex/sync.py
@@ -15,20 +15,18 @@ dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are
item-detail/player path (P2), not here.
"""
-import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
-from app import sysconfig
+from app import logs, sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex import detail, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
-log = logging.getLogger("siftlode.plex")
-
+log = logs.get_logger(__name__)
# Codecs/containers a browser can play in a native