<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Python · Loosely Decoupled</title>
    <link>https://looselydecoupled.com/topics/python/</link>
    <description>Python for automation, tooling and cloud APIs.</description>
    <language>en-us</language>
    
    <copyright>© 2026 Emmanuel Tsouris</copyright>
    <lastBuildDate>Fri, 16 Oct 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://looselydecoupled.com/topics/python/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Why Is the uv Cache So Large on My Mac?</title>
      <link>https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/</guid>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Python</category>
      
      <description>Coding agents fill the uv cache when they launch MCP servers with uvx; prune it safely and see how much disk you really get back.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Quit every coding agent and editor that launches MCP servers, then run <code>uv cache prune</code>. A running session holds the cache lock, so prune prints &ldquo;Cache is currently in-use, waiting for other uv processes to finish&rdquo; and, after uv&rsquo;s lock timeout, gives up. Do not use <code>--force</code> to skip it. Expect much less back than prune reports: on this Mac it reported 111.1 GiB (about 119 GB) removed and the disk gained 27.5 GB, most likely because cloned files share blocks.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">prune</span></span></code></pre>
    </div>
  </div></div>
</div>

<p>My Mac was down to 38.4 GB free on a 1 TB disk. The largest directory on it was a 132.7 GiB uv cache that I had never thought about. The coding agents I use launch MCP servers with <code>uvx</code>, and that was enough to fill it.</p>
<p><code>uvx</code> is uv&rsquo;s command for running a Python tool from a temporary environment, without installing it. If you have never run uv by hand, an agent or editor ran it for you. This page is part of a series on <a href="https://looselydecoupled.com/series/developer-mac-disk-cleanup/">cleaning up a developer Mac&rsquo;s disk</a>. Part one of the series, finding what&rsquo;s filling the disk, is how to know the cache is the cause; you don&rsquo;t need it to follow the steps here.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>uv</dt> <dd>0.11.12</dd></div>
    <div class="tested__row"><dt>macOS</dt> <dd>27.0.1, Apple silicon, APFS</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
<h2 id="check-the-size-and-location-of-the-cache">Check the size and location of the cache<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#check-the-size-and-location-of-the-cache"
     aria-label="Link to section: Check the size and location of the cache" data-pagefind-ignore></a>
</h2>
<p>The environments <code>uvx</code> builds go in uv&rsquo;s cache. Ask uv for the size and the directory:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">warning:</span> <span class="console__token">`uv</span> <span class="console__token">cache</span> <span class="console__token">size`</span> <span class="console__token">is</span> <span class="console__token">experimental</span> <span class="console__token">and</span> <span class="console__token">may</span> <span class="console__token">change</span> <span class="console__token">without</span> <span class="console__token">warning.</span> <span class="console__token">Pass</span> <span class="console__token">`--preview-features</span> <span class="console__token">cache-size`</span> <span class="console__token">to</span> <span class="console__token">disable</span> <span class="console__token">this</span> <span class="console__token">warning.</span>
</span><span class="line"><span class="console__token">132.7GiB</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">dir</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">/Users/&lt;user&gt;/.cache/uv</span></span></code></pre>
    </div>
  </div></div><p>uv labels <code>uv cache size</code> as experimental, which is the reason for the warning. The number is still the one to start from. The <code>--cache-dir</code> option, the <code>UV_CACHE_DIR</code> and <code>XDG_CACHE_HOME</code> variables and the <code>tool.uv.cache-dir</code> setting move the directory, according to <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. Every command below reads the directory from <code>uv cache dir</code>, so it follows a moved cache.</p>
<h2 id="find-what-fills-the-cache">Find what fills the cache<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#find-what-fills-the-cache"
     aria-label="Link to section: Find what fills the cache" data-pagefind-ignore></a>
</h2>
<p>Environments that <code>uvx</code> builds sit in the cache&rsquo;s <code>archive-v0</code> directory. Count its entries, then count the ones that are Python environments, which have a <code>pyvenv.cfg</code> at their root:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">find</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">-maxdepth</span> <span class="console__token">2</span> <span class="console__token">-name</span> <span class="console__token">pyvenv.cfg</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span></span></code></pre>
    </div>
  </div></div><p>On this Mac, <code>archive-v0</code> held 4,005 entries, and 2,562 of them were environments. To see which MCP servers the environments belong to, count the executables with <code>mcp</code> in their names in the environments&rsquo; <code>bin</code> directories (a server named otherwise needs its own pattern):</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-d</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0/*/bin/*mcp*</span> <span class="console__token">|</span> <span class="console__token">xargs</span> <span class="console__token">-n1</span> <span class="console__token">basename</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">|</span> <span class="console__token">uniq</span> <span class="console__token">-c</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-rn</span> <span class="console__token">|</span> <span class="console__token">head</span></span></code></pre>
    </div>
  </div></div><p>Nearly all of the environments were copies of three MCP servers, with 1,150, 1,143 and 191 copies, plus 50 of a fourth. One server&rsquo;s environments held only 158 distinct sets of installed packages, and environments built on the same day had the same installed packages.</p>
<h3 id="why-each-launch-can-add-an-environment">Why each launch can add an environment<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#why-each-launch-can-add-an-environment"
     aria-label="Link to section: Why each launch can add an environment" data-pagefind-ignore></a>
</h3>
<p>An MCP server configured as <code>uvx &lt;server&gt;@latest</code> asks uv to resolve the latest version on every launch. With <code>uvx -v</code>, the log shows uv fetching the latest version and revalidating the index. Launching one server did not explain more than a thousand copies, so I measured when a launch adds an environment:</p>
<div class="table-wrap" data-scrollable data-label="Table: Launches">
  <table>
    <thead>
      <tr><th scope="col">Launches</th><th scope="col">Resolution</th><th class="align-right" scope="col">Environments added</th></tr>
    </thead>
    <tbody>
      <tr><td>One</td><td>New (not cached yet)</td><td class="align-right">1</td></tr>
      <tr><td>Four at the same moment</td><td>New</td><td class="align-right">4</td></tr>
      <tr><td>Four at the same moment</td><td>Already cached</td><td class="align-right">0</td></tr>
      <tr><td>Four, <code>@latest</code> and plain</td><td>Already cached</td><td class="align-right">0</td></tr>
    </tbody>
  </table>
</div>
<p>When several sessions start together and the resolution isn&rsquo;t cached yet, each launch builds its own environment. Once the resolution is cached, launches add nothing. That implies pinning a version, instead of <code>@latest</code>, would stop most of the copies. I didn&rsquo;t test that. I also didn&rsquo;t check whether a later release of uv reuses an environment that another process built moments earlier. All of this is uv 0.11.12.</p>
<h2 id="choose-between-uv-cache-prune-and-uv-cache-clean">Choose between uv cache prune and uv cache clean<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#choose-between-uv-cache-prune-and-uv-cache-clean"
     aria-label="Link to section: Choose between uv cache prune and uv cache clean" data-pagefind-ignore></a>
</h2>
<p><code>uv cache prune</code> removes the cache entries nothing uses, and all centralized project environments, which uv recreates as needed. <code>uv cache clean</code> removes every entry and empties the cache; <code>uv cache clean &lt;package&gt;</code> removes one package&rsquo;s entries. Both are described in <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>. This page uses prune, which here took the cache from 132.7 GiB to 3.9 GiB and left 540 entries in place. I didn&rsquo;t run clean.</p>
<h2 id="quit-every-session-that-holds-the-cache-lock">Quit every session that holds the cache lock<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#quit-every-session-that-holds-the-cache-lock"
     aria-label="Link to section: Quit every session that holds the cache lock" data-pagefind-ignore></a>
</h2>
<p>Each running <code>uvx</code> process holds the cache lock, the <code>.lock</code> file in the cache directory, for as long as its server runs. Cache-modifying commands wait for the lock, so a prune started now waits too. List the holders:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">COMMAND</span>   <span class="console__token">PID</span>    <span class="console__token">USER</span>   <span class="console__token">FD</span>   <span class="console__token">TYPE</span> <span class="console__token">DEVICE</span> <span class="console__token">SIZE/OFF</span>    <span class="console__token">NODE</span> <span class="console__token">NAME</span>
</span><span class="line"><span class="console__token">uv</span>      <span class="console__token">&lt;pid&gt;</span> <span class="console__token">&lt;user&gt;</span>   <span class="console__token">9u</span>   <span class="console__token">REG</span>  <span class="console__token">&lt;dev&gt;</span>        <span class="console__token">0</span> <span class="console__token">&lt;inode&gt;</span> <span class="console__token">/Users/&lt;user&gt;/.cache/uv/.lock</span>
</span><span class="line"><span class="console__token">uv</span>      <span class="console__token">&lt;pid&gt;</span> <span class="console__token">&lt;user&gt;</span>   <span class="console__token">9u</span>   <span class="console__token">REG</span>  <span class="console__token">&lt;dev&gt;</span>        <span class="console__token">0</span> <span class="console__token">&lt;inode&gt;</span> <span class="console__token">/Users/&lt;user&gt;/.cache/uv/.lock</span></span></code></pre>
    </div>
  </div></div><p><code>lsof</code> prints nothing when the lock is free; empty output from a path that is not your cache directory is not a clear lock. Each row is one <code>uvx</code> process. To find the session that started it, look up the parent of each <code>&lt;pid&gt;</code>:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ps</span> <span class="console__token">-o</span> <span class="console__token">ppid=</span> <span class="console__token">-p</span> <span class="console__token">&lt;pid&gt;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">ps</span> <span class="console__token">-o</span> <span class="console__token">comm=</span> <span class="console__token">-p</span> <span class="console__token">&lt;ppid&gt;</span></span></code></pre>
    </div>
  </div></div><p>On this Mac, one holder was launched by a Claude Code session and another by the VS Code extension host. Quitting a session or an editor ends its <code>uvx</code> children, which release the lock:</p>
<ol>
<li>Run <code>lsof &quot;$(uv cache dir)/.lock&quot;</code>.</li>
<li>Look up the parent of each <code>&lt;pid&gt;</code>.</li>
<li>Quit that session or editor.</li>
<li>Run <code>lsof</code> again until it prints nothing.</li>
</ol>
<h2 id="record-free-space-before-pruning">Record free space before pruning<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#record-free-space-before-pruning"
     aria-label="Link to section: Record free space before pruning" data-pagefind-ignore></a>
</h2>
<p>The comparison needs readings from before the prune, so take them now and save the output:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">size</span> <span class="console__token">-H</span></span></code></pre>
    </div>
  </div></div><h2 id="run-the-prune">Run the prune<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#run-the-prune"
     aria-label="Link to section: Run the prune" data-pagefind-ignore></a>
</h2>
<p>This command deletes cached environments. With nothing running, it removed every environment in the cache, not only the ones that no process referenced. The next launch of each MCP server builds a fresh environment. Run it from a plain Terminal window, not from inside one of the sessions you quit:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">prune</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Pruning</span> <span class="console__token">cache</span> <span class="console__token">at:</span> <span class="console__token">.cache/uv</span>
</span><span class="line"><span class="console__token">Removed</span> <span class="console__token">7278379</span> <span class="console__token">files</span> <span class="console__token">(111.1GiB)</span></span></code></pre>
    </div>
  </div></div><p>It took 409 seconds. Do not interrupt it.</p>
<h2 id="check-the-space-you-got-back">Check the space you got back<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#check-the-space-you-got-back"
     aria-label="Link to section: Check the space you got back" data-pagefind-ignore></a>
</h2>
<p>The free space went up by much less than 111.1 GiB. Run the same three commands again and compare them with the readings you saved:</p>
<div class="table-wrap" data-scrollable data-label="Table: Reading">
  <table>
    <thead>
      <tr><th scope="col">Reading</th><th scope="col">Before</th><th scope="col">After</th></tr>
    </thead>
    <tbody>
      <tr><td><code>df -h /</code>, available</td><td>36Gi</td><td>61Gi</td></tr>
      <tr><td>Container Free Space</td><td>38.4 GB (38,372,454,400 bytes)</td><td>65.8 GB (65,847,853,056 bytes)</td></tr>
      <tr><td><code>uv cache size -H</code></td><td>132.7GiB</td><td>3.9GiB</td></tr>
    </tbody>
  </table>
</div>
<p>The container gained 27.5 GB. uv reported 111.1 GiB removed, which is about 119 GB. uv prints binary units (GiB, 2^30 bytes) and <code>diskutil</code> prints decimal ones (GB), so do not compare the two figures as equals. Even in the same unit, the disk got back about a quarter of what uv reported.</p>
<p>The explanation that fits is the link mode; I didn&rsquo;t test it directly. uv&rsquo;s default on macOS is <code>clone</code>, per <a href="https://docs.astral.sh/uv/reference/settings/#link-mode" rel="external noopener" target="_blank">uv&rsquo;s settings reference<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>, which makes APFS copy-on-write clones. Environments share blocks with each other and with the cached wheels. uv 0.11.12 counts every cloned file at its full size, and deleting a clone frees nothing when another file still uses its blocks. <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> describes a <code>cache-physical-space</code> preview feature that accounts for copy-on-write clones; I didn&rsquo;t test it.</p>
<p><code>du -sh</code> agreed with <code>uv cache size</code> at 3.9G after the prune. That is a drop of 128.8 GiB from 132.7GiB, while prune printed 111.1GiB removed; I did not find why the two differ.</p>
<p>A local snapshot could also hold space after a delete. On this Mac, <code>tmutil listlocalsnapshots /</code> listed none, and <code>diskutil apfs listSnapshots</code> showed none on the data volume, so no snapshot was holding any.</p>
<h2 id="confirm-your-mcp-servers-still-start">Confirm your MCP servers still start<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#confirm-your-mcp-servers-still-start"
     aria-label="Link to section: Confirm your MCP servers still start" data-pagefind-ignore></a>
</h2>
<p>The prune left <code>archive-v0</code> with 540 entries and no environments, and emptied <code>environments-v2</code>. Start each agent or editor that uses MCP servers and open a session. Each server builds a fresh environment on its next launch; on this Mac, one appeared at the next session start. Check:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">find</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)&#34;/archive-v0</span> <span class="console__token">-maxdepth</span> <span class="console__token">2</span> <span class="console__token">-name</span> <span class="console__token">pyvenv.cfg</span> <span class="console__token">|</span> <span class="console__token">wc</span> <span class="console__token">-l</span></span></code></pre>
    </div>
  </div></div><p>A count above zero means at least one server built an environment. Check each agent&rsquo;s MCP server list or status view for errors.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>Paste this prompt into your own local coding agent. It measures and reports, and it proposes before anything is removed:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Investigate my uv cache on this Mac. Measure and report only; change nothing yet.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Report the cache directory (uv cache dir) and its size (uv cache size -H).
</span></span><span class="line"><span class="cl">2. Report how many entries in archive-v0 are Python environments (a pyvenv.cfg
</span></span><span class="line"><span class="cl">   at the root), and which tools they belong to (the executables in their bin
</span></span><span class="line"><span class="cl">   directories).
</span></span><span class="line"><span class="cl">3. Report free space two ways: df -h / and diskutil info / (Container Free
</span></span><span class="line"><span class="cl">   Space).
</span></span><span class="line"><span class="cl">4. List every process holding the cache lock (lsof on the .lock file in the
</span></span><span class="line"><span class="cl">   cache directory), and for each one its parent process, so I can tell which
</span></span><span class="line"><span class="cl">   agent or editor session to quit.
</span></span><span class="line"><span class="cl">5. Separate what is safe from what is in use: running uv processes, open agent
</span></span><span class="line"><span class="cl">   or editor sessions, and any project whose own virtual environment lives
</span></span><span class="line"><span class="cl">   inside the cache. If this session is itself one of the lock holders, say so
</span></span><span class="line"><span class="cl">   and stop; I will run the prune from a plain Terminal.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then propose each removal as a separate item, with the command and the current
</span></span><span class="line"><span class="cl">cache size as an upper bound. uv has no dry run, so do not state an expected
</span></span><span class="line"><span class="cl">figure, and note that APFS clones make the real gain smaller. Remove nothing until I approve that item. Remove only
</span></span><span class="line"><span class="cl">through uv&#39;s own command (uv cache prune). Never delete files under the cache
</span></span><span class="line"><span class="cl">directory by hand and never use --force. Afterwards, report free space again
</span></span><span class="line"><span class="cl">with the same two readings and say how far the figure uv printed is from the
</span></span><span class="line"><span class="cl">space actually gained.</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="cache-is-currently-in-use-waiting-for-other-uv-processes-to-finish">Cache is currently in-use, waiting for other uv processes to finish<a class="heading-anchor" href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#cache-is-currently-in-use-waiting-for-other-uv-processes-to-finish"
     aria-label="Link to section: Cache is currently in-use, waiting for other uv processes to finish" data-pagefind-ignore></a>
</h3>
<div class="console console--error"><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Error</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Cache</span> <span class="console__token">is</span> <span class="console__token">currently</span> <span class="console__token">in-use,</span> <span class="console__token">waiting</span> <span class="console__token">for</span> <span class="console__token">other</span> <span class="console__token">uv</span> <span class="console__token">processes</span> <span class="console__token">to</span> <span class="console__token">finish</span> <span class="console__token">(use</span> <span class="console__token">`--force`</span> <span class="console__token">to</span> <span class="console__token">override)</span></span></code></pre>
    </div>
  </div></div><p>Another uv process holds the cache lock, and <code>uv cache prune</code> and <code>uv cache clean</code> wait for it. Run <code>lsof &quot;$(uv cache dir)/.lock&quot;</code>, quit the session that owns each holder as described earlier, and run the command again. Nothing was changed while it waited: I stopped a prune that had waited eight seconds, and the cache was unchanged.</p>
<p>These <code>uv cache</code> commands give up after a timeout; <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv&rsquo;s cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> gives its value and the <code>UV_LOCK_TIMEOUT</code> environment variable that changes it. Waiting longer does not help while a session still runs its server.</p>
<p>Do not use <code>--force</code>. The documentation reserves it for cases where no other uv process is reading or writing the cache, and a running <code>uvx</code> server is exactly such a process.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>What&#39;s Taking Up All the Space on My Developer Mac?</title>
      <link>https://looselydecoupled.com/mac-disk-full-developer-caches/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/mac-disk-full-developer-caches/</guid>
      <pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>macOS</category>
      <category>Python</category>
      
      <description>Find which developer tool is filling your Mac&#39;s disk, such as uv, Xcode or Docker, and which cleanup applies.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Record the free space, then narrow down one level at a time with <code>du</code> until a
directory belongs to one tool. That tells you which cleanup you need. Clean it
with that tool&rsquo;s own command where it has one, never with <code>rm</code> on a cache a
tool manages, and record the free space again; the cleanup&rsquo;s own report is not what the Mac got back.</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span></span></code></pre>
    </div>
  </div></div><div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>macOS</dt> <dd>27.0.1, MacBook Pro (Apple silicon)</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>

</div>

<p>My Mac had 37.7 GB free on a 1 TB disk. I expected Xcode to be the reason,
since I keep a full set of iOS simulators, and Xcode did hold 79 GiB. The
largest directory was a 132 GiB uv cache, which I had never thought about: most
of the entries in it were environments <code>uvx</code> had built for MCP servers,
covered in part 2.
Which directory is largest on your Mac depends on what you build and which
tools you run. The method for finding it is below, and the
<a href="https://looselydecoupled.com/series/developer-mac-disk-cleanup/">series</a> covers each cause.</p>
<h2 id="record-the-free-space-before-you-start">Record the free space before you start<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#record-the-free-space-before-you-start"
     aria-label="Link to section: Record the free space before you start" data-pagefind-ignore></a>
</h2>
<p>On a modern Mac, <code>df -h /</code> reports the sealed system volume, so its <code>Used</code>
column is misleading:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">df</span> <span class="console__token">-h</span> <span class="console__token">/</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Filesystem</span>        <span class="console__token">Size</span>    <span class="console__token">Used</span>   <span class="console__token">Avail</span> <span class="console__token">Capacity</span> <span class="console__token">iused</span> <span class="console__token">ifree</span> <span class="console__token">%iused</span>  <span class="console__token">Mounted</span> <span class="console__token">on</span>
</span><span class="line"><span class="console__token">/dev/disk3s1s1</span>   <span class="console__token">926Gi</span>    <span class="console__token">13Gi</span>    <span class="console__token">40Gi</span>    <span class="console__token">24%</span>    <span class="console__token">484k</span>  <span class="console__token">423M</span>    <span class="console__token">0%</span>   <span class="console__token">/</span></span></code></pre>
    </div>
  </div></div><p><code>/</code> is the sealed system snapshot. Your files live on the Data volume in the
same APFS container. Every volume in a container draws on one pool of free
space, so <code>Used</code> for <code>/</code> counts the system alone, and <code>Avail</code> is the pool. The
container&rsquo;s own figure comes from the startup disk&rsquo;s container only, so an
external APFS drive cannot be mistaken for it:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line">   <span class="console__token">Container</span> <span class="console__token">Free</span> <span class="console__token">Space:</span>      <span class="console__token">65.8</span> <span class="console__token">GB</span> <span class="console__token">(65847853056</span> <span class="console__token">Bytes)</span> <span class="console__token">(exactly</span> <span class="console__token">128609088</span> <span class="console__token">512-Byte-Units)</span></span></code></pre>
    </div>
  </div></div><p>This reading was taken after the cleanup, so it does not match the <code>df</code>
output above, which was taken partway through, or the 37.7 GB before any
cleanup. <code>df -h</code> prints GiB and
<code>diskutil</code> prints GB, so the same moment looks different in the two units. Use one of them for every reading, or the difference
between the units looks like progress. A Mac keeps writing while you work, so
treat a change of a gigabyte or two as noise.</p>
<h2 id="find-whats-taking-up-space-with-du">Find what&rsquo;s taking up space with du<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#find-whats-taking-up-space-with-du"
     aria-label="Link to section: Find what’s taking up space with du" data-pagefind-ignore></a>
</h2>
<p><code>du</code> reads sizes and changes nothing. <code>2&gt;/dev/null</code> hides the permission
errors that protected directories print.</p>
<!-- style-check: procedure -->
<ol>
<li>
<p>List the largest directories in your home directory:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>Skip the first line, which is the total for the directory you ran it on.
Run the same command on the largest directory below it, and repeat until
the directory belongs to one tool. On this Mac, that took three levels:
<code>~/.cache</code>, then <code>~/.cache/uv</code>, then <code>~/.cache/uv/archive-v0</code>.</p>
</li>
<li>
<p>Check the two places that hide the most, even if they didn&rsquo;t top the first
list:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~/Library/Caches</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span> <span class="console__token">|</span> <span class="console__token">head</span> <span class="console__token">-15</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">du</span> <span class="console__token">-hd</span> <span class="console__token">1</span> <span class="console__token">~/Library/Developer</span> <span class="console__token">2&gt;/dev/null</span> <span class="console__token">|</span> <span class="console__token">sort</span> <span class="console__token">-hr</span></span></code></pre>
    </div>
  </div></div></li>
<li>
<p>For a package manager, ask the tool where its cache is rather than
assuming a path. pip on macOS uses <code>~/Library/Caches/pip</code>, not
<code>~/.cache/pip</code>:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">uv</span> <span class="console__token">cache</span> <span class="console__token">dir</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">/Users/&lt;user&gt;/.cache/uv</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">-m</span> <span class="console__token">pip</span> <span class="console__token">cache</span> <span class="console__token">dir</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">/Users/&lt;user&gt;/Library/Caches/pip</span></span></code></pre>
    </div>
  </div></div></li>
</ol>
<!-- style-check: end procedure -->
<p>A <code>du</code> total is an upper bound on what deleting frees. APFS clones share
their blocks with the original until one of them changes, and <code>du</code> counts a
cloned file at full size wherever it appears.</p>
<h2 id="match-the-directory-to-its-cause">Match the directory to its cause<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#match-the-directory-to-its-cause"
     aria-label="Link to section: Match the directory to its cause" data-pagefind-ignore></a>
</h2>
<p>These are the directories that were large on this Mac, and the part of this
series that covers each one:</p>
<div class="table-wrap" data-scrollable data-label="Table: Directory and what fills it">
  <table>
    <thead>
      <tr><th scope="col">Directory and what fills it</th><th class="align-right" scope="col">Size here</th><th scope="col">Covered in</th></tr>
    </thead>
    <tbody>
      <tr><td><code>~/.cache/uv</code><br>Environments that <code>uvx</code> builds for MCP servers</td><td class="align-right">132 GiB</td><td>Part 2</td></tr>
      <tr><td><code>~/Library/Developer/CoreSimulator/Devices</code><br>iOS simulators and the apps installed in them</td><td class="align-right">44 GiB</td><td>Part 5</td></tr>
      <tr><td><code>~/Library/Developer/Xcode/iOS DeviceSupport</code><br>Debug symbols for each device and iOS version you&rsquo;ve connected</td><td class="align-right">35 GiB</td><td>Part 5</td></tr>
      <tr><td><code>~/Library/Containers/com.docker.docker</code><br>Docker Desktop&rsquo;s VM disk, <code>Docker.raw</code></td><td class="align-right">32 GiB</td><td>Part 3</td></tr>
      <tr><td><code>~/.npm/_npx</code><br>Packages that <code>npx</code> runs, MCP servers among them</td><td class="align-right">8.9 GiB</td><td>Part 4</td></tr>
      <tr><td><code>.build</code>, <code>node_modules</code>, <code>.venv</code> in your projects<br>Build output and dependencies, including projects you no longer work on</td><td class="align-right">varies</td><td>Part 6</td></tr>
      <tr><td><code>~/Library/Caches/Homebrew</code><br>Cached downloads of formulae and casks</td><td class="align-right">1.1 GiB</td><td><a href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs">Below</a></td></tr>
      <tr><td><code>~/.ollama/logs</code><br>Ollama&rsquo;s server and app logs</td><td class="align-right">947 MB</td><td><a href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs">Below</a></td></tr>
    </tbody>
  </table>
</div>
<p>For the uv cache, read <a href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/">part 2</a>
before you prune: a running coding session holds a lock that makes the prune
wait, and the prune reports far more than the disk gets back. The other parts
are listed on the series page.</p>
<p>Some directories stay whatever their size. A Docker volume holds an app&rsquo;s
data, not cache. An app&rsquo;s <code>Application Support</code> directory holds its state.</p>
<h2 id="clear-homebrew-downloads-and-ollama-logs">Clear Homebrew downloads and Ollama logs<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#clear-homebrew-downloads-and-ollama-logs"
     aria-label="Link to section: Clear Homebrew downloads and Ollama logs" data-pagefind-ignore></a>
</h2>
<p>Two directories on the list need no part of their own.</p>
<p><code>brew cleanup -s</code> removes old versions and the cached downloads of anything no
longer installed. It keeps the downloads of what is installed now, so on this
Mac it freed about 270 MB of the 1.1 GiB:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">brew</span> <span class="console__token">cleanup</span> <span class="console__token">-s</span></span></code></pre>
    </div>
  </div></div><p>An app can keep its data somewhere else and still write its logs to the
internal disk. My Ollama models live on an external SSD, linked into place:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-ld</span> <span class="console__token">~/.ollama/models</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">lrwxr-xr-x</span>  <span class="console__token">1</span> <span class="console__token">&lt;user&gt;</span>  <span class="console__token">staff</span>  <span class="console__token">...</span> <span class="console__token">/Users/&lt;user&gt;/.ollama/models</span> <span class="console__token">-&gt;</span> <span class="console__token">/Volumes/&lt;drive&gt;/ollama/models</span></span></code></pre>
    </div>
  </div></div><p>The logs aren&rsquo;t linked. List them first:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ls</span> <span class="console__token">-lh</span> <span class="console__token">~/.ollama/logs</span></span></code></pre>
    </div>
  </div></div><p>Here they had reached 947 MB in two files, <code>server-1.log</code> and <code>app-1.log</code>.
Yours may have other names. Ollama has no cleanup command for them, so quit
Ollama and delete the files that list showed, by name:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">rm</span> <span class="console__token">~/.ollama/logs/server-1.log</span> <span class="console__token">~/.ollama/logs/app-1.log</span></span></code></pre>
    </div>
  </div></div><p>Deleting a log leaves the models and the settings as they are.</p>
<p>Ollama wasn&rsquo;t in Open at Login, but App Background Activity, under System
Settings &gt; General &gt; Login Items, allowed it to run in the background. I
turned that off. I didn&rsquo;t check whether background runs are what filled the
logs.</p>
<h2 id="record-the-free-space-again">Record the free space again<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#record-the-free-space-again"
     aria-label="Link to section: Record the free space again" data-pagefind-ignore></a>
</h2>
<p>Run the same command you started with:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">diskutil</span> <span class="console__token">info</span> <span class="console__token">/</span> <span class="console__token">|</span> <span class="console__token">grep</span> <span class="console__token">&#34;Container</span> <span class="console__token">Free</span> <span class="console__token">Space&#34;</span></span></code></pre>
    </div>
  </div></div><p>Here it went from 37.7 GB before any cleanup to 65.8 GB after the uv prune.
The cleanup commands had reported more than 140 GB between them. A cleanup
command reports what it removed from its own storage; only the container&rsquo;s
figure says what the Mac got back.</p>
<h2 id="do-this-with-a-coding-agent">Do this with a coding agent<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#do-this-with-a-coding-agent"
     aria-label="Link to section: Do this with a coding agent" data-pagefind-ignore></a>
</h2>
<p>Paste this prompt into your own local coding agent. It finds and reports, and
it deletes nothing:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Find what is taking up space on this Mac&#39;s internal disk. Measure and report
</span></span><span class="line"><span class="cl">only; delete, prune or clean nothing. Stay in my home directory, and don&#39;t
</span></span><span class="line"><span class="cl">follow links to external drives.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. Report free space with diskutil info / (Container Free Space) and
</span></span><span class="line"><span class="cl">   df -h /, and say which units each prints.
</span></span><span class="line"><span class="cl">2. Run du -hd 1 on my home directory, then on the largest subdirectory it
</span></span><span class="line"><span class="cl">   lists (not the total line for the directory itself), and keep going down
</span></span><span class="line"><span class="cl">   until each large directory belongs to one tool. Also check
</span></span><span class="line"><span class="cl">   ~/Library/Caches and ~/Library/Developer. Ask package managers for their
</span></span><span class="line"><span class="cl">   cache paths (for example uv cache dir, python3 -m pip cache dir) instead of
</span></span><span class="line"><span class="cl">   assuming them.
</span></span><span class="line"><span class="cl">3. For each directory over 1 GB, report its size, which tool owns it, what
</span></span><span class="line"><span class="cl">   fills it, and the tool&#39;s own cleanup command if it has one.
</span></span><span class="line"><span class="cl">4. Mark anything that is in use or holds work: running processes using the
</span></span><span class="line"><span class="cl">   directory (lsof), open coding-agent or editor sessions, uncommitted or
</span></span><span class="line"><span class="cl">   unpushed changes in any git checkout, Docker volumes, app data under
</span></span><span class="line"><span class="cl">   Application Support. Say what would be lost if each were removed.
</span></span><span class="line"><span class="cl">5. Note that du counts APFS clones at full size, so a total is an upper bound
</span></span><span class="line"><span class="cl">   on what deleting frees.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Then stop and wait. I will choose what to clean.</span></span></code></pre></div></div>
  </div>
</div><h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="free-space-barely-moved-after-a-cleanup">Free space barely moved after a cleanup<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#free-space-barely-moved-after-a-cleanup"
     aria-label="Link to section: Free space barely moved after a cleanup" data-pagefind-ignore></a>
</h3>
<p>Docker reports space inside its VM disk, and <code>du</code> and <code>uv cache size</code> count
cloned files at full size. Measure the container with <code>diskutil info /</code>
before and after; that is the only figure that says what the Mac got back.</p>
<p>With Time Machine on, a local snapshot can keep a deleted file&rsquo;s space in use.
Time Machine keeps hourly snapshots for 24 hours and deletes them as they age
or as space is needed, per <a href="https://support.apple.com/en-us/102154" rel="external noopener" target="_blank">Apple&rsquo;s Time Machine guide<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/mac-disk-full-developer-caches/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>.
List them; on this Mac the list was empty:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">tmutil</span> <span class="console__token">listlocalsnapshots</span> <span class="console__token">/</span></span></code></pre>
    </div>
  </div></div><h3 id="uv-cache-prune-waits-then-times-out">uv cache prune waits, then times out<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#uv-cache-prune-waits-then-times-out"
     aria-label="Link to section: uv cache prune waits, then times out" data-pagefind-ignore></a>
</h3>
<p><code>uv cache prune</code> waits while any other uv process is running, and every <code>uvx</code>
server counts. After five minutes by default (<code>UV_LOCK_TIMEOUT</code>) it gives up;
the <a href="https://docs.astral.sh/uv/concepts/cache/" rel="external noopener" target="_blank">uv cache documentation<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/mac-disk-full-developer-caches/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> covers
the lock. Quit the coding sessions that started the servers, then run it again.
To see what holds the lock:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">lsof</span> <span class="console__token">&#34;$(uv</span> <span class="console__token">cache</span> <span class="console__token">dir)/.lock&#34;</span></span></code></pre>
    </div>
  </div></div><p><a href="https://looselydecoupled.com/uv-cache-huge-mac-uvx-mcp-servers/">Part 2</a> shows how to find which session
holds it.</p>
<h3 id="npm-cache-clean-left-npm-large">npm cache clean left ~/.npm large<a class="heading-anchor" href="https://looselydecoupled.com/mac-disk-full-developer-caches/#npm-cache-clean-left-npm-large"
     aria-label="Link to section: npm cache clean left ~/.npm large" data-pagefind-ignore></a>
</h3>
<p><code>npm cache clean --force</code> empties npm&rsquo;s package cache, <code>_cacache</code>. The space
is in <code>_npx</code>, where npx
keeps the packages it runs; part 4 covers it.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Finding a Bridge in 2 TB of Dashcam Video by the Color of Tunnel Lights</title>
      <link>https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/</guid>
      <pubDate>Fri, 02 Oct 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      
      <description>A red/blue ratio found the sodium-lit tunnel on 71 of 79 bridge drives after auto-exposure hid it from every brightness check.</description>
      <content:encoded><![CDATA[<figure><img src="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/interchange-curve-sunset_hu_6d94b69d2acd59a0.jpg" alt="A side-camera fisheye frame on the I-5/I-90 interchange curve at sunset: the low sun flares at the left edge, and the downtown Seattle skyline stands above the ramps and trees." width="1600" height="1280" loading="lazy" decoding="async">
</figure>
<p>I wanted every crossing of the I-90 floating bridge in 240 drive folders of
dashcam footage, 1,958 GB, for a montage. I wasn&rsquo;t going to watch the footage
to find them, and my Rivian R1T&rsquo;s GPS was too coarse to. What found them was the
color of the lights in the Mount Baker tunnel, which sits at the Seattle end
of the bridge. Brightness couldn&rsquo;t see that
tunnel. A red/blue ratio found it on 71 of the 79 bridge drives
scanned, and a person looking at one frame per candidate confirmed 68 crossings.
<a href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/">How to sort months of dashcam footage with Claude
Code</a> has the rest of the job this was
one stage of.</p>
<h2 id="signals-instead-of-pixels">Signals instead of pixels<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#signals-instead-of-pixels"
     aria-label="Link to section: Signals instead of pixels" data-pagefind-ignore></a>
</h2>
<p>The footage already describes itself if you read the right parts of it. Each
15-minute segment carries <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">one GPS fix, at its start, in the container&rsquo;s
<code>location</code> tag</a>. That sorts drives by area, but one fix per
15 minutes can&rsquo;t place a bridge crossing to the minute, let alone the second.</p>
<p>The finer signal is in the keyframes. The truck writes one keyframe per second.
Decoding only those, downscaled to 64 × 48, gives a per-second record of a
drive and skips the 29 frames between them. Each
second becomes four numbers: the mean change in green from the previous
keyframe, which I called motion, and the mean red, green and blue. That record
was computed once, cached as CSV, and every detector afterward read the cache
instead of the video.</p>
<p>The first calibration read a motion of zero everywhere, parked or moving.
The cause was ffmpeg duplicating the keyframes back up to 30 fps, so every
frame was compared with a copy of itself. <code>-fps_mode passthrough</code> fixed it. The bug
showed up only because the check ran on a known parked segment and a known
moving one before it ran on anything else. A detector that reads zero
on both cases is measuring itself.</p>
<h2 id="why-brightness-failed">Why brightness failed<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#why-brightness-failed"
     aria-label="Link to section: Why brightness failed" data-pagefind-ignore></a>
</h2>
<p>A tunnel should be the easiest thing in a drive to find: the outside goes away
and the picture gets darker. In this footage it doesn&rsquo;t get darker. The camera&rsquo;s
auto-exposure raises the gain as the light drops and holds the frame&rsquo;s
brightness close to what it was in daylight. That&rsquo;s what the exposure control
is for, and it erases the one property a brightness threshold depends on.</p>
<h2 id="why-color-worked">Why color worked<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#why-color-worked"
     aria-label="Link to section: Why color worked" data-pagefind-ignore></a>
</h2>
<p>Exposure scales brightness, and the hue of the lamps comes through. The Mount
Baker tunnel is lit by sodium lamps, and sodium light is orange. Inside the
tunnel, the front camera&rsquo;s mean red rises above its mean blue. That ratio is
the whole detector.</p>
<p>It runs with three guards:</p>
<ul>
<li>The red/blue ratio has to stay above 1.0 for 25–150 seconds, so a single
orange frame or a long orange stretch doesn&rsquo;t count.</li>
<li>A rolling median smooths the ratio first, so one bright frame inside a tunnel
doesn&rsquo;t break the run in two.</li>
<li>The first and last 3 minutes of every drive are ignored, because parking
garages look the same to the ratio.</li>
</ul>
<figure>
  <img src="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/red-blue-ratio-tunnel.svg" alt="Two stacked charts over a 1,600-second window of one drive. The top chart is the smoothed red/blue ratio: it sits near 0.9 and crosses the dashed 1.0 threshold in five shaded runs, and the run at 381 seconds, labeled as the Mount Baker tunnel, peaks near 1.3. The bottom chart is mean green over the same seconds: it wanders between about 50 and 75 and stays in that range through the tunnel." loading="lazy" decoding="async"><figcaption>Drive 07_16_26_120000, front camera, from the cached per-second signals. The ratio is red / (blue + 1), smoothed by the detector&rsquo;s rolling median.</figcaption>
</figure>
<p>On the 79 bridge drives scanned, it found a tunnel on 71. Seven of
the eight misses were after dark. My reading is that the ratio describes
light, and once the light outside is artificial too, a tunnel stops standing
out from the road around it.</p>
<h2 id="detector-proposes-person-confirms">Detector proposes, person confirms<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#detector-proposes-person-confirms"
     aria-label="Link to section: Detector proposes, person confirms" data-pagefind-ignore></a>
</h2>
<p>Finding a tunnel isn&rsquo;t the same as finding the right one. A drive on I-90
passes more than one stretch that reads as a tunnel, including the Mercer
Island lid, and most drives had several candidates. The obvious rule, pick one
tunnel by its position in the drive, was right about two times in three.</p>
<p>I could have spent the effort on a better rule. Instead the tool writes a guess
for every drive and a contact sheet to check it against, then stops. Each row
of the sheet is one drive, and each tile is a frame from the bridge side of one
candidate. The bridge is unmistakable in a frame, so the right
column is obvious, and the edit is one number in a picks file, which <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">the cut</a>
turns into synced clips.
Reviewing that way confirmed 68 crossings. Two night drives couldn&rsquo;t be
confirmed, because a dark frame of a dark bridge proves nothing, and one drive
had no crossing among its candidates.</p>
<figure><img src="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/review-sheet-row_hu_42d4d411e91d9c33.jpg" alt="One row of the review sheet: the drive from the chart above, with one tile per candidate. The first tile is a freeway lined with trees, the second is the floating bridge, and the last three are replaced by gray blocks." width="1600" height="256" loading="lazy" decoding="async"><figcaption>The second tile is the bridge, so the pick is candidate 2. Three frames removed.</figcaption>
</figure>
<p>The layout of the sheet mattered as much as having one. The first version was
a flat grid of every candidate frame, and it was easy to match a tile to the
wrong drive. One row per drive makes that mistake visible rather than merely
possible.</p>
<p>The sheet is also an instrument, and an instrument can be wrong. At one point I
announced that the picks for some drives were the Mercer Island lid, not the Mount
Baker tunnel. The sheets had been built with a seek that ffmpeg applied after
tiling, so they showed a different window than the one they claimed. Extracting
the same frame two ways exposed it, and the picks had been right all along.
When a result contradicts work that was already verified, check the instrument
before the conclusion.</p>
<p>The review step reads like an admission that the detector wasn&rsquo;t good enough.
I think it&rsquo;s the reason the detector could stay simple. Take a rule that&rsquo;s
right two times in three, and add a sheet a person reads at a glance. That
beats a rule that&rsquo;s right more often and still needs someone to check it.</p>
<h2 id="signals-that-could-not-separate-the-cases">Signals that could not separate the cases<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#signals-that-could-not-separate-the-cases"
     aria-label="Link to section: Signals that could not separate the cases" data-pagefind-ignore></a>
</h2>
<p>The same per-second record answered four other questions, and each answer was
no for the same reason: the signal moved for the thing I wanted and for
something I didn&rsquo;t.</p>
<ul>
<li><strong>Parked footage.</strong> Near-zero motion found parked time reliably, but there
was little to find: 23 GB of the 1,760 GB scanned, because with Gear Guard turned off,
the truck stops recording seconds after it parks.</li>
<li><strong>Hard stops.</strong> A sudden change in the picture marks braking, but turning
changes the picture as much. Of 875 candidates, all 12 at the top were turns
into parking lots or ordinary stops.</li>
<li><strong>Traffic jams.</strong> Sustained low motion should mark crawling traffic, but the
neighboring cars keep moving, so the picture keeps changing and a jam looks
like driving.</li>
<li><strong>Garage boundaries.</strong> A gray band at the top of the frame marks a concrete
ceiling on a clear day, but an overcast sky is the same gray. The boundary
was set by timing instead.</li>
</ul>
<p>None of these is fixed by tuning a threshold. When a signal can&rsquo;t tell two
cases apart, a better number for the threshold still can&rsquo;t.</p>
<p>Close passes did work once the frame&rsquo;s overall brightness change was
subtracted from the side cameras: 37 of the top 60 were real passes. A person
still had to sort them.</p>
<h2 id="signals-the-camera-leaves-alone">Signals the camera leaves alone<a class="heading-anchor" href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/#signals-the-camera-leaves-alone"
     aria-label="Link to section: Signals the camera leaves alone" data-pagefind-ignore></a>
</h2>
<p>A camera processes its own picture before anything reads it, and that
processing exists to make the picture look normal. Auto-exposure made a tunnel
look like daylight and left the lamp color intact. Before measuring anything in
recorded video, ask which properties the camera flattens and which it passes
through, and build on the second kind.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Rivian Dashcam Camera Missing from a Drive: It Restarted in Another Folder</title>
      <link>https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/</guid>
      <pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      
      <description>A Rivian R1T camera that stops partway through a drive often restarted into a sibling folder; motion correlation, not folder names, gives its offset.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>The camera didn&rsquo;t stop recording. It restarted seconds later into a new drive
folder that holds only the restarted cameras. Look for a folder named a few
seconds after the broken one, with fewer than five cameras in it.</p>
<p>Don&rsquo;t take the offset from the two folder names. Measure it by correlating the
motion of two cameras that face the same way, such as the Gear Guard camera
and the rear camera. Then re-encode the restarted cameras from the sibling folder at that
offset, so that they start on the exact frame.</p>

</div>

<p>I cut a five-camera clip from a drive on August 7 and dropped it into a
multicam timeline. Three angles played to the end. The rear camera and the
side-left camera went black after 33 seconds. Every file was readable and nothing
reported an error. The footage I thought was lost was in the next folder.</p>
<h2 id="rear-or-side-camera-stopped-recording-after-a-few-seconds">Rear or side camera stopped recording after a few seconds<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#rear-or-side-camera-stopped-recording-after-a-few-seconds"
     aria-label="Link to section: Rear or side camera stopped recording after a few seconds" data-pagefind-ignore></a>
</h2>
<p>The symptom has a few forms, depending on what you build from the drive:</p>
<ul>
<li>A camera&rsquo;s only file in the drive folder is a few seconds long, while the
other cameras have a full set of 15-minute segments.</li>
<li>A multicam clip or a grid of the five cameras shows black tiles for one or two
angles for most of the drive.</li>
<li>A hyperlapse of one angle is much shorter than the others, or ends in black.</li>
</ul>
<p>On the August 7 drive, the folder <code>08_07_26_140000</code> holds full segments for the
front, Gear Guard and side-right cameras, and one 33-second file each for the
rear and side-left cameras:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-show_entries</span> <span class="console__token">format=duration</span> <span class="console__token">-of</span> <span class="console__token">csv=p=0</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140000/08_07_26_140000_rearCenter_001.mp4</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">33.000000</span></span></code></pre>
    </div>
  </div></div><figure><img src="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/five-cameras-before-stitch_hu_1be5a11e4db848dc.jpg" alt="A five-camera grid on the I-90 floating bridge built from the original folder alone. The front, side-right and Gear Guard tiles show the bridge; the rear and side-left tiles are solid black." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The August 7 drive on the floating bridge, from 08_07_26_140000 only. Plates blurred.</figcaption>
</figure>
<h2 id="cause-the-camera-restarted-into-a-sibling-folder">Cause: the camera restarted into a sibling folder<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#cause-the-camera-restarted-into-a-sibling-folder"
     aria-label="Link to section: Cause: the camera restarted into a sibling folder" data-pagefind-ignore></a>
</h2>
<p>The truck writes one folder per drive, named <code>MM_DD_YY_HHMMSS</code> in local time, and
one file per camera per 15-minute segment inside it. When a camera stops and
restarts partway through a drive, its recording doesn&rsquo;t continue in the
drive&rsquo;s folder. A new folder is created a few seconds later, and it holds only
the cameras that restarted. The rest of the drive stays in the original folder.</p>
<p>The two folders together hold the whole drive. Nothing was lost, but the drive is
now split across two clocks.</p>
<h2 id="find-the-sibling-folder">Find the sibling folder<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#find-the-sibling-folder"
     aria-label="Link to section: Find the sibling folder" data-pagefind-ignore></a>
</h2>
<p>Run this in the <code>DriveStream</code> folder of the truck&rsquo;s drive, or of your copy of it.
It prints each drive folder with the cameras it holds:</p>
<div class="code-block" data-copyable data-copy-label="Bash">
  <div class="code-block__head"><span class="code-block__lang">Bash</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">for</span> d in */<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%s &#39;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">d</span><span class="p">%/</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ls <span class="s2">&#34;</span><span class="nv">$d</span><span class="s2">&#34;</span> <span class="p">|</span> grep -v <span class="s1">&#39;^\._&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> sed -E <span class="s1">&#39;s/^[0-9_]+_([A-Za-z]+)_[0-9]{3}\.mp4$/\1/&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> sort -u <span class="p">|</span> tr <span class="s1">&#39;\n&#39;</span> <span class="s1">&#39; &#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div></div>
  </div>
</div><p>The <code>grep -v '^\._'</code> drops the AppleDouble files that macOS leaves beside every
clip on the truck&rsquo;s exFAT drive. The August 7 folders look like this:</p>
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">08_07_26_100000 frontCenter gearGuard rearCenter sideLeft sideRight
</span></span><span class="line"><span class="cl">08_07_26_140000 frontCenter gearGuard rearCenter sideLeft sideRight
</span></span><span class="line"><span class="cl">08_07_26_140034 rearCenter sideLeft
</span></span><span class="line"><span class="cl">08_07_26_160000 frontCenter gearGuard rearCenter sideLeft sideRight</span></span></code></pre></div></div>
  </div>
</div><p>A sibling folder has all three of these properties:</p>
<ul>
<li>Its name is seconds, not minutes, after the previous folder&rsquo;s name.</li>
<li>It holds fewer than five cameras.</li>
<li>Its cameras are the ones that stop early in the previous folder.</li>
</ul>
<p>A short folder with one camera and no partner is a different case: a stray
fragment, not half of a drive. Across 233 drive folders, one was a 21-second
fragment from a single camera.</p>
<h2 id="folder-name-offset-is-wrong">Folder name offset is wrong<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#folder-name-offset-is-wrong"
     aria-label="Link to section: Folder name offset is wrong" data-pagefind-ignore></a>
</h2>
<p>The folder names differ by 34 seconds, so the obvious fix is to place the
restarted cameras 34 seconds into the drive. Measured from the footage, the
restarted cameras begin 32.7 seconds into the drive. The names are wrong by more
than a second. The files&rsquo; <code>creation_time</code> tags are no better: the two folders'
differ by 35 seconds, a third answer.</p>
<p>A folder name is a label, not a clock: nothing ties it to the first frame of
the video inside. An error of more than a second is visible in a multicam edit.
A car that passes the truck crosses the rear view and the side view at moments
that no longer match. The truck&rsquo;s files carry no timecode track to correct it, so
the offset has to come from the pictures.</p>
<h2 id="measure-the-offset-by-motion-correlation">Measure the offset by motion correlation<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#measure-the-offset-by-motion-correlation"
     aria-label="Link to section: Measure the offset by motion correlation" data-pagefind-ignore></a>
</h2>
<p>Two cameras that see the same scene change at the same moments. When the truck
hits a bump or a car passes behind it, both backward-facing pictures move at
once. The script reduces each camera to a motion signal and slides one signal
along the other until they match best:</p>
<ol>
<li>Decode a window of each clip at a fixed sample rate, scaled to a tiny
grayscale frame.</li>
<li>Take the mean absolute change between consecutive frames. That&rsquo;s the motion
signal: high when the picture changes, low when it holds still.</li>
<li>Standardize the reference, and each stretch of the other signal it&rsquo;s
compared with, so that a brighter or busier camera or stretch doesn&rsquo;t
outweigh the rest.</li>
<li>Try every lag within a search range around your guess, and keep the lag with
the highest correlation.</li>
</ol>
<p>It needs Python 3 and <code>ffmpeg</code> on your path, and nothing else:</p>
<div class="code-block" data-copyable data-copy-label="measure_offset.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">measure_offset.py</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Measure the time offset between two dashcam clips by correlating their motion.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Usage:
</span></span></span><span class="line"><span class="cl"><span class="s2">    python3 measure_offset.py REFERENCE OTHER --at SECONDS [--guess SECONDS]
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Reads a window of REFERENCE starting at --at, and a slightly wider window of
</span></span></span><span class="line"><span class="cl"><span class="s2">OTHER around --at minus --guess. Prints the offset that lines the two up:
</span></span></span><span class="line"><span class="cl"><span class="s2">a moment at time t in REFERENCE is at time t - offset in OTHER.
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">WIDTH</span><span class="p">,</span> <span class="n">HEIGHT</span> <span class="o">=</span> <span class="mi">32</span><span class="p">,</span> <span class="mi">24</span>
</span></span><span class="line"><span class="cl"><span class="n">SAMPLES_PER_SECOND</span> <span class="o">=</span> <span class="mi">10</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">motion</span><span class="p">(</span><span class="n">clip</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">start</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">seconds</span><span class="p">:</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Mean absolute change between consecutive tiny grayscale frames.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">raw</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="s2">&#34;ffmpeg&#34;</span><span class="p">,</span> <span class="s2">&#34;-nostdin&#34;</span><span class="p">,</span> <span class="s2">&#34;-v&#34;</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-ss&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">start</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;-t&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">seconds</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;-i&#34;</span><span class="p">,</span> <span class="n">clip</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-vf&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;fps=</span><span class="si">{</span><span class="n">SAMPLES_PER_SECOND</span><span class="si">}</span><span class="s2">,scale=</span><span class="si">{</span><span class="n">WIDTH</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">HEIGHT</span><span class="si">}</span><span class="s2">,format=gray&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-f&#34;</span><span class="p">,</span> <span class="s2">&#34;rawvideo&#34;</span><span class="p">,</span> <span class="s2">&#34;-&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">        <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">    <span class="n">size</span> <span class="o">=</span> <span class="n">WIDTH</span> <span class="o">*</span> <span class="n">HEIGHT</span>
</span></span><span class="line"><span class="cl">    <span class="n">frames</span> <span class="o">=</span> <span class="p">[</span><span class="n">raw</span><span class="p">[</span><span class="n">i</span><span class="p">:</span><span class="n">i</span> <span class="o">+</span> <span class="n">size</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="o">-</span> <span class="n">size</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="n">size</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="nb">sum</span><span class="p">(</span><span class="nb">abs</span><span class="p">(</span><span class="n">a</span> <span class="o">-</span> <span class="n">b</span><span class="p">)</span> <span class="k">for</span> <span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">before</span><span class="p">,</span> <span class="n">after</span><span class="p">))</span> <span class="o">/</span> <span class="n">size</span>
</span></span><span class="line"><span class="cl">            <span class="k">for</span> <span class="n">before</span><span class="p">,</span> <span class="n">after</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">frames</span><span class="p">,</span> <span class="n">frames</span><span class="p">[</span><span class="mi">1</span><span class="p">:])]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">standardize</span><span class="p">(</span><span class="n">values</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="n">mean</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">values</span><span class="p">)</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">variance</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">((</span><span class="n">v</span> <span class="o">-</span> <span class="n">mean</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">values</span><span class="p">)</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">spread</span> <span class="o">=</span> <span class="n">variance</span> <span class="o">**</span> <span class="mf">0.5</span> <span class="ow">or</span> <span class="mf">1.0</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[(</span><span class="n">v</span> <span class="o">-</span> <span class="n">mean</span><span class="p">)</span> <span class="o">/</span> <span class="n">spread</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">values</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">best_lag</span><span class="p">(</span><span class="n">reference</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">],</span> <span class="n">other</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">float</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">int</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Slide reference along other; return the best correlation and its lag.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">lag</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">other</span><span class="p">)</span> <span class="o">-</span> <span class="nb">len</span><span class="p">(</span><span class="n">reference</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">window</span> <span class="o">=</span> <span class="n">standardize</span><span class="p">(</span><span class="n">other</span><span class="p">[</span><span class="n">lag</span><span class="p">:</span><span class="n">lag</span> <span class="o">+</span> <span class="nb">len</span><span class="p">(</span><span class="n">reference</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl">        <span class="n">score</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">a</span> <span class="o">*</span> <span class="n">b</span> <span class="k">for</span> <span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">reference</span><span class="p">,</span> <span class="n">window</span><span class="p">))</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">reference</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">scores</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">score</span><span class="p">,</span> <span class="n">lag</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="n">scores</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;reference&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;other&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--at&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;window start in REFERENCE, seconds&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--guess&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mf">0.0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;expected offset, seconds&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--length&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mf">120.0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;window length, seconds&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--search&#34;</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mf">5.0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;how far either side of the guess to look&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">parser</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">reference</span> <span class="o">=</span> <span class="n">standardize</span><span class="p">(</span><span class="n">motion</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">reference</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">at</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">length</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># The other window starts --search seconds early, so lag 0 means guess + search.</span>
</span></span><span class="line"><span class="cl">    <span class="n">other_start</span> <span class="o">=</span> <span class="n">args</span><span class="o">.</span><span class="n">at</span> <span class="o">-</span> <span class="n">args</span><span class="o">.</span><span class="n">guess</span> <span class="o">-</span> <span class="n">args</span><span class="o">.</span><span class="n">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">other_length</span> <span class="o">=</span> <span class="n">args</span><span class="o">.</span><span class="n">length</span> <span class="o">+</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">args</span><span class="o">.</span><span class="n">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">other</span> <span class="o">=</span> <span class="n">motion</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">other</span><span class="p">,</span> <span class="n">other_start</span><span class="p">,</span> <span class="n">other_length</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">score</span><span class="p">,</span> <span class="n">lag</span> <span class="o">=</span> <span class="n">best_lag</span><span class="p">(</span><span class="n">reference</span><span class="p">,</span> <span class="n">other</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">offset</span> <span class="o">=</span> <span class="n">args</span><span class="o">.</span><span class="n">guess</span> <span class="o">+</span> <span class="n">args</span><span class="o">.</span><span class="n">search</span> <span class="o">-</span> <span class="n">lag</span> <span class="o">/</span> <span class="n">SAMPLES_PER_SECOND</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;window at </span><span class="si">{</span><span class="n">args</span><span class="o">.</span><span class="n">at</span><span class="si">:</span><span class="s2">g</span><span class="si">}</span><span class="s2"> s: correlation </span><span class="si">{</span><span class="n">score</span><span class="si">:</span><span class="s2">.2f</span><span class="si">}</span><span class="s2">, offset </span><span class="si">{</span><span class="n">offset</span><span class="si">:</span><span class="s2">+.1f</span><span class="si">}</span><span class="s2"> s&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">main</span><span class="p">()</span></span></span></code></pre></div></div>
  </div>
</div><p>Give it a camera from the original folder as the reference, a camera from the
sibling folder as the other, and the folder-name difference as the guess. Both
folders number their segments from <code>001</code> and every segment but the last runs
900 seconds, so segment <code>002</code> of the sibling starts the same offset after
segment <code>002</code> of the original. Compare files with the same segment number, and
keep the window inside one segment.</p>
<p>In the <code>DriveStream</code> folder, run:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">measure_offset.py</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140000/08_07_26_140000_gearGuard_001.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140034/08_07_26_140034_rearCenter_001.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">--at</span> <span class="console__token">300</span> <span class="console__token">--guess</span> <span class="console__token">34</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">window</span> <span class="console__token">at</span> <span class="console__token">300</span> <span class="console__token">s:</span> <span class="console__token">correlation</span> <span class="console__token">0.84,</span> <span class="console__token">offset</span> <span class="console__token">+32.6</span> <span class="console__token">s</span></span></code></pre>
    </div>
  </div></div><p>One window is one measurement. Repeat it in windows that don&rsquo;t overlap, from
different segments, and trust the offset only when they agree. Four windows on
the August 7 drive gave this:</p>
<div class="table-wrap" data-scrollable data-label="Table: Segment">
  <table>
    <thead>
      <tr><th scope="col">Segment</th><th scope="col"><code>--at</code></th><th scope="col">Correlation</th><th scope="col">Offset</th></tr>
    </thead>
    <tbody>
      <tr><td><code>001</code></td><td>300</td><td>0.84</td><td>+32.6 s</td></tr>
      <tr><td><code>001</code></td><td>700</td><td>0.73</td><td>+32.7 s</td></tr>
      <tr><td><code>002</code></td><td>600</td><td>0.85</td><td>+32.8 s</td></tr>
      <tr><td><code>003</code></td><td>200</td><td>0.69</td><td>+32.7 s</td></tr>
    </tbody>
  </table>
</div>
<p>That&rsquo;s 32.7 seconds, plus or minus 0.1, from four independent windows. A moment
at time <em>t</em> in the original folder is at <em>t</em> − 32.7 in the sibling folder.</p>
<figure>
  <img src="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/motion-correlation-offset.svg" alt="Two plots of motion against time, from 300 to 330 seconds into the Gear Guard file, with the Gear Guard signal in blue and the restarted rear camera in amber. Shifted by the folder-name offset of 34 seconds, each amber peak lands about a second after the blue one. Shifted by the measured 32.7 seconds, the peaks fall together." loading="lazy" decoding="async"><figcaption>Motion from measure_offset.py&rsquo;s motion() function, standardized, for the first window in the table.</figcaption>
</figure>
<h3 id="compare-cameras-that-face-the-same-way">Compare cameras that face the same way<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#compare-cameras-that-face-the-same-way"
     aria-label="Link to section: Compare cameras that face the same way" data-pagefind-ignore></a>
</h3>
<p>The front camera was my first choice for the reference, because it recorded the
whole drive. Against the restarted rear camera it gave two answers:</p>
<div class="table-wrap" data-scrollable data-label="Table: Segment">
  <table>
    <thead>
      <tr><th scope="col">Segment</th><th scope="col"><code>--at</code></th><th scope="col">Correlation</th><th scope="col">Offset</th></tr>
    </thead>
    <tbody>
      <tr><td><code>001</code></td><td>300</td><td>0.74</td><td>+31.9 s</td></tr>
      <tr><td><code>002</code></td><td>600</td><td>0.52</td><td>+32.8 s</td></tr>
    </tbody>
  </table>
</div>
<p>The two windows disagree by 0.9 seconds, and each one looks plausible on its
own. The front camera sees the road ahead and the rear camera sees the road
behind. Their pictures change for different reasons most of the time, so the
best match moves from one window to the next. The Gear Guard camera looks backward over
the bed, the same direction as the rear camera, and the two agree.</p>
<p>Pick a reference camera that kept recording in the original folder and faces
the same direction as one of the restarted cameras. The files in a sibling
folder share one start time and the same segment lengths, so the offset you
measure for one of its cameras applies to all of them.</p>
<h2 id="stitch-the-restarted-cameras">Stitch the restarted cameras<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#stitch-the-restarted-cameras"
     aria-label="Link to section: Stitch the restarted cameras" data-pagefind-ignore></a>
</h2>
<p>A stream copy can only start on a keyframe, and the truck writes one keyframe per
second. The stream-copied angles from the original folder start on their own
keyframes. An offset of 32.7 seconds isn&rsquo;t a whole number of seconds, so no
keyframe in the sibling folder falls on the same instant. Re-encode only the restarted
cameras, cut at the exact frame, and leave the rest as stream copies.</p>
<p>Suppose your clip covers <code>START</code> to <code>END</code> seconds of the original folder, and
<code>OFFSET</code> is the measured offset. The window has to begin after the restart, so
<code>START</code> is greater than <code>OFFSET</code>. In the sibling folder, the clip begins at
<code>START</code> minus <code>OFFSET</code> and lasts <code>END</code> minus <code>START</code> seconds.</p>
<p>In the <code>DriveStream</code> folder, the following commands write a concat list of the
restarted camera&rsquo;s segments in the sibling folder, and then cut the clip from
the joined segments, re-encoding to HEVC. They create two files and change
nothing in the drive folders:</p>
<div class="code-block" data-copyable data-copy-label="Bash">
  <div class="code-block__head"><span class="code-block__lang">Bash</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">printf</span> <span class="s2">&#34;file &#39;%s&#39;\n&#34;</span> <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">&#34;</span>/08_07_26_140034/08_07_26_140034_rearCenter_*.mp4 <span class="se">\
</span></span></span><span class="line"><span class="cl">  &gt; rearCenter.txt
</span></span><span class="line"><span class="cl">ffmpeg -f concat -safe <span class="m">0</span> -i rearCenter.txt -ss &lt;sibling-start&gt; -t &lt;duration&gt; <span class="se">\
</span></span></span><span class="line"><span class="cl">  -c:v hevc_videotoolbox -q:v <span class="m">80</span> -tag:v hvc1 -color_range pc -an <span class="se">\
</span></span></span><span class="line"><span class="cl">  &lt;clip-folder&gt;/rearCenter.mp4</span></span></code></pre></div></div>
  </div>
</div><p>Run the same two commands for every other camera in the sibling folder.</p>
<p><code>-ss</code> after <code>-i</code> decodes from the start of the list and discards frames up to
the exact time, so the output starts on that instant rather than on the nearest
keyframe. It&rsquo;s slow for a window late in the drive, because ffmpeg decodes
everything before the window first. Joining the segments in one concat pass,
rather than cutting each file and joining the cuts, keeps the frames whose
group of pictures runs across a segment boundary. <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam
clips with ffmpeg</a> covers the
stream-copied cameras and that join in full.</p>
<p>The encoder <code>hevc_videotoolbox</code> is the hardware HEVC encoder on macOS. On
another system, use the encoder you&rsquo;d use for the rest of the project. The
option <code>-color_range pc</code> labels the re-encoded angle full range like the
truck&rsquo;s footage, so it matches the stream-copied ones; check it with
<code>ffprobe -show_entries stream=color_range</code>. The truck
records no audio, so <code>-an</code> drops nothing.</p>
<p>Every file now starts at time 0. The re-encoded angles start on the exact
frame, and a stream-copied one starts early by the lead <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/#build-the-multicam-clip">the cut
script</a>
printed for it. In DaVinci Resolve, mark each lead as an In point, then create
the multicam clip with angle sync set to the In point.</p>
<figure><img src="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/five-cameras-after-stitch_hu_dedc50ea5b067455.jpg" alt="The same moment on the floating bridge after the stitch. All five tiles are filled: the rear tile shows the car following behind, which the Gear Guard tile also shows over the truck bed, and the side-left tile shows the bridge deck and railing." width="1600" height="853" loading="lazy" decoding="async"><figcaption>Rear and side-left from 08_07_26_140034, cut at the measured 32.7 s offset. Plates and the following driver blurred.</figcaption>
</figure>
<h2 id="confirm-the-stitch">Confirm the stitch<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/#confirm-the-stitch"
     aria-label="Link to section: Confirm the stitch" data-pagefind-ignore></a>
</h2>
<p>Run the same script on the finished clip, with the guess left at zero. In the
clip&rsquo;s folder, compare the Gear Guard camera, which was stream-copied from the original
folder, with the re-encoded rear camera:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">measure_offset.py</span> <span class="console__token">08_07_26_140000_gearGuard.mp4</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">08_07_26_140000_rearCenter.mp4</span> <span class="console__token">--at</span> <span class="console__token">300</span> <span class="console__token">--length</span> <span class="console__token">90</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">window</span> <span class="console__token">at</span> <span class="console__token">300</span> <span class="console__token">s:</span> <span class="console__token">correlation</span> <span class="console__token">0.79,</span> <span class="console__token">offset</span> <span class="console__token">+0.1</span> <span class="console__token">s</span></span></code></pre>
    </div>
  </div></div><p>An offset of +0.1 seconds is one sample step of <code>SAMPLES_PER_SECOND</code>, the
resolution of the method. The angles are in sync. The same script reported
32.7 seconds on the unstitched folders, so it does notice an offset when one
exists. A zero from a check that has never reported anything else proves
nothing.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Read the GPS Location from Rivian Dashcam Video with ffprobe</title>
      <link>https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/</guid>
      <pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      
      <description>A Rivian R1T dashcam clip carries one GPS fix, taken where its segment began, in the MP4 location tag, and ffprobe reads it in one command.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>The GPS position is in the MP4 container&rsquo;s <code>location</code> tag. Read it with
ffprobe:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-show_entries</span> <span class="console__token">format_tags=location</span> <span class="console__token">-of</span> <span class="console__token">default=nw=1:nk=1</span> <span class="console__token">\</span>
</span><span class="line">    <span class="console__token">&lt;clip&gt;.mp4</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">47.589462-122.2655</span></span></code></pre>
    </div>
  </div></div><p>The value is a signed latitude immediately followed by a signed longitude, in
decimal degrees: <code>47.589462</code> north, <code>-122.2655</code> west. That clip starts on the
I-90 floating bridge. <code>0,0</code> means the truck had no fix.</p>
<p>There is one fix per file, taken where the 15-minute segment began. There is
no per-second GPS track.</p>

</div>

<figure><img src="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/floating-bridge-segment-start_hu_2b5d759a1bdfb1b3.jpg" alt="The front camera half a second into a segment that begins on the I-90 floating bridge under an overcast sky, with two cars ahead and a distant skyline low on the horizon." width="1600" height="1280" loading="lazy" decoding="async"><figcaption>Half a second into the clip. Its tag reads 47.589462-122.2655: 47.589462, -122.2655. Plates blurred.</figcaption>
</figure>
<h2 id="spellings-of-the-tag">Spellings of the tag<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#spellings-of-the-tag"
     aria-label="Link to section: Spellings of the tag" data-pagefind-ignore></a>
</h2>
<p>The value has no separator between the two numbers. The sign of the longitude
is the separator, so a parser has to split on the second <code>+</code> or <code>-</code>, not on a
comma or a space. The truck never writes a leading plus sign. Across 475
front-camera segments, all from encoder tag Rivian Firmware ver. 2022-47, three
spellings turned up, and <code>0,0</code> stands in when there is no fix:</p>
<dl class="facts">
  <dt>Usual</dt>
  <dd><code>47.589462-122.2655</code>, on 439 of the 475 segments.</dd>
  <dt>Trailing slash</dt>
  <dd>the same form ending in <code>/</code>, on the other 36.</dd>
  <dt>After ffmpeg copies the file</dt>
  <dd><code>+47.5894-122.2655/</code>, the ISO 6709 form, with a leading plus sign and four decimal places. This is ffmpeg&rsquo;s rewrite, not a firmware variant.</dd>
  <dt>No fix</dt>
  <dd><code>0,0</code>, which is a position in the Gulf of Guinea, not a reading.</dd>
</dl>

<p>ffprobe also prints a <code>location-eng</code> tag with the same value. Either one
works; the commands here read <code>location</code>.</p>
<h2 id="what-one-fix-per-segment-rules-out">What one fix per segment rules out<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#what-one-fix-per-segment-rules-out"
     aria-label="Link to section: What one fix per segment rules out" data-pagefind-ignore></a>
</h2>
<p>The truck records one file per camera per 15-minute segment, and each file
carries a single fix from the moment that segment started. Nothing inside the
file records where the truck was a minute later. That rules out:</p>
<ul>
<li>A route drawn from the video. A segment gives you a point every 15 minutes,
which is too coarse to follow a road.</li>
<li>Speed. Two fixes 15 minutes apart give an average over the whole segment, not
the speed at any moment.</li>
<li>Finding a place by GPS alone. A drive across the floating bridge takes less
than a segment, so the bridge usually falls between two fixes.</li>
</ul>
<p>The five cameras of a segment each carry their own fix. In the clip earlier,
the front and rear files read <code>47.589462-122.2655</code> and the two side cameras and
the Gear Guard camera read <code>47.589474-122.2659</code>. Treat any one of them as the
segment&rsquo;s position.</p>
<p>To place a moment inside a segment, read the picture instead of the
container. <a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in dashcam video by the color of tunnel
lights</a> locates the I-90
floating bridge crossing from the picture, where the GPS can&rsquo;t.</p>
<h2 id="parse-the-tag-in-python">Parse the tag in Python<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#parse-the-tag-in-python"
     aria-label="Link to section: Parse the tag in Python" data-pagefind-ignore></a>
</h2>
<p><code>parse_location</code> returns a <code>(latitude, longitude)</code> tuple, or <code>None</code> when the
tag is missing, unreadable, or <code>0,0</code>. <code>read_location</code> runs ffprobe on one
file. The standard library is enough; Python 3.10 or later, for the <code>|</code> in the
type hints.</p>
<div class="code-block" data-copyable data-copy-label="rivian_location.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">rivian_location.py</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># A signed latitude immediately followed by a signed longitude. The leading</span>
</span></span><span class="line"><span class="cl"><span class="c1"># plus sign and the trailing slash are optional because not every writer</span>
</span></span><span class="line"><span class="cl"><span class="c1"># emits them.</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCATION</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;([+-]?\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">parse_location</span><span class="p">(</span><span class="n">tag</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">float</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">match</span> <span class="o">=</span> <span class="n">LOCATION</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="k">match</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="n">latitude</span><span class="p">,</span> <span class="n">longitude</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="k">match</span><span class="p">[</span><span class="mi">1</span><span class="p">]),</span> <span class="nb">float</span><span class="p">(</span><span class="k">match</span><span class="p">[</span><span class="mi">2</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># The truck writes 0,0 when it has no GPS fix.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">latitude</span> <span class="o">==</span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">longitude</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">latitude</span><span class="p">,</span> <span class="n">longitude</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">read_location</span><span class="p">(</span><span class="n">clip</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">float</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">tag</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="s2">&#34;ffprobe&#34;</span><span class="p">,</span> <span class="s2">&#34;-v&#34;</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">,</span> <span class="s2">&#34;-show_entries&#34;</span><span class="p">,</span> <span class="s2">&#34;format_tags=location&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">         <span class="s2">&#34;-of&#34;</span><span class="p">,</span> <span class="s2">&#34;default=nw=1:nk=1&#34;</span><span class="p">,</span> <span class="n">clip</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">        <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">parse_location</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">clip</span> <span class="ow">in</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]:</span>
</span></span><span class="line"><span class="cl">        <span class="n">point</span> <span class="o">=</span> <span class="n">read_location</span><span class="p">(</span><span class="n">clip</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="n">clip</span><span class="p">,</span> <span class="s2">&#34;no fix&#34;</span> <span class="k">if</span> <span class="n">point</span> <span class="ow">is</span> <span class="kc">None</span> <span class="k">else</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">point</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="si">}</span><span class="s2">, </span><span class="si">{</span><span class="n">point</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
  </div>
</div><p>Pass it one or more clips:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">rivian_location.py</span> <span class="console__token">&lt;clip&gt;.mp4</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">47.589462,</span> <span class="console__token">-122.2655</span></span></code></pre>
    </div>
  </div></div><p>The tests cover every spelling in the list earlier, a southern and eastern
position so both signs are exercised, and each way the tag can mean &ldquo;no fix&rdquo;:</p>
<div class="code-block" data-copyable data-copy-label="test_rivian_location.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">test_rivian_location.py</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">pytest</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">rivian_location</span> <span class="kn">import</span> <span class="n">parse_location</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@pytest.mark.parametrize</span><span class="p">((</span><span class="s2">&#34;tag&#34;</span><span class="p">,</span> <span class="s2">&#34;expected&#34;</span><span class="p">),</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;47.589462-122.2655&#34;</span><span class="p">,</span> <span class="p">(</span><span class="mf">47.589462</span><span class="p">,</span> <span class="o">-</span><span class="mf">122.2655</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;47.589462-122.2655/&#34;</span><span class="p">,</span> <span class="p">(</span><span class="mf">47.589462</span><span class="p">,</span> <span class="o">-</span><span class="mf">122.2655</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;+47.5894-122.2655/&#34;</span><span class="p">,</span> <span class="p">(</span><span class="mf">47.5894</span><span class="p">,</span> <span class="o">-</span><span class="mf">122.2655</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;-33.8688+151.2093/&#34;</span><span class="p">,</span> <span class="p">(</span><span class="o">-</span><span class="mf">33.8688</span><span class="p">,</span> <span class="mf">151.2093</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl"><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_reads_each_spelling_of_the_tag</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="n">expected</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">parse_location</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span> <span class="o">==</span> <span class="n">expected</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@pytest.mark.parametrize</span><span class="p">(</span><span class="s2">&#34;tag&#34;</span><span class="p">,</span> <span class="p">[</span><span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;garbage&#34;</span><span class="p">,</span> <span class="s2">&#34;+0.0000+0.0000/&#34;</span><span class="p">,</span> <span class="s2">&#34;0-0&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_returns_none_for_no_fix</span><span class="p">(</span><span class="n">tag</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">parse_location</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span> <span class="ow">is</span> <span class="kc">None</span></span></span></code></pre></div></div>
  </div>
</div><div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">-m</span> <span class="console__token">pytest</span> <span class="console__token">-q</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">.........</span>                                                                <span class="console__token">[100%]</span>
</span><span class="line"><span class="console__token">9</span> <span class="console__token">passed</span> <span class="console__token">in</span> <span class="console__token">0.01s</span></span></code></pre>
    </div>
  </div></div><p>Dropping the optional sign from the latitude&rsquo;s half of the pattern fails the
southern-hemisphere case, so the tests do notice a broken pattern.</p>
<h2 id="remove-the-gps-location-from-a-clip-before-sharing-it">Remove the GPS location from a clip before sharing it<a class="heading-anchor" href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/#remove-the-gps-location-from-a-clip-before-sharing-it"
     aria-label="Link to section: Remove the GPS location from a clip before sharing it" data-pagefind-ignore></a>
</h2>
<p>The tag travels with the file. A raw clip posted online tells anyone who
downloads it where it was recorded, and a folder of them maps where the truck
goes every day. Trimming the clip doesn&rsquo;t necessarily remove it: a stream copy
with ffmpeg kept the tag, rewritten in the <code>+…/</code> form, and exiftool reports it
as a GPS position:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">-t</span> <span class="console__token">2</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">copy.mp4</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">exiftool</span> <span class="console__token">-G1</span> <span class="console__token">-a</span> <span class="console__token">&#34;-gps*&#34;</span> <span class="console__token">&#34;-location*&#34;</span> <span class="console__token">copy.mp4</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Altitude</span>                    <span class="console__token">:</span> <span class="console__token">0</span> <span class="console__token">m</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Altitude</span> <span class="console__token">Ref</span>                <span class="console__token">:</span> <span class="console__token">Above</span> <span class="console__token">Sea</span> <span class="console__token">Level</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Latitude</span>                    <span class="console__token">:</span> <span class="console__token">47</span> <span class="console__token">deg</span> <span class="console__token">35&#39;</span> <span class="console__token">22.02&#34;</span> <span class="console__token">N</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Longitude</span>                   <span class="console__token">:</span> <span class="console__token">122</span> <span class="console__token">deg</span> <span class="console__token">15&#39;</span> <span class="console__token">55.76&#34;</span> <span class="console__token">W</span>
</span><span class="line"><span class="console__token">[Composite]</span>     <span class="console__token">GPS</span> <span class="console__token">Position</span>                    <span class="console__token">:</span> <span class="console__token">47</span> <span class="console__token">deg</span> <span class="console__token">35&#39;</span> <span class="console__token">22.02&#34;</span> <span class="console__token">N,</span> <span class="console__token">122</span> <span class="console__token">deg</span> <span class="console__token">15&#39;</span> <span class="console__token">55.76&#34;</span> <span class="console__token">W</span>
</span><span class="line"><span class="console__token">[UserData]</span>      <span class="console__token">Location</span> <span class="console__token">Information</span>            <span class="console__token">:</span> <span class="console__token">(none)</span> <span class="console__token">Role=shooting</span> <span class="console__token">Lat=47.58945</span> <span class="console__token">Lon=-122.26549</span> <span class="console__token">Alt=0.00</span> <span class="console__token">Body=earth</span> <span class="console__token">Notes=</span></span></code></pre>
    </div>
  </div></div><p>Other ways of writing a file behave differently. The <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">multi-camera clips cut
with ffmpeg&rsquo;s concat demuxer</a>
came out with no <code>location</code> tag at all, because the demuxer drops file-level
metadata. The hyperlapse tool I used kept it, because it copies the
first source file&rsquo;s metadata on purpose. Check
each file rather than trusting the tool. To drop the container&rsquo;s metadata while copying,
add <code>-map_metadata -1</code>; the same exiftool command then prints nothing:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffmpeg</span> <span class="console__token">-i</span> <span class="console__token">&lt;clip&gt;.mp4</span> <span class="console__token">-t</span> <span class="console__token">2</span> <span class="console__token">-c</span> <span class="console__token">copy</span> <span class="console__token">-map_metadata</span> <span class="console__token">-1</span> <span class="console__token">strip.mp4</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">exiftool</span> <span class="console__token">-G1</span> <span class="console__token">-a</span> <span class="console__token">&#34;-gps*&#34;</span> <span class="console__token">&#34;-location*&#34;</span> <span class="console__token">strip.mp4</span></span></code></pre>
    </div>
  </div></div><p>Empty output is the result you want. Run the check on the file you&rsquo;re about
to upload, not on its source: each tool between the two can keep the tag or
drop it.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>ffprobe</dt> <dd>9.0.1</dd></div>
    <div class="tested__row"><dt>exiftool</dt> <dd>13.55</dd></div>
    <div class="tested__row"><dt>Python</dt> <dd>3.12.11</dd></div>
    <div class="tested__row"><dt>pytest</dt> <dd>9.1.1</dd></div>
    <div class="tested__row"><dt>OS</dt> <dd>macOS 27.0</dd></div>
    <div class="tested__row"><dt>Footage</dt> <dd>Rivian, encoder tag Rivian Firmware ver. 2022-47</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>October 2026</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
    <item>
      <title>How to Sort Months of Dashcam Footage with Claude Code</title>
      <link>https://looselydecoupled.com/triage-dashcam-footage-claude-code/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/triage-dashcam-footage-claude-code/</guid>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Automation</category>
      <category>Python</category>
      <category>macOS</category>
      
      <description>Five copy-paste Claude Code prompts sort months of Rivian R1T dashcam footage by GPS and keyframes, each with the check that caught a real failure.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Give Claude Code one prompt per stage: survey, move, find, cut, publish. Each
prompt states what the agent can&rsquo;t touch and carries three demands: run on
one drive first, break your own check on purpose and confirm that it fails, and
list what you couldn&rsquo;t verify. The agent reads the GPS tag and the keyframes instead
of the pixels and proposes candidates. You confirm every pick on a contact sheet
before anything is cut, moved, or deleted.</p>

</div>

<p>Driving from July to September filled the truck&rsquo;s 2 TB dashcam drive, a
<a href="https://www.amazon.com/dp/B08HN37XC1?tag=loosely-decoupled-20" rel="external noopener sponsored" target="_blank">SanDisk Extreme Portable SSD<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>
(affiliate link): 240 drive folders,
1,958 GB, five cameras recording on every trip. I wanted to keep only what might
make a video, and the backup drive couldn&rsquo;t hold all of it. Watching it wasn&rsquo;t an
option. I directed Claude Code in plain language instead. It sorted the drives
by where they went using GPS, found the I-90 floating bridge by the color of the tunnel
lights, cut synced clips, and rendered a hyperlapse of every drive.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/five-cameras-floating-bridge_hu_c7373bb90be2c6e0.jpg" alt="Five fisheye views from the same second on the I-90 floating bridge: front, rear, both sides and the Gear Guard camera over the truck bed. A red car passing behind appears in three views at once." width="1600" height="853" loading="lazy" decoding="async"><figcaption>The five cameras at one second of a crossing.</figcaption>
</figure>
<p>Almost every failure in that work was caught by a check: a size comparison, a
frame count, a positive control, a contact sheet, or me. One failure was caught by
nothing for hours. The prompts in this guide are general versions of the ones I
typed, rewritten for your own footage, and each one carries the guardrail that
the failure taught.</p>
<h2 id="signals-already-in-the-files">Signals already in the files<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#signals-already-in-the-files"
     aria-label="Link to section: Signals already in the files" data-pagefind-ignore></a>
</h2>
<p>A Rivian writes one folder per drive and one MP4 per camera per 15-minute segment.
Two things in those files do most of the work, and neither needs anyone to watch
the video:</p>
<ul>
<li><strong>GPS.</strong> The container&rsquo;s <code>location</code> tag holds one fix per segment, taken at its
start. That&rsquo;s the only GPS in the file; there&rsquo;s no per-second track.</li>
<li><strong>Keyframes.</strong> Every camera writes one keyframe per second. Decoding only the
keyframes, downscaled to 64 × 48, gives a per-second record of motion and color
without decoding the frames between them.</li>
</ul>
<p>Read the tag from any clip:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">ffprobe</span> <span class="console__token">-v</span> <span class="console__token">error</span> <span class="console__token">-show_entries</span> <span class="console__token">format_tags=location</span> <span class="console__token">-of</span> <span class="console__token">default=nw=1:nk=1</span> <span class="console__token">&lt;clip&gt;.mp4</span></span></code></pre>
    </div>
  </div></div><p>It prints a signed latitude immediately followed by a signed longitude, such as
<code>47.589462-122.2655</code>. Some tags end in <code>/</code> and most don&rsquo;t, and <code>0,0</code> means the truck
had no fix. <a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a> covers every spelling. Other
dashcams store GPS differently, which is why the first prompt asks the agent to
find out rather than assume.</p>
<h2 id="tools-and-rules-for-every-stage">Tools and rules for every stage<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#tools-and-rules-for-every-stage"
     aria-label="Link to section: Tools and rules for every stage" data-pagefind-ignore></a>
</h2>
<p>You need:</p>
<ul>
<li>Claude Code, run in a terminal on the machine that has the footage mounted</li>
<li>The <code>ffmpeg</code> and <code>ffprobe</code> commands on the <code>PATH</code></li>
<li>The <code>exiftool</code> command, for the privacy stage</li>
<li>A second drive with room for whatever you keep</li>
</ul>
<p>Every prompt carries three rules, worded for its stage. They&rsquo;re repeated on
purpose, so that a prompt pasted on its own still carries them:</p>
<ul>
<li><strong>One drive first.</strong> A bug found on the first drive costs one drive&rsquo;s time. The
same bug found late in a batch costs every drive before it.</li>
<li><strong>Break the check on purpose.</strong> A check that has never failed proves nothing.
The agent damages its own input or output, confirms the check catches it, and
then restores it.</li>
<li><strong>Say what couldn&rsquo;t be verified.</strong> Matching file sizes aren&rsquo;t matching content,
and a report that doesn&rsquo;t say so reads as if they were.</li>
</ul>
<!-- style-check: procedure -->
<h2 id="survey-every-drive-without-changing-anything">Survey every drive without changing anything<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#survey-every-drive-without-changing-anything"
     aria-label="Link to section: Survey every drive without changing anything" data-pagefind-ignore></a>
</h2>
<p>In Claude Code, from the folder that holds the drive folders, paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The folder &lt;footage-folder&gt; holds video from my truck&#39;s dashcam: one subfolder per drive, and one MP4 per camera per segment. Survey it without changing anything.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">For every drive folder, use ffprobe to read each segment&#39;s duration and GPS location tag. Report each drive&#39;s start time, length, GPS points, the distance between its two farthest points, and its size on disk. Ignore any ._ files macOS has left beside the clips on a FAT32 or exFAT drive.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only. Do not move, rename, re-encode, or delete anything.
</span></span><span class="line"><span class="cl">- Run on one drive first and show me the result before you run the rest.
</span></span><span class="line"><span class="cl">- The location tag is written in more than one format. Parse it permissively, and treat 0,0 as no fix, not as a place.
</span></span><span class="line"><span class="cl">- Before you run the whole batch, feed your parser a malformed tag on purpose and confirm that it reports the drive instead of skipping it silently.
</span></span><span class="line"><span class="cl">- Cache everything you read in one file, so that later steps don&#39;t read the drives again.
</span></span><span class="line"><span class="cl">- Then propose a way to sort the drives into routine and unusual, and wait. I&#39;ll tell you my regular routes.
</span></span><span class="line"><span class="cl">- Finish with a list of the drives you couldn&#39;t read and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p><strong>What went wrong here.</strong> I asked for road trips, and the agent ranked drives by
distance. The ranking itself showed there were none: no drive in the batch went
far. Distance was the wrong question, and only I could supply the right one: the
corridor across the I-90 bridges that most of the drives used. With that, the
classification became a corridor test. A drive whose every GPS point falls
inside a box around that corridor is a corridor drive; anything else is worth a
look. The test found 168 corridor drives, 1,594 GB, and 14 off the corridor
worth keeping,
171 GB. That&rsquo;s why the prompt ends by
waiting for you. Your routes are context the agent can&rsquo;t get from the files.</p>
<p>The parser also failed on its first real run, on a tag that ended in <code>/</code>. The
fix was a pattern that finds two signed numbers wherever they sit.
Expect the same from your own camera&rsquo;s metadata, and test both spellings.</p>
<h2 id="move-the-drives-you-keep">Move the drives you keep<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#move-the-drives-you-keep"
     aria-label="Link to section: Move the drives you keep" data-pagefind-ignore></a>
</h2>
<p>Decide which drives to keep from the survey, then paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Move these drives from &lt;footage-folder&gt; to &lt;backup-drive&gt;/&lt;kept-folder&gt;: &lt;list of drive folders&gt;.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Before you copy anything, compare the total size with the free space on &lt;backup-drive&gt;, and stop if it won&#39;t fit.
</span></span><span class="line"><span class="cl">- Copy first. After each drive, compare every file&#39;s size on the destination with the source.
</span></span><span class="line"><span class="cl">- Do not delete anything from the source until I say so. Show me the drives whose sizes all match, and wait.
</span></span><span class="line"><span class="cl">- When I confirm, remove the source files with a tool that removes only what it has copied, such as rsync --remove-source-files. Do not rely on shell-specific glob features.
</span></span><span class="line"><span class="cl">- Skip any folder that another process, such as Finder, is still writing to or moving.
</span></span><span class="line"><span class="cl">- Report the empty folders that a move leaves behind. Do not delete them, and make every later step skip them.
</span></span><span class="line"><span class="cl">- Move one drive first and show me the result.
</span></span><span class="line"><span class="cl">- Before the batch, truncate a copy in a scratch folder and confirm your size check reports it.
</span></span><span class="line"><span class="cl">- Say plainly that a size match isn&#39;t a content match, and list anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>Do not let an agent delete source footage in the same step that copies it. Copy,
verify on the destination, and approve the removal yourself. A move that copies
and then fails to delete costs disk space; a move that deletes and then fails to
copy costs the footage.</p>
</div><p><strong>What went wrong here.</strong> A queued move used a <code>zsh</code> glob qualifier that the
shell Claude Code runs commands in doesn&rsquo;t enable. It would have copied every file
and deleted none. Inspecting the running process caught it, and the move switched
to <code>rsync --remove-source-files</code>, which removes only what it copied. Later, a
cleanup command of the form <code>rm -f $B/*.jpg</code> was blocked by Claude Code&rsquo;s own
guard, because an unset <code>$B</code> would expand the path to <code>/*</code>. The cleanup wasn&rsquo;t
needed, and the guard was right to stop it.</p>
<p>The moves also left empty drive folders behind. Nothing failed because of them
until the hyperlapse run, in a later stage, which is why this prompt asks for
them to be reported now.</p>
<h2 id="find-a-landmark-from-computed-signals">Find a landmark from computed signals<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#find-a-landmark-from-computed-signals"
     aria-label="Link to section: Find a landmark from computed signals" data-pagefind-ignore></a>
</h2>
<p>GPS places a drive to within a 15-minute segment, which is too coarse to find a
bridge crossing. The picture has to do it, and the cheap way to read the picture
is to compute a few numbers per second once and reuse them. Paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">I want the moment in each drive where the truck passes &lt;landmark&gt;. The GPS has one point per segment, which is too coarse, so find it from the picture.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">1. From keyframes only, downscaled to 64x48, compute per-second signals for each camera: the mean change in green from the previous keyframe, and the mean red, green, and blue. Cache them per drive and per camera, so that no later step decodes video again.
</span></span><span class="line"><span class="cl">2. Propose a signal that marks &lt;landmark&gt;, and explain why it should separate the landmark from things that look like it.
</span></span><span class="line"><span class="cl">3. Propose candidates. Do not pick for me.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only on &lt;footage-folder&gt;. Write only under &lt;work-folder&gt;.
</span></span><span class="line"><span class="cl">- When you extract keyframes as raw frames, make sure ffmpeg doesn&#39;t duplicate them back up to the source frame rate.
</span></span><span class="line"><span class="cl">- Before you scan everything, calibrate the signal on a drive where I know the landmark is present and a drive where I know it isn&#39;t. If both read the same, suspect your extraction before the signal.
</span></span><span class="line"><span class="cl">- Run on one drive first.
</span></span><span class="line"><span class="cl">- Build a contact sheet with one row per drive, labeled with the drive&#39;s name, and one frame per candidate in time order. Write a picks file with your best guess for each drive, which I can edit. Then wait for me.
</span></span><span class="line"><span class="cl">- Ignore the first and last few minutes of each drive, where the truck is parking or leaving.
</span></span><span class="line"><span class="cl">- If a result contradicts something already confirmed, check your tool before you report the contradiction: extract the same frame two ways and compare them.
</span></span><span class="line"><span class="cl">- List the drives with no candidate, and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>For a tunnel, the signal turned out to be color. Auto-exposure keeps a tunnel&rsquo;s
brightness close to daylight&rsquo;s, but sodium lighting tips the front camera&rsquo;s red/blue
ratio above 1.0. A run of 25–150 s above that ratio, away from the first and last
3 minutes of the drive, found a tunnel on 71 of the 79 bridge drives
scanned. Seven of the eight misses were after dark. <a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in
dashcam video by the color of tunnel
lights</a> is the full account,
including what didn&rsquo;t work.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/bridge-daylight-vs-tunnel-sodium_hu_addb249c3b37c46e.jpg" alt="Two front-camera frames side by side. On the floating bridge in daylight the picture is blue-gray, with a red/blue ratio of 0.81. Inside the Mount Baker tunnel it is orange-brown under the sodium lamps, with a ratio of 1.37, and the road and cars are still well exposed." width="1600" height="640" loading="lazy" decoding="async"><figcaption>Mean red over mean blue for each whole frame, from the same drive. Plates blurred.</figcaption>
</figure>
<p><strong>What went wrong here.</strong> The first calibration read zero change between every
pair of keyframes, parked or moving. A signal that&rsquo;s flat on a known positive and
a known negative is a broken instrument, not a finding. The cause was ffmpeg
duplicating keyframes back up to 30 fps, so consecutive frames were identical.
<code>-fps_mode passthrough</code> fixed it. That bug is why the prompt asks for a known
positive and a known negative before the scan.</p>
<p>The automatic pick, one tunnel chosen by its position in the drive, was right
about two times in three. The rest landed on other tunnels. The contact sheet is
what turned an unreliable detector into a usable one. It showed one frame per
candidate, taken from the bridge side of the tunnel. From it, I confirmed 68
crossings, couldn&rsquo;t confirm two night drives, and found none on one. The first sheet was a flat grid
of every candidate frame, and it was easy to match a frame to the wrong drive.
One row per drive makes that mistake visible.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/review-sheet-one-row-per-drive_hu_bceefb337d7a67ac.jpg" alt="Three rows of a review contact sheet, one drive per row, each tile a frame from the bridge side of one tunnel candidate. Each row has a floating-bridge tile among freeway tiles, so the right pick stands out at a glance. The first row’s last three tiles are replaced by gray blocks." width="1600" height="768" loading="lazy" decoding="async"><figcaption>Three rows of a review sheet. Some frames are removed.</figcaption>
</figure>
<p>The worst wrong claim of the whole session came at this stage. Partway through,
the agent reported that the picks for some drives were the wrong tunnel, the Mercer
Island lid instead of the Mount Baker tunnel. The contact sheets had been built
with an output-side <code>-ss</code>, which ffmpeg applied after tiling, so the sheets showed
a different window from the one the picks named. Extracting one frame two ways
exposed it. The picks had been right, and the claim was retracted.</p>
<h2 id="cut-synced-clips-and-render-hyperlapses">Cut synced clips and render hyperlapses<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#cut-synced-clips-and-render-hyperlapses"
     aria-label="Link to section: Cut synced clips and render hyperlapses" data-pagefind-ignore></a>
</h2>
<p>With picks confirmed, paste:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">For each confirmed pick in &lt;picks-file&gt;, cut a clip from every camera, from &lt;seconds-before&gt; seconds before the landmark to &lt;seconds-after&gt; seconds after it, without re-encoding. Then render a 30x hyperlapse of every drive for every camera, decoding keyframes only.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Read only on &lt;footage-folder&gt;. Write only under &lt;work-folder&gt;.
</span></span><span class="line"><span class="cl">- A stream copy can start only on a keyframe. Measure each camera&#39;s keyframe timing first; do not assume the cameras share it.
</span></span><span class="line"><span class="cl">- A camera&#39;s first frames in a segment file can depend on frames at the end of the previous file. Cut across segment files in one pass, and check the joins.
</span></span><span class="line"><span class="cl">- After cutting, compare every camera&#39;s frame count, duration, and start_time. Same length isn&#39;t the same as in sync.
</span></span><span class="line"><span class="cl">- Pad a camera that stopped early with black frames, per segment, so that every camera stays the same length as the front camera. Give the black the footage&#39;s pixel format and color range, and check the range of every joined file.
</span></span><span class="line"><span class="cl">- Do not trust folder names or file timestamps for timing. If a camera restarted into another folder, measure its offset from cameras that see the same scene, in more than one window. If that offset doesn&#39;t land on one of the camera&#39;s keyframes, re-encode only that camera, cut at the exact frame, and tell me which files were re-encoded.
</span></span><span class="line"><span class="cl">- Test on one drive first, and include a drive where a camera stopped early.
</span></span><span class="line"><span class="cl">- Before the batch, shift one camera by a second on purpose and confirm that your sync check fails. Then restore it.
</span></span><span class="line"><span class="cl">- In the batch, one bad drive must be logged and skipped, not stop the run. Write each output to a temporary name and rename it when it&#39;s complete, so that a rerun resumes.
</span></span><span class="line"><span class="cl">- Time the test drive and estimate the batch from that.
</span></span><span class="line"><span class="cl">- If the run will outlive this session, start a watcher that tells me when the run ends or stops making progress.
</span></span><span class="line"><span class="cl">- List the drives that were skipped or padded, and anything you couldn&#39;t verify.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>Each keyframe becomes one frame at 30 fps, so a hyperlapse plays at 30× speed. A
36-minute drive became 72 s and took 4.4 minutes to render. The hyperlapses of
218 drives, five cameras each, came to 107 GB. <a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam clips
with ffmpeg</a> and <a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">make a
hyperlapse from keyframes with ffmpeg</a> cover each
step in full.</p>
<p><strong>What went wrong here.</strong> On the first drive I cut, the side-left camera came out
short. On other drives it&rsquo;s a different camera, or none, so the prompt measures
every camera. The frame-count comparison caught it twice:</p>
<ol>
<li>Its GOPs run across segment files. Cutting each segment separately and joining
them dropped the frames that reference the previous file: the camera came out
1.9 s short, with a jump at the join. One concat-demuxer pass over the source
segments keeps them.</li>
<li>It was still 29 frames short, because its keyframes fall at n + 0.967 s, not on
whole seconds like the other cameras&rsquo;. Landing each camera&rsquo;s in-point exactly
on its own keyframe makes every file start at time 0. An offset camera
starts a few frames early, and setting that clip&rsquo;s In point that many frames
in lines it up with the others.</li>
</ol>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/synced-clip-five-cameras_hu_321fa648b5047a9c.jpg" alt="The five cameras of a finished synced clip at the same second on the floating bridge. A container truck passing on the left fills the front and side-left views, and the pickup behind appears in the rear and Gear Guard views." width="1600" height="853" loading="lazy" decoding="async"><figcaption>One frame from each camera&rsquo;s cut file, all at the same timestamp. Plates blurred.</figcaption>
</figure>
<p>A third check, on <code>start_time</code>, caught a version that had the right length but
started 0.967 s late, which an editor might ignore.</p>
<p>The hyperlapse test drive caught two more. Two cameras came out at 1,303 frames
against the front camera&rsquo;s 2,170. The first guess was the black filler&rsquo;s pixel
format; the real cause was the rear camera stopping 33 s into a segment. The fix
is to pad every segment to the front camera&rsquo;s length. The filler has a trap of
its own: it has to be full range like the footage, which takes <code>-color_range pc</code>;
Apple&rsquo;s hardware encoder ignores <code>-pix_fmt yuvj420p</code>. A mismatched part doesn&rsquo;t make the stream-copy join fail.
The joined file takes the first part&rsquo;s label, so a camera whose file opens on
filler was labeled limited range throughout: 11 of more than a thousand hyperlapse files, found
and re-rendered later. Across 233 drive folders, six hyperlapse sets needed
black-filled segments.</p>
<p>On that same drive, the rear and side-left cameras had restarted into a sibling
folder whose name was 34 s later. The name was wrong by more than a second.
Correlating motion between the Gear Guard camera and the restarted rear camera,
which both face backward, measured 32.7 s ± 0.1 s across four independent
windows. Front against rear disagreed by 0.9 s between windows, because those two
cameras don&rsquo;t see the same scene. <a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">Rivian dashcam camera missing from a
drive</a> covers the restart.</p>
<p>Then the batch failed, and no check caught it. Background jobs in the session
stopped at a time limit, so the run was relaunched detached. A detached process
sends no notice when it ends. It crashed at 10:11 on one of the empty folders the
moves had left behind, and it sat dead until 16:48, when I asked what was next:
more than six and a half hours. Two
fixes came out of it, and both are in the prompt: an empty or bad drive is now a
logged skip, and a long run gets a watcher.</p>
<h2 id="keep-location-out-of-anything-you-publish">Keep location out of anything you publish<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#keep-location-out-of-anything-you-publish"
     aria-label="Link to section: Keep location out of anything you publish" data-pagefind-ignore></a>
</h2>
<p>Paste, for each file you mean to share:</p>
<!-- style-check: off -->
<div class="code-block code-block--wrap" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Prepare &lt;export-file&gt; for publishing.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Rules:
</span></span><span class="line"><span class="cl">- Treat GPS in any clip as private. List every metadata field that holds a location, with: exiftool -G1 -a &#34;-gps*&#34; &#34;-location*&#34; &lt;file&gt;
</span></span><span class="line"><span class="cl">- First run that command on an original clip from the truck and confirm that it finds the location. Then run it on the export.
</span></span><span class="line"><span class="cl">- If the export carries a location, write a copy without it, run the same command again, and show me the empty result.
</span></span><span class="line"><span class="cl">- List the first and last frames of every clip, and flag any frame from either end of a drive, any license plate, and any face. Do not decide for me what to blur or cut.
</span></span><span class="line"><span class="cl">- Also list creation dates, and any drive date or time in file names. Strip them with -map_metadata -1 and rename the files, unless I say otherwise.
</span></span><span class="line"><span class="cl">- List what this check can&#39;t see, such as a street sign or a house number in the picture.</span></span></code></pre></div></div>
  </div>
</div><!-- style-check: on -->
<p>Run the check on every export rather than reasoning about which files carry a
tag. In this work the answer differed by tool. The raw clips carry it. The
hyperlapse tool I used copies the first source file&rsquo;s metadata, GPS included,
so the set stays traceable; the <a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">published hyperlapse
script</a> strips it. Clips cut with the concat demuxer came
out without it, but a plain <code>-c copy</code> of a single file keeps it, and
<code>-map_metadata -1</code> removes it. Show only
public landmarks on screen, and never frames from either end of a drive.</p>
<figure><img src="https://looselydecoupled.com/triage-dashcam-footage-claude-code/interchange-curve-sunset_hu_6d94b69d2acd59a0.jpg" alt="A side-camera fisheye frame on the I-5/I-90 interchange curve at sunset: the low sun flares at the left edge, and the downtown Seattle skyline stands above the ramps and trees." width="1600" height="1280" loading="lazy" decoding="async"><figcaption>A public landmark mid-drive, raw fisheye, which keeps the sun in frame.</figcaption>
</figure>
<div class="admonition admonition--caution" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#i-octagon-alert"/></svg>
    <span>Caution</span>
  </p>
  <p>A clip from the truck&rsquo;s drive carries a GPS fix for the start of every segment, and
the first segment of a drive usually starts at home. Run the <code>exiftool</code> check on
every file before it leaves your machine.</p>
</div><!-- style-check: end procedure -->
<h2 id="detectors-that-didnt-work">Detectors that didn&rsquo;t work<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#detectors-that-didnt-work"
     aria-label="Link to section: Detectors that didn’t work" data-pagefind-ignore></a>
</h2>
<p>Four ideas failed, and each failure says something about the signal rather than
the threshold:</p>
<ul>
<li><strong>Parked footage.</strong> The hypothesis was that recording while parked padded every
drive. The measurement found 23 GB of 1,760 GB: with Gear Guard turned off, the
truck stops recording seconds after parking. The scan still paid for itself, because its per-second
signals fed every detector after it.</li>
<li><strong>Hard stops.</strong> The detector returned 875 candidates, and all 12 at the top
were turns into parking lots or ordinary stops. Turning changes the picture as
much as braking does.</li>
<li><strong>Traffic jams.</strong> Crawling traffic still changes the picture, because the
neighboring cars move.</li>
<li><strong>Garage boundaries by color.</strong> An overcast sky is as gray as a concrete
ceiling. It worked on a clear day and failed on a cloudy one, which is the case
to test first.</li>
</ul>
<p>One detector worked with a person in the loop. Close passes, read as a
one-sided spike on a side camera while the front camera shows steady driving,
were swamped by overpass shadows until the frame&rsquo;s overall brightness change was
subtracted. Of the top 60, 37 were real passes, and a contact sheet sorted them.
A human pass was cheaper than a better detector.</p>
<h2 id="failures-and-the-checks-that-caught-them">Failures and the checks that caught them<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#failures-and-the-checks-that-caught-them"
     aria-label="Link to section: Failures and the checks that caught them" data-pagefind-ignore></a>
</h2>
<div class="table-wrap" data-scrollable data-label="Table: Failure">
  <table>
    <thead>
      <tr><th scope="col">Failure</th><th scope="col">Caught by</th></tr>
    </thead>
    <tbody>
      <tr><td>No road trips in the footage</td><td>The distance ranking itself</td></tr>
      <tr><td>GPS parser failed on a tag ending in <code>/</code></td><td>An exception on the first run</td></tr>
      <tr><td>Move would copy but never delete</td><td>Inspecting the running process</td></tr>
      <tr><td><code>rm</code> with a possibly unset variable</td><td>Claude Code&rsquo;s command guard</td></tr>
      <tr><td>Every frame difference read zero</td><td>A known parked and a known moving segment</td></tr>
      <tr><td>Automatic tunnel pick wrong about a third of the time</td><td>A one-row-per-drive contact sheet</td></tr>
      <tr><td>Claim that the picks were the wrong tunnel</td><td>Extracting one frame two ways</td></tr>
      <tr><td>One camera short in a synced cut, 1.9 s and then 29 frames</td><td>Frame counts across cameras</td></tr>
      <tr><td>The same camera starting 0.967 s late</td><td>Inspecting <code>start_time</code></td></tr>
      <tr><td>Hyperlapse cameras at 1,303 frames, not 2,170</td><td>Frame counts on a test drive with a stopped camera</td></tr>
      <tr><td>Folder name said 34 s; the offset was 32.7 s</td><td>Motion correlation in four windows</td></tr>
      <tr><td>Detached run dead for more than six and a half hours</td><td>Nothing, until I asked; now a watcher</td></tr>
      <tr><td>Hard stops, jams, and garage color</td><td>A contact sheet, a count, and an overcast day</td></tr>
    </tbody>
  </table>
</div>
<h2 id="each-stage-in-depth">Each stage in depth<a class="heading-anchor" href="https://looselydecoupled.com/triage-dashcam-footage-claude-code/#each-stage-in-depth"
     aria-label="Link to section: Each stage in depth" data-pagefind-ignore></a>
</h2>
<p>Each later part of this series takes one stage and stands on its own:</p>
<ol start="2">
<li><a href="https://looselydecoupled.com/rivian-dashcam-gps-location-ffprobe/">Read the GPS location from Rivian dashcam video with
ffprobe</a>: the tag&rsquo;s spellings, a
parser, and how to strip it.</li>
<li><a href="https://looselydecoupled.com/rivian-dashcam-camera-missing-sibling-folder/">Rivian dashcam camera missing from a drive: it restarted in another
folder</a>: finding the sibling
folder and measuring its real offset.</li>
<li><a href="https://looselydecoupled.com/ffmpeg-cut-multi-camera-dashcam-clips-in-sync/">Cut multi-camera dashcam clips with ffmpeg without re-encoding, and keep them
in sync</a>: snapping each
camera to its own keyframe.</li>
<li><a href="https://looselydecoupled.com/ffmpeg-keyframe-hyperlapse/">Make a hyperlapse from keyframes with ffmpeg without decoding every
frame</a>: the 30× render, the padding, and the
full-range filler.</li>
<li><a href="https://looselydecoupled.com/find-landmark-dashcam-video-tunnel-light-color/">Finding a bridge in dashcam video by the color of tunnel
lights</a>: the detector, the
review sheet, and what didn&rsquo;t work.</li>
</ol>
]]></content:encoded>
    </item>
    
    <item>
      <title>Export Every Published Post From a Blogger Blog as JSON With the API in Python</title>
      <link>https://looselydecoupled.com/export-blogger-posts-json-python/</link>
      <guid isPermaLink="true">https://looselydecoupled.com/export-blogger-posts-json-python/</guid>
      <pubDate>Sat, 06 Dec 2025 00:00:00 +0000</pubDate>
      <dc:creator>Emmanuel Tsouris</dc:creator>
      <category>Python</category>
      <category>Automation</category>
      
      <description>You end up with a folder of JSON files, one per published post and named by its date and title, with the HTML body beside each when you want it separate.</description>
      <content:encoded><![CDATA[<div class="answer" data-answer="true">
  <p class="answer__label" data-pagefind-ignore>TL;DR</p>
  <p>Blogger&rsquo;s own export is one XML file. The v3 API gives you every published
post as a record, with its labels, dates, URL and HTML body, and the script
below writes each one to disk as JSON:</p>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python</span> <span class="console__token">export_blogger_posts.py</span> <span class="console__token">--blog-url</span> <span class="console__token">https://example.blogspot.com</span> <span class="console__token">--write-html</span></span></code></pre>
    </div>
  </div></div><p>The first run opens a browser to authorize a read-only scope and caches the
token; every run after that is silent. It needs a Desktop OAuth client from
Google Cloud, which takes the loopback redirect without any setup. A web-type
client is what stops most first runs: it needs <code>http://localhost:8080/</code> in its
authorized redirect URIs, or the consent screen ends in
<code>redirect_uri_mismatch</code>.</p>

</div>

<p>I started a blog on Blogger in 2001 and wrote there until 2012. Sixty-nine
posts, mostly VBScript and SQL for Systems Management Server, and I wanted
them as files before doing anything else with them. One record per post, the
HTML body separate from the metadata, in a shape a script can read. Blogger&rsquo;s
Settings page offers a single Atom XML file of the whole blog. The API offers
the posts.</p>
<!-- style-check: procedure -->
<h2 id="1-create-an-oauth-client">1. Create an OAuth client<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#1-create-an-oauth-client"
     aria-label="Link to section: 1. Create an OAuth client" data-pagefind-ignore></a>
</h2>
<p>The Blogger API needs a Google Cloud project with the Blogger API enabled and
an OAuth client of type <strong>Desktop</strong>. A web client works too, only if
<code>http://localhost:8080/</code> is in its authorized redirect URIs; a Desktop client
allows loopback redirects without listing them. Download the client JSON.</p>
<p>Two settings on the consent screen matter for a personal tool. The app can
stay in testing, and your own Google account has to be listed as a test user,
or sign-in fails with <code>access_denied</code> before the consent screen. An app
in testing gets
<a href="https://developers.google.com/identity/protocols/oauth2#expiration" rel="external noopener" target="_blank">short-lived refresh tokens<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/export-blogger-posts-json-python/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a>,
so a later run opens the browser again; the script treats a failed refresh as
a first run.</p>
<div class="admonition admonition--warning" role="note">
  <p class="admonition__label">
    <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/export-blogger-posts-json-python/#i-triangle-alert"/></svg>
    <span>Warning</span>
  </p>
  <p>The client JSON and the cached token are credentials. If the script lives in
a repository, add <code>client_secret*.json</code> and <code>.blogger_token.json</code> to its
<code>.gitignore</code> before the first commit, not after.</p>
</div><h2 id="2-install-the-libraries">2. Install the libraries<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#2-install-the-libraries"
     aria-label="Link to section: 2. Install the libraries" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">python3</span> <span class="console__token">-m</span> <span class="console__token">venv</span> <span class="console__token">.venv</span> <span class="console__token">&amp;&amp;</span> <span class="console__token">source</span> <span class="console__token">.venv/bin/activate</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">python</span> <span class="console__token">-m</span> <span class="console__token">pip</span> <span class="console__token">install</span> <span class="console__token">google-api-python-client</span> <span class="console__token">google-auth-oauthlib</span> <span class="console__token">python-dotenv</span></span></code></pre>
    </div>
  </div></div><p><code>python-dotenv</code> is optional; the script loads a <code>.env</code> when the module is
present and reads the environment either way.</p>
<h2 id="3-the-python-script">3. The Python script<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#3-the-python-script"
     aria-label="Link to section: 3. The Python script" data-pagefind-ignore></a>
</h2>
<div class="code-block" data-copyable data-copy-label="export_blogger_posts.py">
  <div class="code-block__head"><span class="code-block__lang code-block__file">export_blogger_posts.py</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Export every published post from a Blogger blog as one JSON file per post.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">argparse</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">pathlib</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">typing</span> <span class="kn">import</span> <span class="n">Any</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google.auth.exceptions</span> <span class="kn">import</span> <span class="n">RefreshError</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google.auth.transport.requests</span> <span class="kn">import</span> <span class="n">Request</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google.oauth2.credentials</span> <span class="kn">import</span> <span class="n">Credentials</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">google_auth_oauthlib.flow</span> <span class="kn">import</span> <span class="n">InstalledAppFlow</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">googleapiclient.discovery</span> <span class="kn">import</span> <span class="n">build</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">googleapiclient.errors</span> <span class="kn">import</span> <span class="n">HttpError</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SCOPES</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;https://www.googleapis.com/auth/blogger.readonly&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">parse_args</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">argparse</span><span class="o">.</span><span class="n">Namespace</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="kn">from</span> <span class="nn">dotenv</span> <span class="kn">import</span> <span class="n">load_dotenv</span>
</span></span><span class="line"><span class="cl">        <span class="n">load_dotenv</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ImportError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="o">.</span><span class="n">ArgumentParser</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s2">&#34;Export Blogger posts to JSON files.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--client-secret-file&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_CLIENT_SECRET_FILE&#34;</span><span class="p">,</span> <span class="s2">&#34;client_secret.json&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--token-file&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_TOKEN_FILE&#34;</span><span class="p">,</span> <span class="s2">&#34;.blogger_token.json&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--blog-id&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_BLOG_ID&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s2">&#34;--blog-url&#34;</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_BLOG_URL&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--output-dir&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_OUTPUT_DIR&#34;</span><span class="p">,</span> <span class="s2">&#34;exported_posts&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--auth-port&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">default</span><span class="o">=</span><span class="nb">int</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BLOGGER_AUTH_PORT&#34;</span><span class="p">,</span> <span class="s2">&#34;8080&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;Loopback port for the OAuth redirect; fixed so it is authorized once.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">add_argument</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--write-html&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">action</span><span class="o">=</span><span class="s2">&#34;store_true&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">help</span><span class="o">=</span><span class="s2">&#34;Also write each post&#39;s HTML body as a .html file.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">parser</span><span class="o">.</span><span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">get_credentials</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">client_secret</span><span class="p">:</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">token_path</span><span class="p">:</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">auth_port</span><span class="p">:</span> <span class="nb">int</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Credentials</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">creds</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">token_path</span><span class="o">.</span><span class="n">is_file</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Owner-only on macOS and Linux, including a token left by an older,</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># looser run. Windows ignores the mode; the file keeps its folder&#39;s.</span>
</span></span><span class="line"><span class="cl">        <span class="n">token_path</span><span class="o">.</span><span class="n">chmod</span><span class="p">(</span><span class="mo">0o600</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">creds</span> <span class="o">=</span> <span class="n">Credentials</span><span class="o">.</span><span class="n">from_authorized_user_file</span><span class="p">(</span><span class="n">token_path</span><span class="p">,</span> <span class="n">SCOPES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">creds</span> <span class="ow">and</span> <span class="n">creds</span><span class="o">.</span><span class="n">expired</span> <span class="ow">and</span> <span class="n">creds</span><span class="o">.</span><span class="n">refresh_token</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">creds</span><span class="o">.</span><span class="n">refresh</span><span class="p">(</span><span class="n">Request</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="n">RefreshError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="c1"># An app in testing expires its refresh tokens; consent again.</span>
</span></span><span class="line"><span class="cl">            <span class="n">creds</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">creds</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">creds</span><span class="o">.</span><span class="n">valid</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">flow</span> <span class="o">=</span> <span class="n">InstalledAppFlow</span><span class="o">.</span><span class="n">from_client_secrets_file</span><span class="p">(</span><span class="n">client_secret</span><span class="p">,</span> <span class="n">SCOPES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">creds</span> <span class="o">=</span> <span class="n">flow</span><span class="o">.</span><span class="n">run_local_server</span><span class="p">(</span><span class="n">port</span><span class="o">=</span><span class="n">auth_port</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># A new token file is created owner-only rather than tightened after.</span>
</span></span><span class="line"><span class="cl">        <span class="n">fd</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">token_path</span><span class="p">,</span> <span class="n">os</span><span class="o">.</span><span class="n">O_WRONLY</span> <span class="o">|</span> <span class="n">os</span><span class="o">.</span><span class="n">O_CREAT</span> <span class="o">|</span> <span class="n">os</span><span class="o">.</span><span class="n">O_TRUNC</span><span class="p">,</span> <span class="mo">0o600</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">with</span> <span class="n">os</span><span class="o">.</span><span class="n">fdopen</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="s2">&#34;w&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">token_file</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">token_file</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">creds</span><span class="o">.</span><span class="n">to_json</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">creds</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">resolve_blog_id</span><span class="p">(</span><span class="n">service</span><span class="p">:</span> <span class="n">Any</span><span class="p">,</span> <span class="n">blog_id</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">blog_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">blog_id</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">blog_id</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">blog_url</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;Either BLOGGER_BLOG_ID or BLOGGER_BLOG_URL must be provided.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">service</span><span class="o">.</span><span class="n">blogs</span><span class="p">()</span><span class="o">.</span><span class="n">getByUrl</span><span class="p">(</span><span class="n">url</span><span class="o">=</span><span class="n">blog_url</span><span class="p">)</span><span class="o">.</span><span class="n">execute</span><span class="p">()[</span><span class="s2">&#34;id&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">fetch_posts</span><span class="p">(</span><span class="n">service</span><span class="p">:</span> <span class="n">Any</span><span class="p">,</span> <span class="n">blog_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]]:</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="n">params</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;blogId&#34;</span><span class="p">:</span> <span class="n">blog_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;maxResults&#34;</span><span class="p">:</span> <span class="mi">500</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;fetchBodies&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;status&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;LIVE&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">page_token</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">page_token</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">params</span><span class="p">[</span><span class="s2">&#34;pageToken&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">page_token</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">service</span><span class="o">.</span><span class="n">posts</span><span class="p">()</span><span class="o">.</span><span class="n">list</span><span class="p">(</span><span class="o">**</span><span class="n">params</span><span class="p">)</span><span class="o">.</span><span class="n">execute</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="n">posts</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;items&#34;</span><span class="p">,</span> <span class="p">[]))</span>
</span></span><span class="line"><span class="cl">        <span class="n">page_token</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;nextPageToken&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">page_token</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">posts</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">slugify</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">slug</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">ch</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span> <span class="k">if</span> <span class="n">ch</span><span class="o">.</span><span class="n">isalnum</span><span class="p">()</span> <span class="k">else</span> <span class="s2">&#34;-&#34;</span> <span class="k">for</span> <span class="n">ch</span> <span class="ow">in</span> <span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;-&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">filter</span><span class="p">(</span><span class="kc">None</span><span class="p">,</span> <span class="n">slug</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;-&#34;</span><span class="p">)))</span> <span class="ow">or</span> <span class="s2">&#34;untitled&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">write_posts</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]],</span> <span class="n">output_dir</span><span class="p">:</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">write_html</span><span class="p">:</span> <span class="nb">bool</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">output_dir</span><span class="o">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">post</span> <span class="ow">in</span> <span class="n">posts</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">title</span> <span class="o">=</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;untitled&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">published</span> <span class="o">=</span> <span class="p">(</span><span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;published&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)[:</span><span class="mi">10</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">parts</span> <span class="o">=</span> <span class="p">[</span><span class="n">published</span><span class="p">,</span> <span class="n">slugify</span><span class="p">(</span><span class="n">title</span><span class="p">),</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;id&#34;</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">        <span class="n">base_name</span> <span class="o">=</span> <span class="s2">&#34;-&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">part</span> <span class="k">for</span> <span class="n">part</span> <span class="ow">in</span> <span class="n">parts</span> <span class="k">if</span> <span class="n">part</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">keys</span> <span class="o">=</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;id&#34;</span><span class="p">,</span> <span class="s2">&#34;status&#34;</span><span class="p">,</span> <span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="s2">&#34;url&#34;</span><span class="p">,</span> <span class="s2">&#34;published&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;updated&#34;</span><span class="p">,</span> <span class="s2">&#34;labels&#34;</span><span class="p">,</span> <span class="s2">&#34;author&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">record</span> <span class="o">=</span> <span class="p">{</span><span class="n">key</span><span class="p">:</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">keys</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">(</span><span class="n">output_dir</span> <span class="o">/</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">base_name</span><span class="si">}</span><span class="s2">.json&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">record</span><span class="p">,</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">False</span><span class="p">),</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">write_html</span> <span class="ow">and</span> <span class="n">post</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;content&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="p">(</span><span class="n">output_dir</span> <span class="o">/</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">base_name</span><span class="si">}</span><span class="s2">.html&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="n">post</span><span class="p">[</span><span class="s2">&#34;content&#34;</span><span class="p">],</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">args</span> <span class="o">=</span> <span class="n">parse_args</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">client_secret</span> <span class="o">=</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">client_secret_file</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">client_secret</span><span class="o">.</span><span class="n">exists</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="sa">f</span><span class="s2">&#34;Client secret file not found at </span><span class="si">{</span><span class="n">client_secret</span><span class="si">}</span><span class="s2">; &#34;</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;set BLOGGER_CLIENT_SECRET_FILE.&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">creds</span> <span class="o">=</span> <span class="n">get_credentials</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">client_secret</span><span class="p">,</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">token_file</span><span class="p">),</span> <span class="n">args</span><span class="o">.</span><span class="n">auth_port</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">service</span> <span class="o">=</span> <span class="n">build</span><span class="p">(</span><span class="s2">&#34;blogger&#34;</span><span class="p">,</span> <span class="s2">&#34;v3&#34;</span><span class="p">,</span> <span class="n">credentials</span><span class="o">=</span><span class="n">creds</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">blog_id</span> <span class="o">=</span> <span class="n">resolve_blog_id</span><span class="p">(</span><span class="n">service</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">blog_id</span><span class="p">,</span> <span class="n">args</span><span class="o">.</span><span class="n">blog_url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">posts</span> <span class="o">=</span> <span class="n">fetch_posts</span><span class="p">(</span><span class="n">service</span><span class="p">,</span> <span class="n">blog_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">write_posts</span><span class="p">(</span><span class="n">posts</span><span class="p">,</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">output_dir</span><span class="p">),</span> <span class="n">args</span><span class="o">.</span><span class="n">write_html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Exported </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">posts</span><span class="p">)</span><span class="si">}</span><span class="s2"> posts to </span><span class="si">{</span><span class="n">args</span><span class="o">.</span><span class="n">output_dir</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="n">HttpError</span> <span class="k">as</span> <span class="n">err</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Blogger API error: </span><span class="si">{</span><span class="n">err</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">main</span><span class="p">()</span></span></span></code></pre></div></div>
  </div>
</div><p>Three decisions in it are worth knowing before you run it.</p>
<p><strong>The redirect port is explicit.</strong> <code>run_local_server</code> listens on 8080 unless
told otherwise, and a web-type client rejects a redirect it was not told
about. The flag keeps the port in sight, so the URI you authorize on a web
client and the one the script uses can&rsquo;t drift apart. A Desktop client does
not need the entry.</p>
<p><strong>The scope is read-only.</strong> <code>blogger.readonly</code> is all an export needs. The
token this caches cannot post, edit or delete, so a leaked
<code>.blogger_token.json</code> is a smaller problem than it could be. On macOS and
Linux the script also makes the file readable by you alone. On Windows it
takes the permissions of the folder it&rsquo;s in, because
<a href="https://docs.python.org/3/library/os.html#os.chmod" rel="external noopener" target="_blank">Python&rsquo;s <code>os.chmod</code><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="https://looselydecoupled.com/export-blogger-posts-json-python/#i-external-link"/></svg><span class="visually-hidden"> (opens in a new tab)</span></a> can
set only the read-only flag there, so keep it in a folder only you can read.</p>
<p><strong>The blog is found by URL.</strong> <code>blogs().getByUrl</code> turns the address you know
into the numeric ID the rest of the API takes. Nothing numeric has to be
copied out of the Blogger dashboard. Pass <code>--blog-id</code> if you have it already.</p>
<h2 id="4-run-it">4. Run it<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#4-run-it"
     aria-label="Link to section: 4. Run it" data-pagefind-ignore></a>
</h2>
<div class="console"><div class="code-block code-block--wrap console__command" data-copyable data-copy-label="command">
    <div class="code-block__head"><span class="code-block__lang">Command</span></div>
    <div class="code-block__scroll"><pre data-copy-source><code><span class="line"><span class="prompt">$ </span><span class="console__token">export</span> <span class="console__token">BLOGGER_CLIENT_SECRET_FILE=client_secret.json</span>
</span><span class="line"><span class="prompt">$ </span><span class="console__token">python</span> <span class="console__token">export_blogger_posts.py</span> <span class="console__token">--blog-url</span> <span class="console__token">https://example.blogspot.com</span> <span class="console__token">--write-html</span></span></code></pre>
    </div>
  </div><div class="code-block code-block--wrap console__output">
    <p class="console__output-label">Output</p>
    <div class="code-block__scroll"><pre><code><span class="line"><span class="console__token">Exported</span> <span class="console__token">69</span> <span class="console__token">posts</span> <span class="console__token">to</span> <span class="console__token">exported_posts</span></span></code></pre>
    </div>
  </div></div><p>The first run opens a browser for consent and writes <code>.blogger_token.json</code>.
Each post becomes <code>YYYY-MM-DD-&lt;title-slug&gt;-&lt;postId&gt;.json</code>, and with
<code>--write-html</code> a <code>.html</code> beside it holding the body alone:</p>
<div class="code-block" data-copyable data-copy-label="Text">
  <div class="code-block__head"><span class="code-block__lang">Text</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">exported_posts/
</span></span><span class="line"><span class="cl">  2001-08-15-sql-to-return-identity-when-inserting-2676633582946536328.json
</span></span><span class="line"><span class="cl">  2001-08-15-sql-to-return-identity-when-inserting-2676633582946536328.html
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
  </div>
</div><p>Each record carries what the API returns for the post, with the body last:</p>
<div class="code-block" data-copyable data-copy-label="JSON">
  <div class="code-block__head"><span class="code-block__lang">JSON</span>
  </div>
  <div class="code-block__scroll">
    <div data-copy-source><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;4298533488206221767&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;Reading the SCCM Client GUID&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;http://example.blogspot.com/2012/03/reading-sccm-client-guid.html&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;published&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-03-27T15:43:00-07:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;updated&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-04-30T12:00:20-07:00&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;labels&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;PowerShell&#34;</span><span class="p">,</span> <span class="s2">&#34;SCCM&#34;</span><span class="p">,</span> <span class="s2">&#34;VBScript&#34;</span><span class="p">,</span> <span class="s2">&#34;WMI&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;author&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;displayName&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;p&gt;...&lt;/p&gt;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
  </div>
</div><p><code>status</code> came back null on every post in this export. The API sets it only
for admin-level requests, and the script doesn&rsquo;t make one.</p>
<!-- style-check: end procedure -->
<h2 id="common-failures">Common failures<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#common-failures"
     aria-label="Link to section: Common failures" data-pagefind-ignore></a>
</h2>
<h3 id="redirect_uri_mismatch-on-the-consent-screen"><code>redirect_uri_mismatch</code> on the consent screen<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#redirect_uri_mismatch-on-the-consent-screen"
     aria-label="Link to section: redirect_uri_mismatch on the consent screen" data-pagefind-ignore></a>
</h3>
<p>The client is web-type and
<code>http://localhost:8080/</code> isn&rsquo;t in its authorized redirects; the trailing
slash is part of the match. Add it, or recreate the client as Desktop.</p>
<h3 id="access_denied-before-the-consent-screen"><code>access_denied</code> before the consent screen<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#access_denied-before-the-consent-screen"
     aria-label="Link to section: access_denied before the consent screen" data-pagefind-ignore></a>
</h3>
<p>The app is in testing and your
account isn&rsquo;t a test user. Add it under the OAuth consent screen.</p>
<h3 id="fewer-posts-than-expected">Fewer posts than expected<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#fewer-posts-than-expected"
     aria-label="Link to section: Fewer posts than expected" data-pagefind-ignore></a>
</h3>
<p>The script asks for 500 posts a page and follows <code>nextPageToken</code> until the
API stops sending one. It asks for published posts only, so drafts and
scheduled posts aren&rsquo;t in the count.</p>
<h3 id="old-files-after-a-second-export">Old files after a second export<a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#old-files-after-a-second-export"
     aria-label="Link to section: Old files after a second export" data-pagefind-ignore></a>
</h3>
<p>The script writes into the output folder and never removes anything. A post
renamed since the last run leaves its old file beside the new one, and an
unpublished post keeps its file. Export into an empty folder, with
<code>--output-dir</code>, when the folder has to match the blog.</p>
<h3 id="client-secret-file-not-found"><code>Client secret file not found</code><a class="heading-anchor" href="https://looselydecoupled.com/export-blogger-posts-json-python/#client-secret-file-not-found"
     aria-label="Link to section: Client secret file not found" data-pagefind-ignore></a>
</h3>
<p>The path is read from
<code>BLOGGER_CLIENT_SECRET_FILE</code> or <code>--client-secret-file</code> and is relative to the
directory you run from, not the script&rsquo;s.</p>
<div class="tested" id="tested">
  <p class="label label--seam">Tested with</p><dl class="tested__rows">
    <div class="tested__row"><dt>Python</dt> <dd>3.13</dd></div>
    <div class="tested__row"><dt>google-api-python-client</dt> <dd>2.187</dd></div>
    <div class="tested__row"><dt>google-auth-oauthlib</dt> <dd>1.2</dd></div>
    <div class="tested__row"><dt>Blog</dt> <dd>69 posts, 2001 to 2012</dd></div>
    <div class="tested__row"><dt>Date</dt> <dd>December 2025</dd></div>
  </dl>
</div>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
