<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>VFLARED MODDING LLC</title>
  <link>https://vflared.com/</link>
  <description>Write-ups and tech tips from the VFLARED workbench: Windows, homelab builds and hardware projects.</description>
  <language>en-us</language>
  <lastBuildDate>Fri, 16 Oct 2026 12:00:00 GMT</lastBuildDate>
  <atom:link href="https://vflared.com/rss.xml" rel="self" type="application/rss+xml" />
  <item>
    <title>I Rebuilt SpaceMonger for Windows 11</title>
    <link>https://vflared.com/posts/spacewindow</link>
    <guid isPermaLink="true">https://vflared.com/posts/spacewindow</guid>
    <pubDate>Fri, 16 Oct 2026 12:00:00 GMT</pubDate>
    <category>DEV</category>
    <description>SpaceMonger, the classic 1998 disk-space map, rebuilt from its MIT-licensed source as SpaceWindow: C++20, WinUI 3 and Direct2D for x64 and ARM64, with full-drive scans up to 13× faster.</description>
    <content:encoded><![CDATA[
<p>SpaceMonger has been around since 1998, and it's still one of the quickest ways to see where your disk space went. The catch is its age: it was built for a Windows long gone. It's a 32-bit MFC app that scans on a single thread, looks blurry on high-DPI screens and skips paths longer than 260 characters.</p>
<p>Thankfully, Sean Werkema released the source code for version 1.4.0 under the MIT License (<a href="https://github.com/seanofw/spacemonger1" target="_blank" rel="noopener noreferrer">seanofw/spacemonger1</a>).</p>

<h2>Meet SpaceWindow</h2>
<p>SpaceWindow is a remake written in C++20 with WinUI 3 and Direct2D, for x64 and ARM64, with macOS and Linux versions in the works.</p>
<figure>
  <img src="https://vflared.com/images/posts/spacewindow-treemap.webp" width="1100" height="740" loading="lazy" alt="SpaceWindow's treemap of a Visual Studio Build Tools folder, with every file drawn as a box sized by its space on disk" />
  <figcaption>The treemap view: every file is a box sized by the space it takes on disk, nested inside the boxes of its folders.</figcaption>
</figure>
<p>What's new:</p>
<ul>
  <li>Treemap, pie chart and inverted pie chart views</li>
  <li>As administrator, whole NTFS drives are read straight from the master file table (MFT)</li>
  <li><strong>Reload</strong> reads only what changed, using the NTFS change journal or folder watching</li>
  <li>Themes (Windows light and dark, Dracula, Alucard or your own) and optional background animations</li>
  <li>Full keyboard navigation</li>
</ul>
<figure>
  <img src="https://vflared.com/images/posts/spacewindow-inverted-pie.webp" width="1100" height="740" loading="lazy" alt="SpaceWindow's inverted pie chart of the same folder, with the deepest folders at the centre and the top level as the outer ring" />
  <figcaption>The inverted pie chart turns the usual sunburst inside out: the top-level folders form the outer ring, and each ring inward goes one level deeper.</figcaption>
</figure>

<h2>At a glance</h2>
<div class="table-wrap">
<table class="wrap">
  <thead>
    <tr><th scope="col"></th><th scope="col">SpaceMonger 1.4.0</th><th scope="col">SpaceWindow</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">Runs on</th><td>Windows 95, 98, NT</td><td>Windows 11 (x64 and ARM64)</td></tr>
    <tr><th scope="row">Language</th><td>C++ for Visual C++ 6, ANSI strings</td><td>C++20, Unicode throughout</td></tr>
    <tr><th scope="row">User interface</th><td>MFC and GDI</td><td>WinUI 3 with Mica and Fluent design</td></tr>
    <tr><th scope="row">Drawing</th><td>9-pixel bitmap fonts, stretched on high-DPI screens</td><td>Direct2D and DirectWrite at the screen's real resolution</td></tr>
    <tr><th scope="row">Scanning</th><td>One thread, recursive</td><td>A parallel worker pool; whole NTFS drives from the MFT as administrator; reloads read only what changed</td></tr>
    <tr><th scope="row">Views</th><td>Treemap</td><td>Treemap, pie chart and inverted pie chart</td></tr>
    <tr><th scope="row">Themes</th><td>Windows' colours</td><td>Light and dark, Dracula, Alucard and your own</td></tr>
    <tr><th scope="row">Paths</th><td>Up to 260 characters</td><td>Any length</td></tr>
    <tr><th scope="row">Keyboard</th><td>Mouse only</td><td>Full keyboard navigation and shortcuts</td></tr>
    <tr><th scope="row">Tests</th><td>None</td><td>98 unit tests, drive tests, a UI smoke test, AddressSanitizer and static analysis</td></tr>
  </tbody>
</table>
</div>

<h2>Where the speed comes from</h2>
<p><strong>Reading the MFT.</strong> As administrator, SpaceWindow doesn't open a single folder. It reads the drive's master file table, where NTFS keeps every file's names, sizes and attributes, in 4 MB blocks with six reads in flight, and decodes each block on four cores while the next ones arrive. The tree is assembled in parallel too, with every hard-linked file counted once, so the whole scan takes little longer than the disk takes to hand over the table: 2.8 million records of 1 KB on my drive.</p>
<p><strong>Reloading only what changed.</strong> The NTFS change journal lists every file changed since the last scan, so Reload reads just those files and patches the folders they're in. The tree keeps an index of its folders and large files between reloads, so the work grows with what changed rather than with the size of the drive. If the journal can't tell (it was reset, or too much changed), Reload scans the drive again. Where there is no journal to use (a single folder, a network share, or a scan without administrator rights) the scanned folder is watched from the moment the scan starts, and Reload re-lists only the folders that changed.</p>
<p><strong>Listing folders in parallel.</strong> Without administrator rights, a pool of worker threads shares a queue of folders: three for every four cores on SSDs, more on network shares to hide their latency, and only two on spinning disks, where parallel reads would make the heads seek. Each listing returns thousands of entries per system call, along with every file's allocation size and reparse tag, so no file has to be opened. Each folder is totalled and sorted by whichever thread finishes its last sub-folder, so there's no second pass over the tree.</p>

<h2>Benchmarks</h2>
<p>Both programs scanning the whole of a 1 TB NVMe system drive with 1.94 million files, on a 16-thread Ryzen 9 6900HS with a warm file-system cache, the median of three runs. SpaceWindow is timed from the moment it starts, so its start-up counts against it:</p>
<ul>
  <li>Full scan as administrator: 32.2 s → 2.5 s (13× faster)</li>
  <li>Full scan as a standard user: 31.0 s → 4.8 s (6.5× faster)</li>
  <li>Scanner alone on <code>C:\Windows</code>: 4.75 s → 0.445 s (10.7× faster)</li>
  <li>Reload after changes: about 0.1 s instead of a full rescan</li>
</ul>
<p>The trade-off is memory: peak use roughly doubles, 509–606 MB against SpaceMonger's 254–264 MB. SpaceWindow is a 64-bit program that keeps more about each file (its size on disk, and the IDs that hard links and reloads need) and draws with WinUI and Direct3D.</p>

<h2>Get it</h2>
<p>SpaceWindow is free and open source under the MIT License, available as an installer or a portable zip from the <a href="https://github.com/TechieAndroid/SpaceWindow/releases" target="_blank" rel="noopener noreferrer">releases page</a>. It needs Windows 11 on x64 or ARM64. The downloads aren't code-signed yet, so SmartScreen may say "Windows protected your PC": choose <em>More info</em>, then <em>Run anyway</em>.</p>
<p>Big thanks to Sean Werkema for the original program and for releasing its source.</p>
<p>Feel free to check it out and send feedback — I'm always looking for ways to improve my work. The full project is on GitHub: <a href="https://github.com/TechieAndroid/SpaceWindow" target="_blank" rel="noopener noreferrer">TechieAndroid/SpaceWindow</a>, and I also shared it <a href="https://www.linkedin.com/posts/akilgore98_windows-windows11-cplusplus-share-7516909195917533185--VL7/" target="_blank" rel="noopener noreferrer">on LinkedIn</a>.</p>
    ]]></content:encoded>
  </item>
  <item>
    <title>Repair Windows Without Starting Over: The In-Place Upgrade</title>
    <link>https://vflared.com/tech-tips/windows-in-place-upgrade</link>
    <guid isPermaLink="true">https://vflared.com/tech-tips/windows-in-place-upgrade</guid>
    <pubDate>Wed, 07 Oct 2026 20:49:00 GMT</pubDate>
    <category>WINDOWS</category>
    <description>When DISM and SFC can’t fix it, an in-place upgrade rebuilds the entire Windows system folder from clean installation media while keeping your apps, files and settings. It’s the closest thing to a fresh install, without the reinstall.</description>
    <content:encoded><![CDATA[
<p>When Windows starts misbehaving, with failed updates, corrupted system files or errors that survive every fix, the usual last resort is to wipe the drive and reinstall. Before you do that, try an <strong>in-place upgrade</strong>, also called a repair install. It reinstalls Windows on top of itself from official installation media and keeps your apps, files and settings.</p>

<h2>What an in-place upgrade actually does</h2>
<p>Windows Setup builds a brand-new <code>C:\Windows</code> system folder from the installation media, moves your apps, files and settings into it, and sets the previous installation aside as <code>C:\Windows.old</code>. Every system file comes from a clean copy, and the clutter that builds up over years of updates stays behind: superseded versions of system components, old update caches and logs. Windows normally keeps superseded components for a while so updates can be rolled back (<a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/clean-up-the-winsxs-folder" target="_blank" rel="noopener noreferrer">Microsoft Learn: Clean Up the WinSxS Folder</a>). A rebuilt system folder starts without them.</p>
<p>Depending on the media you use, it runs either as an upgrade to a newer build or as a reinstall of the build you already have. Both rebuild the system folder the same way and give you the same benefits.</p>

<h2>Start with the built-in repair tools</h2>
<p>For a quick fix, run Windows’ own repair commands from an elevated Terminal first:</p>
<pre><code>DISM /Online /Cleanup-Image /RestoreHealth
sfc /scannow</code></pre>
<p>DISM repairs the Windows component store, the local source Windows uses for clean copies of system files (<a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/repair-a-windows-image" target="_blank" rel="noopener noreferrer">Microsoft Learn: Repair a Windows Image</a>). System File Checker then scans every protected system file and replaces incorrect versions (<a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/sfc" target="_blank" rel="noopener noreferrer">Microsoft Learn: sfc</a>). These tools fix corruption, but they only repair what’s already there. They don’t clear out years of accumulated clutter, and they can come up short when the damage runs deep. Repair commands only get you so far. An in-place upgrade gets you farther.</p>
<p><strong>A shortcut on Windows 11:</strong> <em>Settings › System › Recovery › Fix problems using Windows Update › Reinstall now</em> performs a similar repair reinstall of the version you’re running, downloaded through Windows Update, with no ISO needed. It requires Windows 11 version 22H2 or later with the February 2024 optional update or newer (<a href="https://support.microsoft.com/en-us/windows/deployment/install-upgrade/fix-issues-by-reinstalling-the-current-version-of-windows" target="_blank" rel="noopener noreferrer">Microsoft Support: Fix issues by reinstalling the current version of Windows</a>). The ISO method below doesn’t need those updates, and it can also move you to a newer build.</p>

<h2>Before you start</h2>
<ul>
  <li><strong>Back up anything important.</strong> An in-place upgrade is designed to keep everything, but no system-level repair is risk-free.</li>
  <li><strong>Use matching media.</strong> To keep your apps and files, the media must match your installation’s language and architecture, and be the same or a newer version of Windows. If Setup won’t offer to keep your apps and files, mismatched media is the usual cause.</li>
  <li><strong>Leave plenty of free space.</strong> Setup needs room for the new system folder while it keeps the old one.</li>
  <li><strong>Have your BitLocker recovery key handy</strong> if the drive is encrypted, and plug laptops into power.</li>
</ul>

<h2>How to run it</h2>
<ol>
  <li><strong>Download the installation media.</strong> On Microsoft’s <a href="https://www.microsoft.com/software-download/windows11" target="_blank" rel="noopener noreferrer">Windows 11 download page</a>, use the Media Creation Tool and choose <em>ISO file</em>, or download the disk image (ISO) directly from the same page. The tool needs a 64-bit PC running Windows 10 version 2004 or later, and it only creates media for x64 processors.</li>
  <li><strong>Mount the ISO.</strong> Right-click the file and choose <em>Mount</em>. Windows opens it as a virtual DVD drive that you can browse in File Explorer.</li>
  <li><strong>Run Setup from within Windows.</strong> Open the drive and run <code>setup.exe</code>. Booting from the media instead only offers a clean install.</li>
  <li><strong>Keep everything.</strong> Follow the prompts, and when Setup asks what to keep, choose <em>Keep personal files and apps</em>.</li>
  <li><strong>Let it finish.</strong> The PC restarts several times along the way. Afterwards, run Windows Update and check that your apps and devices work.</li>
</ol>

<h2>Before and after on my PC</h2>
<p>Here’s what an in-place upgrade did on one of my own machines. This one also moved the PC to a newer Windows 11 build.</p>
<div class="figure-pair">
  <figure>
    <img src="https://vflared.com/images/tech-tips/windows-old-properties.png" width="363" height="481" loading="lazy" alt="Properties of C:\Windows.old: 50.6 GB, 724,787 files and 287,460 folders" />
    <figcaption><code>C:\Windows.old</code>: the previous installation. Besides the old Windows folder, it holds Setup’s copies of Program Files, ProgramData and user folders, so its totals cover more than Windows itself.</figcaption>
  </figure>
  <figure>
    <img src="https://vflared.com/images/tech-tips/windows-properties.png" width="363" height="481" loading="lazy" alt="Properties of the new C:\Windows: 64.6 GB, 143,846 files and 34,933 folders" />
    <figcaption>The rebuilt <code>C:\Windows</code>.</figcaption>
  </figure>
</div>
<p>Side by side, here are the totals from those two Properties windows:</p>
<div class="table-wrap">
<table>
  <thead>
    <tr><th scope="col"></th><th scope="col">Previous installation (<code>C:\Windows.old</code>)</th><th scope="col">New <code>C:\Windows</code></th><th scope="col">Change</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">Folders</th><td>287,460</td><td>34,933</td><td>−88%</td></tr>
    <tr><th scope="row">Files</th><td>724,787</td><td>143,846</td><td>−80%</td></tr>
    <tr><th scope="row">Size</th><td>50.6 GB</td><td>64.6 GB</td><td>+14.0 GB</td></tr>
    <tr><th scope="row">Size on disk</th><td>51.6 GB</td><td>64.3 GB</td><td>+12.7 GB</td></tr>
  </tbody>
</table>
</div>
<p>The rebuilt system folder holds far fewer folders and files. Part of that drop is old copies of apps and user folders, since <code>C:\Windows.old</code> holds the whole previous installation (see the caption above); the rest is accumulated clutter that didn’t make the trip. That’s less for Windows servicing, antivirus scans and backups to work through.</p>
<p>Both size figures went up, by about 13–14 GB. Size is a poor measure of clutter here, though: File Explorer counts a hard-linked file once for every place it appears, so its figures for the Windows folder overstate what’s actually on disk (<a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/determine-the-actual-size-of-the-winsxs-folder" target="_blank" rel="noopener noreferrer">Microsoft Learn: Determine the Actual Size of the WinSxS Folder</a>). The file and folder counts are the better yardstick.</p>

<h2>Cleaning up afterwards</h2>
<p>Keep <code>C:\Windows.old</code> until you’re sure everything works. It’s what Windows uses if you choose to go back under <em>Settings › System › Recovery</em>, and by default that option lasts 10 days (<a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/dism-uninstallos-command-line-options" target="_blank" rel="noopener noreferrer">Microsoft Learn: DISM Operating System Uninstall Command-Line Options</a>). To reclaim the space sooner, go to <em>Settings › System › Storage › Temporary files</em> and select <em>Previous Windows installation(s)</em>.</p>

<h2>The bottom line</h2>
<p>Repair commands fix individual broken files. An in-place upgrade replaces the whole system folder with a clean one, which is as close as you can get to a fresh install without losing your apps, files and settings. Make it your next step before you reach for a full wipe.</p>

<h2>Microsoft documentation</h2>
<ul>
  <li><a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/repair-a-windows-image" target="_blank" rel="noopener noreferrer">Repair a Windows Image</a> (DISM)</li>
  <li><a href="https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/sfc" target="_blank" rel="noopener noreferrer">sfc</a> (System File Checker)</li>
  <li><a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/clean-up-the-winsxs-folder" target="_blank" rel="noopener noreferrer">Clean Up the WinSxS Folder</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/determine-the-actual-size-of-the-winsxs-folder" target="_blank" rel="noopener noreferrer">Determine the Actual Size of the WinSxS Folder</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/dism-uninstallos-command-line-options" target="_blank" rel="noopener noreferrer">DISM Operating System Uninstall Command-Line Options</a></li>
  <li><a href="https://support.microsoft.com/en-us/windows/deployment/install-upgrade/fix-issues-by-reinstalling-the-current-version-of-windows" target="_blank" rel="noopener noreferrer">Fix issues by reinstalling the current version of Windows</a> (Microsoft Support)</li>
  <li><a href="https://www.microsoft.com/software-download/windows11" target="_blank" rel="noopener noreferrer">Download Windows 11</a> (Media Creation Tool and ISO)</li>
</ul>
    ]]></content:encoded>
  </item>
  <item>
    <title>Building a Self-Hosted NVR Web Gallery with Live Streaming</title>
    <link>https://vflared.com/posts/self-hosted-nvr-gallery</link>
    <guid isPermaLink="true">https://vflared.com/posts/self-hosted-nvr-gallery</guid>
    <pubDate>Wed, 27 May 2026 12:00:00 GMT</pubDate>
    <category>SYS</category>
    <description>How I built a self-hosted NVR web interface with live WebRTC streaming, a recording gallery browser, and on-device storage — using Reolink cameras, go2rtc, vsftpd, and nginx on my Arch Linux homelab.</description>
    <content:encoded><![CDATA[
<p>I've been running a couple of Reolink RLC-520A cameras around my house for a while now, and while the Reolink app is fine for casual use, I wanted something more: a self-hosted web interface I could pull up on any browser on my local network, browse recordings by date, watch clips without downloading anything, and see live feeds from both cameras in one place. This post walks through how I built it from scratch on my Arch Linux homelab server.</p>

<h2>The Stack</h2>
<p>Everything runs on my main server — a Ryzen 5 8600G with 16GB RAM. The full stack ended up being:</p>
<ul>
  <li><strong>vsftpd</strong> — FTP server that receives recordings directly from the cameras</li>
  <li><strong>go2rtc</strong> — RTSP-to-WebRTC bridge for live streaming</li>
  <li><strong>nginx</strong> — serves the frontend and proxies the go2rtc WebSocket</li>
  <li><strong>Docker Compose</strong> — ties nginx and go2rtc together</li>
  <li>A single-file vanilla JS/HTML frontend — no frameworks, no build step</li>
</ul>

<h2>Step 1: FTP Server for Camera Uploads</h2>
<p>The Reolink cameras support FTP upload for recordings and snapshots. I set up vsftpd on the server with anonymous upload enabled, pointed at <code>/disks/nvme2tb/NVR/</code> with subdirectories for each camera.</p>
<p>Getting vsftpd to cooperate with the Reolink firmware took more debugging than expected. The cameras insist on attempting <code>AUTH TLS</code> before every connection, which caused vsftpd to respond with a <code>530</code> error and leave the camera stuck in a login loop. The fix was enabling SSL properly with a self-signed cert and setting <code>force_anon_logins_ssl=NO</code> and <code>force_anon_data_ssl=NO</code> — letting TLS negotiate without requiring it.</p>
<p>The other gotcha was vsftpd's chroot security check. The <code>anon_root</code> directory can't be writable, so the solution was to make <code>/disks/nvme2tb/NVR</code> owned by root with <code>755</code> permissions, while the actual camera subdirectories (<code>Front/</code> and <code>Rear/</code>) are owned by the <code>ftp</code> user and fully writable.</p>
<p>The final vsftpd config that works:</p>
<pre><code>anonymous_enable=YES
no_anon_password=YES
anon_root=/disks/nvme2tb/NVR
anon_upload_enable=YES
anon_mkdir_write_enable=YES
anon_other_write_enable=YES
write_enable=YES
local_enable=NO
ssl_enable=YES
allow_anon_ssl=YES
force_anon_logins_ssl=NO
force_anon_data_ssl=NO
require_ssl_reuse=NO
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=40100
pasv_address=192.168.50.61
seccomp_sandbox=NO
isolate_network=NO
pam_service_name=vsftpd</code></pre>
<p>Three lines in there deserve a note before you copy them. <code>seccomp_sandbox=NO</code> and <code>isolate_network=NO</code> turn off two of vsftpd's extra sandboxing layers; that's acceptable on a server that only listens on your LAN, but leave them at their defaults on anything that faces the internet. The same goes for anonymous upload with no password: it works here because nothing outside my network can reach the server, so on a network you share with people you don't trust, give the cameras a real FTP account instead. And <code>pasv_address</code> is the server's own LAN address, so put yours there.</p>
<p>The cameras auto-organize uploads into <code>Front/YYYY/MM/DD/</code> and <code>Rear/YYYY/MM/DD/</code> directories, which made building the date browser in the frontend trivial.</p>

<h2>Step 2: The Frontend Gallery</h2>
<p>Rather than reaching for a framework, I wrote the entire frontend as a single <code>index.html</code> served by nginx. It fetches nginx's JSON autoindex output to walk the directory tree and build the file list entirely client-side.</p>
<p>Key features:</p>
<p><strong>Recordings browser</strong> — files grouped by time of day (Morning / Afternoon / Evening / Night), paginated at 48 per page with options up to "All". Each card shows the camera name, file size, date, and time in 12-hour format extracted from the filename.</p>
<p><strong>Video thumbnails</strong> — generated client-side using a hidden <code>&lt;video&gt;</code> element and <code>&lt;canvas&gt;</code>. An <code>IntersectionObserver</code> queues thumbnail generation only as cards scroll into view, with a concurrency limit of 3 simultaneous extractions to avoid overwhelming the browser. Generated thumbnails are cached in memory so switching filters and pages doesn't re-generate them.</p>
<p><strong>Bookmarks</strong> — a ☆ icon on each card that fills to ★ when clicked. Bookmarks persist in <code>localStorage</code> and a dedicated filter button shows only saved items.</p>
<p><strong>Today summary</strong> — when viewing all cameras with no filters, a summary bar at the top shows today's file counts, video/image breakdown, and total storage consumed.</p>
<p><strong>Modal viewer</strong> — click any card to open it fullscreen with keyboard navigation (← / → arrows), a download button, and prev/next controls.</p>

<h2>Step 3: Live Streaming with go2rtc</h2>
<p>The cameras expose RTSP streams at port 554. Browsers can't consume RTSP directly, so I added go2rtc to the Docker Compose stack. It pulls the RTSP stream from each camera and serves it over WebRTC via a WebSocket signaling channel.</p>
<p>The cameras shoot H.265 at 2560×1920 / 25fps / 8192 Kbps. Firefox doesn't support H.265 in WebRTC at all, and Chrome on some platforms is hit-or-miss, so I configured go2rtc to transcode to H.264 using the Ryzen's integrated GPU via VAAPI:</p>
<pre><code>streams:
  front:
    - rtsp://admin:PASSWORD@192.168.50.125:554/h264Preview_01_main
    - ffmpeg:front#video=h264#hardware=vaapi#audio=opus
  rear:
    - rtsp://admin:PASSWORD@192.168.50.246:554/h264Preview_01_main
    - ffmpeg:rear#video=h264#hardware=vaapi#audio=opus

ffmpeg:
  h264: "-codec:v h264_vaapi -g:v 25 -keyint_min:v 25 -force_key_frames:v expr:gte(t\\,n_forced*1) -preset:v fast -profile:v baseline -level:v 3.1"</code></pre>
<p>The forced keyframe every second (<code>-g:v 25</code> at 25fps) was critical — without it, new WebRTC connections would often render a grey corrupted frame for several seconds while waiting for the camera's natural IDR keyframe interval.</p>
<p>The WebRTC signaling in the frontend connects directly to go2rtc's WebSocket API at <code>/api/ws?src=front&amp;video=h264&amp;audio=opus</code>, explicitly requesting the H.264 track:</p>
<pre><code>const ws = new WebSocket(`ws://${location.host}/api/ws?src=${cam}&amp;video=h264&amp;audio=opus`);

ws.onopen = async () => {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  ws.send(JSON.stringify({ type: 'webrtc/offer', value: offer.sdp }));
};

ws.onmessage = async e => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'webrtc/answer')
    await pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
  else if (msg.type === 'webrtc/candidate')
    await pc.addIceCandidate({ candidate: msg.value, sdpMid: '0' });
};</code></pre>
<p>Streams auto-reconnect on disconnect, show a live clock timestamp overlay, and have a fullscreen button.</p>

<h2>Step 4: Stats and Cleanup</h2>
<p>The Stats tab shows total file counts, per-camera storage breakdown, and a 30-day bar chart of recordings per day. Clicking any bar drills into that day's detail — files by type, size, camera, and time-of-day distribution.</p>
<figure>
  <img src="https://vflared.com/images/posts/nvr-stats.webp" width="1480" height="405" loading="lazy" alt="The gallery's Stats tab: totals for 26,682 files and 425 GB, a bar chart of recordings per day over the last 30 days, and storage bars for the front and rear cameras" />
  <figcaption>The Stats tab after a month of recordings: 26,682 files across both cameras, and the front camera using most of the 425 GB.</figcaption>
</figure>
<p>For storage management I wrote a simple cleanup script that removes mp4 files older than 7 days from both camera directories and cleans up empty date folders:</p>
<pre><code>mapfile -t FILES < <(find "$CAM_DIR" -type f -name "*.mp4" -mtime +7)
for f in "${FILES[@]}"; do
    SZ=$(stat -c%s "$f")
    TOTAL_BYTES=$((TOTAL_BYTES + SZ))
    rm -f "$f"
done
find "$CAM_DIR" -mindepth 1 -type d -empty -delete</code></pre>
<p>Calculating sizes before deletion (not after) was the key detail — <code>du</code> on already-deleted files returns zero.</p>

<h2>Docker Compose</h2>
<p>The full stack is two containers:</p>
<pre><code>services:
  go2rtc:
    image: alexxit/go2rtc:latest-hardware
    container_name: nvr-go2rtc
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./go2rtc.yaml:/config/go2rtc.yaml:ro

  nvr-gallery:
    image: nginx:alpine
    container_name: nvr-gallery
    ports:
      - "8889:80"
    volumes:
      - /disks/nvme2tb/NVR:/data/NVR:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./gallery:/usr/share/nginx/html:ro
    depends_on:
      - go2rtc</code></pre>
<p>go2rtc runs in <code>network_mode: host</code> so it can reach the cameras on the LAN directly and bind to the WebRTC UDP ports without NAT complications. nginx handles the gallery frontend and proxies go2rtc's API for the WebSocket signaling.</p>

<h2>Lessons Learned</h2>
<p><strong>vsftpd's <code>AUTH TLS</code> handling</strong> — The Reolink firmware always attempts TLS negotiation. Disabling SSL entirely causes <code>530 Please login with USER and PASS</code> in response to <code>AUTH TLS</code>, which confuses the camera firmware into an infinite reconnect loop. You have to actually enable SSL and let the handshake complete.</p>
<p><strong>go2rtc signaling</strong> — The go2rtc API uses WebSocket-based signaling, not HTTP POST like older documentation suggests. The correct endpoint is <code>/api/ws?src=STREAM_NAME</code> with JSON messages of type <code>webrtc/offer</code>, <code>webrtc/answer</code>, and <code>webrtc/candidate</code>.</p>
<p><strong>H.265 in browsers</strong> — Firefox doesn't support H.265 in WebRTC at all. Even in Chrome, receiving H.265 without a proper IDR keyframe produces a grey corrupted image. Transcoding to H.264 with forced keyframes solved both problems cleanly.</p>
<p><strong>nftables policy drop</strong> — My server's default nftables config drops everything not explicitly allowed. FTP passive mode needs both port 21 and the PASV range (40000-40100) open, and go2rtc needs ports 1984, 8554, and 8555. Easy to miss if you're used to permissive default firewall configs.</p>
<p>The end result is a clean, fast, fully local NVR web interface that works on every browser and device on my network. No cloud, no subscriptions, no data leaving the house. The full project is on GitHub: <a href="https://github.com/TechieAndroid/nvr" target="_blank" rel="noopener noreferrer">TechieAndroid/nvr</a>.</p>
    ]]></content:encoded>
  </item>
</channel>
</rss>
