Skip to content

Ship v1.2#1

Merged
madevmuc merged 63 commits intomainfrom
ship-v1
Apr 23, 2026
Merged

Ship v1.2#1
madevmuc merged 63 commits intomainfrom
ship-v1

Conversation

@madevmuc
Copy link
Copy Markdown
Owner

What this changes

Why

How to verify

  • PYTHONPATH=. .venv/bin/pytest passes
  • Manual smoke test of the affected tab / command
  • No new runtime dependencies added
  • Privacy check: no new outbound calls to third parties

Screenshots

Notes for reviewers

madevmuc and others added 30 commits April 22, 2026 21:20
Add render_episode_markdown to core/export.py: a source-aware renderer
that emits youtube_id / youtube_url / channel_id / transcript_source
frontmatter and a [Watch on YouTube] body link when source='youtube'.
Default source='podcast' produces minimal frontmatter and leaves the
existing transcriber inline-frontmatter path untouched.

Task 11 of the Theme A YouTube ingestion plan.
Part A — wire CheckAllThread._pctx_for to populate
youtube_channel_id / youtube_transcript_pref /
youtube_default_transcript_source for shows with source="youtube"
(parsed from the canonical channel-RSS URL). Podcast shows are
unaffected.

Part B — Add a 4th "YouTube URL" segment to AddShowDialog (gated on
sources_youtube). Accepts handle URLs, channel-id URLs, and (with
a clear "follow-up" message) video URLs. Resolves the channel via
core.youtube_meta, renders a preview card, offers an
[All / Only new / Last 20 / Last 50] backfill picker, and creates
a Show(source="youtube", rss=rss_url_for_channel_id(cid), ...).
Shows an Install yt-dlp button when the binary is missing and
retries the resolve after a successful install.
The QGroupBox-with-frame style stuck out next to the flat-header
pattern used by Library & output and other sections. Switching to
_section() removes the bordered frame; checkboxes render directly
under the bold header, matching Library/Obsidian/etc.

Test refactor: drop the QGroupBox-title assertion in favour of
finding the two checkboxes by objectName.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Settings → Automation & remote control: agent prompt now mentions
  YouTube channels, captions-first behaviour, yt-dlp auto-install,
  the new sources_* / youtube_* settings keys, and 4 example tasks
  spanning both sources.
- README → GUI workflows: Add Podcast is 4 modes (with YouTube),
  Sources toggle documented, Re-run setup button mentioned.
- README → Headless CLI: shows YouTube add examples + captions-first
  override path.
- Both fix the stale 'scripts/paragraphos/' path → '~/dev/paragraphos/'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bundled MenuBarIconTemplate.png was a 22x22 PNG with a tiny
glyph centered in lots of whitespace, making the menu-bar icon
hard to spot. Always paint the idle icon via QPainter so it fills
the canvas at point size 16. macOS click behaviour with a context
menu attached makes left-click open the menu; rename 'Open' to
'Open Paragraphos' so users immediately see how to reach the
window.
…ion() style

Match the Library & output and Sources pattern. Removes the
bordered QGroupBox frame from three sections that visually
clashed with the flat-header neighbours.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Many podcast hosts (immopreneur, several CDNs) serve MP3 with
Content-Type: binary/octet-stream — the strict allowlist was
rejecting all of them as non-audio.

Accept binary/octet-stream and application/octet-stream
optimistically, but verify the first chunk's magic bytes match a
known audio format (ID3, MPEG sync, fLaC, OggS, RIFF, MP4 ftyp)
before continuing the download. Real text/html or PDF served as
octet-stream still fails fast, just at byte level instead of
header level.
…play

Bug: _open_ytdlp_installer() called dlg.exec() but never dlg.start(),
so the worker thread never spawned and the progress bar sat at 0%
forever. Move the start() trigger into showEvent so it auto-fires.

UX: when the user activates the YouTube mode for the first time
without yt-dlp installed, auto-open the install dialog (no extra
button click). During the download show MB-counter + percentage +
ETA so the user knows how long the wait is. After completion the
URL input field re-enables automatically.
Setup guide's Obsidian page now:
- Walks ~/Documents, ~/, iCloud-synced obsidian dir, ~/Dropbox to
  find any folder with a .obsidian/ subdir → pre-fills the path
  field and defaults the YES/NO toggle accordingly.
- Shows an explanation block: what Obsidian is (with download
  link), why pairing it with Paragraphos + an AI assistant
  unlocks cross-transcript search and summarisation.

Discovery lives in a new core/obsidian.py so the UI stays thin
and the walk is unit-testable.
_run_brew is called directly (bypassing _install_whisper), so
_whisper_started stays False. The on_finished closure's trailing
_refresh() sees brew=True + whisper-cli=False + flag=False on a CI
runner and re-fires _install_whisper, flipping the pill back to
"installing… 0s" and breaking the assertion.

Locally whisper-cli is present → auto-chain skipped → test passes.
CI macos-arm64 has brew but no whisper-cli → auto-chain fires → test
fails.

Pin deps.check() to all-False inside this test.
v1.2 added YouTube ingestion via a lazy-installed yt-dlp binary that
was not credited anywhere in the license tracking. Add yt-dlp
(Unlicense / public domain) to the in-app About → Credits & Licenses
dialog, the README license summary, and a new canonical
THIRD_PARTY_LICENSES.md at the repo root that also documents the
PyQt6 GPL-vs-Riverbank-Commercial situation.

No new Python deps were introduced by the YouTube modules — they
shell out to the yt-dlp binary via subprocess, so requirements.txt
is unchanged.
Without an explicit QMessageBox rule the dialog body inherited the
native palette in dark mode, leaving the message text and button
labels low-contrast against the themed surface. Style the dialog,
its labels, and its buttons (with a default-button accent) so the
delete-confirm popup (and any other QMessageBox.question) reads
cleanly in both themes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
set_priority() only updated the SQL row's priority. The worker
re-queries pending episodes by priority DESC on its next pass —
but if the queue was idle, that next pass might be hours away,
so 'Run next' looked broken from the user's perspective: the
episode never moved, the queue never started.

Fix: after bumping, call shows_tab.start_check(force=True) so
the worker re-queries immediately and the bumped episode shows
up at the head of the queue right away. Applied to both the
Show Details dialog and the Queue tab context menus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
madevmuc and others added 28 commits April 23, 2026 08:26
…01-01

Plus: stabilise test_scrape_og_audio against environment-dependent
DNS — _is_private_ip does a real lookup and on networks where
cdn.podigee.com resolves to a private IP (custom resolver, captive
portal, school filter) the SSRF guard fired before respx could mock
the call. Mock _is_private_ip in the test.
Adds a small ui/widgets/resizable_header.py helper that turns each
non-stretch / non-fixed column into ResizeMode.Interactive, persists
widths to QSettings on drag (debounced 300 ms), and exposes a
right-click "Reset columns" header action that restores the supplied
defaults and clears the saved entry.

Wired into Shows / Queue / Failed tabs — one make_resizable() call
per tab, replacing the previous per-column setSectionResizeMode
blocks. Queue tab keeps Status fixed at 150 px to avoid jitter from
the live "transcribing · NN%" text. Failed tab drops its second
Stretch column (Reason) to Interactive — only one column can
effectively absorb spare space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a background TCP-probe (1.1.1.1 → 8.8.8.8 → youtube.com) running on a
30 s/5 s cadence; when the network drops the queue is paused with an
"Offline" banner, and when it returns network-failed episodes from the
last auto_resume_failed_window_hours hours are re-queued and the next
check fires automatically. paused_reason discriminates auto-pause from
user-initiated pause so the auto-resume path won't override an explicit
choice. Off-switch via Settings.connectivity_monitor_enabled for users
behind captive portals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
systematic-debugging Phase 1: faulthandler showed the segfault was
in the lambda we connect to header.sectionResized, fired during
QStackedWidget.addWidget → setParent → style cascade →
QHeaderView::length() → updateGeometries → sectionResized.

ROOT CAUSE: PyQt6 6.7's slot dispatch crashed inside the closure
when sectionResized fired mid-reparent. Plus the lambda would have
overwritten saved widths with Qt's transient initial-layout values
even when not crashing.

FIX: replace the lambda with a named slot that:
1) Skips events while a 1 s 'arming' QTimer (started at make_resizable
   call time) is still active. By then the widget has settled into
   its real layout.
2) Wraps the timer-start in try/except RuntimeError so a deleted
   underlying Qt object doesn't propagate a SIGSEGV — it just no-ops.

The persist callback gets the same RuntimeError guard so a worker
that fires after table close also no-ops cleanly.

Test: bypass the suppression timer (table._resizable_armed_at.stop())
in the existing persist+restore round-trip test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Add Podcast / Show…' rename

Three small UX touch-ups:

- Library tab: when the table populates with nothing selected,
  auto-select row 0 (the most-recent episode by pub_date DESC). The
  preview pane fills immediately when the user switches shows
  instead of staying empty.

- Shows tab: rename the 'Add Podcast…' button to 'Add Podcast / Show…'
  to reflect the YouTube channel mode added in v1.2.

- Themes: drop the global QPushButton styling block so default buttons
  inherit macOS 26.x's native rounded-rectangle appearance (the
  glassy bevel, system gradient, focus ring). Only role='primary'
  and role='ghost' keep custom styling because the design needs
  more emphasis than native gives there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing)

systematic-debugging finding: the painter-drawn QIcon was a coloured
bitmap, not marked QIcon.setIsMask(True). On macOS Sequoia + macOS 26,
only template images get the system's correct menu-bar treatment —
auto-tint for light/dark + accent, system-managed sizing, and priority
placement against the menu-bar overflow chevron. Without it, the icon
rendered as a too-small fixed-colour bitmap and could be hidden when
the menu bar overflowed.

ROOT CAUSE: removed the bundled MenuBarIconTemplate.png path earlier
because the bundled glyph was too tiny in too-much whitespace, but
neglected to keep the setIsMask(True) call that made macOS treat the
icon as a real menu-bar icon.

FIX: paint the glyph in opaque black on transparent (alpha mask shape
matches the silhouette), set the QIcon devicePixelRatio for both 1x
and 2x pixmaps, then call icon.setIsMask(True). macOS now auto-tints
+ sizes + places the icon natively. Removed the manual _fg() colour
picker — pen colour is ignored by the OS for template images.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…line

Startup log now records yt-dlp + whisper-cli versions and every
user-tunable setting, with `(rec=N)` annotations on parallel/multiproc
when current values differ from the hardware-aware recommendation. Makes
support tickets self-diagnosing — one log line shows whether the user's
tuning matches what their machine should run.

Offline behaviour: dropping the connection no longer flips
queue_paused=1. whisper.cpp doesn't need network for already-downloaded
episodes, so we let the worker keep draining them. The banner now spells
out what actually pauses (feeds, new downloads) vs. what keeps moving
(transcription of downloaded items). Reconnect path always re-queues
network-failed episodes since we no longer need to discriminate
auto-pause from user-pause via paused_reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both blocks were written when the app shipped only its original 4 CLI
commands and have drifted. Reflect the current reality:

- Captions fallback chain (requested → en → any) is now spelled out.
- Yt-dlp lazy install + weekly self-update are noted.
- Single-instance lock is mentioned so users don't try to run CLI while
  GUI is open and wonder why nothing happens.
- Startup fingerprint log line is documented (one-stop support diagnostic).
- Offline behaviour: CLI/check still works for already-downloaded items.
- New settings (auto_start_delay, save_srt, mp3_retention,
  connectivity_monitor_enabled, auto_resume_failed_window_hours,
  youtube_default_language, parallel_transcribe + whisper_multiproc tuning)
  are surfaced.
- Per-show overrides (language, enabled, output_override, whisper_prompt)
  are listed.
- Honest "GUI-only operations not yet in the CLI" note + sqlite snippets
  for the most-asked workarounds (priority bump, re-transcribe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expand cli.py from 4 commands to 23 so an LLM agent can drive the whole
app without raw sqlite or YAML edits. Most read commands accept --json
for parseable output. The CLI shares state with a running GUI via
SQLite WAL mode (no single-instance lock on cli.py); SQLite-backed
mutations are picked up live, watchlist edits land on disk and surface
on the GUI's next refresh.

New commands:

  Inspection (--json):
    status                snapshot — queue depth, in-flight, by-status
                          counts, queue_paused flag
    shows                 watchlist + per-show counts + feed health
                          (alias: list)
    show <slug>           full detail for one show
    episodes <slug>       --status / --limit filters
    failed                cross-show or --show; --limit
    settings              all settings + (rec=N) on hardware mismatch
    feed-health           per-show health + backoff state

  Queue control (live):
    pause / resume / stop (force-kill whisper-cli + yt-dlp + recover)
    clear-queue
    priority <guid> <N>   set priority
    run-next <guid>       priority=100
    retranscribe <guid>   status=pending + priority=100
    retry-failed [--show] [--all-time] [--window-hours N]

  Show management:
    enable / disable <slug>
    remove <slug> [-y] [--purge-state]
    set <slug> key=value  (whitelisted keys; type-coerced)

  Feed retry:
    retry-feed <slug>     clear backoff + immediate fetch
    retry-all-feeds       same for every fail-marked feed

  Settings:
    set-setting <key> <value>   type-coerced from Settings model

Settings pane help and the agent prompt are rewritten to enumerate every
new command and include realistic example tasks an agent can execute.

tests/test_cli_parser.py adds 38 sanity tests: full subcommand inventory,
arg parsing for every documented invocation, _coerce_value type
coverage, and a guard that _SHOW_SETTABLE keys all exist on the Show
model so a typo can't crash `set` at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User on a macOS LAN with DNS64/NAT64 saw every podcast feed fail with
'refused private-network host (SSRF guard)'. Root cause: the resolver
synthesised IPv6 in the well-known NAT64 prefix 64:ff9b::/96 (RFC
6052) and the IPv4-mapped prefix ::ffff:0:0/96 (RFC 4291). Python's
ipaddress module classifies both ranges as is_reserved=True (IANA
"IPv4/IPv6 Translators"), so _is_private_ip rejected every plain
IPv4-only public host whenever Happy Eyeballs / DNS64 was active.

Fix: when a getaddrinfo result lands in either prefix, pull the
embedded IPv4 (last 32 bits) and run the private-IP checks on THAT.
Tests cover (1) IPv4-mapped public, (2) IPv4-mapped doesn't soften
real fe80::/10, (3) NAT64 wrapping a public IPv4 passes (regression
for the actual gvh.podcaster.de → 64:ff9b::5e82:dfe4 case the user
hit), (4) NAT64 wrapping a private IPv4 (10.0.0.1) still blocks so
the unwrap can't be turned into an SSRF bypass.

Verified end-to-end: `cli.py retry-all-feeds` went from 1/14 → 14/14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Shows-tab pill said only 'fail'; the Show-details dialog had no
feed-health surface at all; and there was no in-GUI affordance to
clear backoff after fixing whatever broke. With 14 feeds parked for 7
days a user couldn't tell *which* failure mode hit them or how to
recover without sqlite-poking. This wires the loop end-to-end.

Backend:
  core/feed_errors.py — bucket exceptions into 11 stable categories
  (dns, timeout, tls, forbidden, gone, server, malformed,
  redirect_loop, ssrf, too_large, other) plus a ~1-line per-category
  recommendation and a short pill label.
  core/backoff.py — on_failure(state, slug, exc=None) now persists
  feed_fail_category, feed_fail_message, feed_fail_at alongside the
  existing health flag + counter; on_success clears them.
  ui/worker_thread.py — passes the exception in.

Display:
  ui/shows_tab.py — fail pill text becomes 'fail · <category>'.
  Tooltip carries the full message, when, recommendation, and any
  backoff window. New 'Retry failed feeds' toolbar button clears
  backoff and re-fetches every fail-marked feed synchronously.
  ui/show_details_dialog.py — new Feed health panel below the form
  (only rendered when health=fail). Shows category pill, message,
  timestamp, backoff state, recommendation, and a Retry-now button
  that rebuilds the panel in place on success/failure.

CLI parity:
  cli.py — _clear_feed_backoff and the new _record_feed_failure
  helper update all five meta keys. cmd_feed_health surfaces
  category, message, failed_at, recommendation (always in --json,
  optional in human mode for --show). cmd_show same. retry-feed and
  retry-all-feeds now persist categorised failure detail when their
  retry itself fails.

Tests:
  tests/test_feed_errors.py — 16 tests pinning the categorisation
  table, including the SSRF-first dispatch ordering (UnsafeURLError
  is a ValueError subclass, must be caught by class), HTTP-status
  buckets, DNS/TLS/timeout text-match fallbacks, recommendation/label
  invariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related changes to the Queue tab's Status column:

1) Default sort now follows pipeline stage:
     transcribing → downloading → downloaded → pending
   (downloading and downloaded were swapped before, which buried the
   actively-fetching MP3s under already-downloaded ones).

2) Clicking the Status header cycles three modes instead of Qt's
   asc↔desc toggle:
     priority (default SQL order) → ascending → descending → priority …
   The priority leg restores the pipeline-stage ordering and clears the
   sort indicator so users can get back to the worker view in one click,
   without having to sort by another column to "unsort" Status.

Clicking any other column resets the Status mode to "priority" so the
next Status click starts the cycle fresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Crash report (SIGABRT via PyQt6 qFatal) on 2026-04-23 13:10 happened
when the user clicked Retry now in the Show Details dialog and the
feed fetch succeeded.

Root cause: in _retry_feed_now the rebuild block did

    new_panel = self._build_feed_health_panel()
    old = self._feed_health_container

The builder always reassigns self._feed_health_container to a fresh
QWidget before returning, so `old` ended up aliasing that NEW
un-parented widget. old.parentWidget() returned None,
.layout() raised AttributeError, and PyQt6's slot proxy converted
that into qFatal → abort.

Fix: capture `old` BEFORE building, plus a defensive None-check on
parentWidget() so future similar mistakes degrade gracefully instead
of crashing. Comment in the rebuild explains the ordering rule.

Defense-in-depth: install sys.excepthook in app.py so any future
uncaught Python exception inside a Qt slot logs + shows a
QMessageBox instead of taking down the whole app. PyQt6's default
qFatal-on-slot-exception is genuinely user-hostile for an
interactive desktop app — this neutralises it process-wide.

Regression test reproduces the exact crash (AttributeError on the
success path) and pins the failure-path early-return so a future
refactor can't reintroduce the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four episodes from hausverwalter-inside (.mp4 / .m4a podcasts saved
as .mp3) sat in the failed bucket with the diagnostic:

  whisper-cli exited 0 but expected outputs missing.
    stderr: ... whisper_print_timings: total time = 716.75 ms ...
    temp dir contents: ['<slug>.stdout.log']

Root cause: whisper-cli shells out to ffmpeg internally for non-WAV
inputs. Paragraphos.app launched from /Applications has
PATH=/usr/bin:/bin only — Homebrew's ffmpeg at /opt/homebrew/bin is
invisible. Whisper "ran" on the .mp4 container, decoded zero audio
frames, exited clean with no .txt/.srt output, ~700 ms wall time.

This wasn't surfaced earlier because most podcasts are real .mp3,
which whisper.cpp can decode without ffmpeg. The .mp4/.m4a outliers
hit the silent-failure path.

Fix: mirror the existing _locate_whisper_bin Homebrew-fallback
pattern with _locate_ffmpeg_dir, then build a PATH-augmented env via
_whisper_subprocess_env() and pass it to both subprocess.run call
sites (classic + streaming). Returns None when no augmentation is
needed (ffmpeg already on PATH or genuinely absent), keeping every
existing transcriber test that mocks subprocess.run unaffected.

Startup fingerprint now uses the same locator so `ffmpeg=yes` reflects
what whisper-cli actually sees.

8 new tests pin: PATH-first lookup, Homebrew fallback, env=None
short-circuits, env augmentation prepends correctly, empty-PATH
edge case, end-to-end env wiring through transcribe_episode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent reasons every (version) probe was returning '—' on
the user's actual app:

1. whisper-cli detection used bare shutil.which("whisper-cli"),
   which returns None when PATH is /usr/bin:/bin only (Paragraphos.app
   launched from /Applications). Switched to the WHISPER_BIN locator
   (already had the Homebrew-fallback path), then run --help via the
   resolved absolute path.

2. yt-dlp's PyInstaller-bundled binary takes ~11 s for its first
   --version call (cold extractor-load). The 4-8 s timeout I tried
   first kept timing out. Solution: cache the value in meta after
   the first successful probe, and on a cold cache fire-and-forget a
   20-s probe in a daemon thread. The first launch logs '—'; every
   launch after that shows the version. Yt-dlp self-updates weekly
   so the cache never drifts more than 7 days.

3. whisper-cli has no clean --version flag (its first stderr line
   is a GGML BLAS-backend init banner, not a version) and even its
   --help dumps the same noise. Switch to extracting the version
   from the Homebrew Cellar symlink target — Path.resolve() on
   /opt/homebrew/bin/whisper-cli yields .../Cellar/whisper-cpp/1.8.4/
   bin/whisper-cli and we lift the version segment. Same trick for
   ffmpeg. No subprocess, no GGML init noise, instant.

Result on the user's install:
  tooling: whisper-cli=yes (1.8.4) yt-dlp=yes (2026.03.17) ffmpeg=yes (8.1)

(was: whisper-cli=no (—) yt-dlp=yes (—) ffmpeg=no for nearly every
launch since the fingerprint shipped.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…import design

v1.3.0 scope: extend ingest beyond RSS + YouTube to any audio/video
the user has. Drop zone (files or pasted URL), watched folder with
subfolder-as-show convention, and one-shot folder import. Search +
analysis stay out of scope — those belong downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sequenced plan for v1.3.0: core/local_source.py → pipeline branch →
watch_folder → Settings UI → drop zone → folder-import dialog → CLI
parity → app wiring → CHANGELOG. Each task follows the TDD shape
(failing test → minimal impl → green → commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l + UX polish

Multiple user-reported issues bundled because they touch overlapping
files:

1. Queue tab — Pause/Stop buttons appeared twice (top of hero card AND
   bottom toolbar), and the rest of the controls (Start/Refresh/Remove
   all) lived at the bottom. Consolidated everything into a single top
   toolbar; the hero card now renders state only. QueueHero's
   on_pause/on_stop ctor args removed.

2. settings — QSpinBox / QComboBox / QSlider widgets respond to the
   scroll wheel by default, so users mistakenly bumped values while
   scrolling the pane. New _NoScrollFilter eats QEvent.Wheel on every
   value widget after pane construction. Widgets stay fully editable
   via type / arrow keys / spinbox arrows; only wheel-stepping is
   suppressed.

3. core/transcriber — TranscriptionError messages now include a
   one-line human explanation alongside the raw exit code, e.g.
   "whisper-cli exit -9 (killed (SIGKILL — usually the Stop button's
   force-kill, or macOS OOM))". Maps the common signals.

4. parallel_transcribe — read everywhere as a config but only ONE
   _TranscribeWorker was ever spawned, so users with parallel=2+ saw a
   single transcribing line at a time despite paying for the
   configuration. Spawn N workers sharing the same in_q (Python's
   queue.Queue is thread-safe) and a shared done_idx counter (atomic
   via threading.Lock); the download pool now enqueues N _SHUTDOWN
   sentinels at end-of-stream (one per consumer) via the new
   `consumer_count` attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes bundled because both touch the download→transcribe handoff:

1. Startup fingerprint now replays into the in-app LogDock + LogsPane
   in addition to the file handler. Previously the log line fired
   during ParagraphosApp.__init__, before MainWindow had wired its
   dock, so a user opening Logs never saw it. Pre-format the message
   once, log immediately to the file (post-crash debugging), stash on
   self, and open_window() replays it via dock.append + pane.append on
   first open.

2. Orphan-recovery slug mismatch (exit 2 transcribes on limmo):
   download_phase now persists mp3_path to state.sqlite so the orphan-
   recovery path in _DownloadPool reads the authoritative on-disk
   filename instead of rebuilding the slug from (pub_date, title,
   episode_number). Legacy rows (downloaded before this change) fall
   back to a slug-rebuild and then to a best-effort glob
   <audio_dir>/<YYYY-MM-DD>_*.mp3; when a hit is found the path is
   backfilled so subsequent runs skip the glob. Transcribe now
   receives the actual filename and whisper-cli gets a real input
   file, not a made-up _0000_ path that doesn't exist.

Background: ep_num_map (guid → episode_number) is only populated from
the CURRENT run's feed-fetch. Orphans from a previous run aren't in
it, so the rebuild defaulted to "0000" and wrote
  audio/2024-07-22_0000_Startup per intrinsischer Motivation.mp3
while the file on disk was
  audio/2024-07-22_0227_Startup per intrinsischer Motivation.mp3
→ whisper-cli "error: input file not found" → exit 2 + usage text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI: Shows tab buttons split between top (bulk-action row) and bottom
(action row), with the Library stats banner squashed in between. Move
both rows to the very top in the order:
  1. action row  — Add Podcast/Show, Add Episodes, Start/Check Now,
                   Pause, Stop, Refresh, Check feeds, Retry failed
                   feeds, Rescan library
  2. bulk row    — Disable / Enable / Mark stale / Delete selected
                   (disabled until selection)
  3. Library stats banner
  4. Filter row + table

Now Shows / Queue / Failed all keep their toolbars in the same screen
position. Bottom button row removed.

README: bumped version badge (v0.5.0 → v1.2.0), tests badge (99 → 386),
added YouTube + captions-first description, parallel transcribe /
queue offline behaviour / feed-fail diagnosis / NAT64 SSRF unwrap /
humanised exit codes / startup fingerprint to the feature list, and
a new "CLI" section with the 23-command table + an example agent task
chain (jq-piped JSON).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QHBoxLayout's implicit contentsMargins aren't always identical across
instances (Qt sets them from the parent layout's spacing settings,
which can drift when the toolbar is built in different files /
construction orders). Explicitly set (0,0,0,0) on every top-toolbar
HBoxLayout in Shows, Queue, and Failed so the first button's x-
position lines up exactly across all three pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@madevmuc madevmuc merged commit 9d5a30b into main Apr 23, 2026
1 check passed
@madevmuc madevmuc changed the title Ship v1 Ship v1.2 Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant