Struggling with unorganized files is a data hoarder's worst nightmare. I had thousands of mp3s (totally legally downloaded ripped) lying around that I had collected since I was in 7th grade. Some had wrong/non-sanitized names, some had random images for album cover. Before long, every music player I used looked like it was having a mental breakdown. I finally decided it was time to put an end to the chaos.
Since I hated the bloated GUI tag editors which depended on 27 other libraries to be installed, I decided to create my own application. This way I was able to fix all my mp3 files in a matter of few weeks, a task which could have been done manually in some days! This is what it looks like-
Core Features
- Edit mp3 id3v2 tags
- Fetch lyrics & artwork from various online sources
- Batch operations over directories (recursive + pattern match)
- Integrated TUI (file browsing, preview, editing) or pure CLI usage
Usage
Some quick usage examples:
-
Show tags
tagTonic show "song.mp3" # → File: ... # → Title: ... # → Artist: ... # → Album: ... # ... -
Fetch lyrics & artwork automatically
tagTonic fetch "song.mp3" --lyrics --force tagTonic fetch "song.mp3" --artwork --force -
Edit tags manually
tagTonic edit "song.mp3" --title "New Title" --artist "Artist Name" -
Batch process complete directories
tagTonic batch --dir ~/Music --artwork --workers 5 --force -
Launch interactive TUI
tagTonic tui
For full usage, check out the docs
Starting Out
I just wanted a simple cli app for myself that would make my life easier. I had been learning golang at the time and loved its simplicity and concurrency model. Handling multiple APIs in parallel was clean, easy and lightweight. So, I decided to go with go and made a rough list of features I personally wanted like showing tags, manually editing tags and fetching lyrics & artwork and batch processing.
Then, I came across rmpc which is a terminal based frontend for mpd (I had been using ncmpcpp before which did not have artwork support). Rmpc had image rendering support which piqued my interest and I immediately decided to turn my simple cli app into a full-fledged tui app with an album art renderer.
CLI
Building CLI was pretty trivial thanks to the aforementioned ease of concurrency patterns go provides.
The CLI exposes four primary commands:
- show - inspect metadata without modifying files
- edit - manually edit tags
- fetch - automatically fetch lyrics & artwork (from multiple sources in parallel)
- batch - process directories (with concurrency)
1. Show and Edit Tags
For decoding and encoding mp3 tags, I used n10v/id3v2 library. It is well documented and simple to use. You simply open and parse the mp3. Like this:
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer tag.Close()
Then, just read or edit any frames or fields you require. For instance, here is how I update the artwork. (Note: te is a pointer to tagEditor struct which implements tagEditor interface)
if updates.ClearArtwork || len(updates.Artwork) > 0 {
tag.DeleteFrames("APIC")
if len(updates.Artwork) > 0 {
if err := te.ValidateArtwork(updates.Artwork); err != nil {
return fmt.Errorf("invalid artwork: %w", err)
}
resizedArtwork, err := te.ResizeArtwork(updates.Artwork, 500, 500)
if err != nil {
logrus.Warnf("Failed to resize artwork: %v", err)
resizedArtwork = updates.Artwork
}
pictureFrame := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: getMIMEType(resizedArtwork),
PictureType: id3v2.PTFrontCover,
Description: "Cover Art",
Picture: resizedArtwork,
}
tag.AddAttachedPicture(pictureFrame)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save tags: %w", err)
}
2. Fetching Data
- Artwork Fetcher: Uses 3 different APIs to get the artwork. The search term consists of the artist name and the album name. They are first sanitized using regex and common string operations.
-
Lyrics Fetcher: Tries multiple search variants against multiple sources
- First, we use the file’s metadata to pinpoint which song it is, and then fetch the matching lyrics.
- To find the best match for the song, a similarity score is calculated.
- We start all fetchers in parallel. Collect their outputs using a channel and return as soon as any fetcher succeeds. Cancel everything after 30 seconds. (Note:
sourcesis a slice of functions)
func (lf *lyricsFetcher) fetchConcurrently(title, artist string, sources []func(string, string) (string, error)) string { type result struct { lyrics string err error source string } results := make(chan result, len(sources)) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() sourceNames := []string{"Genius", "AZLyrics", "Lyrics.ovh", "ChartLyrics"} for i, source := range sources { sourceName := sourceNames[i] go func(src func(string, string) (string, error), name string) { lyrics, err := src(title, artist) select { case results <- result{lyrics: lyrics, err: err, source: name}: case <-ctx.Done(): } }(source, sourceName) } for i := 0; i < len(sources); i++ { select { case res := <-results: if res.err == nil && res.lyrics != "" { return res.lyrics } case <-ctx.Done(): return "" } } return "" }
3. Batch Operations
Batch mode uses a simple worker-pool to process large directories efficiently. A bounded queue feeds file paths to workers, each performing tag reads, parallel artwork/lyrics fetches, and tag updates.
-
Creating workers:
jobs := make(chan FileJob, len(files)) results := make(chan FileResult, len(files)) for i := 0; i < numWorkers; i++ { wg.Add(1) go worker(ctx, jobs, results, &wg, cfg) } -
Producer → workers → aggregator:
go func() { defer close(jobs) for _, file := range files { jobs <- FileJob{filepath: file} } }() -
Each worker fetches lyrics and artwork concurrently:
if batchLyrics { wg.Add(1) go func() { defer wg.Done() lyrics, err = lyricsFetcher.Fetch(title, artist) }() }
BubbleTea
Now for the real challenge, I had to create a TUI that catches the eyes & is easy to use. Some core features I had in mind were: file browser (yazi style), preview tags, edit metadata, and view artwork all in one place.
Charm is doing incredible work for the go ecosystem and BubbleTea is an outstanding TUI framework which strikes great balance between performance and looks. Admittedly, its learning curve is a little steep.
1. Concurrency made easy
The biggest strength of BubbleTea, for me, is how it makes concurrency predictable.
- Workers run in the background
- Messages flow back into Update
- No manual mutex-ing
- UI remains responsive even while fetching lyrics/artwork
- Works in the alternate screen buffer without flickering
- Supports dynamic updates (like artwork loading asynchronously)
Instead of goroutines ever touching shared state, everything returns a tea.Cmd that emits a typed message later.
Following pattern is something I often use for safe async work that times out gracefully:
// Gives work to a goroutine and returns a typed message later.
func fetchLyricsCmd(title, artist string, lf fetcher.LyricsFetcher) tea.Cmd {
return func() tea.Msg {
done := make(chan LyricsFetchedMsg, 1)
go func() {
lyrics, err := lf.Fetch(title, artist)
done <- LyricsFetchedMsg{Lyrics: lyrics, Error: err}
}()
select {
case msg := <-done:
return msg
case <-time.After(5 * time.Second):
return LyricsFetchedMsg{Lyrics: "", Error: fmt.Errorf("lyrics fetch timed out")}
}
}
}
// In Update: handle the message and update UI state only here.
case LyricsFetchedMsg:
if msg.Error != nil { a.setError("Failed to fetch lyrics", msg.Error.Error()) }
if msg.Lyrics != "" { a.mediaManager.GetLyricsPanel().SetLyrics(msg.Lyrics) }
2. Architecture (Model → Update → View)
BubbleTea insists on a simple Model–Update–View architecture. That basically means your state goes in the Model, all changes to that state happen through the Update function using messages, and the View simply displays whatever the current state is. The framework does the looping for you. We just have to describe how the app should look and react. In tagTonic,
-
Model: A single
Appmodel composes focused modules:FileBrowser,TagEditor,MediaManager,Layout,Cache, and theme. Each child manages its own state and helpers. -
Update: Pure message-driven state changes. All side effects are
tea.Cmdthat later send typed messages (no shared mutable state from goroutines). - View: A deterministic render that reads from the model only. Layout decisions are centralized and reused in rendering and placement.
type App struct {
fileBrowser *FileBrowser
tagEditor *TagEditor
mediaManager *MediaManager
layout *Layout
currentMode Mode
currentFile *FileEntry
statusMessage string
}
func (a *App) Init() tea.Cmd { return nil }
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.layout.Update(msg.Width, msg.Height)
return a, nil
case FileLoadedMsg:
a.tagEditor.LoadTags(msg.Tags)
// trigger artwork/lyrics renders via MediaManager
return a, a.mediaManager.LoadFileAsyncWithPosition(
msg.FilePath, msg.Tags.Lyrics, msg.Tags.Artwork,
a.layout.Calculate().ArtworkMaxWidth,
a.layout.Calculate().ArtworkMaxHeight,
0, 0,
)
}
return a, nil
}
func (a *App) View() string {
layout := a.layout.Calculate()
return a.renderMainView(layout)
}
3. Deciding Layout
- Adaptive columns: Files | Tags+Lyrics | Artwork. The right panel appears only when there’s enough width.
-
Single source of truth:
Layout.Calculate()returns sizes, flags, and positions the rest of the UI uses. -
Artwork placement: We compute the exact
(x, y)for kitty image placement from the current layout.
func (l *Layout) Calculate() AdaptiveLayout {
left := min(max(l.WindowWidth/3, 25), 45)
right := 0; aw := 0; ah := 0
if l.WindowWidth >= 100 {
right = min(50, l.WindowWidth/3)
aw = right - 6
ah = l.WindowHeight - 6
if aw < 20 || ah < 10 { right, aw, ah = 0, 0, 0 }
}
middle := l.WindowWidth - left - right - 2
if middle < 30 { right, aw, ah = 0, 0, 0; middle = l.WindowWidth - left - 2 }
content := l.WindowHeight - 3
tags := min(12, content/2)
return AdaptiveLayout{LeftPanelWidth: left, MiddlePanelWidth: middle, RightPanelWidth: right,
ContentHeight: content, ArtworkMaxWidth: aw, ArtworkMaxHeight: ah,
TagsPanelHeight: tags, ShowArtwork: aw > 0 && ah > 0}
}
4. File Browser
-
Fast nav + search: Arrow/page keys with lightweight in-memory filter. Only directories and
.mp3files are listed. -
Batch mode: Toggle with
b, select with Space, then run multi-file fetchers. - No I/O in Update: The browser mutates only its own state; filesystem operations happen in methods called from Update.
// Search: updates filtered view without touching disk
func (fb *FileBrowser) SetSearch(q string) {
fb.searchQuery = q
fb.isSearchMode = q != ""
fb.applyFilter()
fb.selectedIndex = 0
}
// Enter on a directory: update dir + reload entries
func (fb *FileBrowser) Navigate() error {
if fb.selectedIndex >= len(fb.filteredEntries) { return nil }
sel := fb.filteredEntries[fb.selectedIndex]
if sel.IsDir { fb.currentDir = sel.Path; fb.selectedIndex = 0; return fb.LoadDirectory() }
return nil
}
5. Media Manager
-
One orchestrator: Centralizes lyrics fetching and artwork rendering behind clean
tea.Cmds. -
Asynchronous by default: All fetches/rendering send
ArtworkFetchedMsg/LyricsFetchedMsgback to Update. -
Layout-aware: Rendering commands accept width/height and absolute
(x, y)positions.
// Start rendering and lyrics updates for a file
func (mm *MediaManager) LoadFileAsyncWithPosition(path, lyrics string, art []byte,
w, h, x, y int) tea.Cmd {
mm.currentFile = path
mm.lyricsPanel.SetLyrics(lyrics)
if len(art) == 0 { mm.artworkResult = ArtworkResult{Content: "No artwork embedded"}; return nil }
mm.artworkResult = ArtworkResult{Content: "Rendering artwork..."}
return mm.artworkRenderer.RenderArtworkWithSizeAndPositionAsync(path, art, w, h, x, y)
}
// Fetchers return typed messages; Update wires them into the model
func (mm *MediaManager) FetchArtwork(title, artist, album string) tea.Cmd {
return func() tea.Msg {
art, err := mm.artworkFetcher.Fetch(title, artist, album)
return ArtworkFetchedMsg{Artwork: art, Error: err}
}
}
6. Batch Processing
-
Message pipeline: Each file emits a
BatchProcessMsg; the Update loop tallies progress and schedules the next file. -
Resilient loop: Success/failure is tracked without crashing the TUI; final
BatchCompleteMsgsummarizes results.
// In Update: progress + scheduling
case BatchProcessMsg:
a.batchProcessed++
if msg.Success { a.batchSucceeded++ } else { a.batchFailed++ }
if a.batchProcessed < a.batchTotal {
switch a.batchMode {
case "lyrics": return a, a.processBatchLyrics(a.batchFilePaths, a.batchProcessed)
case "artwork": return a, a.processBatchArtwork(a.batchFilePaths, a.batchProcessed)
default: return a, a.processBatchBoth(a.batchFilePaths, a.batchProcessed)
}
}
return a, func() tea.Msg { return BatchCompleteMsg{Total: a.batchTotal, Succeeded: a.batchSucceeded, Failed: a.batchFailed} }
7. Artwork Rendering
-
View-integrated placement: On resize and file load, the app computes
(x, y)placement fromLayout.Calculate()and triggers a render cmd. -
No blocking: Rendering returns immediately; results flow back as
ArtworkRenderMsg. - Terminal-friendly: We clear previously drawn images when sizes change to avoid artifacts.
// Window resize: recompute layout, clear previous image, re-render at new position
case tea.WindowSizeMsg:
a.layout.Update(msg.Width, msg.Height)
layout := a.layout.Calculate()
clearKittyImages()
if layout.ShowArtwork {
x := layout.LeftPanelWidth + layout.MiddlePanelWidth + 3
y := 4
if cmd := a.mediaManager.HandleWindowResizeWithPosition(layout.ArtworkMaxWidth, layout.ArtworkMaxHeight, x, y); cmd != nil {
return a, cmd
}
}
// After file loads: kick off artwork render with exact bounds and position
case FileLoadedMsg:
a.tagEditor.LoadTags(msg.Tags)
layout := a.layout.Calculate()
x := layout.LeftPanelWidth + layout.MiddlePanelWidth + 3
y := 4
return a, a.mediaManager.LoadFileAsyncWithPosition(
msg.FilePath,
msg.Tags.Lyrics,
msg.Tags.Artwork,
layout.ArtworkMaxWidth,
layout.ArtworkMaxHeight,
x, y,
)
Kitty Protocol
Rendering graphics in terminal has always been a difficult task. Even though some legacy solutions like Sixel exist, they don't show real colors. Sixel only supports 256 color palette size. Other issues are CPU overhead, no placement, no layering, etc.
I have been using kitty terminal since I started using linux. Kitty supports graphics rendering through its own Kitty Protocol, it quickly became popular and was implemented by other terminals and applications as well. Thus, kitty was an obvious choice.
1. Escape Sequences
Terminal graphics work by sending specially formatted ANSI escape sequences like \x1B[31m (\x1B is ESC btw) which the terminal interprets to display images or richer UI.
According to kitty protocol, for rendering graphics, we need to send bytes to terminal in this format \x1B_G ... \x1B\. Inside these sequences, we can send:
- metadata (width, height, placement)
- the actual image data (base64 encoded)
- commands to display, move, resize, delete the image
2. Kittens
Instead of sending raw bytes, we can use frameworks provided by kitty that make these tasks easier. These frameworks are called kittens. For instance, the following command renders an image in the terminal.
kitty +kitten icat ".../image_path/image.png"
Of course, it also uses escape sequences internally. Given that my application only needed one image rendered at a time (i.e. no animations, resizing, moving images once they are rendered), I decided to just call the sub-process at runtime.
3. Implementation
- Writing to
/dev/tty
After hours of debugging, I learned that Kitty’s image commands need to be written directly to the controlling TTY instead of standard stdout/stdin, otherwise the image won’t render where the TUI is running.
Therefore, inside the renderer, every command is invoked with:
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
This writes encoded graphics escape sequences directly onto the TTY, ensuring the image appears exactly in the current terminal window rather than being swallowed by BubbleTea's alternate screen buffer.
- Rendering the image file I write the artwork image into a temp file first.
tmpFile := filepath.Join(os.TempDir(), ...)
os.WriteFile(tmpFile, data, 0644)
Then, pass the image file and execute the sub-process
kitty +kitten icat --transfer-mode=file ...
- Clearing existing images Kitty doesn't automatically garbage-collect images, so before rendering we need to explicitly send a delete command:
"\x1b_Ga=d,d=A\x1b\\"
- Async rendering with timeouts Rendering is performed asynchronously so the TUI never blocks:
go func() {
rendered := ar.renderWithKittyIcat(...)
result <- rendered
}()
If Kitty is busy or something hangs, a 2-second timeout returns gracefully:
case <-time.After(2 * time.Second):
return ArtworkResult{Content: "(rendering timed out)"}
This is essential in batch browsing, where the one may scroll through hundreds of files rapidly.
Limitations
- Minor TUI inconsistencies
- Only supports terminals that support image rendering through kitty protocol
- Only mp3 support (could be extended using taglib)
- Sub-process created for rendering images
Conclusion
tagTonic was a great learning experience for me and brought me closer to the terminal & linux more than I wanted :)
You can check it out here: GitHub
I’d love more feedback or issues.
Also, if you have a better idea for the cat face logo ₍^. .^₎⟆, I’m all ears.


Top comments (0)