<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Phillip</title>
    <description>The latest articles on DEV Community by Phillip (@phillipmex).</description>
    <link>https://dev.to/phillipmex</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4032939%2F292d5af0-ce58-4ad7-ab7b-d5e813d941db.png</url>
      <title>DEV Community: Phillip</title>
      <link>https://dev.to/phillipmex</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/phillipmex"/>
    <language>en</language>
    <item>
      <title>My YouTube channel ran unattended for 8 days. The two hard problems weren't the ones I expected.</title>
      <dc:creator>Phillip</dc:creator>
      <pubDate>Sat, 08 Aug 2026 14:56:59 +0000</pubDate>
      <link>https://dev.to/phillipmex/my-youtube-channel-ran-unattended-for-8-days-the-two-hard-problems-werent-the-ones-i-expected-bpk</link>
      <guid>https://dev.to/phillipmex/my-youtube-channel-ran-unattended-for-8-days-the-two-hard-problems-werent-the-ones-i-expected-bpk</guid>
      <description>&lt;p&gt;I automated a faceless YouTube channel end to end on a home Windows PC: idea file in, published&lt;br&gt;
video out. It shipped 156 videos across 4 pipeline variants, and its longest streak with zero human&lt;br&gt;
input was 8+ days. No cloud, no subscriptions — local LLM (Ollama) or an Anthropic API key for&lt;br&gt;
scripts, Piper TTS for narration, Remotion for rendering, the YouTube Data API for uploads, and&lt;br&gt;
Windows Task Scheduler holding it all together.&lt;/p&gt;

&lt;p&gt;Going in, I assumed the hard parts would be script quality and rendering. Both turned out to be&lt;br&gt;
commodity problems. The two that actually cost me weeks were &lt;strong&gt;audio/visual timing&lt;/strong&gt; and&lt;br&gt;
&lt;strong&gt;YouTube's quota system&lt;/strong&gt; — and almost nothing written about "YouTube automation" mentions either.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 1: TTS timing is the difference between a video and a slideshow
&lt;/h2&gt;

&lt;p&gt;The naive pipeline everyone builds first: generate a script, synthesize the whole narration as one&lt;br&gt;
audio file, divide the visuals evenly across its duration. The result instantly reads as robotic —&lt;br&gt;
images change mid-sentence, a visual lingers while the narrator has moved on two ideas ago.&lt;/p&gt;

&lt;p&gt;The fix is structural: stop treating narration as one blob. My scripts are generated as &lt;strong&gt;beats&lt;/strong&gt; —&lt;br&gt;
one idea, one visual, one narration chunk. This is the actual type from the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pipeline/lib/types.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;NarrationBeat&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// one sentence/clause of narration&lt;/span&gt;
  &lt;span class="nl"&gt;imageQuery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// image search query for the fact/subject of this beat&lt;/span&gt;
  &lt;span class="c1"&gt;// Optional animated-board spec (generated by 1-generate-script.ts for&lt;/span&gt;
  &lt;span class="c1"&gt;// Connect-Four-topic videos only; schema lives in render/src/HobbyVideo.tsx&lt;/span&gt;
  &lt;span class="c1"&gt;// boardSpecSchema). When set, the renderer draws the board instead of this beat's photo.&lt;/span&gt;
  &lt;span class="nl"&gt;board&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audio stage then synthesizes each part &lt;em&gt;separately&lt;/em&gt;, measures the real duration of every&lt;br&gt;
generated WAV, and only then concatenates — so the render stage works with what the audio actually&lt;br&gt;
does, not an estimate. The source comment states the intent plainly: parts are "synthesized as&lt;br&gt;
separate clips and concatenated, so we get each part's &lt;em&gt;real&lt;/em&gt; spoken duration for stage 3 instead&lt;br&gt;
of estimating it from text length."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pipeline/2-generate-audio.ts&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Synthesizing &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; parts)...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;partPaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-part-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.wav`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runPiper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;partPaths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;piperModel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;partDurationsSec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partPaths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ffprobeDuration&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;concatWavs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partPaths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wavPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;partPaths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Remotion composition consumes those measured durations: each beat's visual holds exactly as&lt;br&gt;
long as its narration plays, transitions land on beat boundaries, and captions are baked in against&lt;br&gt;
the same timing data. Nothing is synced "by feel" — the timeline is derived from the audio that&lt;br&gt;
will actually play.&lt;/p&gt;

&lt;p&gt;Two practical notes if you build this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure the WAV, don't trust the synthesizer.&lt;/strong&gt; Note the measurement is &lt;code&gt;ffprobeDuration&lt;/code&gt; —
ffprobe reading the real file, not Piper's own output metadata. Piper is local and fast enough
that per-part synthesis costs nothing, and measuring the artifact you'll actually play removes a
whole class of drift bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-beat synthesis makes localization nearly free.&lt;/strong&gt; My pipeline optionally builds a Spanish
twin of every video: translate beats, re-synthesize with a Spanish voice, reuse the visuals, and
the chapter markers recompute from the new durations — frame-accurate, automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Problem 2: YouTube's API quota shapes your entire architecture
&lt;/h2&gt;

&lt;p&gt;The YouTube Data API gives you 10,000 quota units per day by default, and per Google's documented&lt;br&gt;
costs a single &lt;code&gt;videos.insert&lt;/code&gt; is &lt;strong&gt;1,600 units&lt;/strong&gt; — six uploads a day before you've spent a unit on&lt;br&gt;
anything else. My pipeline doesn't do unit accounting (I tried; a &lt;code&gt;--limit N&lt;/code&gt; flag plus treating&lt;br&gt;
quota errors as fatal turned out to be simpler and just as effective). But quota still bent the&lt;br&gt;
whole architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate detection is mandatory, not defensive.&lt;/strong&gt; A retry after an ambiguous failure can
double-post. Before uploading anything, the pipeline pulls every live title on the channel — via
the uploads playlist, since that's the cheap way to enumerate your own videos:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// pipeline/7-upload-youtube.ts&lt;/span&gt;
  &lt;span class="c1"&gt;// Fetch every live video title on the channel so we can refuse to upload&lt;/span&gt;
  &lt;span class="c1"&gt;// anything that looks like a duplicate of a video that's already public.&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchLiveTitles&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channelRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;youtube&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contentDetails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;mine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadsPlaylistId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;channelRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;contentDetails&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;relatedPlaylists&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;uploads&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;uploadsPlaylistId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The match is a fuzzy title-overlap score with a 0.7 threshold, not an exact comparison — a&lt;br&gt;
  re-rendered video whose title shifted by a word must still be refused.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never retry blindly.&lt;/strong&gt; An escalating retry ladder against a quota error is a machine for
converting one failure into a day of failures. In the uploader, quota errors kill the run on the
spot and the queue resumes tomorrow:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// pipeline/7-upload-youtube.ts&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Quota exceeded — stop immediately, don't burn retries&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quota&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`\nQuota/auth error: [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Quota exceeded after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; uploads. Re-run tomorrow to resume.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watch for the undocumented ceilings.&lt;/strong&gt; Uploading a video and setting its thumbnail are separate
calls with separate limits — and thumbnail updates hit an account-level cap far lower than the
documented unit math suggests (I measured roughly a dozen per day before hard rejections). I
found that one in production logs, not documentation. Assume every write endpoint has a second,
quieter limit and let your scheduler respect it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The rule that made unattended operation safe
&lt;/h2&gt;

&lt;p&gt;Eight days of hands-off uploads sounds like the scary part, but it was governed by the most boring&lt;br&gt;
component in the system: a QA gate. Nothing uploads until I've watched the rendered video and&lt;br&gt;
dropped a &lt;code&gt;qa-approved.txt&lt;/code&gt; into its job folder. The uploader hard-skips anything unapproved —&lt;br&gt;
every run, forever, until a human puts the file there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pipeline/7-upload-youtube.ts&lt;/span&gt;
&lt;span class="c1"&gt;// QA gate: a video only enters the upload queue after a manual watch-through&lt;/span&gt;
&lt;span class="c1"&gt;// (see PRODUCTION.md) — the reviewer drops qa-approved.txt into the job dir.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qaFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qa-approved.txt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qaFile&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — awaiting manual QA (no qa-approved.txt), skipping`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one file turns "automation posts to my channel" into "automation prepares work; I approve&lt;br&gt;
releases." Generation being automated is fine; &lt;em&gt;publication&lt;/em&gt; being automated without a human gate&lt;br&gt;
is how channels die. The unattended streak worked because approvals were queued up ahead of it —&lt;br&gt;
the machine was never deciding what my channel says.&lt;/p&gt;

&lt;p&gt;A health check runs before every scheduled cycle and verifies each credential, binary, and path the&lt;br&gt;
run will need — an OAuth token that expired overnight should fail the run in second one, loudly,&lt;br&gt;
not in stage six after an hour of rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  What automation doesn't fix
&lt;/h2&gt;

&lt;p&gt;Honest closing, because this space is full of the opposite: automation did not make the channel&lt;br&gt;
successful, and tooling never will. Niche selection, idea quality, and YouTube's recommendation&lt;br&gt;
system decide that. What the pipeline bought me was &lt;em&gt;marginal cost&lt;/em&gt; — once an idea was chosen and&lt;br&gt;
approved, production and publication cost ~$0 and ~0 minutes. That's the correct promise of&lt;br&gt;
automation: it makes shipping cheap, not popular.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I packaged this pipeline — full TypeScript source, setup guide from blank Windows PC to first&lt;br&gt;
authenticated upload, the per-video QA checklist, and my idea-sourcing method — as a one-time&lt;br&gt;
purchase: &lt;a href="https://beforeyoustart.gumroad.com/l/channel-kit" rel="noopener noreferrer"&gt;channel-kit on Gumroad&lt;/a&gt;. Self-hosted,&lt;br&gt;
your API keys, no subscription, and no income promises for the reasons above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>automation</category>
      <category>showdev</category>
    </item>
    <item>
      <title>File presence is all you need: a 7-stage video pipeline with no database and no queue service</title>
      <dc:creator>Phillip</dc:creator>
      <pubDate>Fri, 17 Jul 2026 01:59:41 +0000</pubDate>
      <link>https://dev.to/phillipmex/file-presence-is-all-you-need-a-7-stage-video-pipeline-with-no-database-and-no-queue-service-n9a</link>
      <guid>https://dev.to/phillipmex/file-presence-is-all-you-need-a-7-stage-video-pipeline-with-no-database-and-no-queue-service-n9a</guid>
      <description>&lt;p&gt;I run a video automation pipeline on a home Windows PC: idea file in, QA'd&lt;br&gt;
1080p YouTube upload out. Seven stages — script generation (Ollama/Claude),&lt;br&gt;
TTS narration (Piper), image sourcing, Remotion render, thumbnail, human QA,&lt;br&gt;
YouTube Data API upload. It has produced 156 uploads and once ran 8+ days&lt;br&gt;
unattended.&lt;/p&gt;

&lt;p&gt;Here's the part worth writing about: &lt;strong&gt;it has no database, no message queue,&lt;br&gt;
and no daemon.&lt;/strong&gt; The entire orchestration layer is the filesystem.&lt;/p&gt;
&lt;h3&gt;
  
  
  One folder per job, file presence = state
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jobs/
  connect4-tactics/
    idea.md            &amp;lt;- input
    script.json        &amp;lt;- stage 1 done
    manifest.json      &amp;lt;- stage 2 done (+ audio/*.wav)
    images/            &amp;lt;- stage 3 done
    connect4-tactics.mp4   &amp;lt;- stages 4-5 done (+ .jpg thumbnail)
    qa-approved.txt    &amp;lt;- the human said yes
    youtube-id.txt     &amp;lt;- stage 7 done; folder moves to archive/uploaded/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every stage's contract is the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;listJobs&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;outputFileFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already done — skip&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runStage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                              &lt;span class="c1"&gt;// do the work&lt;/span&gt;
  &lt;span class="c1"&gt;// the output file IS the state transition&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole scheduler. There is no &lt;code&gt;status&lt;/code&gt; column to update, so there&lt;br&gt;
is no &lt;code&gt;status&lt;/code&gt; column to be wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this buys you
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Crash safety for free.&lt;/strong&gt; Power cut mid-render? The mp4 was never written,&lt;br&gt;
so the next scheduled run re-renders that job and skips everything else.&lt;br&gt;
Recovery isn't a code path — it's the &lt;em&gt;only&lt;/em&gt; code path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redo = delete.&lt;/strong&gt; Bad narration on one video? Delete &lt;code&gt;manifest.json&lt;/code&gt; and the&lt;br&gt;
WAVs; only stage 2 re-runs. Every partial redo you'd normally build tooling&lt;br&gt;
for is a &lt;code&gt;rm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dashboard is &lt;code&gt;ls&lt;/code&gt;.&lt;/strong&gt; Any file manager shows you exactly where every&lt;br&gt;
video is in the pipeline. Debugging is opening a folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The QA gate is incorruptible by automation.&lt;/strong&gt; Upload requires&lt;br&gt;
&lt;code&gt;qa-approved.txt&lt;/code&gt;, and only a human watching the video creates that file. The&lt;br&gt;
gate isn't a boolean a bug can flip — it's a file only I write.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs you
&lt;/h3&gt;

&lt;p&gt;Honesty section: this design assumes &lt;strong&gt;one machine, one writer&lt;/strong&gt;. Two&lt;br&gt;
concurrent runs of the same stage would race (I serialize via Task Scheduler,&lt;br&gt;
which never overlaps runs). File-presence also can't express rich state like&lt;br&gt;
"attempted 3 times, failing" — my answer in the one flaky stage (network&lt;br&gt;
image downloads) is to not track attempts at all: a failed download degrades&lt;br&gt;
to a text-card visual and the pipeline keeps moving. If you need multi-node&lt;br&gt;
workers or per-job retry metadata, use a real queue; below that threshold,&lt;br&gt;
the filesystem is shockingly hard to beat.&lt;/p&gt;

&lt;h3&gt;
  
  
  The health check
&lt;/h3&gt;

&lt;p&gt;The other half of unattended reliability: a &lt;code&gt;check-health.ts&lt;/code&gt; that verifies&lt;br&gt;
every credential, binary, model and token &lt;em&gt;before&lt;/em&gt; a scheduled run touches&lt;br&gt;
anything — expired OAuth tokens are found by the health check at 6am, not by&lt;br&gt;
the upload stage at 2am.&lt;/p&gt;




&lt;p&gt;I've packaged the whole pipeline (TypeScript source, setup + production&lt;br&gt;
docs) as a one-time self-hosted kit: &lt;a href="https://beforeyoustart.gumroad.com/l/channel-kit" rel="noopener noreferrer"&gt;https://beforeyoustart.gumroad.com/l/channel-kit&lt;/a&gt;. No revenue promises —&lt;br&gt;
it's infrastructure, not a business model. But if you've ever wanted to see&lt;br&gt;
what "boring tech" looks like applied to video automation, the source is the&lt;br&gt;
best argument I have.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>architecture</category>
      <category>automation</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
