fix: correct Rockbox's local wall-clock timestamps to UTC
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:
co-authored by
Claude Opus 5
parent
374a17474f
commit
9942e1a920
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user