Podcast2Article is an open-source Node.js app that turns a public Spotify podcast episode, YouTube video, Fathom recording, or Google Meet recording into:
- a transcript with speaker labels and timestamps;
- a clear blog article that retains the recording's distinctive style;
- verifiable source links from each article paragraph to the relevant transcript passage and audio timestamp.
Completed articles appear automatically on the /articles page, newest first.
You can mark articles as read and undo that choice; the status is stored locally with the job.
Queued and processing jobs appear at the top with their current stage and progress.
This part of the overview refreshes automatically.
The Articles menu badge counts newly completed articles from followed series since your last successful overview visit.
Opening the overview clears the badge without marking articles as read.
The server stores this visit per account across devices; your first visit establishes a baseline without flagging the existing backlog.
Unknown page URLs redirect to /articles after authentication; logged-out visitors still go to /login.
Unknown API routes and invalid shared links remain errors.
The public health endpoint is GET /api/health.
Source references open a dialog alongside the article, showing the relevant transcript passage and audio from the selected timestamp. Closing the dialog or pressing Escape pauses the audio and returns you to the same reference. Shared articles expose only playback controls; the private transcript remains protected. Table-of-contents links preserve both the article and section, including after a refresh. On desktop, the contents sidebar highlights the section currently being read in owner and shared articles.
Audio is not downloaded from Spotify. The app uses the Spotify link to identify the episode, then finds the same episode through the public Apple Podcasts index and the original public audio source. For public YouTube videos, it downloads only the best available audio stream. Playlists, active livestreams, and videos requiring sign-in are not processed. Google Meet recordings are retrieved through a public Google Drive link. The app creates a compact local audio version for reliable playback and timestamp links; the original video file is deleted after processing.
For a Meet recording, paste the recording file's Drive link, such as https://drive.google.com/file/d/.../view.
Set general access in Drive to Anyone with the link and allow viewers to download the file.
A meet.google.com/... meeting-room link does not contain a recording file and is therefore rejected.
For Fathom, use the public share link https://fathom.video/share/....
Copy it through Share and choose Anyone with the link.
Internal fathom.video/calls/... links require sign-in and are rejected.
The app uses yt-dlp to retrieve the recording, then creates the same local audio and transcript files as it does for Drive.
Existing Fathom summaries and transcripts are not imported.
No Fathom API key is needed; cookies, private recordings, and team-restricted access are not supported.
Downloads are subject to MAX_RECORDING_MB and MEDIA_DOWNLOAD_TIMEOUT_MS.
The compact login page links to an example article about engineering and management in a new tab. The existing public permalink provides the complete English article and source playback without signing in or starting new processing. The link and explanation follow the visitor's Dutch or English language preference.
The example links to the production article at reads.rogierslag.nl.
Deleting that article disables the permalink; preserve it or update the example link.
GET /robots.txt is public and blocks crawling everywhere except the exact /login URL and the example article linked there.
All other responses carry X-Robots-Tag: noindex, nofollow, including owner pages, other permalinks, APIs, audio, assets, and URLs with query parameters.
The example article omits the shared reader's noindex metadata only at its exact URL; missing or deleted examples remain non-indexable.
Keep the example link, the server's exampleArticlePath, and the allow rule in public/robots.txt in sync when replacing it.
These are crawler directives, not access controls; non-compliant crawlers can still access public permalinks.
A URL already listed by a search engine may require removal through that engine because blocked crawling prevents it from reading new noindex directives.
The brand guidelines describe the visual identity, typography, colors, interactions, and intended use of rounded corners. Use them alongside AGENTS.md when changing the interface.
The user journeys describe what people want to achieve, which features support those outcomes, and how we can test their value. The accessibility review records the screens checked, issues found, and remaining limitations.
Requirements: Node.js 24+, Yarn 1.22.22, Python 3.11+, and an OpenAI API key. FFmpeg and yt-dlp are bundled as Node dependencies. yt-dlp uses Python on macOS and Linux. PDFs are generated directly in Node.js; no server-side browser is required.
Set FFMPEG_BIN to the absolute path of a separately installed FFmpeg executable to override the bundled binary.
The production installer installs a pinned FFmpeg/ffprobe build on Linux x64 with SHA-256 verification.
An existing 90-ffmpeg-override.conf is preserved; activating a different version is an explicit, reversible administrative action.
With the current infrastructure updater installed, every new release runs a real media test before activation.
Application pushes do not update that host script.
See the management and rollback runbook and the incident report.
Use Yarn 1.22.22 for dependency installation and scripts.
The version is pinned in package.json; yarn.lock is the dependency lockfile.
If Yarn is missing, install it once with npm install --global yarn@1.22.22.
npm is only used to bootstrap package-management tooling, including Corepack on the production host.
Do not generate a package-lock.json.
yarn install --frozen-lockfile
OPENAI_API_KEY='your-key' yarn run devThen open http://localhost:3000. The key stays in the process and is not persisted by the app.
To run the compiled application locally:
cp .env.example .env
# Set OPENAI_API_KEY in .env; configure APP_USERS before exposing the app.
yarn run build
yarn startFor a managed production host, follow the deployment guide, which uses systemd and /etc/podcast2article.env instead of a release-local .env.
For a public installation, configure user accounts as JSON in the applicable environment file.
Each password must contain at least 16 characters.
Sign-in uses a signed HttpOnly cookie valid for 30 days, which is automatically invalidated when the account configuration changes:
APP_USERS='{"rogier":"a-long-unique-password","john_appleseed":"another-unique-password"}'When upgrading an older installation, configure APP_USERS before deployment: the former single-password setting no longer enables authentication.
Leaving APP_USERS unset or blank disables authentication for local development.
The example accounts above are rogier and John Appleseed (john_appleseed).
Always put production installations behind HTTPS, for example through Caddy or Nginx.
Sign-in allows five failed attempts per IP address in each fifteen-minute window, then returns 429 until that window resets.
Successful sign-ins do not consume that quota; concurrent attempts count until verification finishes.
All routes also share a default limit of 600 requests per minute per IP address, including public assets, audio, API routes, and OpenAI webhooks.
Set REQUEST_RATE_LIMIT_PER_MINUTE to a positive integer up to 1,000,000 to adjust this quota; the failed-login limit remains unchanged.
Rate-limited responses include Retry-After and are not cached.
These limits live in memory, reset on restart, and apply per server process; IPv6 clients are grouped by /56 subnet.
Passwords are verified asynchronously using salted scrypt (N=32768, r=8, p=3); APP_USERS remains an operator-managed plaintext secret.
Existing session cookies remain valid when restarting with unchanged accounts.
For regional OpenAI processing in the EU or US, set OPENAI_REGION=eu or OPENAI_REGION=us respectively in .env.
yarn start reads the variables from that file:
OPENAI_REGION=euflowchart LR
Sources["Public Spotify, YouTube,<br/>Drive or Fathom link"] --> Queue
RSS["Followed podcast RSS feed"] --> Queue["Per-user job"]
Queue --> Resolve["Resolve or reuse source metadata<br/>and public media location"]
Resolve --> Audio["Download and prepare<br/>audio with FFmpeg"]
Audio --> Transcript["Transcribe with speakers<br/>and timestamps"]
Transcript --> Article["Generate article<br/>with source references"]
Article --> Library["Read, verify, export<br/>or share"]
See the service flow diagrams for request validation, processing stages, failure recovery, podcast subscriptions, budget enforcement, and the boundary between owner access and public permalinks.
Jobs are stored per user as JSON in data/users/<username>/jobs/.
Compact playback audio is stored in data/users/<username>/media/; source files and chunks are removed after their replacement results are persisted.
Users can access only their own jobs, articles, transcripts, and audio through owner routes.
Public permalinks separately grant access to one completed article and its audio.
Unfinished jobs restart automatically after a server restart with the same job ID.
Up to three jobs prepare media or transcribe concurrently, with three separate article slots.
Downloads and FFmpeg run one at a time; transcription and article generation can overlap with other jobs.
Source metadata is fetched separately, with up to three concurrent requests, so titles and images already appear in the queue.
Incomplete jobs reuse saved source audio, chunk transcripts, complete transcripts, and background article response IDs.
A hard crash can still lose an in-flight transcription or an article submission whose response ID was not saved.
Article generation defaults to gpt-6.1-sol with low reasoning effort and Flex processing.
The same model and effort apply to new quote-repair requests.
ARTICLE_MODEL overrides the default; other models retain their existing reasoning behavior.
Existing deployments that pin a model must update their environment setting to select 6.1 Sol.
Transient failures (including capacity errors and timeouts) receive up to three Flex attempts, then up to three standard-processing attempts with service_tier: "default".
HTTP failures and recognized transient background failures share this attempt limit, including quote repairs.
Background retries require a confirmed failed response with a server, overload, capacity, or rate-limit error code; unknown errors, cancellations, and incomplete output stop without a new submission.
Retries use exponential backoff and respect provider retry headers.
Cancellation and permanent errors stop immediately, including HTTP 429 responses that identify exhausted credits or spend/usage limits.
Set ARTICLE_SERVICE_TIER=default to use standard processing from the first attempt.
Transcription retains its existing three-attempt policy.
Each article submission uses OPENAI_ARTICLE_TIMEOUT_MS (10 minutes by default); background generation continues independently and is polled until a terminal status is received.
Budget checks apply before every attempt and can stop processing before fallback.
Actual reported tiers determine prices; unknown outcomes retain unknown costs and their budget reservations.
Each new job stores API usage in apiUsage in the same JSON file.
For each transcription chunk and article request, it records the model, requested and reported service tier, request ID, duration, usage figures, and attempts.
Automatic retries each get their own record.
Usage from a successful API request is retained even when the resulting article content is rejected.
knownEstimatedCostUsd sums known USD estimates and is stored with the article in the job JSON.
These amounts are not recalculated against the current price table when loaded.
unknownCostRequests counts attempts with unknown costs.
A missing amount is null, not zero.
New estimates use prices checked on 4 October 2026: reported audio duration for gpt-4o-transcribe-diarize, and tokens, cache breakdown, context length, reported service tier, and any regional surcharge for gpt-5.6-terra, gpt-5.6-sol, and gpt-6.1-sol.
Other models and custom API endpoints still record usage but receive no cost estimate.
Prices are in src/services/api-usage.ts; each estimate stores the prices and source used, so historical amounts do not change when prices are updated.
According to OpenAI, promotional pricing for gpt-5.6-sol applies until at least 21 November 2026.
Existing jobs are not automatically repriced.
These are API cost estimates, not invoice amounts.
Hosting, downloads, and FFmpeg costs are excluded.
Historical costs remain unchanged in the ledger: missing apiUsage means unknown, and a new attempt on such a job sets coverage to partial.
After a hard stop, an attempt may remain pending with unknown costs.
Usage data is available through the owner's job and account summary, never through public links or saved copies.
The 4 October cost evaluation supports the 6.1 Sol default with production pricing scenarios, a six-transcript model comparison, and a forty-draft prompt comparison with blinded Ultra grading. Applying 6.1 Sol rates to the observed production usage estimates $0.3935 per article, including transcription, versus $0.4813 with 5.6 Sol; the 18.2% reduction is a fixed-usage estimate rather than a measured rollout result.
Each account has a USD 5 processing limit over the preceding 30 days. Historical requests made before budget enforcement are excluded from this allowance, including known historical costs. New requests reserve budget before sending; confirmed costs replace reservations, while uncertain outcomes retain them. Reservations can block work before actual estimates reach USD 5. Article output is capped at 16,384 tokens.
Open Usage in the account footer to see estimated 30-day spend, excluded historical costs, reserved budget and the remaining allowance.
Operators can set SPENDING_LIMIT_EXEMPT_USERS=rogier (comma-separated exact usernames) to exempt accounts while continuing to track their costs.
Limited accounts cannot use models or endpoints without verified reservation pricing.
See the budget runbook for configuration, restart and revocation behavior.
Deployments pause new transcription requests and article submissions, wait for active results to be saved, then restart and resume processing.
Draining is capped at 15 minutes; a timeout cancels deployment, keeps the current release running, and resumes admission.
Completed chunks and complete transcripts are reused after restart.
Article generation runs in OpenAI background mode; persisted response IDs allow retrieval to continue after deployment, with optional signed webhooks to wake the five-second poller.
Direct SIGINT or SIGTERM still cancels active HTTP connections within a 15-second shutdown deadline; it does not cancel remote background articles.
See deployment recovery for the first-rollout procedure, failure handling, retention, and remaining crash risks.
The browser reports LCP, CLS, and INP through the bundled web-vitals dependency.
Measurements are stored locally for later analysis; no third-party analytics service receives them.
Use yarn run report:web-vitals after building to inspect sample counts and p75 by page type, viewport category, release, and navigation type.
See Core Web Vitals monitoring for collection, storage, privacy, and measurement limits.
Article actions create a stable anonymous permalink. Recipients can read and play source audio without an account; signed-in recipients can save an independent copy in their own library. Deleting the original disables its permalink while preserving previously saved copies. Public pages never expose the owner's identity, private transcript, read state, API costs, or usage statistics.
Shared pages skip monitoring when the browser declares WebDriver automation.
Other visits record a load only after two consecutive seconds in a visible tab.
An estimated read requires 30 seconds of visible, active reading and at least 90% scroll progress.
Counts survive restarts and are available to the owner through GET /api/jobs/:id/share-stats and in Shared link activity at the bottom of the owner article.
The footer shows shared loads and estimated shared reads, including zero counts, and offers a retry when statistics are unavailable.
These count page visits, not unique people or confirmed comprehension.
They do not change Mark as read in the owner's library.
See shared article monitoring for API examples, privacy, deduplication, and measurement limits.
Set PUBLIC_BASE_URL to the canonical external origin for permalink and social-preview URLs in production.
The interface follows the primary browser language: Dutch (nl, nl-NL, nl-BE, and related variants) uses Dutch text; all other languages fall back to English.
This also applies to error messages, dates, and fixed labels in PDF exports.
The language setting for article generation is independent.
Articles and transcripts are not translated again when the interface language changes.
Shared translations live in src/shared/i18n.ts, using semantic keys such as article.delete and nav.articles rather than Dutch text as keys.
Tests automatically check all HTML templates and browser modules for missing translations, including accessibility labels and singular/plural forms.
The server uses Accept-Language for the initial HTML response; browser requests include the selected interface language.
Refresh the page after changing the browser language.
| Variable | Default | Meaning |
|---|---|---|
OPENAI_API_KEY |
required for processing | OpenAI API key supplied through the process environment or environment file |
REQUEST_RATE_LIMIT_PER_MINUTE |
600 |
Requests per minute per IP across all routes; positive integer up to 1,000,000 |
APP_USERS |
empty | JSON account map; unset or blank disables authentication |
SPENDING_LIMIT_EXEMPT_USERS |
empty | Comma-separated usernames exempt from spending limits; usage stays tracked |
OPENAI_REGION |
global |
OpenAI API region: global, eu (EEA + Switzerland), or us |
OPENAI_BASE_URL |
region-selected endpoint | Advanced endpoint override; custom endpoints have no verified cost estimate or reservation pricing |
HOST |
127.0.0.1 |
Network interface; consider 0.0.0.0 only inside a container |
PUBLIC_BASE_URL |
request origin | Canonical external origin for permalinks and social previews |
PORT |
3000 |
HTTP port |
ARTICLE_MODEL |
gpt-6.1-sol |
Article generation and quote-repair model; 6.1 Sol uses low reasoning effort |
ARTICLE_SERVICE_TIER |
flex |
Article tier: flex (three attempts, then standard fallback) or default |
TRANSCRIPTION_MODEL |
gpt-4o-transcribe-diarize |
Transcription model |
MAX_AUDIO_MB |
500 |
Maximum Spotify/RSS audio download size |
MAX_YOUTUBE_MB |
500 |
Maximum YouTube audio download size |
MAX_RECORDING_MB |
1500 |
Maximum Google Drive or Fathom recording download size |
YOUTUBE_METADATA_TIMEOUT_MS |
60000 |
YouTube metadata timeout (1 minute) |
MEDIA_DOWNLOAD_TIMEOUT_MS |
900000 |
Media download timeout (15 minutes) |
FFMPEG_BIN |
bundled binary | Absolute path to an alternative FFmpeg executable for normalization, splitting, and Fathom postprocessing |
OPENAI_WEBHOOK_SECRET |
unset | Optional signature secret for background article notifications at /hooks/openai; polling works without it |
AUDIO_CHUNK_SECONDS |
300 |
Audio chunk length (5 minutes; allowed range: 60–1200) |
OPENAI_TRANSCRIPTION_TIMEOUT_MS |
600000 |
Timeout per transcription chunk (10 minutes) |
OPENAI_ARTICLE_TIMEOUT_MS |
600000 |
Background article submission timeout (10 minutes) |
LOG_STACKS |
false |
Show full error stacks in the CLI |
Download limits are independent: MAX_AUDIO_MB applies to Spotify/RSS, MAX_YOUTUBE_MB to YouTube, and MAX_RECORDING_MB to Drive and Fathom.
The former MAX_MEDIA_MB setting is ignored, and YouTube no longer inherits MAX_AUDIO_MB.
Set each applicable limit explicitly when upgrading.
Existing article files remain readable; older source fields are normalized when loaded.
For each job, the CLI reports source resolution, download and FFmpeg duration, chunk sizes, OpenAI start and completion times, and a heartbeat every 30 seconds while an OpenAI request is running. API keys and transcript content are not logged.
OPENAI_REGION selects the OpenAI API endpoint for both transcription and article generation.
Regional data residency must also be configured for the OpenAI project in use and depends on the selected models and features.
If only article generation fails and the transcript is already complete, the existing transcript can be reused without new audio or transcription costs:
curl -X POST 'http://localhost:3000/api/jobs/<job-id>/retry-article'Replace <job-id> with the failed job's ID.
This example assumes local mode without authentication; authenticated installations also require the owner's session cookie.
The interface sends that cookie when using its retry action.
This is available only for failed jobs, with at most two attempts per job in addition to the original generation.
Every accepted attempt counts, including failed attempts.
The counter is saved before paid work starts and survives restarts.
Completed articles and jobs that have reached the limit return 409; their content and read status remain intact.
For older jobs without a counter, recorded article operations count as previous attempts. The initial generation is subtracted only when usage history is complete; partial histories count all known operations. Automatic API retries within the same operation count together as one attempt. Without recorded history, the counter starts at zero; unknown earlier attempts cannot be reconstructed. This limit applies to owner-requested article regeneration. New jobs and restart recovery do not consume this allowance.
The length selector shows target word counts: compact (700–1,000), standard (1,100–1,700), and extended (1,800–2,600). These are generation guidelines, not guaranteed counts. The same source can be processed again in a different language or length. Only an existing or active job with the same source, language setting, and length counts as a duplicate. Automatic language detection remains a separate choice from an explicit language.
The article prompt asks the writer to cover the transcript's main topics, including defining examples and qualifications, within the chosen length. The coverage evaluation explains the offline comparison behind these instructions and the remaining quality limits.
- Public
open.spotify.com/episode/...links, YouTube video, Shorts, and completed livestream links, public Fathom share links, and Google Drive links to a single public audio or video file are accepted. - The episode must also appear in a public podcast index or RSS source. Spotify exclusives do not work.
- Titles that differ substantially between Spotify and the RSS source cannot be matched automatically; the app deliberately selects no source when uncertain.
- YouTube playlists, active or scheduled livestreams, private videos, and videos requiring sign-in are not supported.
- A Drive recording must be accessible to anyone with the link and allow downloads. Recordings restricted by Workspace policy deliberately do not work without Google authentication.
- Meet room, Drive folder, and Google Calendar links do not point directly to a recording file and therefore do not work.
- Speaker labels may change between long audio chunks. Text and timestamps remain linked.
- Transcription and rewriting can introduce errors. Timestamp links make it easier to check articles before publication.
Use only recordings you are legally permitted to process. A public link does not automatically grant permission to commercially republish a full transcript or derived article. Respect copyright, image rights, privacy, licenses, and the source's terms. Credit and link to the original recording.
GitHub Actions automatically runs formatting checks, ESLint, the TypeScript build, and all tests on every pull request and push to main.
These checks run as seven independent jobs, so a failure in one check does not prevent the others from reporting results.
The main-branch ruleset requires all seven checks, including Browser regressions (Chromium) and Browser regressions (WebKit), directly.
The build job type-checks server, browser, and shared application code as well as TypeScript tests.
The workflow can be started manually through Actions → Tests → Run workflow.
It uses the Node.js version from .nvmrc and installs dependencies with the existing yarn.lock.
Yarn package downloads are cached by the lockfile; every job still performs a frozen-lockfile install, including package install scripts.
The workflow uses a standard Linux runner, which is free for public repositories. These checks require no repository secrets or paid API calls. Runs time out after 15 minutes; a new run on the same branch or pull request cancels the previous run.
yarn test
yarn run typecheck
yarn run check
yarn playwright install chromium webkit
yarn run test:browser
yarn run check:mediayarn run typecheck checks the server and shared TypeScript in src/, including tests, and the browser TypeScript in public/ without emitting files.
Both configurations reject unused imports, declarations, and parameters so dead code does not accumulate.
They also check return paths, switch fallthrough, overrides, unreachable code, labels, type-only imports, isolated compilation, side-effect imports, filename casing, and dependency declarations.
Type-aware ESLint rejects explicit any, non-null assertions, unhandled promises, invalid await expressions, and unnecessary type assertions in application code and TypeScript tests.
Production code additionally rejects unsafe assignments, arguments, calls, property access, and return values; these rules exclude test fixtures because Vitest matchers and mock calls expose library-provided any values.
Exact optional-property checking remains disabled because current persistence helpers use undefined to clear optional state and API schemas also permit it; enabling it requires a separate review of those contracts.
yarn run build uses tsconfig.build.json to compile the application while keeping tests out of dist/, then builds the browser assets.
yarn run build:client compiles, bundles, and minifies the frontend TypeScript into JavaScript with esbuild and minifies and prefixes CSS with Lightning CSS.
The browser baseline is Chrome/Edge 109, Firefox 121, and Safari/iOS 16.4 or newer; the targets cover JavaScript syntax and CSS transformations, without adding runtime API polyfills.
Firefox 121 is required by the existing :has() selectors.
Generated files live in dist/client/, with rewritten HTML templates in dist/client-templates/; readable TypeScript frontend sources remain in public/.
Shared translations, source-link helpers, and API response schemas live in src/shared/.
Browser schemas validate incoming JSON and reuse the domain types in src/types.ts; esbuild compilation does not replace TypeScript type-checking.
Compatible unversioned JavaScript assets are compiled into dist/client-legacy/ so existing asset URLs keep working; TypeScript source files are not served.
Built pages use content-hashed /assets/ URLs with one-year immutable caching and precompressed Brotli/gzip variants selected through Accept-Encoding.
Shared HTML revalidates on each request so a deployment does not leave cached pages pointing at old asset hashes.
Only JavaScript and CSS listed in the build manifest are public under /assets/; templates, manifests, and compression sidecars cannot be fetched directly.
Built assets share the same global request quota as other routes; their handler stays on the main Express app so static analysis can see that boundary.
Shared assets do not change authentication requirements for owner APIs or article data.
Development through yarn run dev builds browser assets before starting the server and watches public/ and src/.
Frontend or shared-source changes stop the server, rebuild the client, and restart the server with the new manifest and templates; other server-source changes only restart the server.
A failed client build leaves the server stopped until the next edit succeeds, so pages cannot reference partially rebuilt assets.
Both development and the compiled server serve the built JavaScript and templates.
Starting the server without a client build fails visibly rather than serving uncompiled TypeScript.
Rebuild after editing frontend files before running the compiled server.
yarn run check runs type-checking and builds alongside formatting, lint, and tests.
Desktop Chromium and mobile WebKit run in separate browser jobs, each with one worker and its own disposable application server and data.
Each installs only its required browser; Chromium uses the headless shell without downloading the headed browser.
Browser binaries are cached separately by browser, runner platform, and Playwright browser revisions.
System libraries are still installed on every fresh runner, and the installer verifies the required browser binaries after restoration.
Both run the full suite for their project and retain HTML reports, screenshots, and failure traces for 14 days in browser-regressions-desktop-chromium and browser-regressions-mobile-webkit artifacts.
The media job processes a synthetic recording with Ubuntu's FFmpeg through an explicit FFMPEG_BIN setting.
It reuses /usr/bin/ffmpeg when available and installs it otherwise.
Browser and media tests run separately from yarn run check.
Production deployments build the release and run a media preflight with the installed runtime; they rely on CI before merging for formatting, lint, full type checks, and tests.
- Known bugs use Playwright's
test.failand are listed separately in the Actions report. A green workflow does not mean these bugs are fixed. Once such a test passes, the suite fails until the annotation is removed. - Mobile WebKit does not replace a physical iPhone. Native share sheets, focus zoom, status-bar taps, and Home Screen mode still require device checks.
- Browser tests block external requests, use fallback fonts, and mock some API responses. They do not compare pixels against previous screenshots. Server tests check the real API and storage boundaries separately.
- The media job checks the conversion pipeline with Ubuntu's FFmpeg, not the exact production executable or every codec.
The download in
deploy/ffmpeg-release.jsonwas unavailable during verification (HTTP 404); repairing it and verifying the production binary remain separate work. - Offline tests do not verify external model availability or quality. A green workflow does not confirm a production deployment either; see the operational checks.
Contributions are welcome. See LICENSE for the MIT license. Repository documentation is maintained in English. The app supports both Dutch and English interfaces, with a separate choice for generated-article language.
On Series, add a Spotify show link or public RSS feed. Review the discovered series. New episodes only is selected by default; optionally include the latest three episodes. Before confirmation, the screen shows how many episodes will be fetched for catch-up. Each new processing job uses the configured paid transcription and article models.
The server checks active series at startup and every hour after that. It must keep running; no external cron job is required. The queue has three media/transcription slots and three separate article slots, while media preparation remains serial. Pause stops new checks when chosen manually; jobs already in the processing queue still complete. Resume also retrieves episodes missed during the pause, provided they are still in the feed. Missed episodes are scheduled oldest first, filling only the available slots. The remaining detected episodes are saved and processed before newly discovered episodes, including after a restart or removal from the feed. During an automatic limit pause, feed checks continue to save newly discovered episodes without scheduling paid processing. Series management warns when episodes are waiting beyond the available capacity, shows the saved backlog count and links to articles so you can make room. The count reflects the last successful check, not a live count of the publisher's feed. Expand Episodes without an article in a followed series to see saved waiting episodes and unscheduled releases since following began, rather than older archive entries. The list checks the current feed on demand, excludes episodes already completed or processing in your account, and falls back to saved waiting entries when the feed is unavailable. Each episode title is a quick link to New article, with its source and the series' saved language and article length filled in. Review those settings and submit explicitly; opening the list or a quick link never starts paid work. RSS quick links use authenticated, server-resolved episode metadata when submitted and respect the same eight-episode capacity limit. A manually paused series stays paused after submitting a single reviewed episode.
Series, skipped episodes, and episodes awaiting scheduling are stored per user in data/users/<username>/subscriptions.json.
This state is saved atomically and survives restarts.
Episodes are identified by feed URL and RSS GUID, falling back to the audio link when no GUID exists.
An already known audio link is also skipped.
Failed jobs appear with the series and are not restarted every hour.
Failed feed checks are retried automatically.
Nothing new is scheduled without OPENAI_API_KEY.
From a podcast article, choose Follow this podcast at the top or bottom. The existing confirmation page opens with new episodes only selected by default; fetching the latest three remains an explicit choice. If you already follow the series, its status appears immediately, including any pause, with a link to series management. For older Spotify articles, the feed is looked up through the stored audio link. If it is no longer in the public index, the follow status cannot be determined.
Spotify is used to find the series. When several search results exist, you choose the correct public feed. Only audio episodes in RSS 2.0 are supported; Spotify exclusives, paid feeds, and missing archive episodes without a public audio source cannot be retrieved. Feeds use the same public-network checks as other sources, with a 10 MB limit; DTDs and external XML entities are rejected.
Catch-up only offers episodes still in the feed's latest three, within the remaining capacity. It never walks backward through the archive. New feed entries published before you followed are excluded from automatic processing. For undated entries, only entries before a previously seen or saved pending episode in feed order are treated as new; when there is no known episode to compare against, they are not scheduled automatically.
A series pauses automatically at eight unread, queued, or processing episodes. Read, deleted, and failed jobs do not count. Once capacity drops below eight, a limit-paused series resumes automatically on the next startup, hourly, or API-requested check. Manually paused series require explicit resuming; resuming respects the same limit. Already scheduled jobs are not cancelled, including previously confirmed catch-up still awaiting scheduling. The latest-three action preserves a manual pause.
Optional private S3 backups retain final articles and source metadata after local completion, including article-only retries.
Configure ARTICLE_BACKUP_BUCKET, ARTICLE_BACKUP_REGION, optional ARTICLE_BACKUP_PREFIX and standard AWS credentials.
See article backups for bucket security, restart recovery, backfill, retention, restore and monthly cost estimates.
Audio, transcripts and account state need separate backups.
Optional SES emails notify the installation operator about failed jobs, series checks and deployments. Alerts are plain text, batched and deduplicated across restarts. See admin alerts for sender permissions, configuration, privacy boundaries and delivery limitations.