Scrobbles everything you play — local files and Tidal — including
what you listened to with no network at all.
No root. No firmware modification. It never writes to the player's database.
Features · Install · Battery · How it works · Security · Português
The R1 has no scrobbling. This puts a tiny collector inside the player that writes down what you listen to — offline, on a plane, in a car — and sends it all to Last.fm afterwards. If the R1's Wi-Fi happens to be on, it sends by itself, with no PC in the loop.
Works on the plain HiBy R1 (Ingenic X1600, MIPS32 little-endian, stock firmware 1.6 with ADB).
python r1lastfm.py
One window, six cards, top to bottom. Nothing is hidden in menus, and every card says what it is about to do before it does it.
The interface is in English and Portuguese, English by default, switchable from the bottom-right corner of the window at any time.
| Tidal, not just local files | Streamed tracks never enter the player's local database — which is why a scrobbler that only reads that database is blind to them. This one reads the Tidal track id the player leaves behind and asks Tidal's own API for artist, title, album and duration, using the token already on your device. |
| Offline collection | A whole trip with no network: what played stays on the device and goes out when a connection appears. Nothing is lost, nothing is guessed. |
| Two ways out, both at once | Automatically over the R1's Wi-Fi, and/or over the cable from your PC. They never duplicate each other, because what was accepted is written down on the device. |
| Live “now playing” | The track you are playing pulses on your Last.fm profile — for local files and for Tidal. |
| Honest listening times | The seconds are measured — audio actually coming out of the device — not inferred from the gap between two history rows. Pausing suspends the count instead of ending the track; a track you skipped at 0:19 is recorded as 19 seconds and does not go. A track has to play almost to the end to count: 90% of it, stricter than the half Last.fm settles for. |
| Fast | The scrobble appears a couple of seconds after the track ends, not on a twelve-minute timer. |
| A log and a spreadsheet on the SD card | <card>/r1lastfm/scrobbles.csv and r1lastfm.log. Pull the card, open the CSV in a spreadsheet — no ADB, no this program, nothing. |
| It does not create anything while music plays | Going to the network used to mean forking a 1.6 MB curl, opening a socket and negotiating TLS — on a device with 1.5 MB free. A resident helper holds one TLS connection open, so a request became a write to a descriptor that already exists: zero processes. |
| It keeps its own house | The queue drops what Last.fm has already accepted, by itself, when the audio is stopped. Without that it grew forever, and so did the memory needed to read it. |
| Cheap | 1 ms of CPU per cycle, zero child processes while idle. Measured on the device, not estimated. |
| Yours | Your own Last.fm API key, stored only on your computer. No account, no server, no telemetry, nothing phones home. |
<card>/r1lastfm/: r1lastfm.log and scrobbles.csv. Pull the card, open
the CSV in a spreadsheet — no ADB, no this program, nothing.r1net) starts with the daemon, when the device still has
22 MB free, and pays every expensive cost once — reading the certificate
bundle, seeding the random generator, building the TLS contexts. After that
a request is a line written to a descriptor that is already open, on a
connection that is already negotiated.Real rows, straight off a device — Tidal tracks, with the ones you skipped marked as such and the album title quoted because it contains a comma:
started_at,started_at_epoch,artist,track,album,album_artist,seconds_heard,track_seconds,status,rowid
2026-08-01 16:36:05,1785612965,Odeal,Coming Home (feat. Jorja Smith),Coming Home (feat. Jorja Smith),,223,223,sent,1000000006
2026-08-01 16:40:11,1785613211,Wale,Overthink,Overthink,,187,207,sent,1000000007
2026-08-01 16:45:00,1785613500,Too $hort,So So So Good,"SIR TOO $HORT, VOL. 2 (DRINK & SMOKE)",,19,142,skipped,1000000008
2026-08-01 16:45:19,1785613519,Train,Mad Dog in the Fog,Mad Dog in the Fog,,227,227,sent,1000000009
2026-08-01 16:53:38,1785613980,Remi Wolf,Twiggy,Twiggy,,38,209,skipped,1000000011
status is one of sent, pending, playing, skipped,
track-too-short, too-old, future, bad-clock or no-metadata — so you
can see not just what went, but why the rest did not. playing is the track
that has not finished yet; it is not a rejection, and it becomes pending on
its own once the track ends.
Measured on the device, not estimated:
| cost | |
|---|---|
| collector, idle | 1 ms of CPU per minute — 0.0017% of the time, zero child processes |
| collector, playing | one cycle every 15 s, same 1 ms per cycle |
| memory, the daemon | 880 kB RSS |
| memory, the network helper | 716 kB RSS, and it does not grow between requests |
| memory, assembling a batch | 396 kB peak, with a trimmed queue |
| one Wi-Fi send | ~0.1% of the battery |
| “now playing” | 10 ms per detection; 2.4 s of CPU per hour |
The network helper's 716 kB are permanent, and that is a deliberate trade: a
constant cost in exchange for never allocating at the moment the player needs
memory. The device has about 1.5 MB free while Tidal plays, and what used to
happen there was a 1.6 MB curl being forked. The peak is what kills, not the
average.
What actually costs is having Wi-Fi on — the radio draws 50-150 mW against the ~260 mW of the device playing, which takes 20-40% off the battery life. That bill belongs to Wi-Fi, not to this program: with the radio off, the collector's cost is indistinguishable from zero.
| Python 3.9+ with Tkinter | standard library only; nothing to pip install |
| adb (Android Platform Tools) | how the program talks to the R1 |
That is all, for everything except one optional feature. The three programs
that run on the R1 ship already compiled, in
r1lastfm/bin/ — you do not need a compiler.
| on the device | what it does |
|---|---|
r1collect | reads the player's history and the Tidal track id; never writes |
r1send | assembles and signs the Last.fm batch, and writes the card's spreadsheet |
r1net | holds one TLS connection open, so reaching the network creates no process |
Only if you want the R1 to send by itself over Wi-Fi do you also need
WSL + Zig, to build a static curl for the device. The program installs
Zig itself, verifying the SHA256 published by ziglang.org. Without it,
everything else works: it collects offline and sends over the cable.
Tkinter is the graphical toolkit this program's window is built on. It comes with Python, but on some systems it is a separate package — which is why it is called out here instead of being assumed.
Windows — get the installer from python.org/downloads. Two boxes matter:
macOS — the installer from python.org/downloads ships a working Tk and is the easy path. If you prefer Homebrew, Tkinter is a separate formula:
brew install python python-tk
Linux — Tkinter is almost always a separate package:
sudo apt install python3 python3-tk # Debian, Ubuntu, Mint
sudo dnf install python3 python3-tkinter # Fedora
sudo pacman -S python tk # Arch
sudo zypper install python3-tk # openSUSE
Check that it worked:
python -c "import tkinter; print('Tkinter', tkinter.TkVersion)"
A version number means you are set. ModuleNotFoundError: No module named 'tkinter' means Python is installed but the Tk package is not — install it
with the line for your system above. python r1lastfm.py --check tells you
the same thing in plainer words, along with everything else it needs.
On Linux,
pythonmay not exist whilepython3does. Usepython3in both commands if that is your case.
Download the Android Platform Tools for your system: https://developer.android.com/tools/releases/platform-tools
C:\platform-tools. That is one of the places
this program looks, so nothing else is needed.brew install android-platform-tools.sudo apt install adb (or your distribution's equivalent).Check it with python r1lastfm.py --check.
Only needed for Wi-Fi sending and “now playing”. In a PowerShell running as administrator:
wsl --install -d Ubuntu
Restart, then open Ubuntu once so it can finish setting itself up. You do not need to install Zig — the program does that when you ask it to build.
Go to https://www.last.fm/api/account/create (the program has a button that opens it). Fill in a name — something like my R1 scrobbler — and a description. Everything else can stay empty. Submit, and copy the two values it shows you: API key and Shared secret.
Paste both into card 1 and click Save.
Why your own key, and not one shipped with the program? A shared secret published in a public repository is not a secret. Anyone could impersonate the application, and Last.fm would revoke it the moment they noticed — breaking it for everybody. Registering takes a minute and it is yours.
The key is stored on your computer only, in config.json. It is not your
password, and this program never sees your password at any point.
Click Authorise in the browser in card 2. A page on Last.fm itself opens, where you approve the access. Come back and click I authorised it — finish.
What comes back is a session key, not your password. You can revoke it at any time at last.fm → Settings → Applications.
On the device: System → USB working mode → Device. Then plug in the cable.
ADB and USB-DAC share the same USB controller and are mutually exclusive: in DAC mode
adbddoes not even start, and the device list is empty exactly the way it is with a bad cable. Also check that the cable carries data — many only charge.
In card 3, click Install / update. That is it — the programs that go on the device ship prebuilt.
(The Build button next to it is for people who would rather compile the two device programs themselves. If you do, yours is used instead of the ones that shipped. It needs Zig, which the program installs on its own.)
The collector is added to /usr/data/init.sh. Reboot once and look at card 3.
On stock HiBy firmware it will say “⚠ this firmware will not start it at
boot”, and that is not something you did wrong. Nothing in the factory
firmware ever executes /usr/data/init.sh. I checked this against the real
1.6 update package rather than guessing: its /usr/bin/hiby_player.sh is
fourteen lines long and does not contain the string /usr/data at all, and no
other script, init file or binary in that image runs anything from writable
storage. / is mounted squashfs (ro), so it cannot be patched at runtime
either.
init.sh is a convention that modded firmwares introduced — the podcast
mod ships a patched hiby_player.sh that runs it. If you have one of those,
card 3 says “Starts together with the player” and you can ignore all of this.
If you do not, you have two options, and everything else works normally either way:
Patch your own firmware — the permanent fix. Card 3 grows a Fix
auto-start… button whenever it detects a firmware that will not start the
collector. It takes your own stock r1.upt, adds the missing line, and
writes a new package you install from the player's own update menu. It also
turns ADB on at boot, which stock firmware never does — without that,
this program cannot see a fresh R1 at all until you dig out the hidden
developer switch.
From the command line, if you prefer:
python3 ferramentas/remendar_firmware.py --entendi-o-risco --com-adb r1.upt r1-autostart.upt
A package built this way has been installed on a real R1 and booted: stock 1.6, collector starting by itself, ADB up, cable working immediately.
It changes two files and nothing else, and proves it before writing anything:
it replays the device updater's own verification loop — reads the manifest,
walks the chunks by their chained md5 names, checks each against the md5
list, adds up the sizes — then unpacks its own output and diffs all 4,718
files against your input: contents, permissions and ownership. Needs
squashfs-tools, genisoimage and p7zip-full (run it in WSL on Windows).
It never downloads or ships HiBy firmware — you supply the file.
Installing firmware cannot be undone from software. Put a known-good
.upt on the memory card before you flash. If an install ever hangs on
Upgrading…, power off and power on holding power + volume-up — it
installs the good firmware from the card. Do not flash on a low battery, or
from a card with read errors.
Press Start now in card 3 whenever you plug the cable in. The collector then keeps running — offline, cable unplugged — until you power the player off. Equivalent by hand:
adb shell "setsid /usr/data/scrobble/r1scrobbled </dev/null >/dev/null 2>&1 &"
Anything you listened to while it was stopped is not lost: the player's own history database is what gets read, so the collector picks it all up the moment it starts. It just cannot know the exact clock times, so it reconstructs them back-to-back ending at the moment it woke up.
At this point everything already works over the cable. If you never want to use Wi-Fi, you are done: listen to music, plug in when you feel like it, click Fetch the queue and Send to Last.fm.
In card 4, in this order:
Tick Show “now playing” on my profile if you want live scrobbling. It applies immediately, no reinstall needed.
curl stays on the device as the fallback: when the resident helper is not
running, the daemon uses it and says so in the log. That is also why the
certificates matter for both — neither will send without being able to check
who is on the other end.
python r1lastfm.py --check
And to see everything it would do, without touching the device:
python r1lastfm.py --dry-run
--lang en / --lang pt forces a language for one run without changing your
preference.
The R1 keeps an SQLite database at /usr/data/usrlocal_media.db with a
HISTORY_TABLE. It says what played and in what order, but records the
time of nothing — and the time is what Last.fm needs.
So the collector (collector.c, a hand-written SQLite reader, ~770 lines, no
libsqlite) looks at the database now and then and writes down the device's
clock at the moment each new row appears. That gives the time a track
started. Track duration is not in the database either: it is computed from
size * 8 / bit_rate, which matched the real length to within half a second in
testing.
How long you listened is measured, not inferred. The obvious shortcut — the gap between two history rows — is wall-clock time, not music, and it breaks in three ways that happen every day: you pause and the gap counts the pause as listening; the collector starts while music is already playing and the track gets credited for time when nothing was watching; the last track of a session has no next row at all. So instead the collector counts the seconds audio is actually coming out of the device, and writes the total down.
Pausing does not end a track, because the device tells the difference: the audio device closes but the player keeps the file open. Measured live, with the pause pressed by hand — 50 s playing, 50 s paused with the file still open, 29 s playing again, and the history row never changed, because resuming writes nothing. Audio out and no file open means playback really stopped.
The measurement carries its own uncertainty: the audio state is sampled every so often, so each time it starts or stops, the exact moment is lost inside one sampling interval. That uncertainty is added up and sent along, and the 90% bar is judged with it — otherwise a track you heard to the end would be rejected for a couple of seconds nobody could have counted. It never lowers the bar by more than ten points.
Details that only turn up by working on the real device, and that are documented in the code's comments:
most_played.db is corrupt out of the factory (one row mixes one
track's name with another's path); only its mtime is used, never its contents;Sending from inside the device is done by r1send.c, which assembles and signs
the batch (MD5 over the sorted parameters plus the secret, as the Last.fm API
requires). The daemon (r1scrobbled.sh) is busybox ash and sleeps on a
read -t over a fifo, which costs 34× less than calling sleep — that is why
it spawns no processes at all while waiting.
This one was learned the hard way, and the kernel is what settled it. On a device that froze while Tidal played, the log said:
HiBy_Main_Threa invoked oom-killer: gfp_mask=0x24201ca, order=0
[ 5122] ... 2193 2105 ... r1send <- 8.4 MB resident
[ 961] ... 68995 4836 ... system_main_thr
Normal free:928kB min:940kB ... all_unreclaimable? yes
Killed process 961 (system_main_thr)
order=0 — the allocation that failed was a single 4 KB page. It was not
fragmentation, it was plain exhaustion, and the kernel killed the biggest
process, which is the player. The firmware's supervisor restarts it and, after
five deaths in a row, reboots the device. That is what a "freeze" was.
The 8.4 MB were ours: r1send reserved room for 4096 tracks up front and
calloc zeroed all of it, so a queue of ten cost the same as a queue of four
thousand. It now grows as it fills, and the daemon trims the queue by itself.
The same lesson shaped the network. r1net.c starts with the daemon, while
the device still has 22 MB free, parses the certificate bundle, seeds the
generator and builds its TLS contexts once, then waits on a fifo with the
connection to Last.fm held open. Sending a request became:
printf '...\n' >&8 # printf and redirection are shell builtins
No fork, no exec, no socket, no handshake. It is 421 KB of program against
curl's 1,643,940, and its resident size does not move between requests.
curl is still there and still used when the helper is not running — the
daemon falls back to it on its own.
Connecting the helper broke a real device three times before it worked, so it
is only wired in behind a test that runs a whole listening cycle twice —
once through curl, once through the helper — and requires the two queues to
come out identical. That test needs
testes/servidor_falso.py, a local HTTPS server
that generates its own certificate, so the helper's certificate validation is
exercised rather than switched off.
On this computer, in %LOCALAPPDATA%\R1LastFm (Windows),
~/Library/Application Support/R1LastFm (macOS) or ~/.local/share/r1-lastfm
(Linux):
config.json — your API key, the Last.fm session key, and your language;registros/ — the full log of every session, with the exact commands, so any
step can be redone by hand;trabalho/ — the compiled binaries.On the device, in /usr/data/scrobble: the queue, the list of what was
already sent, and (if you enable Wi-Fi sending) the session key. They live there
on purpose — using the program from another computer then re-sends nothing and
loses nothing.
/usr/data/scrobble/sk with mode 600. It does not give access to your
password, but anyone with ADB access to the device can read it. If that
bothers you, use cable sending only: it works just as well, and nothing leaves
the PC.curl is compiled on your machine from
the official curl and Mbed-TLS sources, and whatever comes out still goes
through an ELF check before reaching the device.The Remove button, in card 3, takes everything out: the programs, the
init.sh block, and (if you say so) the queue. Nothing outside
/usr/data/scrobble and /usr/data/init.sh is ever touched.
The session log has the exact command for every step — it is the first place to look, and it is designed so you can redo it by hand line by line. Some common cases:
“it says installed but it is not running / it never starts by itself” —
on stock firmware it cannot. See step 4:
nothing in the factory image executes /usr/data/init.sh. Card 3 tells you
when this is your case. Use Start now, or a patched firmware.
“nothing shows up on Last.fm after a reboot” — if card 3 does say
Starts together with the player, then your init.sh may have an exit
before the scrobbler block; the program inserts the block before the
first exit for exactly this reason.
“I listened to a whole album and it logged 0 seconds” / “a track counted as fully played the moment it started” — fixed in version 8. The player writes its history row when a track starts, not when it ends (watched live on the device: the row changed the same second the track did, with audio still playing for another 45s). The code assumed the opposite, so the gap between two rows — which is how long the first one played — was being credited to the second. Update the device (card 3) to get it.
“there is no scrobbles.csv on my card” — fixed in version 8. The program that writes it only got installed together with Wi-Fi sending, so if you set up the collector over the cable and stopped there, no spreadsheet was ever written. It now ships with the collector, and card 3 shows you the exact path on the card.
“automatic sending never sends” — the R1 does not turn Wi-Fi on by itself. Turn it on and wait up to twelve minutes, or use Send now (test).
“old scrobbles do not go up” — Last.fm refuses timestamps older than 14 days. Nothing to be done.
“a Tidal track shows nothing at all — no ‘now playing’, no scrobble” —
look in the device log for o catalogo nao conhece a faixa. Some ids the
player hands over are not in Tidal's public catalogue: asking for them
returns {"status":404,...,"userMessage":"Track [...] not found"}, and the
same 404 comes back from videos, albums and episodes. Without an
artist and a title there is nothing to announce and nothing to scrobble.
The daemon writes one line and stops asking, rather than spending two
requests a minute forever on a name that will not come.
“the ‘now playing’ takes half a minute to appear” — that is expected, and it is mostly the polling interval. The daemon notices a Tidal track change within 15 seconds, waits out a short calm window so it is not asking for memory at the same moment the player is, and then announces. Roughly 10 to 20 seconds in practice. Making it faster means looking more often, which means creating more processes while music plays — the exact thing that used to freeze the device.
“it froze while playing Tidal” — this should be gone; if it happens,
REDE_NO_TIDAL=0 at the top of /usr/data/scrobble/r1scrobbled turns off
all network use while a Tidal track plays. Scrobbles keep working and only
the live indicator stops:
adb shell "sed -i 's/^REDE_NO_TIDAL=1/REDE_NO_TIDAL=0/' /usr/data/scrobble/r1scrobbled"
python testes/t_scrobble_all.py
Twenty modules: the SQLite reader in C against real SQLite, the daemon running
under busybox ash, queue reconstruction, signing, automatic sending, live “now
playing”, the init.sh edits, the device API against a fake adb, the ELF check,
the window itself, and the translation catalogue.
The longest one is the Tidal cycle, and it exists because of scars. It plays a track, repeats it, skips one, plays another, drops the network in the middle and resolves pending lookups — all in the same run — and then counts the queue lines per track. Then it does the whole thing again through the resident network helper and demands the two queues come out identical:
OK the helper does not change what reaches the queue
curl gave (2, 0, 2, 0) and the helper gave (2, 0, 2, 0)
OK and no curl was created
Three separate regressions reached a real device before that test existed — an announcement firing every 15 seconds, an announcement that never came back after one failed request, and the same track entering the queue three times. Every one of them would have passed a test that checked a single thing at a time.
Tests that talk to the real Last.fm API are skipped unless you provide your own key:
LASTFM_API_KEY=... LASTFM_API_SECRET=... python testes/t_scrobble_all.py
Every user-visible string lives in r1lastfm/textos.py,
one key per message, one entry per language. To add a language, add its code to
IDIOMAS in r1lastfm/idioma.py and a matching entry to
every key. Then run:
python testes/t_idioma.py
It walks the whole source, collects every key that is asked for, and tells you
exactly which ones you missed — including any whose {placeholders} do not
match the English original.
Written with Claude Code (Anthropic).
The SQLite file format, the R1's HISTORY_TABLE behaviour and the battery
numbers above were all worked out on the device, by measuring — not estimating.
MIT licence, in LICENSE.
60 commits
Python
75.7%
Shell
13.2%
C
11.1%
Scrobbles everything you play — local files and Tidal — including
what you listened to with no network at all.
No root. No firmware modification. It never writes to the player's database.
Features · Install · Battery · How it works · Security · Português
The R1 has no scrobbling. This puts a tiny collector inside the player that writes down what you listen to — offline, on a plane, in a car — and sends it all to Last.fm afterwards. If the R1's Wi-Fi happens to be on, it sends by itself, with no PC in the loop.
Works on the plain HiBy R1 (Ingenic X1600, MIPS32 little-endian, stock firmware 1.6 with ADB).
python r1lastfm.py
One window, six cards, top to bottom. Nothing is hidden in menus, and every card says what it is about to do before it does it.
The interface is in English and Portuguese, English by default, switchable from the bottom-right corner of the window at any time.
| Tidal, not just local files | Streamed tracks never enter the player's local database — which is why a scrobbler that only reads that database is blind to them. This one reads the Tidal track id the player leaves behind and asks Tidal's own API for artist, title, album and duration, using the token already on your device. |
| Offline collection | A whole trip with no network: what played stays on the device and goes out when a connection appears. Nothing is lost, nothing is guessed. |
| Two ways out, both at once | Automatically over the R1's Wi-Fi, and/or over the cable from your PC. They never duplicate each other, because what was accepted is written down on the device. |
| Live “now playing” | The track you are playing pulses on your Last.fm profile — for local files and for Tidal. |
| Honest listening times | The seconds are measured — audio actually coming out of the device — not inferred from the gap between two history rows. Pausing suspends the count instead of ending the track; a track you skipped at 0:19 is recorded as 19 seconds and does not go. A track has to play almost to the end to count: 90% of it, stricter than the half Last.fm settles for. |
| Fast | The scrobble appears a couple of seconds after the track ends, not on a twelve-minute timer. |
| A log and a spreadsheet on the SD card | <card>/r1lastfm/scrobbles.csv and r1lastfm.log. Pull the card, open the CSV in a spreadsheet — no ADB, no this program, nothing. |
| It does not create anything while music plays | Going to the network used to mean forking a 1.6 MB curl, opening a socket and negotiating TLS — on a device with 1.5 MB free. A resident helper holds one TLS connection open, so a request became a write to a descriptor that already exists: zero processes. |
| It keeps its own house | The queue drops what Last.fm has already accepted, by itself, when the audio is stopped. Without that it grew forever, and so did the memory needed to read it. |
| Cheap | 1 ms of CPU per cycle, zero child processes while idle. Measured on the device, not estimated. |
| Yours | Your own Last.fm API key, stored only on your computer. No account, no server, no telemetry, nothing phones home. |
<card>/r1lastfm/: r1lastfm.log and scrobbles.csv. Pull the card, open
the CSV in a spreadsheet — no ADB, no this program, nothing.r1net) starts with the daemon, when the device still has
22 MB free, and pays every expensive cost once — reading the certificate
bundle, seeding the random generator, building the TLS contexts. After that
a request is a line written to a descriptor that is already open, on a
connection that is already negotiated.Real rows, straight off a device — Tidal tracks, with the ones you skipped marked as such and the album title quoted because it contains a comma:
started_at,started_at_epoch,artist,track,album,album_artist,seconds_heard,track_seconds,status,rowid
2026-08-01 16:36:05,1785612965,Odeal,Coming Home (feat. Jorja Smith),Coming Home (feat. Jorja Smith),,223,223,sent,1000000006
2026-08-01 16:40:11,1785613211,Wale,Overthink,Overthink,,187,207,sent,1000000007
2026-08-01 16:45:00,1785613500,Too $hort,So So So Good,"SIR TOO $HORT, VOL. 2 (DRINK & SMOKE)",,19,142,skipped,1000000008
2026-08-01 16:45:19,1785613519,Train,Mad Dog in the Fog,Mad Dog in the Fog,,227,227,sent,1000000009
2026-08-01 16:53:38,1785613980,Remi Wolf,Twiggy,Twiggy,,38,209,skipped,1000000011
status is one of sent, pending, playing, skipped,
track-too-short, too-old, future, bad-clock or no-metadata — so you
can see not just what went, but why the rest did not. playing is the track
that has not finished yet; it is not a rejection, and it becomes pending on
its own once the track ends.
Measured on the device, not estimated:
| cost | |
|---|---|
| collector, idle | 1 ms of CPU per minute — 0.0017% of the time, zero child processes |
| collector, playing | one cycle every 15 s, same 1 ms per cycle |
| memory, the daemon | 880 kB RSS |
| memory, the network helper | 716 kB RSS, and it does not grow between requests |
| memory, assembling a batch | 396 kB peak, with a trimmed queue |
| one Wi-Fi send | ~0.1% of the battery |
| “now playing” | 10 ms per detection; 2.4 s of CPU per hour |
The network helper's 716 kB are permanent, and that is a deliberate trade: a
constant cost in exchange for never allocating at the moment the player needs
memory. The device has about 1.5 MB free while Tidal plays, and what used to
happen there was a 1.6 MB curl being forked. The peak is what kills, not the
average.
What actually costs is having Wi-Fi on — the radio draws 50-150 mW against the ~260 mW of the device playing, which takes 20-40% off the battery life. That bill belongs to Wi-Fi, not to this program: with the radio off, the collector's cost is indistinguishable from zero.
| Python 3.9+ with Tkinter | standard library only; nothing to pip install |
| adb (Android Platform Tools) | how the program talks to the R1 |
That is all, for everything except one optional feature. The three programs
that run on the R1 ship already compiled, in
r1lastfm/bin/ — you do not need a compiler.
| on the device | what it does |
|---|---|
r1collect | reads the player's history and the Tidal track id; never writes |
r1send | assembles and signs the Last.fm batch, and writes the card's spreadsheet |
r1net | holds one TLS connection open, so reaching the network creates no process |
Only if you want the R1 to send by itself over Wi-Fi do you also need
WSL + Zig, to build a static curl for the device. The program installs
Zig itself, verifying the SHA256 published by ziglang.org. Without it,
everything else works: it collects offline and sends over the cable.
Tkinter is the graphical toolkit this program's window is built on. It comes with Python, but on some systems it is a separate package — which is why it is called out here instead of being assumed.
Windows — get the installer from python.org/downloads. Two boxes matter:
macOS — the installer from python.org/downloads ships a working Tk and is the easy path. If you prefer Homebrew, Tkinter is a separate formula:
brew install python python-tk
Linux — Tkinter is almost always a separate package:
sudo apt install python3 python3-tk # Debian, Ubuntu, Mint
sudo dnf install python3 python3-tkinter # Fedora
sudo pacman -S python tk # Arch
sudo zypper install python3-tk # openSUSE
Check that it worked:
python -c "import tkinter; print('Tkinter', tkinter.TkVersion)"
A version number means you are set. ModuleNotFoundError: No module named 'tkinter' means Python is installed but the Tk package is not — install it
with the line for your system above. python r1lastfm.py --check tells you
the same thing in plainer words, along with everything else it needs.
On Linux,
pythonmay not exist whilepython3does. Usepython3in both commands if that is your case.
Download the Android Platform Tools for your system: https://developer.android.com/tools/releases/platform-tools
C:\platform-tools. That is one of the places
this program looks, so nothing else is needed.brew install android-platform-tools.sudo apt install adb (or your distribution's equivalent).Check it with python r1lastfm.py --check.
Only needed for Wi-Fi sending and “now playing”. In a PowerShell running as administrator:
wsl --install -d Ubuntu
Restart, then open Ubuntu once so it can finish setting itself up. You do not need to install Zig — the program does that when you ask it to build.
Go to https://www.last.fm/api/account/create (the program has a button that opens it). Fill in a name — something like my R1 scrobbler — and a description. Everything else can stay empty. Submit, and copy the two values it shows you: API key and Shared secret.
Paste both into card 1 and click Save.
Why your own key, and not one shipped with the program? A shared secret published in a public repository is not a secret. Anyone could impersonate the application, and Last.fm would revoke it the moment they noticed — breaking it for everybody. Registering takes a minute and it is yours.
The key is stored on your computer only, in config.json. It is not your
password, and this program never sees your password at any point.
Click Authorise in the browser in card 2. A page on Last.fm itself opens, where you approve the access. Come back and click I authorised it — finish.
What comes back is a session key, not your password. You can revoke it at any time at last.fm → Settings → Applications.
On the device: System → USB working mode → Device. Then plug in the cable.
ADB and USB-DAC share the same USB controller and are mutually exclusive: in DAC mode
adbddoes not even start, and the device list is empty exactly the way it is with a bad cable. Also check that the cable carries data — many only charge.
In card 3, click Install / update. That is it — the programs that go on the device ship prebuilt.
(The Build button next to it is for people who would rather compile the two device programs themselves. If you do, yours is used instead of the ones that shipped. It needs Zig, which the program installs on its own.)
The collector is added to /usr/data/init.sh. Reboot once and look at card 3.
On stock HiBy firmware it will say “⚠ this firmware will not start it at
boot”, and that is not something you did wrong. Nothing in the factory
firmware ever executes /usr/data/init.sh. I checked this against the real
1.6 update package rather than guessing: its /usr/bin/hiby_player.sh is
fourteen lines long and does not contain the string /usr/data at all, and no
other script, init file or binary in that image runs anything from writable
storage. / is mounted squashfs (ro), so it cannot be patched at runtime
either.
init.sh is a convention that modded firmwares introduced — the podcast
mod ships a patched hiby_player.sh that runs it. If you have one of those,
card 3 says “Starts together with the player” and you can ignore all of this.
If you do not, you have two options, and everything else works normally either way:
Patch your own firmware — the permanent fix. Card 3 grows a Fix
auto-start… button whenever it detects a firmware that will not start the
collector. It takes your own stock r1.upt, adds the missing line, and
writes a new package you install from the player's own update menu. It also
turns ADB on at boot, which stock firmware never does — without that,
this program cannot see a fresh R1 at all until you dig out the hidden
developer switch.
From the command line, if you prefer:
python3 ferramentas/remendar_firmware.py --entendi-o-risco --com-adb r1.upt r1-autostart.upt
A package built this way has been installed on a real R1 and booted: stock 1.6, collector starting by itself, ADB up, cable working immediately.
It changes two files and nothing else, and proves it before writing anything:
it replays the device updater's own verification loop — reads the manifest,
walks the chunks by their chained md5 names, checks each against the md5
list, adds up the sizes — then unpacks its own output and diffs all 4,718
files against your input: contents, permissions and ownership. Needs
squashfs-tools, genisoimage and p7zip-full (run it in WSL on Windows).
It never downloads or ships HiBy firmware — you supply the file.
Installing firmware cannot be undone from software. Put a known-good
.upt on the memory card before you flash. If an install ever hangs on
Upgrading…, power off and power on holding power + volume-up — it
installs the good firmware from the card. Do not flash on a low battery, or
from a card with read errors.
Press Start now in card 3 whenever you plug the cable in. The collector then keeps running — offline, cable unplugged — until you power the player off. Equivalent by hand:
adb shell "setsid /usr/data/scrobble/r1scrobbled </dev/null >/dev/null 2>&1 &"
Anything you listened to while it was stopped is not lost: the player's own history database is what gets read, so the collector picks it all up the moment it starts. It just cannot know the exact clock times, so it reconstructs them back-to-back ending at the moment it woke up.
At this point everything already works over the cable. If you never want to use Wi-Fi, you are done: listen to music, plug in when you feel like it, click Fetch the queue and Send to Last.fm.
In card 4, in this order:
Tick Show “now playing” on my profile if you want live scrobbling. It applies immediately, no reinstall needed.
curl stays on the device as the fallback: when the resident helper is not
running, the daemon uses it and says so in the log. That is also why the
certificates matter for both — neither will send without being able to check
who is on the other end.
python r1lastfm.py --check
And to see everything it would do, without touching the device:
python r1lastfm.py --dry-run
--lang en / --lang pt forces a language for one run without changing your
preference.
The R1 keeps an SQLite database at /usr/data/usrlocal_media.db with a
HISTORY_TABLE. It says what played and in what order, but records the
time of nothing — and the time is what Last.fm needs.
So the collector (collector.c, a hand-written SQLite reader, ~770 lines, no
libsqlite) looks at the database now and then and writes down the device's
clock at the moment each new row appears. That gives the time a track
started. Track duration is not in the database either: it is computed from
size * 8 / bit_rate, which matched the real length to within half a second in
testing.
How long you listened is measured, not inferred. The obvious shortcut — the gap between two history rows — is wall-clock time, not music, and it breaks in three ways that happen every day: you pause and the gap counts the pause as listening; the collector starts while music is already playing and the track gets credited for time when nothing was watching; the last track of a session has no next row at all. So instead the collector counts the seconds audio is actually coming out of the device, and writes the total down.
Pausing does not end a track, because the device tells the difference: the audio device closes but the player keeps the file open. Measured live, with the pause pressed by hand — 50 s playing, 50 s paused with the file still open, 29 s playing again, and the history row never changed, because resuming writes nothing. Audio out and no file open means playback really stopped.
The measurement carries its own uncertainty: the audio state is sampled every so often, so each time it starts or stops, the exact moment is lost inside one sampling interval. That uncertainty is added up and sent along, and the 90% bar is judged with it — otherwise a track you heard to the end would be rejected for a couple of seconds nobody could have counted. It never lowers the bar by more than ten points.
Details that only turn up by working on the real device, and that are documented in the code's comments:
most_played.db is corrupt out of the factory (one row mixes one
track's name with another's path); only its mtime is used, never its contents;Sending from inside the device is done by r1send.c, which assembles and signs
the batch (MD5 over the sorted parameters plus the secret, as the Last.fm API
requires). The daemon (r1scrobbled.sh) is busybox ash and sleeps on a
read -t over a fifo, which costs 34× less than calling sleep — that is why
it spawns no processes at all while waiting.
This one was learned the hard way, and the kernel is what settled it. On a device that froze while Tidal played, the log said:
HiBy_Main_Threa invoked oom-killer: gfp_mask=0x24201ca, order=0
[ 5122] ... 2193 2105 ... r1send <- 8.4 MB resident
[ 961] ... 68995 4836 ... system_main_thr
Normal free:928kB min:940kB ... all_unreclaimable? yes
Killed process 961 (system_main_thr)
order=0 — the allocation that failed was a single 4 KB page. It was not
fragmentation, it was plain exhaustion, and the kernel killed the biggest
process, which is the player. The firmware's supervisor restarts it and, after
five deaths in a row, reboots the device. That is what a "freeze" was.
The 8.4 MB were ours: r1send reserved room for 4096 tracks up front and
calloc zeroed all of it, so a queue of ten cost the same as a queue of four
thousand. It now grows as it fills, and the daemon trims the queue by itself.
The same lesson shaped the network. r1net.c starts with the daemon, while
the device still has 22 MB free, parses the certificate bundle, seeds the
generator and builds its TLS contexts once, then waits on a fifo with the
connection to Last.fm held open. Sending a request became:
printf '...\n' >&8 # printf and redirection are shell builtins
No fork, no exec, no socket, no handshake. It is 421 KB of program against
curl's 1,643,940, and its resident size does not move between requests.
curl is still there and still used when the helper is not running — the
daemon falls back to it on its own.
Connecting the helper broke a real device three times before it worked, so it
is only wired in behind a test that runs a whole listening cycle twice —
once through curl, once through the helper — and requires the two queues to
come out identical. That test needs
testes/servidor_falso.py, a local HTTPS server
that generates its own certificate, so the helper's certificate validation is
exercised rather than switched off.
On this computer, in %LOCALAPPDATA%\R1LastFm (Windows),
~/Library/Application Support/R1LastFm (macOS) or ~/.local/share/r1-lastfm
(Linux):
config.json — your API key, the Last.fm session key, and your language;registros/ — the full log of every session, with the exact commands, so any
step can be redone by hand;trabalho/ — the compiled binaries.On the device, in /usr/data/scrobble: the queue, the list of what was
already sent, and (if you enable Wi-Fi sending) the session key. They live there
on purpose — using the program from another computer then re-sends nothing and
loses nothing.
/usr/data/scrobble/sk with mode 600. It does not give access to your
password, but anyone with ADB access to the device can read it. If that
bothers you, use cable sending only: it works just as well, and nothing leaves
the PC.curl is compiled on your machine from
the official curl and Mbed-TLS sources, and whatever comes out still goes
through an ELF check before reaching the device.The Remove button, in card 3, takes everything out: the programs, the
init.sh block, and (if you say so) the queue. Nothing outside
/usr/data/scrobble and /usr/data/init.sh is ever touched.
The session log has the exact command for every step — it is the first place to look, and it is designed so you can redo it by hand line by line. Some common cases:
“it says installed but it is not running / it never starts by itself” —
on stock firmware it cannot. See step 4:
nothing in the factory image executes /usr/data/init.sh. Card 3 tells you
when this is your case. Use Start now, or a patched firmware.
“nothing shows up on Last.fm after a reboot” — if card 3 does say
Starts together with the player, then your init.sh may have an exit
before the scrobbler block; the program inserts the block before the
first exit for exactly this reason.
“I listened to a whole album and it logged 0 seconds” / “a track counted as fully played the moment it started” — fixed in version 8. The player writes its history row when a track starts, not when it ends (watched live on the device: the row changed the same second the track did, with audio still playing for another 45s). The code assumed the opposite, so the gap between two rows — which is how long the first one played — was being credited to the second. Update the device (card 3) to get it.
“there is no scrobbles.csv on my card” — fixed in version 8. The program that writes it only got installed together with Wi-Fi sending, so if you set up the collector over the cable and stopped there, no spreadsheet was ever written. It now ships with the collector, and card 3 shows you the exact path on the card.
“automatic sending never sends” — the R1 does not turn Wi-Fi on by itself. Turn it on and wait up to twelve minutes, or use Send now (test).
“old scrobbles do not go up” — Last.fm refuses timestamps older than 14 days. Nothing to be done.
“a Tidal track shows nothing at all — no ‘now playing’, no scrobble” —
look in the device log for o catalogo nao conhece a faixa. Some ids the
player hands over are not in Tidal's public catalogue: asking for them
returns {"status":404,...,"userMessage":"Track [...] not found"}, and the
same 404 comes back from videos, albums and episodes. Without an
artist and a title there is nothing to announce and nothing to scrobble.
The daemon writes one line and stops asking, rather than spending two
requests a minute forever on a name that will not come.
“the ‘now playing’ takes half a minute to appear” — that is expected, and it is mostly the polling interval. The daemon notices a Tidal track change within 15 seconds, waits out a short calm window so it is not asking for memory at the same moment the player is, and then announces. Roughly 10 to 20 seconds in practice. Making it faster means looking more often, which means creating more processes while music plays — the exact thing that used to freeze the device.
“it froze while playing Tidal” — this should be gone; if it happens,
REDE_NO_TIDAL=0 at the top of /usr/data/scrobble/r1scrobbled turns off
all network use while a Tidal track plays. Scrobbles keep working and only
the live indicator stops:
adb shell "sed -i 's/^REDE_NO_TIDAL=1/REDE_NO_TIDAL=0/' /usr/data/scrobble/r1scrobbled"
python testes/t_scrobble_all.py
Twenty modules: the SQLite reader in C against real SQLite, the daemon running
under busybox ash, queue reconstruction, signing, automatic sending, live “now
playing”, the init.sh edits, the device API against a fake adb, the ELF check,
the window itself, and the translation catalogue.
The longest one is the Tidal cycle, and it exists because of scars. It plays a track, repeats it, skips one, plays another, drops the network in the middle and resolves pending lookups — all in the same run — and then counts the queue lines per track. Then it does the whole thing again through the resident network helper and demands the two queues come out identical:
OK the helper does not change what reaches the queue
curl gave (2, 0, 2, 0) and the helper gave (2, 0, 2, 0)
OK and no curl was created
Three separate regressions reached a real device before that test existed — an announcement firing every 15 seconds, an announcement that never came back after one failed request, and the same track entering the queue three times. Every one of them would have passed a test that checked a single thing at a time.
Tests that talk to the real Last.fm API are skipped unless you provide your own key:
LASTFM_API_KEY=... LASTFM_API_SECRET=... python testes/t_scrobble_all.py
Every user-visible string lives in r1lastfm/textos.py,
one key per message, one entry per language. To add a language, add its code to
IDIOMAS in r1lastfm/idioma.py and a matching entry to
every key. Then run:
python testes/t_idioma.py
It walks the whole source, collects every key that is asked for, and tells you
exactly which ones you missed — including any whose {placeholders} do not
match the English original.
Written with Claude Code (Anthropic).
The SQLite file format, the R1's HISTORY_TABLE behaviour and the battery
numbers above were all worked out on the device, by measuring — not estimating.
MIT licence, in LICENSE.
60 commits
Python
75.7%
Shell
13.2%
C
11.1%