<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.jackbodine.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.jackbodine.com/" rel="alternate" type="text/html" /><updated>2026-04-12T15:44:01+00:00</updated><id>https://www.jackbodine.com/feed.xml</id><title type="html">Jack Bodine’s Website</title><subtitle>Host site for the projects, essay and photos of Jack Bodine.</subtitle><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><entry><title type="html">Expanding The Short Form Video App</title><link href="https://www.jackbodine.com/blog/short-form-app-iii/" rel="alternate" type="text/html" title="Expanding The Short Form Video App" /><published>2026-03-27T00:00:00+00:00</published><updated>2026-03-27T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/short-form-app-iii</id><content type="html" xml:base="https://www.jackbodine.com/blog/short-form-app-iii/"><![CDATA[<p>This is the third and final part of my series on making a self-hosted short form media app. The first two parts can be read <a href="https://www.jackbodine.com/blog/short-form-app/">here</a>  and <a href="https://www.jackbodine.com/blog/short-form-app-ii/">here</a> if you haven’t already done so. This third part focuses on adding some useful extensions to the project we have completed up to this point. I will also elaborate a bit further on my motivation for this project.</p>

<h2 id="adding-image-support">Adding Image Support</h2>
<p>The instagram scraping script we wrote in Part I, indiscriminately scrapes both video posts and image posts. Until now the app completely ignored the downloaded jpgs and solely focused on processing and serving the videos. There are a couple considerations when adding support to also ingest and serve the image posts. First off, a specific instagram post can consist of multiple images, in which case we want them all grouped and served together. Secondly, not all .jpgs in the raw media folder are posts, in fact most of them are thumbnails attached to some video post, so we need to deliberately select which jpgs get processed.</p>

<p>The ideal way to implement image posts in the front end would be to create a paginated horizontal scroll view for each image in a post; and to serve either the video player or scroll view depending on the next post. However, this would require quite a bit of extra work on the frontend which is unnecessary for the scope of this project. Instead we can take a clever shortcut. All we have to do is tweak our HLS conversion script to also convert the image files to HLS. This way, there is no necessary change to the frontend of the app. By tricking the native video player into treating static image carousels as low-framerate video streams, we completely bypass the headache of writing and maintaining a separate, complex image pagination UI.</p>

<p>The following code, which you can simply append to the preprocess script from Part 1, iterates over every image in the raw directory. First it checks if a processed directory already exists, if it does, that means it was a video that has already been processed. Second, it checks how many images share the same base name. In the case there is just one, it sets the <code class="language-plaintext highlighter-rouge">ffmpeg</code> arguments to convert it to a single segment HLS file. If there are multiple images that share the base name, each become one segment of the resulting file. Keys are processed the exact same way as they were in Part 1.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>f <span class="k">in</span> <span class="s2">"</span><span class="nv">$RAW_DIR</span><span class="s2">"</span>/<span class="k">*</span>.@<span class="o">(</span>jpg|webp<span class="o">)</span><span class="p">;</span> <span class="k">do</span>
    <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue

    </span><span class="nv">filename</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="nt">--</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span><span class="si">)</span>
    <span class="nv">no_ext</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">filename</span><span class="p">%.*</span><span class="k">}</span><span class="s2">"</span>

    <span class="c"># Strip any trailing _1, _2, etc to get the base folder name</span>
    <span class="nv">foldername</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$no_ext</span><span class="s2">"</span> | <span class="nb">sed</span> <span class="nt">-E</span> <span class="s1">'s/_[0-9]+$//'</span><span class="si">)</span>

    <span class="nv">OUTPUT_FOLDER</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PROCESSED_DIR</span><span class="s2">/</span><span class="nv">$foldername</span><span class="s2">"</span>

    <span class="c"># If the folder already exists, it means either Pass 1 grabbed the mp4,</span>
    <span class="c"># or an earlier pass over '_1.jpg' already processed this carousel. </span>
    <span class="k">if</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
        continue
    fi

    </span><span class="nb">echo</span> <span class="s2">"Processing IMAGE/GROUP: </span><span class="nv">$foldername</span><span class="s2">..."</span>

	<span class="c"># Same as video processing</span>
    <span class="nv">KEY_FOLDER</span><span class="o">=</span><span class="s2">"</span><span class="nv">$KEYS_DIR</span><span class="s2">/</span><span class="nv">$foldername</span><span class="s2">"</span>
    <span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">"</span>
    openssl rand 16 <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/video.key"</span>
    <span class="nv">IV</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 16<span class="si">)</span>
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$BASE_KEY_URL</span><span class="s2">/</span><span class="nv">$foldername</span><span class="s2">/key"</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/video.key"</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$IV</span><span class="s2">"</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>

    <span class="nb">declare</span> <span class="nt">-a</span> FFMPEG_ARGS

   <span class="c"># Determine if its a carousel or a single image</span>
   <span class="nv">group_jpgs</span><span class="o">=(</span><span class="s2">"</span><span class="nv">$RAW_DIR</span><span class="s2">/</span><span class="k">${</span><span class="nv">foldername</span><span class="k">}</span><span class="s2">_"</span><span class="k">*</span>.jpg<span class="o">)</span>
   <span class="nv">group_webps</span><span class="o">=(</span><span class="s2">"</span><span class="nv">$RAW_DIR</span><span class="s2">/</span><span class="k">${</span><span class="nv">foldername</span><span class="k">}</span><span class="s2">_"</span><span class="k">*</span>.webp<span class="o">)</span>

   <span class="k">if</span> <span class="o">[</span> <span class="k">${#</span><span class="nv">group_jpgs</span><span class="p">[@]</span><span class="k">}</span> <span class="nt">-gt</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
       <span class="c"># Grouped JPG</span>
       <span class="nv">FFMPEG_ARGS</span><span class="o">=(</span><span class="s2">"-framerate"</span> <span class="s2">"1/2"</span> <span class="s2">"-i"</span> <span class="s2">"</span><span class="nv">$RAW_DIR</span><span class="s2">/</span><span class="k">${</span><span class="nv">foldername</span><span class="k">}</span><span class="s2">_%d.jpg"</span> <span class="s2">"-c:v"</span> <span class="s2">"libx264"</span> <span class="s2">"-vf"</span> <span class="s2">"format=yuv420p,scale=trunc(iw/2)*2:trunc(ih/2)*2"</span><span class="o">)</span>
   <span class="k">elif</span> <span class="o">[</span> <span class="k">${#</span><span class="nv">group_webps</span><span class="p">[@]</span><span class="k">}</span> <span class="nt">-gt</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
       <span class="c"># Grouped WEBP</span>
       <span class="nv">FFMPEG_ARGS</span><span class="o">=(</span><span class="s2">"-framerate"</span> <span class="s2">"1/2"</span> <span class="s2">"-i"</span> <span class="s2">"</span><span class="nv">$RAW_DIR</span><span class="s2">/</span><span class="k">${</span><span class="nv">foldername</span><span class="k">}</span><span class="s2">_%d.webp"</span> <span class="s2">"-c:v"</span> <span class="s2">"libx264"</span> <span class="s2">"-vf"</span> <span class="s2">"format=yuv420p,scale=trunc(iw/2)*2:trunc(ih/2)*2"</span><span class="o">)</span>
   <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$filename</span><span class="s2">"</span> <span class="o">==</span> <span class="k">*</span>.jpg <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
        <span class="c"># Single JPG</span>
        <span class="nv">FFMPEG_ARGS</span><span class="o">=(</span><span class="s2">"-loop"</span> <span class="s2">"1"</span> <span class="s2">"-i"</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="s2">"-t"</span> <span class="s2">"3"</span> <span class="s2">"-c:v"</span> <span class="s2">"libx264"</span> <span class="s2">"-vf"</span> <span class="s2">"format=yuv420p,scale=trunc(iw/2)*2:trunc(ih/2)*2"</span><span class="o">)</span>
    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$filename</span><span class="s2">"</span> <span class="o">==</span> <span class="k">*</span>.webp <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
        <span class="c"># Single WEBP</span>
        <span class="nv">FFMPEG_ARGS</span><span class="o">=(</span><span class="s2">"-loop"</span> <span class="s2">"1"</span> <span class="s2">"-i"</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="s2">"-t"</span> <span class="s2">"3"</span> <span class="s2">"-c:v"</span> <span class="s2">"libx264"</span> <span class="s2">"-vf"</span> <span class="s2">"format=yuv420p,scale=trunc(iw/2)*2:trunc(ih/2)*2"</span><span class="o">)</span>
    <span class="k">fi

    if </span>ffmpeg <span class="nt">-y</span> <span class="s2">"</span><span class="k">${</span><span class="nv">FFMPEG_ARGS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="nt">-r</span> 30 <span class="nt">-g</span> 60 <span class="nt">-keyint_min</span> 60 <span class="nt">-sc_threshold</span> 0 <span class="se">\</span>
        <span class="nt">-hls_time</span> 2 <span class="nt">-hls_list_size</span> 0 <span class="se">\</span>
        <span class="nt">-hls_key_info_file</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span> <span class="se">\</span>
        <span class="nt">-hls_segment_filename</span> <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">/chunk_%03d.ts"</span> <span class="se">\</span>
        <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">/index.m3u8"</span> <span class="p">;</span> <span class="k">then

        </span><span class="nb">echo</span> <span class="s2">"Finished </span><span class="nv">$foldername</span><span class="s2">"</span>
    <span class="k">else
        </span><span class="nb">echo</span> <span class="s2">"FFmpeg failed on </span><span class="nv">$foldername</span><span class="s2">."</span>
    <span class="k">fi
    </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>
<span class="k">done</span>

</code></pre></div></div>

<p>That’s it! Without touching any frontend code, we could now run the sync endpoint and our app would be serving both videos and images. I think it’s super cool we can do this without touching the frontend. However, it would be nice to pass along the information and show the user how many images are connected to that post. This can easily be done with an update to the sync function, model and UI.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># In sync_videos()
</span>            <span class="n">meta_is_video</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">.../Reels/Raw/</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s">.mp4</span><span class="sh">"</span><span class="p">)</span>

            <span class="n">meta_post_image_count</span> <span class="o">=</span> <span class="mi">1</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="n">meta_is_video</span><span class="p">:</span>
                <span class="n">jpgs</span> <span class="o">=</span> <span class="n">glob</span><span class="p">.</span><span class="nf">glob</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">.../Reels/Raw/</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s">_*.jpg</span><span class="sh">"</span><span class="p">)</span>
                <span class="n">webps</span> <span class="o">=</span> <span class="n">glob</span><span class="p">.</span><span class="nf">glob</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">.../Reels/Raw/</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s">_*.webp</span><span class="sh">"</span><span class="p">)</span>
                <span class="n">carousel_count</span> <span class="o">=</span> <span class="nf">len</span><span class="p">(</span><span class="n">jpgs</span><span class="p">)</span> <span class="o">+</span> <span class="nf">len</span><span class="p">(</span><span class="n">webps</span><span class="p">)</span>

                <span class="k">if</span> <span class="n">carousel_count</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">:</span>
                    <span class="n">meta_post_image_count</span> <span class="o">=</span> <span class="n">carousel_count</span>
                    

<span class="bp">...</span>

			<span class="n">new_video</span> <span class="o">=</span> <span class="nc">Video</span><span class="p">(</span>  
			    <span class="bp">...</span>
			    <span class="n">is_video</span><span class="o">=</span><span class="n">meta_is_video</span><span class="p">,</span>  
			    <span class="n">post_image_count</span><span class="o">=</span><span class="n">meta_post_image_count</span>  
			<span class="p">)</span>
</code></pre></div></div>

<p>Then after updating the model to support the <code class="language-plaintext highlighter-rouge">post_image_count</code> and <code class="language-plaintext highlighter-rouge">is_video</code> columns, adding the following lines to the ReelsOverlay.swift in the frontend.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"TYPE"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">isVideo</span> <span class="p">??</span> <span class="kc">true</span> <span class="p">?</span> <span class="s">"VIDEO"</span> <span class="p">:</span> <span class="s">"IMAGE"</span><span class="p">)</span>
<span class="k">if</span> <span class="n">video</span><span class="o">.</span><span class="n">isVideo</span> <span class="o">==</span> <span class="kc">false</span> <span class="p">{</span>
	<span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"IMAGE COUNT"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="s">"</span><span class="se">\(</span><span class="n">video</span><span class="o">.</span><span class="n">postImageCount</span> <span class="p">??</span> <span class="mi">1</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="progress-bar">Progress Bar</h2>

<p>The next fun addition is a progress bar that shows how far into each video you are. We can also expand this to show how many total images are in each photo post.</p>

<p>To pull this off in SwiftUI, we need to track the current time of the <code class="language-plaintext highlighter-rouge">AVPlayer</code> and use that to calculate a percentage for our progress bar. We also need to calculate which page of a photo post we are currently viewing. Because we set our image segments to be exactly 2 seconds long in the previous section, to calculate the current page we just divide the current time by two.</p>

<p>Let’s start by adding the necessary state tracking and computed properties to our <code class="language-plaintext highlighter-rouge">ReelsOverlay</code> view:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">currentTime</span><span class="p">:</span> <span class="kt">Double</span> <span class="o">=</span> <span class="mi">0</span>
<span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">activeObserver</span><span class="p">:</span> <span class="p">(</span><span class="nv">player</span><span class="p">:</span> <span class="kt">AVPlayer</span><span class="p">,</span> <span class="nv">token</span><span class="p">:</span> <span class="kt">Any</span><span class="p">)?</span> <span class="o">=</span> <span class="kc">nil</span>

<span class="kd">private</span> <span class="k">var</span> <span class="nv">currentPage</span><span class="p">:</span> <span class="kt">Int</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">count</span> <span class="o">=</span> <span class="n">video</span><span class="o">.</span><span class="n">postImageCount</span> <span class="p">??</span> <span class="mi">1</span>
    <span class="k">return</span> <span class="nf">min</span><span class="p">(</span><span class="kt">Int</span><span class="p">(</span><span class="n">currentTime</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="n">count</span><span class="p">)</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="k">var</span> <span class="nv">videoProgress</span><span class="p">:</span> <span class="kt">Double</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="k">let</span> <span class="nv">item</span> <span class="o">=</span> <span class="n">player</span><span class="o">.</span><span class="n">currentItem</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="mi">0</span> <span class="p">}</span>
    <span class="k">let</span> <span class="nv">duration</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">duration</span><span class="o">.</span><span class="n">seconds</span>
    <span class="k">guard</span> <span class="n">duration</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">duration</span><span class="o">.</span><span class="n">isFinite</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="mi">0</span> <span class="p">}</span>
    <span class="k">return</span> <span class="nf">min</span><span class="p">(</span><span class="nf">max</span><span class="p">(</span><span class="n">currentTime</span> <span class="o">/</span> <span class="n">duration</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span> <span class="mi">1</span><span class="p">)</span>
<span class="p">}</span>

</code></pre></div></div>

<p>With the logic in place, we can build the UI. We’ll use a <code class="language-plaintext highlighter-rouge">GeometryReader</code> placed at the top of our main <code class="language-plaintext highlighter-rouge">ZStack</code> to draw an edge-to-edge line across the screen. We animate the width of the inner rectangle so that it smoothly ticks forward as the <code class="language-plaintext highlighter-rouge">videoProgress</code> increases.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">GeometryReader</span> <span class="p">{</span> <span class="n">geo</span> <span class="k">in</span>
	<span class="kt">Rectangle</span><span class="p">()</span>
		<span class="o">.</span><span class="nf">fill</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">white</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.3</span><span class="p">))</span>
		<span class="o">.</span><span class="nf">overlay</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span> <span class="p">{</span>
			<span class="kt">Rectangle</span><span class="p">()</span>
				<span class="o">.</span><span class="nf">fill</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
				<span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="n">geo</span><span class="o">.</span><span class="n">size</span><span class="o">.</span><span class="n">width</span> <span class="o">*</span> <span class="kt">CGFloat</span><span class="p">(</span><span class="n">videoProgress</span><span class="p">))</span>
				<span class="o">.</span><span class="nf">animation</span><span class="p">(</span><span class="o">.</span><span class="nf">linear</span><span class="p">(</span><span class="nv">duration</span><span class="p">:</span> <span class="mf">0.1</span><span class="p">),</span> <span class="nv">value</span><span class="p">:</span> <span class="n">videoProgress</span><span class="p">)</span>
		<span class="p">}</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">height</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span>
<span class="o">.</span><span class="nf">zIndex</span><span class="p">(</span><span class="mi">100</span><span class="p">)</span> <span class="c1">// Ensure it stays on top of all the other overlay UI</span>
</code></pre></div></div>

<p>The last and most important step is actually feeding the time data from the <code class="language-plaintext highlighter-rouge">AVPlayer</code> into our <code class="language-plaintext highlighter-rouge">currentTime</code> variable. Thankfully, <code class="language-plaintext highlighter-rouge">AVPlayer</code> provides a method called <code class="language-plaintext highlighter-rouge">addPeriodicTimeObserver</code> for exactly this purpose.</p>

<p>We can attach this observer when the view appears. However, because our app uses an infinite scroll view that recycles video players, if we don’t manually remove this observer when the video disappears from the screen, it will create a memory leak and crash the app.</p>

<p>We handle this safely by saving the observer token to our <code class="language-plaintext highlighter-rouge">activeObserver</code> state tuple.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">onAppear</span> <span class="p">{</span>
    <span class="c1">// Update 10 times a second</span>
    <span class="k">let</span> <span class="nv">interval</span> <span class="o">=</span> <span class="kt">CMTime</span><span class="p">(</span><span class="nv">seconds</span><span class="p">:</span> <span class="mf">0.1</span><span class="p">,</span> <span class="nv">preferredTimescale</span><span class="p">:</span> <span class="mi">600</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">token</span> <span class="o">=</span> <span class="n">player</span><span class="o">.</span><span class="nf">addPeriodicTimeObserver</span><span class="p">(</span><span class="nv">forInterval</span><span class="p">:</span> <span class="n">interval</span><span class="p">,</span> <span class="nv">queue</span><span class="p">:</span> <span class="o">.</span><span class="n">main</span><span class="p">)</span> <span class="p">{</span> <span class="n">time</span> <span class="k">in</span>
        <span class="n">currentTime</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">seconds</span>
    <span class="p">}</span>
    <span class="n">activeObserver</span> <span class="o">=</span> <span class="p">(</span><span class="n">player</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="n">onDisappear</span> <span class="p">{</span>
	<span class="k">if</span> <span class="k">let</span> <span class="nv">observerData</span> <span class="o">=</span> <span class="n">activeObserver</span> <span class="p">{</span> 
		<span class="n">observerData</span><span class="o">.</span><span class="n">player</span><span class="o">.</span><span class="nf">removeTimeObserver</span><span class="p">(</span><span class="n">observerData</span><span class="o">.</span><span class="n">token</span><span class="p">)</span> 
		<span class="n">activeObserver</span> <span class="o">=</span> <span class="kc">nil</span> 
	<span class="p">}</span> 
<span class="p">}</span>
</code></pre></div></div>

<p>To finish this part off I added a progress badge at the top of all image posts, to convey clearly to the user if they are seeing a video or if they should wait for another image.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code>            <span class="kt">HStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">8</span><span class="p">)</span> <span class="p">{</span>
                <span class="k">if</span> <span class="n">video</span><span class="o">.</span><span class="n">isVideo</span> <span class="o">==</span> <span class="kc">false</span> <span class="p">{</span>
                    <span class="kt">Text</span><span class="p">(</span><span class="s">"Image"</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">caption</span><span class="o">.</span><span class="nf">weight</span><span class="p">(</span><span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                        <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">,</span> <span class="mi">4</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">cyan</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.8</span><span class="p">))</span>
                        <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="kt">Capsule</span><span class="p">())</span>
                    
                    <span class="k">if</span> <span class="k">let</span> <span class="nv">count</span> <span class="o">=</span> <span class="n">video</span><span class="o">.</span><span class="n">postImageCount</span><span class="p">,</span> <span class="n">count</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="p">{</span>
                        <span class="kt">Text</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">currentPage</span><span class="se">)</span><span class="s">/</span><span class="se">\(</span><span class="n">count</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">caption</span><span class="o">.</span><span class="nf">weight</span><span class="p">(</span><span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                            <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">,</span> <span class="mi">4</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.6</span><span class="p">))</span>
                            <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="kt">Capsule</span><span class="p">())</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">maxWidth</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">,</span> <span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">center</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">top</span><span class="p">,</span> <span class="mi">20</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="hashtags">Hashtags</h2>

<p>Hashtags are a staple feature in virtually all social media apps. In our case, the video description already include each uploaders designated tags written as plain text, but we can expand on this to support explicitly adding and removing tags from our database, and eventually filtering our feed by them.</p>

<p>To make this work on the backend, we first need to set up a many-to-many relationship using SQLAlchemy, since a video can have multiple tags, and a single tag will likely belong to multiple videos. This means we need to create a new ‘tags’ table and tag-video relationship table to the database. We also need to define our schemas and create a couple of routes: one to fetch all available tags, and a single “toggle” endpoint to handle both adding and removing tags from a specific video.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="c1"># Models
</span><span class="n">video_tag_association</span> <span class="o">=</span> <span class="nc">Table</span><span class="p">(</span>  
    <span class="sh">'</span><span class="s">video_tag</span><span class="sh">'</span><span class="p">,</span> <span class="n">Base</span><span class="p">.</span><span class="n">metadata</span><span class="p">,</span>  
    <span class="nc">Column</span><span class="p">(</span><span class="sh">'</span><span class="s">video_id</span><span class="sh">'</span><span class="p">,</span> <span class="n">Integer</span><span class="p">,</span> <span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">'</span><span class="s">videos.id</span><span class="sh">'</span><span class="p">)),</span>  
    <span class="nc">Column</span><span class="p">(</span><span class="sh">'</span><span class="s">tag_id</span><span class="sh">'</span><span class="p">,</span> <span class="n">Integer</span><span class="p">,</span> <span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">'</span><span class="s">tags.id</span><span class="sh">'</span><span class="p">))</span>  
<span class="p">)</span>

<span class="k">class</span> <span class="nc">Tag</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>  
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">tags</span><span class="sh">"</span>  
    <span class="nb">id</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">name</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">unique</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">videos</span> <span class="o">=</span> <span class="nf">relationship</span><span class="p">(</span><span class="sh">"</span><span class="s">Video</span><span class="sh">"</span><span class="p">,</span> <span class="n">secondary</span><span class="o">=</span><span class="n">video_tag_association</span><span class="p">,</span> <span class="n">back_populates</span><span class="o">=</span><span class="sh">"</span><span class="s">tags</span><span class="sh">"</span><span class="p">)</span>  
  
    <span class="nd">@property</span>  
    <span class="k">def</span> <span class="nf">video_count</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>  
        <span class="k">return</span> <span class="nf">len</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">videos</span><span class="p">)</span>
        
<span class="k">class</span> <span class="nc">Video</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>  
    <span class="bp">...</span>
    <span class="n">tags</span> <span class="o">=</span> <span class="nf">relationship</span><span class="p">(</span><span class="sh">"</span><span class="s">Tag</span><span class="sh">"</span><span class="p">,</span> <span class="n">secondary</span><span class="o">=</span><span class="n">video_tag_association</span><span class="p">,</span> <span class="n">back_populates</span><span class="o">=</span><span class="sh">"</span><span class="s">videos</span><span class="sh">"</span><span class="p">)</span>  

<span class="c1"># Schemas
</span><span class="k">class</span> <span class="nc">TagOut</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>  
    <span class="nb">id</span><span class="p">:</span> <span class="nb">int</span>  
    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span>  
    <span class="n">video_count</span><span class="p">:</span> <span class="nb">int</span>  
    <span class="n">model_config</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">from_attributes</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span><span class="p">}</span>  
  
<span class="k">class</span> <span class="nc">TagSimpleOut</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>  
    <span class="nb">id</span><span class="p">:</span> <span class="nb">int</span>  
    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span>  
    <span class="n">model_config</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">from_attributes</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span><span class="p">}</span>
    
<span class="c1"># Routes
</span><span class="nd">@router.post</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/videos/{folder_name}/tags/{tag_name}/toggle</span><span class="sh">"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>
<span class="k">def</span> <span class="nf">toggle_video_tag</span><span class="p">(</span><span class="n">folder_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">tag_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>
    <span class="n">video</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Video</span><span class="p">.</span><span class="n">folder_name</span> <span class="o">==</span> <span class="n">folder_name</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">video</span><span class="p">:</span>
        <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="sh">"</span><span class="s">Video not found</span><span class="sh">"</span><span class="p">)</span>

    <span class="n">clean_tag</span> <span class="o">=</span> <span class="n">tag_name</span><span class="p">.</span><span class="nf">strip</span><span class="p">().</span><span class="nf">lower</span><span class="p">()</span>
    <span class="n">tag</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Tag</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Tag</span><span class="p">.</span><span class="n">name</span> <span class="o">==</span> <span class="n">clean_tag</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>

    <span class="k">if</span> <span class="ow">not</span> <span class="n">tag</span><span class="p">:</span>
        <span class="n">tag</span> <span class="o">=</span> <span class="nc">Tag</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">clean_tag</span><span class="p">)</span>
        <span class="n">db</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">tag</span> <span class="ow">in</span> <span class="n">video</span><span class="p">.</span><span class="n">tags</span><span class="p">:</span>
        <span class="n">video</span><span class="p">.</span><span class="n">tags</span><span class="p">.</span><span class="nf">remove</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span>
        <span class="c1"># Cleanup if the tag is no longer used by any video
</span>        <span class="n">video_count</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">video_tag_association</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">video_tag_association</span><span class="p">.</span><span class="n">c</span><span class="p">.</span><span class="n">tag_id</span> <span class="o">==</span> <span class="n">tag</span><span class="p">.</span><span class="nb">id</span><span class="p">).</span><span class="nf">count</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">video_count</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="n">db</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">video</span><span class="p">.</span><span class="n">tags</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span>

    <span class="n">db</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
    <span class="n">db</span><span class="p">.</span><span class="nf">refresh</span><span class="p">(</span><span class="n">video</span><span class="p">)</span>

    <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">message</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Tag toggled</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">tags</span><span class="sh">"</span><span class="p">:</span> <span class="n">video</span><span class="p">.</span><span class="n">tags</span><span class="p">}</span>

<span class="nd">@router.get</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/tags</span><span class="sh">"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="n">List</span><span class="p">[</span><span class="n">TagOut</span><span class="p">],</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>
<span class="k">def</span> <span class="nf">get_all_tags</span><span class="p">(</span><span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>
    <span class="n">results</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
        <span class="n">Tag</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span>
        <span class="n">Tag</span><span class="p">.</span><span class="n">name</span><span class="p">,</span>
        <span class="n">func</span><span class="p">.</span><span class="nf">count</span><span class="p">(</span><span class="n">video_tag_association</span><span class="p">.</span><span class="n">c</span><span class="p">.</span><span class="n">video_id</span><span class="p">).</span><span class="nf">label</span><span class="p">(</span><span class="sh">'</span><span class="s">video_count</span><span class="sh">'</span><span class="p">)</span>
    <span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="n">video_tag_association</span><span class="p">,</span> <span class="n">isouter</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span> \
        <span class="p">.</span><span class="nf">group_by</span><span class="p">(</span><span class="n">Tag</span><span class="p">.</span><span class="nb">id</span><span class="p">).</span><span class="nf">all</span><span class="p">()</span>

    <span class="k">return</span> <span class="p">[{</span><span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">:</span> <span class="n">r</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span> <span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="n">r</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="sh">"</span><span class="s">video_count</span><span class="sh">"</span><span class="p">:</span> <span class="n">r</span><span class="p">.</span><span class="n">video_count</span><span class="p">}</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">results</span><span class="p">]</span>

</code></pre></div></div>

<p>Instead of manually tagging thousands of posts, we can update our <code class="language-plaintext highlighter-rouge">sync_videos</code> function to automatically extract any existing hashtags from the caption using a simple regex search, and populate our new database tables during intake. We add the following to the end of the sync script:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">sync_videos</span><span class="p">():</span>
	<span class="bp">...</span>
	
	<span class="k">if</span> <span class="n">active_vid</span><span class="p">.</span><span class="n">description</span><span class="p">:</span>  
	    <span class="c1"># Lowercase inside the comprehension
</span>	    <span class="n">extracted</span> <span class="o">=</span> <span class="nf">set</span><span class="p">(</span><span class="n">ht</span><span class="p">.</span><span class="nf">lower</span><span class="p">()</span> <span class="k">for</span> <span class="n">ht</span> <span class="ow">in</span> <span class="n">re</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">#(\w+)</span><span class="sh">"</span><span class="p">,</span> <span class="n">active_vid</span><span class="p">.</span><span class="n">description</span><span class="p">))</span>  
	    <span class="k">for</span> <span class="n">clean_t</span> <span class="ow">in</span> <span class="n">extracted</span><span class="p">:</span>  
	        <span class="k">if</span> <span class="ow">not</span> <span class="nf">any</span><span class="p">(</span><span class="n">h</span><span class="p">.</span><span class="n">name</span> <span class="o">==</span> <span class="n">clean_ht</span> <span class="k">for</span> <span class="n">h</span> <span class="ow">in</span> <span class="n">active_vid</span><span class="p">.</span><span class="n">hashtags</span><span class="p">):</span>  
	            <span class="n">db_tag</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Tag</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Tag</span><span class="p">.</span><span class="n">name</span> <span class="o">==</span> <span class="n">clean_t</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>  
	            <span class="k">if</span> <span class="ow">not</span> <span class="n">db_tag</span><span class="p">:</span>  
	                <span class="n">db_tag</span> <span class="o">=</span> <span class="nc">Tag</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">clean_t</span><span class="p">)</span>  
	                <span class="n">db</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">db_tag</span><span class="p">)</span>  
	                <span class="n">db</span><span class="p">.</span><span class="nf">flush</span><span class="p">()</span>
	            <span class="n">active_vid</span><span class="p">.</span><span class="n">hashtags</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">db_tag</span><span class="p">)</span>
</code></pre></div></div>

<p>Moving over to the frontend, we need to mirror these changes. First, we define a Swift model to match the <code class="language-plaintext highlighter-rouge">TagOut</code> schema we created on our server.</p>

<p>In Tag.Swift…</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">Tag</span><span class="p">:</span> <span class="kt">Codable</span><span class="p">,</span> <span class="kt">Identifiable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">id</span><span class="p">:</span> <span class="kt">Int</span>
    <span class="k">let</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span>
    <span class="k">let</span> <span class="nv">videoCount</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
    
    <span class="kd">enum</span> <span class="kt">CodingKeys</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CodingKey</span> <span class="p">{</span>
        <span class="k">case</span> <span class="n">id</span>
        <span class="k">case</span> <span class="n">name</span>
        <span class="k">case</span> <span class="n">videoCount</span> <span class="o">=</span> <span class="s">"video_count"</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<p>Next, we update our <code class="language-plaintext highlighter-rouge">NetworkManager</code> to hit the new API endpoints we just wrote. These are not much different than the toggleFavorite, and setRating functions we wrote in Part II.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	<span class="k">var</span> <span class="nv">allTags</span><span class="p">:</span> <span class="p">[</span><span class="kt">Tag</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
	
	<span class="o">...</span>

    <span class="kd">func</span> <span class="nf">toggleTag</span><span class="p">(</span><span class="k">for</span> <span class="nv">folderName</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">tagName</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/videos/</span><span class="se">\(</span><span class="n">folderName</span><span class="se">)</span><span class="s">/tags/</span><span class="se">\(</span><span class="n">tagName</span><span class="se">)</span><span class="s">/toggle"</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">request</span> <span class="o">=</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">method</span><span class="p">:</span> <span class="s">"POST"</span><span class="p">)</span>
        
        <span class="k">do</span> <span class="p">{</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">response</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            <span class="k">if</span> <span class="k">let</span> <span class="nv">http</span> <span class="o">=</span> <span class="n">response</span> <span class="k">as?</span> <span class="kt">HTTPURLResponse</span><span class="p">,</span> <span class="n">http</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">==</span> <span class="mi">200</span> <span class="p">{</span>
                <span class="kd">struct</span> <span class="kt">TagResponse</span><span class="p">:</span> <span class="kt">Codable</span> <span class="p">{</span> <span class="k">let</span> <span class="nv">tags</span><span class="p">:</span> <span class="p">[</span><span class="kt">Tag</span><span class="p">]</span> <span class="p">}</span>
                <span class="k">let</span> <span class="nv">result</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">(</span><span class="kt">TagResponse</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
                <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">tags</span> <span class="o">=</span> <span class="n">result</span><span class="o">.</span><span class="n">tags</span> <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">print</span><span class="p">(</span><span class="s">"Failed to toggle tag: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
    
    <span class="kd">func</span> <span class="nf">fetchAllTags</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/tags"</span><span class="p">)</span>
        <span class="k">do</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">request</span> <span class="o">=</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">_</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            <span class="k">let</span> <span class="nv">decodedTags</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">([</span><span class="kt">Tag</span><span class="p">]</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
            <span class="k">self</span><span class="o">.</span><span class="n">allTags</span> <span class="o">=</span> <span class="n">decodedTags</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">print</span><span class="p">(</span><span class="s">"Failed to fetch all tags: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>Finally, we can tie it all together in the UI. In <code class="language-plaintext highlighter-rouge">ReelsOverlay.swift</code>, we need to add a horizontal scroll view to display the currently assigned tags just above the video description, and a sheet containing a <code class="language-plaintext highlighter-rouge">LazyVGrid</code> to allow the user to search, create, and toggle tags on the fly.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1">// Tag Management State</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">showTagSheet</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">showDeleteAlert</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">tagToDelete</span><span class="p">:</span> <span class="kt">Tag</span><span class="p">?</span> <span class="o">=</span> <span class="kc">nil</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">newTagText</span> <span class="o">=</span> <span class="s">""</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">stableSortedTags</span><span class="p">:</span> <span class="p">[</span><span class="kt">Tag</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    
    <span class="o">...</span>
    
    <span class="c1">// Scrollable Tags Row</span>
    <span class="kt">ScrollView</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="nv">showsIndicators</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span> <span class="p">{</span>
         <span class="kt">HStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">8</span><span class="p">)</span> <span class="p">{</span>
             <span class="k">if</span> <span class="k">let</span> <span class="nv">tags</span> <span class="o">=</span> <span class="n">video</span><span class="o">.</span><span class="n">tags</span><span class="p">,</span> <span class="o">!</span><span class="n">tags</span><span class="o">.</span><span class="n">isEmpty</span> 
             <span class="kt">ForEach</span><span class="p">(</span><span class="n">tags</span><span class="p">)</span> <span class="p">{</span> <span class="n">tag</span> <span class="k">in</span>
                <span class="kt">Text</span><span class="p">(</span><span class="s">"#</span><span class="se">\(</span><span class="n">tag</span><span class="o">.</span><span class="n">name</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
	             <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">12</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">bold</span><span class="p">))</span>
	             <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
	             <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="mi">12</span><span class="p">)</span>
	             <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">,</span> <span class="mi">6</span><span class="p">)</span>
	             <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.5</span><span class="p">))</span>
	             <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="kt">Capsule</span><span class="p">())</span>
	             <span class="o">.</span><span class="n">onTapGesture</span> <span class="p">{</span>
		            <span class="n">tagToDelete</span> <span class="o">=</span> <span class="n">tag</span>
                    <span class="n">showDeleteAlert</span> <span class="o">=</span> <span class="kc">true</span>
                 <span class="p">}</span>
             <span class="p">}</span>
     <span class="p">}</span>
                                    
                                    <span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">showTagSheet</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">})</span> <span class="p">{</span>
                                        <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"plus"</span><span class="p">)</span>
                                            <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">12</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">black</span><span class="p">))</span>
                                            <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
                                            <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="mi">8</span><span class="p">)</span>
                                            <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.5</span><span class="p">))</span>
                                            <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="kt">Circle</span><span class="p">())</span>
                                    <span class="p">}</span>
                                <span class="p">}</span>
                            <span class="p">}</span>
                            <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">maxWidth</span><span class="p">:</span> <span class="mi">280</span><span class="p">)</span>
                            
                            <span class="o">...</span>
                            <span class="o">.</span><span class="nf">sheet</span><span class="p">(</span><span class="nv">isPresented</span><span class="p">:</span> <span class="n">$showTagSheet</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">tagSheetContent</span>
        <span class="p">}</span>
        
        <span class="o">...</span>
            <span class="kd">private</span> <span class="k">var</span> <span class="nv">displayTags</span><span class="p">:</span> <span class="p">[</span><span class="kt">Tag</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">trimmed</span> <span class="o">=</span> <span class="n">newTagText</span><span class="o">.</span><span class="nf">trimmingCharacters</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">whitespaces</span><span class="p">)</span><span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">trimmed</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">{</span> <span class="k">return</span> <span class="n">stableSortedTags</span> <span class="p">}</span>
        
        <span class="k">var</span> <span class="nv">tags</span> <span class="o">=</span> <span class="n">stableSortedTags</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">name</span><span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">trimmed</span><span class="p">)</span> <span class="p">}</span>
        
        <span class="c1">// If the exact search term doesn't exist, append a placeholder to create it</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">stableSortedTags</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">name</span><span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span> <span class="o">==</span> <span class="n">trimmed</span> <span class="p">})</span> <span class="p">{</span>
            <span class="n">tags</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">Tag</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="nv">name</span><span class="p">:</span> <span class="n">trimmed</span><span class="p">,</span> <span class="nv">videoCount</span><span class="p">:</span> <span class="kc">nil</span><span class="p">))</span>
        <span class="p">}</span>
        
        <span class="k">return</span> <span class="n">tags</span>
    <span class="p">}</span>
        

</code></pre></div></div>

<p>And here is the view layout for the bottom sheet itself, providing a clean search bar and an adaptive grid of selectable tag pills.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="k">var</span> <span class="nv">tagSheetContent</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">NavigationStack</span> <span class="p">{</span>
            <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
                <span class="c1">// Search</span>
                <span class="kt">HStack</span> <span class="p">{</span>
                    <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"magnifyingglass"</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
                    <span class="kt">TextField</span><span class="p">(</span><span class="s">"Search or create tag..."</span><span class="p">,</span> <span class="nv">text</span><span class="p">:</span> <span class="n">$newTagText</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">textFieldStyle</span><span class="p">(</span><span class="o">.</span><span class="n">plain</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">textInputAutocapitalization</span><span class="p">(</span><span class="o">.</span><span class="n">never</span><span class="p">)</span>
                        <span class="o">.</span><span class="nf">autocorrectionDisabled</span><span class="p">()</span>
                    <span class="k">if</span> <span class="o">!</span><span class="n">newTagText</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">{</span>
                        <span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">newTagText</span> <span class="o">=</span> <span class="s">""</span> <span class="p">})</span> <span class="p">{</span>
                            <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"xmark.circle.fill"</span><span class="p">)</span>
                                <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">}</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="mi">12</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">secondary</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.1</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">cornerRadius</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">()</span>
                
                <span class="kt">Divider</span><span class="p">()</span>
                
                <span class="c1">//List</span>
                <span class="kt">ScrollView</span> <span class="p">{</span>
                    <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">16</span><span class="p">)</span> <span class="p">{</span>
                        <span class="kt">Text</span><span class="p">(</span><span class="s">"All Tags"</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">14</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                            <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">secondary</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">top</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span>
                        
                        <span class="kt">LazyVGrid</span><span class="p">(</span><span class="nv">columns</span><span class="p">:</span> <span class="p">[</span><span class="kt">GridItem</span><span class="p">(</span><span class="o">.</span><span class="nf">adaptive</span><span class="p">(</span><span class="nv">minimum</span><span class="p">:</span> <span class="mi">100</span><span class="p">))],</span> <span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span> <span class="p">{</span>
                            <span class="kt">ForEach</span><span class="p">(</span><span class="n">displayTags</span><span class="p">)</span> <span class="p">{</span> <span class="n">dbTag</span> <span class="k">in</span>
                                <span class="k">let</span> <span class="nv">isSelected</span> <span class="o">=</span> <span class="n">video</span><span class="o">.</span><span class="n">tags</span><span class="p">?</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">name</span> <span class="o">==</span> <span class="n">dbTag</span><span class="o">.</span><span class="n">name</span> <span class="p">})</span> <span class="p">??</span> <span class="kc">false</span>
                                
                                <span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
                                    <span class="kt">Task</span> <span class="p">{</span>
                                        <span class="k">await</span> <span class="n">networkManager</span><span class="o">.</span><span class="nf">toggleTag</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">folderName</span><span class="p">,</span> <span class="nv">tagName</span><span class="p">:</span> <span class="n">dbTag</span><span class="o">.</span><span class="n">name</span><span class="p">)</span>
                                        <span class="k">if</span> <span class="n">dbTag</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span> <span class="p">{</span>
                                            <span class="n">newTagText</span> <span class="o">=</span> <span class="s">""</span>
                                            <span class="k">await</span> <span class="n">networkManager</span><span class="o">.</span><span class="nf">fetchAllTags</span><span class="p">()</span>
                                        <span class="p">}</span>
                                    <span class="p">}</span>
                                <span class="p">})</span> <span class="p">{</span>
                                    <span class="kt">HStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
                                        <span class="k">if</span> <span class="n">dbTag</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span> <span class="p">{</span> <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"plus"</span><span class="p">)</span> <span class="p">}</span>
                                        <span class="kt">Text</span><span class="p">(</span><span class="n">dbTag</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span> <span class="p">?</span> <span class="s">"Create </span><span class="se">\"</span><span class="s">#</span><span class="se">\(</span><span class="n">dbTag</span><span class="o">.</span><span class="n">name</span><span class="se">)\"</span><span class="s">"</span> <span class="p">:</span> <span class="s">"#</span><span class="se">\(</span><span class="n">dbTag</span><span class="o">.</span><span class="n">name</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
                                    <span class="p">}</span>
                                    <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">15</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">semibold</span><span class="p">))</span>
                                    <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="mi">14</span><span class="p">)</span>
                                    <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
                                    <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="n">isSelected</span> <span class="p">?</span> <span class="kt">Color</span><span class="o">.</span><span class="nv">blue</span> <span class="p">:</span> <span class="kt">Color</span><span class="o">.</span><span class="n">secondary</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.1</span><span class="p">))</span>
                                    <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="n">isSelected</span> <span class="p">?</span> <span class="o">.</span><span class="nv">white</span> <span class="p">:</span> <span class="p">(</span><span class="n">dbTag</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span> <span class="p">?</span> <span class="o">.</span><span class="nv">blue</span> <span class="p">:</span> <span class="o">.</span><span class="n">primary</span><span class="p">))</span>
                                    <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="kt">Capsule</span><span class="p">())</span>
                                    <span class="o">.</span><span class="nf">overlay</span><span class="p">(</span><span class="n">dbTag</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="o">-</span><span class="mi">1</span> <span class="p">?</span> <span class="kt">Capsule</span><span class="p">()</span><span class="o">.</span><span class="nf">stroke</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">blue</span><span class="p">,</span> <span class="nv">lineWidth</span><span class="p">:</span> <span class="mi">1</span><span class="p">)</span> <span class="p">:</span> <span class="kc">nil</span><span class="p">)</span>
                                <span class="p">}</span>
                            <span class="p">}</span>
                        <span class="p">}</span>
                        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">)</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
            <span class="o">.</span><span class="nf">navigationTitle</span><span class="p">(</span><span class="s">"Tags"</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">navigationBarTitleDisplayMode</span><span class="p">(</span><span class="o">.</span><span class="n">inline</span><span class="p">)</span>
            <span class="o">.</span><span class="n">toolbar</span> <span class="p">{</span>
                <span class="kt">ToolbarItem</span><span class="p">(</span><span class="nv">placement</span><span class="p">:</span> <span class="o">.</span><span class="n">navigationBarTrailing</span><span class="p">)</span> <span class="p">{</span>
                    <span class="kt">Button</span><span class="p">(</span><span class="s">"Done"</span><span class="p">)</span> <span class="p">{</span> <span class="n">showTagSheet</span> <span class="o">=</span> <span class="kc">false</span> <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">presentationDetents</span><span class="p">([</span><span class="o">.</span><span class="n">medium</span><span class="p">,</span> <span class="o">.</span><span class="n">large</span><span class="p">])</span>
        <span class="o">.</span><span class="n">task</span> <span class="p">{</span>
            <span class="k">await</span> <span class="n">networkManager</span><span class="o">.</span><span class="nf">fetchAllTags</span><span class="p">()</span>
            <span class="n">stableSortedTags</span> <span class="o">=</span> <span class="n">networkManager</span><span class="o">.</span><span class="n">allTags</span><span class="o">.</span><span class="n">sorted</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">name</span> <span class="o">&lt;</span> <span class="nv">$1</span><span class="o">.</span><span class="n">name</span> <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
</code></pre></div></div>

<h2 id="filters">Filters</h2>

<p>There isn’t much purpose in adding all this user data to posts if it isn’t utilized, and the clearest way to do so is by using it to filter posts. Maybe you only want to see posts you’ve previously favorited, or maybe you specifically want to browse videos tagged with <code class="language-plaintext highlighter-rouge">#art</code> while excluding anything tagged with <code class="language-plaintext highlighter-rouge">#memes</code>.</p>

<p>To support this, we need to create a filtering system. The cleanest approach on the backend is to define a Pydantic schema specifically for our filter criteria, and then create a new <code class="language-plaintext highlighter-rouge">POST</code> endpoint that dynamically builds a SQLAlchemy query based on whatever parameters the client sends.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="kt">FeedFilter</span><span class="p">(</span><span class="kt">BaseModel</span><span class="p">):</span>
    <span class="nv">require_tags</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="kt">List</span><span class="p">[</span><span class="n">str</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="nv">exclude_tags</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="kt">List</span><span class="p">[</span><span class="n">str</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="nv">only_favorites</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="n">bool</span><span class="p">]</span> <span class="o">=</span> <span class="kt">False</span>
    <span class="nv">min_rating</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="n">int</span><span class="p">]</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="nv">limit</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="n">int</span><span class="p">]</span> <span class="o">=</span> <span class="mi">10</span>
    <span class="nv">offset</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="n">int</span><span class="p">]</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="nv">randomize</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="n">bool</span><span class="p">]</span> <span class="o">=</span> <span class="kt">False</span>
    <span class="nv">seen_ids</span><span class="p">:</span> <span class="kt">Optional</span><span class="p">[</span><span class="kt">List</span><span class="p">[</span><span class="n">int</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>

<span class="kd">@router</span><span class="o">.</span><span class="nf">post</span><span class="p">(</span><span class="s">"/api/feed/filter"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="kt">List</span><span class="p">[</span><span class="kt">VideoOut</span><span class="p">],</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="kt">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>
<span class="n">def</span> <span class="nf">get_filtered_feed</span><span class="p">(</span><span class="nv">filters</span><span class="p">:</span> <span class="kt">FeedFilter</span><span class="p">,</span> <span class="nv">db</span><span class="p">:</span> <span class="kt">Session</span> <span class="o">=</span> <span class="kt">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>
    <span class="s">"""Returns a list of videos matching specific criteria."""</span>
    <span class="n">query</span> <span class="o">=</span> <span class="n">db</span><span class="o">.</span><span class="nf">query</span><span class="p">(</span><span class="kt">Video</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">filters</span><span class="o">.</span><span class="nv">only_favorites</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">filter</span><span class="p">(</span><span class="kt">Video</span><span class="o">.</span><span class="n">is_favorited</span> <span class="o">==</span> <span class="kt">True</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">filters</span><span class="o">.</span><span class="n">min_rating</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">filter</span><span class="p">(</span><span class="kt">Video</span><span class="o">.</span><span class="n">rating</span> <span class="o">&gt;=</span> <span class="n">filters</span><span class="o">.</span><span class="n">min_rating</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">filters</span><span class="o">.</span><span class="nv">require_tags</span><span class="p">:</span>
        <span class="k">for</span> <span class="n">tag_name</span> <span class="k">in</span> <span class="n">filters</span><span class="o">.</span><span class="nv">require_tags</span><span class="p">:</span>
            <span class="n">clean_tag</span> <span class="o">=</span> <span class="n">tag_name</span><span class="o">.</span><span class="nf">strip</span><span class="p">()</span><span class="o">.</span><span class="nf">lower</span><span class="p">()</span>
            <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">filter</span><span class="p">(</span><span class="kt">Video</span><span class="o">.</span><span class="n">tags</span><span class="o">.</span><span class="nf">any</span><span class="p">(</span><span class="kt">Tag</span><span class="o">.</span><span class="n">name</span> <span class="o">==</span> <span class="n">clean_tag</span><span class="p">))</span>

    <span class="k">if</span> <span class="n">filters</span><span class="o">.</span><span class="nv">exclude_tags</span><span class="p">:</span>
        <span class="k">for</span> <span class="n">tag_name</span> <span class="k">in</span> <span class="n">filters</span><span class="o">.</span><span class="nv">exclude_tags</span><span class="p">:</span>
            <span class="n">clean_tag</span> <span class="o">=</span> <span class="n">tag_name</span><span class="o">.</span><span class="nf">strip</span><span class="p">()</span><span class="o">.</span><span class="nf">lower</span><span class="p">()</span>
            <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">filter</span><span class="p">(</span><span class="o">~</span><span class="kt">Video</span><span class="o">.</span><span class="n">tags</span><span class="o">.</span><span class="nf">any</span><span class="p">(</span><span class="kt">Tag</span><span class="o">.</span><span class="n">name</span> <span class="o">==</span> <span class="n">clean_tag</span><span class="p">))</span>

    <span class="k">if</span> <span class="n">filters</span><span class="o">.</span><span class="nv">seen_ids</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">filter</span><span class="p">(</span><span class="o">~</span><span class="kt">Video</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="nf">in_</span><span class="p">(</span><span class="n">filters</span><span class="o">.</span><span class="n">seen_ids</span><span class="p">))</span>

    <span class="k">if</span> <span class="n">filters</span><span class="o">.</span><span class="nv">randomize</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">order_by</span><span class="p">(</span><span class="kd">func</span><span class="o">.</span><span class="nf">random</span><span class="p">())</span>
    <span class="nv">else</span><span class="p">:</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">query</span><span class="o">.</span><span class="nf">order_by</span><span class="p">(</span><span class="kt">Video</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="nf">desc</span><span class="p">())</span>

    <span class="k">return</span> <span class="n">query</span><span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="n">filters</span><span class="o">.</span><span class="n">offset</span><span class="p">)</span><span class="o">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">filters</span><span class="o">.</span><span class="n">limit</span><span class="p">)</span><span class="o">.</span><span class="nf">all</span><span class="p">()</span>
</code></pre></div></div>

<p>Per usual, we need to mirror this structure on the frontend by creating a Swift <code class="language-plaintext highlighter-rouge">Codable</code> struct that matches the Pydantic model exactly.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">FeedFilter</span><span class="p">:</span> <span class="kt">Codable</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">requireTags</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]?</span>
    <span class="k">var</span> <span class="nv">excludeTags</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]?</span>
    <span class="k">var</span> <span class="nv">onlyFavorites</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">minRating</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">limit</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">offset</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">randomize</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">seenIds</span><span class="p">:</span> <span class="p">[</span><span class="kt">Int</span><span class="p">]?</span>
    
    <span class="kd">enum</span> <span class="kt">CodingKeys</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CodingKey</span> <span class="p">{</span>
        <span class="k">case</span> <span class="n">requireTags</span> <span class="o">=</span> <span class="s">"require_tags"</span>
        <span class="k">case</span> <span class="n">excludeTags</span> <span class="o">=</span> <span class="s">"exclude_tags"</span>
        <span class="k">case</span> <span class="n">onlyFavorites</span> <span class="o">=</span> <span class="s">"only_favorites"</span>
        <span class="k">case</span> <span class="n">minRating</span> <span class="o">=</span> <span class="s">"min_rating"</span>
        <span class="k">case</span> <span class="n">limit</span>
        <span class="k">case</span> <span class="n">offset</span>
        <span class="k">case</span> <span class="n">randomize</span>
        <span class="k">case</span> <span class="n">seenIds</span> <span class="o">=</span> <span class="s">"seen_ids"</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Next, we need our <code class="language-plaintext highlighter-rouge">NetworkManager</code> to track whether a filter is currently active, save it across app launches using <code class="language-plaintext highlighter-rouge">UserDefaults</code>, and route our feed requests to the new endpoint whenever a filter is applied.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">activeFilter</span><span class="p">:</span> <span class="kt">FeedFilter</span><span class="p">?</span> <span class="p">{</span>
        <span class="k">get</span> <span class="p">{</span>
            <span class="k">if</span> <span class="k">let</span> <span class="nv">data</span> <span class="o">=</span> <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"savedFeedFilter"</span><span class="p">),</span>
               <span class="k">let</span> <span class="nv">filter</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">(</span><span class="kt">FeedFilter</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span> <span class="p">{</span>
                <span class="k">return</span> <span class="n">filter</span>
            <span class="p">}</span>
            <span class="k">return</span> <span class="kc">nil</span>
        <span class="p">}</span>
        <span class="k">set</span> <span class="p">{</span>
            <span class="k">if</span> <span class="k">let</span> <span class="nv">newValue</span> <span class="o">=</span> <span class="n">newValue</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">data</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">JSONEncoder</span><span class="p">()</span><span class="o">.</span><span class="nf">encode</span><span class="p">(</span><span class="n">newValue</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="s">"savedFeedFilter"</span><span class="p">)</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"savedFeedFilter"</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">buildFeedRequest</span><span class="p">()</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">URLRequest</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">currentFilter</span> <span class="o">=</span> <span class="n">activeFilter</span> <span class="p">??</span> <span class="kt">FeedFilter</span><span class="p">(</span><span class="nv">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
        
        <span class="n">currentFilter</span><span class="o">.</span><span class="n">randomize</span> <span class="o">=</span> <span class="kc">true</span> 
        
        <span class="c1">// If there is an active filter, use the new POST endpoint</span>
        <span class="k">if</span> <span class="n">activeFilter</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/feed/filter"</span><span class="p">)</span>
            <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">method</span><span class="p">:</span> <span class="s">"POST"</span><span class="p">)</span>
            <span class="n">request</span><span class="o">.</span><span class="n">httpBody</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONEncoder</span><span class="p">()</span><span class="o">.</span><span class="nf">encode</span><span class="p">(</span><span class="n">currentFilter</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">request</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="c1">// Fall back to the basic random endpoint if no filters are applied</span>
            <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/feed/random"</span><span class="p">)</span>
            <span class="k">return</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">fetchVideos</span><span class="p">(</span><span class="k">for</span> <span class="nv">tag</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">limit</span><span class="p">:</span> <span class="kt">Int</span> <span class="o">=</span> <span class="mi">20</span><span class="p">,</span> <span class="nv">seenIds</span><span class="p">:</span> <span class="p">[</span><span class="kt">Int</span><span class="p">]</span> <span class="o">=</span> <span class="p">[])</span> <span class="k">async</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Video</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">filter</span> <span class="o">=</span> <span class="kt">FeedFilter</span><span class="p">(</span><span class="nv">requireTags</span><span class="p">:</span> <span class="p">[</span><span class="n">tag</span><span class="p">],</span> <span class="nv">limit</span><span class="p">:</span> <span class="n">limit</span><span class="p">)</span>
        <span class="n">filter</span><span class="o">.</span><span class="n">randomize</span> <span class="o">=</span> <span class="kc">true</span>
        
        <span class="k">if</span> <span class="o">!</span><span class="n">seenIds</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">{</span> <span class="n">filter</span><span class="o">.</span><span class="n">seenIds</span> <span class="o">=</span> <span class="n">seenIds</span> <span class="p">}</span>
        
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/feed/filter"</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">method</span><span class="p">:</span> <span class="s">"POST"</span><span class="p">)</span>
        
        <span class="k">do</span> <span class="p">{</span>
            <span class="n">request</span><span class="o">.</span><span class="n">httpBody</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONEncoder</span><span class="p">()</span><span class="o">.</span><span class="nf">encode</span><span class="p">(</span><span class="n">filter</span><span class="p">)</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">_</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            <span class="k">return</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">([</span><span class="kt">Video</span><span class="p">]</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">print</span><span class="p">(</span><span class="s">"Failed to fetch videos for tag </span><span class="se">\(</span><span class="n">tag</span><span class="se">)</span><span class="s">: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
            <span class="k">return</span> <span class="p">[]</span>
        <span class="p">}</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>Lastly, we need to build the UI for the user to actually set these parameters. I built a <code class="language-plaintext highlighter-rouge">FilterSheet</code> that heavily utilizes a custom <code class="language-plaintext highlighter-rouge">FilterAccordion</code> view to keep each filter option organized. When the user taps “Apply”, it evaluates if the filter is essentially empty (and clears it if so) or saves it to the network manager and triggers a feed refresh. I’ve opted to omit this code from the blog post, as UI code is quite long, cluttered, and uncomplicated. However, if you wish, you can see it in the project repository on Github.</p>

<h2 id="etcetera">Etcetera</h2>
<p>I’ve made quite a few more additions to the app; including a custom collections view which allows you to explore all posts with a certain tag in a grid, more filters, and renaming tags or syncing from the client. Importantly I added a custom algorithm which changes the frequency at which posts are shown depending on the rating and which hashtags it contains. I.e, I’ve decreased the frequency of ‘#fyp’ which tends to be engagment bait, and increased more niche topics like ‘#linux’ and ‘#3dprinting’.</p>

<p>I’ll avoid explaining all of my additions in-depth since this post has already become considerably long and the remaining features generally reuse already introduced concepts. This goes to show that what we’ve made serves as a great basis for expanding upon, and there is no shortage of possible additions.</p>

<h2 id="motivation">Motivation</h2>
<p>As I was working on this project, a landmark social media addiction case just concluded, with the jury siding against Meta. The case ruled that both Instagram and YouTube are deliberately engineered to be addictive, and have caused enough damage to the lives of individuals that they were awarded $6 Million USD. It is absolutely wonderful that companies who have caused so much harm no longer have impunity for their actions. It is virtually a truism now that large tech companies solely act in their own interest with little regard for their users. This one sided relationship makes their products inherently toxic to spend time on.</p>

<p>In my personal journals, there is no topic I have written more at length about than the atrocities of social media. I find it entirely antithetical to the human experience for a multitude of reasons I could not entirely explain here. This may then sound paradoxical at first— why I would make my own pseudo social media app— but I don’t believe that any media format itself can be inherently good or bad. Decentralized social media such as e-mail or the fediverse tends to be significantly more compatible with life. Unfortunately, there isn’t much of an alternative to corporations for SFVs yet, prompting me to come up with my own solution.</p>

<p>In this app we’ve created, you have entire control over the content you see, how you see it, and how much of it you see. These are not abilities that corporations will ever give up willingly, as it gives them a lot of power over their users. When you own the database and control the algorithm, it turns the feed from an infinite slot machine designed to extract engagement into something more delibrate, personal, and useful.</p>

<p>Then, why is it necessary at all to have a SFV app in the first place? What exactly is the benefit of this format if I think it is predisposed to harm? To me, the saved posts act as a bit of a vision board. There are some truly beautiful things people around the world have made and shared online in SFV apps. Seeing such passion in others helps to stir my own, give me motivation and focus. We don’t have to abandon the format entirely just because corporations have misused it. By taking ownership of the software, you can strip away the manipulation and rebuild it into a tool that fosters motivation rather than dependency.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[This is the third and final part of my series on making a self-hosted short form media app. The first two parts can be read here and here if you haven’t already done so. This third part focuses on adding some useful extensions to the project we have completed up to this point. I will also elaborate a bit further on my motivation for this project.]]></summary></entry><entry><title type="html">Making an Instagram Reels Inspired Frontend</title><link href="https://www.jackbodine.com/blog/short-form-app-ii/" rel="alternate" type="text/html" title="Making an Instagram Reels Inspired Frontend" /><published>2026-03-26T00:00:00+00:00</published><updated>2026-03-26T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/short-form-app-ii</id><content type="html" xml:base="https://www.jackbodine.com/blog/short-form-app-ii/"><![CDATA[<p>This is Part II in a series about making a custom short form video (SFV) app like Instagram Reels, or YouTube Shorts. If you haven’t read Part I, I suppose that is a prerequisite for this post. This specific post focuses on building the frontend for our project and assumes that the backend is functional and loaded with media as described in Part I.</p>

<p><img src="/assets/march2026/1.png" alt="Software Architecture Diagram" /></p>

<h2 id="the-frontend">The Frontend</h2>

<p>Now that the backend is up waiting for anyone to request videos and database information, the next step is to make a client to do exactly that. I chose to go with making an iOS app using Swift, to recreate the authentic Instagram reels feel, and since I already have experience as an iOS developer.</p>

<p>Standard iOS projects use a software architecture called MVVM, which stands for Model, View, ViewModel. These are the three main components which handle the different responsibilities of the app. The Models, serve the same purpose as the Models in our backend. The View is the user interface, and the ViewModel handles all communication between the model and the View.</p>

<p>The first step, after making a new Xcode project, is to implement in the front end a complete copy of the Video model we have in our backend. I created a new Video.swift file with the following contents to mirror the video object in our database. Since variable naming conventions differ between languages (snake_case in python, and lowerCamelCase in swift) I added a coding keys enum which lets the JSON decoder know which swift variables correspond to which variables the server returns in its response.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">struct</span> <span class="kt">Video</span><span class="p">:</span> <span class="kt">Codable</span><span class="p">,</span> <span class="kt">Identifiable</span> <span class="p">{</span>

<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">id</span><span class="p">:</span> <span class="kt">Int</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">folderName</span><span class="p">:</span> <span class="kt">String</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">isFavorited</span><span class="p">:</span> <span class="kt">Bool</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">rating</span><span class="p">:</span> <span class="kt">Int</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">description</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">tags</span><span class="p">:</span> <span class="p">[</span><span class="kt">Tag</span><span class="p">]?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">flags</span><span class="p">:</span> <span class="p">[</span><span class="kt">Flag</span><span class="p">]?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">shortcode</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">datePosted</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">uploader</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">username</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">dateAdded</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>

<span class="err"> </span> <span class="err"> </span> <span class="kd">enum</span> <span class="kt">CodingKeys</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CodingKey</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">id</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">folderName</span> <span class="o">=</span> <span class="s">"folder_name"</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">isFavorited</span> <span class="o">=</span> <span class="s">"is_favorited"</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">rating</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">description</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">tags</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">flags</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">shortcode</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">datePosted</span> <span class="o">=</span> <span class="s">"date_posted"</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">uploader</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">username</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">case</span> <span class="n">dateAdded</span> <span class="o">=</span> <span class="s">"date_added"</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="c1">// Generates the standard url for the manifest</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">func</span> <span class="nf">streamURL</span><span class="p">(</span><span class="nv">base</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">URL</span><span class="p">?</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">base</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"videos/</span><span class="se">\(</span><span class="n">folderName</span><span class="se">)</span><span class="s">/index.m3u8"</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>How the client is going to work is by using the iOS native <code class="language-plaintext highlighter-rouge">AVPlayer</code> which natively supports playing HLS videos. However, it has notoriously strict networking constraints, and objects to any additional HTTP headers in the request like our API key. If we were to just give AVPlayer the path to the <code class="language-plaintext highlighter-rouge">.m3u8</code> and <code class="language-plaintext highlighter-rouge">.ts</code> files, it would immediately request the files, to which our server wouldn’t respond due to missing the API key. Instead we have to write a custom delegate which intercepts this request, appends the API key, then sends the request off to the server to get the actual video decryption key. To do this, all of our requests are going to use a custom scheme called ‘lockdown,’ but can be anything except from https/http. The handler also replaces the scheme with https before the request is sent. Because iOS delegates media playback to a separate background system process that completely ignores our app’s custom URLSession headers, this custom scheme acts as a necessary middleman, allowing us to intercept the request and manually inject the API key before the system makes the actual network call.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">internal</span> <span class="kd">import</span> <span class="kt">AVFoundation</span>

<span class="kd">class</span> <span class="kt">LockdownResourceLoaderDelegate</span><span class="p">:</span> <span class="kt">NSObject</span><span class="p">,</span> <span class="kt">AVAssetResourceLoaderDelegate</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">private</span> <span class="k">let</span> <span class="nv">secretKey</span> <span class="o">=</span> <span class="kt">APIConfig</span><span class="o">.</span><span class="n">secretKey</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">func</span> <span class="nf">resourceLoader</span><span class="p">(</span><span class="n">_</span> <span class="nv">resourceLoader</span><span class="p">:</span> <span class="kt">AVAssetResourceLoader</span><span class="p">,</span> <span class="n">shouldWaitForLoadingOfRequestedResource</span> <span class="nv">loadingRequest</span><span class="p">:</span> <span class="kt">AVAssetResourceLoadingRequest</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">guard</span> <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">loadingRequest</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">url</span><span class="p">,</span> <span class="n">url</span><span class="o">.</span><span class="n">scheme</span> <span class="o">==</span> <span class="s">"lockdown"</span> <span class="k">else</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">return</span> <span class="kc">false</span> <span class="c1">// Let AVPlayer handle it if it's not our custom scheme</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">baseURL</span> <span class="o">=</span> <span class="kt">APIConfig</span><span class="o">.</span><span class="n">webBaseURL</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="c1">// Reconstruct the URL using the correct scheme, host, and port, but keep the path</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">components</span> <span class="o">=</span> <span class="kt">URLComponents</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">resolvingAgainstBaseURL</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">components</span><span class="p">?</span><span class="o">.</span><span class="n">scheme</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="n">scheme</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">components</span><span class="p">?</span><span class="o">.</span><span class="n">host</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="n">host</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">components</span><span class="p">?</span><span class="o">.</span><span class="n">port</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="n">port</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">guard</span> <span class="k">let</span> <span class="nv">finalURL</span> <span class="o">=</span> <span class="n">components</span><span class="p">?</span><span class="o">.</span><span class="n">url</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">false</span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="c1">// Build the request with the API Key</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">URLRequest</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">finalURL</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">request</span><span class="o">.</span><span class="nf">setValue</span><span class="p">(</span><span class="n">secretKey</span><span class="p">,</span> <span class="nv">forHTTPHeaderField</span><span class="p">:</span> <span class="s">"api-key"</span><span class="p">)</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">task</span> <span class="o">=</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">dataTask</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">if</span> <span class="k">let</span> <span class="nv">data</span> <span class="o">=</span> <span class="n">data</span><span class="p">,</span> <span class="n">error</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">loadingRequest</span><span class="o">.</span><span class="n">dataRequest</span><span class="p">?</span><span class="o">.</span><span class="nf">respond</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">loadingRequest</span><span class="o">.</span><span class="nf">finishLoading</span><span class="p">()</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">loadingRequest</span><span class="o">.</span><span class="nf">finishLoading</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="n">error</span> <span class="p">??</span> <span class="kt">URLError</span><span class="p">(</span><span class="o">.</span><span class="n">badServerResponse</span><span class="p">))</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">task</span><span class="o">.</span><span class="nf">resume</span><span class="p">()</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">return</span> <span class="kc">true</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One more thing is to securely add the API key to the front end. If you merely paste the string in, it could accidentally be pushed to your git repo (bad). Instead, I added the key in a Secrets.xcconfig and listed that file in my .gitignore. Then I wrote the following helper enum to hold both key network constants: the API key, and the BaseURL:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">APIConfig</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">webBaseURL</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"&lt;Your Domain&gt;"</span><span class="p">)</span><span class="o">!</span>
<span class="err"> </span> <span class="err"> </span> 
<span class="err"> </span> <span class="err"> </span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">secretKey</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">guard</span> <span class="k">let</span> <span class="nv">key</span> <span class="o">=</span> <span class="kt">Bundle</span><span class="o">.</span><span class="n">main</span><span class="o">.</span><span class="nf">object</span><span class="p">(</span><span class="nv">forInfoDictionaryKey</span><span class="p">:</span> <span class="s">"API_KEY"</span><span class="p">)</span> <span class="k">as?</span> <span class="kt">String</span> <span class="k">else</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nf">fatalError</span><span class="p">(</span><span class="s">"API_SECRET_KEY is missing from Info.plist or Secrets.xcconfig"</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">return</span> <span class="n">key</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With this out of the way we can write our two ViewModels: <code class="language-plaintext highlighter-rouge">NetworkManager</code> and <code class="language-plaintext highlighter-rouge">VideoFeedManager</code>. Let’s start with the VideoFeedManager. This ViewModel will control what videos are being shown to the user, requesting new ones when the feed gets low. Next we need to replicate the infinite scroll effect, which are notorious memory leakers in mobile dev. To create the instant infinite scroll effect, we will actually use three different video players that will get recycled as the user scrolls, this way we can load videos in advance, and save the last one in case the user quickly scrolls back. By strictly maintaining a pool of exactly three recycled players, we keep memory usage completely flat and prevent the app from eventually crashing, no matter how many reels the user scrolls through.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">import</span> <span class="kt">AVKit</span>
<span class="kd">import</span> <span class="kt">Observation</span>

<span class="kd">@Observable</span>
<span class="kd">class</span> <span class="kt">VideoFeedManager</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="c1">// 3-Player Pool</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">players</span><span class="p">:</span> <span class="p">[</span><span class="kt">AVQueuePlayer</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="kt">AVQueuePlayer</span><span class="p">(),</span> <span class="kt">AVQueuePlayer</span><span class="p">(),</span> <span class="kt">AVQueuePlayer</span><span class="p">()]</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">loopers</span><span class="p">:</span> <span class="p">[</span><span class="kt">AVPlayerLooper</span><span class="p">?]</span> <span class="o">=</span> <span class="p">[</span><span class="kc">nil</span><span class="p">,</span> <span class="kc">nil</span><span class="p">,</span> <span class="kc">nil</span><span class="p">]</span>
<span class="err"> </span> <span class="err"> </span> <span class="k">var</span> <span class="nv">items</span><span class="p">:</span> <span class="p">[</span><span class="kt">URL</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>

<span class="err"> </span> <span class="err"> </span> <span class="c1">// Create a delegate instance</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">private</span> <span class="k">let</span> <span class="nv">resourceLoaderDelegate</span> <span class="o">=</span> <span class="kt">LockdownResourceLoaderDelegate</span><span class="p">()</span>

<span class="err"> </span> <span class="err"> </span> <span class="kd">func</span> <span class="nf">setup</span><span class="p">(</span><span class="n">with</span> <span class="nv">urls</span><span class="p">:</span> <span class="p">[</span><span class="kt">URL</span><span class="p">])</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">self</span><span class="o">.</span><span class="n">items</span> <span class="o">=</span> <span class="n">urls</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nf">updatePool</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

	<span class="c1">// Pause every player in the pool</span>
<span class="err"> </span> <span class="err"> </span> <span class="kd">func</span> <span class="nf">pauseAll</span><span class="p">()</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">players</span><span class="o">.</span><span class="n">forEach</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">pause</span><span class="p">()</span> <span class="p">}</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="kd">func</span> <span class="nf">updatePool</span><span class="p">(</span><span class="k">for</span> <span class="nv">currentIndex</span><span class="p">:</span> <span class="kt">Int</span><span class="p">)</span> <span class="p">{</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">guard</span> <span class="o">!</span><span class="n">items</span><span class="o">.</span><span class="n">isEmpty</span><span class="p">,</span> <span class="n">items</span><span class="o">.</span><span class="n">indices</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">currentIndex</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">currentPlayerIndex</span> <span class="o">=</span> <span class="n">currentIndex</span> <span class="o">%</span> <span class="mi">3</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="c1">// Prepare current, next, and previous players</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nf">preparePlayer</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">currentIndex</span><span class="p">,</span> <span class="nv">poolIndex</span><span class="p">:</span> <span class="n">currentPlayerIndex</span><span class="p">)</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">players</span><span class="p">[</span><span class="n">currentPlayerIndex</span><span class="p">]</span><span class="o">.</span><span class="nf">play</span><span class="p">()</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">if</span> <span class="n">currentIndex</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="n">items</span><span class="o">.</span><span class="n">count</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nf">preparePlayer</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">currentIndex</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">poolIndex</span><span class="p">:</span> <span class="p">(</span><span class="n">currentIndex</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">%</span> <span class="mi">3</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">if</span> <span class="n">currentIndex</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">&gt;=</span> <span class="mi">0</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nf">preparePlayer</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">currentIndex</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">poolIndex</span><span class="p">:</span> <span class="p">(</span><span class="n">currentIndex</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">%</span> <span class="mi">3</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="kd">private</span> <span class="kd">func</span> <span class="nf">preparePlayer</span><span class="p">(</span><span class="n">at</span> <span class="nv">itemIndex</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span> <span class="nv">poolIndex</span><span class="p">:</span> <span class="kt">Int</span><span class="p">)</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">guard</span> <span class="n">items</span><span class="o">.</span><span class="n">indices</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">itemIndex</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">items</span><span class="p">[</span><span class="n">itemIndex</span><span class="p">]</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="c1">// Build the asset and attach our security delegate</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">asset</span> <span class="o">=</span> <span class="kt">AVURLAsset</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">asset</span><span class="o">.</span><span class="n">resourceLoader</span><span class="o">.</span><span class="nf">setDelegate</span><span class="p">(</span><span class="n">resourceLoaderDelegate</span><span class="p">,</span> <span class="nv">queue</span><span class="p">:</span> <span class="kt">DispatchQueue</span><span class="o">.</span><span class="nf">global</span><span class="p">(</span><span class="nv">qos</span><span class="p">:</span> <span class="o">.</span><span class="n">userInitiated</span><span class="p">))</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">item</span> <span class="o">=</span> <span class="kt">AVPlayerItem</span><span class="p">(</span><span class="nv">asset</span><span class="p">:</span> <span class="n">asset</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">player</span> <span class="o">=</span> <span class="n">players</span><span class="p">[</span><span class="n">poolIndex</span><span class="p">]</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="c1">// Skip rebuilding the looper if the URL is already loaded in this player</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">if</span> <span class="k">let</span> <span class="nv">currentAsset</span> <span class="o">=</span> <span class="n">player</span><span class="o">.</span><span class="n">currentItem</span><span class="p">?</span><span class="o">.</span><span class="n">asset</span> <span class="k">as?</span> <span class="kt">AVURLAsset</span><span class="p">,</span> <span class="n">currentAsset</span><span class="o">.</span><span class="n">url</span> <span class="o">==</span> <span class="n">url</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">return</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">loopers</span><span class="p">[</span><span class="n">poolIndex</span><span class="p">]?</span><span class="o">.</span><span class="nf">disableLooping</span><span class="p">()</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">let</span> <span class="nv">newPlayer</span> <span class="o">=</span> <span class="kt">AVQueuePlayer</span><span class="p">()</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">players</span><span class="p">[</span><span class="n">poolIndex</span><span class="p">]</span> <span class="o">=</span> <span class="n">newPlayer</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="n">loopers</span><span class="p">[</span><span class="n">poolIndex</span><span class="p">]</span> <span class="o">=</span> <span class="kt">AVPlayerLooper</span><span class="p">(</span><span class="nv">player</span><span class="p">:</span> <span class="n">newPlayer</span><span class="p">,</span> <span class="nv">templateItem</span><span class="p">:</span> <span class="n">item</span><span class="p">)</span>

<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>

<span class="err"> </span> <span class="err"> </span> <span class="kd">func</span> <span class="nf">append</span><span class="p">(</span><span class="nv">urls</span><span class="p">:</span> <span class="p">[</span><span class="kt">URL</span><span class="p">])</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">self</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="nv">contentsOf</span><span class="p">:</span> <span class="n">urls</span><span class="p">)</span>

<span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now we can create our second ViewModel, the NetworkManager. This will facilitate most contact with the backend (aside from the HLS requests which are automatically handled by our lockdown delegate and iOS’s <code class="language-plaintext highlighter-rouge">AVPlayer</code>). The NetworkManager also stores all currently loaded video references which are passed to our new VideoFeedManager. We need to implement two important functions: <code class="language-plaintext highlighter-rouge">fetchFeed()</code> and <code class="language-plaintext highlighter-rouge">fetchMoreFeed()</code>, which are just two ways for the frontend to tell the backend that I needs some videos.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">import</span> <span class="kt">Observation</span>

<span class="kd">@Observable</span>
<span class="kd">class</span> <span class="kt">NetworkManager</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">videos</span><span class="p">:</span> <span class="p">[</span><span class="kt">Video</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">isFetchingMore</span> <span class="o">=</span> <span class="kc">false</span>
    
    <span class="kd">@ObservationIgnored</span>
    <span class="kd">private</span> <span class="k">let</span> <span class="nv">manager</span><span class="p">:</span> <span class="kt">VideoFeedManager</span>

    <span class="k">let</span> <span class="nv">resourceLoaderDelegate</span> <span class="o">=</span> <span class="kt">LockdownResourceLoaderDelegate</span><span class="p">()</span>

    <span class="nf">init</span><span class="p">(</span><span class="nv">manager</span><span class="p">:</span> <span class="kt">VideoFeedManager</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">self</span><span class="o">.</span><span class="n">manager</span> <span class="o">=</span> <span class="n">manager</span>
    <span class="p">}</span>

    <span class="c1">// Could be http://localhost:3001 or wherever you are hosting the api.</span>
    <span class="k">var</span> <span class="nv">baseURL</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"&lt;YOUR DOMAIN&gt;"</span><span class="p">)</span><span class="o">!</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="k">for</span> <span class="nv">url</span><span class="p">:</span> <span class="kt">URL</span><span class="p">,</span> <span class="nv">method</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="s">"GET"</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">URLRequest</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">URLRequest</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
        <span class="n">request</span><span class="o">.</span><span class="n">httpMethod</span> <span class="o">=</span> <span class="n">method</span>
        <span class="n">request</span><span class="o">.</span><span class="nf">setValue</span><span class="p">(</span><span class="kt">APIConfig</span><span class="o">.</span><span class="n">secretKey</span><span class="p">,</span> <span class="nv">forHTTPHeaderField</span><span class="p">:</span> <span class="s">"api-key"</span><span class="p">)</span>
        <span class="n">request</span><span class="o">.</span><span class="nf">setValue</span><span class="p">(</span><span class="s">"application/json"</span><span class="p">,</span> <span class="nv">forHTTPHeaderField</span><span class="p">:</span> <span class="s">"Content-Type"</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">request</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">mutate</span><span class="p">:</span> <span class="p">(</span><span class="k">inout</span> <span class="kt">Video</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Void</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="k">let</span> <span class="nv">i</span> <span class="o">=</span> <span class="n">videos</span><span class="o">.</span><span class="nf">firstIndex</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">folderName</span> <span class="o">==</span> <span class="n">folderName</span> <span class="p">})</span> <span class="p">{</span>
            <span class="nf">mutate</span><span class="p">(</span><span class="o">&amp;</span><span class="n">videos</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">fetchFeed</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">do</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">request</span> <span class="o">=</span> <span class="k">try</span> <span class="nf">buildFeedRequest</span><span class="p">()</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">_</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            <span class="k">let</span> <span class="nv">decodedVideos</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">([</span><span class="kt">Video</span><span class="p">]</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
            
            <span class="k">self</span><span class="o">.</span><span class="n">videos</span> <span class="o">=</span> <span class="n">decodedVideos</span>
            <span class="k">let</span> <span class="nv">urls</span> <span class="o">=</span> <span class="n">decodedVideos</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">streamURL</span><span class="p">(</span><span class="nv">base</span><span class="p">:</span> <span class="k">self</span><span class="o">.</span><span class="n">baseURL</span><span class="p">)</span> <span class="p">}</span>
            
            <span class="n">manager</span><span class="o">.</span><span class="nf">setup</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="n">urls</span><span class="p">)</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">print</span><span class="p">(</span><span class="s">"Feed fetch failed: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>


    <span class="kd">func</span> <span class="nf">fetchMoreFeed</span><span class="p">()</span> <span class="k">async</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Video</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="o">!</span><span class="n">isFetchingMore</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">[]</span> <span class="p">}</span>
        <span class="n">isFetchingMore</span> <span class="o">=</span> <span class="kc">true</span>
        <span class="k">defer</span> <span class="p">{</span> <span class="n">isFetchingMore</span> <span class="o">=</span> <span class="kc">false</span> <span class="p">}</span>

        <span class="k">do</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">request</span> <span class="o">=</span> <span class="k">try</span> <span class="nf">buildFeedRequest</span><span class="p">()</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">_</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            <span class="k">let</span> <span class="nv">decodedVideos</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">([</span><span class="kt">Video</span><span class="p">]</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
            
            <span class="k">let</span> <span class="nv">existingIds</span> <span class="o">=</span> <span class="kt">Set</span><span class="p">(</span><span class="k">self</span><span class="o">.</span><span class="n">videos</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">id</span> <span class="p">})</span>
            <span class="k">let</span> <span class="nv">newVideos</span> <span class="o">=</span> <span class="n">decodedVideos</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span> <span class="o">!</span><span class="n">existingIds</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="nv">$0</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="p">}</span>

            
            <span class="k">self</span><span class="o">.</span><span class="n">videos</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="nv">contentsOf</span><span class="p">:</span> <span class="n">newVideos</span><span class="p">)</span>
            <span class="k">let</span> <span class="nv">newUrls</span> <span class="o">=</span> <span class="n">newVideos</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">streamURL</span><span class="p">(</span><span class="nv">base</span><span class="p">:</span> <span class="k">self</span><span class="o">.</span><span class="n">baseURL</span><span class="p">)</span> <span class="p">}</span>
            <span class="n">manager</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="nv">urls</span><span class="p">:</span> <span class="n">newUrls</span><span class="p">)</span>
            
            <span class="k">return</span> <span class="n">newVideos</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">print</span><span class="p">(</span><span class="s">"Failed to fetch more feed: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
            <span class="k">return</span> <span class="p">[]</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">buildFeedRequest</span><span class="p">()</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">URLRequest</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/feed/random"</span><span class="p">)</span>
        <span class="k">return</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That covers creating and maintaining our apps video feed. Now we need to add two local functions for sending the request to favorite/unfavorite a video, and update the rating for a video.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="kd">func</span> <span class="nf">toggleFavorite</span><span class="p">(</span><span class="k">for</span> <span class="nv">folderName</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">currentState</span> <span class="o">=</span> <span class="n">videos</span><span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">folderName</span> <span class="o">==</span> <span class="n">folderName</span> <span class="p">})?</span><span class="o">.</span><span class="n">isFavorited</span> <span class="p">??</span> <span class="kc">false</span>
        <span class="k">let</span> <span class="nv">desiredState</span> <span class="o">=</span> <span class="o">!</span><span class="n">currentState</span>

        <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">isFavorited</span> <span class="o">=</span> <span class="n">desiredState</span> <span class="p">}</span>

        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/videos/</span><span class="se">\(</span><span class="n">folderName</span><span class="se">)</span><span class="s">/favorite"</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">method</span><span class="p">:</span> <span class="s">"POST"</span><span class="p">)</span>
        
        <span class="k">do</span> <span class="p">{</span>
            <span class="n">request</span><span class="o">.</span><span class="n">httpBody</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONSerialization</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">withJSONObject</span><span class="p">:</span> <span class="p">[</span><span class="s">"is_favorited"</span><span class="p">:</span> <span class="n">desiredState</span><span class="p">])</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="nv">response</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            
            <span class="k">if</span> <span class="k">let</span> <span class="nv">http</span> <span class="o">=</span> <span class="n">response</span> <span class="k">as?</span> <span class="kt">HTTPURLResponse</span><span class="p">,</span> <span class="n">http</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">!=</span> <span class="mi">200</span> <span class="p">{</span>
                <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">isFavorited</span> <span class="o">=</span> <span class="n">currentState</span> <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">isFavorited</span> <span class="o">=</span> <span class="n">currentState</span> <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">updateRating</span><span class="p">(</span><span class="k">for</span> <span class="nv">folderName</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">rating</span><span class="p">:</span> <span class="kt">Int</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">currentRating</span> <span class="o">=</span> <span class="n">videos</span><span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">folderName</span> <span class="o">==</span> <span class="n">folderName</span> <span class="p">})?</span><span class="o">.</span><span class="n">rating</span> <span class="p">??</span> <span class="mi">0</span>

        <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">rating</span> <span class="o">=</span> <span class="n">rating</span> <span class="p">}</span>
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">baseURL</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"api/videos/</span><span class="se">\(</span><span class="n">folderName</span><span class="se">)</span><span class="s">/rating"</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="nf">authenticatedRequest</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">method</span><span class="p">:</span> <span class="s">"POST"</span><span class="p">)</span>
        
        <span class="k">do</span> <span class="p">{</span>
            <span class="n">request</span><span class="o">.</span><span class="n">httpBody</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONSerialization</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">withJSONObject</span><span class="p">:</span> <span class="p">[</span><span class="s">"rating"</span><span class="p">:</span> <span class="n">rating</span><span class="p">])</span>
            <span class="k">let</span> <span class="p">(</span><span class="nv">_</span><span class="p">,</span> <span class="nv">response</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
            
            <span class="k">if</span> <span class="k">let</span> <span class="nv">http</span> <span class="o">=</span> <span class="n">response</span> <span class="k">as?</span> <span class="kt">HTTPURLResponse</span><span class="p">,</span> <span class="n">http</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">!=</span> <span class="mi">200</span> <span class="p">{</span>
                <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">rating</span> <span class="o">=</span> <span class="n">currentRating</span> <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="nf">mutateVideo</span><span class="p">(</span><span class="nv">folderName</span><span class="p">:</span> <span class="n">folderName</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">rating</span> <span class="o">=</span> <span class="n">currentRating</span> <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

</code></pre></div></div>

<h3 id="the-user-interface">The User Interface</h3>

<p>Finally, we have made it to the point where we can make the app fully functional by implementing the user interface. When you created the Xcode project, it automatically creates a ContentView file with SwiftUI imported. This file is where the UI is set up. If you are unfamiliar with SwiftUI, I highly recommend <a href="https://youtu.be/n1qabtjZ_jg?si=_Tl13-mVqTsmCizH">Paul Hegarty’s CS193p</a> course on YouTube.</p>

<p>In this view we initialize both of our ViewModels. For the infinite scroll effect, we will put all three of our players inside of a <code class="language-plaintext highlighter-rouge">ScrollView</code> with <code class="language-plaintext highlighter-rouge">.scrollTargetBehavior(.paging)</code>, to ensure it is always centered on a video. In order to overwrite the default video player UI which has annoying buttons, we need to create a custom video player class as well.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">SwiftUI</span>
<span class="kd">internal</span> <span class="kd">import</span> <span class="kt">AVFoundation</span>
<span class="kd">import</span> <span class="kt">AVKit</span>

<span class="kd">struct</span> <span class="kt">ContentView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">manager</span><span class="p">:</span> <span class="kt">VideoFeedManager</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">networkManager</span><span class="p">:</span> <span class="kt">NetworkManager</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">activeIndex</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>

    <span class="nf">init</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">playerManager</span> <span class="o">=</span> <span class="kt">VideoFeedManager</span><span class="p">()</span>
        <span class="k">self</span><span class="o">.</span><span class="n">_manager</span> <span class="o">=</span> <span class="kt">State</span><span class="p">(</span><span class="nv">initialValue</span><span class="p">:</span> <span class="n">playerManager</span><span class="p">)</span>
        <span class="k">self</span><span class="o">.</span><span class="n">_networkManager</span> <span class="o">=</span> <span class="kt">State</span><span class="p">(</span><span class="nv">initialValue</span><span class="p">:</span> <span class="kt">NetworkManager</span><span class="p">(</span><span class="nv">manager</span><span class="p">:</span> <span class="n">playerManager</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">networkManager</span><span class="o">.</span><span class="n">videos</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">{</span>
			<span class="c1">// Message for when no videos are found.</span>
			<span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">16</span><span class="p">)</span> <span class="p">{</span>
				<span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"film.slash.fill"</span><span class="p">)</span>
					<span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">56</span><span class="p">))</span>
					<span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.4</span><span class="p">),</span> <span class="o">.</span><span class="n">white</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.1</span><span class="p">))</span>
					<span class="o">.</span><span class="nf">symbolRenderingMode</span><span class="p">(</span><span class="o">.</span><span class="n">hierarchical</span><span class="p">)</span>
				
				<span class="kt">Text</span><span class="p">(</span><span class="s">"No Videos Found"</span><span class="p">)</span>
					<span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">title3</span><span class="o">.</span><span class="nf">weight</span><span class="p">(</span><span class="o">.</span><span class="n">bold</span><span class="p">))</span>
					<span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.9</span><span class="p">))</span>
			<span class="p">}</span>
		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
			<span class="kt">ScrollView</span><span class="p">(</span><span class="o">.</span><span class="n">vertical</span><span class="p">,</span> <span class="nv">showsIndicators</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span> <span class="p">{</span>
				<span class="kt">LazyVStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
					<span class="kt">ForEach</span><span class="p">(</span><span class="kt">Array</span><span class="p">(</span><span class="n">manager</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="nf">enumerated</span><span class="p">()),</span> <span class="nv">id</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="n">offset</span><span class="p">)</span> <span class="p">{</span> <span class="n">index</span><span class="p">,</span> <span class="n">_</span> <span class="k">in</span>
						<span class="k">let</span> <span class="nv">isInPool</span> <span class="o">=</span> <span class="n">activeIndex</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="nf">abs</span><span class="p">(</span><span class="n">index</span> <span class="o">-</span> <span class="p">(</span><span class="n">activeIndex</span> <span class="p">??</span> <span class="mi">0</span><span class="p">))</span> <span class="o">&lt;=</span> <span class="mi">1</span>
						<span class="k">let</span> <span class="nv">player</span> <span class="o">=</span> <span class="n">isInPool</span> <span class="p">?</span> <span class="n">manager</span><span class="o">.</span><span class="n">players</span><span class="p">[</span><span class="n">index</span> <span class="o">%</span> <span class="mi">3</span><span class="p">]</span> <span class="p">:</span> <span class="kc">nil</span>
						
						<span class="kt">ZStack</span> <span class="p">{</span>
							<span class="k">if</span> <span class="k">let</span> <span class="nv">player</span> <span class="o">=</span> <span class="n">player</span> <span class="p">{</span>
								<span class="kt">CustomVideoPlayer</span><span class="p">(</span><span class="nv">player</span><span class="p">:</span> <span class="n">player</span><span class="p">)</span>
									<span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="p">)</span>
									<span class="o">.</span><span class="n">onTapGesture</span> <span class="p">{</span>
										<span class="k">if</span> <span class="n">player</span><span class="o">.</span><span class="n">timeControlStatus</span> <span class="o">==</span> <span class="o">.</span><span class="n">playing</span> <span class="p">{</span>
											<span class="n">player</span><span class="o">.</span><span class="nf">pause</span><span class="p">()</span>
										<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
											<span class="n">player</span><span class="o">.</span><span class="nf">play</span><span class="p">()</span>
										<span class="p">}</span>
									<span class="p">}</span>
								
								<span class="k">if</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">networkManager</span><span class="o">.</span><span class="n">videos</span><span class="o">.</span><span class="n">count</span> <span class="p">{</span>
									<span class="k">let</span> <span class="nv">video</span> <span class="o">=</span> <span class="n">networkManager</span><span class="o">.</span><span class="n">videos</span><span class="p">[</span><span class="n">index</span><span class="p">]</span>
									<span class="kt">ReelsOverlay</span><span class="p">(</span><span class="nv">video</span><span class="p">:</span> <span class="n">video</span><span class="p">,</span> <span class="nv">networkManager</span><span class="p">:</span> <span class="n">networkManager</span><span class="p">,</span> <span class="nv">player</span><span class="p">:</span> <span class="n">player</span><span class="p">)</span>
								<span class="p">}</span>
							<span class="p">}</span>
						<span class="p">}</span>
						<span class="o">.</span><span class="nf">containerRelativeFrame</span><span class="p">([</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="o">.</span><span class="n">vertical</span><span class="p">])</span>
						<span class="o">.</span><span class="nf">clipped</span><span class="p">()</span>
						<span class="o">.</span><span class="nf">id</span><span class="p">(</span><span class="n">index</span><span class="p">)</span>
						<span class="o">.</span><span class="n">onAppear</span> <span class="p">{</span>
							<span class="k">if</span> <span class="n">index</span> <span class="o">==</span> <span class="n">manager</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="n">count</span> <span class="o">-</span> <span class="mi">3</span> <span class="p">{</span>
								<span class="kt">Task</span> <span class="p">{</span> <span class="k">await</span> <span class="n">networkManager</span><span class="o">.</span><span class="nf">fetchMoreFeed</span><span class="p">()</span> <span class="p">}</span>
							<span class="p">}</span>
						<span class="p">}</span>
					<span class="p">}</span>
				<span class="p">}</span>
				<span class="o">.</span><span class="nf">scrollTargetLayout</span><span class="p">()</span>
			<span class="p">}</span>
			<span class="o">.</span><span class="nf">scrollTargetBehavior</span><span class="p">(</span><span class="o">.</span><span class="n">paging</span><span class="p">)</span>
			<span class="o">.</span><span class="nf">clipped</span><span class="p">()</span>
			<span class="o">.</span><span class="nf">scrollPosition</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="n">$activeIndex</span><span class="p">)</span>
			<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">activeIndex</span><span class="p">)</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">newIndex</span> <span class="k">in</span>
				<span class="k">if</span> <span class="k">let</span> <span class="nv">newIndex</span> <span class="p">{</span>
					<span class="n">manager</span><span class="o">.</span><span class="nf">updatePool</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">newIndex</span><span class="p">)</span>
				<span class="p">}</span>
			
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">maxWidth</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">,</span> <span class="nv">maxHeight</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="p">)</span>
        <span class="o">.</span><span class="n">task</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">manager</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="n">isEmpty</span> <span class="p">{</span> <span class="k">await</span> <span class="n">networkManager</span><span class="o">.</span><span class="nf">fetchFeed</span><span class="p">()</span> <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">class</span> <span class="kt">PlayerUIView</span><span class="p">:</span> <span class="kt">UIView</span> <span class="p">{</span>
    <span class="k">override</span> <span class="kd">class</span> <span class="k">var</span> <span class="nv">layerClass</span><span class="p">:</span> <span class="kt">AnyClass</span> <span class="p">{</span> <span class="kt">AVPlayerLayer</span><span class="o">.</span><span class="k">self</span> <span class="p">}</span>
    <span class="k">var</span> <span class="nv">playerLayer</span><span class="p">:</span> <span class="kt">AVPlayerLayer</span> <span class="p">{</span> <span class="n">layer</span> <span class="k">as!</span> <span class="kt">AVPlayerLayer</span> <span class="p">}</span>
<span class="p">}</span>

<span class="kd">struct</span> <span class="kt">CustomVideoPlayer</span><span class="p">:</span> <span class="kt">UIViewRepresentable</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">player</span><span class="p">:</span> <span class="kt">AVPlayer</span>

    <span class="kd">func</span> <span class="nf">makeUIView</span><span class="p">(</span><span class="nv">context</span><span class="p">:</span> <span class="kt">Context</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">PlayerUIView</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">view</span> <span class="o">=</span> <span class="kt">PlayerUIView</span><span class="p">()</span>
        <span class="n">view</span><span class="o">.</span><span class="n">playerLayer</span><span class="o">.</span><span class="n">player</span> <span class="o">=</span> <span class="n">player</span>
        <span class="n">view</span><span class="o">.</span><span class="n">playerLayer</span><span class="o">.</span><span class="n">videoGravity</span> <span class="o">=</span> <span class="o">.</span><span class="n">resizeAspectFill</span>
        <span class="k">return</span> <span class="n">view</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">updateUIView</span><span class="p">(</span><span class="n">_</span> <span class="nv">uiView</span><span class="p">:</span> <span class="kt">PlayerUIView</span><span class="p">,</span> <span class="nv">context</span><span class="p">:</span> <span class="kt">Context</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">uiView</span><span class="o">.</span><span class="n">playerLayer</span><span class="o">.</span><span class="n">player</span> <span class="o">=</span> <span class="n">player</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If you launch the app now it finally is a working prototype! You should be able to swipe endlessly and view all your reels. This is exciting, however its looking a little bland, what about all that metadata we pulled from the jsons, and the API hooks for rating/favoriting videos? We can incorporate those video-specific features by adding an overlay to each player, which includes buttons for liking and displays the metadata.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">SwiftUI</span>
<span class="kd">internal</span> <span class="kd">import</span> <span class="kt">AVFoundation</span>

<span class="kd">struct</span> <span class="kt">ReelsOverlay</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">video</span><span class="p">:</span> <span class="kt">Video</span>
    <span class="k">var</span> <span class="nv">networkManager</span><span class="p">:</span> <span class="kt">NetworkManager</span>
    <span class="k">let</span> <span class="nv">player</span><span class="p">:</span> <span class="kt">AVPlayer</span>
    
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isExpanded</span> <span class="o">=</span> <span class="kc">false</span>
    
    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">ZStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">topLeading</span><span class="p">)</span> <span class="p">{</span>
            
            <span class="k">if</span> <span class="n">isExpanded</span> <span class="p">{</span>
                <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">8</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"FOLDER"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">folderName</span><span class="p">)</span>
                    <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"SHORTCODE"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">shortcode</span> <span class="p">??</span> <span class="s">"NULL"</span><span class="p">)</span>
                    <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"POSTED"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">datePosted</span> <span class="p">??</span> <span class="s">"NULL"</span><span class="p">)</span>
                    <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"UPLOADER"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">uploader</span> <span class="p">??</span> <span class="s">"NULL"</span><span class="p">)</span>
                    <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"USERNAME"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">username</span> <span class="p">??</span> <span class="s">"NULL"</span><span class="p">)</span>
                    <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"ADDED"</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">dateAdded</span> <span class="p">??</span> <span class="s">"NULL"</span><span class="p">)</span>
                <span class="p">}</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="mi">16</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.8</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">cornerRadius</span><span class="p">(</span><span class="mi">12</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">top</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">leading</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">transition</span><span class="p">(</span><span class="o">.</span><span class="n">opacity</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">zIndex</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
            <span class="p">}</span>
            
            <span class="c1">// Bottom UI</span>
            <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
                <span class="kt">Spacer</span><span class="p">()</span>
                
                <span class="kt">HStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">bottom</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">16</span><span class="p">)</span> <span class="p">{</span>
                    <span class="kt">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">8</span><span class="p">)</span> <span class="p">{</span>
                        <span class="k">if</span> <span class="k">let</span> <span class="nv">username</span> <span class="o">=</span> <span class="n">video</span><span class="o">.</span><span class="n">username</span> <span class="p">{</span>
                            <span class="kt">Text</span><span class="p">(</span><span class="s">"@</span><span class="se">\(</span><span class="n">username</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
                                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">16</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                                <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
                                <span class="o">.</span><span class="nf">shadow</span><span class="p">(</span><span class="nv">radius</span><span class="p">:</span> <span class="mi">4</span><span class="p">)</span>
                        <span class="p">}</span>
                        
                        <span class="kt">Text</span><span class="p">(</span><span class="n">video</span><span class="o">.</span><span class="n">description</span> <span class="p">??</span> <span class="s">"No description available"</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">14</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">medium</span><span class="p">))</span>
                            <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">lineLimit</span><span class="p">(</span><span class="n">isExpanded</span> <span class="p">?</span> <span class="nv">nil</span> <span class="p">:</span> <span class="mi">2</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">multilineTextAlignment</span><span class="p">(</span><span class="o">.</span><span class="n">leading</span><span class="p">)</span>
                            <span class="o">.</span><span class="nf">shadow</span><span class="p">(</span><span class="nv">radius</span><span class="p">:</span> <span class="mi">4</span><span class="p">)</span>
                            <span class="o">.</span><span class="n">onTapGesture</span> <span class="p">{</span>
                                <span class="nf">withAnimation</span><span class="p">(</span><span class="o">.</span><span class="nf">spring</span><span class="p">())</span> <span class="p">{</span> <span class="n">isExpanded</span><span class="o">.</span><span class="nf">toggle</span><span class="p">()</span> <span class="p">}</span>
                            <span class="p">}</span>
                    <span class="p">}</span>
                    
                    <span class="kt">Spacer</span><span class="p">()</span>
                    
                    <span class="c1">// Bottom Right Buttons</span>
                    <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">18</span><span class="p">)</span> <span class="p">{</span>
                        <span class="kt">Button</span><span class="p">(</span><span class="nv">action</span><span class="p">:</span> <span class="p">{</span>
                            <span class="kt">Task</span> <span class="p">{</span> <span class="k">await</span> <span class="n">networkManager</span><span class="o">.</span><span class="nf">toggleFavorite</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">folderName</span><span class="p">)</span> <span class="p">}</span>
                        <span class="p">})</span> <span class="p">{</span>
                            <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="n">video</span><span class="o">.</span><span class="n">isFavorited</span> <span class="p">?</span> <span class="s">"heart.fill"</span> <span class="p">:</span> <span class="s">"heart"</span><span class="p">)</span>
                                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                                <span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="n">video</span><span class="o">.</span><span class="n">isFavorited</span> <span class="p">?</span> <span class="o">.</span><span class="nv">pink</span> <span class="p">:</span> <span class="o">.</span><span class="n">white</span><span class="p">)</span>
                                <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">52</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mi">52</span><span class="p">)</span>
                                <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="kt">Color</span><span class="o">.</span><span class="n">black</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.6</span><span class="p">))</span>
                                <span class="o">.</span><span class="nf">clipShape</span><span class="p">(</span><span class="kt">Circle</span><span class="p">())</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">}</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">,</span> <span class="mi">20</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">bottom</span><span class="p">,</span> <span class="mi">12</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
    
    <span class="c1">// A small view builder to neatly display each video feature. </span>
    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">metadataRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">HStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">top</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Text</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">label</span><span class="se">)</span><span class="s">:"</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.5</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">75</span><span class="p">,</span> <span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span>
            <span class="kt">Text</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">foregroundColor</span><span class="p">(</span><span class="o">.</span><span class="n">white</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="a-full-stack-project">A Full Stack Project</h2>

<p>At this point, we have finished a complete full stack project. You can find plenty of tutorials online about how to install your app onto your device using TestFlight, and if you have the server set up for remote access, you can now access your reels from anywhere!</p>

<p>Working on this project was a fun reminder of why I enjoy the full-stack approach. It was satisfying to bridge the gap between the backend and the SwiftUI frontend, and I specifically enjoyed the challenge of setting up my own backend again and getting a proper database running as it’s been a while since I’ve done either from scratch.</p>

<p>Of course, this is a personal project, not a commercial product. If I were looking at scaling this for actual users, the current setup of local file mounting and a simple SQLite database would hit a wall pretty quickly. To make it production-ready, you’d want to move toward S3-compatible storage, a proper Content Delivery Network like Cloudfront, and aggressive caching layers for the video players.</p>

<p>It’s easy to forget that while corporations invest billions into these massive apps that we have zero control over, it isn’t actually that difficult to recreate a similar experience for yourself. If you want more control over what you’re consuming, building your own tools is a great way to get it.</p>

<p>If you want, you can stop here, and continue to work on the project with your own additions. In Part III of this series, I’ll dive into some extensions I’ve been working on to make the app even more functional. I’ll discuss adding a tagging system, adding image post support, and a way to filter the feed so you can see exactly what you’re looking for.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[This is Part II in a series about making a custom short form video (SFV) app like Instagram Reels, or YouTube Shorts. If you haven’t read Part I, I suppose that is a prerequisite for this post. This specific post focuses on building the frontend for our project and assumes that the backend is functional and loaded with media as described in Part I.]]></summary></entry><entry><title type="html">How and Why I Made a Self-Hosted Short Form Video Server</title><link href="https://www.jackbodine.com/blog/short-form-app/" rel="alternate" type="text/html" title="How and Why I Made a Self-Hosted Short Form Video Server" /><published>2026-03-25T00:00:00+00:00</published><updated>2026-03-25T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/short-form-app</id><content type="html" xml:base="https://www.jackbodine.com/blog/short-form-app/"><![CDATA[<p>One of the most belabored topics on the internet is the negative consequences of social media, particularly short form video (SFV) such as TikTok, Instagram Reels, and Youtube Shorts. Despite harming attention spans, invoking negative emotions, and reducing life satisfaction, people (including myself) still find these apps entertaining. <a href="https://psycnet.apa.org/buy/2021-01169-001">Recent studies</a> show that the negative effect depends mostly on the type of content you watch. However, in the corporate owned apps, you have little control over the videos they feed you. That is why I sought to create my own short form video client. This resulted in a full stack project that was very satisfying to work on, so I thought to document the process here in case anyone else wanted to give it a try.</p>

<p>This project consists of three blog posts; this is Part I, where we develop the backend in Python using FastAPI, SQLite3, and SQLAlchemy. We also make a couple small scripts to scrape and preprocess data into HLS format. In Part II, we make the frontend, an iOS app written in Swift that plays the videos in an infinite wheel of iOS AVPlayers, and can favorite and rate posts. Part III contains a couple smaller extensions to the app such as tags, progress bars, and a bit of discussion on the utility of short form video.</p>

<p><img src="/assets/march2026/1.png" alt="Software Architecture Diagram" /></p>

<h2 id="collecting-and-preprocessing-media">Collecting and Preprocessing Media</h2>

<p>The first step is collecting the media that will eventually be served over the app. As a long time user of instagram reels, I’ve added thousands of videos into my profiles ‘saved’ collection. Mostly consisting of art that resonated with me, creative ideas I would one day attempt myself, or memes I found particularly funny. It makes sense that these would be the content I would want to see in my own app.</p>

<p>A quick kagi search led me to <a href="https://github.com/instaloader/instaloader">instaloader</a>, an open source project designed for scraping posts and reels from instagram. Another quick search lead me to realize that (unsurprisingly) many accounts which use instaloader get flagged as bots and deactivated, which was not a risk I wanted to take. Instead I created a burner account on instagram to make the API calls, and exported all my main profile’s information to get ahold of a <code class="language-plaintext highlighter-rouge">saved_posts.json</code> file, which contains the shortcodes of all the posts I’ve ever saved that can be fed to instaloader.</p>

<p>Since I would manually be loading shortcodes and feeding them to instaloader, I couldn’t use the regular instaloader API, and instead needed to write a small notebook file to do the scraping.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">json</span>  
<span class="kn">import</span> <span class="n">re</span>  
<span class="kn">import</span> <span class="n">time</span>  
<span class="kn">import</span> <span class="n">random</span>  
<span class="kn">import</span> <span class="n">os</span>  
<span class="kn">import</span> <span class="n">instaloader</span>  
<span class="kn">from</span> <span class="n">instaloader</span> <span class="kn">import</span> <span class="n">Post</span>  

<span class="n">USERNAME</span> <span class="o">=</span> <span class="sh">"</span><span class="s">...</span><span class="sh">"</span>        <span class="c1"># My burner account's name 
</span><span class="n">JSON_FILE</span> <span class="o">=</span> <span class="sh">"</span><span class="s">...</span><span class="sh">"</span>       <span class="c1"># Path to saved_posts.json
</span><span class="n">DOWNLOAD_DIR</span> <span class="o">=</span> <span class="sh">"</span><span class="s">...</span><span class="sh">"</span>    <span class="c1"># Where I saved the reels
</span>
<span class="c1"># %%
</span>
<span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">JSON_FILE</span><span class="p">,</span> <span class="sh">'</span><span class="s">r</span><span class="sh">'</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">'</span><span class="s">utf-8</span><span class="sh">'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>  
    <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>  
  
<span class="c1"># Extract the shortcodes using regex  
</span><span class="n">content</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>  
<span class="n">pattern</span> <span class="o">=</span> <span class="sa">r</span><span class="sh">'</span><span class="s">instagram\.com/(?:p|reel|tv)/([^/</span><span class="sh">"</span><span class="s">]+)</span><span class="sh">'</span>  
<span class="n">all_shortcodes</span> <span class="o">=</span> <span class="nf">set</span><span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nf">findall</span><span class="p">(</span><span class="n">pattern</span><span class="p">,</span> <span class="n">content</span><span class="p">))</span>  
  
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Extracted </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">all_shortcodes</span><span class="p">)</span><span class="si">}</span><span class="s"> shortcodes from </span><span class="si">{</span><span class="n">JSON_FILE</span><span class="si">}</span><span class="s">.</span><span class="sh">"</span><span class="p">)</span>

<span class="c1"># %%
</span>
<span class="n">L</span> <span class="o">=</span> <span class="n">instaloader</span><span class="p">.</span><span class="nc">Instaloader</span><span class="p">(</span><span class="n">dirname_pattern</span><span class="o">=</span><span class="n">DOWNLOAD_DIR</span><span class="p">)</span>  

<span class="c1"># You must run instaloader from the command line at least once
# to save the session file.
</span><span class="k">try</span><span class="p">:</span>  
    <span class="n">L</span><span class="p">.</span><span class="nf">load_session_from_file</span><span class="p">(</span><span class="n">USERNAME</span><span class="p">)</span>  
<span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>  
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Session error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>  
    <span class="k">raise</span>  
  
<span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">shortcode</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">all_shortcodes</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>  
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">[</span><span class="si">{</span><span class="n">index</span><span class="si">}</span><span class="s">/</span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">all_shortcodes</span><span class="p">)</span><span class="si">}</span><span class="s">] Processing </span><span class="si">{</span><span class="n">shortcode</span><span class="si">}</span><span class="s">...</span><span class="sh">"</span><span class="p">)</span>  
    <span class="k">try</span><span class="p">:</span>  
        <span class="n">post</span> <span class="o">=</span> <span class="n">Post</span><span class="p">.</span><span class="nf">from_shortcode</span><span class="p">(</span><span class="n">L</span><span class="p">.</span><span class="n">context</span><span class="p">,</span> <span class="n">shortcode</span><span class="p">)</span>  
        <span class="n">expected_prefix</span> <span class="o">=</span> <span class="n">post</span><span class="p">.</span><span class="n">date_utc</span><span class="p">.</span><span class="nf">strftime</span><span class="p">(</span><span class="sh">'</span><span class="s">%Y-%m-%d_%H-%M-%S</span><span class="sh">'</span><span class="p">)</span>  
        <span class="n">L</span><span class="p">.</span><span class="nf">download_post</span><span class="p">(</span><span class="n">post</span><span class="p">,</span> <span class="n">target</span><span class="o">=</span><span class="n">DOWNLOAD_DIR</span><span class="p">)</span>  
  
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>  
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s"> -&gt; Exception processing </span><span class="si">{</span><span class="n">shortcode</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>  
  
	<span class="c1"># Random delay to get around instagrams safeguards.
</span>	<span class="n">delay</span> <span class="o">=</span> <span class="n">random</span><span class="p">.</span><span class="nf">uniform</span><span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span>  
	<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s"> -&gt; Waiting </span><span class="si">{</span><span class="n">delay</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">s...</span><span class="se">\n</span><span class="sh">"</span><span class="p">)</span>  
	<span class="n">time</span><span class="p">.</span><span class="nf">sleep</span><span class="p">(</span><span class="n">delay</span><span class="p">)</span>
</code></pre></div></div>

<p>The result is a directory cluttered with various file types. Although the only parts relevant to us right now are the .mp4 and .json.xz files, the reels and metadata respectively.</p>

<p>Before we can write the backend, we need to convert the video files into HLS segments. Most modern video servers never try to serve an entire mp4 to the user, as that would create an awkward buffering period before every video is played, basically nullifying the traditional short form video experience. Instead, the <a href="https://en.wikipedia.org/wiki/HTTP_Live_Streaming">HTTP Live Streaming</a> or HLS format breaks a file down into many smaller chunks called <code class="language-plaintext highlighter-rouge">.ts</code> files and a <code class="language-plaintext highlighter-rouge">.m3u8</code> playlist file. In our case, each chunk will be two seconds long, loading almost instantly on a modern internet connection, and allowing the video client to start playing the video as soon as the first <code class="language-plaintext highlighter-rouge">.ts</code> segment is received while it continues to fetch the consecutive segments in the background.</p>

<p><img src="/assets/march2026/2.png" alt="Video Formats" /></p>

<p>We can easily convert our scraped <code class="language-plaintext highlighter-rouge">.mp4</code> files to the HLS format using <code class="language-plaintext highlighter-rouge">ffmpeg</code>.  <code class="language-plaintext highlighter-rouge">ffmpeg</code> also allows us to encrypt each video file with an encryption key. We can generate a random key for each video using <code class="language-plaintext highlighter-rouge">openssl</code> and save it to a special key directory. It’s important to not save the keys to the same processed folder as the HLS videos since the latter will eventually be mounted as a public directory. After generating the key we call the following ffmpeg command in our bash script to process the files:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ffmpeg <span class="nt">-y</span> <span class="nt">-i</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="nt">-c</span>:v libx264 <span class="nt">-c</span>:a aac <span class="nt">-r</span> 30 <span class="nt">-g</span> 60 <span class="nt">-keyint_min</span> 60 <span class="nt">-sc_threshold</span> 0 <span class="nt">-hls_time</span> 2 <span class="nt">-hls_list_size</span> 0 <span class="nt">-hls_key_info_file</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span> <span class="nt">-hls_segment_filename</span> <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">/chunk_%03d.ts"</span> <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">/index.m3u8"</span> 
</code></pre></div></div>

<p>To break this up part by part:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-y</code> is a generic flag to disable confirmation requests when the script is being run.</li>
  <li><code class="language-plaintext highlighter-rouge">-i &lt;file&gt;</code> specifies the input file, in our case the raw .mp4.</li>
  <li><code class="language-plaintext highlighter-rouge">-c:v libx264</code> tells ffmpeg to save with videos with the <a href="https://en.wikipedia.org/wiki/Advanced_Video_Coding">H.264 video codec</a>, the most widely used video compression standard. Without it, certain videos would be too large, even in chunks, to send to the client.</li>
  <li><code class="language-plaintext highlighter-rouge">-c:a aac</code> sets the audio compression to use <a href="https://en.wikipedia.org/wiki/Advanced_Audio_Coding">AAC</a> (Advanced Audio Coding).</li>
</ul>

<p><strong>Keyframing</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">-r 30</code> forces the output to be 30 frames per second. This does incur a quality loss for some videos, but all instagram reels seem to be normalized to 30fps anyways.</li>
  <li><code class="language-plaintext highlighter-rouge">-g 60</code> forces ffmpeg to insert a keyframe every 60 frames (2 seconds). This is vital since the HLS format can only split chunks on a keyframe.</li>
  <li><code class="language-plaintext highlighter-rouge">-sc_threshold 0</code> disables scene detection. Normally, ffmpeg tries to insert keyframes when the scene changes. Since most SFVs are only one frame, we instead want keyframes to just appear every 2 seconds.</li>
</ul>

<p><strong>HLS &amp; Encryption</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">-hls_time 2</code> tells ffmpeg that we want each .ts segment file to be two seconds long.</li>
  <li><code class="language-plaintext highlighter-rouge">-hls_list_size 0</code> ensures that the .m3u8 playlist includes all segments. Seems obvious however HLS is often used for livestreaming, in which case you only want the most recent n chunks being saved.</li>
  <li><code class="language-plaintext highlighter-rouge">-hls_key_info_file &lt;key_info&gt;</code> path to the encryption manifest, including the key and URI (I’ll get to this later).</li>
  <li><code class="language-plaintext highlighter-rouge">-hls_segment_filename "$OUTPUT_FOLDER/chunk_%03d.ts"</code> is the naming template for each chunk. <code class="language-plaintext highlighter-rouge">%03d</code> is the standard C string formatter for a 3-digit zero-padded number. If you for some reason are including files greater than 33 minutes long, you need to increase the number size, or preferably use larger chunks.</li>
  <li>Lastly the final argument is where to save the file.</li>
</ul>

<p>Below is the bash script built around this command, iterating over all the videos, generating and saving each an encryption key, then running the processing command.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash  </span>
  
<span class="nv">RAW_DIR</span><span class="o">=</span><span class="s2">".../Reels/Raw"</span>  
<span class="nv">PROCESSED_DIR</span><span class="o">=</span><span class="s2">".../Reels/Processed"</span>  
<span class="nv">KEYS_DIR</span><span class="o">=</span><span class="s2">".../Reels/Keys"</span>  
<span class="nv">BASE_KEY_URL</span><span class="o">=</span><span class="s2">"lockdown://localhost:8000/api/videos"</span>  

<span class="c"># Loops over every mp4 file in the raw directory and creates an HLS copy in the processed directory.</span>
<span class="k">for </span>f <span class="k">in</span> <span class="s2">"</span><span class="nv">$RAW_DIR</span><span class="s2">"</span>/<span class="k">*</span>.mp4<span class="p">;</span> <span class="k">do</span>  
    <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue  
  
    </span><span class="nv">filename</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="nt">--</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span><span class="si">)</span>  
    <span class="nv">foldername</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">filename</span><span class="p">%.*</span><span class="k">}</span><span class="s2">"</span>  
    <span class="nv">OUTPUT_FOLDER</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PROCESSED_DIR</span><span class="s2">/</span><span class="nv">$foldername</span><span class="s2">"</span>  
  
    <span class="k">if</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then  
        </span><span class="nb">echo</span> <span class="s2">"Skipping </span><span class="nv">$foldername</span><span class="s2"> (already processed)"</span>        <span class="k">continue  
    fi  
  
    </span><span class="nb">echo</span> <span class="s2">"Processing vdeio: </span><span class="nv">$foldername</span><span class="s2">..."</span>  
  
    <span class="nv">KEY_FOLDER</span><span class="o">=</span><span class="s2">"</span><span class="nv">$KEYS_DIR</span><span class="s2">/</span><span class="nv">$foldername</span><span class="s2">"</span>  
    <span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">"</span>  
    openssl rand 16 <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/video.key"</span>  
    <span class="nv">IV</span><span class="o">=</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 16<span class="si">)</span>  
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$BASE_KEY_URL</span><span class="s2">/</span><span class="nv">$foldername</span><span class="s2">/key"</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>  
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/video.key"</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>  
    <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$IV</span><span class="s2">"</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>  
  
    <span class="k">if </span>ffmpeg <span class="nt">-y</span> <span class="nt">-i</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="nt">-c</span>:v libx264 <span class="nt">-c</span>:a aac <span class="nt">-r</span> 30 <span class="nt">-g</span> 60 <span class="nt">-keyint_min</span> 60 <span class="nt">-sc_threshold</span> 0 <span class="se">\ </span> 
        <span class="nt">-hls_time</span> 2 <span class="nt">-hls_list_size</span> 0 <span class="se">\ </span> 
        <span class="nt">-hls_key_info_file</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span> <span class="se">\ </span> 
        <span class="nt">-hls_segment_filename</span> <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">/chunk_%03d.ts"</span> <span class="se">\ </span> 
        <span class="s2">"</span><span class="nv">$OUTPUT_FOLDER</span><span class="s2">/index.m3u8"</span> <span class="p">;</span> <span class="k">then  
  
        </span><span class="nb">echo</span> <span class="s2">"Finished </span><span class="nv">$foldername</span><span class="s2">"</span>  
    <span class="k">else  
        </span><span class="nb">echo</span> <span class="s2">"FFmpeg failed on </span><span class="nv">$foldername</span><span class="s2">."</span>  
    <span class="k">fi  
    </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$KEY_FOLDER</span><span class="s2">/key_info"</span>  
<span class="k">done</span>  
</code></pre></div></div>

<p>You might have noticed the custom <code class="language-plaintext highlighter-rouge">lockdown://</code> scheme in the <code class="language-plaintext highlighter-rouge">BASE_KEY_URL</code> instead of a standard <code class="language-plaintext highlighter-rouge">http://</code>. This is a custom URL scheme we’ll use on the iOS side to intercept AVPlayer’s key requests so we can securely inject our API key header. I will explain this further when we implement it in Part II.</p>

<p>Before running both the scraping and preprocessing scripts I made a couple more changes including skipping shortcodes already downloaded or processed and recovering safely from script crashes. I can’t go in depth into all the small changes I’ve included but they can be seen in the code on github. You can run the bash script simply by doing <code class="language-plaintext highlighter-rouge">./preprocess.sh</code> or wherever you saved it. If it doesn’t work the first time, that’s likely because it needs execution permissions which can be done with <code class="language-plaintext highlighter-rouge">chmod +x ./preprocess.sh</code></p>

<h2 id="the-backend">The Backend</h2>
<p>Next up is constructing the backend that will ingest the HLS videos along with the metadata, handle the API requests for delivering content, and maintain the database. Since this is more of a personal toy project, I avoided using something heavy like Django which I’ve used in the past. I wrote a very simple API handler using <a href="https://fastapi.tiangolo.com/">FastAPI</a>, a basic <a href="https://unixdigest.com/articles/sqlite-the-only-database-you-will-ever-need-in-most-cases.html">SQLite database</a>, and sqlalchemy to connect the two.</p>

<p>First, our backend needs to initialize the sqlite database and mount the directories which stores all the processed HLS files.</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">FastAPI</span><span class="p">,</span> <span class="n">APIRouter</span>  
<span class="kn">from</span> <span class="n">fastapi.staticfiles</span> <span class="kn">import</span> <span class="n">StaticFiles</span>  
<span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">create_engine</span>  
<span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">declarative_base</span><span class="p">,</span> <span class="n">sessionmaker</span>

<span class="n">SQLALCHEMY_DATABASE_URL</span> <span class="o">=</span> <span class="sh">"</span><span class="s">sqlite:///./reels.db</span><span class="sh">"</span>  
<span class="n">engine</span> <span class="o">=</span> <span class="nf">create_engine</span><span class="p">(</span><span class="n">SQLALCHEMY_DATABASE_URL</span><span class="p">,</span> <span class="n">connect_args</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">check_same_thread</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">})</span>  
<span class="n">SessionLocal</span> <span class="o">=</span> <span class="nf">sessionmaker</span><span class="p">(</span><span class="n">autocommit</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">autoflush</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">bind</span><span class="o">=</span><span class="n">engine</span><span class="p">)</span>  
<span class="n">Base</span> <span class="o">=</span> <span class="nf">declarative_base</span><span class="p">()</span>  

<span class="n">Base</span><span class="p">.</span><span class="n">metadata</span><span class="p">.</span><span class="nf">create_all</span><span class="p">(</span><span class="n">bind</span><span class="o">=</span><span class="n">engine</span><span class="p">)</span> 

<span class="c1"># Small generator function to pass around the db safely 
</span><span class="k">def</span> <span class="nf">get_db</span><span class="p">():</span> 
	<span class="n">db</span> <span class="o">=</span> <span class="nc">SessionLocal</span><span class="p">()</span> 
	<span class="k">try</span><span class="p">:</span> 
		<span class="k">yield</span> <span class="n">db</span> 
	<span class="k">finally</span><span class="p">:</span> 
		<span class="n">db</span><span class="p">.</span><span class="nf">close</span><span class="p">()</span>
  
<span class="n">router</span> <span class="o">=</span> <span class="nc">APIRouter</span><span class="p">()</span>
<span class="n">app</span> <span class="o">=</span> <span class="nc">FastAPI</span><span class="p">()</span>  

<span class="n">app</span><span class="p">.</span><span class="nf">mount</span><span class="p">(</span><span class="sh">"</span><span class="s">/videos</span><span class="sh">"</span><span class="p">,</span> <span class="nc">StaticFiles</span><span class="p">(</span><span class="n">directory</span><span class="o">=</span><span class="sh">"</span><span class="s">.../Reels/Processed</span><span class="sh">"</span><span class="p">),</span> <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">videos</span><span class="sh">"</span><span class="p">)</span>  
  
<span class="n">app</span><span class="p">.</span><span class="nf">include_router</span><span class="p">(</span><span class="n">router</span><span class="p">)</span>
</code></pre></div></div>

<p>Then, we can begin to write the API endpoints that the client will use to request videos and send feedback back to the server such as likes, ratings, and views.</p>

<p>Typical backends consist of three things: schemas, routes, and models. The model itself details what information will be stored for each object we will have in our database (for now, just videos, but eventually hashtags will be an object as well). Schemas are similar to models, however they are specifically objects that will be passed between the server and client, which doesn’t always include every attribute that the model contains in the database.</p>

<p>Let’s start by defining our ‘video’ model. Each column such as description or uploader is an attribute we want to store for every video, and eventually will automatically pull from the metadata .json.xz files.</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">Column</span><span class="p">,</span> <span class="n">Integer</span><span class="p">,</span> <span class="n">String</span><span class="p">,</span> <span class="n">Boolean</span><span class="p">,</span> <span class="n">Table</span> 
  
<span class="k">class</span> <span class="nc">Video</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>  
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">videos</span><span class="sh">"</span>  
    <span class="nb">id</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">folder_name</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">unique</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">is_favorited</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Boolean</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>  
    <span class="n">rating</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>  
    <span class="n">encryption_key</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  

	<span class="c1"># Metadata extracted from the .json.xz files
</span>    <span class="n">description</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">shortcode</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">date_posted</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">uploader</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">username</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
    <span class="n">date_added</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>    
</code></pre></div></div>

<p>As a quick security note: since this is a personal homelab project, storing the video encryption keys directly in our SQLite database is fine. However, if you were building this for a production environment, you would want to use a dedicated Key Management Service (KMS) to handle these keys securely.</p>

<p>The schemas are similarly straight forward for now, however we must add additional objects for two future features: rating and favoriting a post which need to pass to the server if the post was just favorited or unfavorited, and which rating was just sent for the post.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">typing</span> <span class="kn">import</span> <span class="n">List</span><span class="p">,</span> <span class="n">Optional</span>  
<span class="kn">from</span> <span class="n">pydantic</span> <span class="kn">import</span> <span class="n">BaseModel</span>  
  
<span class="k">class</span> <span class="nc">FavoriteUpdate</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>  
    <span class="n">is_favorited</span><span class="p">:</span> <span class="nb">bool</span>  
  
<span class="k">class</span> <span class="nc">RatingUpdate</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>  
    <span class="n">rating</span><span class="p">:</span> <span class="nb">int</span>  
  
<span class="k">class</span> <span class="nc">VideoOut</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>  
    <span class="nb">id</span><span class="p">:</span> <span class="nb">int</span>  
    
    <span class="n">folder_name</span><span class="p">:</span> <span class="nb">str</span>  
    <span class="n">is_favorited</span><span class="p">:</span> <span class="nb">bool</span>  
    <span class="n">rating</span><span class="p">:</span> <span class="nb">int</span>  
    <span class="n">description</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>  
    <span class="n">shortcode</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>  
    <span class="n">date_posted</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>  
    <span class="n">uploader</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>  
    <span class="n">username</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>  
    <span class="n">date_added</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>  
    
    <span class="n">model_config</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">from_attributes</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span><span class="p">}</span>  
</code></pre></div></div>

<p>Lastly, we can move onto the routes, these are the endpoints which the client will use to directly communicate with the server, requesting information or videos and providing updates. However, before we get into that, we need to add a bit of security. Otherwise, anyone with the URL will be able to access the endpoints. This means that anyone could request the video data from the database which includes the encryption keys. Fortunately this is a fairly light hearted project, so in the worst case they would just be decrypting videos that are already publically available on instagram, or messing with how you rated the videos. Still, this is a learning project, and knowing how to include API keys is vital for any production work.</p>

<p>First off, I generated a secure API key again using SSL in my terminal and saving that key to a <code class="language-plaintext highlighter-rouge">.env</code> file in my project directory. Then I wrote a very simple boilerplate function that the routes will use to compare the API key attached to every request to the one saved in the <code class="language-plaintext highlighter-rouge">.env</code> file. In the case that the API key is incorrect, it raises a HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/401">401</a> error, the standard for incorrect credentials. One small note is that the comparison uses the <code class="language-plaintext highlighter-rouge">secrets.compare_digest()</code> function instead of a simple <code class="language-plaintext highlighter-rouge">!=</code> comparison. This is a standard precaution to fend off <a href="https://en.wikipedia.org/wiki/Timing_attack">timing attacks</a>, where it’s possible to decrypt an API key by measuring the time it takes for the server to reject a request.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">os</span>  
<span class="kn">import</span> <span class="n">secrets</span>  
<span class="kn">from</span> <span class="n">dotenv</span> <span class="kn">import</span> <span class="n">load_dotenv</span>  
<span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">Security</span><span class="p">,</span> <span class="n">HTTPException</span><span class="p">,</span> <span class="n">status</span>  
<span class="kn">from</span> <span class="n">fastapi.security</span> <span class="kn">import</span> <span class="n">APIKeyHeader</span>  
  
<span class="nf">load_dotenv</span><span class="p">()</span>  
<span class="n">API_SECRET_KEY</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="nf">getenv</span><span class="p">(</span><span class="sh">"</span><span class="s">API_KEY</span><span class="sh">"</span><span class="p">)</span>  
<span class="n">api_key_header</span> <span class="o">=</span> <span class="nc">APIKeyHeader</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">api-key</span><span class="sh">"</span><span class="p">,</span> <span class="n">auto_error</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  
  
<span class="k">def</span> <span class="nf">verify_api_key</span><span class="p">(</span><span class="n">api_key</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="nc">Security</span><span class="p">(</span><span class="n">api_key_header</span><span class="p">)):</span>  
    <span class="k">if</span> <span class="ow">not</span> <span class="n">secrets</span><span class="p">.</span><span class="nf">compare_digest</span><span class="p">(</span><span class="n">api_key</span><span class="p">,</span> <span class="n">API_SECRET_KEY</span><span class="p">):</span>  
        <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span>  
            <span class="n">status_code</span><span class="o">=</span><span class="n">status</span><span class="p">.</span><span class="n">HTTP_401_UNAUTHORIZED</span><span class="p">,</span>  
            <span class="n">detail</span><span class="o">=</span><span class="sh">"</span><span class="s">Invalid or missing API Key</span><span class="sh">"</span>  
        <span class="p">)</span>
</code></pre></div></div>

<p>Now we can start implementing the routes. Perhaps the most important route to start with is a command that will tell the server to go through all the processed video files and add them to the database, since it is still currently empty. Note the decorator at the top of the function <code class="language-plaintext highlighter-rouge">@router.post("/api/sync", dependencies=[Depends(verify_api_key)])</code>, this defines how the endpoint is accessed and that it must satisy the API key condition we just wrote. Additionally, it denotes this endpoint as a POST request, meaning that it is telling the server to do something with new information, opposed to a GET request which is a request for the server to return some information.</p>

<p>The sync function does a bit more than just create the entries. It also parses the key file stored in the Keys directory, and parses the .json.xz files that our instaloader script returned for any metadata which might be useful to display in the front end. The json is pretty rich, including all comments, tags, profile pictures, etc. Tons of additional information which you may want to include.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">os</span>  
<span class="kn">import</span> <span class="n">lzma</span>  
<span class="kn">import</span> <span class="n">json</span>  
<span class="kn">import</span> <span class="n">re</span>
<span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">APIRouter</span><span class="p">,</span> <span class="n">Depends</span><span class="p">,</span> <span class="n">HTTPException</span><span class="p">,</span> <span class="n">Response</span>
<span class="kn">from</span> <span class="n">datetime</span> <span class="kn">import</span> <span class="n">datetime</span> 
<span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">Session</span>

<span class="nd">@router.post</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/sync</span><span class="sh">"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>
<span class="k">def</span> <span class="nf">sync_videos</span><span class="p">(</span><span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>

    <span class="n">video_root</span> <span class="o">=</span> <span class="sh">"</span><span class="s">.../Reels/Processed</span><span class="sh">"</span>
    <span class="n">keys_root</span> <span class="o">=</span> <span class="sh">"</span><span class="s">.../Reels/Keys</span><span class="sh">"</span>
    
    <span class="n">added</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">os</span><span class="p">.</span><span class="nf">listdir</span><span class="p">(</span><span class="n">video_root</span><span class="p">):</span>

        <span class="n">full_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">video_root</span><span class="p">,</span> <span class="n">item</span><span class="p">)</span>
        <span class="n">is_dir</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">isdir</span><span class="p">(</span><span class="n">full_path</span><span class="p">)</span>
        <span class="n">has_index</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">full_path</span><span class="p">,</span> <span class="sh">"</span><span class="s">index.m3u8</span><span class="sh">"</span><span class="p">))</span>

        <span class="k">if</span> <span class="n">is_dir</span> <span class="ow">and</span> <span class="n">has_index</span><span class="p">:</span>
            <span class="n">existing_video</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Video</span><span class="p">.</span><span class="n">folder_name</span> <span class="o">==</span> <span class="n">item</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>
        
            <span class="n">key_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">keys_root</span><span class="p">,</span> <span class="n">item</span><span class="p">,</span> <span class="sh">"</span><span class="s">video.key</span><span class="sh">"</span><span class="p">)</span>
            <span class="n">key_hex</span> <span class="o">=</span> <span class="bp">None</span>
            <span class="k">if</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="n">key_path</span><span class="p">):</span>
                <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">key_path</span><span class="p">,</span> <span class="sh">"</span><span class="s">rb</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
                    <span class="n">key_hex</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="nf">read</span><span class="p">(</span><span class="mi">16</span><span class="p">).</span><span class="nf">hex</span><span class="p">()</span>

            <span class="n">raw_metadata_path</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="s">.../Reels/Raw/</span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s">.json.xz</span><span class="sh">"</span>
            <span class="n">meta_desc</span> <span class="o">=</span> <span class="n">meta_shortcode</span> <span class="o">=</span> <span class="n">meta_date</span> <span class="o">=</span> <span class="n">meta_uploader</span> <span class="o">=</span> <span class="n">meta_username</span> <span class="o">=</span> <span class="bp">None</span>

			<span class="c1"># Parse the metadata json
</span>            <span class="k">if</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="n">raw_metadata_path</span><span class="p">):</span>
                <span class="k">try</span><span class="p">:</span>
                    <span class="k">with</span> <span class="n">lzma</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="n">raw_metadata_path</span><span class="p">,</span> <span class="sh">"</span><span class="s">rt</span><span class="sh">"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">"</span><span class="s">utf-8</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
                        <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">f</span><span class="p">).</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">node</span><span class="sh">"</span><span class="p">,</span> <span class="p">{})</span>

                        <span class="n">caption_edges</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">edge_media_to_caption</span><span class="sh">"</span><span class="p">,</span> <span class="p">{}).</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">edges</span><span class="sh">"</span><span class="p">,</span> <span class="p">[])</span>
                        <span class="k">if</span> <span class="n">caption_edges</span><span class="p">:</span>
                            <span class="n">meta_desc</span> <span class="o">=</span> <span class="n">caption_edges</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">node</span><span class="sh">"</span><span class="p">,</span> <span class="p">{}).</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">text</span><span class="sh">"</span><span class="p">)</span>

                        <span class="n">meta_shortcode</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">shortcode</span><span class="sh">"</span><span class="p">)</span>

                        <span class="n">timestamp</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">taken_at_timestamp</span><span class="sh">"</span><span class="p">)</span>
                        <span class="k">if</span> <span class="n">timestamp</span><span class="p">:</span>
                            <span class="n">meta_date</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">fromtimestamp</span><span class="p">(</span><span class="n">timestamp</span><span class="p">).</span><span class="nf">strftime</span><span class="p">(</span><span class="sh">'</span><span class="s">%Y-%m-%d</span><span class="sh">'</span><span class="p">)</span>

                        <span class="n">owner</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">owner</span><span class="sh">"</span><span class="p">,</span> <span class="p">{})</span>
                        <span class="n">meta_uploader</span> <span class="o">=</span> <span class="n">owner</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">full_name</span><span class="sh">"</span><span class="p">)</span>
                        <span class="n">meta_username</span> <span class="o">=</span> <span class="n">owner</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">username</span><span class="sh">"</span><span class="p">)</span>

                <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
                    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Failed to parse metadata for </span><span class="si">{</span><span class="n">item</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

            <span class="k">if</span> <span class="ow">not</span> <span class="n">existing_video</span><span class="p">:</span>
                <span class="n">new_video</span> <span class="o">=</span> <span class="nc">Video</span><span class="p">(</span>
                    <span class="n">folder_name</span><span class="o">=</span><span class="n">item</span><span class="p">,</span>
                    <span class="n">encryption_key</span><span class="o">=</span><span class="n">key_hex</span><span class="p">,</span>
                    <span class="n">description</span><span class="o">=</span><span class="n">meta_desc</span><span class="p">,</span>
                    <span class="n">shortcode</span><span class="o">=</span><span class="n">meta_shortcode</span><span class="p">,</span>
                    <span class="n">date_posted</span><span class="o">=</span><span class="n">meta_date</span><span class="p">,</span>
                    <span class="n">uploader</span><span class="o">=</span><span class="n">meta_uploader</span><span class="p">,</span>
                    <span class="n">username</span><span class="o">=</span><span class="n">meta_username</span><span class="p">,</span>
                    <span class="n">date_added</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">().</span><span class="nf">strftime</span><span class="p">(</span><span class="sh">'</span><span class="s">%Y-%m-%d</span><span class="sh">'</span><span class="p">),</span>
                <span class="p">)</span>
                <span class="n">db</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">new_video</span><span class="p">)</span>
                <span class="n">added</span> <span class="o">+=</span> <span class="mi">1</span>

    <span class="n">db</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
    <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">message</span><span class="sh">"</span><span class="p">:</span> <span class="sa">f</span><span class="sh">"</span><span class="s">Synced </span><span class="si">{</span><span class="n">added</span><span class="si">}</span><span class="s"> new videos.</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">total_in_db</span><span class="sh">"</span><span class="p">:</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">count</span><span class="p">()}</span>
</code></pre></div></div>

<p>There are a couple more vital endpoints we need to add before we can move on to the front end. First, we need the two most important GET requests:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">get_random_feed</code>, to get a set number of random videos from the database to add to the users feed.</li>
  <li><code class="language-plaintext highlighter-rouge">get_video_key</code>, the endpoint to get a decryption key for a specific video</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">sqlalchemy.sql.expression</span> <span class="kn">import</span> <span class="n">func</span>

<span class="nd">@router.get</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/feed/random</span><span class="sh">"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="n">List</span><span class="p">[</span><span class="n">VideoOut</span><span class="p">],</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>  
<span class="k">def</span> <span class="nf">get_random_feed</span><span class="p">(</span><span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>  
    <span class="sh">"""</span><span class="s">Returns 10 random videos from the database.</span><span class="sh">"""</span>  
    <span class="k">return</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">order_by</span><span class="p">(</span><span class="n">func</span><span class="p">.</span><span class="nf">random</span><span class="p">()).</span><span class="nf">limit</span><span class="p">(</span><span class="mi">10</span><span class="p">).</span><span class="nf">all</span><span class="p">()</span>  
  
<span class="nd">@router.get</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/videos/{folder_name}/key</span><span class="sh">"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>  
<span class="k">def</span> <span class="nf">get_video_key</span><span class="p">(</span><span class="n">folder_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>  
    <span class="n">video</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Video</span><span class="p">.</span><span class="n">folder_name</span> <span class="o">==</span> <span class="n">folder_name</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>  
    <span class="k">if</span> <span class="ow">not</span> <span class="n">video</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">video</span><span class="p">.</span><span class="n">encryption_key</span><span class="p">:</span>  
        <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="sh">"</span><span class="s">Key not found</span><span class="sh">"</span><span class="p">)</span>  
  
    <span class="k">return</span> <span class="nc">Response</span><span class="p">(</span><span class="n">content</span><span class="o">=</span><span class="nb">bytes</span><span class="p">.</span><span class="nf">fromhex</span><span class="p">(</span><span class="n">video</span><span class="p">.</span><span class="n">encryption_key</span><span class="p">),</span> <span class="n">media_type</span><span class="o">=</span><span class="sh">"</span><span class="s">application/octet-stream</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Again, notice that both of the above requests require the API key for anything to happen, otherwise we are just handing the videos and decryption keys to anyone who asks. Next we can implement the next two POST requests: <code class="language-plaintext highlighter-rouge">update_favorite</code> and <code class="language-plaintext highlighter-rouge">update_rating</code>. These will make use of our <code class="language-plaintext highlighter-rouge">FavoriteUpdate</code> and <code class="language-plaintext highlighter-rouge">RatingUpdate</code> schemas respectively. I chose to have my ratings limited to be between 0 and 10 so that the UI can represent them as stars.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@router.post</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/videos/{folder_name}/favorite</span><span class="sh">"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>  
<span class="k">def</span> <span class="nf">update_favorite</span><span class="p">(</span><span class="n">folder_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">payload</span><span class="p">:</span> <span class="n">FavoriteUpdate</span><span class="p">,</span> <span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>  
    <span class="n">video</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Video</span><span class="p">.</span><span class="n">folder_name</span> <span class="o">==</span> <span class="n">folder_name</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>  
    <span class="k">if</span> <span class="ow">not</span> <span class="n">video</span><span class="p">:</span>  
        <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="sh">"</span><span class="s">Video not found</span><span class="sh">"</span><span class="p">)</span>  
  
    <span class="n">video</span><span class="p">.</span><span class="n">is_favorited</span> <span class="o">=</span> <span class="n">payload</span><span class="p">.</span><span class="n">is_favorited</span>  
    <span class="n">db</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>  
    <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">message</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Favorite updated</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">folder_name</span><span class="sh">"</span><span class="p">:</span> <span class="n">folder_name</span><span class="p">,</span> <span class="sh">"</span><span class="s">is_favorited</span><span class="sh">"</span><span class="p">:</span> <span class="n">video</span><span class="p">.</span><span class="n">is_favorited</span><span class="p">}</span>  
  
<span class="nd">@router.post</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/videos/{folder_name}/rating</span><span class="sh">"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="nc">Depends</span><span class="p">(</span><span class="n">verify_api_key</span><span class="p">)])</span>  
<span class="k">def</span> <span class="nf">update_rating</span><span class="p">(</span><span class="n">folder_name</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">payload</span><span class="p">:</span> <span class="n">RatingUpdate</span><span class="p">,</span> <span class="n">db</span><span class="p">:</span> <span class="n">Session</span> <span class="o">=</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">get_db</span><span class="p">)):</span>  
    <span class="k">if</span> <span class="n">payload</span><span class="p">.</span><span class="n">rating</span> <span class="o">&lt;</span> <span class="mi">0</span> <span class="ow">or</span> <span class="n">payload</span><span class="p">.</span><span class="n">rating</span> <span class="o">&gt;</span> <span class="mi">10</span><span class="p">:</span>  
        <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">400</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="sh">"</span><span class="s">Rating must be between 0 and 10</span><span class="sh">"</span><span class="p">)</span>  
  
    <span class="n">video</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">Video</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">Video</span><span class="p">.</span><span class="n">folder_name</span> <span class="o">==</span> <span class="n">folder_name</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>  
    <span class="k">if</span> <span class="ow">not</span> <span class="n">video</span><span class="p">:</span>  
        <span class="k">raise</span> <span class="nc">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="sh">"</span><span class="s">Video not found</span><span class="sh">"</span><span class="p">)</span>  
  
    <span class="n">video</span><span class="p">.</span><span class="n">rating</span> <span class="o">=</span> <span class="n">payload</span><span class="p">.</span><span class="n">rating</span>  
    <span class="n">db</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>  
    <span class="k">return</span> <span class="p">{</span><span class="sh">"</span><span class="s">message</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Rating updated</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">folder_name</span><span class="sh">"</span><span class="p">:</span> <span class="n">folder_name</span><span class="p">,</span> <span class="sh">"</span><span class="s">rating</span><span class="sh">"</span><span class="p">:</span> <span class="n">video</span><span class="p">.</span><span class="n">rating</span><span class="p">}</span>
</code></pre></div></div>

<p>That finishes the bare minimum for the backend! Next steps are to get the server running, then to work on the front end, which, in this case is an iOS SwiftUI app.</p>

<h2 id="making-it-run">Making It Run</h2>
<p>There isn’t a one-size-fits-all approach to hosting your server. In some cases you might only want it available locally, in others you want something that would scale like an AWS server. In my case, I own a small media server which I mostly use as a homelab for movies. Since I’m the only person ever going to use this client, its a no-brainer for me to run it there.</p>

<p>Regardless of where you deploy your backend, I recommend running the server with <a href="https://uvicorn.dev/">Uvicorn</a> and using <a href="https://caddyserver.com/">Caddy</a> as a reverse proxy to handle the HTTPS certification.</p>

<p>To run the server from the project directory with uvicorn, you can use the following:
<code class="language-plaintext highlighter-rouge">uvicorn &lt;filename&gt;:app --host 0.0.0.0 --port XXXX --reload</code>. Then, to populate the database, from the same machine run <code class="language-plaintext highlighter-rouge">curl -X POST http://127.0.0.1:XXXX/api/sync -H "api-key: &lt;your api key&gt;"</code> to call the sync command and have the server ingest all the data. This will take a couple seconds to run depending on how many processed videos you have, but when it’s complete you should see something like this: <code class="language-plaintext highlighter-rouge">{"message":"Synced 424 new videos.","total_in_db":424}</code>.  If you get to this point, you know your backend is live and the data has been added to the database!</p>

<h2 id="part-i-finale">Part I: Finale</h2>

<p>This wraps up Part I. At this point we have successfully created an offline data pipeline to scrape and preprocess SFVs, and a backend that will allow us to serve those videos along with metadata to the frontend. Part II is solely focused on creating this frontend, an iOS app made with iOS’s AVPlayer and SwiftUI, and concludes with a fully functional product. Part III will cover some smaller extensions, such as adding tags, filtering posts, and adding a progress bar to the player. If you actually read this far, I hope you will continue to read on, so that you too will have a fully fledged short form video app.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[One of the most belabored topics on the internet is the negative consequences of social media, particularly short form video (SFV) such as TikTok, Instagram Reels, and Youtube Shorts. Despite harming attention spans, invoking negative emotions, and reducing life satisfaction, people (including myself) still find these apps entertaining. Recent studies show that the negative effect depends mostly on the type of content you watch. However, in the corporate owned apps, you have little control over the videos they feed you. That is why I sought to create my own short form video client. This resulted in a full stack project that was very satisfying to work on, so I thought to document the process here in case anyone else wanted to give it a try.]]></summary></entry><entry><title type="html">Using LLMs to Reverse Skill Atrophy</title><link href="https://www.jackbodine.com/blog/skill-atrophy/" rel="alternate" type="text/html" title="Using LLMs to Reverse Skill Atrophy" /><published>2025-05-14T00:00:00+00:00</published><updated>2025-05-14T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/skill-atrophy</id><content type="html" xml:base="https://www.jackbodine.com/blog/skill-atrophy/"><![CDATA[<p>A couple of weeks ago a blog post titled “Avoiding Skill Atrophy in the Age of AI” blew up online. It warned that over-reliance on LLMs causes the user’s programming (or other) skills to decay over time; a phenomenon a lot of people have been seeing in themselves and others. For months now, I’ve found myself struggling with data science fundamentals: NumPy, pandas, and Matplotlib. These three Python libraries should feel like second nature after years of using them almost daily for my studies and work. Yet around the time I started experimenting with AI-code assistance like Copilot and Cursor, even routine tasks would send me back to documentation I’d once known by heart. I’ve seen this over-reliance in my peers too, sometimes not even knowing how to create any Matplotlib plot at all without having their IDE spit out an AI-generated template for them to edit. This problem, skill decay, is a burgeoning industry-wide crisis affecting professionals, researchers, and students alike, and finding a solution is crucial.</p>

<p>The blog post, written by <a href="https://substack.com/@addyosmani">Addy Osmani</a>, suggests a couple of useful ways to avoid skill atrophy; however all of the methods put forward require limiting one’s own usage of LLMs. One suggestion is not using AI-generated code for the basics, another is keeping track of AI-assists with pen and paper. While these suggestions surely help fight skill atrophy, they are difficult to implement. It is hard to get someone to discard what feels like an efficiency boost. And if they already are noticing skill atrophy, giving up their crutch will feel like a major setback. Instead, I’d like to propose a different solution, one that turns LLMs from the cause of skill atrophy into the solution.</p>

<h3 id="an-llm-powered-solution">An LLM-Powered Solution</h3>

<p>For the past month I’ve been doing automated daily programming warm-up challenges. Generated each day by an LLM and customized to hit all of your weak points. Using ChatGPT’s scheduled tasks feature, every day at 9:30am I get a variety of fundamental Python challenges, taking between 20 and 40 minutes in total. And every day I complete these challenges without any AI assistance. Once I’m done, I give my solutions back to ChatGPT and it assesses what works well and what doesn’t; it gives me feedback and, importantly, remembers what I struggled with for tomorrow’s challenge. This type of personalized challenge and feedback was previously only possible with a private tutor or perhaps an advisor with too much time on their hands.</p>

<p>The image below shows an example set of warm-ups. At first it took me at least half an hour to complete. Now I can do this warm-up in less than 5 minutes!</p>

<p><img src="/assets/may2025/1.png" alt="Example warm-up exercises" /></p>

<p>I’ve found these warm-ups to be invaluable. They allow me to practice the basics in new ways every day. This helps me solidify libraries through rote memorization. It also starts each day by encouraging me to think about small puzzles which puts me in a good mindset. Also they help to build confidence in different tasks. Many tasks that I previously would’ve turned straight to the AI-Assistance window for, I now feel and know I can do faster than trying to understand and fix some AI-generated lines. They even introduce me to new concepts I’ve never learned about. The feedback I get from the LLM also teaches me more efficient ways of doing basic tasks. Learning can often be difficult and exhausting, but these feel refreshing and energizing.</p>

<h3 id="tips">Tips</h3>

<p>I have my challenges set to cover several different pandas, NumPy, Matplotlib and seaborn features each day. And once these concepts have become more developed in my mind, I plan on introducing PyTorch and scikit-learn warm-ups as well. The great thing about this approach is that it’s customizable to the skills you use. I spend every day using these Python libraries, so it makes sense that they are what I should practice, but of course you should tailor your warm-ups to the skills you need to use and perhaps have been offloading to LLMs.</p>

<p>A couple of notes if you want to try this yourself. (1) In my prompt for generating the challenges, I’ve instructed my LLM to not direct me to any specific functions or approaches. For instance, I don’t want the instructions to include ‘Use pandas <code class="language-plaintext highlighter-rouge">.rolling(7, centered=True)</code> to compute the rolling average of temperatures in the DataFrame.’ Instead I’d prefer just ‘Calculate the 7-day rolling average for temperatures over the last two weeks.’ There is value in needing to recall functions and design approaches on your own. (2) If you get stuck, ask for the smallest possible hint to get you moving again. I’ve noticed that LLMs try to be as helpful as possible, sometimes even giving me the full solution when I ask for some minor syntax help.</p>

<p>While doing these warm-ups, it’s important that you have auto-suggestions disabled or are using a traditional text editor like Neovim. I used to turn off auto-completions during my warm-ups, but now just keep them off permanently. I had found that writing code during the warm-ups felt the most streamlined. I realized that autocomplete, even the non-AI-powered suggestions, would keep causing me to stop what I was writing to read and evaluate some suggestion, thus taking me out of my flow. It’s much less frustrating and easier to concentrate when you can just type your thoughts without being interrupted. This workflow also helps because when I do need to turn to AI, it’s a conscious effort as I’ve gotten stuck on one particular thing, and thus will be able to learn from the solution. Turning off auto-suggestions naturally encourages the solutions proposed by Osmani in his blog post. And by getting to that point via exercises, it feels more liberating rather than limiting.</p>

<h3 id="closing-thoughts">Closing Thoughts</h3>

<p>Even if you aren’t suffering from skill atrophy or using any AI tools in your workflows, I still recommend you try this out and see what it can teach you. I feel very fortunate that AI-assisted coding and writing tools didn’t come out until after I had finished my undergraduate studies. With the amount of pressure that high school and bachelor’s students face, I don’t blame them for turning to these tools to help them complete coursework. But I am fearful that many will fail to pick up basic skills and will then face immense difficulty trying to move to more difficult concepts. However, as I’ve seen with this process, when done right, LLMs can contribute to your skills rather than take them away.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[A couple of weeks ago a blog post titled “Avoiding Skill Atrophy in the Age of AI” blew up online. It warned that over-reliance on LLMs causes the user’s programming (or other) skills to decay over time; a phenomenon a lot of people have been seeing in themselves and others. For months now, I’ve found myself struggling with data science fundamentals: NumPy, pandas, and Matplotlib. These three Python libraries should feel like second nature after years of using them almost daily for my studies and work. Yet around the time I started experimenting with AI-code assistance like Copilot and Cursor, even routine tasks would send me back to documentation I’d once known by heart. I’ve seen this over-reliance in my peers too, sometimes not even knowing how to create any Matplotlib plot at all without having their IDE spit out an AI-generated template for them to edit. This problem, skill decay, is a burgeoning industry-wide crisis affecting professionals, researchers, and students alike, and finding a solution is crucial.]]></summary></entry><entry><title type="html">Machine Learning without Neural Nets</title><link href="https://www.jackbodine.com/blog/ML-without-NNs/" rel="alternate" type="text/html" title="Machine Learning without Neural Nets" /><published>2025-04-07T00:00:00+00:00</published><updated>2025-04-07T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/ML-without-NNs</id><content type="html" xml:base="https://www.jackbodine.com/blog/ML-without-NNs/"><![CDATA[<h2 id="introduction">Introduction</h2>

<p>One of the pivotal moments in deep learning history was the development of LeNet-5 in 1998. Research into neural networks had begun decades prior but had reached a lull. LeNet-5 ended that lull by showing that neural networks were capable of achieving state-of-the-art results in tasks that were notoriously difficult at the time, such as optical character recognition. This model, created by Yann LeCun, was part of the first deep learning image recognition models using convolutional neural nets. It was trained to identify the handwritten digits below, familiar to everyone who studies machine learning as the MNIST dataset. Nowadays, neural networks achieve state-of-the-art results in many of the domains they are applied to.</p>

<!--![[Pasted image 20250406205427.png]]!-->

<p><img src="/assets/april2025/1.png" alt="MNIST Examples from Wikipedia" /></p>

<p>When I was taught machine learning at university, I was thrown straight into deep learning with neural networks. In fact, the introductory course offered was aptly called “Deep Learning with Artificial Neural Networks.” This was rightly done since after all neural networks dominate the field. However, it’s a fun exercise to take a step back and see what I wasn’t taught, to see exactly why I was instructed to go straight into neural nets and not bother with other elementary approaches.</p>

<p>Machine learning without neural networks can, of course, be done. Reinforcement learning is a large area of research under the ML umbrella that doesn’t necessarily rely on deep learning. Still the prominence of deep learning with neural nets is huge. In this post, I am trying to take a task commonly solved by DL approaches, the MNIST classification task, and see if we can train a model without using neural nets, backpropagation, or any of the now ubiquitous techniques.</p>

<h2 id="classification-via-pixel-similarity">Classification via Pixel Similarity</h2>

<p>I was reading the <a href="https://course.fast.ai/">practical deep learning textbook</a> where the authors introduced how one could perform image recognition by creating the ‘mean’ of each digit, a single image representing what an ideal form of each number should look like. I’ve put some examples below. Then, you can classify unseen numbers by comparing a sample to each of these ideal forms. You can calculate how ‘different’ the sample is from each ideal digit using a variety of loss functions— I chose to go with <a href="https://pytorch.org/docs/stable/generated/torch.nn.L1Loss.html">mean absolute error</a>. The ideal form with the smallest loss from the sample would categorize your number. This is a classic approach to digit classification with decent results.</p>

<!--![[clipboard8.png]]!-->
<p><img src="/assets/april2025/2.png" alt="Example averaged MNIST digits" /></p>

<p>The authors went on to suggest that improving upon these results requires turning to deep learning: “Our pixel similarity approach does not… have any kind of weight assignment, or any way of improving based on testing the effectiveness of a weight assignment. In other words, we can’t really improve our pixel similarity approach by modifying a set of parameters.” At this point, the authors introduce deep learning—learning with neural networks and stochastic gradient descent—as the clear way to improve model performance. However, I had another idea.</p>

<h2 id="improvement-without-backpropagation">Improvement without Backpropagation</h2>

<p>The authors overlooked that we do actually have some hidden parameters here! Our model is essentially trained on the images used to build the ‘ideal’ version of each digit. To improve its performance, we can look at digit samples the model fails to identify and adjust our ‘ideal’ numbers to better encompass these misfits by giving them more weight when building the optimal version of the corresponding digit.</p>

<p>The book only demonstrates identifying 3s vs. 7s, which is fair since their purpose is to move on quickly to the SGD approach. However, since we aim to modify their pixel-similarity approach to achieve actual learning, I expanded their code to work with all digits, which was easy enough. The resulting average accuracy across all digits in the validation set is 64%. While far from production-quality, it’s significantly better than a random guess accuracy of 10%. Let’s see how much better we can do by introducing some machine learning—but not deep learning.</p>

<p>My idea was that after performing one step, we could examine which samples the pixel-distance model fails to classify correctly, then weigh those misfits more heavily while recalculating the ‘ideal digit’ forms. We can repeat this process until performance plateaus or decreases. In this way, we update the model to better fit each digit without using neural networks or gradient descent.</p>

<!--![[clipboard1.png]]!-->
<p><img src="/assets/april2025/3.png" alt="Results Plot 1" /></p>

<p>The plot above shows the accuracy of the model each time we repeat the described process. You can see an improvement in performance—the model learned! Using this simple technique, we improved the performance of this pixel-distance classification model. However, there is a notable drop-off after the second step. While still better than the baseline, the performance isn’t strictly improving as we’d hope. This drop-off likely happens because the model starts to overvalue the misfits. Since it is trained and validated on different datasets, the model learns specific misfits from the training set, not present in the validation set. Our non-deep-learning machine learning model is overfitting!</p>

<p>An easy fix is to value the learned knowledge a tad less in each step, similar to setting a learning rate in gradient descent, where we take progressively smaller steps toward the local minimum. Instead, we’re just weighting each round of training slightly less, preventing unique outliers from overly influencing the results.</p>

<!--![[clipboard2.png]]!-->
<p><img src="/assets/april2025/4.png" alt="Results Plot 2" /></p>

<p>Perfect! Now our learning curve actually converges toward some improved performance, clearly an improvement. It’s interesting to observe that problems like overfitting and solutions like learning rates still arise in machine learning outside deep learning. It’s unsurprising, but since we usually jump straight into deep learning, seeing these concepts appear elsewhere is fascinating. But it makes me wonder what other common deep learning optimizations like momentum, regularization, or cross-validation could be brought over to improve performance further.</p>

<h2 id="conclusion">Conclusion</h2>

<p>LeNet-5, the deep learning model I mentioned at the beginning of this blog post, achieved <a href="https://en.wikipedia.org/wiki/LeNet">98.4%</a> classification accuracy on the MNIST dataset. Modern DL architectures get greater than <a href="https://ieeexplore.ieee.org/document/10939547">99%</a> validation accuracy. Compared to these, the baseline and trained mean-pixel-distance models perform abysmally.</p>

<p>Our model’s final performance remains modest, and the gain in accuracy from training is minimal, but that’s not the point. What’s interesting is that we built a learning model devoid of neural networks. The authors overlooked an opportunity for learning without deep learning. Clearly, this classical approach wasn’t going to achieve state-of-the-art optical character recognition results, reinforcing that deep learning is king. Still, experimenting with non-neural network machine learning is enjoyable and underscores the importance of deep learning. I no longer merely take professors at their word—I’ve confirmed for myself that deep learning deserves its reverence.</p>

<p>If you would like to see the code I wrote for this post, it is available <a href="https://github.com/jackbodine/Algorithms/blob/main/other/MNIST.ipynb">here</a>.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[Introduction]]></summary></entry><entry><title type="html">Counting Books in the Library of Babel</title><link href="https://www.jackbodine.com/blog/goodreads/" rel="alternate" type="text/html" title="Counting Books in the Library of Babel" /><published>2025-01-19T00:00:00+00:00</published><updated>2025-01-19T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/goodreads</id><content type="html" xml:base="https://www.jackbodine.com/blog/goodreads/"><![CDATA[<p>I have decided to stop using Goodreads for the sole reason that it moves the emphasis of reading from quality to quantity. While it’s widely accepted nowadays that social media is harmful, for a while I thought a reading platform would be benign if not encourage healthy habits. However, I’ve noticed in my reading life that Goodreads has had a net-negative effect on both my understanding and enjoyment of books. This isn’t the fault of the platform itself, I believe; rather, it’s a symptom of trying to measure deep experiences with shallow metrics.</p>

<p>It takes effort to grasp the message of a good book. As <a href="https://en.wikipedia.org/wiki/How_to_Read_a_Book">Mortimer J. Adler</a> put it, reading is like catching a ball. The author’s job is to give a good throw, but you still have to play an active role in receiving. Without taking the time to properly engage, you’re at best missing out on quite a lot and at worst completely wasting your time.</p>

<p>The issue with Goodreads is that it encourages reading as many books as possible rather than understanding each book deeply. The obvious culprit is the social feed. People see how many books their friends are reading, and that can stress them into reading more to “catch up,” perhaps guiding them to pick shorter, less engaging titles or to read faster with less depth. Others might be motivated to tackle classics or fancy books just to show off. But it’s not only the social features that discourage engagement.</p>

<p>Offline features such as setting yearly reading goals, monthly achievements, and the like, also contribute to surface-reading. All of these try to ‘gamify’ reading. There’s absolutely nothing wrong with using gamification to encourage reading among people who struggle to pick up a book. But at least for me, gamifying something turns it into a min-max problem which in many cases detracts from the experience.</p>

<p>There is also nothing wrong with stopping a book whose content isn’t resonating with you or is just straight up bad. Yet there have been times where I’ve pushed through to finish a book for no reason other than I had spent enough time on it that it’s worth finishing to have one more on my profile.</p>

<p>Goodreads’ rating and recommendation systems don’t help much either. Reducing a book to a star rating feels insufficient, and I’d rather keep my written reviews on a platform I control, like my own blog. The recommendation feed is relatively harmless, but on a site owned by Amazon I’m skeptical that suggestions aren’t skewed toward what’s available on Kindle or simply most expensive. Besides, like most people, I have an ever‑growing backlog already long enough to keep me busy for years.</p>

<p>Perhaps this attitude is fine for the YA and trendy novels that dominate the platform, but for getting more out of reading than just entertainment, Goodreads is not the place to be.</p>

<p>This ties to a broader problem which I face, and is common among us who work in technical fields. That is putting too much attention on the countable metrics and too little to lived experiences. Trying to reduce life to numbers is unjust. Having a metric such as books-read, places-visited, projects-completed all shifts focus from depth to breadth. For me, the mere existence of these metrics create pressure to finish one thing and rush to the next so the counter keeps increasing. I suspect this flaw comes naturally to those of us who spend our time optimizing fancy numbers and comparing models, techniques, or data.</p>

<p>If there is nothing other than just me and the book, I can take my time, learn more, and be less stressed. It’s unfortunate that engagement is harder to measure than book count. Once you’ve caught this mindset it’s harmful to your health, learning, and work quality. Hard tasks become frustrating because progress appears stalled (although it isn’t) and you want to move on. The correct approach is to stop, consciously address the problem, and learn from it. Solving challenging problems without pressure matters even if it’s less measurable.</p>

<p>I’ve seen this problem pop up across several domains. At least for reading the cure is simple: stop tracking your reading. I’ll still keep around a list of books I’ve read and want to read— but its purpose is a memory aid, rather than a score. As for books I want to share my opinion on, I’ll post occasional reviews to my blog, as that still encourages understanding each book thoroughly. The only measure I care about now is whether each book gets the engagement it deserves.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[I have decided to stop using Goodreads for the sole reason that it moves the emphasis of reading from quality to quantity. While it’s widely accepted nowadays that social media is harmful, for a while I thought a reading platform would be benign if not encourage healthy habits. However, I’ve noticed in my reading life that Goodreads has had a net-negative effect on both my understanding and enjoyment of books. This isn’t the fault of the platform itself, I believe; rather, it’s a symptom of trying to measure deep experiences with shallow metrics.]]></summary></entry><entry><title type="html">DIKU Frequently Asked Questions</title><link href="https://www.jackbodine.com/blog/DIKU-faq/" rel="alternate" type="text/html" title="DIKU Frequently Asked Questions" /><published>2024-06-07T00:00:00+00:00</published><updated>2024-06-07T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/DIKU-faq</id><content type="html" xml:base="https://www.jackbodine.com/blog/DIKU-faq/"><![CDATA[<p>Recently, I’ve had an influx of strangers and friends contacting me with questions about the MSc program at DIKU. Maybe you are one of them and have just been directed to this page! I’m always happy to receive further questions if you can’t find what you’re looking for here.</p>

<p>If you are not familiar with <a href="https://en.wikipedia.org/wiki/UCPH_Department_of_Computer_Science">DIKU</a>, it is the computer science department at the University of Copenhagen. And where I currently study.</p>

<p>This guide is based on my personal experience as an MSc student at the University of Copenhagen, and while I aim to provide accurate and up-to-date information, please note that some details might change over time. Your experience may vary, but I hope to give you an overview of what to expect and answer some of the most common questions I’ve received.</p>

<h3 id="how-is-the-program">How is the program?</h3>

<p>Fantastic! I’ve gained a lot from every course. Copenhagen is a great city to live in, and the DIKU professors really know their stuff. Whether or not it is right for you will vary on a lot of things and is not something I can answer.</p>

<p>I don’t know anyone who openly regrets joining the program. (Although I do hear complaints that courses can be too theoretical.) I am happy with my decision to come here, I have learned a lot and gained a lot of experience that I wouldn’t have been able to achieve on my own. The program has opened many new opportunities for me that I am very grateful for. Including the following,</p>
<ul>
  <li>Job opportunities that are outside the domain of BSc graduates.</li>
  <li>Ability to specialize in Machine Learning at a pivotal moment.</li>
  <li>Studying in a foreign country/continent, meeting international people from all over the world and learning from them as well.</li>
  <li>Learning a new language for free.</li>
  <li>Fun and interesting volunteer opportunities like KUFest and Studenterhuset.</li>
  <li>A really fantastic and lively city to live in.</li>
</ul>

<h3 id="what-is-the-quality-of-the-lectures">What is the quality of the lectures?</h3>

<p>Lecture quality varies from professor to professor and course to course. Many courses have three or more different lecturers, so if there is a bad one, you don’t have to tolerate them for too long. Overall, I’d say they are pretty good. I rarely miss a lecture, but some students can get by on readings alone. But personally, I feel like they are missing out.</p>

<h3 id="are-the-courses-demanding">Are the courses demanding?</h3>

<p>In my opinion, the courses are pretty rigorous. Again, this varies, and it is possible to just sign up for the easiest ones, but that would be a waste. Specifically the machine learning courses are notoriously tricky. MLA, MLB, OReL, and ATML are all taught by the same group of professors. They are very theoretical, so if math proofs aren’t your cup of tea then they will take quite a lot of time. That being said, I’ve taken the most out of these courses– the challenge is very rewarding.</p>

<p>Course grade statistics are all public and you can find them <a href="https://karakterstatistik.stads.ku.dk/#searchText=Programming&amp;term=&amp;block=&amp;institute=&amp;faculty=1868&amp;searchingCourses=true&amp;page=1">here</a>.</p>

<h3 id="what-type-of-work-do-you-do">What type of work do you do?</h3>

<p>Many classes have you completing assignments in groups. But there are some that are entirely individual. Most of the time, you have to complete a certain number of assignments to be able to qualify for the exam. Some classes forgo the exam and just give you the average assignment grade as your course grade.</p>

<p>You should expect to spend 4-6 hours in lectures, a couple hours for readings and 8+ hours on assignments per class, per week.</p>

<p>There is also the option to do a project with a professor as a substitute for a course. I don’t know much about this option myself but it’s not uncommon. You will have to take some initiative though by finding a professor and proposing a project.</p>

<h3 id="is-it-possible-to-work-part-time">Is it possible to work part-time?</h3>

<p>Many students work part-time, but most of them do not plan on graduating in just two years. If you’re from the EU, this isn’t too much of an issue because you can get the study grant (SU) from the Danish government if you are working more than a certain number of hours.</p>

<p>A lot of people, myself included, get to work a job related to their field. I’m taking up a ML engineering position soon, and I know a lot of others are in student software engineering positions. In other degree programs too, it is usual for people to find a student job related to their topic of study.</p>

<p>Keep in mind that <a href="https://uniavisen.dk/en/here-are-the-stats-on-student-well-being-income-and-grades-at-the-university-of-copenhagen/">83% of KU students</a> take at least one extra year to finish their master’s.</p>

<h3 id="do-courses-have-a-limited-number-of-spots">Do courses have a limited number of spots?</h3>

<p>It is true that most electives do not fill up as long as you sign up during the regular registration period. If you have to change courses after they officially start, there may not be space for you. There are some classes that have a set number of spots, but those are rare, and you can see on the course catalog if that’s the case. <a href="https://kurser.ku.dk/course/ndaa09031u/2023-2024">Proactive Computer Security</a> is one such example.</p>

<h3 id="what-do-you-think-of-my-course-plan">What do you think of my course plan?</h3>

<p>It’s probably fine. You only sign up for one semester at a time, so your plan will almost surely change during your studies. The first semester is mostly compulsory courses, so don’t stress about it until you’re actually at DIKU. During the second block, there is a meeting for first-year students where professors from each course come and give an overview and have the chance to answer questions. I’d wait until that meeting before thinking too much about what you’re going to take.</p>

<p>While it is possible, I would not recommend taking three courses at once, especially if you are working part-time. I don’t know anyone who has attempted the triple enrollment, but I doubt it ends well.</p>

<p>If you haven’t already, take a look at the different recommended study tracks. You can always start with that and make adjustments. Also look at the <a href="https://studenterservice.science.ku.dk/studieplan/?&amp;lang=en">KU Course Plan Builder Website</a> to sketch out your study plan.</p>

<h3 id="should-i-take-machine-learning-a-mla">Should I take Machine Learning A (MLA)?</h3>

<p>MLA, along with the other machine learning classes MLB, OReL, PML, and ATML, is very theoretical, and many intro to ML classes at other universities don’t prepare you for that.</p>

<p>Thankfully, the professors also get this question a lot and have put together a self-assessment. See if you can complete the <a href="https://sites.google.com/diku.edu/machine-learning-courses/orel">self-preparation assessment</a> for online and reinforcement learning. If it looks too difficult, or you’ve never applied Hoeffding’s Inequality before, take MLA.</p>

<h3 id="whats-the-deal-with-advanced-programming-ap">Whats the deal with Advanced Programming (AP)?</h3>

<p>You may have noticed that the advanced programming course has a frighteningly low pass rate (one year, only 44% of those enrolled passed!) Yes, it was a difficult exam, and the year I took it stirred up quite a bit of drama that I could rant about for a while. But what’s important to you is that the course organizer has been replaced from 2024 and onwards. So it will likely be a different and much more fair course than what was previously given.</p>

<h3 id="how-are-classes-structured">How are classes structured?</h3>

<p>Each class typically has two, two-to-three hour lectures a week. In addition, you’ll probably have one-to-two TA sessions or exercise sessions, also lasting two or three hours each.</p>

<p>The TA sessions sometimes begin with a quick overview or some tips from the TA on your assignments. But they are mostly an opportunity for you to study or work on the assignments while being able to ask the TA questions.</p>

<p>You will spend more time each week working on assignments and doing readings than you will in lectures.</p>

<h3 id="is-it-easy-to-find-friends">Is it easy to find friends?</h3>

<p>For me it was, and it probably will be for you too. It’s easy to socialize with people in your classes or at your living accommodations.</p>

<p>Additionally, you are offered free danish lessons when living in Copenhagen, I <em>highly</em> recommend you take them, and it is a good environment for meeting other international students.</p>

<p>Also consider volunteering at <a href="https://studenterhuset.com/">Studenterhuset</a>!</p>

<h3 id="where-are-classes">Where are classes?</h3>

<p>I’ve had all my classes at various buildings on Nørre Campus (North Campus). Mostly the H. C. Ørsted Institute, but also at the DIKU building, the Biocenter and Neils Bohr Building.</p>

<p>I think the campus is nice. Some buildings, like the DIKU building, are starting to show their age. And overall the campus is nowhere near as pristine as some newer European campuses, even the KU south campus, but I think it has more character and charm. It is a nice blend of modern and old buildings and there are a lot of spots to study.</p>

<!-- NOTE TO SELF: Add photos! -->

<h3 id="should-i-learn-danish">Should I learn Danish?</h3>

<p>If you live in Denmark and are over 18 you are offered <a href="https://international.kk.dk/live/learn-danish/danish-language-courses/danish-language-education-programme">free danish lessons</a>. You can attend at one of several language schools, I personally take my lessons at <a href="https://ucplusdansk.dk/en/danish-courses/danish-for-international-students/">UCplus</a>. You will be able to find their booth at the international welcome days. Don’t worry about contacting them to register until after you arrive in Denmark and receive your CPR number.</p>

<p>You absolutely should learn Danish while you are here. Personally, I think it would be a waste to live in a foreign country and not put in the effort to learn the language. Especially since classes are offered at campus and are free, there is little excuse not to learn.</p>

<h3 id="do-you-have-tips-on-finding-housing">Do you have tips on finding housing?</h3>

<p>Finding housing in Copenhagen is really difficult for some students, depending on your individual limitations. Most importantly, start looking early. I had arranged my housing before I had even finished my BSc, so I was able to dodge most of the scramble. Also, read <a href="https://uniavisen.dk/en/student-housing-in-copenhagen-the-guide/">this guide</a>.</p>

<p>I am not that well versed when it comes to housing here, but I know its difficult. You should try to find someone else to reach out to on this matter.</p>

<h3 id="questions-about-the-visaresidence-permit-application-process">Questions about the VISA/Residence Permit application process?</h3>

<p>This information varies by country and changes frequently. I recommend you look up the most recent information on <a href="https://www.nyidanmark.dk/en-GB">nyidanmark.dk</a> or by contacting your faculties student services. If you are applying for a residence permit from the United States, then I might be able to give you some answers if you email me.</p>

<p>Like housing, it is important to get started early. There are a lot of waits between submitting forms and you need to ensure that everything is sorted out before courses start.</p>

<h3 id="where-to-go-for-more-information">Where to go for more information?</h3>

<p>Uniavisen, the university newspaper, has a number of guides for new students. Start <a href="https://uniavisen.dk/en/new-student-in-copenhagen/">here</a>. If you need more specific help, call <a href="https://science.ku.dk/english/studentservices/">SCIENCE Student Services</a>. They can take a while to respond to emails–calling is best.</p>

<h3 id="i-have-more-specific-questions-can-i-reach-out">I have more specific questions, can I reach out?</h3>

<p>Yes! You can find my contact email on my website. I can usually respond pretty quickly. If you have a general question that’s not in this guide, I’ll likely add it here too.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[Recently, I’ve had an influx of strangers and friends contacting me with questions about the MSc program at DIKU. Maybe you are one of them and have just been directed to this page! I’m always happy to receive further questions if you can’t find what you’re looking for here.]]></summary></entry><entry><title type="html">Why I Wear Two Watches</title><link href="https://www.jackbodine.com/blog/two-watches/" rel="alternate" type="text/html" title="Why I Wear Two Watches" /><published>2023-06-07T00:00:00+00:00</published><updated>2023-06-07T00:00:00+00:00</updated><id>https://www.jackbodine.com/blog/two-watches</id><content type="html" xml:base="https://www.jackbodine.com/blog/two-watches/"><![CDATA[<p>Ever since I can remember, I’ve been the guy with two watches. Over the years, I’ve had plenty of friends and colleagues asking about it, and now, I figure, it’s time to put my answers in one place.</p>

<p>Many people wonder why someone would choose to wear two watches. The truth is, I don’t have a definitive answer. Instead I have a couple of different responses that I like to give. Recently, my go-to response has been “To tell time twice as fast!” Other times, you may get, “So I never forget which wrist I’m wearing my watch on.”</p>

<p>For while my usual response was a bit more elaborate: My Apple Watch, being internet-connected, is synced with time-keeping servers, so it always gives the exact time on Earth. My Casio, on the other hand, is a stand-alone device, keeping time on its own. So, if I ever find myself in a <a href="https://youtu.be/dBxxi5XAm3U?feature=shared">time-travel mishap</a>, near a black hole, or entangled in some other mind-bending relativity event, I always know the actual time and my personal elapsed time.</p>

<p>On very rare occasions, I meet another double-wrister. There is a sort of camaraderie among the time-telling doubly-equipped. Their reasons are usually more practical than mine. Some say they do it to keep track of two different time zones, others mention fashion, or wanting to make an impression. Although it’s not a common practice, there’s even a term for wearing two watches - ‘Schwarzkopfing.’ Named after U.S. General Norman Schwarzkopf, who needed to track time both where he was stationed and in Washington.</p>

<p>I fully recognize the absurdity of wearing two watches without a practical reason. One day, a younger me put on two watches and that was that - it became who I am. My name is Jack, I have blue eyes, brown hair, and I wear two watches. I continue to do it because it is a subtle quality that people know me for.</p>

<p>One reason I created this website was to paint a fuller picture of who I am than what a single-page resume or a short bio might offer. If you’ve landed on my website and want to learn more about me, I invite you to explore my photos, resume, and other blog posts. They should give you a decent insight into the life of someone who lives every second, twice.</p>]]></content><author><name>Jack Bodine</name><email>contact@jackbodine.com</email></author><summary type="html"><![CDATA[Ever since I can remember, I’ve been the guy with two watches. Over the years, I’ve had plenty of friends and colleagues asking about it, and now, I figure, it’s time to put my answers in one place.]]></summary></entry></feed>