Fix CI: refresh uv.lock, resolve ruff findings, pin ruff 0.16.1
Lint Python / lint (push) Successful in 15s
Test Python / test (push) Failing after 30s

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:
flan
2026-08-03 19:43:34 +00:00
parent 49d80ec4f6
commit acc399a88f
16 changed files with 60 additions and 39 deletions
+3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# adscrub
[![test](https://git.onetick.ninja/flan/adscrub/actions/workflows/test.yml/badge.svg?branch=main)](https://git.onetick.ninja/flan/adscrub/actions) [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue)](LICENSE) [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-EA4AAA?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/sudolulo) [![Ko-fi](https://img.shields.io/badge/Ko--fi-support-FF5E5B?logo=kofi&logoColor=white)](https://ko-fi.com/sudolulo)
[![test](https://github.com/sudolulo/adscrub/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/sudolulo/adscrub/actions) [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue)](LICENSE) [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-%E2%9D%A4-EA4AAA?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/sudolulo) [![Ko-fi](https://img.shields.io/badge/Ko--fi-support-FF5E5B?logo=kofi&logoColor=white)](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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+2 -1
View File
@@ -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
View File
@@ -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:
+4 -6
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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
+9 -9
View File
@@ -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()):
+5 -3
View File
@@ -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
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.12"
[[package]]
name = "adscrub"
version = "0.17.0"
version = "0.18.0"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },