2026-08-25 11:05:00 +01:00
|
|
|
import json
|
2026-08-26 18:32:05 +01:00
|
|
|
import subprocess
|
2026-08-25 11:05:00 +01:00
|
|
|
import sys
|
2026-08-27 14:18:52 +01:00
|
|
|
from datetime import datetime, timezone
|
2026-08-25 11:05:00 +01:00
|
|
|
from pathlib import Path
|
2026-08-27 14:18:52 +01:00
|
|
|
from zoneinfo import ZoneInfo
|
2026-08-25 11:05:00 +01:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
|
|
|
|
|
|
|
|
|
|
import submit_scrobbles # noqa: E402
|
|
|
|
|
|
|
|
|
|
LOG = """#AUDIOSCROBBLER/1.1
|
|
|
|
|
#TZ/UNKNOWN
|
|
|
|
|
#CLIENT/Rockbox ipodvideo 4.0
|
|
|
|
|
#ARTIST\t#ALBUM\t#TITLE\t#TRACKNUM\t#LENGTH\t#RATING\t#TIMESTAMP\t#MUSICBRAINZ_TRACKID
|
|
|
|
|
Pendulum\tImmersion\tWatercolour\t3\t245\tL\t1700000300\t
|
|
|
|
|
Green Day\tDookie\tBasket Case\t7\t180\tS\t1700000200\t
|
|
|
|
|
Mötley Crüe\tDr. Feelgood\tKickstart My Heart\t2\t283\tL\t1700000100\tmb-1
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_only_listened_tracks_are_submitted():
|
|
|
|
|
"""A skip is not a play."""
|
|
|
|
|
played, skipped, timeless = submit_scrobbles.parse_log(LOG)
|
|
|
|
|
|
|
|
|
|
assert [entry["track"] for entry in played] == ["Kickstart My Heart", "Watercolour"]
|
|
|
|
|
assert skipped == 1
|
|
|
|
|
assert timeless == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_entries_come_back_oldest_first():
|
|
|
|
|
played, _, _ = submit_scrobbles.parse_log(LOG)
|
|
|
|
|
|
|
|
|
|
assert [entry["timestamp"] for entry in played] == ["1700000100", "1700000300"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_timeless_log_is_counted_and_not_submitted():
|
|
|
|
|
"""Without a real-time clock Rockbox writes every timestamp as zero. Those
|
|
|
|
|
cannot be scrobbled without inventing when they happened."""
|
|
|
|
|
log = LOG + "Band\tAlbum\tTrack\t-1\t100\tL\t0\t\n"
|
|
|
|
|
|
|
|
|
|
played, _, timeless = submit_scrobbles.parse_log(log)
|
|
|
|
|
|
|
|
|
|
assert timeless == 1
|
|
|
|
|
assert all(int(entry["timestamp"]) > 0 for entry in played)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_absent_optional_fields_are_dropped():
|
|
|
|
|
played, _, _ = submit_scrobbles.parse_log(LOG)
|
|
|
|
|
params = submit_scrobbles.batch_params(played)
|
|
|
|
|
|
|
|
|
|
assert "mbid[0]" in params # Kickstart My Heart has one
|
|
|
|
|
assert "mbid[1]" not in params # Watercolour does not
|
|
|
|
|
assert params["trackNumber[0]"] == "2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_track_number_of_minus_one_is_not_sent():
|
|
|
|
|
"""Rockbox writes -1 when it does not know, which is not a track number."""
|
|
|
|
|
played, _, _ = submit_scrobbles.parse_log(
|
|
|
|
|
"Band\tAlbum\tTrack\t-1\t100\tL\t1700000000\t\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert submit_scrobbles.batch_params(played).get("trackNumber[0]") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_signature_sorts_names_by_ascii_not_by_number():
|
|
|
|
|
"""Last.fm sorts parameter names as strings, so artist[10] precedes
|
|
|
|
|
artist[1]. Sorting numerically produces an invalid signature and nothing
|
|
|
|
|
else."""
|
|
|
|
|
params = {"artist[1]": "b", "artist[10]": "a", "api_key": "k"}
|
|
|
|
|
|
|
|
|
|
expected = submit_scrobbles.hashlib.md5(
|
|
|
|
|
("api_keyk" + "artist[1]b" + "artist[10]a" + "s").encode()
|
|
|
|
|
).hexdigest()
|
|
|
|
|
assert submit_scrobbles.sign(params, "s") != expected
|
|
|
|
|
|
|
|
|
|
correct = submit_scrobbles.hashlib.md5(
|
|
|
|
|
("api_keyk" + "artist[10]a" + "artist[1]b" + "s").encode()
|
|
|
|
|
).hexdigest()
|
|
|
|
|
assert submit_scrobbles.sign(params, "s") == correct
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fake_transport(responses):
|
|
|
|
|
"""Return a transport serving canned responses and recording requests."""
|
|
|
|
|
sent = []
|
|
|
|
|
|
|
|
|
|
def transport(request, timeout=None):
|
|
|
|
|
sent.append(dict(submit_scrobbles.urllib.parse.parse_qsl(request.data.decode())))
|
|
|
|
|
return json.dumps(responses[len(sent) - 1])
|
|
|
|
|
|
|
|
|
|
transport.sent = sent
|
|
|
|
|
return transport
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scrobbles_are_sent_in_batches_of_fifty():
|
|
|
|
|
entries = [
|
|
|
|
|
{"artist": "A", "track": f"T{i}", "timestamp": str(1700000000 + i)}
|
|
|
|
|
for i in range(120)
|
|
|
|
|
]
|
|
|
|
|
accepted = {"scrobbles": {"@attr": {"accepted": 50, "ignored": 0}}}
|
|
|
|
|
transport = fake_transport([accepted, accepted, accepted])
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.submit(entries, "k", "s", "sk", transport, delay=0)
|
|
|
|
|
|
|
|
|
|
assert len(transport.sent) == 3
|
|
|
|
|
assert transport.sent[0]["method"] == "track.scrobble"
|
|
|
|
|
assert "artist[49]" in transport.sent[0]
|
|
|
|
|
assert "artist[50]" not in transport.sent[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_request_carries_a_signature_and_session():
|
|
|
|
|
entries = [{"artist": "A", "track": "T", "timestamp": "1700000000"}]
|
|
|
|
|
transport = fake_transport([{"scrobbles": {"@attr": {"accepted": 1, "ignored": 0}}}])
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.submit(entries, "k", "s", "session-key", transport, delay=0)
|
|
|
|
|
|
|
|
|
|
assert transport.sent[0]["sk"] == "session-key"
|
|
|
|
|
assert len(transport.sent[0]["api_sig"]) == 32
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_service_error_is_raised_not_swallowed():
|
|
|
|
|
entries = [{"artist": "A", "track": "T", "timestamp": "1700000000"}]
|
|
|
|
|
transport = fake_transport([{"error": 9, "message": "Invalid session key"}])
|
|
|
|
|
|
|
|
|
|
with pytest.raises(submit_scrobbles.LastfmError, match="error 9"):
|
|
|
|
|
submit_scrobbles.submit(entries, "k", "s", "sk", transport, delay=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_log_is_set_aside_after_a_successful_submission(tmp_path, monkeypatch):
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
device.mkdir()
|
|
|
|
|
(device / ".scrobbler.log").write_text(LOG, encoding="utf-8")
|
|
|
|
|
transport = fake_transport([{"scrobbles": {"@attr": {"accepted": 2, "ignored": 0}}}])
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "load_session", lambda: "sk")
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.main(
|
|
|
|
|
[str(device), "--api-key", "k", "--api-secret", "s"], transport=transport
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert not (device / ".scrobbler.log").exists()
|
|
|
|
|
# Renamed, not deleted: if Last.fm quietly dropped one, the evidence remains.
|
|
|
|
|
assert list(device.glob(".scrobbler.log.*.submitted"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failed_submission_leaves_the_log_alone(tmp_path, monkeypatch):
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
device.mkdir()
|
|
|
|
|
(device / ".scrobbler.log").write_text(LOG, encoding="utf-8")
|
|
|
|
|
transport = fake_transport([{"error": 29, "message": "Rate limit"}])
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "load_session", lambda: "sk")
|
|
|
|
|
|
|
|
|
|
code = submit_scrobbles.main(
|
|
|
|
|
[str(device), "--api-key", "k", "--api-secret", "s"], transport=transport
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert code == 1
|
|
|
|
|
assert (device / ".scrobbler.log").is_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_dry_run_submits_nothing(tmp_path):
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
device.mkdir()
|
|
|
|
|
(device / ".scrobbler.log").write_text(LOG, encoding="utf-8")
|
|
|
|
|
transport = fake_transport([])
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.main([str(device), "--dry-run"], transport=transport)
|
|
|
|
|
|
|
|
|
|
assert transport.sent == []
|
|
|
|
|
assert (device / ".scrobbler.log").is_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_log_is_not_an_error(tmp_path):
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
device.mkdir()
|
|
|
|
|
|
|
|
|
|
assert submit_scrobbles.main([str(device)], transport=fake_transport([])) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_write_credentials_are_required(tmp_path, capsys, monkeypatch):
|
|
|
|
|
"""The read-only key used elsewhere is not enough for a write method."""
|
|
|
|
|
monkeypatch.delenv("LASTFM_API_KEY", raising=False)
|
|
|
|
|
monkeypatch.delenv("LASTFM_API_SECRET", raising=False)
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
device.mkdir()
|
|
|
|
|
(device / ".scrobbler.log").write_text(LOG, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
code = submit_scrobbles.main([str(device)], transport=fake_transport([]))
|
|
|
|
|
|
|
|
|
|
assert code == 2
|
|
|
|
|
assert "LASTFM_API_SECRET" in capsys.readouterr().err
|
2026-08-26 18:24:12 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
PLAYBACK_LOG = """1700000300:180000:245000:/Music/Pendulum/Immersion/01.mp3
|
|
|
|
|
1700000200:9000:180000:/Music/Green Day/Dookie/07.mp3
|
|
|
|
|
0:180000:245000:/Music/No/Clock/track.mp3
|
|
|
|
|
malformed line without colons
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tags_of(artist="Pendulum", title="Watercolour", album="Immersion", track="1/11"):
|
|
|
|
|
def runner(path):
|
|
|
|
|
return json.dumps(
|
|
|
|
|
{"format": {"tags": {"ARTIST": artist, "TITLE": title,
|
|
|
|
|
"ALBUM": album, "track": track}}}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return runner
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_playback_log_format_is_four_fields():
|
|
|
|
|
"""timestamp:elapsed_ms:length_ms:path, written by Rockbox core."""
|
|
|
|
|
plays = submit_scrobbles.parse_playback_log(PLAYBACK_LOG)
|
|
|
|
|
|
|
|
|
|
assert len(plays) == 3 # the malformed line is dropped
|
|
|
|
|
assert plays[0] == (1700000300, 180000, 245000, "/Music/Pendulum/Immersion/01.mp3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_device_path_maps_onto_the_mirror():
|
|
|
|
|
assert submit_scrobbles.device_to_local(
|
|
|
|
|
"/Music/Pendulum/Immersion/01.mp3", "/Music", "/mnt/mirror"
|
|
|
|
|
) == Path("/mnt/mirror/Pendulum/Immersion/01.mp3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_path_outside_the_prefix_is_not_mapped():
|
|
|
|
|
"""Something played from elsewhere on the card is not in the mirror."""
|
|
|
|
|
assert submit_scrobbles.device_to_local(
|
|
|
|
|
"/Podcasts/episode.mp3", "/Music", "/mnt/mirror"
|
|
|
|
|
) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_path_at_the_card_root_maps_straight_across():
|
|
|
|
|
assert submit_scrobbles.device_to_local(
|
|
|
|
|
"/Pendulum/Immersion/01.mp3", "/", "/mnt/mirror"
|
|
|
|
|
) == Path("/mnt/mirror/Pendulum/Immersion/01.mp3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_short_play_is_a_skip_not_a_scrobble(tmp_path, monkeypatch):
|
|
|
|
|
"""Nine seconds of a three minute track. The on-device plugin uses the same
|
|
|
|
|
fraction, so the two never disagree about what counted as a play."""
|
|
|
|
|
monkeypatch.setattr(Path, "is_file", lambda self: True)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
result = submit_scrobbles.plays_from_playback_log(
|
2026-08-26 18:24:12 +01:00
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
assert result.skipped == 1
|
|
|
|
|
assert [entry["timestamp"] for entry in result.played] == ["1700000300"]
|
2026-08-26 18:24:12 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_zero_timestamp_is_refused(tmp_path, monkeypatch):
|
|
|
|
|
"""Without a real-time clock Rockbox logs ticks, not dates. Scrobbling
|
|
|
|
|
those would mean inventing when they happened."""
|
|
|
|
|
monkeypatch.setattr(Path, "is_file", lambda self: True)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
result = submit_scrobbles.plays_from_playback_log(
|
2026-08-26 18:24:12 +01:00
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
assert result.timeless == 1
|
2026-08-26 18:24:12 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tags_come_from_the_mirror(tmp_path, monkeypatch):
|
|
|
|
|
"""The log carries only a path -- which is precisely why the on-device
|
|
|
|
|
plugin exists. Off the mirror the tags are free."""
|
|
|
|
|
monkeypatch.setattr(Path, "is_file", lambda self: True)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
played = submit_scrobbles.plays_from_playback_log(
|
2026-08-26 18:24:12 +01:00
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
2026-08-26 18:32:05 +01:00
|
|
|
).played
|
2026-08-26 18:24:12 +01:00
|
|
|
|
|
|
|
|
assert played[0]["artist"] == "Pendulum"
|
|
|
|
|
assert played[0]["album"] == "Immersion"
|
|
|
|
|
assert played[0]["trackNumber"] == "1" # "1/11" -> "1"
|
|
|
|
|
assert played[0]["duration"] == "245" # milliseconds -> seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_track_missing_from_the_mirror_is_counted_not_guessed(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(Path, "is_file", lambda self: False)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
result = submit_scrobbles.plays_from_playback_log(
|
2026-08-26 18:24:12 +01:00
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-26 18:32:05 +01:00
|
|
|
assert result.played == []
|
2026-08-26 18:24:12 +01:00
|
|
|
# One: the skip and the clockless entry are filtered before the file is
|
|
|
|
|
# looked for, since neither would be submitted either way.
|
2026-08-26 18:32:05 +01:00
|
|
|
assert result.unresolved == 1
|
|
|
|
|
# And that one play is kept, so a later run can try it again.
|
|
|
|
|
assert len(result.retain) == 1
|
2026-08-26 18:24:12 +01:00
|
|
|
|
|
|
|
|
|
2026-08-27 14:18:52 +01:00
|
|
|
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) == []
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 18:24:12 +01:00
|
|
|
def test_playback_logs_are_found_including_rotations(tmp_path):
|
|
|
|
|
"""Rockbox rotates the log once it passes half a megabyte."""
|
|
|
|
|
rockbox = tmp_path / ".rockbox"
|
|
|
|
|
rockbox.mkdir()
|
|
|
|
|
for name in ("playback.log", "playback_0001.log", "playback_0002.log"):
|
|
|
|
|
(rockbox / name).write_text("1700000000:1:1:/Music/a.mp3\n")
|
|
|
|
|
(rockbox / "empty.log").write_text("")
|
|
|
|
|
|
|
|
|
|
found = submit_scrobbles.find_playback_logs(tmp_path)
|
|
|
|
|
|
|
|
|
|
assert [path.name for path in found] == [
|
|
|
|
|
"playback.log", "playback_0001.log", "playback_0002.log"
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_without_a_mirror_it_says_what_is_needed(tmp_path, capsys):
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
(device / ".rockbox").mkdir(parents=True)
|
|
|
|
|
(device / ".rockbox" / "playback.log").write_text("1700000000:1:1:/Music/a.mp3\n")
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.main([str(device)], transport=fake_transport([]))
|
|
|
|
|
|
|
|
|
|
assert "pass --mirror" in capsys.readouterr().err
|
2026-08-26 18:32:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
MIRROR = "/mnt/mirror"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stub_ffprobe(monkeypatch, **tags):
|
|
|
|
|
"""Answer for anything under the mirror without invoking ffprobe."""
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "_ffprobe", tags_of(**tags))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def only_mirror_files_exist(monkeypatch, resolvable=None):
|
|
|
|
|
"""Make the mirror's files appear to exist, and nothing else.
|
|
|
|
|
|
|
|
|
|
Patching is_file wholesale makes the device directory look like a log file,
|
|
|
|
|
which sends main() down the .scrobbler.log path instead.
|
|
|
|
|
"""
|
|
|
|
|
real = Path.is_file
|
|
|
|
|
|
|
|
|
|
def patched(self):
|
|
|
|
|
text = str(self)
|
|
|
|
|
if text.startswith(MIRROR):
|
|
|
|
|
return resolvable is None or text == resolvable
|
|
|
|
|
return real(self)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(Path, "is_file", patched)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def playback_device(tmp_path, log=PLAYBACK_LOG):
|
|
|
|
|
device = tmp_path / "IPOD"
|
|
|
|
|
(device / ".rockbox").mkdir(parents=True)
|
|
|
|
|
(device / ".rockbox" / "playback.log").write_text(log)
|
|
|
|
|
return device
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failed_submission_keeps_every_log(tmp_path, monkeypatch):
|
|
|
|
|
"""Nothing got through, so nothing may be set aside."""
|
|
|
|
|
only_mirror_files_exist(monkeypatch)
|
|
|
|
|
stub_ffprobe(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "load_session", lambda: "sk")
|
|
|
|
|
device = playback_device(tmp_path)
|
|
|
|
|
transport = fake_transport([{"error": 29, "message": "Rate limit"}])
|
|
|
|
|
|
|
|
|
|
code = submit_scrobbles.main(
|
|
|
|
|
[str(device), "--mirror", MIRROR, "--api-key", "k", "--api-secret", "s"],
|
|
|
|
|
transport=transport,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert code == 1
|
|
|
|
|
assert (device / ".rockbox" / "playback.log").is_file()
|
|
|
|
|
assert not list((device / ".rockbox").glob("*.submitted"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unmatched_play_is_written_back_not_lost(tmp_path, monkeypatch, capsys):
|
|
|
|
|
"""A track the mirror does not yet hold is still a play that happened. It
|
|
|
|
|
is kept so a later run, after the file has been copied, can submit it."""
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "load_session", lambda: "sk")
|
|
|
|
|
# Only the first track resolves; the rest are absent from the mirror.
|
|
|
|
|
only_mirror_files_exist(monkeypatch, f"{MIRROR}/Pendulum/Immersion/01.mp3")
|
|
|
|
|
stub_ffprobe(monkeypatch)
|
|
|
|
|
log = (
|
|
|
|
|
"1700000300:180000:245000:/Music/Pendulum/Immersion/01.mp3\n"
|
|
|
|
|
"1700000400:180000:245000:/Music/Missing/Album/09.mp3\n"
|
|
|
|
|
)
|
|
|
|
|
device = playback_device(tmp_path, log)
|
|
|
|
|
transport = fake_transport([{"scrobbles": {"@attr": {"accepted": 1, "ignored": 0}}}])
|
|
|
|
|
|
|
|
|
|
code = submit_scrobbles.main(
|
|
|
|
|
[str(device), "--mirror", MIRROR, "--api-key", "k", "--api-secret", "s"],
|
|
|
|
|
transport=transport,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert code == 0
|
|
|
|
|
rockbox = device / ".rockbox"
|
|
|
|
|
# The original is preserved untouched...
|
|
|
|
|
assert list(rockbox.glob("playback.log.*.submitted"))
|
|
|
|
|
# ...and the unmatched play is back in a live log for the next attempt.
|
|
|
|
|
written = (rockbox / "playback.log").read_text()
|
|
|
|
|
assert "Missing/Album/09.mp3" in written
|
|
|
|
|
assert "Pendulum/Immersion/01.mp3" not in written
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_original_is_renamed_rather_than_deleted(tmp_path, monkeypatch):
|
|
|
|
|
"""If Last.fm quietly dropped something, the evidence stays on the device."""
|
|
|
|
|
only_mirror_files_exist(monkeypatch)
|
|
|
|
|
stub_ffprobe(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "load_session", lambda: "sk")
|
|
|
|
|
device = playback_device(tmp_path)
|
|
|
|
|
transport = fake_transport([{"scrobbles": {"@attr": {"accepted": 1, "ignored": 0}}}])
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.main(
|
|
|
|
|
[str(device), "--mirror", MIRROR, "--api-key", "k", "--api-secret", "s"],
|
|
|
|
|
transport=transport,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
aside = list((device / ".rockbox").glob("playback.log.*.submitted"))
|
|
|
|
|
assert len(aside) == 1
|
|
|
|
|
assert PLAYBACK_LOG.splitlines()[0] in aside[0].read_text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_keep_leaves_everything_alone(tmp_path, monkeypatch):
|
|
|
|
|
only_mirror_files_exist(monkeypatch)
|
|
|
|
|
stub_ffprobe(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(submit_scrobbles, "load_session", lambda: "sk")
|
|
|
|
|
device = playback_device(tmp_path)
|
|
|
|
|
transport = fake_transport([{"scrobbles": {"@attr": {"accepted": 1, "ignored": 0}}}])
|
|
|
|
|
|
|
|
|
|
submit_scrobbles.main(
|
|
|
|
|
[str(device), "--mirror", MIRROR, "--keep",
|
|
|
|
|
"--api-key", "k", "--api-secret", "s"],
|
|
|
|
|
transport=transport,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert (device / ".rockbox" / "playback.log").read_text() == PLAYBACK_LOG
|
|
|
|
|
assert not list((device / ".rockbox").glob("*.submitted"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_file_ffprobe_cannot_read_does_not_abandon_the_rest(monkeypatch):
|
|
|
|
|
"""CalledProcessError is not an OSError, so one bad file used to take the
|
|
|
|
|
whole submission with it."""
|
|
|
|
|
def broken(path):
|
|
|
|
|
raise subprocess.CalledProcessError(1, "ffprobe")
|
|
|
|
|
|
|
|
|
|
assert submit_scrobbles.read_tags(Path("/mnt/mirror/x.mp3"), runner=broken) == {}
|