<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Grant Ammons</title>
    <description>The latest articles on Forem by Grant Ammons (@gammons).</description>
    <link>https://forem.com/gammons</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F89004%2Fc9268a8e-7940-4e2c-a12e-4899451fcb9a.jpeg</url>
      <title>Forem: Grant Ammons</title>
      <link>https://forem.com/gammons</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/gammons"/>
    <language>en</language>
    <item>
      <title>I built a Slack TUI in a week</title>
      <dc:creator>Grant Ammons</dc:creator>
      <pubDate>Sun, 17 May 2026 11:26:24 +0000</pubDate>
      <link>https://forem.com/gammons/i-built-a-slack-tui-in-a-week-3cpn</link>
      <guid>https://forem.com/gammons/i-built-a-slack-tui-in-a-week-3cpn</guid>
      <description>&lt;p&gt;Slack was having a bad day on my machine. Typing &lt;code&gt;:wave:&lt;/code&gt; was taking two seconds to autocomplete. Two seconds to autocomplete an emoji! The fan was spinning. The whole app felt sluggish.&lt;/p&gt;

&lt;p&gt;I have Slack running pretty much nonstop. Most people do. And the whole time I was watching that emoji dropdown crawl I kept thinking.. man, this is just text.&lt;/p&gt;

&lt;p&gt;Terminals are great at text. The tools I rely on every day - &lt;code&gt;vim&lt;/code&gt;, &lt;code&gt;btop&lt;/code&gt;, &lt;code&gt;tig&lt;/code&gt;, &lt;code&gt;k9s&lt;/code&gt; - are a joy to use. They are incredibly fast and responsive. They feel alive. Meanwhile the app that I have to live in all day, where all my conversations happen, is the slowest thing on my machine. It is also freaking enormous, a bundled Electron app that takes up a ton of memory and disk.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Slack is just text. Terminals are great at text. So why am I running 1.5GB of Chromium to read it?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where are all the TUI Slack clients?
&lt;/h2&gt;

&lt;p&gt;I figured someone would have built this already. Slack is twelve years old. The TUI ecosystem has been mature for years. The intersection seemed obvious.&lt;/p&gt;

&lt;p&gt;But it didn't exist. Not really. There were a few projects, mostly abandoned, mostly bare-bones. Nothing daily-driveable. Nothing that felt like a real Slack client. The gap was weird.&lt;/p&gt;

&lt;p&gt;The honest answer is that polishing a TUI Slack client probably wasn't worth one person's year. Slack works, more or less, for most people. The desktop app is bloated but functional. The cost of building a real replacement was high and the audience was small. So nobody did it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The unbuilt apps in the world aren't blocked by demand anymore. They're blocked by whether someone cares enough to scratch the itch.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What if I built it?
&lt;/h2&gt;

&lt;p&gt;So I decided to try. Were TUI libraries up to a good Slack experience in 2026? Many unknowns: Real-time messaging. Threads. Multi-workspace. Polish that didn't feel like a 1995 IRC client. Images and avatars, which I knew were possible in terminals but didn't know how they worked, or whether they'd hold up in a Slack TUI.&lt;/p&gt;

&lt;p&gt;The answer, working through them one at a time, seemed to be yes. The TUI ecosystem is in a really good place. &lt;a href="https://opencode.ai/" rel="noopener noreferrer"&gt;Opencode&lt;/a&gt;, the AI coding TUI, was already proof. I use it daily. Modals, spinners, motion. Well put together.&lt;/p&gt;

&lt;p&gt;Early on while I was deciding the stack, I really wanted to land on one single, portable binary. I didn't want to ask users to install Node or Python or Ruby or any runtime. I wanted a single download that just worked. That meant Go.&lt;/p&gt;

&lt;p&gt;The next question was what TUI libraries are available for Go. I discovered &lt;a href="https://charm.land/" rel="noopener noreferrer"&gt;charm.land&lt;/a&gt;. What a joy this suite of librares is.  Bubbletea handles architecture, lipgloss handles styling. The V2 release also handled layouts, which was the key to making it feel polished without a ton of custom code.&lt;/p&gt;

&lt;p&gt;Images and avatars are a core part of Slack's UX.  So I knew I needed to figure out something there.  Kitty's graphics protocol does what it says on the tin, and my tests showed it was viable for my use case.&lt;/p&gt;

&lt;p&gt;All that being done, I figured I'd let agents do most of the typing and see how fast I could go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;I started with the part I was most worried about: images.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://sw.kovidgoyal.net/kitty/graphics-protocol/" rel="noopener noreferrer"&gt;Kitty graphics protocol&lt;/a&gt; gives the terminal a way to render true-pixel images, not just half-blocks or sixel. In a Kitty or Ghostty terminal, &lt;code&gt;slk&lt;/code&gt; shows real Slack avatars and images. They look like avatars, not blocky ASCII approximations. This was the moment &lt;code&gt;slk&lt;/code&gt; stopped feeling like a half-toy. The screenshots in this post look the way they do because the avatars and images actually render in the terminal. If you try &lt;code&gt;slk&lt;/code&gt; in a non-Kitty terminal, you'll get ASCII approximations instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft85jnlrmokx0kb7i7lij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft85jnlrmokx0kb7i7lij.png" alt="slk main view with kitty-graphics avatars" width="800" height="619"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The main slk view. Avatars are real pixel data rendered via the Kitty graphics protocol, not ASCII approximations.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The rest of the work was less surprising and more satisfying. Real-time messages, edits, deletes, reactions, and typing indicators over a WebSocket. Multiple workspaces stay connected in parallel with live unread badges in the left rail. Press 1 through 9 to jump between them. Vim-style modal editing. Fuzzy channel finder. Threads in a side panel, plus a workspace-wide threads view because I always forget which channel a thread came from.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F75kox0g2eupihdi9fo28.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F75kox0g2eupihdi9fo28.png" alt="threads side panel in slk" width="800" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The threads side panel. The workspace-wide threads view sits one keypress away.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;35 themes ship with it (including &lt;a href="https://blog.codinghorror.com/a-tribute-to-the-windows-31-hot-dog-stand-color-scheme/" rel="noopener noreferrer"&gt;Hot Dog Stand&lt;/a&gt;, haha.  I'm old.), with a live switcher. Smart paste handles clipboard images and file paths and text in one go, captions included. Slack-native sidebar sections are kept live, or you can configure your own with globs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdz3frf7qp6qp2q7f45il.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdz3frf7qp6qp2q7f45il.png" alt="slk theme switcher mid-switch" width="800" height="614"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mid-switch in the live theme switcher. 35 of these ship with slk.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The binary is 24 megabytes. A live multi-workspace session uses a fraction of the RAM. The official Slack app on the same setup uses five hundred megabytes to a gigabyte and a half. Same conversations. Same productivity.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This isn't a toy. It's my daily driver on Linux and Mac.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  One week of intense vibed-out building
&lt;/h2&gt;

&lt;p&gt;The spark hit hard.&lt;/p&gt;

&lt;p&gt;I had been irritated by Slack for years, the kind of irritation that builds when a piece of software is always there but never quite right. Once I started writing slk, I couldn't stop. I ravenously coded for a week. I shipped more commits in those seven days than I'd shipped in any week of my life.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkob1ry9qdo4yao57z6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkob1ry9qdo4yao57z6t.png" alt="GitHub contribution graph showing a week of intense commits" width="775" height="199"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One week of slk commits. More than I'd shipped in any week of my life, and I commit stuff near-daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The setup was four tmux windows, opencode in each one, agents running in parallel git worktrees. Most of the typing was theirs. The decisions were mine.&lt;/p&gt;

&lt;p&gt;The agents were great at the things I didn't want to spend a week on. Bubbletea boilerplate, lipgloss styling, parsing Slack's internal protocol, the theme system. The repetitive UI plumbing that holds the whole thing together.&lt;/p&gt;

&lt;p&gt;I drove the things that mattered, starting with the architecture.  After that was settled, it was a matter of implementing functionality on top. Then many &lt;strong&gt;many&lt;/strong&gt; rounds of small polish decisions that make a tool feel alive, not just functional.&lt;/p&gt;

&lt;p&gt;The reality is that building this way feels magical and is insanely addictive. The agents did not replace the builder's high of seeing something work. They cranked it up to borderline unhealthy levels. I was riding a wave of obsession for a week, and the agents were the jet fuel.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Most vibe-coded projects are toys because most people stop. slk is what happens when a builder gets obsessed and runs four agents in parallel for a week. The skill isn't writing code. It's communication, judgment, and being ready to ride the obsession when it shows up.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  ...but slk is not garbage
&lt;/h2&gt;

&lt;p&gt;The line on vibe-coded software is that it is garbage. Unreliable. Falls apart the second anyone pushes on it. I am living that in my day job right now. The people saying it are not wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;slk&lt;/code&gt; is not that. &lt;code&gt;slk&lt;/code&gt; works. The reason is not the agents. The reason is how I drove them.&lt;/p&gt;

&lt;p&gt;I challenged the agents deeply on architecture at the start.  I stressed unit tests and TDD for every change.  I stressed documentation and readability.  I actually read the code.  &lt;/p&gt;

&lt;p&gt;I insisted on agents generating code I would have strived to have written myself - well-documented, well-factored, easy to change, easy to extend. All those things that &lt;a href="https://x.com/unclebobmartin" rel="noopener noreferrer"&gt;Uncle Bob&lt;/a&gt; likes.  If the agent shipped code that didn't meet that bar, the code did not stay.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Most vibe-coded projects are garbage because most operators don't bring the discipline. The agent is the typist. You're still the architect.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Try it out
&lt;/h2&gt;

&lt;p&gt;I built &lt;code&gt;slk&lt;/code&gt; for me. That has not changed. If you live in the terminal and live in Slack, it might fit your life too. If it does, here you go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brew install gammons/tap/slk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/gammons/slk" rel="noopener noreferrer"&gt;source is on Github&lt;/a&gt;.  There's also &lt;a href="https://getslk.sh" rel="noopener noreferrer"&gt;a marketing page of sorts&lt;/a&gt;. The wiki has setup, keybindings, and configuration. Read the tradeoffs page first if you want to know what is and isn't there.  It's still not perfect - there are missing features and bugs. But it is a real Slack client that you can use today.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;slk&lt;/code&gt; is not feature-complete. Huddles, screen sharing, and Slack apps are not there. But it is my daily driver. PRs welcome. Bug reports more welcome. The people I most want to hear from are terminal nerds who try it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>vibecoding</category>
      <category>go</category>
      <category>terminal</category>
    </item>
    <item>
      <title>What I learned creating 12inch.reviews, a mashup of Spotify and Pitchfork</title>
      <dc:creator>Grant Ammons</dc:creator>
      <pubDate>Wed, 22 Jul 2020 14:56:57 +0000</pubDate>
      <link>https://forem.com/gammons/what-i-learned-creating-12inch-reviews-a-mashup-of-spotify-and-pitchfork-59oi</link>
      <guid>https://forem.com/gammons/what-i-learned-creating-12inch-reviews-a-mashup-of-spotify-and-pitchfork-59oi</guid>
      <description>&lt;p&gt;I'm a huge music nerd.  I've played in many bands in my teens 20s, and music is a big part of my life.  I'm also a big fan of &lt;a href="https://pitchfork.com/" rel="noopener noreferrer"&gt;Pitchfork&lt;/a&gt; music reviews.&lt;/p&gt;

&lt;p&gt;There was a mashup site I was using called &lt;a href="http://pitchify.com" rel="noopener noreferrer"&gt;Pitchify&lt;/a&gt;, which was no longer updating, and it eventually got taken down.  So I did what any engineer would do and I created my own mashup!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://12inch.reviews" rel="noopener noreferrer"&gt;12inch.reviews&lt;/a&gt; is a mashup of Pitchfork's &lt;a href="https://pitchfork.com/reviews/albums/" rel="noopener noreferrer"&gt;album reviews&lt;/a&gt; with Spotify's &lt;a href="https://developer.spotify.com/documentation/web-playback-sdk/quick-start/" rel="noopener noreferrer"&gt;web playback SDK&lt;/a&gt;.  It utilizes the browser's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API" rel="noopener noreferrer"&gt;IndexedDB API&lt;/a&gt; to allow for fast, responsive searching and sorting of 17k+ album reviews, and allows you to play the full album right in the browser!&lt;/p&gt;

&lt;p&gt;As with any side project, I had a few learning goals in mind that I wanted to bake in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Continue to invest in learning React, specifically &lt;a href="https://reactjs.org/docs/hooks-intro.html" rel="noopener noreferrer"&gt;React hooks&lt;/a&gt;, and progressive web apps.&lt;/li&gt;
&lt;li&gt;Learn &lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;tailwind.css&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;I wanted it to include &lt;em&gt;all&lt;/em&gt; of Pitchfork's reviews, and have them be easily searchable.  There are a lot of seminal albums that I just haven't had exposure to.  Being able to find them easily would be a requirement.&lt;/li&gt;
&lt;li&gt;I wanted to leverage different and interesting browser technologies to keep the main functionality of this site all on the frontend.&lt;/li&gt;
&lt;li&gt;I wanted it to be completely &lt;a href="https://github.com/gammons/12inch.reviews" rel="noopener noreferrer"&gt;open source&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pitchfork has over 20k reviews on their site, so being able to store that many records on the frontend, specifically in Javascript, would be a challenge.  Each browser has different &lt;a href="https://developers.google.com/web/tools/workbox/guides/storage-quota" rel="noopener noreferrer"&gt;storage quotas&lt;/a&gt; that aren't particularly well-documented.  So I needed to think about how to work around these quotas in a seamless and transparent way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend
&lt;/h2&gt;

&lt;p&gt;12inch.reviews uses a simple(ish) &lt;a href="https://github.com/gammons/12inch.reviews/blob/master/retriever/retrieve.rb" rel="noopener noreferrer"&gt;retriever script&lt;/a&gt; script that does the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;utilizes Pitchfork's &lt;a href="https://pitchfork.com/api/v2/search/?types=reviews" rel="noopener noreferrer"&gt;undocumented API&lt;/a&gt; to find new albums since the last time the script was run&lt;/li&gt;
&lt;li&gt;Attempts to find that album using Spotify's &lt;a href="https://developer.spotify.com/documentation/web-api/reference/search/search/" rel="noopener noreferrer"&gt;search API&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;If found, add that album to a simple &lt;a href="https://www.sqlite.org/index.html" rel="noopener noreferrer"&gt;SQLite&lt;/a&gt; DB&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is another backend function that will take the contents of the SQLite DB and to create a series of JSON files, which includes all the data the frontend needs.  The structure of each JSON album looks like so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13501&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"pitchfork_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5929e2d1eb335119a49ef060"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Out of Tune"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"artist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mojave 3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bnm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bnr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4AD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://pitchfork.com/reviews/albums/5376-out-of-tune/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Out of Tune is a Steve Martin album. Yes, I'll explain: Once upon a time, there was ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"genre"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"spotify_album_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2TLUvacBePI5753CqHPpxF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"spotify_artist_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4jSYHcSo85heWskYvAULio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.scdn.co/image/ab67616d0000b27360b1fa1c0a15bcb97f9544a2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1999-01-12 06:00:00 UTC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2019-10-25 12:33:03 UTC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;916120800&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the JSON files are created, they are uploaded to S3 for the frontend to use.  Each album entry has all the info needed in order for Spotify's web SDK to use them on the frontend, and to be searchable.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/gammons/12inch.reviews/blob/master/retriever/Rakefile" rel="noopener noreferrer"&gt;retriever Rakefile&lt;/a&gt; also has functions to backfill all albums (takes multiple hours!) and has some utility functions to be able to create a new SQLite DB and other functions to massage the data into the correct format.&lt;/p&gt;

&lt;p&gt;The main task, &lt;code&gt;refresh_and_upload&lt;/code&gt; runs hourly.  Currently it's running as a &lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/" rel="noopener noreferrer"&gt;Kubernetes CronJob&lt;/a&gt; on my homelab Kube cluster (I'll talk more about that in another post).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netlify Lambda functions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;12inch.reviews is hosted on &lt;a href="https://netlify.com" rel="noopener noreferrer"&gt;Netlify&lt;/a&gt;, mainly because Netlify is amazing.  It provides a seamless CI/CD pipeline, SSL, and &lt;a href="https://www.netlify.com/products/functions/" rel="noopener noreferrer"&gt;AWS Lambda-like functions&lt;/a&gt; - all for free.&lt;/p&gt;

&lt;p&gt;I use these functions to "log in" a user via Spotify Oauth.  I would have done this all on the frontend, except for the fact that Oauth requires a secret token and that can't be exposed on the frontend.  &lt;/p&gt;

&lt;p&gt;Since Spotify's access tokens are short-lived (1 hour max!) there is also a secondary function that will renew an access token seamlessly upon playback.&lt;/p&gt;

&lt;p&gt;Once an access token is obtained, state is stored via the browser's &lt;code&gt;LocalStorage&lt;/code&gt; API.&lt;/p&gt;

&lt;h3&gt;
  
  
  The frontend
&lt;/h3&gt;

&lt;p&gt;The frontend of 12inch.reviews is a relatively simple &lt;code&gt;create-react-app&lt;/code&gt; single-page app, that provides search and sort functionality, as well as the ability to play any album using Spotify's &lt;a href="https://developer.spotify.com/documentation/web-playback-sdk/quick-start/" rel="noopener noreferrer"&gt;web playback SDK&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting all the album data&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you visit 12inch.reviews the first time, it will show the albums from a small JSON file called &lt;code&gt;initial.json&lt;/code&gt;.  This file includes only the first 25 most recent albums, so we have something to paint on the screen.&lt;/p&gt;

&lt;p&gt;Then, the rest of the album data will be backfilled in via a series of &lt;code&gt;fetch&lt;/code&gt;es to retrieve all of the JSON files.  I decided to partition each JSON file with 1000 albums, so there are 17 files altogether.  Each album JSON file is at least 600k uncompressed, so there is probably room for more optimization here.&lt;/p&gt;

&lt;p&gt;After each JSON file is retrieved, they are stored in an IndexedDB on the frontend.  Subsequent visits to 12inch.reviews don't require the large JSON payload  - it will only load the delta payloads into the DB.  I'm taking advantage of the fact that these reviews are immutable - once they are written they will never change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Searching albums&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Although IndexedDB is great for &lt;em&gt;storing&lt;/em&gt; this data, there is currently no functionality to actually &lt;em&gt;query&lt;/em&gt; IndexedDB like a regular database.  So in order for 12inch.reviews to do searching and sorting, all of the data must be loaded into a &lt;a href="https://github.com/gammons/12inch.reviews/blob/master/src/app.js#L53" rel="noopener noreferrer"&gt;simple javascript array&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Playing albums&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I utilized Spotify's &lt;a href="https://developer.spotify.com/documentation/web-playback-sdk/quick-start/" rel="noopener noreferrer"&gt;Web playback SDK&lt;/a&gt; to do the actual playing.  It provides a series of hooks to use in order to initialize the player, and to do the actual playing.&lt;/p&gt;

&lt;p&gt;I wrapped the actual playing into a &lt;a href="https://github.com/gammons/12inch.reviews/blob/master/src/models/spotifyPlayer.js" rel="noopener noreferrer"&gt;simple class&lt;/a&gt; that ensures a refreshed access token is always provided.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/gammons/12inch.reviews/blob/master/src/components/player.js" rel="noopener noreferrer"&gt;player component&lt;/a&gt; is one of the most complex react components in the app.  Although the web playback SDK has its own state, I had to essentially "sync up" the player component's state with the SDK's state.  As with any type of synchronization, there are &lt;em&gt;probably&lt;/em&gt; bugs keeping these 2 things in sync with each other.&lt;/p&gt;

&lt;p&gt;The progress bar is clickable and utilizes some simple CSS animations to look and feel like a regular music progress bar.  &lt;/p&gt;

&lt;p&gt;IANA designer, but I was heavily influenced by Tesla's UX when designing the player component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Criticism of Spotify's Web Playback SDK
&lt;/h3&gt;

&lt;p&gt;My experience with Spotify's web playback SDK has been sub-par.  The SDK does not have a &lt;code&gt;package.json&lt;/code&gt; file, and therefore is not in the npm universe.  There isn't an easy way to hook the SDK into a React or Vue app.  This required a lot of &lt;a href="https://github.com/gammons/12inch.reviews/blob/master/src/components/player.js#L52-L58" rel="noopener noreferrer"&gt;manual syncing&lt;/a&gt; code that is probably hiding bugs.&lt;/p&gt;

&lt;p&gt;They have a &lt;a href="https://github.com/spotify/web-playback-sdk" rel="noopener noreferrer"&gt;public issue tracker&lt;/a&gt; on Github, but many of the issues don't have answered questions.&lt;/p&gt;

&lt;p&gt;It's hard for me to understand who the target audience was for this SDK.  I think it would benefit greatly from being open source and part of the NPM ecosystem.  This would allow others to create wrappers for popular frameworks, which would allow me to remove my terrible syncing code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons learned + planned optimizations
&lt;/h3&gt;

&lt;p&gt;This was a really fun project to work on.  It took me about 6 weeks of coding, utilizing my "side project hours" (roughly 5:30am - 7am on weekdays).&lt;/p&gt;

&lt;p&gt;I learned a bunch of things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tailwind.css&lt;/li&gt;
&lt;li&gt;IndexedDB&lt;/li&gt;
&lt;li&gt;Netlify Lambda Functions&lt;/li&gt;
&lt;li&gt;Spotify's APIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;12inch.reviews is a toy.  It has &lt;em&gt;enormous&lt;/em&gt; shortcomings, mainly the fact that it needs to backfill nearly 20Mb of album review data in order to work well.  This is insanely inefficient and wouldn't be appropriate for anything other than a personal side project site coded for educational purposes.&lt;/p&gt;

&lt;p&gt;Other shortcomings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I don't feel like I leaned into Flow types as much as I could.&lt;/li&gt;
&lt;li&gt;Overall performance is still not great, as measured by Chromes Dev Tools.&lt;/li&gt;
&lt;li&gt;I should utilize a service worker to populate the indexeddb.&lt;/li&gt;
&lt;li&gt;Could create a GraphQL backend to do the data serving, to alleviate the need to bring all 20MB of data to the frontend.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sideprojects</category>
      <category>netlify</category>
      <category>react</category>
      <category>spotify</category>
    </item>
  </channel>
</rss>
