<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Posts on Inobtenio</title>
    <link>https://inobtenio.com/en/posts/</link>
    <description>Recent content in Posts on Inobtenio</description>
    <image>
      <title>Inobtenio</title>
      <url>https://cdn.inobtenio.com/Ub-simple-dark.png</url>
      <link>https://cdn.inobtenio.com/Ub-simple-dark.png</link>
    </image>
    <generator>Hugo -- 0.143.0</generator>
    <language>en-US</language>
    <managingEditor>knmartinm@gmail.com (Kevin Martin)</managingEditor>
    <webMaster>knmartinm@gmail.com (Kevin Martin)</webMaster>
    <lastBuildDate>Sun, 01 Jun 2025 11:11:11 -0500</lastBuildDate>
    <atom:link href="https://inobtenio.com/en/posts/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Where does Spotify get that color from?</title>
      <link>https://inobtenio.com/en/posts/spotify-song-colors/</link>
      <pubDate>Sun, 01 Jun 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/en/posts/spotify-song-colors/</guid>
      <description>Siempre me dio curiosidad cuando usaba Spotify cómo determinan el color del cual coloreaban el reproductor. es el color dominante? es el más llamativo? ¿qué es? Aquí un intento de averiguarlo y replicarlo.</description><enclosure url="https://inobtenio.com/img/posts/spotify-song-colors/strokes-cover.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>Over the years, I&rsquo;ve done several &ldquo;little things&rdquo; that have had something to do, one way or another, with Spotify. I used the API to classify the music I listen to most by decade to see how old my tastes were, to connect it with <a href="https://www.home-assistant.io/" target="_blank">Home Assistant</a>, to separate music by language, and a few other bits. But in recent days I&rsquo;ve been busy <a href="/posts/vobot-mini-dock-spotify/" target="_blank">getting a clock to tell me what song I&rsquo;m listening to</a>.</p>
<p>That was where I built, for the first time, a graphical interface for my music project. This interface needed a colorful background for the image indicating which song was playing.<br>
The problem is that, looking at the Spotify developer portal documentation, there was no reference to colors anywhere, much less was that information made public (something that <a href="https://developer.apple.com/documentation/applemusicapi/artwork" target="_blank">Apple</a> does, for example), nor on <a href="https://engineering.atspotify.com/" target="_blank">its engineering and technology blog</a>; moreover, I had no idea how to even begin programming a color extraction algorithm that was at least similar. I had a purely intuitive idea of which color was chosen based on how the album covers looked, but that would hardly be enough.</p>
<p>I started looking at a few examples, hoping something would come to mind—a starting point.</p>
<figure class="width25perc group">
  <img loading="lazy" height="1600" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_d7ab9f28e95f133c.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_d7c013a2b11b0dfc.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_2bee279007f5b060.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_91025486b4d4b108.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/prisioneros_hu_91025486b4d4b108.webp"></figure><figure class="width25perc group">
  <img loading="lazy" height="1561" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_9d60eaa37f59875c.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_2010da6e974fdd56.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_3d9176691a1c8344.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_dd4df8a77443ad98.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/muse_hu_dd4df8a77443ad98.webp"></figure><figure class="width25perc group">
  <img loading="lazy" height="1600" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_858aa68c3e29fccf.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_999ebfe929c258aa.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_3810cb2cd8bfa3dd.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_16c107f1d3e0b5f2.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/racer-x_hu_16c107f1d3e0b5f2.webp"></figure><figure class="width25perc group">
  <img loading="lazy" height="1561" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_d87954537417d3fc.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_df3ee13116feb1b2.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_e21b6e846921d7b3.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_6b42984eb7a87eca.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/junko-yagami_hu_6b42984eb7a87eca.webp"></figure><h2 id="can-i-avoid-fatigue">Can I avoid fatigue?</h2>
<p>I thought the images were probably processed server-side since the colors remain (for the most part) across different platforms, but I was also sure I hadn&rsquo;t found anything about it in the documentation, so I went to the <a href="https://open.spotify.com/" target="_blank">web player</a> to scrutinize the requests and responses and, yes, indeed, I found that when the request is made to get a song&rsquo;s lyrics, the response also includes the background color they should have—the color I was looking for.</p>
<figure class="width85perc">
  <img loading="lazy" alt="&lsquo;Response with the background color&rsquo;" height="298" title="Response for [Myxomatosis](https://www.youtube.com/watch?v=PxOO8YkB_Og), including background color and lyrics (great song, by the way, thanks FIFA 2004)" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_964f5741dba5c5bb.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_7ee55432644f0e9f.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_15ce71acfe76359.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_fc258a158943dcd.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_fc258a158943dcd.webp"><figcaption>Response for <a href="https://www.youtube.com/watch?v=PxOO8YkB_Og" target="_blank">Myxomatosis</a>, including background color and lyrics (great song, by the way, thanks FIFA 2004)</figcaption></figure><p>The difference is that the endpoint the request goes to doesn&rsquo;t have much to do with the official API; I imagine they&rsquo;re internal services that only first party applications can use. The endpoint looks like <code>https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}/image/{image_url}</code> and only returns the expected color if Spotify (or <a href="https://www.musixmatch.com/" target="_blank">Musixmatch</a>) has a record of the corresponding lyrics or if they are available for the region where the account is registered and if the image to be processed is also in their database; otherwise, there is neither lyric nor color.</p>
<p>I just had to figure out how to get the token required to automate the process, simulate being a web client from the terminal, and get all the colors I might want. The impediment was my laziness in finding patterns among the 10 headers the requests had and the death sentence threat Spotify gave me, so I didn&rsquo;t continue down that path. It seemed promising.</p>
<figure class="width85perc">
  <img loading="lazy" alt="&lsquo;Threat from Spotify&rsquo;" height="109" title="Threat from Spotify" width="720"srcset="https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_aa6bb36cf940886e.webp 480w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_75a9d071e9869032.webp 720w ,https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_4ce31076b4a3b422.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_1b2b71e5f92e84db.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/token_hu_1b2b71e5f92e84db.webp"><figcaption>Threat from Spotify</figcaption></figure><h2 id="trying-to-understand-spotify">Trying to understand Spotify</h2>
<p>I had to devise some other way, so I tried to understand what color Spotify chooses for the backgrounds. Maybe the most colorful one? Maybe the one that occupies the largest region in the image? Maybe the one that stands out the most? Perhaps a combination of all the above and even more factors?</p>
<p>Let&rsquo;s look at a few images along with their selected background color:</p>
<figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 1**: Ghost In The Machine - The Police" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/police.jpg"><figcaption><strong>Fig. 1</strong>: Ghost In The Machine - The Police</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 2**: Locura - Virus" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/virus.jpg"><figcaption><strong>Fig. 2</strong>: Locura - Virus</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 3**: The Singles 1986-1995 - Duran Duran" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/duran-duran.jpg"><figcaption><strong>Fig. 3</strong>: The Singles 1986-1995 - Duran Duran</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 4**: The Blue Room - Coldplay" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/coldplay.jpg"><figcaption><strong>Fig. 4</strong>: The Blue Room - Coldplay</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 5**: Private Eyes - Hall & Oates" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/hall&amp;oates.jpg"><figcaption><strong>Fig. 5</strong>: Private Eyes - Hall &amp; Oates</figcaption></figure><figure class="width33perc no-conversion no-responsive group">
  <img loading="lazy" height="765" title="**Fig. 6**: Blur: The Best Of - Blur" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/blur.jpg"><figcaption><strong>Fig. 6</strong>: Blur: The Best Of - Blur</figcaption></figure><ul>
<li><strong>Figure 1</strong>: seems easy; the most vibrant color (or the one farthest from black and white, as I understand it) is red and it&rsquo;s not in a negligible proportion. Resulting color: red.</li>
<li><strong>Figure 2</strong>: let&rsquo;s assume neither the white nor the gray from the top lyrics will be chosen, leaving magenta, yellow, light blue, and even green as options, but none stands out too much. Resulting color: pink.</li>
<li><strong>Figure 3</strong>: I expected it to be black, but apparently the algorithm avoids it at all costs, same as white. Resulting color: gray.</li>
<li><strong>Figure 4</strong>: here there are problems; I rule out black, but the red/orange stands out against the blue; it seems blue isn&rsquo;t so discriminated against, but it leaves me doubtful. Resulting color: blue.</li>
<li><strong>Figure 5</strong>: well, the hypothesis of the most colorful color is holding up, although yellow might have been discarded for being dull or not representative enough. Resulting color: magenta.</li>
<li><strong>Figure 6</strong>: it gets difficult; there are at least three colors that could be considered vibrant in some way, but apparently, among them, the darkest one is chosen. Resulting color: blue.</li>
</ul>
<p>If we also consider the first images in this article, the idea generally holds true except for two cases. In the second case, I would say the predominant color, in every aspect, should fluctuate between orange and red, but the officially chosen background is blue; much the same with the third case, the color that stands out is a sort of orange but also results in blue.</p>
<p>I haven&rsquo;t been able to determine it completely yet, but I think it goes more or less like this:</p>
<ul>
<li>The image will be divided into <code>N</code> groups of similar colors to avoid dealing with potentially hundreds of slightly different colors. This helps the algorithm&rsquo;s performance.</li>
<li>The most colorful or vivid colors (defined later) will always be preferred over the rest. This preference factor will be defined by <code>qC</code>.</li>
<li>All else being equal regarding how colorful a color is, the darkest color (i.e., the least luminous) wins. This preference coefficient will be stored in <code>qD</code>.</li>
<li>The percentage of the image occupied by the color will be taken into account but won&rsquo;t be given as much relevance as its level of vividness. This relevance will be defined by <code>qR</code>.</li>
<li>If the resulting color is white or black, a lighter or darker gray will be chosen instead, depending on the color obtained previously.</li>
<li>The final obtained color will have its saturation increased by a factor <code>S</code> to make it contrast more with the color white.</li>
</ul>
<p>In pseudo-code, ignore the handwriting:</p>
<figure class="width85perc no-conversion no-responsive no-zoom">
  <img loading="lazy" height="931" width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/pseudo-code.jpg"></figure><h2 id="trying-to-copy-spotify">Trying to copy Spotify</h2>
<p>Now, how to calculate the (excuse the word) &ldquo;vividness&rdquo;? With RGB coordinates it&rsquo;s a bit complicated, so we&rsquo;ll translate the colors to the <a href="https://en.wikipedia.org/wiki/CIELAB_color_space" target="_blank">CIELAB</a> color space, which allows us to perform this type of mathematical operation more simply and without necessarily resorting to trigonometry. It&rsquo;s like moving from Cartesian coordinates to polar coordinates. In <a href="https://www.xrite.com/-/media/xrite/files/whitepaper_pdfs/l10-001_a_guide_to_understanding_color_communication/l10-001_understanding_color_en.pdf" target="_blank">this link</a> you can find a guide from X-Rite and PANTONE to better understand how colors are expressed mathematically in their different systems.</p>
<p>According to theory, how vivid a human perceives a certain color depends on its saturation and its luminance, as follows:</p>
<p class="latex-big">\begin{aligned}
V = (L^*)^2 + (C^*ab)^2
\end{aligned}</p>
<p class="idea">Where L* is luminance and C*ab is saturation</p>
<p>But I ignored it after discovering that focusing on saturation (<code>C*ab</code>) as the first component gave me better results.</p>
<p>However, for the luminance calculation we <strong>will</strong> make use of the RGB space, since it&rsquo;s simpler that way. The equation (described in <a href="https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf" target="_blank">this manual</a> from the UN telecommunications agency in section 3.2) is:</p>
<p>\begin{aligned}
Y = 0.2126 * R_{lin} + 0.7152 * G_{lin} + 0.0722 * B_{lin}
\end{aligned}</p>
<p class="idea">Where each RGB component is expressed linearly—or without gamma encoding—</p>
<p>I used the values <strong>10</strong> for the number of groups or clusters and <strong>5.0</strong>, <strong>1.5</strong>, and <strong>0.8</strong> for <code>qC</code>, <code>qD</code>, and <code>qR</code>, respectively, after doing some quick tests seeing which color the algorithm returned and which color Spotify put in its applications. It worked well empirically, but perhaps there were coefficients that worked better and would have taken me a long time of trial and error to find. Besides that, the formula I had come up with might or might not be a good alternative for the calculation I needed.
It was time to use statistics to back up my assumptions.</p>
<h2 id="trying-to-improve-the-copy">Trying to improve the copy</h2>
<p>With the help of the forbidden endpoint that Spotify didn&rsquo;t want me to use, which I mentioned a few lines above (which I used with an alternate account to avoid dying), I put together a quick script to get key data from about 100 moderately random songs (though surely influenced by my musical tastes), which allowed me to subject the album covers to be processed by the algorithm and compare the result with the official color, apart from the three aspects that contribute to the final score without taking the <code>qX</code> coefficients into account.</p>
<p>Something more or less like this, although with many more rows, with 10/20 results per <code>id</code> (depending on <code>N</code>) and more decimals in the numbers:</p>
<table>
  <thead>
      <tr>
          <th>track_id</th>
          <th>track_name</th>
          <th>cover_url</th>
          <th>cluster_color⠀⠀⠀</th>
          <th>spotify_color⠀⠀⠀</th>
          <th>chroma</th>
          <th>darkness</th>
          <th>dominance</th>
          <th>score</th>
          <th>color_delta</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>id1</td>
          <td>name1</td>
          <td>url1</td>
          <td>(193, 53, 115)</td>
          <td>(213, 54, 125)</td>
          <td>0.5519</td>
          <td>0.8480</td>
          <td>0.0694</td>
          <td>4.0873</td>
          <td>5.1784</td>
      </tr>
      <tr>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
      </tr>
      <tr>
          <td>id1</td>
          <td>name1</td>
          <td>url1</td>
          <td>(253, 251, 251)</td>
          <td>(213, 54, 125)</td>
          <td>0.0073</td>
          <td>0.0282</td>
          <td>0.7031</td>
          <td>0.6418</td>
          <td>57.4733</td>
      </tr>
      <tr>
          <td>id2</td>
          <td>name2</td>
          <td>url2</td>
          <td>(181, 78, 37)</td>
          <td>(219, 59, 31)</td>
          <td>0.5463</td>
          <td>0.8446</td>
          <td>0.0699</td>
          <td>4.0545</td>
          <td>13.1916</td>
      </tr>
      <tr>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
          <td>&hellip;</td>
      </tr>
      <tr>
          <td>id2</td>
          <td>name2</td>
          <td>url2</td>
          <td>(247, 248, 246)</td>
          <td>(219, 59, 31)</td>
          <td>0.0057</td>
          <td>0.0612</td>
          <td>0.0534</td>
          <td>0.1633</td>
          <td>66.4606</td>
      </tr>
  </tbody>
</table>
<p class="idea"><code>chroma</code> refers to <code>colorfulness</code> in the pseudocode. It seemed more appropriate.</p>
<p>In <a href="https://www.r-project.org/" target="_blank">R</a> I was able to perform a couple of linear regressions to find the best coefficients, at least for the 100 songs in question, giving more weight to cases where a higher <code>score</code> coincided with a lower <code>color_delta</code>, which means the formula works (or is on the right track) and removing relevance from the rest of the results for each group of colors belonging to an image.</p>
<p>But the formula might not be the most suitable for the calculation, so I also tried a slight change:</p>
<pre tabindex="0"><code>  S = qC * chroma + qD * darkness + qR * dominance =&gt;  S = chroma ^ qC + darkness ^ qD + dominance ^ qR
</code></pre><p class="idea">Note the change from <code>*</code> to <code>^</code>, moving from a linear to an exponential formula.</p>
<p>And I generated data again. The format was the same as the table above, but the data, of course, changed. I decided not to beat around the bush too much and start with values quite similar to those from the linear formula, <strong>5.0</strong>, <strong>1.2</strong>, and <strong>0.9</strong>, and trust the math.</p>
<p>In this way, however, a linear regression would no longer be the appropriate analysis for the data, so after some research I went for the non-linear least squares method (or <a href="https://en.wikipedia.org/wiki/Non-linear_least_squares" target="_blank">NLS</a> for friends).</p>
<p>After a few rounds of <a href="https://en.wikipedia.org/wiki/Curve_fitting" target="_blank">fitting</a> I put the results in a small table to better choose the path I would take.</p>
<figure class="width85perc no-conversion no-responsive">
  <img loading="lazy" height="110" title="Results of the statistical analyses, highlighting the best by category. The \"(fit)\" annotations refer to the data after being adjusted." width="720"src="https://cdn.inobtenio.com/img/posts/spotify-song-colors/table_1.png"><figcaption>Results of the statistical analyses, highlighting the best by category. The &ldquo;(fit)&rdquo; annotations refer to the data after being adjusted.</figcaption></figure><p>It&rsquo;s clear that neither of the two formulas, whether using 10 or 20 color groups, is very accurate. Most likely, I think the Spotify engineers take into account more variables than I do with this algorithm, although I can&rsquo;t think which ones. It&rsquo;s also possible that the formula itself is considerably more complicated, of course.</p>
<p>But it doesn&rsquo;t matter; in that entire sea of mediocre numbers there is a method that is surely less bad than the rest, so I&rsquo;ll go to war with it even if it only has a 31% success rate.</p>
<p>The resulting code for the adjusted linear formula with 20 clusters would end up being this:</p>
<div class="tab-container" id="2dd38712">
    <div class="tab-labels">
	    <button class="tab-label active" data-lang="python">python</button>
    </div>
    <div class="tab-contents">
            <div class="tab-content content-python" style="display:block;">
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> math
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> numpy <span style="color:#fc5fa3">as</span> np
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">from</span> PIL <span style="color:#fc5fa3">import</span> Image
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">from</span> sklearn.cluster <span style="color:#fc5fa3">import</span> KMeans
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>qC = <span style="color:#d0bf69">4.9226</span>
</span></span><span style="display:flex;"><span>qD = <span style="color:#d0bf69">1.4060</span>
</span></span><span style="display:flex;"><span>qR = <span style="color:#d0bf69">0.7932</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">calculate_dark_colorfulness</span>(rgb):
</span></span><span style="display:flex;"><span>    red, green, blue = [x / <span style="color:#d0bf69">255.0</span> <span style="color:#fc5fa3">for</span> x in rgb]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    red_greenness = red - green <span style="color:#6c7986"># or the a component</span>
</span></span><span style="display:flex;"><span>    yellow_blueness = (red + green)/<span style="color:#d0bf69">2</span> - blue <span style="color:#6c7986"># or the b component. red + green output yellow in additive color (light)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    chroma = math.sqrt(red_greenness ** <span style="color:#d0bf69">2</span> + yellow_blueness ** <span style="color:#d0bf69">2</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    Choose any of these three, bascially according to preference, the output is not going to change
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    significantly. If you care about accuracy, you&#39;re free to do some research to determine the best
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    option for your use case. Keep in mind that the overall formula coefficients might need to change too.
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    They&#39;re as follows:
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    First option: the standard way of obtaining luminance from RGB coordinates but without linearizing
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    Second option: same as the previous, but linear (removing gamma correction)
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    Third option: Independent HSP color space &#34;percieved brightness&#34; (https://alienryderflex.com/hsp.html)
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d">    &#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6c7986">#    luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue # Relative luminance</span>
</span></span><span style="display:flex;"><span><span style="color:#6c7986">#    luminance = 0.2126 * (red ** (1/2.2)) + 0.7152 * (green ** (1/2.2)) + 0.0722 * (blue ** (1/2.2))# Relative luminance without gamma correction</span>
</span></span><span style="display:flex;"><span>    luminance = math.sqrt(<span style="color:#d0bf69">0.299</span> * (red ** <span style="color:#d0bf69">2</span>) + <span style="color:#d0bf69">0.587</span> * (green ** <span style="color:#d0bf69">2</span>) + <span style="color:#d0bf69">0.114</span> * (blue ** <span style="color:#d0bf69">2</span>)) <span style="color:#6c7986"># Percieved brightness (HSP)</span>
</span></span><span style="display:flex;"><span>    darkness = <span style="color:#d0bf69">1</span> - luminance
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">return</span> (chroma, darkness)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6c7986">### TO-DO: Implement improving picked color contrast with white, as lyrics are always white, to better match Spotify&#39;s choice</span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">improve_white_contrast</span>(rgb, brightness_factor=<span style="color:#d0bf69">0.85</span>, saturation_boost=<span style="color:#d0bf69">1.1</span>):
</span></span><span style="display:flex;"><span>    red, green, blue = [x / <span style="color:#d0bf69">255</span> <span style="color:#fc5fa3">for</span> x in rgb]
</span></span><span style="display:flex;"><span>    hue, sat, value = colorsys.rgb_to_hsv(red, green, blue)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    sat = <span style="color:#d0a8ff">min</span>(sat * saturation_boost, <span style="color:#d0bf69">1.0</span>)
</span></span><span style="display:flex;"><span>    value = <span style="color:#d0a8ff">max</span>(value * brightness_factor, <span style="color:#d0bf69">0.0</span>)  <span style="color:#6c7986"># darken</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    red, green, blue = colorsys.hsv_to_rgb(hue, sat, value)
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">return</span> <span style="color:#d0a8ff">tuple</span>(<span style="color:#d0a8ff">int</span>(x * <span style="color:#d0bf69">255</span>) <span style="color:#fc5fa3">for</span> x in (red, green, blue))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">extract_color_clusters</span>(num_clusters):
</span></span><span style="display:flex;"><span>    image = Image.open(<span style="color:#fc6a5d">&#34;./cover.jpg&#34;</span>).convert(<span style="color:#fc6a5d">&#34;RGB&#34;</span>)
</span></span><span style="display:flex;"><span>    width, height = image.size
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">try</span>:
</span></span><span style="display:flex;"><span>      pixels = np.array(image).reshape(-<span style="color:#d0bf69">1</span>, <span style="color:#d0bf69">3</span>) <span style="color:#6c7986"># Turn a RGB matrix into an RGB 2D array</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#6c7986"># Cluster colors using K-Means</span>
</span></span><span style="display:flex;"><span>      kmeans = KMeans(n_clusters=num_clusters, random_state=<span style="color:#d0bf69">0</span>, n_init=<span style="color:#fc6a5d">&#34;auto&#34;</span>)
</span></span><span style="display:flex;"><span>      kmeans.fit(pixels)
</span></span><span style="display:flex;"><span>      labels = kmeans.labels_
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      colors = []
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#6c7986"># Group colors by cluster and calculate average for each</span>
</span></span><span style="display:flex;"><span>      clusters = [[] <span style="color:#fc5fa3">for</span> _ in <span style="color:#d0a8ff">range</span>(num_clusters)]
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">for</span> i, label in <span style="color:#d0a8ff">enumerate</span>(labels):
</span></span><span style="display:flex;"><span>        clusters[label].append(pixels[i])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">for</span> group in clusters:
</span></span><span style="display:flex;"><span>        color = np.mean(group, axis=<span style="color:#d0bf69">0</span>)
</span></span><span style="display:flex;"><span>        chroma, darkness = calculate_dark_colorfulness(color)
</span></span><span style="display:flex;"><span>        dominance = <span style="color:#d0a8ff">len</span>(group)/(width * height)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        score = chroma * qC + darkness * qD + dominance * qR
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        colors.append({
</span></span><span style="display:flex;"><span>          <span style="color:#fc6a5d">&#39;color&#39;</span>: <span style="color:#d0a8ff">tuple</span>(<span style="color:#d0a8ff">int</span>(c) <span style="color:#fc5fa3">for</span> c in color),
</span></span><span style="display:flex;"><span>          <span style="color:#fc6a5d">&#39;score&#39;</span>: score,
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">return</span> colors
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">except</span> Exception <span style="color:#fc5fa3">as</span> e:
</span></span><span style="display:flex;"><span>      <span style="color:#d0a8ff">print</span>(e)
</span></span><span style="display:flex;"><span>      <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Black and white image?&#34;</span>)
</span></span><span style="display:flex;"><span>      <span style="color:#fc5fa3">return</span> [{
</span></span><span style="display:flex;"><span>        <span style="color:#fc6a5d">&#39;color&#39;</span>: (<span style="color:#d0bf69">128</span>,<span style="color:#d0bf69">128</span>,<span style="color:#d0bf69">128</span>),
</span></span><span style="display:flex;"><span>        <span style="color:#fc6a5d">&#39;score&#39;</span>: <span style="color:#d0bf69">10.0</span>,
</span></span><span style="display:flex;"><span>      }]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">get_best_color</span>():
</span></span><span style="display:flex;"><span>  <span style="color:#fc5fa3">return</span> <span style="color:#d0a8ff">max</span>(extract_color_clusters(<span style="color:#d0bf69">20</span>), key=<span style="color:#fc5fa3">lambda</span> c: c[<span style="color:#fc6a5d">&#39;score&#39;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">if</span> __name__ == <span style="color:#fc6a5d">&#39;__main__&#39;</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">print</span>(get_best_color())
</span></span></code></pre></div>
            </div>
    </div>
    <button class="toggle-btn" onclick="toggleCode(this)">See more</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function () {
  const container = document.getElementById("2dd38712");
  if (!container) return;

  const buttons = container.querySelectorAll(".tab-label");
  const contents = container.querySelectorAll(".tab-content");

  buttons.forEach(button => {
    button.addEventListener("click", () => {
      const lang = button.getAttribute("data-lang");

      contents.forEach(content => content.style.display = "none");
      buttons.forEach(btn => btn.classList.remove("active"));

      container.querySelector(`.content-${lang}`).style.display = "block";
      button.classList.add("active");
    });
  });
});

function toggleCode(btn) {
  const container = btn.previousElementSibling;

  if (container.classList.contains("expanded")) {
    container.classList.remove("expanded");
    btn.textContent = "See more";
  } else {
    container.classList.add("expanded");
    btn.textContent = "See less";
  }
}
</script>

</br>
<p>Code also available in this <a href="https://gist.github.com/Inobtenio/bd6c1c20e1af3dbafeafb4dc781d0144" target="_blank">gist</a> on GitHub.</p>
<p>Of course, the final formula becomes:</p>
<p>\begin{align}
S = 4.923 * Ch + 1.406 * Dk + 0.793 * Do
\end{align}</p>
]]></content:encoded>
    </item>
    <item>
      <title>Spotify on the Vobot Mini Dock</title>
      <link>https://inobtenio.com/en/posts/vobot-mini-dock-spotify/</link>
      <pubDate>Sat, 24 May 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/en/posts/vobot-mini-dock-spotify/</guid>
      <description>The Vobot Mini Dock is a clock that functions as a USB hub for which apps can be developed, so as any curious guy would do I try to make it tell me what I&amp;#39;m listening to all the time.</description><enclosure url="https://inobtenio.com/img/posts/vobot-mini-dock-spotify/mini-dock-pressure.jpg" length="0" type="image/jpeg" />
      <content:encoded><![CDATA[<p>Sometimes, when I&rsquo;m glued to my computer at my desk, I <em>need</em> to know the time. Of course, I <em>could</em> glance at the bottom corners of my monitors (Windows 11) to get the info, but more often than not, I have so much stuff piled on my desk that I can&rsquo;t even <em>see</em> the bottom of the screens. Plus, when I&rsquo;m gaming or watching videos, I absolutely <em>don&rsquo;t</em> want to be looking at the clock! It breaks the immersion! So, I needed a dedicated, always-visible clock. Enter: the little gadget.</p>
<p>That&rsquo;s where this little device comes in. It&rsquo;s a Vobot Mini Dock, and I&rsquo;ve been hacking on it to display Spotify information. I&rsquo;ve been wanting to do this for a while, and it&rsquo;s been a fun project.</p>
<p>It all started when I saw the <a href="https://web.archive.org/web/20210413130229/http://carthing.spotify.com/" target="_blank">Spotify Car Thing</a> (RIP, that thing was cool) and thought, &ldquo;Hey, I could do something like that, but smaller and for my desk!&rdquo;  It was supposed to be a fun little side project, something I could tinker with in my spare time. Little did I know&hellip;</p>
<p>Obviously, I couldn&rsquo;t expect it to be as powerful or feature-rich as the Car Thing. It&rsquo;s based on the <a href="https://www.espressif.com/en/products/socs/esp32-s3" target="_blank">ESP32S3</a>, which is a tiny microcontroller with 512 KB of RAM and a 240 MHz CPU (for comparison, a Samsung Galaxy S25 has 12 GB of RAM and a processor that can reach 4.47 GHz). It also has a tiny LCD screen, 240x320 pixels. So, yeah, it&rsquo;s <em>very</em> basic. But I figured, &ldquo;Surely, displaying the time and a song title won&rsquo;t require <em>that</em> much power, right?&rdquo;</p>
<p>My initial goal was simple: get the Vobot to display the currently playing song, the artist, and, ideally, the album art. I searched the Vobot app store for something similar, but the selection was pretty limited, so I decided to roll my own.</p>
<figure>
  <video class="width50perc" src="/video/posts/vobot-mini-dock-spotify/thanos-myself.mp4" autoplay loop muted playsinline></video>
</figure>
<h2 id="the-problems">The Problem(s)</h2>
<p>The first hurdle was figuring out what programming language the Vobot used. I assumed it would be C++ or something similar. Nope! It uses <a href="https://micropython.org/" target="_blank">MicroPython</a> for the core logic and <a href="https://lvgl.io/" target="_blank">LVGL</a> for the graphical user interface. I have some experience with Python, which was a good thing, but I&rsquo;d never even heard of LVGL. So, I had to dive into the documentation and learn how to create buttons, labels, and images.</p>
<p>The LVGL documentation for C is pretty comprehensive, but the documentation for MicroPython felt a bit… lacking.  I spent a lot of time just trying to get a basic &ldquo;Hello, World!&rdquo; app running. Plus, LVGL is a bit finicky.</p>
<p>I also had a few pre-existing projects that I could draw inspiration from. Thanks to @itandy848 and their <a href="https://github.com/itandy848/vobot/tree/main/HK%20Weather" target="_blank">HK Weather</a> project, and the Vobot community itself, I managed to piece together a working solution.</p>
<p>One more thing: the official Vobot documentation for application development is pretty sparse. If they hadn&rsquo;t open-sourced the code for some of their built-in apps, I probably wouldn&rsquo;t have been able to figure out how to get started.</p>
<p>Alright, with all the pieces (or at least most of them) in place, I finally started writing the code.</p>
<p>Here&rsquo;s the basic idea:</p>
<ul>
<li><strong>Authentication:</strong>  The Vobot needs to authenticate with Spotify to access user data.</li>
<li><strong>Song Information:</strong>  I need to retrieve the currently playing song&rsquo;s title, artist, and album art from Spotify&rsquo;s API.</li>
<li><strong>Display:</strong>  I need to display that information on the Vobot&rsquo;s tiny screen.</li>
<li><strong>Real-time Updates:</strong>  The clock needs to update regularly to keep the information current.</li>
</ul>
<p>Things didn&rsquo;t go <em>exactly</em> as planned. It turns out there were a few unexpected challenges.</p>
<h3 id="the-images">The Images</h3>
<figure class="width85perc">
  <img loading="lazy" alt="First version with the album art cropped" height="288" title="First version with the album art cropped" width="720"srcset="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_2844a5050fd5250a.webp 480w ,https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_258e39cbff6f2717.webp 720w ,https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_a7689f86adc4eb5a.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_a6505054c26150e3.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/vobot-mini-dock-spotify/first_hu_a6505054c26150e3.webp"><figcaption>First version with the album art cropped</figcaption></figure><p>As you can see, the first version had a problem with the album art. It was… cropped.  It took me a while to figure out why. LVGL, especially on resource-constrained microcontrollers like the ESP32, is really sensitive to image processing. Resizing images on the fly is <em>expensive</em>.</p>
<p>So, I thought, &ldquo;Maybe I can just ask Spotify for a smaller image!&rdquo;  Unfortunately, Spotify provides three image sizes: 64x64, 300x300, and 640x640.  None of those were ideal for the Vobot&rsquo;s screen.  I ended up having to experiment with different resizing algorithms to find one that looked reasonably good without completely bogging down the device.</p>
<h3 id="the-requests">The Requests</h3>
<p>Each time the clock needed to update (roughly every 3 seconds), it had to make a request to Spotify&rsquo;s servers.  Those requests took a few milliseconds, which isn&rsquo;t <em>terrible</em>, but it added up.  Also, the responses from Spotify were surprisingly large—often tens or even hundreds of kilobytes.  That&rsquo;s a lot of data for a tiny device with limited memory.</p>
<p>Another thing: I wanted the background of the screen to be the dominant color from the album art, just like Spotify does in its apps.  That seemed like a nice touch.  But even <em>that</em> required some extra processing power.</p>
<p>It quickly became clear that trying to cram all that logic onto the Vobot was going to result in a pretty mediocre user experience. The clock would be slow, the memory would fill up quickly, and the update lag would be noticeable. Not to mention my code was far from optimized.</p>
<h2 id="client-and-server">Client and Server</h2>
<p>So, I decided to offload some of the work to a more powerful machine. I set up a Python server on a virtual machine running on my main computer. This server handles image resizing, makes the requests to Spotify, and prepares the data for the Vobot.</p>
<p>The advantages were clear:</p>
<ul>
<li><strong>Faster Processing:</strong> Image resizing and API requests are much faster on a server.</li>
<li><strong>Reduced Memory Usage:</strong> The Vobot only receives the data it absolutely needs.</li>
<li><strong>More Reliable:</strong> The server can handle errors and retries more gracefully.</li>
<li><strong>Easier Development:</strong> Python is a more flexible language for this kind of task.</li>
</ul>
<figure>
  <video class="width50perc" src="/video/posts/vobot-mini-dock-spotify/final.mp4" autoplay loop muted playsinline></video>
  <figcaption>The numbers don't flicker in the final version.</figcaption>
</figure>
<p>Finally, I got everything working! The &ldquo;play/pause&rdquo; indicator shows whether something is currently playing, the progress bar moves smoothly, the song title and artist labels scroll if they&rsquo;re too long, and the background color dynamically changes to match the album art.</p>
<p>As for how I managed to get the background color to match the album art? Well, I wrote a whole separate post about that. Check it out <a href="/en/posts/spotify-song-colors" target="_blank">here</a>.</p>
<p>The source code for both the Vobot application and the server is available on <a href="https://gist.github.com/Inobtenio/9e1840cbe3eb82a02c7e7c0ff9d57867" target="_blank">this GitHub gist</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cosplaying as a composer to find a song</title>
      <link>https://inobtenio.com/en/posts/finding-music-copying/</link>
      <pubDate>Wed, 23 Apr 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/en/posts/finding-music-copying/</guid>
      <description>After days of frustration, unable to find a song despite using every music identification method I knew, I turned to GarageBand to see if it could help.</description><enclosure url="https://inobtenio.com/img/posts/finding-music-copying/gazebo-cover-noname.jpg" length="0" type="image/jpeg" />
      <content:encoded><![CDATA[<p>Okay, this is going to be a quick one.</p>
<p>A couple of days ago, a melody from a song I&rsquo;d heard ages ago popped back into my head. I couldn&rsquo;t recall anything about it except that somewhere in the song, the singer (or singers) mentioned the word &ldquo;dangerous.&rdquo;</p>
<p>I did what any normal person would do, I cleared my throat and started trying to imitate a piano and a synthesizer to the best of my poor abilities, so that <a href="https://www.shazam.com/" target="_blank">Shazam</a> might be able to help. I put a lot of effort into it, but I couldn&rsquo;t get it to recognize the sound; it was probably because my attempts at imitating instruments are pretty awful.</p>
<p>To add a bit of humor and context, this is me trying to make myself sound as close as possible to the song:</p>

    <figure class="audio">
      <audio controls id="audio-001" preload="auto">
        <source src="https://cdn.inobtenio.com/audio/posts/finding-music-copying/trying.m4a" type="audio/mp4">
      </audio>
        <figcaption>Failed attempt</figcaption>
    </figure>

<p>After no luck with Shazam, I figured I&rsquo;d try ChatGPT. Here&rsquo;s what we managed to piece together, based on what I could remember:</p>
<ul>
<li>Dreamy male vocals</li>
<li>A romantic, almost retro-futuristic vibe</li>
<li>A synth melody that kind of carries the whole thing</li>
<li>Simple piano chords</li>
<li>A medium-speed tempo (~100 BPM)</li>
<li>That feeling of electronic/synth-pop music</li>
<li>A smooth, understated rhythm</li>
<li>A cosmic and slightly melancholic atmosphere</li>
</ul>
<p>But still, nothing. After all that, I hadn&rsquo;t found the song. Some of the suggestions were interesting, and some even had a similar feel, but none of them were it. So I mentioned the problem to a couple of people, hoping someone might recognize it, but they just shrugged and said they couldn&rsquo;t quite figure it out even when the sounds sounded (heh) familiar to them. Bad, bad luck.</p>
<p>I recalled the song had a simple structure, and I thought I had a pretty good idea of what it sounded like, so I decided to give <a href="https://www.apple.com/mac/garageband/" target="_blank">GarageBand</a> a shot , to see if I could make a melody as close as possible to the original to then feed it back to Shazam. I was (and still am) basically a GarageBand newbie at the time.</p>
<p>The project ended up looking like this:</p>
<figure class="width85perc">
  <img loading="lazy" alt="Project in GarageBand" height="415" title="Project in GarageBand" width="720"srcset="https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_75910c1ae7beef7d.webp 480w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_d526b96b5de86ca2.webp 720w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_6423994ed4674c9b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_e85fd0323737cc7f.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/finding-music-copying/garageband-proyect_hu_e85fd0323737cc7f.webp"><figcaption>Project in GarageBand</figcaption></figure><p>I figured that maybe, just maybe, a few seconds of the track would be enough for it to recognize it. It was a long shot, but hey, you&rsquo;ve got to try. And the track itself wasn&rsquo;t super complex – just a piano, a synth, and a basic drum beat. I wasn&rsquo;t even sure if the drum beat should&rsquo;ve been played along with the piano, so I experimented with different options.</p>
<p>I ended up with something like this:</p>

    <figure class="audio">
      <audio controls id="audio-002" preload="auto">
        <source src="https://cdn.inobtenio.com/audio/posts/finding-music-copying/track.m4a" type="audio/mp4">
      </audio>
        <figcaption>A little better</figcaption>
    </figure>

<p>A second attempt with Shazam, and boom, there it was! I finally found what I was looking for.
The song is called <em>I Like Chopin</em> by Gazebo, and it&rsquo;s not exactly synth-pop, though it has some of those elements. It doesn&rsquo;t have two male voices, but it does have a very distinctive sound, and it definitely doesn&rsquo;t have the word &ldquo;dangerous&rdquo; anywhere in the lyrics (which is what I&rsquo;d been searching for! Silly me). That&rsquo;s probably why ChatGPT couldn&rsquo;t help me. &#x1f614;</p>
<figure class="width50perc">
  <img loading="lazy" alt="EP of I Like Chopin" height="720" title="EP of I Like Chopin" width="720"srcset="https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_a373c1f5ea73d041.webp 480w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_2a6912b001c1630c.webp 720w ,https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_c8dbb7b16dd5cbc9.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_5e830e4e5ef734a5.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/finding-music-copying/i-like-chopin-ep_hu_5e830e4e5ef734a5.webp"><figcaption>EP of I Like Chopin</figcaption></figure><p>Here&rsquo;s a sample of the song:</p>
<iframe class="spotify-embed-compact" src="https://open.spotify.com/embed/track/2FHnN5ELL83TGbtXMDzoiJ?utm_source=generator" width="100%" height="152" frameBorder="0" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
<h3 id="to-wrap-things-up-just-in-case">To wrap things up, just in case</h3>
<p>I was curious whether it was really my fault that I couldn&rsquo;t find the song with ChatGPT&rsquo;s help, so I started a new session and gradually fed it all the corrected information. I even told it to focus on searching for songs that included the words &ldquo;rainy days&rdquo;, which are actually part of the lyrics, and even then it didn&rsquo;t give me a satisfactory answer. It seems like it can&rsquo;t &ldquo;read&rdquo; the lyrics of songs to accurately identify them.</p>
<p>Here&rsquo;s how I tried to give ChatGPT better clues about the lyrics:</p>
<figure class="width60perc no-responsive">
  <img loading="lazy" alt="Attempt lyrics" height="361" width="720"src="https://cdn.inobtenio.com/img/posts/finding-music-copying/rainy-days.png"></figure><p>Here, I tried to help it out by specifying the genre:</p>
<figure class="width60perc no-responsive">
  <img loading="lazy" alt="Attempt genre" height="498" width="720"src="https://cdn.inobtenio.com/img/posts/finding-music-copying/italo-disco.png"></figure><p>And then, after I told it the title of the song, this is what it said:</p>
<figure class="width60perc no-responsive">
  <img loading="lazy" alt="Okay&hellip;" height="258" width="720"src="https://cdn.inobtenio.com/img/posts/finding-music-copying/rainy-days-2.png"></figure><p>It seems like it&rsquo;s not the best resource for finding music, even though it&rsquo;s helped me out a few times in the past.</p>
<p>The GarageBand project can be downloaded from <a href="special/posts/finding-music-copying/Track%20N1.band.zip" target="_blank">this link</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Making Emby faster</title>
      <link>https://inobtenio.com/en/posts/emby-optimization/</link>
      <pubDate>Mon, 24 Mar 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/en/posts/emby-optimization/</guid>
      <description>Fed up with how slow Emby was when browsing the timelines of the videos I was watching, I discovered that a couple of tweaks and tools could significantly improve the experience.</description><enclosure url="https://inobtenio.com/img/posts/emby-optimizations/emby-requests.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>I enjoy movies, like anyone else, and have accumulated many over the years, though I haven’t always acquired them through the most formal means. 😉
To organize and watch them, I usually use <a href="https://plex.tv/" target="_blank">Plex</a>, and I like it a lot; it’s a great platform and, in my opinion, deserves a few bucks from me. However, since they’ve been making some <a href="https://www.plex.tv/blog/important-2025-plex-updates/#are-you-going-to-continue-to-invest-in-features-for-personal-media-plex-pass" target="_blank">curious</a> decisions lately, I started using an <a href="https://emby.media" target="_blank">Emby</a> instance more frequently for similar purposes.</p>
<p>Everything was going relatively well until it started going relatively poorly: I noticed that sometimes the movies or videos I store there had difficulty starting playback and/or continuing playback when I made jumps on the timeline. It wasn’t a huge problem, as it’s not common to make time jumps when watching a movie, but the issue had already caught my attention.</p>
<p>I first thought my hard drives were failing since this problem didn’t occur a couple of years ago when I moved my media library to them. So, I ran a couple of <a href="https://www.hdsentinel.com/smart/" target="_blank">SMART</a> tests, and everything seemed fine with them. Ruled out.
I thought it might be a network bottleneck, but it was very unlikely since I observed the problem during local playback of the files. Additionally, I had recently upgraded my local network bandwidth to a respectable 10 Gigabit (more on this in another post in the future). Ruled out.
It crossed my mind that one of the nodes (computers/servers) might be having issues when jumping from one part of the movie to another, but I didn’t see any excessive CPU usage in any case. Ruled out.</p>
<p>I ruled out a couple more options over days and weeks, but I noticed something interesting while deliberately trying to reproduce the error for diagnostic purposes. It didn’t matter how many minutes the jump was; whether I skipped 5 or 50 minutes, there was still a noticeable delay. More interestingly, I realized that if I didn’t hover the cursor over the timeline and jumped directly by placing it right at the point I wanted to skip to, the delay practically disappeared. Very interesting.</p>
<h2 id="the-problem">The problem</h2>
<p>Reviewing the logs in the browser’s DevTools window, I noticed many requests made to the same endpoint; all started with “Thumbnail,” and then everything made sense: while scrubbing, i.e., moving the cursor over the timeline to see a small preview of what happens along it, dozens of requests were made that allowed Emby to obtain thumbnails for the preview. Thumbnails are lightweight, yes, but in the order of dozens, they significantly delay subsequent actions.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Emby requests to get thumbnails" height="200" title="Emby requests to get thumbnails" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_63f6ece2c8dd273a.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_872a9173bb9a75a9.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_2bdc394eafb0f907.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_363e488378c8f93d.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/emby-requests_hu_363e488378c8f93d.webp"><figcaption>Emby requests to get thumbnails</figcaption></figure><p>I couldn’t determine how many requests are made per pixel, perhaps per em, but they result in many in a short time while scrubbing and can become frustrating because the video resumes playback only when all of them (or at least a good percentage) have been completed, successfully or not.</p>
<h2 id="the-solution">The solution</h2>
<p>Visiting the Emby forums, I saw that they recommended enabling the option to save thumbnails on the same disk where the content resides. It&rsquo;s an option I had deliberately left disabled because I didn&rsquo;t want to use space unnecessarily. Additionally, it was also recommended that each video file reside within its own directory and not have them all at the same level, as this way both the operating systems involved (Debian for the NAS and Windows for the client I was using at the time) and Emby itself would have less work finding and accessing files.</p>
<p>Let&rsquo;s get hands on, then.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Option to save thumbnails into media folders" height="292" title="Option to save thumbnails into media folders" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_a67a47433a6f4a6b.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_8134a1055113815f.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_821043d3250aa58b.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_a1ae955de0102d6.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/thumbnails-option_hu_a1ae955de0102d6.webp"><figcaption>Option to save thumbnails into media folders</figcaption></figure><p>This initiated a process that, using FFmpeg, generates <a href="https://developer.roku.com/es-pe/docs/developer-program/media-playback/trick-mode/bif-file-creation.md" target="_blank">BIF</a> files (created by Roku) that encapsulate snapshots taken at regular intervals throughout the video. So far, so good, but the process takes approximately 9 seconds per gigabyte on my hardware, and with several terabytes in tow, it was going to take a good few hours.</p>
<p>Some time later, with the process finished, I had double the files in my main movies/videos directory; each MP4 or MKV file had an analogous BIF file with the same name. If I recall correctly, they recommended not having so many files at the same level, and what I did doubled the original number—totally wrong. The good thing is that the next step would surely improve things.</p>
<p>I had to move each pair of files to their own folder; the bad thing is that Emby doesn&rsquo;t get happy at all when files are moved. It doesn&rsquo;t matter if you just put them inside another folder; it represents a change in their location, and that&rsquo;s enough. The consequence of this is that Emby wouldn&rsquo;t recognize the &ldquo;new&rdquo; files as files it already knew about, and references to the &ldquo;old&rdquo; ones would be invalidated, thus ruining playlists and favorites, for example.</p>
<p>Reading around, they recommended using <a href="https://en.wikipedia.org/wiki/.nfo" target="_blank">NFO</a> files because they could preserve important information about the files, and as long as they&rsquo;re alongside the videos, that information will be available.</p>
<p>I did the test: enabled the option to save everything on the media disk in NFO format and moved a couple of movies to their respective new folders. I rescanned the folder in Emby, refreshed the metadata, but nothing—the movies in question had disappeared from the playlists they were in, as well as from favorites. The curious thing is that if I returned the files to their original location, everything went back to normal. Apparently, the correct way to remove items from these lists is by deliberately removing them through the applications or the API.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Option to use NFO as metadata format" height="115" title="Option to use NFO as metadata format" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_4a921b69deb8bf56.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_facf165526c05f8d.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_9d73fa7fb218f371.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_f51c26b370c53039.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-savers_hu_f51c26b370c53039.webp"><figcaption>Option to use NFO as metadata format</figcaption></figure><figure class="width85perc">
  <img loading="lazy" alt="Option to enable NFO files as preferred metadata" height="129" title="Option to enable NFO files as preferred metadata" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_5263dcbb0ca54332.webp 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_98f311de81313f0f.webp 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_64d64602659eb8bf.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_919907adcc9e7a78.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/metadata-readers_hu_919907adcc9e7a78.webp"><figcaption>Option to enable NFO files as preferred metadata</figcaption></figure><p>Clearly, I needed another way to preserve my favorites and lists. I also noticed that Emby seems to assign a unique identifier to each video but based on an index that increments as new files are added, which means that, by Emby considering the moved files as new, they also didn&rsquo;t retain their IDs and obtained a new one.</p>
<p>The best I could think of was to resolve it through the names of the movies/videos. I was almost 100% sure that none would be repeated and that they would basically function as a unique identifier, but Emby doesn&rsquo;t think the same way, so I would have to use its API to search by name and thus be able to reach the real ID (the new one) to be able to add the files back to favorites.</p>
<p>Something to keep in mind is that when searching through the API or GUI, Emby doesn&rsquo;t return a single result even if there&rsquo;s an exact match, so we have to refine the search. A simple string comparison should be sufficient.</p>
<div class="tab-container" id="6f61d088">
    <div class="tab-labels">
	    <button class="tab-label active" data-lang="python">python</button>
    </div>
    <div class="tab-contents">
            <div class="tab-content content-python" style="display:block;">
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> requests
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">import</span> json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>EMBY_IP = <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>API_KEY = <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>USER_ID = <span style="color:#fc6a5d">&#34;&#34;</span> <span style="color:#6c7986"># In the format XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</span>
</span></span><span style="display:flex;"><span>PARENT_ID = <span style="color:#fc6a5d">&#34;&#34;</span> <span style="color:#6c7986"># In case the videos need to be filtered by library</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>HEADERS = {<span style="color:#fc6a5d">&#34;X-Emby-Token&#34;</span>: API_KEY}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">get_favorites</span>():
</span></span><span style="display:flex;"><span>    url = <span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc6a5d">{</span>EMBY_IP<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/Users/</span><span style="color:#fc6a5d">{</span>USER_ID<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/Items?Recursive=true&amp;IsFavorite=true&amp;ParentId=</span><span style="color:#fc6a5d">{</span>PARENT_ID<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&amp;IncludeItemTypes=Movie&#34;</span>
</span></span><span style="display:flex;"><span>    response = requests.get(url, headers=HEADERS)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">if</span> response.status_code == <span style="color:#d0bf69">200</span>:
</span></span><span style="display:flex;"><span>        items = response.json().get(<span style="color:#fc6a5d">&#34;Items&#34;</span>, [])
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">return</span> [item[<span style="color:#fc6a5d">&#34;Name&#34;</span>] <span style="color:#fc5fa3">for</span> item in items]
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">else</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Error fetching favorites:&#34;</span>, response.text)
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">return</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">search_new_item_id</span>(name):
</span></span><span style="display:flex;"><span>    url = <span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc6a5d">{</span>EMBY_IP<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/emby/Items?Recursive=true&amp;SearchTerm=</span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    response = requests.get(url, headers=HEADERS)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">if</span> response.status_code == <span style="color:#d0bf69">200</span>:
</span></span><span style="display:flex;"><span>        items = response.json().get(<span style="color:#fc6a5d">&#34;Items&#34;</span>, [])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        matching_items = [item <span style="color:#fc5fa3">for</span> item in items <span style="color:#fc5fa3">if</span> item[<span style="color:#fc6a5d">&#34;Name&#34;</span>].strip().lower() == name.strip().lower()]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">if</span> matching_items:
</span></span><span style="display:flex;"><span>            <span style="color:#fc5fa3">return</span> matching_items[<span style="color:#d0bf69">0</span>][<span style="color:#fc6a5d">&#34;Id&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">return</span> <span style="color:#fc5fa3">None</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">def</span> <span style="color:#41a1c0">re_add_favorites</span>(old_favorites):
</span></span><span style="display:flex;"><span>    <span style="color:#fc5fa3">for</span> name in old_favorites:
</span></span><span style="display:flex;"><span>        new_id = search_new_item_id(name)
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">if</span> new_id:
</span></span><span style="display:flex;"><span>            url = <span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc6a5d">{</span>EMBY_IP<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/Users/</span><span style="color:#fc6a5d">{</span>USER_ID<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">/FavoriteItems/</span><span style="color:#fc6a5d">{</span>new_id<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>            response = requests.post(url, headers=HEADERS)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>            <span style="color:#fc5fa3">if</span> response.status_code == <span style="color:#d0bf69">200</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;Re-added favorite: </span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#fc5fa3">else</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;Failed to re-add </span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">: </span><span style="color:#fc6a5d">{</span>response.text<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#fc5fa3">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">f</span><span style="color:#fc6a5d">&#34;Could not find new ID for: </span><span style="color:#fc6a5d">{</span>name<span style="color:#fc6a5d">}</span><span style="color:#fc6a5d">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">if</span> __name__ == <span style="color:#fc6a5d">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Fetching current favorites...&#34;</span>)
</span></span><span style="display:flex;"><span>    old_favorites = get_favorites()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#d0a8ff">input</span>(<span style="color:#fc6a5d">&#34;Move your files now and press Enter when done...&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#d0a8ff">print</span>(<span style="color:#fc6a5d">&#34;Re-adding favorites with new paths...&#34;</span>)
</span></span><span style="display:flex;"><span>    re_add_favorites(old_favorites)
</span></span><span style="display:flex;"><span>
</span></span></code></pre></div>
            </div>
    </div>
    <button class="toggle-btn" onclick="toggleCode(this)">See more</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function () {
  const container = document.getElementById("6f61d088");
  if (!container) return;

  const buttons = container.querySelectorAll(".tab-label");
  const contents = container.querySelectorAll(".tab-content");

  buttons.forEach(button => {
    button.addEventListener("click", () => {
      const lang = button.getAttribute("data-lang");

      contents.forEach(content => content.style.display = "none");
      buttons.forEach(btn => btn.classList.remove("active"));

      container.querySelector(`.content-${lang}`).style.display = "block";
      button.classList.add("active");
    });
  });
});

function toggleCode(btn) {
  const container = btn.previousElementSibling;

  if (container.classList.contains("expanded")) {
    container.classList.remove("expanded");
    btn.textContent = "See more";
  } else {
    container.classList.add("expanded");
    btn.textContent = "See less";
  }
}
</script>

</br>
This script retrieves the names of the favorites, waits for them to be moved, and once it's told to continue, it locates them and re-adds them.
<p>But now the files come in trios, since we have the media file, the thumbnail file (BIF), and the metadata file (NFO), and moving them manually into their respective new folders can be tedious. It&rsquo;s tedious.</p>
<p>To solve this, I used <a href="https://www.filebot.net/" target="_blank">FileBot</a>, which is apparently quite popular among users on the Emby and Plex forums for organizing files—moving, copying, deleting, and more. The downside is that it&rsquo;s paid software, and worse, it&rsquo;s a subscription. But considering that it’s not very expensive (priced at $6 per year at the time of writing this), that I’ll probably need to perform these kinds of operations regularly in the future, and that I was too lazy to write another script, I decided to give this community-proven tool a shot.</p>
<figure class="width50perc no-conversion">
  <img loading="lazy" alt="Original files" height="656" title="Original files" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals_hu_562209c09727408b.png 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals_hu_61df6a35a544fb23.png 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals_hu_459ac587b85b8199.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-originals.png"><figcaption>Original files</figcaption></figure><figure class="width85perc no-conversion">
  <img loading="lazy" alt="Files in their new directories" height="396" title="Files in their new directories" width="720"srcset="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed_hu_10d40dd31cdefb9c.png 480w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed_hu_d4acbc03e6dd4c54.png 720w ,https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed_hu_b8df8e1f1d493066.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/emby-optimizations/filebot-renamed.png"><figcaption>Files in their new directories</figcaption></figure><p>Using the &ldquo;File to Folder Name&rdquo; preset, we’re able to create a shared folder for the three files and make that their new home. In the example, there are no other types of files, but as long as they all have exactly the same name (excluding the extension, of course), they&rsquo;ll be placed into the same directory.</p>
<h2 id="final-thoughts">Final thoughts</h2>
<p>With the above changes, the performance improvement is noticeable in some areas and significant in others. For instance, when scanning video libraries, it seems to run a bit faster; when it comes to scrubbing, thumbnail previews on the timeline now appear almost instantly, and there&rsquo;s hardly any wait time when jumping to a different point, no matter how it&rsquo;s done. The results seem quite satisfying.</p>
<h3 id="for-some-day-hopefully">For some day&hellip; hopefully</h3>
<ul>
<li>Write a script to perform the same process but for playlists. It shouldn&rsquo;t be complicated; it&rsquo;s quite similar to the code we already have and could be adapted with little trouble.</li>
<li>While I can use FileBot on as many machines as I want, regardless of the platform (it supports Docker, Ubuntu, macOS, Windows, and even CentOS), it would be wise to eventually replace it with a basic script. For now, I’ve already paid for it and will use the program for these tasks, but for what I need just a few lines of code should be enough.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Lighter and more usable video with FFmpeg (and other issues with DJI cameras)</title>
      <link>https://inobtenio.com/en/posts/dji-bitrate-gps/</link>
      <pubDate>Mon, 24 Feb 2025 11:11:11 -0500</pubDate><author>knmartinm@gmail.com (Kevin Martin)</author>
      <guid>https://inobtenio.com/en/posts/dji-bitrate-gps/</guid>
      <description>One day I came home from a trip and wanted to watch the videos I&amp;#39;d recorded with a DJI camera. I realized they were huge, and for good reason. This is an exercise of reducing file size with the least possible loss of image quality.</description><enclosure url="https://inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before.png" length="0" type="image/png" />
      <content:encoded><![CDATA[<p>I&rsquo;m not really a photogenic guy but from time to time I enjoy saving memories of what surrounds me in the form of photos and videos, especially if I&rsquo;m on a trip, could be my last visit for all I know.<br>
A phone is usually enough for the duty but sometimes a dedicated device that I can solely use to capture images sounds like the way to go, so I have a couple of cameras: an action can (<a href="https://www.dji.com/global/osmo-action-3?site=brandsite&amp;from=landing_page" target="_blank">DJI Action 3</a>) and a vlogging cam (<a href="https://www.dji.com/global/support/product/osmo-pocket" target="_blank">DJI Osmo Pocket</a>). They&rsquo;re nice for the use case, given I&rsquo;m not any kind of photo pro, I just want decently looking and stabilized images, the rest is pretty much accessory, or so I thought.</p>
<p>While on a trip, I rented a car and decided to record my way to some second-hand stores located on the outskirts of the city I was staying in, a good 90 minute road trip one-way and around the same for the return.
When the moment arrived to transfer all the media to a safer storage medium, I got reminded every clip the cameras recorded was about 4GB in size for every 7 minutes. Didn&rsquo;t care, it looked good.</p>
<p>Some weeks later, when wanting to transfer them to their final destination for multi-device offline viewing thanks to <a href="https://immich.app/" target="_blank">Immich</a>, my home NAS, not only did they take quite some time to transfer, but I also noticed a couple of issues:</p>
<ul>
<li>As alluded to above, the cameras cut the recording when a single clip reaches about 4GB in size, DJI <a href="https://forum.dji.com/forum.php?mod=redirect&amp;goto=findpost&amp;ptid=277498&amp;pid=2882469" target="_blank">says</a> they use FAT32 (which precisely presents this limitation), which explains this</li>
<li>Given the settings I picked when recording (1080p 60 fps HQ), videos had a bitrate of around 80Mbps, or 80000Kbps if you will, which is more than double that of many Blu-ray conventional recordings and a little less that what could be found in 4K Blu-ray discs</li>
<li>These two cameras have no built-in GPS, which is definitely not a problem for the majority of people and cases but given the nature of what I wanted to capture this time, I think it would&rsquo;ve been a really nice to have</li>
</ul>
<p>Let&rsquo;s see&hellip;</p>
<h2 id="fat32">FAT32</h2>
<p>The first issue is not really that problematic, it may very well not even be an issue and instead more of a feature of this kind of device. The fact that DJI uses FAT32 and video files are capped at 4GB could come in handy when working with them and could even prevent corruption problems.</p>
<p>Transferring MPEG-4 files that are too big could be cumbersome. At more than 36GB per hour of video, that kind of transfer could take hours to complete between wireless devices and, it not being a video container format that plays along particularly well with errors and corruption makes it so that the process would have to be restarted when that happens, which is frankly tedious.</p>
<p>On the other hand, if errors occur during the recording or while storing the videos in the MicroSD cards, the fact that they are cut into smaller pieces favors the odds of recovery.</p>
<p>In a nutshell, may be inconvenient in a couple of aspects but no biggie. Next.</p>
<figure class="width85perc">
  <img loading="lazy" alt="List of original files" height="117" title="List of original files" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before_hu_37eea6d03029adef.png 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before_hu_800d5359b057a941.png 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before_hu_eafc51e67dc9b905.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_before.png"><figcaption>List of original files</figcaption></figure><h2 id="bitrate">Bitrate</h2>
<p>This is indeed a problem. Okay, not a problem per se but hear me out: I know the bigger the file the more information it holds inside and the better when the time to edit comes, no doubt, but I think in this case the size is not offering that much value.<br>
With its <a href="https://newatlas.com/camera-sensor-size-guide/26684/" target="_blank">1/2.3-inch sensor</a>, it&rsquo;s not capturing that much light (comparatively, of course) at all and the added file size is translated to, I don&rsquo;t know, noise? Still, having more information doesn&rsquo;t hurt (or shouldn&rsquo;t), but in this case I think I can get rid of a lot of bytes without sacrificing much image quality. At least that&rsquo;s the reasoning.</p>
<p>With that in mind, a bitrate of 80000kbps is just too much for casual videos that we might want to share on social media or play on a phone from time to time to share with friends. It would be different had the goal been to play them often in a big TV, in that case I would&rsquo;ve gone straight for 4K but I don&rsquo;t have 20 MicroSD cards laying around to be able to keep and keep recording and not worrying.</p>
<p>So, given the problems their size presents to edit them, move them around or even play them, here&rsquo;s where <a href="https://www.ffmpeg.org/" target="_blank">FFmpeg</a> enters the game.</p>
<p>FFmpeg (pronounced /&lsquo;ɛfɛfɛm&rsquo;pɛɡ/ 😉), helped me reducing the file sizes without a huge impact on image quality. These are the used parameters:</p>
<pre tabindex="0"><code>ffmpeg -hide_banner -loglevel error -hwaccel qsv -c:v h264_qsv -i {input} -c:v h264_qsv -global_quality 25 {output}
</code></pre><ul>
<li><code>-hide_banner</code> prevents the banner, copyright related stuff and compilation options from being output as they&rsquo;re generally not useful data</li>
<li><code>-loglevel error</code> shows errors</li>
<li><code>-hwaccel qsv</code> makes use of Intel Quick Sync Video hardware encoders which greatly accelerate the process</li>
<li><code>-c:v h264_qsv</code> specifies the codec, h.264 for compatibility reasons</li>
<li><code>-global_quality 25</code> sets the quality level of the output file. The lower the number, the better the quality, so the idea is to have some sort of balance. I&rsquo;ll expand on this later</li>
</ul>
<p>Results are very promising, files are reduced to about 850MB from 4GB, savings of up to 80%.</p>
<figure class="width85perc">
  <img loading="lazy" alt="Original" height="405" title="Original frame" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_e60b3427507329cf.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_92d5e78ca551d21c.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_306380247da3b127.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_819a9db97711311c.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_original_hu_819a9db97711311c.webp"><figcaption>Original frame</figcaption></figure><figure class="width85perc">
  <img loading="lazy" alt="Processed" height="405" title="Frame after the diet" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_8b4a27b8d27605d9.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_70e2c3654c2c2799.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_e574b7be73a5c645.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_caff4867a0acf273.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/frame1_processed_hu_caff4867a0acf273.webp"><figcaption>Frame after the diet</figcaption></figure><p class="idea"><em>Images are compressed to enhance the site&rsquo;s performance. Original, full quality frames can be found <a href="/img/posts/dji-bitrate-gps/frame1_original.png" target="_blank">here</a> and <a href="/img/posts/dji-bitrate-gps/frame1_processed.png" target="_blank">here</a> respectively.</em></p>
<p>But don&rsquo;t blindly (heh) trust my or your eyes, there are image quality perception and comparison algorithms such as <a href="https://netflixtechblog.com/toward-a-practical-perceptual-video-quality-metric-653f208b9652" target="_blank">VMAF</a>, developed by Netflix, or <a href="https://www.imatest.com/docs/ssim/" target="_blank">SSIM</a>, developed by US universities, that can help us decide what are the best FFmpeg encoding parameters given our needs.</p>
<p>I chose SSIM because in order to use Netflix&rsquo; method (by the way, don&rsquo;t let the opportunity of reading their blog pass by) I would&rsquo;ve needed to complete a couple of prior steps, such as making sure the videos use the <a href="https://es.wikipedia.org/wiki/YUV" target="_blank">YUV</a> color space, for example, which means encoding them first if that&rsquo;s not the case.
The laziness in me wins this time.</p>
<p>SSIM, the structural similarity index, works by simulating (or calculating) the perceived quality someone would have about an image or a set of them, including aspects like luminance and contrast. More than one way of calculating it exist, but I opted for FFmpeg because it comes built-in witht the binary.</p>
<pre tabindex="0"><code>ffmpeg.exe -i {input1} -i {input2} -lavfi ssim=stats_file={output} -f null -
</code></pre><p>This command produces a pretty long file where we can see the comparison between each frame of both videos, giving us a general value and partial values for each of the three components of YUV (maybe there wasn&rsquo;t a need to convert the videos before using VMAF after all &#x1f61b;).</p>
<p>Its structure is similar to this:</p>
<figure class="width50perc">
  <img loading="lazy" alt="SSIM" height="268" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract_hu_f368d60c1c880a89.png 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract_hu_60d03f9dc97cc581.png 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract_hu_4d9bd3f8a23d79dc.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/ffmpeg_ssim_extract.png"></figure><p>Doing a light statistic analysis of the results for both 24 and 25 as <code>-global_quality</code> parameters when issuing the FFmpeg command to reduce quality, 24 has a slight advantage, a margin that seems acceptable to me in favor of 25, which made me double down on the decision to pick 25 as the final number.</p>
<p>Next, some numbers courtesy of <a href="https://www.calculatorsoup.com/" target="_blank">CalculatorSoup</a> (looks like the free version of Wolfram Alpha couldn&rsquo;t handle it) about how similar the processed videos are to the original one in average as well as the standard deviation in both cases. The higher the average and the lower the standard deviation (σ), the better.</p>
<figure class="half">
  <img loading="lazy" alt="Quality 24" height="987" title="Quality 24" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_c3993c9b78c8cf92.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_2a0e4c536d30622a.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_d3b374cd865eb15f.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_f2df079f06733943.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q24_hu_f2df079f06733943.webp"><figcaption>Quality 24</figcaption></figure><figure class="half">
  <img loading="lazy" alt="Quality 25" height="987" title="Quality 25" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_71fd49063f1d31f0.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_2a15bc8bd491f205.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_7930fece935b49a2.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_ef5cc8d23fa1ab37.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/mean_sd_q25_hu_ef5cc8d23fa1ab37.webp"><figcaption>Quality 25</figcaption></figure><p>Keeping in mind that, in general, an MSSIM value (mean SSIM) greater than or eaqual to 0.99 means two images are <a href="https://www.researchgate.net/publication/342435717_Understanding_SSIM" target="_blank"><strong>indistinguishable</strong></a> (Sec. 4.1 Luminance), the results appear to be satisfactory.</p>
<figure class="width60perc">
  <img loading="lazy" alt="&ldquo;68-95-99.7 rule formulas&rdquo;" height="173" title="[68-95-99.7](https://es.wikipedia.org/wiki/Regla_68-95-99.7) rule formulas" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_fa1dd6325e97f968.webp 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_5a67b3a579bf21d3.webp 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_326aed80492e15ff.webp 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_7c9b1faeddf98c5c.webp"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/68-95-99-rule_hu_7c9b1faeddf98c5c.webp"><figcaption><a href="https://es.wikipedia.org/wiki/Regla_68-95-99.7" target="_blank">68-95-99.7</a> rule formulas</figcaption></figure><p>On the one hand, with quality 24, the higher of the two, we have a mean of 0.9862 with an σ of 0.0084. This means that, following the formula above, ~95% of the time both videos have a similarity level between 96.94% and 100% and that more than 99% of the time that level is between 96.1% and 100%. Furthermore, they are in average 98.62% similar. Pretty nice.</p>
<p>On the other hand, with quality 25, the mean is 0.9844 with an σ of 0.0109, which means that ~95% of the time both videos have a similarity level between 96.26% and 100% and that more than 99% of the time that level is between 95.17% and 100%. Besides, they are in average 98.44% similar. Not too shabby.</p>
<p>That difference sounds like a fair price to pay for saving some bytes here and there.</p>
<p>Was all this numeric fest unnecessary? Most likely, but I really hoped I can get some numbers supporting or disproving my decision; happily, something closer to the former happened.</p>
<h2 id="gps">GPS</h2>
<p>I realized the lack of geolocation info on the videos metadata when I added them to Immich, where a little map indicates where they were taken if said info is available. Good thing that it offers a built-in metadata editor, albeit basic, which I used to save the GPS coordinates of the point where each recording started; I had to watch them all and accurately locate them with the help of Google Maps.</p>
<p>Once geolocated, Immich created an external Exif file for each video, all of them with the same name but different extension: <code>xmp</code>.</p>
<p>So far so good, despite the Exif metadata being external in this case, having the same file name helps with some video players recognizing them automatically so it shouldn&rsquo;t be a problem; however, I wanted all the data to be embedded in the videos themselves, that&rsquo;s better and easier to manage.</p>
<p>So I used <a href="https://exiftool.org/" target="_blank">ExifTool</a> to solve that:</p>
<pre tabindex="0"><code>exiftool -gpsposition -c &#34;%.7f&#34; {input}
</code></pre><ul>
<li><code>-gpsposition -c &quot;%.7f&quot;</code> extracts the GPS data in the &ldquo;%.7f&rdquo; format, which basically means an integer follow by 7 decimals (e.g. -31.8267316)</li>
</ul>
<pre tabindex="0"><code>exiftool -TagsFromFile {input} -All:All -XMP -ExtractEmbedded -overwrite_original {output}
</code></pre><ul>
<li><code>-TagsFromFile</code> takes the Exif tags stored in the files previously created by Immich</li>
<li><code>-All:All</code> takes into consideration all the tags it possibly can</li>
<li><code>-XMP</code> same as above but with XMP tags, which tend to be non-standard metadata defined by the camera or editing software vendor</li>
</ul>
<p>There was a tiny little additional problem, unfortunately: <a href="https://forum.dji.com/forum.php?mod=redirect&amp;goto=findpost&amp;ptid=304071&amp;pid=3209186" target="_blank">apparently</a>, DJI cameras need to be connected to a smartphone or tablet via Bluetooth after having been disconnected for <em>a long period of time</em> in order to get the right date and time. This caused the timestamps in my videos to be off by around 90 minutes. I don&rsquo;t know the exact amount of time because I didn&rsquo;t have a solid reference besides a little video I took with my phone, so I estimate that they still might be off by about 3 minutes tops give or take. Sounds okay.</p>
<p>Naturally, I had to change the file timestamps but also the ones found in the Exif tags, given that I only applied the change to the files some video players would still be able to see the error because they sometimes prefer the metadata and not the what the filesystem says. Having everything correctly time-coded also helps with organization since they use time to build timelines.</p>
<p>Changing the files timestamps was easy. In Windows (PowerShell) we could do something like:</p>
<div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>(<span style="color:#d0a8ff">Get-Item</span> {input}).LastWriteTime = {new_date}
</span></span></code></pre></div><p>Don&rsquo;t forget that <code>new_date</code> must have the correct format, in this case I opted for <code>MM/dd/yyyy HH:mm</code> because that worked on my Windows install.</p>
<p style="font-size:13px;">(we&rsquo;re close, promise)</p>
<p>Buuuut, and this is a big but, the intention was to subtract 90 minutes from the creation/modification datetime present on the files, so I needed to extract first. Good thing is that it&rsquo;s not a destructive operation, so I left it for later once FFmpeg and ExifTool had done their job.</p>
<p>One little thing before having the complete script: when it comes to exif tags, at lest here in this case, the timezone was always set to UTC and the format <code>yyyy-MM-dd HH:mm:ss</code>, so that had to be taken into account, although not for changing the timestamps Windows sees.</p>
<p>Well, with all that in mind and a couple of extra details, the script would end up like this (now with a bash version for completeness):</p>
<div class="tab-container" id="84f13ab2">
    <div class="tab-labels">
	    <button class="tab-label active" data-lang="powershell">powershell</button>
	    <button class="tab-label " data-lang="bash">bash</button>
    </div>
    <div class="tab-contents">
            <div class="tab-content content-powershell" style="display:block;">
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span><span style="color:#41a1c0">$originals_path</span> = {originals_path}
</span></span><span style="display:flex;"><span><span style="color:#41a1c0">$processed_path</span> = {processed_path}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#d0a8ff">Get-ChildItem</span> <span style="color:#41a1c0">$originals_path</span> -Filter *.mp4 | <span style="color:#d0a8ff">Sort-Object</span> -Property Name | <span style="color:#d0a8ff">Foreach-Object</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Original file: </span>$(<span style="color:#41a1c0">$_</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Reducing video bitrate&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    ffmpeg -hide_banner -loglevel error -hwaccel qsv -c:v h264_qsv -i <span style="color:#41a1c0">$_</span>.Fullname -c:v h264_qsv -global_quality <span style="color:#d0bf69">25</span> {original_file}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># qs stands for Quick Sync</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$qs_file</span> = (<span style="color:#d0a8ff">Get-Item</span> <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$processed_path</span><span style="color:#fc6a5d">\</span>$(<span style="color:#41a1c0">$_</span>.Basename)<span style="color:#fc6a5d">.mp4&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Bitrate-reduced file: </span>$(<span style="color:#41a1c0">$qs_file</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Get the current creation datetime (wrong creation date)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$wrongCreationDateTime</span> = <span style="color:#fc6a5d">&#34;{0:yyyy-MM-dd HH:mm}&#34;</span> -f $(<span style="color:#41a1c0">$_</span>.CreationTime)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Get the right creation datetime but in Windows format</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span> = [datetime]::parseexact(<span style="color:#41a1c0">$wrongCreationDateTime</span>, <span style="color:#fc6a5d">&#39;yyyy-MM-dd HH:mm&#39;</span>, $null).AddMinutes(<span style="color:#d0bf69">-90</span>).toString(<span style="color:#fc6a5d">&#39;MM/dd/yyyy HH:mm&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Get the right creation datetime but in Exif format (UTC with seconds)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span> = [datetime]::parseexact(<span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span>, <span style="color:#fc6a5d">&#39;MM/dd/yyyy HH:mm&#39;</span>, $null).AddMinutes(<span style="color:#d0bf69">300</span>).toString(<span style="color:#fc6a5d">&#39;yyyy:MM:dd HH:mm:00&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Original file: </span>$(<span style="color:#41a1c0">$_</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;QS file: </span>$(<span style="color:#41a1c0">$qs_file</span>.Fullname)<span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Wrong creation datetime: </span><span style="color:#41a1c0">$wrongCreationDateTime</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Right creation datetime in Windows format: </span><span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;Right datetime in ExifTool UTC format with seconds: </span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;ExifTool copying all tags from original...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    exiftool -TagsFromFile <span style="color:#41a1c0">$_</span>.Fullname -All:All -XMP -ExtractEmbedded -overwrite_original <span style="color:#41a1c0">$qs_file</span>.Fullname
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#6c7986"># Extracting coordinates info from XMP files</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$coords</span> = exiftool -gpsposition -c <span style="color:#fc6a5d">&#34;%.7f&#34;</span> <span style="color:#fc6a5d">&#34;</span>$(<span style="color:#41a1c0">$qs_file</span>.Fullname)<span style="color:#fc6a5d">.xmp&#34;</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#41a1c0">$coords</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;ExifTool setting geoposition and updated timestaps...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    exiftool <span style="color:#fc6a5d">&#34;-CreateDate=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">&#34;-ModifyDate=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">&#34;-Track*Date=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">&#34;-Media*Date=</span><span style="color:#41a1c0">$rightDateTimeExifUTCFormattedWithSeconds</span><span style="color:#fc6a5d">&#34;</span> -gpsposition=<span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$coords</span><span style="color:#fc6a5d">&#34;</span> -overwrite_original <span style="color:#41a1c0">$qs_file</span>.Fullname
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    (<span style="color:#d0a8ff">Get-Item</span> <span style="color:#41a1c0">$qs_file</span>).LastWriteTime = <span style="color:#41a1c0">$rightDateTimeWindowsFormatted</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#fc6a5d">&#34;=================================================================================================&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span></code></pre></div>
            </div>
            <div class="tab-content content-bash" >
		    <div class="highlight"><pre tabindex="0" style="color:#fff;background-color:#1f1f24;-moz-tab-size:2;-o-tab-size:2;tab-size:2;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#fd8f3f">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#fd8f3f"></span>
</span></span><span style="display:flex;"><span><span style="color:#41a1c0">$originals_path</span> = {originals_path}
</span></span><span style="display:flex;"><span><span style="color:#41a1c0">$processed_path</span> = {processed_path}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">for</span> filename in <span style="color:#fc6a5d">`</span><span style="color:#41a1c0">$originals_path</span>/*.mp4<span style="color:#fc6a5d">`</span>; <span style="color:#fc5fa3">do</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Original file: </span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Reducing video bitrate&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$qs_filename</span> =  $<span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$processed_path</span><span style="color:#fc6a5d">/</span><span style="color:#fc5fa3">$(</span>basename <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span><span style="color:#fc5fa3">)</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  ffmpeg -hide_banner -loglevel error -hwaccel qsv -c:v h264_qsv -i <span style="color:#41a1c0">$filename</span> -c:v h264_qsv -global_quality <span style="color:#d0bf69">25</span> <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Bitrate-reduced file: </span><span style="color:#41a1c0">$qs_filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$wrong_datetime</span> = <span style="color:#fc5fa3">$(</span>date -r <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span> +<span style="color:#fc6a5d">&#34;%F %H:%M&#34;</span><span style="color:#fc5fa3">)</span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$right_datetime</span> = <span style="color:#fc5fa3">$(</span>date -d <span style="color:#fc6a5d">&#34;&#34;</span><span style="color:#41a1c0">$wrong_datetime</span><span style="color:#fc6a5d">&#34;  90 minutes&#34;</span> +<span style="color:#fc6a5d">&#34;%F %H:%M&#34;</span><span style="color:#fc5fa3">)</span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span> = <span style="color:#fc5fa3">$(</span>date -d <span style="color:#fc6a5d">&#34;&#34;</span><span style="color:#41a1c0">$right_datetime</span><span style="color:#fc6a5d">&#34;  300 minutes&#34;</span> +<span style="color:#fc6a5d">&#34;%F %H:%M:%S&#34;</span><span style="color:#fc5fa3">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Original file: </span><span style="color:#41a1c0">$filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;QS file: </span><span style="color:#41a1c0">$qs_filename</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Wrong creation datetime: </span><span style="color:#41a1c0">$wrong_datetime</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Right datetime: </span><span style="color:#41a1c0">$right_datetime</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;Right datetime in ExifTool UTC format with seconds: </span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;ExifTool copying all tags from original...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  exiftool -TagsFromFile <span style="color:#41a1c0">$filename</span> -All:All -XMP -ExtractEmbedded -overwrite_original <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#41a1c0">$coords</span> = exiftool -gpsposition -c <span style="color:#fc6a5d">&#34;%.7f&#34;</span> <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$qs_filename</span><span style="color:#fc6a5d">.xmp&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#41a1c0">$coords</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;ExifTool setting geoposition and updated timestaps...&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  exiftool <span style="color:#fc6a5d">&#34;-CreateDate=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          <span style="color:#fc6a5d">&#34;-ModifyDate=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          <span style="color:#fc6a5d">&#34;-Track*Date=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          <span style="color:#fc6a5d">&#34;-Media*Date=</span><span style="color:#41a1c0">$right_datetime_exif_utc_w_seconds</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#fc6a5d">\
</span></span></span><span style="display:flex;"><span><span style="color:#fc6a5d"></span>          -gpsposition=<span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$coords</span><span style="color:#fc6a5d">&#34;</span> -overwrite_original <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  touch -d <span style="color:#fc6a5d">&#34;</span><span style="color:#41a1c0">$right_datetime</span><span style="color:#fc6a5d">&#34;</span> <span style="color:#41a1c0">$qs_filename</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span>
</span></span><span style="display:flex;"><span>  <span style="color:#d0a8ff">echo</span> <span style="color:#fc6a5d">&#34;=================================================================================================&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#fc5fa3">done</span>
</span></span></code></pre></div>
            </div>
    </div>
    <button class="toggle-btn" onclick="toggleCode(this)">See more</button>
</div>

<script>
document.addEventListener("DOMContentLoaded", function () {
  const container = document.getElementById("84f13ab2");
  if (!container) return;

  const buttons = container.querySelectorAll(".tab-label");
  const contents = container.querySelectorAll(".tab-content");

  buttons.forEach(button => {
    button.addEventListener("click", () => {
      const lang = button.getAttribute("data-lang");

      contents.forEach(content => content.style.display = "none");
      buttons.forEach(btn => btn.classList.remove("active"));

      container.querySelector(`.content-${lang}`).style.display = "block";
      button.classList.add("active");
    });
  });
});

function toggleCode(btn) {
  const container = btn.previousElementSibling;

  if (container.classList.contains("expanded")) {
    container.classList.remove("expanded");
    btn.textContent = "See more";
  } else {
    container.classList.add("expanded");
    btn.textContent = "See less";
  }
}
</script>

<h2 id="final-thoughts">Final thoughts</h2>
<p>All of this left me with much lighter videos, properly tagged regarding where they were captured and with files with the right timestamps. We went from an outlook described by the cover image to this:</p>
<figure class="width85perc">
  <img loading="lazy" alt="List of processed files" height="117" title="List of processed files" width="720"srcset="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after_hu_91f2537aecd47bc7.png 480w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after_hu_ff3c0c9af7619f2e.png 720w ,https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after_hu_fa3e5d540706d009.png 960w ,"
      sizes="(max-width: 480px) 360px, (max-width: 960px) 720px, 960px" src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after.png"
      data-zoom-src="https://cdn.inobtenio.com/img/posts/dji-bitrate-gps/DJI_file_list_linux_after.png"><figcaption>List of processed files</figcaption></figure><p>This way is easier to edit, transmit or even just play them.</p>
<p>I know the more information there is in a video, the more flexibility it will offer when being processed; after all, computers still can&rsquo;t programagically invent information out of nothing, right?
That&rsquo;s why I may end up using the actual original clips to merge them and obtain a larger video on which only then apply all the processes learned here so that a reasonably-sized video is generated.</p>
<p>What&rsquo;s important is that if we gained knowledge we didn&rsquo;t waste time, although the time invested in manually locating every piece of video will hardly be recovered, unless I can somehow extrapolate the GPS data and generate a data-based route which would later let me create a real-time map animation to overlay on top of the video or something like that.</p>
<p>Who knows. &#x1f609;</p>
<h3 id="why-not-use-premiere-resolve-or-even-movie-maker">Why not use Premiere, Resolve or even Movie Maker?</h3>
<p>Well, I think using FFmpeg is not only more versatile but more fun when dealing with multiple files and when not knowing exactly what to do, and I&rsquo;m all about having fun while solving problems up to a certain point.<br>
Of course, it&rsquo;s also more difficult than using Resolve, which I typically use, but I sometimes feel more at home in the terminal and I was going to use it anyways because of ExifTool.<br>
Besides, we use FFmpeg in this house, it&rsquo;s free and open source and lets you fully use your GPU for encoding if you want. Besides, it has a pretty big community.</p>
<p>I&rsquo;ll use Resolve for color correction, I don&rsquo;t think I want to deal with FFmpeg when using LUTs. &#x1f614;</p>
<h3 id="for-some-day-hopefully">For some day&hellip; hopefully</h3>
<ul>
<li>Improve the code, it can surely be improved, especially if we can prevent ExifTool from being called twice, given it&rsquo;s a destructive operation and it consumes quite some time. Also can be a bit more elegant</li>
<li>Color correct the video</li>
<li>Remember to connect the cameras to my phone the next time I want to record something</li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
