fix: never discard a play that has not been submitted
Build and publish container / build (pull_request) Successful in 5m11s

Setting the logs aside after a successful submission threw away more than it
had submitted. A play of a track missing from the mirror -- not yet copied, or
its tags unreadable -- was counted as unresolved and then carried off with the
rest, with nothing to retry it. The play happened and was lost.

Two obligations now, kept separate. The original log is renamed rather than
deleted, so a mistake here cannot destroy the record. And every play that was
not submitted is written back into a live log, so the next run attempts it
again: the unmatched ones, and anything in a batch that failed.

Submission is recorded batch by batch as each is accepted, so a failure partway
through knows exactly what got through. The remainder is written back and
nothing is sent twice. When nothing at all is accepted the logs are left
untouched.

Skips and clockless entries are deliberately not retained. Neither can ever be
submitted, so keeping them would mean reprocessing them for ever, and the
untouched original holds them regardless.

Also fixes a way to lose the lot: read_tags caught OSError and ValueError, but
ffprobe failing raises CalledProcessError, which is neither. A single unreadable
file aborted the whole submission rather than costing one unidentified play.
This commit is contained in:
Emma Thorpe
2026-08-26 18:32:05 +01:00
parent f324b1b720
commit 374a17474f
3 changed files with 273 additions and 39 deletions
+136 -10
View File
@@ -1,4 +1,5 @@
import json
import subprocess
import sys
from pathlib import Path
@@ -238,12 +239,12 @@ def test_a_short_play_is_a_skip_not_a_scrobble(tmp_path, monkeypatch):
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(
result = 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"]
assert result.skipped == 1
assert [entry["timestamp"] for entry in result.played] == ["1700000300"]
def test_a_zero_timestamp_is_refused(tmp_path, monkeypatch):
@@ -251,11 +252,11 @@ def test_a_zero_timestamp_is_refused(tmp_path, monkeypatch):
those would mean inventing when they happened."""
monkeypatch.setattr(Path, "is_file", lambda self: True)
_, _, _, timeless = submit_scrobbles.plays_from_playback_log(
result = submit_scrobbles.plays_from_playback_log(
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
)
assert timeless == 1
assert result.timeless == 1
def test_tags_come_from_the_mirror(tmp_path, monkeypatch):
@@ -263,9 +264,9 @@ def test_tags_come_from_the_mirror(tmp_path, monkeypatch):
plugin exists. Off the mirror the tags are free."""
monkeypatch.setattr(Path, "is_file", lambda self: True)
played, _, _, _ = submit_scrobbles.plays_from_playback_log(
played = submit_scrobbles.plays_from_playback_log(
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
)
).played
assert played[0]["artist"] == "Pendulum"
assert played[0]["album"] == "Immersion"
@@ -276,14 +277,16 @@ def test_tags_come_from_the_mirror(tmp_path, monkeypatch):
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(
result = submit_scrobbles.plays_from_playback_log(
PLAYBACK_LOG, "/Music", "/mnt/mirror", runner=tags_of()
)
assert played == []
assert result.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
assert result.unresolved == 1
# And that one play is kept, so a later run can try it again.
assert len(result.retain) == 1
def test_playback_logs_are_found_including_rotations(tmp_path):
@@ -309,3 +312,126 @@ def test_without_a_mirror_it_says_what_is_needed(tmp_path, capsys):
submit_scrobbles.main([str(device)], transport=fake_transport([]))
assert "pass --mirror" in capsys.readouterr().err
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) == {}