fix: correct Rockbox's local wall-clock timestamps to UTC
Build and publish container / build (pull_request) Successful in 4m23s

Rockbox has no concept of a timezone. Its clock holds local time, and it
builds log timestamps with mktime(get_time()) -- but firmware/libc/mktime.c
is plain calendar arithmetic applying no offset, so the RTC's local fields
come out as though they were UTC. The number in the log is ahead of the real
instant by whatever the offset was, and Last.fm stores UTC, so every play
submitted during BST landed an hour in the future.

Rockbox states this itself: its scrobbler plugin writes #TZ/UNKNOWN, and the
AUDIOSCROBBLER spec allows #TZ/UTC only for a device that actually converted.
The correction belongs to the consumer.

Each timestamp is decoded back to its wall-clock fields and reinterpreted in
the player's zone, per play rather than as one offset over the whole log, so
a log spanning a daylight saving change converts each side correctly. A log
declaring #TZ/UTC is left alone rather than shifted twice.

The zone defaults to this machine's, overridable with --device-timezone or
ROCKBOX_TIMEZONE. Deriving it needs the whole IANA name: /etc/localtime
resolves into the tzdata tree, and taking only the final component yields
"London", which no database holds, silently falling back to a fixed offset
that is wrong for half the year.

Since Rockbox cannot adjust for daylight saving on its own, the player's
clock has to be changed by hand twice a year. Any play converting to a future
time is now reported, which is what a forgotten adjustment looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emma Thorpe
2026-08-27 15:32:54 +01:00
co-authored by Claude Opus 5
parent 8d6885c46a
commit c12d2328e5
4 changed files with 332 additions and 7 deletions
+126
View File
@@ -1,7 +1,9 @@
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
@@ -289,6 +291,130 @@ def test_a_track_missing_from_the_mirror_is_counted_not_guessed(monkeypatch):
assert len(result.retain) == 1
LONDON = ZoneInfo("Europe/London")
# Rockbox builds timestamps with mktime(get_time()), and its mktime applies no
# zone at all, so the number is the RTC's local wall clock read as if it were
# UTC. Under BST that puts every play an hour ahead of when it happened.
SUMMER_LOGGED = 1787839200 # 2026-08-27 14:00 written by the device
SUMMER_TRUE = 1787835600 # the instant that actually was, 13:00 UTC
WINTER_LOGGED = 1796479200 # 2026-12-05 14:00, when London is already UTC
WINTER_TRUE = 1796479200
def test_a_summer_timestamp_is_pulled_back_to_real_utc():
"""The device logged 14:00 local. That was 13:00 UTC, and UTC is what
Last.fm stores."""
assert submit_scrobbles.device_time_to_utc(SUMMER_LOGGED, LONDON) == SUMMER_TRUE
def test_a_winter_timestamp_is_left_alone():
"""London keeps UTC for half the year, so there is nothing to correct and
the correction must not invent an offset anyway."""
assert submit_scrobbles.device_time_to_utc(WINTER_LOGGED, LONDON) == WINTER_TRUE
def test_the_converted_time_reads_back_as_the_clock_the_device_showed():
"""The round trip, which is the property that actually matters: whatever
the player's screen said is what Last.fm should show in local time."""
shown = datetime.fromtimestamp(SUMMER_LOGGED, timezone.utc)
corrected = submit_scrobbles.device_time_to_utc(SUMMER_LOGGED, LONDON)
assert datetime.fromtimestamp(corrected, LONDON).strftime("%Y-%m-%d %H:%M") == (
shown.strftime("%Y-%m-%d %H:%M")
)
def test_one_log_spanning_a_clock_change_converts_each_side_separately():
"""A week's listening either side of the October change carries two
different offsets. Correcting the log by a single figure would put half of
it an hour out, which is why the offset is resolved per play."""
before, after = 1792888200, 1792899000 # 2026-10-25, 00:30 BST and 03:30 GMT
assert submit_scrobbles.device_time_to_utc(before, LONDON) == before - 3600
assert submit_scrobbles.device_time_to_utc(after, LONDON) == after
def test_a_playback_log_is_corrected_before_submission(monkeypatch):
monkeypatch.setattr(Path, "is_file", lambda self: True)
log = f"{SUMMER_LOGGED}:180000:245000:/Music/Pendulum/Immersion/01.mp3\n"
played = submit_scrobbles.plays_from_playback_log(
log, "/Music", "/mnt/mirror", runner=tags_of(), zone=LONDON
).played
assert played[0]["timestamp"] == str(SUMMER_TRUE)
def test_a_scrobbler_log_claiming_utc_is_not_corrected_twice():
"""The format's header exists for exactly this. A client that already did
the conversion says so, and correcting it again would break it."""
log = LOG.replace("#TZ/UNKNOWN", "#TZ/UTC")
played, _, _ = submit_scrobbles.parse_log(log, LONDON)
assert [entry["timestamp"] for entry in played] == ["1700000100", "1700000300"]
def test_a_scrobbler_log_declaring_unknown_is_corrected():
"""Rockbox writes UNKNOWN, meaning local wall clock, so the times are ours
to fix."""
played, _, _ = submit_scrobbles.parse_log(LOG, LONDON)
# 1700000100 and 1700000300 are November, when London is on UTC anyway;
# the point is that the header did not exempt them from being looked at.
assert submit_scrobbles.declares_utc(LOG) is False
assert [entry["timestamp"] for entry in played] == ["1700000100", "1700000300"]
def test_the_system_zone_keeps_its_whole_name(monkeypatch, tmp_path):
"""/etc/localtime points into the tzdata tree. Taking only the last
component gives "London", which no database holds -- and the fallback for
an unknown name is a fixed offset, which is wrong for half the year. The
region has to survive."""
zoneinfo_dir = tmp_path / "share" / "zoneinfo" / "Europe"
zoneinfo_dir.mkdir(parents=True)
(zoneinfo_dir / "London").write_bytes(b"TZif")
monkeypatch.setattr(
submit_scrobbles.Path,
"read_text",
lambda self, **kw: (_ for _ in ()).throw(OSError),
)
monkeypatch.setattr(
submit_scrobbles.Path,
"resolve",
lambda self: zoneinfo_dir / "London",
)
assert submit_scrobbles.system_zone_name() == "Europe/London"
def test_a_named_zone_beats_the_machine_default():
"""The player may not be in the same place as the laptop."""
assert submit_scrobbles.device_zone("Asia/Tokyo") == ZoneInfo("Asia/Tokyo")
def test_plays_dated_after_now_are_reported():
"""A clock never put forward, or the wrong zone, produces plays that have
not happened yet. Last.fm cannot tell those from real ones."""
now = 1787835600
played = [
{"timestamp": str(now - 60)},
{"timestamp": str(now + 3600)},
]
ahead = submit_scrobbles.future_plays(played, now=now)
assert [entry["timestamp"] for entry in ahead] == [str(now + 3600)]
def test_a_clock_a_minute_fast_is_not_reported():
"""Devices drift. Only an offset large enough to be a zone error matters."""
now = 1787835600
assert submit_scrobbles.future_plays([{"timestamp": str(now + 60)}], now=now) == []
def test_playback_logs_are_found_including_rotations(tmp_path):
"""Rockbox rotates the log once it passes half a megabyte."""
rockbox = tmp_path / ".rockbox"