build(backend): close the lint gaps the E32 audit measured
ruff gains RUF100, PLW1510 and flake8-bugbear, every hit fixed rather than ignored. B008 stays on with FastAPI's Depends/Query declared immutable. basedpyright moves to standard mode, pytest to --strict-markers, and a hand run of pytest now requires the database unless SIFTLODE_TEST_REQUIRE_DB=0. PLW1510 found a real bug: the three ffprobe probes in plex/stream.py never looked at the exit status, so a probe that failed was read as the file's truth (an HDR source sent down the HW-safe path, a multi-audio file collapsed to one track, a subtitle offset taken from garbage) and the warning each except branch promises never fired. They now run with check=True.
This commit is contained in:
@@ -1650,24 +1650,6 @@
|
|||||||
"endColumn": 36,
|
"endColumn": 36,
|
||||||
"lineCount": 1
|
"lineCount": 1
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "reportArgumentType",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 46,
|
|
||||||
"endColumn": 52,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"./app/youtube/rss.py": [
|
|
||||||
{
|
|
||||||
"code": "reportGeneralTypeIssues",
|
|
||||||
"range": {
|
|
||||||
"startColumn": 10,
|
|
||||||
"endColumn": 15,
|
|
||||||
"lineCount": 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class BodyLimitMiddleware:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await self.app(scope, counting_receive, guarded_send)
|
await self.app(scope, counting_receive, guarded_send)
|
||||||
except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below
|
except Exception: # only swallowed when WE caused it, see below
|
||||||
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
|
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
|
||||||
# consequence of the disconnect we just faked, and the caller must get the 413 rather
|
# consequence of the disconnect we just faked, and the caller must get the 413 rather
|
||||||
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
|
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label
|
|||||||
try:
|
try:
|
||||||
proc.wait(timeout=grace_s)
|
proc.wait(timeout=grace_s)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
# noqa TRY400: the exception IS the message here. A TimeoutExpired's traceback is this
|
# 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.
|
# `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
|
log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400
|
||||||
|
|
||||||
@@ -456,7 +456,7 @@ def run_ffmpeg(
|
|||||||
try:
|
try:
|
||||||
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
|
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
|
||||||
except subprocess.TimeoutExpired: # pipes closed but the process lingers
|
except subprocess.TimeoutExpired: # pipes closed but the process lingers
|
||||||
raise StatedFailure("ffmpeg did not exit after closing its output")
|
raise StatedFailure("ffmpeg did not exit after closing its output") from None
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this
|
# 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`
|
# function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ def main() -> None:
|
|||||||
logs.configure_logging("runner")
|
logs.configure_logging("runner")
|
||||||
_deprioritize()
|
_deprioritize()
|
||||||
if len(sys.argv) != 2:
|
if len(sys.argv) != 2:
|
||||||
# noqa T201: a usage line is for the person who typed the command. Logging is already
|
# 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
|
# 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.
|
# line wants the bare sentence, not a record with a request id they do not have.
|
||||||
print( # noqa: T201
|
print( # noqa: T201
|
||||||
@@ -104,7 +104,7 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
|
|||||||
else:
|
else:
|
||||||
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
|
||||||
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
|
||||||
# noqa TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
|
# TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
|
||||||
log.error( # noqa: TRY400
|
log.error( # noqa: TRY400
|
||||||
"the download child for job %s failed",
|
"the download child for job %s failed",
|
||||||
job_id,
|
job_id,
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
|||||||
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
||||||
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
||||||
exclude = None if tag_modes.get(category, "or") == "and" else category
|
exclude = None if tag_modes.get(category, "or") == "and" else category
|
||||||
for cid, ch_tags in channel_tags.items():
|
for ch_tags in channel_tags.values():
|
||||||
if not passes(ch_tags, exclude):
|
if not passes(ch_tags, exclude):
|
||||||
continue
|
continue
|
||||||
for tid in ch_tags:
|
for tid in ch_tags:
|
||||||
@@ -671,8 +671,8 @@ def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime |
|
|||||||
raw = payload["k"]
|
raw = payload["k"]
|
||||||
if len(raw) != len(keys):
|
if len(raw) != len(keys):
|
||||||
raise ValueError("key arity mismatch")
|
raise ValueError("key arity mismatch")
|
||||||
vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)]
|
vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw, strict=True)]
|
||||||
for k, v in zip(keys, vals):
|
for k, v in zip(keys, vals, strict=True):
|
||||||
if v is None and not k["nullable"]:
|
if v is None and not k["nullable"]:
|
||||||
raise ValueError("null value for a non-nullable key")
|
raise ValueError("null value for a non-nullable key")
|
||||||
window = payload.get("w")
|
window = payload.get("w")
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ def deliver(
|
|||||||
# already open on the tracker and this is what would have recorded its link. We
|
# already open on the tracker and this is what would have recorded its link. We
|
||||||
# still must not write — another send owns the row and is opening its own issue —
|
# still must not write — another send owns the row and is opening its own issue —
|
||||||
# so say which issue was left behind, because nothing else will.
|
# so say which issue was left behind, because nothing else will.
|
||||||
# noqa TRY400: the traceback is one SendClaimLost carrying the session id and
|
# TRY400: the traceback is one SendClaimLost carrying the session id and
|
||||||
# nothing else; the issue number below is the whole news.
|
# nothing else; the issue number below is the whole news.
|
||||||
log.error( # noqa: TRY400
|
log.error( # noqa: TRY400
|
||||||
"an issue was created by a send that had already lost its claim",
|
"an issue was created by a send that had already lost its claim",
|
||||||
|
|||||||
+1
-1
@@ -166,7 +166,7 @@ def bind(**fields: Any):
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
for key, token in zip(keys, tokens):
|
for key, token in zip(keys, tokens, strict=True):
|
||||||
_CONTEXT_VARS[key].reset(token)
|
_CONTEXT_VARS[key].reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI):
|
|||||||
# booting — an unreachable or not-yet-migrated database has to start into the env level.
|
# booting — an unreachable or not-yet-migrated database has to start into the env level.
|
||||||
try:
|
try:
|
||||||
set_level(sysconfig.get_str(db, "log_level"))
|
set_level(sysconfig.get_str(db, "log_level"))
|
||||||
except Exception: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
|
except Exception: # any DB/config failure keeps the bootstrap level
|
||||||
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
# With the traceback: this catch spans a connection, a query and a coercion at boot,
|
||||||
# and `%s` of the exception does not say which of them raised.
|
# and `%s` of the exception does not say which of them raised.
|
||||||
log.exception("could not read the stored log level; staying at %s", current_level())
|
log.exception("could not read the stored log level; staying at %s", current_level())
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ def _probe_video_kind(src: Path) -> str:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
# A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
|
||||||
|
# below), not an answer: whatever it printed before refusing is not the file's truth.
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
except (subprocess.SubprocessError, OSError) as exc:
|
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
|
# The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind
|
||||||
@@ -502,6 +505,9 @@ def _probe_audio_langs(src: Path) -> list[str]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
# A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
|
||||||
|
# below), not an answer: whatever it printed before refusing is not the file's truth.
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
return [ln.strip() for ln in out.stdout.splitlines()]
|
return [ln.strip() for ln in out.stdout.splitlines()]
|
||||||
except (subprocess.SubprocessError, OSError) as exc:
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
@@ -615,6 +621,9 @@ def _probe_first_pts(seg: Path) -> float | None:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
# A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
|
||||||
|
# below), not an answer: whatever it printed before refusing is not the file's truth.
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
for line in out.stdout.splitlines():
|
for line in out.stdout.splitlines():
|
||||||
tok = line.strip().strip(",")
|
tok = line.strip().strip(",")
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
|||||||
"webvtt",
|
"webvtt",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
]
|
]
|
||||||
r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S)
|
# check=False: the exit status is read on the next line, with ffmpeg's own stderr as the reason.
|
||||||
|
r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S, check=False)
|
||||||
if r.returncode != 0 or not r.stdout:
|
if r.returncode != 0 or not r.stdout:
|
||||||
raise PlexError(
|
raise PlexError(
|
||||||
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
|
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ def sync(db: Session) -> dict:
|
|||||||
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
|
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
|
||||||
try:
|
try:
|
||||||
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
||||||
except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
|
except Exception: # best-effort; never fail the sync over a backfill
|
||||||
# With the traceback: a blind catch over a batch that fetches AND writes can be
|
# With the traceback: a blind catch over a batch that fetches AND writes can be
|
||||||
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
|
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
|
||||||
log.exception("the Plex people-enrich batch failed")
|
log.exception("the Plex people-enrich batch failed")
|
||||||
@@ -282,7 +282,7 @@ def sync(db: Session) -> dict:
|
|||||||
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
||||||
try:
|
try:
|
||||||
stats["media_probed"] = _probe_media_batch(db, plex)
|
stats["media_probed"] = _probe_media_batch(db, plex)
|
||||||
except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
|
except Exception: # best-effort; never fail the sync over a backfill
|
||||||
log.exception("the Plex media-probe batch failed")
|
log.exception("the Plex media-probe batch failed")
|
||||||
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which
|
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which
|
||||||
# logs the job result, and to the admin route, which answers it to the caller.
|
# logs the job result, and to the admin route, which answers it to the caller.
|
||||||
@@ -296,7 +296,7 @@ def sync(db: Session) -> dict:
|
|||||||
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
|
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
|
||||||
try:
|
try:
|
||||||
stats["people_pruned"] = people.prune_orphan_people(db)
|
stats["people_pruned"] = people.prune_orphan_people(db)
|
||||||
except Exception: # noqa: BLE001 — best-effort housekeeping; never fail the sync over it
|
except Exception: # best-effort housekeeping; never fail the sync over it
|
||||||
log.exception("the Plex people prune failed")
|
log.exception("the Plex people prune failed")
|
||||||
log.info("Plex sync done: %s", stats)
|
log.info("Plex sync done: %s", stats)
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ its own thread/context) never cross wires. When no sink is bound (e.g. a manual
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import contextvars
|
import contextvars
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Literal, Optional
|
||||||
|
|
||||||
Sink = Callable[[int, Optional[int], Optional[str]], None]
|
Sink = Callable[[int, Optional[int], Optional[str]], None]
|
||||||
|
|
||||||
@@ -48,7 +48,9 @@ class bind:
|
|||||||
self._token = _sink.set(self._sink)
|
self._token = _sink.set(self._sink)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *exc) -> bool:
|
# Literal[False], not bool: this never swallows the job's exception, and saying so is what lets
|
||||||
|
# a type checker know a name assigned inside `with bind(...)` is bound after it.
|
||||||
|
def __exit__(self, *exc) -> Literal[False]:
|
||||||
if self._token is not None:
|
if self._token is not None:
|
||||||
_sink.reset(self._token)
|
_sink.reset(self._token)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ def explore_channel(
|
|||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
result = explore_ingest_page(db, yt, channel, page_token)
|
result = explore_ingest_page(db, yt, channel, page_token)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
@@ -597,7 +597,7 @@ def subscribe(
|
|||||||
if "subscriptionDuplicate" in exc.reason.split(","):
|
if "subscriptionDuplicate" in exc.reason.split(","):
|
||||||
yt_sub_id = ""
|
yt_sub_id = ""
|
||||||
else:
|
else:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
|
||||||
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
|
||||||
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
# render. Keep the client open (we're still inside the `with`); a metadata failure here
|
||||||
@@ -658,7 +658,7 @@ def unsubscribe(
|
|||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||||
# a transient "connection lost" gateway blip.
|
# a transient "connection lost" gateway blip.
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
db.delete(sub)
|
db.delete(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "unsubscribed": channel_id}
|
return {"ok": True, "unsubscribed": channel_id}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ def add_pin(
|
|||||||
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
||||||
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
|
||||||
# returns above — re-raising handed the user a 500 they could do nothing with.
|
# returns above — re-raising handed the user a 500 they could do nothing with.
|
||||||
raise not_found("item")
|
raise not_found("item") from None
|
||||||
return {"ok": True, "id": winner.id, "created": False}
|
return {"ok": True, "id": winner.id, "created": False}
|
||||||
return {"ok": True, "id": pin.id, "created": True}
|
return {"ok": True, "id": pin.id, "created": True}
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ def set_credential(
|
|||||||
try:
|
try:
|
||||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
except dlcreds.InvalidCookies as e:
|
except dlcreds.InvalidCookies as e:
|
||||||
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)})
|
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e
|
||||||
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -407,7 +407,7 @@ def enqueue_download(
|
|||||||
try:
|
try:
|
||||||
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
@@ -520,7 +520,7 @@ def upload_download(
|
|||||||
display_name=upload.display_name,
|
display_name=upload.display_name,
|
||||||
)
|
)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
@@ -547,9 +547,9 @@ def enqueue_edit(
|
|||||||
try:
|
try:
|
||||||
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=_quota_detail(e))
|
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
|
||||||
except service.EditError as e:
|
except service.EditError as e:
|
||||||
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason})
|
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ def get_feed(
|
|||||||
# Expose each key column on the row so we can build the next cursor from the last item.
|
# Expose each key column on the row so we can build the next cursor from the last item.
|
||||||
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
||||||
if vals is not None:
|
if vals is not None:
|
||||||
for k, v in zip(keys, vals):
|
for k, v in zip(keys, vals, strict=True):
|
||||||
k["value"] = v
|
k["value"] = v
|
||||||
if ranked and window is not None:
|
if ranked and window is not None:
|
||||||
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
|
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
|
||||||
@@ -431,7 +431,7 @@ def get_video_detail(
|
|||||||
):
|
):
|
||||||
items = yt.get_videos([video_id])
|
items = yt.get_videos([video_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
if not items:
|
if not items:
|
||||||
raise not_found("video")
|
raise not_found("video")
|
||||||
|
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ def sync_youtube(
|
|||||||
return {"ok": True, **pull_user_playlists(db, user)}
|
return {"ok": True, **pull_user_playlists(db, user)}
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/revert-youtube")
|
@router.post("/{playlist_id}/revert-youtube")
|
||||||
@@ -369,10 +369,10 @@ def revert_youtube(
|
|||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
return {"ok": True, **repull_playlist(db, user, pl)}
|
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||||
except PlaylistGone:
|
except PlaylistGone:
|
||||||
raise not_found("playlist")
|
raise not_found("playlist") from None
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
@@ -398,7 +398,7 @@ def push_plan(
|
|||||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_merge(db, user, pl)
|
plan = plan_merge(db, user, pl)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
plan["remaining_today"] = quota.remaining_today(db)
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||||
return plan
|
return plan
|
||||||
@@ -436,10 +436,10 @@ def push(
|
|||||||
raise _refused(429, "quota_exceeded")
|
raise _refused(429, "quota_exceeded")
|
||||||
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||||
except PlaylistGone:
|
except PlaylistGone:
|
||||||
raise not_found("playlist")
|
raise not_found("playlist") from None
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
@@ -539,7 +539,7 @@ def delete_playlist(
|
|||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
yt.delete_playlist(pl.yt_playlist_id)
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
# The playlist first, as every item write: the cascade deletes the items before the list row.
|
# The playlist first, as every item write: the cascade deletes the items before the list row.
|
||||||
lock_playlists(db, pl.id)
|
lock_playlists(db, pl.id)
|
||||||
db.delete(pl) # items cascade
|
db.delete(pl) # items cascade
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ def create_collection(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e)
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ def collection_add_item(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e)
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ def collection_remove_item(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e)
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@ def collection_rename(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise _write_or_pending_error(wrote, e)
|
raise _write_or_pending_error(wrote, e) from e
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,14 @@ def _recently_added(
|
|||||||
# share one), so the merge doesn't depend on object identity staying alive.
|
# share one), so the merge doesn't depend on object identity staying alive.
|
||||||
top_movies = [o for _, kind, o in top if kind == "movie"]
|
top_movies = [o for _, kind, o in top if kind == "movie"]
|
||||||
top_shows = [o for _, kind, o in top if kind == "show"]
|
top_shows = [o for _, kind, o in top if kind == "show"]
|
||||||
mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang))}
|
mcards = {
|
||||||
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang))}
|
m.rating_key: c
|
||||||
|
for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang), strict=True)
|
||||||
|
}
|
||||||
|
scards = {
|
||||||
|
s.rating_key: c
|
||||||
|
for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True)
|
||||||
|
}
|
||||||
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
|
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
|
||||||
|
|
||||||
|
|
||||||
@@ -351,10 +357,12 @@ def _because_you_watched(
|
|||||||
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
||||||
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
||||||
mcards = {
|
mcards = {
|
||||||
m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang))
|
m.rating_key: c
|
||||||
|
for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang), strict=True)
|
||||||
}
|
}
|
||||||
scards = {
|
scards = {
|
||||||
s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang))
|
s.rating_key: c
|
||||||
|
for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True)
|
||||||
}
|
}
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
for rk in rks:
|
for rk in rks:
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ def create_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "view_name_taken", name=name)
|
raise refused(409, "view_name_taken", name=name) from None
|
||||||
return _serialize(view)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ def update_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "view_name_taken", name=view.name)
|
raise refused(409, "view_name_taken", name=view.name) from None
|
||||||
return _serialize(view)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def run_job_now(
|
|||||||
try:
|
try:
|
||||||
result = trigger_job(job_id, actor_id=user.id)
|
result = trigger_job(job_id, actor_id=user.id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise not_found("job")
|
raise not_found("job") from None
|
||||||
if result == "already_running":
|
if result == "already_running":
|
||||||
raise refused(409, "job_running")
|
raise refused(409, "job_running")
|
||||||
return {"ok": True, "id": job_id, "started": True}
|
return {"ok": True, "id": job_id, "started": True}
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ def search_youtube(
|
|||||||
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
|
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
|
||||||
try:
|
try:
|
||||||
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
||||||
except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError
|
except Exception as exc: # any ingest failure, not just YouTubeError
|
||||||
db.rollback()
|
db.rollback()
|
||||||
# The term is NOT in this line. A search string is exactly what the privacy
|
# The term is NOT in this line. A search string is exactly what the privacy
|
||||||
# policy keeps out of the log unless the user turned Teljes/TRACE on for
|
# policy keeps out of the log unless the user turned Teljes/TRACE on for
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
|||||||
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
|
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
|
||||||
# blocking "Server error" dialog, with nothing the user could act on (E9 F1).
|
# blocking "Server error" dialog, with nothing the user could act on (E9 F1).
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc) from exc
|
||||||
result["quota_used_estimate"] = m.used
|
result["quota_used_estimate"] = m.used
|
||||||
result["quota_remaining_today"] = m.remaining
|
result["quota_remaining_today"] = m.remaining
|
||||||
return {"ok": True, **result}
|
return {"ok": True, **result}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def create_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "tag_name_taken", name=name)
|
raise refused(409, "tag_name_taken", name=name) from None
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"id": tag.id,
|
"id": tag.id,
|
||||||
@@ -113,7 +113,7 @@ def update_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "tag_name_taken", name=tag.name)
|
raise refused(409, "tag_name_taken", name=tag.name) from None
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"id": tag.id,
|
"id": tag.id,
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]:
|
|||||||
channel.feed_fallback_at = datetime.now(timezone.utc)
|
channel.feed_fallback_at = datetime.now(timezone.utc)
|
||||||
db.add(channel)
|
db.add(channel)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception: # noqa: BLE001 — one channel must not stop the rest
|
except Exception: # one channel must not stop the rest
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception(
|
log.exception(
|
||||||
"Feed fallback could not store channel %s",
|
"Feed fallback could not store channel %s",
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ _SHORT_LOWER = {
|
|||||||
"for",
|
"for",
|
||||||
"or",
|
"or",
|
||||||
"vs",
|
"vs",
|
||||||
"the",
|
|
||||||
"és",
|
"és",
|
||||||
"az",
|
"az",
|
||||||
"egy",
|
"egy",
|
||||||
@@ -120,7 +119,6 @@ _ACRONYMS = {
|
|||||||
"nfl",
|
"nfl",
|
||||||
"asmr",
|
"asmr",
|
||||||
"pov",
|
"pov",
|
||||||
"diy",
|
|
||||||
"wtf",
|
"wtf",
|
||||||
"lol",
|
"lol",
|
||||||
"rtx",
|
"rtx",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ def _wait_for_schema(timeout: float = 300.0) -> bool:
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
db.execute(text("SELECT 1 FROM download_jobs LIMIT 1"))
|
db.execute(text("SELECT 1 FROM download_jobs LIMIT 1"))
|
||||||
return True
|
return True
|
||||||
except Exception: # noqa: BLE001 — table not migrated yet / DB not up yet
|
except Exception: # table not migrated yet / DB not up yet
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
# The traceback, not `str(exc)[:120]`: this is the last thing the worker says
|
# The traceback, not `str(exc)[:120]`: this is the last thing the worker says
|
||||||
# before it exits, and which driver call failed is the whole question.
|
# before it exits, and which driver call failed is the whole question.
|
||||||
@@ -366,7 +367,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
proc = _spawn_download_child(job_id)
|
proc = _spawn_download_child(job_id)
|
||||||
except Exception: # noqa: BLE001 — spawn failed; don't leave the asset stuck 'downloading'
|
except Exception: # spawn failed; don't leave the asset stuck 'downloading'
|
||||||
log.exception("failed to spawn download child for job %s", job_id)
|
log.exception("failed to spawn download child for job %s", job_id)
|
||||||
_reset_asset_pending(asset_id)
|
_reset_asset_pending(asset_id)
|
||||||
_requeue_if_running(job_id, None)
|
_requeue_if_running(job_id, None)
|
||||||
@@ -438,7 +439,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
|||||||
cause="child_exit",
|
cause="child_exit",
|
||||||
exit_code=proc.returncode,
|
exit_code=proc.returncode,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 — boot recovery is the backstop for a stuck 'running' job
|
except Exception: # boot recovery is the backstop for a stuck 'running' job
|
||||||
log.exception("post-exit reconciliation failed for job %s", job_id)
|
log.exception("post-exit reconciliation failed for job %s", job_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -501,6 +502,7 @@ def _process_job(job_id: int) -> None:
|
|||||||
then run in a supervised CHILD PROCESS (`_run_supervised`) so a wedged yt-dlp/ffmpeg can be
|
then run in a supervised CHILD PROCESS (`_run_supervised`) so a wedged yt-dlp/ffmpeg can be
|
||||||
hard-killed — a worker THREAD cannot be killed, nor (when it spins in a C call) even
|
hard-killed — a worker THREAD cannot be killed, nor (when it spins in a C call) even
|
||||||
cooperatively cancelled, and one such thread starves the others via the GIL."""
|
cooperatively cancelled, and one such thread starves the others via the GIL."""
|
||||||
|
diagnosis = None # set exactly when the asset has already failed (asset_status == "error")
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
@@ -528,7 +530,7 @@ def _process_job(job_id: int) -> None:
|
|||||||
if asset_status == "ready":
|
if asset_status == "ready":
|
||||||
_finish_from_cache(job_id, asset_id)
|
_finish_from_cache(job_id, asset_id)
|
||||||
return
|
return
|
||||||
if asset_status == "error":
|
if diagnosis is not None:
|
||||||
_fail_running(
|
_fail_running(
|
||||||
job_id,
|
job_id,
|
||||||
error=asset_error or "Download failed",
|
error=asset_error or "Download failed",
|
||||||
@@ -577,7 +579,7 @@ def _worker_loop(idx: int) -> None:
|
|||||||
while not _stop.is_set():
|
while not _stop.is_set():
|
||||||
try:
|
try:
|
||||||
job_id = _claim_job()
|
job_id = _claim_job()
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
log.exception("claim failed")
|
log.exception("claim failed")
|
||||||
_stop.wait(settings.download_poll_seconds)
|
_stop.wait(settings.download_poll_seconds)
|
||||||
continue
|
continue
|
||||||
@@ -597,12 +599,12 @@ def _worker_loop(idx: int) -> None:
|
|||||||
job_id,
|
job_id,
|
||||||
exc,
|
exc,
|
||||||
"crash",
|
"crash",
|
||||||
lambda: _fail_running(job_id, error="Worker error", error_code=None),
|
partial(_fail_running, job_id, error="Worker error", error_code=None),
|
||||||
)
|
)
|
||||||
# silent-ok: not silent — `_report_worker_error` logged the crash before this
|
# silent-ok: not silent — `_report_worker_error` logged the crash before this
|
||||||
# re-raise. Failing the job hit the database, and letting that out would end this
|
# re-raise. Failing the job hit the database, and letting that out would end this
|
||||||
# worker thread for good; boot recovery settles a job left `running`.
|
# worker thread for good; boot recovery settles a job left `running`.
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
log.exception(
|
log.exception(
|
||||||
"could not fail job %s after its crash",
|
"could not fail job %s after its crash",
|
||||||
job_id,
|
job_id,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class YouTubeClient:
|
|||||||
return cached
|
return cached
|
||||||
try:
|
try:
|
||||||
refresh = decrypt(tok.refresh_token_enc)
|
refresh = decrypt(tok.refresh_token_enc)
|
||||||
except Exception as exc: # noqa: BLE001 — Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed)
|
except Exception as exc: # Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed)
|
||||||
# A key mismatch must degrade like any other token problem (caller handles YouTubeError),
|
# A key mismatch must degrade like any other token problem (caller handles YouTubeError),
|
||||||
# not surface as a raw 500 on every scheduler job / connect check. (B-core-2)
|
# not surface as a raw 500 on every scheduler job / connect check. (B-core-2)
|
||||||
raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc
|
raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class RssError(Exception):
|
|||||||
def _get_feed(channel_id: str) -> bytes:
|
def _get_feed(channel_id: str) -> bytes:
|
||||||
"""The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised."""
|
"""The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised."""
|
||||||
url = RSS_URL.format(channel_id=channel_id)
|
url = RSS_URL.format(channel_id=channel_id)
|
||||||
|
error: RssError | None = None
|
||||||
for attempt in range(len(RETRY_DELAYS) + 1):
|
for attempt in range(len(RETRY_DELAYS) + 1):
|
||||||
if attempt:
|
if attempt:
|
||||||
time.sleep(RETRY_DELAYS[attempt - 1])
|
time.sleep(RETRY_DELAYS[attempt - 1])
|
||||||
@@ -60,6 +61,8 @@ def _get_feed(channel_id: str) -> bytes:
|
|||||||
)
|
)
|
||||||
if resp.status_code not in _RETRYABLE_STATUS:
|
if resp.status_code not in _RETRYABLE_STATUS:
|
||||||
break
|
break
|
||||||
|
if error is None: # cannot happen: the range above is never empty, and every try records one
|
||||||
|
raise RssError(f"RSS fetch for {channel_id} made no attempt", "transport")
|
||||||
raise error
|
raise error
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
{
|
{
|
||||||
"include": ["app", "alembic"],
|
"include": ["app", "alembic"],
|
||||||
"extraPaths": ["."],
|
"extraPaths": ["."],
|
||||||
"typeCheckingMode": "basic",
|
"typeCheckingMode": "standard",
|
||||||
"pythonVersion": "3.12",
|
"pythonVersion": "3.12",
|
||||||
"pythonPlatform": "Linux"
|
"pythonPlatform": "Linux"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,9 @@
|
|||||||
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
|
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
|
||||||
# import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in
|
# import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in
|
||||||
# via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently
|
# via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently
|
||||||
# skipped lane is visible (the compose lane sets SIFTLODE_TEST_REQUIRE_DB=1 to make skips a hard error).
|
# skipped lane is visible (and conftest makes a missing DB/ffmpeg a hard error unless
|
||||||
|
# SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers` (E32): a misspelt `@pytest.mark.skipiff` is an error,
|
||||||
|
# not an unknown marker that quietly does nothing.
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
pythonpath = .
|
pythonpath = .
|
||||||
addopts = -q -rs
|
addopts = -q -rs --strict-markers
|
||||||
|
|||||||
+19
-1
@@ -29,5 +29,23 @@ target-version = "py312"
|
|||||||
# nobody to raise to, a best-effort backfill inside a sync) and always worth a sentence.
|
# nobody to raise to, a best-effort backfill inside a sync) and always worth a sentence.
|
||||||
# The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that
|
# The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that
|
||||||
# neither logs nor re-raises says `# silent-ok: <why>`. Ruff sees only the two narrow shapes.
|
# neither logs nor re-raises says `# silent-ok: <why>`. Ruff sees only the two narrow shapes.
|
||||||
extend-select = ["LOG", "G", "T20", "TRY400", "TRY401", "S110", "S112", "BLE001"]
|
#
|
||||||
|
# E32 (gate integrity) added three more, each measured before it went on and with every hit fixed:
|
||||||
|
# RUF100 — a `# noqa` that suppresses nothing. A stale one is a claim with no rule behind it, and
|
||||||
|
# it stays armed for the day the line changes (24 were removed). A comment that merely
|
||||||
|
# explains a noqa below it names the rule as `# TRY400: …`, never `# noqa TRY400`.
|
||||||
|
# PLW1510 — `subprocess.run` without an explicit `check=`. Found the real E32 bug: three ffprobe
|
||||||
|
# probes read the output of a probe that had FAILED and took it for the file's truth.
|
||||||
|
# B — flake8-bugbear: loop-variable capture, `raise` without `from` inside `except`, a
|
||||||
|
# `zip` that silently truncates (each one is `strict=True` now — the lengths are an
|
||||||
|
# invariant, so a mismatch should be loud). B008 (a call in a default argument) stays ON:
|
||||||
|
# FastAPI's `Depends(...)` / `Query(...)` ARE that shape by design (474 hits, all of
|
||||||
|
# them), so those two are declared immutable below instead of switching the rule off —
|
||||||
|
# any other call in a default still fails.
|
||||||
|
extend-select = [
|
||||||
|
"LOG", "G", "T20", "TRY400", "TRY401", "S110", "S112", "BLE001", "RUF100", "PLW1510", "B",
|
||||||
|
]
|
||||||
ignore = ["E402"]
|
ignore = ["E402"]
|
||||||
|
|
||||||
|
[lint.flake8-bugbear]
|
||||||
|
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy import create_engine, event, text
|
from sqlalchemy import create_engine, event, text
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
import app.models # noqa: F401 — register every model on Base.metadata
|
import app.models # register every model on Base.metadata
|
||||||
import app.state as app_state
|
import app.state as app_state
|
||||||
from app import security
|
from app import security
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
@@ -37,9 +37,11 @@ from app.models import User
|
|||||||
# Sentinel for "this dependency had no override before the fixture set one" (see override_dep).
|
# Sentinel for "this dependency had no override before the fixture set one" (see override_dep).
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
|
|
||||||
# The compose test lane sets this so a would-be silent skip (no DB, or no ffmpeg) becomes a hard error
|
# A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the
|
||||||
# — the gate must not print green while the DB-backed / ffmpeg tests were quietly not run.
|
# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default
|
||||||
_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB") == "1"
|
# since E32: as an opt-in only the compose lane and CI set, a hand-run `pytest` skipped ~60 DB tests
|
||||||
|
# and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run.
|
||||||
|
_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0"
|
||||||
|
|
||||||
|
|
||||||
def _db_reachable() -> bool:
|
def _db_reachable() -> bool:
|
||||||
@@ -86,19 +88,20 @@ def _truncate_all():
|
|||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
def pytest_configure(config):
|
||||||
"""Fail the whole run — loudly, once — when SIFTLODE_TEST_REQUIRE_DB=1 but the environment can't
|
"""Fail the whole run — loudly, once — when the full suite is required (the default) but the
|
||||||
actually run the full suite. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns
|
environment can't actually run it. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns
|
||||||
~60 DB tests + the poster tests into silent skips and the gate still exits green."""
|
~60 DB tests + the poster tests into silent skips and the gate still exits green."""
|
||||||
if not _REQUIRE_FULL:
|
if not _REQUIRE_FULL:
|
||||||
return
|
return
|
||||||
if not _DB_REACHABLE:
|
if not _DB_REACHABLE:
|
||||||
raise pytest.UsageError(
|
raise pytest.UsageError(
|
||||||
"SIFTLODE_TEST_REQUIRE_DB=1 but no Postgres is reachable at the test DATABASE_URL — the "
|
"no Postgres is reachable at the test DATABASE_URL — the DB-backed lane would silently "
|
||||||
"DB-backed lane would silently skip. Fix the DB wiring or unset the flag."
|
"skip. Fix the DB wiring, or run the pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
|
||||||
)
|
)
|
||||||
if shutil.which("ffmpeg") is None:
|
if shutil.which("ffmpeg") is None:
|
||||||
raise pytest.UsageError(
|
raise pytest.UsageError(
|
||||||
"SIFTLODE_TEST_REQUIRE_DB=1 but ffmpeg is missing — the thumbnail/poster tests would skip."
|
"ffmpeg is missing — the thumbnail/poster tests would skip. Install it, or run the "
|
||||||
|
"pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ def _body_reading_functions() -> dict[tuple[str, str], dict]:
|
|||||||
continue
|
continue
|
||||||
names = [name for _, name in calls]
|
names = [name for _, name in calls]
|
||||||
limits = []
|
limits = []
|
||||||
for node, name in zip([n for n, _ in calls], names):
|
for node, name in zip([n for n, _ in calls], names, strict=True):
|
||||||
if name.startswith("request."):
|
if name.startswith("request."):
|
||||||
limits.append("<no ceiling declared>")
|
limits.append("<no ceiling declared>")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ def test_the_writer_drains_what_was_queued_when_it_is_stopped(db):
|
|||||||
log.addHandler(sink)
|
log.addHandler(sink)
|
||||||
try:
|
try:
|
||||||
for i in range(logsink.BATCH_MAX + 5):
|
for i in range(logsink.BATCH_MAX + 5):
|
||||||
# noqa G004: the f-string is the POINT — a distinct rendered message per line is
|
# G004: the f-string is the POINT — a distinct rendered message per line is
|
||||||
# what keeps the flood limiter (which groups by template) out of this case.
|
# what keeps the flood limiter (which groups by template) out of this case.
|
||||||
log.warning(f"line {i}") # noqa: G004
|
log.warning(f"line {i}") # noqa: G004
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
|
|||||||
second half of this file pins what keeps it off a LIVE session's segments."""
|
second half of this file pins what keeps it off a LIVE session's segments."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -830,6 +831,46 @@ class TestProbeVideoKind:
|
|||||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeExitStatus:
|
||||||
|
"""A probe whose ffprobe EXITS NON-ZERO is a failed probe, not an answer (E32). Each probe used to
|
||||||
|
read stdout without looking at the exit status, so a refusing ffprobe silently picked the fallback
|
||||||
|
— HW-safe, single audio, the requested offset — and skipped the warning its except branch promises.
|
||||||
|
Drives the REAL subprocess.run with the probe's own kwargs against a stand-in command that prints a
|
||||||
|
plausible answer and then fails, so the test fails whenever the exit status is ignored."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failing_ffprobe(monkeypatch, stdout: str):
|
||||||
|
real_run = stream.subprocess.run
|
||||||
|
script = f"import sys; sys.stdout.write({stdout!r}); sys.exit(1)"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda _cmd, **kw: real_run([sys.executable, "-c", script], **kw),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _events(caplog) -> list[str]:
|
||||||
|
return [getattr(r, "event", "") for r in caplog.records]
|
||||||
|
|
||||||
|
def test_video_kind(self, monkeypatch, caplog):
|
||||||
|
self._failing_ffprobe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=smpte2084\n")
|
||||||
|
with caplog.at_level("INFO"):
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
assert "plex.stream.probe_kind_failed" in self._events(caplog)
|
||||||
|
|
||||||
|
def test_audio_langs(self, monkeypatch, caplog):
|
||||||
|
self._failing_ffprobe(monkeypatch, "hun\neng\n")
|
||||||
|
with caplog.at_level("INFO"):
|
||||||
|
assert stream._probe_audio_langs(Path("/x")) == []
|
||||||
|
assert "plex.stream.probe_audio_failed" in self._events(caplog)
|
||||||
|
|
||||||
|
def test_first_pts(self, monkeypatch, caplog):
|
||||||
|
self._failing_ffprobe(monkeypatch, "12.5\n")
|
||||||
|
with caplog.at_level("INFO"):
|
||||||
|
assert stream._probe_first_pts(Path("/x")) is None
|
||||||
|
assert "plex.stream.first_pts_probe_failed" in self._events(caplog)
|
||||||
|
|
||||||
|
|
||||||
class TestOpenclPresent:
|
class TestOpenclPresent:
|
||||||
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
|
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
|
||||||
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
|
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
|
||||||
|
|||||||
+5
-5
@@ -21,10 +21,10 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
sys.path.insert(0, "/app")
|
sys.path.insert(0, "/app")
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select # noqa: E402
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
from app.db import SessionLocal # noqa: E402
|
from app.db import SessionLocal
|
||||||
from app.models import ( # noqa: E402
|
from app.models import (
|
||||||
AppState,
|
AppState,
|
||||||
Channel,
|
Channel,
|
||||||
Playlist,
|
Playlist,
|
||||||
@@ -34,7 +34,7 @@ from app.models import ( # noqa: E402
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.security import hash_password # noqa: E402
|
from app.security import hash_password
|
||||||
|
|
||||||
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
|
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
|
||||||
PASSWORD = os.environ.get("E2E_PASSWORD", "")
|
PASSWORD = os.environ.get("E2E_PASSWORD", "")
|
||||||
@@ -171,7 +171,7 @@ def main() -> None:
|
|||||||
saved = svid.id
|
saved = svid.id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# noqa T201: this script's stdout IS its interface — the e2e runner parses this JSON.
|
# T201: this script's stdout IS its interface — the e2e runner parses this JSON.
|
||||||
print( # noqa: T201
|
print( # noqa: T201
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user