Fix CI: refresh uv.lock, resolve ruff findings, pin ruff 0.16.1
The SIM118 unsafe fix was reverted with a noqa where it broke sqlite3.Row access (Row has keys() but no .get()). README CI badge now points at the public GitHub mirror workflow.
This commit is contained in:
@@ -19,3 +19,6 @@ jobs:
|
||||
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
|
||||
with:
|
||||
args: "check"
|
||||
# Pinned: an unpinned ruff means any upstream release can turn main red
|
||||
# with no code change (exactly what happened 2026-07 → 2026-08).
|
||||
version: "0.16.1"
|
||||
|
||||
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- CI: refreshed the stale `uv.lock` (unblocks the `uv lock --check` gate), fixed all
|
||||
outstanding ruff findings, and pinned ruff to 0.16.1 in the lint workflow so an
|
||||
upstream ruff release can no longer turn `main` red without a code change.
|
||||
- README CI badge now points at the public GitHub mirror's workflow instead of the
|
||||
forge outsiders can't browse.
|
||||
|
||||
## [0.18.0] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# adscrub
|
||||
|
||||
[](https://git.onetick.ninja/flan/adscrub/actions) [](https://www.python.org/) [](LICENSE) [](https://github.com/sponsors/sudolulo) [](https://ko-fi.com/sudolulo)
|
||||
[](https://github.com/sudolulo/adscrub/actions) [](https://www.python.org/) [](LICENSE) [](https://github.com/sponsors/sudolulo) [](https://ko-fi.com/sudolulo)
|
||||
|
||||
Self-hosted podcast ad-detection and removal proxy. It sits between a real RSS feed
|
||||
and your podcast player: fetch each new episode server-side, find the ad spans,
|
||||
|
||||
+13
-2
@@ -9,8 +9,19 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from . import (__version__, chapters, cut, dai, db, detect, feed, fingerprint, ingest, repeats,
|
||||
transcribe)
|
||||
from . import (
|
||||
__version__,
|
||||
chapters,
|
||||
cut,
|
||||
dai,
|
||||
db,
|
||||
detect,
|
||||
feed,
|
||||
fingerprint,
|
||||
ingest,
|
||||
repeats,
|
||||
transcribe,
|
||||
)
|
||||
|
||||
DEFAULT_DB = os.environ.get("ADSCRUB_DB", "adscrub.db")
|
||||
USER_AGENT = f"adscrub/{__version__} (homelab podcast ad-removal proxy)"
|
||||
|
||||
+2
-3
@@ -17,16 +17,15 @@ import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from .audio import DEFAULT_DATA_DIR, download_audio, probe_duration
|
||||
from .db import utcnow
|
||||
|
||||
|
||||
# Which sources are trusted to REMOVE AUDIO. The test is not "is this span evidence or
|
||||
# inference" — `repeat` and `fpmatch` are inference and are exactly what the cheap tiers exist to
|
||||
# cut. The test is whether the span's BOUNDARIES are grounded in something precise:
|
||||
@@ -87,7 +86,7 @@ def detect_silences(
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-nostdin", "-i", str(audio_path),
|
||||
"-af", f"silencedetect=noise={noise_db}dB:d={min_duration}", "-f", "null", "-"],
|
||||
capture_output=True, text=True,
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
silences: list[tuple[float, float]] = []
|
||||
start: float | None = None
|
||||
|
||||
+1
-1
@@ -37,9 +37,9 @@ WHAT IT CANNOT DO
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = """
|
||||
@@ -73,4 +73,4 @@ def connect(path: str | Path) -> sqlite3.Connection:
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
@@ -16,8 +16,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,7 +29,7 @@ from . import __version__, db
|
||||
def _parse_pubdate(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def build_feed(conn: sqlite3.Connection, feed: sqlite3.Row, base_url: str) -> bytes:
|
||||
|
||||
@@ -57,12 +57,11 @@ import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .audio import DEFAULT_DATA_DIR, MAX_AUDIO_BYTES, download_audio, probe_duration
|
||||
from .db import utcnow
|
||||
from .detect import DetectedAdSpan, insert_spans
|
||||
from .repeats import GROUND_TRUTH_SOURCES
|
||||
|
||||
@@ -141,7 +140,7 @@ def _fpcalc(path: str | Path, length: int = FP_LENGTH) -> list[int]:
|
||||
"""Raw Chromaprint sub-fingerprints for a whole audio file (any format fpcalc reads)."""
|
||||
out = subprocess.run(
|
||||
["fpcalc", "-raw", "-length", str(length), str(path)],
|
||||
capture_output=True, text=True,
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
for line in out.stdout.splitlines():
|
||||
if line.startswith("FINGERPRINT="):
|
||||
@@ -354,7 +353,7 @@ def stream_index_episodes(
|
||||
fp, _ = stream_episode_fingerprint(conn, eid, url, client)
|
||||
if fp:
|
||||
indexed += 1
|
||||
except Exception: # noqa: BLE001 — a bad URL / fpcalc must not abort the whole batch
|
||||
except Exception: # noqa: BLE001, S110 — a bad URL / fpcalc must not abort the whole batch
|
||||
pass
|
||||
if on_progress:
|
||||
on_progress(n, len(todo))
|
||||
@@ -1093,7 +1092,6 @@ def fingerprint_episode(
|
||||
bounded index stage. This is the cheap side of the index/match split (see index_episodes).
|
||||
"""
|
||||
if indexed_only:
|
||||
got = None
|
||||
row = conn.execute(
|
||||
"SELECT fingerprint, duration FROM episode_fingerprints WHERE episode_id = ?",
|
||||
(episode["id"],),
|
||||
@@ -1109,7 +1107,7 @@ def fingerprint_episode(
|
||||
spans = detector.match_fingerprint(fp, duration, exclude_episode_id=episode["id"])
|
||||
# Corroborate against the transcript when the episode has one. Free, and it removes the
|
||||
# music/room-tone matches an audio-only tier is blind to (see drop_speechless_spans).
|
||||
transcript_path = episode["transcript_path"] if "transcript_path" in episode.keys() else None
|
||||
transcript_path = episode["transcript_path"] if "transcript_path" in episode.keys() else None # noqa: SIM118 — sqlite3.Row has keys() but no __contains__ or .get()
|
||||
if transcript_path:
|
||||
try:
|
||||
with open(transcript_path, encoding="utf-8") as fh:
|
||||
|
||||
@@ -46,9 +46,9 @@ import re
|
||||
import sqlite3
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from .detect import DetectedAdSpan, insert_spans
|
||||
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ def test_each_fetch_gets_an_independent_client_not_a_shared_cookie_jar():
|
||||
body = b"a" * 50 if ua == dai.USER_AGENTS[0] else b"b" * 50
|
||||
return httpx.Response(200, content=body, headers={"set-cookie": "listenerid=tracked; Path=/"})
|
||||
|
||||
factory = lambda: httpx.Client(transport=httpx.MockTransport(handler)) # noqa: E731
|
||||
factory = lambda: httpx.Client(transport=httpx.MockTransport(handler))
|
||||
dai.probe_variance(factory, URL, max_bytes=1000)
|
||||
assert len(seen_cookies) >= 2 # the two base fetches (plus any escalation)
|
||||
assert all(c is None for c in seen_cookies) # NO fetch ever carried a cookie — isolation holds
|
||||
|
||||
@@ -188,7 +188,7 @@ def test_apply_fingerprints_finds_the_unlabelled_copy(corpus):
|
||||
|
||||
|
||||
def test_apply_fingerprints_is_idempotent(corpus):
|
||||
conn, data_dir, a, b = corpus
|
||||
conn, data_dir, _a, b = corpus
|
||||
for _ in range(3):
|
||||
fingerprint.apply_fingerprints(conn, client=None, data_dir=data_dir)
|
||||
n = conn.execute(
|
||||
@@ -200,7 +200,7 @@ def test_apply_fingerprints_is_idempotent(corpus):
|
||||
def test_fpmatch_never_becomes_library_evidence(corpus):
|
||||
"""A fpmatch span is inference; if it seeded the library the detector would bootstrap off
|
||||
its own guesses, the drift repeats.py was bitten by. GROUND_TRUTH_SOURCES excludes it."""
|
||||
conn, data_dir, a, b = corpus
|
||||
conn, data_dir, _a, _b = corpus
|
||||
fingerprint.apply_fingerprints(conn, client=None, data_dir=data_dir)
|
||||
assert conn.execute("SELECT COUNT(*) c FROM ad_segments WHERE source='fpmatch'").fetchone()["c"] > 0
|
||||
# only the one ground-truth ad is ever fingerprinted into the cache
|
||||
@@ -221,7 +221,7 @@ def test_apply_fingerprints_never_touches_other_sources_or_marks_detected(corpus
|
||||
|
||||
|
||||
def test_library_is_cached_not_recomputed(corpus, monkeypatch):
|
||||
conn, data_dir, a, b = corpus
|
||||
conn, data_dir, _a, _b = corpus
|
||||
fingerprint.build_library(conn, data_dir) # first build fingerprints ep-a's ad
|
||||
# a second build must not re-fingerprint anything already cached
|
||||
def boom(p, s, e):
|
||||
@@ -234,7 +234,7 @@ def test_library_is_cached_not_recomputed(corpus, monkeypatch):
|
||||
def test_episode_fingerprint_is_cached_across_rescans(corpus, monkeypatch):
|
||||
"""The whole-episode fpcalc is the tier's only real cost; a re-scan (library grew) must
|
||||
re-run only the matching, never the decode."""
|
||||
conn, data_dir, a, b = corpus
|
||||
conn, data_dir, _a, b = corpus
|
||||
fingerprint.apply_fingerprints(conn, client=None, data_dir=data_dir)
|
||||
assert conn.execute("SELECT COUNT(*) c FROM episode_fingerprints").fetchone()["c"] == 2
|
||||
|
||||
@@ -420,7 +420,7 @@ def campaigns_corpus(conn, data_dir, monkeypatch):
|
||||
|
||||
def test_finds_one_campaign_per_recording_not_per_episode(campaigns_corpus):
|
||||
"""7 episodes carrying 3 recordings must yield 3 campaigns, not 7 findings."""
|
||||
conn, data_dir, eids = campaigns_corpus
|
||||
conn, data_dir, _eids = campaigns_corpus
|
||||
camps = fingerprint.find_campaigns(conn, data_dir)
|
||||
assert len(camps) == 3
|
||||
assert sorted(c.reach for c in camps) == [2, 3, 3]
|
||||
@@ -481,7 +481,7 @@ def test_already_read_campaigns_are_not_selected_again(campaigns_corpus):
|
||||
|
||||
|
||||
def test_seed_selection_respects_limit(campaigns_corpus):
|
||||
conn, data_dir, eids = campaigns_corpus
|
||||
conn, data_dir, _eids = campaigns_corpus
|
||||
assert len(fingerprint.select_seed_episodes(conn, data_dir, limit=1)) == 1
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def corpus(conn, tmp_path):
|
||||
|
||||
|
||||
def test_recognises_the_same_ad_read_in_another_episode(corpus):
|
||||
conn, a, b = corpus
|
||||
conn, _a, _b = corpus
|
||||
detector = repeats.RepeatAdDetector(repeats.build_library(conn))
|
||||
spans = detector.detect(AD_B)
|
||||
assert len(spans) == 1
|
||||
@@ -68,7 +68,7 @@ def test_recognises_the_same_ad_read_in_another_episode(corpus):
|
||||
|
||||
|
||||
def test_leaves_editorial_alone(corpus):
|
||||
conn, a, b = corpus
|
||||
conn, _a, _b = corpus
|
||||
detector = repeats.RepeatAdDetector(repeats.build_library(conn))
|
||||
editorial = [
|
||||
{"start": 0.0, "end": 5.0, "text": "The detective arrived at the scene just after midnight."},
|
||||
@@ -78,7 +78,7 @@ def test_leaves_editorial_alone(corpus):
|
||||
|
||||
|
||||
def test_an_episode_cannot_be_its_own_library(corpus):
|
||||
conn, a, b = corpus
|
||||
conn, a, _b = corpus
|
||||
lib = repeats.build_library(conn, exclude_episode_id=a)
|
||||
# ep-a's ad was the ONLY confirmed one, so excluding it leaves nothing to match against
|
||||
assert repeats.RepeatAdDetector(lib).detect(AD_A) == []
|
||||
@@ -92,7 +92,7 @@ def test_empty_library_detects_nothing():
|
||||
|
||||
|
||||
def test_apply_repeats_finds_the_unlabelled_copy(corpus):
|
||||
conn, a, b = corpus
|
||||
conn, _a, b = corpus
|
||||
results = repeats.apply_repeats(conn)
|
||||
found = {r.episode_id: r.found for r in results}
|
||||
assert found[b] == 1
|
||||
@@ -104,7 +104,7 @@ def test_apply_repeats_finds_the_unlabelled_copy(corpus):
|
||||
|
||||
def test_apply_repeats_is_idempotent(corpus):
|
||||
"""The library grows, so re-scanning is expected — it must refresh, not accumulate."""
|
||||
conn, a, b = corpus
|
||||
conn, _a, b = corpus
|
||||
repeats.apply_repeats(conn)
|
||||
repeats.apply_repeats(conn)
|
||||
repeats.apply_repeats(conn)
|
||||
@@ -118,7 +118,7 @@ def test_library_ignores_the_tier_s_own_output(corpus):
|
||||
"""A repeat span is an inference, not evidence. If it feeds back into the library, the
|
||||
detector bootstraps off its own guesses and drifts — on the real corpus a second sweep
|
||||
went 958 -> 993 spans before this was fixed."""
|
||||
conn, a, b = corpus
|
||||
conn, _a, _b = corpus
|
||||
before = repeats.build_library(conn)
|
||||
repeats.apply_repeats(conn) # writes source='repeat' rows
|
||||
assert conn.execute(
|
||||
@@ -129,7 +129,7 @@ def test_library_ignores_the_tier_s_own_output(corpus):
|
||||
|
||||
|
||||
def test_apply_repeats_never_touches_other_sources(corpus):
|
||||
conn, a, b = corpus
|
||||
conn, a, _b = corpus
|
||||
repeats.apply_repeats(conn)
|
||||
llm = conn.execute(
|
||||
"SELECT COUNT(*) c FROM ad_segments WHERE episode_id = ? AND source = 'llm'", (a,)
|
||||
@@ -139,7 +139,7 @@ def test_apply_repeats_never_touches_other_sources(corpus):
|
||||
|
||||
def test_apply_repeats_does_not_mark_the_episode_llm_detected(corpus):
|
||||
"""A free pass that never read the words must not retire the episode from the LLM."""
|
||||
conn, a, b = corpus
|
||||
conn, _a, b = corpus
|
||||
repeats.apply_repeats(conn)
|
||||
row = conn.execute("SELECT llm_detected_at FROM episodes WHERE id = ?", (b,)).fetchone()
|
||||
assert row["llm_detected_at"] is None
|
||||
@@ -150,7 +150,7 @@ def test_apply_repeats_does_not_mark_the_episode_llm_detected(corpus):
|
||||
|
||||
def test_layered_detector_unions_its_tiers(corpus):
|
||||
"""Composing tiers must need no branching — and each span keeps its own source."""
|
||||
conn, a, b = corpus
|
||||
conn, _a, _b = corpus
|
||||
|
||||
class Stub:
|
||||
def detect(self, transcript, skip=frozenset()):
|
||||
|
||||
@@ -48,9 +48,11 @@ def test_download_audio_reports_http_errors(tmp_path):
|
||||
return httpx.Response(404)
|
||||
|
||||
dest = tmp_path / "audio" / "1.mp3"
|
||||
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
transcribe.download_audio(client, AUDIO_URL, dest)
|
||||
with (
|
||||
httpx.Client(transport=httpx.MockTransport(handler)) as client,
|
||||
pytest.raises(httpx.HTTPStatusError),
|
||||
):
|
||||
transcribe.download_audio(client, AUDIO_URL, dest)
|
||||
assert not dest.exists() # partial file renamed only on success
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user