2026-08-25 11:05:00 +01:00
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
played, skipped, unresolved, timeless = submit_scrobbles.plays_from_playback_log(
|
|
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert skipped == 1
|
|
|
|
|
assert [entry["timestamp"] for entry in played] == ["1700000300"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
_, _, _, timeless = submit_scrobbles.plays_from_playback_log(
|
|
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert timeless == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
played, _, _, _ = submit_scrobbles.plays_from_playback_log(
|
|
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
played, _, unresolved, _ = submit_scrobbles.plays_from_playback_log(
|
|
|
|
|
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert played == []
|
|
|
|
|
# One: the skip and the clockless entry are filtered before the file is
|
|
|
|
|
# looked for, since neither would be submitted either way.
|
|
|
|
|
assert unresolved == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|