From ba76e77d935998e4b128053dcc61d2ed4884cdda Mon Sep 17 00:00:00 2001 From: soaos Date: Fri, 21 Nov 2025 21:14:12 -0500 Subject: zola migration --- content/blog/rockbox_stats/index.md | 547 ++++++++++++++++++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 content/blog/rockbox_stats/index.md (limited to 'content/blog/rockbox_stats/index.md') diff --git a/content/blog/rockbox_stats/index.md b/content/blog/rockbox_stats/index.md new file mode 100644 index 0000000..626cbe9 --- /dev/null +++ b/content/blog/rockbox_stats/index.md @@ -0,0 +1,547 @@ ++++ +title = "Rockbox Stat Tracking" +date = "2025-09-02" ++++ + +
+
+
+

In this post I talk about how I went about setting up a stat visualization page for my rockbox mp3 player.

+
+
+ + + + + + + + + + + + + + + + + + +Progressive Rock +Alternative +Post-Rock +Post-Hardcore +Post-Metal +Rock +Shoegaze +Progressive Metal + + +
A static site generation experiment
+
+
+
+

Preamble: Digital Sovereignity & Rockbox

+

+ I've been building up a pretty sizeable collection of digital music + over the last couple of years. I think there's a lot of value in owning + the music I pay for and being able to choose how I listen to it. + Purchasing music also allows me to support artists in a more direct + and substantial way than the fractions of cents for using streaming services, + but that's more of a happy consequence than some moral obligation I feel. +

+

+ Over the years, I've enjoyed listening to my music in a variety of ways. + For years I kept all of my music files on all of my devices and used + various local music clients depending on the platform, most notably mpd + and ncmpcpp on linux. Eventually, as I charged headlong into the glorious + world of self-hosting, I began using a central Jellyfin media server that + I stream music and video from. It's super convenient, and works on all of + my devices (including my TV!). +

+

+ My media server is great, and it's been the primary way I listen to music + for a while now. But it has limitations. For example, I don't expose my media + server to the internet, so I'm unable to stream from it while I'm out and + about. And even if I could, the bandwidth requirements would be pretty high. + I figured I would need a dedicated music player if I wanted to take my music + library on the go, and settled on the HIFI Walker H2 after reading some + online recommendations. The ability to install Rockbox, an open-source firmware, + was a big factor in my decision. I couldn't tell you how the device works + out of the box, since I flashed the firmware pretty much immediately once I got it, + but I've been super impressed with how the device works while running Rockbox. +

+

+

+ Screenshot of Rockbox player showing cool theme. +
I'm using a modified version of the InfoMatrix-v2 theme, which looks great.
+
+ Rockbox comes with many codecs for common audio formats including FLAC and MP3. The + device boots extremely quickly, and the interface is snappy. Virtually every aspect + of the user experience is tweakable and customizable to a crazy degree. I've even begun + listening to music on my player even at home, since a device specifically for the + purpose provides less distraction while I'm trying to be productive. +

+

+ All this to say I'm pretty much sold on Rockbox. But there's certain things I + still miss from my days of being a user of popular services like Spotify with + fancy APIs and data tracking. Things like Spotify wrapped or third-party apps + for visualizing playback statistics are a fun way to see what my listening history + looks like and could potentially be used to help find more music that I'd enjoy. + This is why when I noticed that Rockbock has a playback logging feature, a little + lightbulb lit up over my head. +

+
+
+

Generating and Parsing Logs

+

+

+ Logging +
The logging feature can be accessed through the settings menu.
+
+ Rockbox has a feature that logs playback information to a text file. This feature can + be enabled by setting Playback Settings > Logging to "On". With this setting enabled, a + new line gets added to the end of the .rockbox/playback.log file every time you play a track, + containing info about what you played and when. +

+

+ The logging feature is actually already used by the LastFM scrobbler plugin that comes preloaded with + Rockbox, which is probably the simplest way to get insights into your playback. However, + I personally want to avoid using third-party services as much as possible, because it's more fun. +

+

+ If I take a look at a logfile generated after a bit of listening, I'll see that I've wound up with + a series of lines that each look something like this: +

+
1758478258:336689:336718:/<microSD0>/Music/This Is The Glasshouse/This Is The Glasshouse - 867/This Is The Glasshouse - 867 - 01 Streetlight By Streetlight.flac
+
An example of a log entry for "Streetlight by Streetlight" by This is the Glasshouse. +
+
+

+

+ I wasn't really able to find any information online about the format of these logs, but they appear + to be simple enough to figure out. From what I can tell, each event is broken up into 4 pieces: +

    +
  1. Timestamp: The number of milliseconds since the UNIX epoch. +
  2. Playback Duration: The amount of the song that was played, in milliseconds. +
  3. Total Track Length: The length of the played track, in milliseconds. +
  4. File Path: An absolute path to the file containing the track on the filesystem. +
+ All of this is enough to know what I was listening to and when. I can use the file path to check for + audio tags which can help glean even more information about my listening habits. +

+

Now that I have this information and know how to interpret it, I'm ready to start processing it!

+
+
+

Analyzing Playback History

+

+ In order to get some useful information out of my playback history, I think it's a good idea to start by + building + a database. I created a sqlite database with the following tables: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
songs
idi64PK
titleString
artistsJSON
album_idi64?
genreString?
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
albums
idi64PK
titleString
artistString
cover_artBlob?
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
history
idi64PK
timestampDatetime
durationi64
song_idi64
+
+
+ I can add more columns later, but this is a good place to start. +

+

+ Now, as I read through the logfile line-by-line, I can check if each album exists before + inserting it into the database: +

+
for line in log_file.lines().flatten() {
+println!("{line}");
+// Skip comments
+if line.starts_with("#") {
+continue;
+}
+let chunks = line.split(":").collect::>();
+
+let timestamp = DateTime::from_timestamp_secs(
+i64::from_str_radix(chunks[0], 10).context("Failed to parse timestamp")?,
+)
+.context("Failed to convert timestamp")?;
+
+// Load tags from file on device
+let file_path = chunks[chunks.len() - 1][1..]
+.split_once("/")
+.context("Missing file")?
+.1;
+let tags = Tag::new()
+.read_from_path(args.mount_point.join(file_path))
+.context("Failed to read audio tags")?;
+
+//...
+}
+
Parsing log entry and loading audio metadata.
+
+
+
if let Some(existing_album) =
+sqlx::query("SELECT id FROM albums WHERE title=$1 AND artist=$2")
+.bind(album_title)
+.bind(album_artist)
+.fetch_optional(&mut *db)
+.await
+.context("Failed to execute query to find existing album")?
+{
+let album_id: i64 = existing_album.get("id");
+info!("Album already exists, id {album_id}");
+//...
+} else {
+info!("Inserting new album: {album_title} by {album_artist}");
+//...
+let result = sqlx::query(
+"INSERT INTO albums (title, artist, cover_art) VALUES ($1, $2, $3);",
+)
+.bind(album_title)
+.bind(album_artist)
+.bind(cover)
+.execute(&mut *db)
+.await
+.context("Failed to execute query to insert album into database")?;
+
+//...
+}
+
Checking for an album with matching artist and title before creating a new row in the + database.
+
+ I did something similar with the songs and history tables, basically building up a cache + of history information and skipping anything that's already in the database on repeat runs. +

+

+ With this database constructed, it's pretty easy to get a bunch of different information + about my listening. For example (forgive me if my SQL skills are kind of ass lol): +

+
SELECT 
+songs.title AS song_title,
+songs.artists AS song_artists,
+songs.genre AS song_genre,
+albums.title AS album_title,
+albums.artist AS album_artist,
+history.timestamp AS timestamp,
+history.duration AS duration
+FROM history
+CROSS JOIN songs ON songs.id = history.song_id
+CROSS JOIN albums ON albums.id = songs.album_id
+ORDER BY timestamp DESC;
+
Querying for a list of each history entry along with track metadata, sorted from most to + least recent.
+
+
+
SELECT 
+songs.genre,
+SUM(history.duration) AS total_duration
+FROM history
+CROSS JOIN songs ON history.song_id = songs.id
+GROUP BY genre
+ORDER BY total_duration DESC
+LIMIT 10; 
+
Querying for the top 10 most listened genres by playtime.
+
+

+

+ It's all well and good to be able to view this information using a database client, + but it would be really cool if I could visualize this data somehow. +

+
+
+

Visualizing this Data Somehow

+

+ I wanted to make this data available on my website for people to view, and for a bunch of mostly trivial + reasons I won't get into here, I have a couple of requirements for pages on this site: +

    +
  1. Pages need to be static. +
  2. Pages need to be JavaScript-free. +
+ This means any chart rendering needs to be done automatically at build time before + deploying. I don't currently use a static site generator for my site (just for fun), + so I'm basically going to need to write one specifically to generate this page. +

+

+ I won't get too deep into the specifics of how I queried the database and generated each visualization + on + the page, but I can explain the visualizations I created using the queries from the previous section. + For the + listening history I wanted to generate a table displaying the information. To accomplish this, I first + used a combination of sqlx's ability to convert a row to a struct and serde to serialize + the rows as JSON values. +

+
#[derive(Serialize, Deserialize, FromRow)]
+struct HistoryEntry {
+song_title: String,
+song_artists: Value,
+timestamp: DateTime<Utc>,
+duration: i64,
+album_title: String,
+album_artist: Option<String>,
+song_genre: Option<String>,
+}
+
+//...later
+let history = sqlx::query_as::<_, HistoryEntry>(
+/* SELECT... */
+).fetch_all(&mut *db).await;
+
+//...later still, tera context accepts
+let mut context = tera::Context::new();
+context.insert("history", &history);
+
+
Struct definition for a history entry, allowing conversion from a sqlx row and + de/serialization from/to JSON.
+
+

+

+ In order to keep the generation as painless as possible, I decided to use the Tera template + engine, which allows me to define a template HTML file and substitute in values from + a context which I can define before rendering. In the case of the table, I can just generate a <tr> + matching the data for each item: +

+
{% macro history_table(history) %}
+<h3>Playback History</h3>
+<div class="table-container">
+<table>
+<thead>
+    <tr>
+        <th>Timestamp</th>
+        <th>Played Duration</th>
+        <th>Title</th>
+        <th>Artists</th>
+        <th>Album</th>
+        <th>Genre</th>
+    </tr>
+</thead>
+<tbody>
+    {% for item in history %}<tr>
+        <td>{{ item.timestamp | date(format="%Y-%m-%d %H:%M:%S") }}</td>
+        <td>{{ item.duration | hms }}</td>
+        <td>{{ item.song_title }}</td>
+        <td>{{ item.song_artists }}</td>
+        <td>{{ item.album_title }}</td>
+        <td>{{ item.song_genre }}</td>
+    </tr>
+    {% endfor %}
+</tbody>
+</table>
+</div>
+{% endmacro history_table %}
+
+ A Tera macro for generating a table from a list of playback history items. + I used a macro so I can re-use this later if I want to add time range views. + (last month, year, etc.) +
+
+

+

+ I wrote similar macros for each of the visualizations I wanted to create. Most are + easy, but for my top 10 genres I wanted to display a pie chart. I found a pretty decent + data visualization crate called charming that's able to render to html, however + the output contains javascript so it's a no-go for me. Luckily, it can also render to + an SVG which I can embed nicely within the page. +

+ + + + + + + + + + + + + + + + + + +Progressive Rock +Alternative +Post-Rock +Post-Hardcore +Post-Metal +Rock +Shoegaze +Progressive Metal + + +
Here's one I generated just now.
+
+

+

+ And that's pretty much all there is to it! The finished thing can be found here. +

+
+
\ No newline at end of file -- cgit v1.2.3