Build and publish container / build (pull_request) Successful in 5m6s
Scrobbling previously needed the on-device Last.fm plugin run by hand before each sync, to turn Rockbox's playback log into AUDIOSCROBBLER format. Forgetting that step means the sync submits nothing and quietly appears not to work. Core Rockbox writes ROCKBOX_DIR/playback.log whenever "play log" is enabled, with no plugin running at all. Each line is timestamp:elapsed_ms:length_ms:path. The only thing missing is tags, and that is exactly why the plugin exists: reading them back off the player is slow. Off the mirror it is free, because the same files are already there -- so the conversion belongs on the laptop, and the plugin can be skipped entirely. A play counts as listened at half the track's length, matching the plugin's savepct default, so the two cannot disagree about what a play was. A short play is a skip. An entry with no usable timestamp is refused rather than invented, which is the clockless case again. A path that maps to nothing in the mirror is counted and reported instead of guessed at. Rotated logs are picked up too; Rockbox starts a new one past half a megabyte. All of them are renamed aside together once Last.fm has accepted the batch. A .scrobbler.log is still read when the plugin has been run and left one.
312 lines
11 KiB
Python
312 lines
11 KiB
Python
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
|
|
|
|
|
|
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
|