<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hasham Tanveer</title>
    <description>The latest articles on DEV Community by Hasham Tanveer (@hasham41).</description>
    <link>https://dev.to/hasham41</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4120975%2Fc50e0656-d883-45cb-8e6f-f091ebc9a927.png</url>
      <title>DEV Community: Hasham Tanveer</title>
      <link>https://dev.to/hasham41</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hasham41"/>
    <language>en</language>
    <item>
      <title>How I Built an Offline YouTube Playlist Downloader &amp; Merger with FastAPI, React, and FFmpeg</title>
      <dc:creator>Hasham Tanveer</dc:creator>
      <pubDate>Fri, 11 Sep 2026 13:23:46 +0000</pubDate>
      <link>https://dev.to/hasham41/how-i-built-an-offline-youtube-playlist-downloader-merger-with-fastapi-react-and-ffmpeg-49n</link>
      <guid>https://dev.to/hasham41/how-i-built-an-offline-youtube-playlist-downloader-merger-with-fastapi-react-and-ffmpeg-49n</guid>
      <description>&lt;p&gt;Most web-based video downloaders share the same limitations: intrusive pop-ups, 15-minute duration caps, throttled cloud processing, and the pain of managing dozens of individual, unsorted video files.&lt;/p&gt;

&lt;p&gt;To solve this, I built &lt;strong&gt;&lt;a href="https://tubemerger.com" rel="noopener noreferrer"&gt;TubeMerger&lt;/a&gt;&lt;/strong&gt;—a local-first, open-source desktop application that downloads YouTube playlists and stitches selected videos into a single master MP4 file, complete with seekable chapter markers.&lt;/p&gt;

&lt;p&gt;In this article, I'll walk through the architectural hurdles of building TubeMerger: avoiding the performance overhead of Electron, overcoming common FFmpeg concatenation traps, and streaming live progress metrics from Python to React.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Core Architecture
&lt;/h2&gt;

&lt;p&gt;Instead of bundling an Electron runtime that bloats memory usage, TubeMerger uses a decoupled, local-first stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; React 19 + TypeScript + Vite + Tailwind CSS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop Window Container:&lt;/strong&gt; &lt;a href="https://pywebview.flowrl.com/" rel="noopener noreferrer"&gt;PyWebView&lt;/a&gt; (leveraging native OS web engines: WebKit/Cocoa on macOS and WebView2 on Windows).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local Application Server:&lt;/strong&gt; FastAPI running over &lt;code&gt;uvicorn&lt;/code&gt; on localhost with an SQLite WAL datastore for merge queues and job history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media Processing:&lt;/strong&gt; &lt;code&gt;yt-dlp&lt;/code&gt; for stream extraction and &lt;code&gt;FFmpeg&lt;/code&gt; for per-clip normalization and final concatenation.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-------------------------------------------------------------+
|                 PyWebView (Native Desktop OS)               |
|  +-------------------------------------------------------+  |
|  |             React 19 / Tailwind SPA Client            |  |
|  +-------------------------------------------------------+  |
|                              ▲                              |
|                    SSE Events / JSON REST                   |
|                              ▼                              |
|  +-------------------------------------------------------+  |
|  |           FastAPI Local Server (Uvicorn / SQLite)     |  |
|  +-------------------------------------------------------+  |
|                              ▲                              |
|                              ▼                              |
|  +-------------------------------------------------------+  |
|  |              yt-dlp  ───►  FFmpeg Pipeline            |  |
|  +-------------------------------------------------------+  |
+-------------------------------------------------------------+

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The Hard Part: Why You Can't Just "Concat" Playlist Clips
&lt;/h2&gt;

&lt;p&gt;If you run ffmpeg&lt;/p&gt;

&lt;p&gt;If you run ffmpeg -f concat directly on raw downloaded videos from an arbitrary YouTube playlist, the stitch often fails or produces corrupt, desynced media. Here's why:&lt;/p&gt;

&lt;p&gt;Inconsistent Video Dimensions: Creators upload videos across different years with varying resolutions (e.g., 720p mixed with 1080p or old 4:3 clips).&lt;/p&gt;

&lt;p&gt;Mismatched Audio Frequencies: One clip might use 44.1 kHz AAC while another uses 48 kHz Opus. Concat demuxers drop audio completely when sample rates drift across segments.&lt;/p&gt;

&lt;p&gt;Variable Frame Rates (VFR): Web-streamed clips frequently switch timing profiles, causing progressive audio-video desync over long renders.&lt;/p&gt;

&lt;p&gt;The Normalization Strategy&lt;br&gt;
To stitch clips reliably without pillarboxing or audio drift, each selected clip goes through a normalization step before passing to the concat demuxer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_normalization_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_w&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:force_original_aspect_ratio=decrease,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pad=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_w&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:(ow-iw)/2:(oh-ih)/2:black,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;setsar=1,fps=30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;21&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-preset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aac&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-ar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;44100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-ac&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-b:a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;output_file&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By enforcing:&lt;/p&gt;

&lt;p&gt;A locked frame rate (fps=30)&lt;/p&gt;

&lt;p&gt;A consistent canvas size and aspect ratio with letterbox padding (pad)&lt;/p&gt;

&lt;p&gt;Uniform stereo AAC audio re-encoded at 44.1 kHz&lt;/p&gt;

&lt;p&gt;The downstream concat demuxer runs seamlessly without audio drops or stuttering.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Automated Chapter Marker Injection
&lt;/h2&gt;

&lt;p&gt;A 4-hour combined video file is cumbersome to navigate without chapter markers. TubeMerger generates native MP4 chapter atoms using the original video titles and duration metadata.&lt;/p&gt;

&lt;p&gt;Before merging, an FFMETADATAFILE is generated programmatically:&lt;br&gt;
`&lt;br&gt;
Ini, TOML&lt;br&gt;
;FFMETADATA1&lt;br&gt;
major_brand=isom&lt;br&gt;
minor_version=512&lt;br&gt;
compatible_brands=isomiso2avc1mp41&lt;/p&gt;

&lt;p&gt;[CHAPTER]&lt;br&gt;
TIMEBASE=1/1000&lt;br&gt;
START=0&lt;br&gt;
END=450000&lt;br&gt;
title=01. Introduction &amp;amp; Overview&lt;/p&gt;

&lt;p&gt;[CHAPTER]&lt;br&gt;
TIMEBASE=1/1000&lt;br&gt;
START=450000&lt;br&gt;
END=1230000&lt;br&gt;
title=02. Architecture &amp;amp; Setup&lt;br&gt;
During the final concatenation pass, the metadata file is attached as an extra input stream:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;shell&lt;br&gt;
ffmpeg -f concat -safe 0 -i concat_list.txt -i metadata.txt -map_metadata 1 -c copy output.mp4&lt;br&gt;
Because -c copy is used, chapter injection takes only a few seconds. The resulting MP4 opens in players like VLC, QuickTime, and Windows Media Player with clickable, labeled chapters.&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Real-Time Telemetry via Server-Sent Events (SSE)
&lt;/h2&gt;

&lt;p&gt;Long-running local jobs require responsive UI feedback. Instead of polling REST endpoints every second, the FastAPI backend pushes live progress metrics to the React frontend using Server-Sent Events:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`python&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/router"&gt;@router&lt;/a&gt;.get("/api/progress")&lt;br&gt;
async def stream_progress(request: Request):&lt;br&gt;
    async def event_generator():&lt;br&gt;
        while True:&lt;br&gt;
            if await request.is_disconnected():&lt;br&gt;
                break&lt;br&gt;
            status = merge_engine.get_active_metrics()&lt;br&gt;
            yield {&lt;br&gt;
                "event": "update",&lt;br&gt;
                "data": json.dumps(status)&lt;br&gt;
            }&lt;br&gt;
            await asyncio.sleep(0.5)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return EventSourceResponse(event_generator())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On the client side, React subscribes to the stream via an EventSource, updating live download speeds, active clip encoding steps, and estimated time remaining.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Summary &amp;amp; Getting Started
&lt;/h2&gt;

&lt;p&gt;Processing everything locally avoids cloud server costs, bypasses artificial duration limits, and ensures full privacy—no URLs, video titles, or media streams are transmitted off your machine.&lt;/p&gt;

&lt;p&gt;Website: tubemerger.com&lt;/p&gt;

&lt;p&gt;Desktop Downloads: tubemerger.com/download&lt;/p&gt;

&lt;p&gt;Source Code: github.com/hashamtanveer-41/tubemerger&lt;/p&gt;

&lt;p&gt;If you're building cross-platform desktop applications with Python and modern web tooling, check out the repository, file an issue, or try running the build locally!&lt;/p&gt;

</description>
      <category>python</category>
      <category>react</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
